OperatorOnlyAudit.java
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.tika.test;
import java.io.File;
import java.io.IOException;
import java.lang.annotation.Annotation;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Enumeration;
import java.util.List;
import java.util.Locale;
import java.util.TreeSet;
import java.util.jar.JarEntry;
import java.util.jar.JarFile;
import java.util.regex.Pattern;
import java.util.stream.Stream;
/**
* Finds config setters that look like they reach the host (a path, executable, class name,
* URL, credential, ...) but carry no {@code @OperatorOnly}. A module's test asserts the
* result is empty, so the next reflective or path-taking setter fails the build instead of
* shipping as a per-request hole.
* <p>
* Scope: every class on the test classpath under {@code org.apache.tika} whose simple name
* ends in {@code Config}, minus the package prefixes the caller excludes (surfaces that are
* never per-request). The name test is a heuristic by design: a reviewed false positive is
* annotated anyway, or excluded by the caller with a reason.
*/
public final class OperatorOnlyAudit {
/** Setter-name suffixes that usually mean host, not document. */
public static final Pattern SENSITIVE = Pattern.compile(
"(?i).*(path|paths|executable|exe|command|cmd|binary|class|classname|url|uri|endpoint"
+ "|host|hostname|apikey|secret|credential|credentials|token|password|passwd"
+ "|bucket|region|model|dir|directory|file|keystore|truststore)$");
private static final String ANNOTATION = "org.apache.tika.config.OperatorOnly";
private OperatorOnlyAudit() {
}
/**
* @param excludedPackagePrefixes packages whose config is never per-request
* @param allowed reviewed {@code Class#setter} names to skip
* @return {@code Class#setter} for every unannotated sensitive setter, sorted
*/
public static List<String> unannotatedSensitiveSetters(List<String> excludedPackagePrefixes,
List<String> allowed) throws IOException {
ClassLoader loader = OperatorOnlyAudit.class.getClassLoader();
TreeSet<String> violations = new TreeSet<>();
for (String className : configClassNames()) {
if (excludedPackagePrefixes.stream().anyMatch(className::startsWith)) {
continue;
}
Class<?> clazz;
Method[] methods;
try {
clazz = Class.forName(className, false, loader);
methods = clazz.getDeclaredMethods();
} catch (Throwable e) {
// optional dependency missing from this classpath; not auditable here
continue;
}
for (Method m : methods) {
if (!isSetter(m) || !SENSITIVE.matcher(m.getName().substring(3)).matches()) {
continue;
}
String id = clazz.getName() + "#" + m.getName();
if (allowed.contains(id) || isAnnotated(clazz, m)) {
continue;
}
violations.add(id);
}
}
return new ArrayList<>(violations);
}
private static boolean isSetter(Method m) {
return Modifier.isPublic(m.getModifiers()) && !Modifier.isStatic(m.getModifiers())
&& m.getName().startsWith("set") && m.getName().length() > 3
&& m.getParameterCount() == 1 && !m.isSynthetic();
}
/** The setter, an override above it, or the matching getter/is-er carries the annotation. */
private static boolean isAnnotated(Class<?> clazz, Method setter) {
String property = setter.getName().substring(3);
for (Class<?> c = clazz; c != null; c = c.getSuperclass()) {
for (Method m : c.getDeclaredMethods()) {
boolean sameSetter = m.getName().equals(setter.getName())
&& m.getParameterCount() == 1
&& m.getParameterTypes()[0].equals(setter.getParameterTypes()[0]);
boolean getter = m.getParameterCount() == 0
&& (m.getName().equals("get" + property) || m.getName().equals("is" + property));
if ((sameSetter || getter) && hasAnnotation(m)) {
return true;
}
}
}
return false;
}
private static boolean hasAnnotation(Method m) {
for (Annotation a : m.getAnnotations()) {
if (a.annotationType().getName().equals(ANNOTATION)) {
return true;
}
}
return false;
}
/** Every {@code org.apache.tika.**.*Config} class name on the test classpath. */
static List<String> configClassNames() throws IOException {
String classPath = System.getProperty("surefire.test.class.path",
System.getProperty("java.class.path"));
TreeSet<String> names = new TreeSet<>();
for (String entry : classPath.split(File.pathSeparator)) {
Path path = Paths.get(entry);
if (Files.isDirectory(path)) {
try (Stream<Path> walk = Files.walk(path)) {
walk.filter(Files::isRegularFile)
.map(p -> path.relativize(p).toString().replace(File.separatorChar, '/'))
.forEach(rel -> addIfConfig(names, rel));
}
} else if (Files.isRegularFile(path)
&& path.toString().toLowerCase(Locale.ROOT).endsWith(".jar")) {
try (JarFile jar = new JarFile(path.toFile())) {
Enumeration<JarEntry> entries = jar.entries();
while (entries.hasMoreElements()) {
addIfConfig(names, entries.nextElement().getName());
}
}
}
}
return Collections.unmodifiableList(new ArrayList<>(names));
}
private static void addIfConfig(TreeSet<String> names, String resource) {
if (!resource.startsWith("org/apache/tika/") || !resource.endsWith(".class")) {
return;
}
String name = resource.substring(0, resource.length() - ".class".length());
int slash = name.lastIndexOf('/');
int dollar = name.lastIndexOf('$');
String simple = name.substring(Math.max(slash, dollar) + 1);
if (simple.endsWith("Config")) {
names.add(name.replace('/', '.'));
}
}
}