diff --git a/src/main/java/de/rub/nds/scanner/core/config/ExecutorConfig.java b/src/main/java/de/rub/nds/scanner/core/config/ExecutorConfig.java index e3ae6643..2110c383 100644 --- a/src/main/java/de/rub/nds/scanner/core/config/ExecutorConfig.java +++ b/src/main/java/de/rub/nds/scanner/core/config/ExecutorConfig.java @@ -9,14 +9,22 @@ package de.rub.nds.scanner.core.config; import com.beust.jcommander.Parameter; +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; import de.rub.nds.scanner.core.probe.ProbeType; import de.rub.nds.scanner.core.probe.ProbeTypeConverter; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Path; import java.util.Arrays; import java.util.LinkedList; import java.util.List; public final class ExecutorConfig { + private static final ObjectMapper SETTINGS_MAPPER = new ObjectMapper(); + @Parameter(names = "-noColor", description = "If you use Windows or don't want colored text.") private boolean noColor = false; @@ -60,14 +68,38 @@ public final class ExecutorConfig { converter = ProbeTypeConverter.class) private List excludedProbes = new LinkedList<>(); + @Parameter( + names = "-profile", + description = + "Path to a scan profile JSON file. Only probes declared by this profile (and" + + " any profiles it inherits from via 'inheritedFromProfiles') will be" + + " executed. Entries in 'inheritedFromProfiles' are paths to other" + + " profile JSON files, resolved relative to this profile's own" + + " directory. Probes excluded via -exclude are removed regardless of" + + " where they came from.") + private String profile = null; + + @Parameter( + names = "-listProbes", + description = + "Print every available probe, grouped by ProbeType class, in the same JSON" + + " syntax used by a scan profile's 'probes' field, then exit without" + + " scanning.") + private boolean listProbes = false; + private List probes = null; + private ProbeTypeSelector profileProbeSelector = null; + private boolean profileProbeSelectorResolved = false; + private boolean settingsResolved = false; + public ExecutorConfig() { // Default constructor } /** - * Returns a copy of the list of probe types that are excluded from scanning. + * Returns a copy of the list of probe types that are excluded from scanning, regardless of + * whether they were selected via {@link #setProbes(List)} or via a scan profile. * * @return a new list containing the excluded probe types */ @@ -84,12 +116,83 @@ public void setExcludedProbes(List excludedProbes) { this.excludedProbes = new LinkedList<>(excludedProbes); } + /** + * Returns the path to the scan profile JSON file, if one was configured. + * + * @return the scan profile path, or null if not set + */ + public String getProfile() { + return profile; + } + + /** + * Sets the path to the scan profile JSON file to use for this scan. Takes effect the next time + * {@link #isProbeIncluded(ProbeType, boolean)} or one of the setting getters (e.g. {@link + * #getScanDetail()}) is called. + * + * @param profile the scan profile path, or null to clear + */ + public void setProfile(String profile) { + this.profile = profile; + this.profileProbeSelectorResolved = false; + this.settingsResolved = false; + } + + /** + * Returns whether {@code -listProbes} was requested, i.e. whether every available probe should + * be printed instead of running a scan. + * + * @return true if the available probes should be listed and no scan performed + */ + public boolean isListProbes() { + return listProbes; + } + + /** + * Sets whether every available probe should be printed instead of running a scan. + * + * @param listProbes true to list probes instead of scanning + */ + public void setListProbes(boolean listProbes) { + this.listProbes = listProbes; + } + + /** + * Applies the settings declared directly by the active scan profile (not including any + * inherited profiles) on top of the current values, the first time this is called after {@link + * #setProfile(String)}. Fields the profile does not declare are left untouched. This works by + * deserializing the profile's {@code settings} JSON object directly onto this instance, so + * adding a new overridable setting only requires adding the corresponding {@code @Parameter} + * field and its getter/setter above — no separate mapping to maintain. + */ + private void resolveSettingsFromProfileIfNecessary() { + if (settingsResolved || profile == null) { + return; + } + JsonNode settings; + try { + settings = ScanProfileIO.read(Path.of(profile)).getSettings(); + } catch (IOException e) { + throw new UncheckedIOException("Could not load scan profile '" + profile + "'", e); + } + if (settings != null) { + try { + SETTINGS_MAPPER.readerForUpdating(this).readValue(settings); + } catch (IOException e) { + throw new UncheckedIOException( + "Could not apply settings from scan profile '" + profile + "'", e); + } + } + settingsResolved = true; + } + /** * Returns the scanner detail level for the scan operation. * * @return the current scanner detail level */ public ScannerDetail getScanDetail() { + resolveSettingsFromProfileIfNecessary(); return scanDetail; } @@ -108,6 +211,7 @@ public void setScanDetail(ScannerDetail scanDetail) { * @return the current post-analysis detail level */ public ScannerDetail getPostAnalysisDetail() { + resolveSettingsFromProfileIfNecessary(); return postAnalysisDetail; } @@ -126,6 +230,7 @@ public void setPostAnalysisDetail(ScannerDetail postAnalysisDetail) { * @return the current report detail level */ public ScannerDetail getReportDetail() { + resolveSettingsFromProfileIfNecessary(); return reportDetail; } @@ -144,6 +249,7 @@ public void setReportDetail(ScannerDetail reportDetail) { * @return true if colored text is disabled, false otherwise */ public boolean isNoColor() { + resolveSettingsFromProfileIfNecessary(); return noColor; } @@ -157,7 +263,10 @@ public void setNoColor(boolean noColor) { } /** - * Returns a copy of the list of probe types to be executed. + * Returns a copy of the list of probe types to be executed, as set via {@link + * #setProbes(List)}/{@link #addProbes(List)}. This is independent of any scan profile + * configured via {@link #setProfile(String)} — see {@link #isProbeIncluded(ProbeType, boolean)} + * for the combined effect of both mechanisms. * * @return a new list containing the probe types, or null if not set */ @@ -179,6 +288,7 @@ public void setProbes(List probes) { * * @param probes the probe types to execute */ + @JsonIgnore public void setProbes(ProbeType... probes) { this.probes = Arrays.asList(probes); } @@ -207,12 +317,60 @@ public void addProbes(ProbeType... probes) { this.probes.addAll(Arrays.asList(probes)); } + /** + * Determines whether a candidate probe should be executed, combining every selection mechanism + * this config supports: + * + *
    + *
  1. If {@link #setProbes(List)}/{@link #addProbes(List)} configured an explicit inclusion + * list, {@code probeType} must be contained in it. + *
  2. Otherwise, if a scan profile is configured via {@link #setProfile(String)}, {@code + * probeType} must be matched by it (resolved lazily, once, against the actual candidate + * probes passed here rather than by reflectively resolving class names up front). + *
  3. Otherwise, {@code executeByDefault} decides. + *
+ * + * In every case, a probe named via {@code -exclude} ({@link #getExcludedProbes()}) is always + * removed, regardless of how it was otherwise selected. + * + * @param probeType the candidate probe's type + * @param executeByDefault whether the probe should run when neither an explicit probe list nor + * a profile is configured + * @return true if the probe should be executed + */ + public boolean isProbeIncluded(ProbeType probeType, boolean executeByDefault) { + if (excludedProbes.contains(probeType)) { + return false; + } + if (probes != null) { + return probes.contains(probeType); + } + resolveProfileProbeSelectorIfNecessary(); + if (profileProbeSelector != null) { + return profileProbeSelector.matches(probeType); + } + return executeByDefault; + } + + private void resolveProfileProbeSelectorIfNecessary() { + if (profileProbeSelectorResolved || profile == null) { + return; + } + try { + profileProbeSelector = ScanProfileIO.resolveProbeSelector(Path.of(profile)); + } catch (IOException e) { + throw new UncheckedIOException("Could not load scan profile '" + profile + "'", e); + } + profileProbeSelectorResolved = true; + } + /** * Returns the timeout value for each probe execution in milliseconds. * * @return the probe timeout in milliseconds */ public int getProbeTimeout() { + resolveSettingsFromProfileIfNecessary(); return probeTimeout; } @@ -231,6 +389,7 @@ public void setProbeTimeout(int probeTimeout) { * @return true if an output file is specified, false otherwise */ public boolean isWriteReportToFile() { + resolveSettingsFromProfileIfNecessary(); return outputFile != null; } @@ -240,6 +399,7 @@ public boolean isWriteReportToFile() { * @return the output file path, or null if not specified */ public String getOutputFile() { + resolveSettingsFromProfileIfNecessary(); return outputFile; } @@ -258,6 +418,7 @@ public void setOutputFile(String outputFile) { * @return the number of parallel probe threads */ public int getParallelProbes() { + resolveSettingsFromProfileIfNecessary(); return parallelProbes; } @@ -276,6 +437,7 @@ public void setParallelProbes(int parallelProbes) { * @return the maximum number of overall threads */ public int getOverallThreads() { + resolveSettingsFromProfileIfNecessary(); return overallThreads; } @@ -294,6 +456,6 @@ public void setOverallThreads(int overallThreads) { * @return true if either parallel probes or overall threads is greater than 1 */ public boolean isMultithreaded() { - return parallelProbes > 1 || overallThreads > 1; + return getParallelProbes() > 1 || getOverallThreads() > 1; } } diff --git a/src/main/java/de/rub/nds/scanner/core/config/ProbeTypeCatalog.java b/src/main/java/de/rub/nds/scanner/core/config/ProbeTypeCatalog.java new file mode 100644 index 00000000..2c477792 --- /dev/null +++ b/src/main/java/de/rub/nds/scanner/core/config/ProbeTypeCatalog.java @@ -0,0 +1,58 @@ +/* + * Scanner Core - A Modular Framework for Probe Definition, Execution, and Result Analysis. + * + * Copyright 2017-2023 Ruhr University Bochum, Paderborn University, Technology Innovation Institute, and Hackmanit GmbH + * + * Licensed under Apache License, Version 2.0 + * http://www.apache.org/licenses/LICENSE-2.0.txt + */ +package de.rub.nds.scanner.core.config; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import de.rub.nds.scanner.core.probe.ProbeType; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Renders the constants of one or more {@link ProbeType} enum classes as the same JSON syntax used + * by a {@link ScanProfile}'s {@code probes} field, so it can be copy-pasted straight into a profile + * (e.g. behind a {@code -listProbes} CLI flag implemented by a concrete scanner, which knows which + * {@link ProbeType} classes it registers). + */ +public final class ProbeTypeCatalog { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private ProbeTypeCatalog() { + // Utility class + } + + /** + * Renders every constant of the given {@link ProbeType} enum classes as pretty-printed JSON, in + * the exact shape expected by a scan profile's {@code probes} field — a map from each class's + * fully qualified name to the list of its constant names, in declaration order. + * + * @param probeTypeClasses the {@link ProbeType} enum classes to list, e.g. {@code + * List.of(TlsProbeType.class, QuicProbeType.class)} + * @return the pretty-printed JSON, ready to paste as (or into) a profile's {@code probes} field + */ + public static String toProfileProbesJson(List> probeTypeClasses) { + Map> probesByType = new LinkedHashMap<>(); + for (Class probeTypeClass : probeTypeClasses) { + List constantNames = new ArrayList<>(); + for (Object constant : probeTypeClass.getEnumConstants()) { + constantNames.add(((Enum) constant).name()); + } + probesByType.put(probeTypeClass.getName(), constantNames); + } + try { + return MAPPER.writerWithDefaultPrettyPrinter().writeValueAsString(probesByType); + } catch (JsonProcessingException e) { + // Unreachable: probesByType only ever contains plain strings and lists thereof. + throw new IllegalStateException("Could not render probe type catalog", e); + } + } +} diff --git a/src/main/java/de/rub/nds/scanner/core/config/ProbeTypeSelector.java b/src/main/java/de/rub/nds/scanner/core/config/ProbeTypeSelector.java new file mode 100644 index 00000000..bd2e324b --- /dev/null +++ b/src/main/java/de/rub/nds/scanner/core/config/ProbeTypeSelector.java @@ -0,0 +1,65 @@ +/* + * Scanner Core - A Modular Framework for Probe Definition, Execution, and Result Analysis. + * + * Copyright 2017-2023 Ruhr University Bochum, Paderborn University, Technology Innovation Institute, and Hackmanit GmbH + * + * Licensed under Apache License, Version 2.0 + * http://www.apache.org/licenses/LICENSE-2.0.txt + */ +package de.rub.nds.scanner.core.config; + +import de.rub.nds.scanner.core.probe.ProbeType; +import java.util.List; +import java.util.Map; + +/** + * Decides whether a candidate {@link ProbeType} is selected by a scan profile's {@code probes} + * declaration (merged across its inheritance chain), by comparing the profile's raw {@code + * "className": ["TOKEN", ...]} entries directly against the candidate's actual declaring class and + * constant name. + * + *

This never resolves a class or constant by name via reflection: a candidate is only ever + * matched against classes and constants that some already-instantiated {@link ProbeType} actually + * belongs to, so a typo or a class that no longer exists in a profile simply never matches instead + * of failing to load. + */ +final class ProbeTypeSelector { + + private final Map> tokensByClassName; + + ProbeTypeSelector(Map> tokensByClassName) { + this.tokensByClassName = tokensByClassName; + } + + /** + * Determines whether {@code probeType} is selected, by replaying the tokens declared for its + * declaring class in order: {@code "*"} selects it, a matching constant name selects it, and a + * matching {@code "!CONSTANT_NAME"} deselects it again — whichever applies last wins. + * + * @param probeType the candidate probe type + * @return true if the tokens declared for this probe's class select it + */ + boolean matches(ProbeType probeType) { + if (!(probeType instanceof Enum)) { + return false; + } + Enum constant = (Enum) probeType; + List tokens = tokensByClassName.get(constant.getDeclaringClass().getName()); + if (tokens == null) { + return false; + } + boolean selected = false; + for (String token : tokens) { + if ("*".equals(token)) { + selected = true; + } else if (token.startsWith("!")) { + if (token.substring(1).equals(constant.name())) { + selected = false; + } + } else if (token.equals(constant.name())) { + selected = true; + } + } + return selected; + } +} diff --git a/src/main/java/de/rub/nds/scanner/core/config/ScanProfile.java b/src/main/java/de/rub/nds/scanner/core/config/ScanProfile.java new file mode 100644 index 00000000..98b3676b --- /dev/null +++ b/src/main/java/de/rub/nds/scanner/core/config/ScanProfile.java @@ -0,0 +1,108 @@ +/* + * Scanner Core - A Modular Framework for Probe Definition, Execution, and Result Analysis. + * + * Copyright 2017-2023 Ruhr University Bochum, Paderborn University, Technology Innovation Institute, and Hackmanit GmbH + * + * Licensed under Apache License, Version 2.0 + * http://www.apache.org/licenses/LICENSE-2.0.txt + */ +package de.rub.nds.scanner.core.config; + +import com.fasterxml.jackson.databind.JsonNode; +import de.rub.nds.scanner.core.probe.ProbeType; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * A JSON-defined scan profile. A profile declares the set of {@link ProbeType}s that should be + * executed during a scan, and may additionally inherit probes from other profiles, referenced by + * file path, via {@link #getInheritedFromProfiles()}. + */ +public final class ScanProfile { + + private List inheritedFromProfiles = new ArrayList<>(); + + private Map> probes = new LinkedHashMap<>(); + + private JsonNode settings; + + public ScanProfile() { + // Default constructor for Jackson + } + + /** + * Returns the paths of the profiles this profile inherits probes from, each resolved relative + * to the directory of the file this profile itself was loaded from (an absolute path is used + * as-is). + * + * @return the list of inherited profile paths + */ + public List getInheritedFromProfiles() { + return inheritedFromProfiles; + } + + /** + * Sets the paths of the profiles this profile inherits probes from. + * + * @param inheritedFromProfiles the list of inherited profile paths + */ + public void setInheritedFromProfiles(List inheritedFromProfiles) { + this.inheritedFromProfiles = + inheritedFromProfiles == null ? new ArrayList<>() : inheritedFromProfiles; + } + + /** + * Returns the raw probes declared directly by this profile (not including inherited ones), as a + * map from the fully qualified name of an enum class implementing {@link ProbeType} to the list + * of its constant names to run — e.g. {@code {"de.rub.nds.tlsscanner.core.constants + * .TlsProbeType": ["CIPHER_SUITE", "CERTIFICATE"]}}. This groups probes by type instead of + * repeating the type for every single probe, while still letting a profile freely combine + * probes from different {@link ProbeType} implementations. + * + *

Each per-type list also accepts two special tokens, processed in order: {@code "*"} + * selects every probe actually registered for that type, and {@code "!CONSTANT_NAME"} deselects + * one again (by name, or previously selected via {@code "*"}). This lets an "everything" + * profile be written as {@code {"...TlsProbeType": ["*"]}} without enumerating every constant, + * and still exclude a few via {@code {"...TlsProbeType": ["*", "!TLS_LATENCY"]}}. These tokens + * are matched against the actual probes a scan registers (see {@link + * ScanProfileIO#resolveProbeSelector}), not resolved reflectively from the class name up front. + * + * @return the raw probes declared by this profile + */ + public Map> getProbes() { + return probes; + } + + /** + * Sets the raw probes declared directly by this profile. + * + * @param probes the probes to declare, grouped by {@link ProbeType} class name + */ + public void setProbes(Map> probes) { + this.probes = probes == null ? new LinkedHashMap<>() : probes; + } + + /** + * Returns the raw {@code settings} JSON object declared directly by this profile, or null if + * the profile declares none. Unlike {@link #getProbes()}, these are never inherited from {@link + * #getInheritedFromProfiles()}. {@link ExecutorConfig} applies this by deserializing it + * directly onto itself, so its shape always matches whatever {@code @Parameter} fields {@link + * ExecutorConfig} currently declares. + * + * @return the raw settings object, or null + */ + public JsonNode getSettings() { + return settings; + } + + /** + * Sets the raw {@code settings} JSON object declared directly by this profile. + * + * @param settings the raw settings object, or null + */ + public void setSettings(JsonNode settings) { + this.settings = settings; + } +} diff --git a/src/main/java/de/rub/nds/scanner/core/config/ScanProfileIO.java b/src/main/java/de/rub/nds/scanner/core/config/ScanProfileIO.java new file mode 100644 index 00000000..26a1cd36 --- /dev/null +++ b/src/main/java/de/rub/nds/scanner/core/config/ScanProfileIO.java @@ -0,0 +1,100 @@ +/* + * Scanner Core - A Modular Framework for Probe Definition, Execution, and Result Analysis. + * + * Copyright 2017-2023 Ruhr University Bochum, Paderborn University, Technology Innovation Institute, and Hackmanit GmbH + * + * Licensed under Apache License, Version 2.0 + * http://www.apache.org/licenses/LICENSE-2.0.txt + */ +package de.rub.nds.scanner.core.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import de.rub.nds.scanner.core.probe.ProbeType; +import java.io.IOException; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Loads {@link ScanProfile}s from JSON files and builds a {@link ProbeTypeSelector} for a profile's + * fully inherited {@code probes} declaration. + */ +public final class ScanProfileIO { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private ScanProfileIO() { + // Utility class + } + + /** + * Reads a single {@link ScanProfile} from a JSON file. + * + * @param profilePath the path to the profile's JSON file + * @return the parsed profile + * @throws IOException if the file cannot be read or parsed + */ + public static ScanProfile read(Path profilePath) throws IOException { + return MAPPER.readValue(profilePath.toFile(), ScanProfile.class); + } + + /** + * Builds a {@link ProbeTypeSelector} for the profile stored at {@code profilePath}, merging its + * {@code probes} declaration with that of every (transitively) inherited profile. Each entry of + * {@link ScanProfile#getInheritedFromProfiles()} is a path to another profile's JSON file, + * resolved relative to the directory of the profile declaring it (an absolute entry is used + * as-is). + * + *

For a given {@link ProbeType} class, the token lists declared for it by every profile in + * the inheritance chain are concatenated in inheritance order (parents first, most-derived + * profile last) before being handed to {@link ProbeTypeSelector}, so a profile can use {@code + * "!CONSTANT_NAME"} to exclude a probe that an inherited profile selected via {@code "*"} or by + * name. + * + * @param profilePath the path to the active profile's JSON file + * @return a selector matching every probe selected by the profile and its inherited profiles + * @throws IOException if a profile file cannot be read or parsed + */ + public static ProbeTypeSelector resolveProbeSelector(Path profilePath) throws IOException { + Map> mergedTokens = + resolveProbeTokens(profilePath.toAbsolutePath().normalize(), new LinkedHashSet<>()); + return new ProbeTypeSelector(mergedTokens); + } + + private static Map> resolveProbeTokens( + Path profilePath, Set visiting) throws IOException { + if (!visiting.add(profilePath)) { + throw new IllegalStateException( + "Cyclic scan profile inheritance detected involving profile '" + + profilePath + + "'"); + } + ScanProfile profile; + try { + profile = read(profilePath); + } catch (IOException e) { + throw new IOException("Could not parse scan profile file '" + profilePath + "'", e); + } + Path directory = profilePath.getParent(); + Map> merged = new LinkedHashMap<>(); + for (String inheritedPath : profile.getInheritedFromProfiles()) { + Path parentPath = directory.resolve(inheritedPath).normalize(); + mergeInto(merged, resolveProbeTokens(parentPath, visiting)); + } + mergeInto(merged, profile.getProbes()); + visiting.remove(profilePath); + return merged; + } + + private static void mergeInto( + Map> target, Map> source) { + for (Map.Entry> entry : source.entrySet()) { + target.computeIfAbsent(entry.getKey(), key -> new ArrayList<>()) + .addAll(entry.getValue()); + } + } +} diff --git a/src/main/java/de/rub/nds/scanner/core/execution/Scanner.java b/src/main/java/de/rub/nds/scanner/core/execution/Scanner.java index 4ff80ff0..604c0209 100644 --- a/src/main/java/de/rub/nds/scanner/core/execution/Scanner.java +++ b/src/main/java/de/rub/nds/scanner/core/execution/Scanner.java @@ -300,14 +300,8 @@ protected void registerProbeForExecution(ProbeT probe) { * @param executeByDefault Whether the probe should be executed by default. */ protected void registerProbeForExecution(ProbeT probe, boolean executeByDefault) { - if ((executorConfig.getProbes() == null && executeByDefault) - || (executorConfig.getProbes() != null - && executorConfig.getProbes().contains(probe.getType()))) { - if (executorConfig.getExcludedProbes().contains(probe.getType())) { - LOGGER.debug("Probe {} is excluded from the scan", probe.getType()); - } else { - probeList.add(probe); - } + if (executorConfig.isProbeIncluded(probe.getType(), executeByDefault)) { + probeList.add(probe); } } diff --git a/src/test/java/de/rub/nds/scanner/core/config/ExecutorConfigTest.java b/src/test/java/de/rub/nds/scanner/core/config/ExecutorConfigTest.java index 81d8f416..e671dbaf 100644 --- a/src/test/java/de/rub/nds/scanner/core/config/ExecutorConfigTest.java +++ b/src/test/java/de/rub/nds/scanner/core/config/ExecutorConfigTest.java @@ -11,16 +11,23 @@ import static org.junit.jupiter.api.Assertions.*; import de.rub.nds.scanner.core.probe.ProbeType; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.Arrays; import java.util.LinkedList; import java.util.List; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; public class ExecutorConfigTest { private ExecutorConfig config; + @TempDir private Path tempDir; + @BeforeEach public void setUp() { config = new ExecutorConfig(); @@ -122,6 +129,88 @@ public void testIsMultithreadedWithBoth() { assertTrue(config.isMultithreaded()); } + @Test + public void testProfileGetterSetter() { + assertNull(config.getProfile()); + config.setProfile("/tmp/some-profile.json"); + assertEquals("/tmp/some-profile.json", config.getProfile()); + config.setProfile(null); + assertNull(config.getProfile()); + } + + @Test + public void testIsProbeIncludedResolvesProfile() throws IOException { + Path profilePath = tempDir.resolve("myProfile.json"); + Files.writeString( + profilePath, + "{\"probes\": {\"" + + de.rub.nds.scanner.core.TestProbeType.class.getName() + + "\": [\"TEST_PROBE_TYPE\"]}}"); + + config.setProfile(profilePath.toString()); + + assertTrue( + config.isProbeIncluded( + de.rub.nds.scanner.core.TestProbeType.TEST_PROBE_TYPE, false)); + assertFalse(config.isProbeIncluded(SecondTestProbeType.SECOND_TEST_PROBE_TYPE, true)); + } + + @Test + public void testIsProbeIncludedResolvesProfileOnlyOnce() throws IOException { + Path profilePath = tempDir.resolve("myProfile.json"); + Files.writeString( + profilePath, + "{\"probes\": {\"" + + de.rub.nds.scanner.core.TestProbeType.class.getName() + + "\": [\"TEST_PROBE_TYPE\"]}}"); + config.setProfile(profilePath.toString()); + config.isProbeIncluded(de.rub.nds.scanner.core.TestProbeType.TEST_PROBE_TYPE, false); + + Files.delete(profilePath); + + // Should not attempt to re-read the (now deleted) file + assertTrue( + config.isProbeIncluded( + de.rub.nds.scanner.core.TestProbeType.TEST_PROBE_TYPE, false)); + } + + @Test + public void testSetProbesOverridesConfiguredProfile() throws IOException { + Path profilePath = tempDir.resolve("myProfile.json"); + Files.writeString( + profilePath, + "{\"probes\": {\"" + + de.rub.nds.scanner.core.TestProbeType.class.getName() + + "\": [\"TEST_PROBE_TYPE\"]}}"); + config.setProfile(profilePath.toString()); + + config.setProbes(List.of()); + + assertFalse( + config.isProbeIncluded( + de.rub.nds.scanner.core.TestProbeType.TEST_PROBE_TYPE, false)); + } + + @Test + public void testIsProbeIncludedThrowsUncheckedIOExceptionOnMissingProfile() { + config.setProfile(tempDir.resolve("doesNotExist.json").toString()); + assertThrows( + UncheckedIOException.class, + () -> + config.isProbeIncluded( + de.rub.nds.scanner.core.TestProbeType.TEST_PROBE_TYPE, false)); + } + + @Test + public void testIsProbeIncludedDefaultsWhenNoProbesOrProfileConfigured() { + assertTrue( + config.isProbeIncluded( + de.rub.nds.scanner.core.TestProbeType.TEST_PROBE_TYPE, true)); + assertFalse( + config.isProbeIncluded( + de.rub.nds.scanner.core.TestProbeType.TEST_PROBE_TYPE, false)); + } + @Test public void testExcludedProbesGetterSetter() { assertTrue(config.getExcludedProbes().isEmpty()); @@ -138,6 +227,125 @@ public void testExcludedProbesGetterSetter() { assertEquals(2, config.getExcludedProbes().size()); } + @Test + public void testExcludedProbesOverrideExecuteByDefault() { + ProbeType excluded = new TestProbeType("excluded"); + config.setExcludedProbes(List.of(excluded)); + + assertFalse(config.isProbeIncluded(excluded, true)); + } + + @Test + public void testExcludedProbesOverrideExplicitProbeList() { + ProbeType excluded = new TestProbeType("excluded"); + ProbeType included = new TestProbeType("included"); + config.setProbes(excluded, included); + config.setExcludedProbes(List.of(excluded)); + + assertFalse(config.isProbeIncluded(excluded, true)); + assertTrue(config.isProbeIncluded(included, true)); + } + + @Test + public void testExcludedProbesOverrideProfile() throws IOException { + Path profilePath = tempDir.resolve("myProfile.json"); + Files.writeString( + profilePath, + "{\"probes\": {\"" + + de.rub.nds.scanner.core.TestProbeType.class.getName() + + "\": [\"TEST_PROBE_TYPE\"]}}"); + config.setProfile(profilePath.toString()); + config.setExcludedProbes(List.of(de.rub.nds.scanner.core.TestProbeType.TEST_PROBE_TYPE)); + + assertFalse( + config.isProbeIncluded( + de.rub.nds.scanner.core.TestProbeType.TEST_PROBE_TYPE, false)); + } + + @Test + public void testProfileSettingsOverrideDefaults() throws IOException { + Path profilePath = tempDir.resolve("myProfile.json"); + Files.writeString( + profilePath, + "{\"settings\": {" + + "\"noColor\": true," + + "\"scanDetail\": \"ALL\"," + + "\"postAnalysisDetail\": \"DETAILED\"," + + "\"reportDetail\": \"QUICK\"," + + "\"outputFile\": \"out.json\"," + + "\"probeTimeout\": 42," + + "\"parallelProbes\": 3," + + "\"overallThreads\": 5" + + "}}"); + + config.setProfile(profilePath.toString()); + + assertTrue(config.isNoColor()); + assertEquals(ScannerDetail.ALL, config.getScanDetail()); + assertEquals(ScannerDetail.DETAILED, config.getPostAnalysisDetail()); + assertEquals(ScannerDetail.QUICK, config.getReportDetail()); + assertEquals("out.json", config.getOutputFile()); + assertTrue(config.isWriteReportToFile()); + assertEquals(42, config.getProbeTimeout()); + assertEquals(3, config.getParallelProbes()); + assertEquals(5, config.getOverallThreads()); + assertTrue(config.isMultithreaded()); + } + + @Test + public void testProfileWithoutSettingsKeepsDefaults() throws IOException { + Path profilePath = tempDir.resolve("myProfile.json"); + Files.writeString(profilePath, "{}"); + + config.setProfile(profilePath.toString()); + + assertFalse(config.isNoColor()); + assertEquals(ScannerDetail.NORMAL, config.getScanDetail()); + assertEquals(ScannerDetail.NORMAL, config.getPostAnalysisDetail()); + assertEquals(ScannerDetail.NORMAL, config.getReportDetail()); + assertNull(config.getOutputFile()); + assertEquals(1800000, config.getProbeTimeout()); + assertEquals(1, config.getParallelProbes()); + assertEquals(1, config.getOverallThreads()); + } + + @Test + public void testProfileWithPartialSettingsOnlyOverridesDeclaredFields() throws IOException { + Path profilePath = tempDir.resolve("myProfile.json"); + Files.writeString(profilePath, "{\"settings\": {\"scanDetail\": \"ALL\"}}"); + + config.setProfile(profilePath.toString()); + + assertEquals(ScannerDetail.ALL, config.getScanDetail()); + assertEquals(ScannerDetail.NORMAL, config.getReportDetail()); + assertEquals(1, config.getParallelProbes()); + } + + @Test + public void testProfileSettingsAreNotInheritedFromParentProfiles() throws IOException { + Files.writeString( + tempDir.resolve("base.json"), "{\"settings\": {\"scanDetail\": \"ALL\"}}"); + Files.writeString( + tempDir.resolve("child.json"), "{\"inheritedFromProfiles\": [\"base.json\"]}"); + + config.setProfile(tempDir.resolve("child.json").toString()); + + assertEquals(ScannerDetail.NORMAL, config.getScanDetail()); + } + + @Test + public void testExplicitSetScanDetailIsNotOverwrittenBeforeProfileIsSet() throws IOException { + Path profilePath = tempDir.resolve("myProfile.json"); + Files.writeString(profilePath, "{\"settings\": {\"scanDetail\": \"ALL\"}}"); + + config.setProfile(profilePath.toString()); + // Accessing a setting once resolves and locks in the profile's settings. + config.getScanDetail(); + config.setScanDetail(ScannerDetail.QUICK); + + assertEquals(ScannerDetail.QUICK, config.getScanDetail()); + } + @Test public void testProbesGetterSetterWithList() { assertNull(config.getProbes()); diff --git a/src/test/java/de/rub/nds/scanner/core/config/MultiConstantTestProbeType.java b/src/test/java/de/rub/nds/scanner/core/config/MultiConstantTestProbeType.java new file mode 100644 index 00000000..920a55fc --- /dev/null +++ b/src/test/java/de/rub/nds/scanner/core/config/MultiConstantTestProbeType.java @@ -0,0 +1,26 @@ +/* + * Scanner Core - A Modular Framework for Probe Definition, Execution, and Result Analysis. + * + * Copyright 2017-2023 Ruhr University Bochum, Paderborn University, Technology Innovation Institute, and Hackmanit GmbH + * + * Licensed under Apache License, Version 2.0 + * http://www.apache.org/licenses/LICENSE-2.0.txt + */ +package de.rub.nds.scanner.core.config; + +import de.rub.nds.scanner.core.probe.ProbeType; + +/** + * A {@link ProbeType} implementation with several constants, used to test the {@code "*"} and + * {@code "!CONSTANT_NAME"} tokens supported by {@link ProbeTypeSelector}. + */ +public enum MultiConstantTestProbeType implements ProbeType { + FIRST, + SECOND, + THIRD; + + @Override + public String getName() { + return name(); + } +} diff --git a/src/test/java/de/rub/nds/scanner/core/config/ProbeTypeCatalogTest.java b/src/test/java/de/rub/nds/scanner/core/config/ProbeTypeCatalogTest.java new file mode 100644 index 00000000..d3cd915d --- /dev/null +++ b/src/test/java/de/rub/nds/scanner/core/config/ProbeTypeCatalogTest.java @@ -0,0 +1,55 @@ +/* + * Scanner Core - A Modular Framework for Probe Definition, Execution, and Result Analysis. + * + * Copyright 2017-2023 Ruhr University Bochum, Paderborn University, Technology Innovation Institute, and Hackmanit GmbH + * + * Licensed under Apache License, Version 2.0 + * http://www.apache.org/licenses/LICENSE-2.0.txt + */ +package de.rub.nds.scanner.core.config; + +import static org.junit.jupiter.api.Assertions.*; + +import com.fasterxml.jackson.databind.ObjectMapper; +import de.rub.nds.scanner.core.TestProbeType; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +public class ProbeTypeCatalogTest { + + @TempDir Path tempDir; + + @Test + public void testRendersOneClassPerEntryWithAllConstants() throws Exception { + String json = + ProbeTypeCatalog.toProfileProbesJson( + List.of(TestProbeType.class, MultiConstantTestProbeType.class)); + + @SuppressWarnings("unchecked") + Map> parsed = new ObjectMapper().readValue(json, Map.class); + + assertEquals(List.of("TEST_PROBE_TYPE"), parsed.get(TestProbeType.class.getName())); + assertEquals( + List.of("FIRST", "SECOND", "THIRD"), + parsed.get(MultiConstantTestProbeType.class.getName())); + } + + @Test + public void testOutputIsDirectlyUsableAsProfileProbesField() throws IOException { + String json = + ProbeTypeCatalog.toProfileProbesJson(List.of(MultiConstantTestProbeType.class)); + Path profilePath = tempDir.resolve("generated.json"); + Files.writeString(profilePath, "{\"probes\": " + json + "}"); + + ProbeTypeSelector selector = ScanProfileIO.resolveProbeSelector(profilePath); + + assertTrue(selector.matches(MultiConstantTestProbeType.FIRST)); + assertTrue(selector.matches(MultiConstantTestProbeType.SECOND)); + assertTrue(selector.matches(MultiConstantTestProbeType.THIRD)); + } +} diff --git a/src/test/java/de/rub/nds/scanner/core/config/ScanProfileIOTest.java b/src/test/java/de/rub/nds/scanner/core/config/ScanProfileIOTest.java new file mode 100644 index 00000000..b6a20243 --- /dev/null +++ b/src/test/java/de/rub/nds/scanner/core/config/ScanProfileIOTest.java @@ -0,0 +1,269 @@ +/* + * Scanner Core - A Modular Framework for Probe Definition, Execution, and Result Analysis. + * + * Copyright 2017-2023 Ruhr University Bochum, Paderborn University, Technology Innovation Institute, and Hackmanit GmbH + * + * Licensed under Apache License, Version 2.0 + * http://www.apache.org/licenses/LICENSE-2.0.txt + */ +package de.rub.nds.scanner.core.config; + +import static org.junit.jupiter.api.Assertions.*; + +import de.rub.nds.scanner.core.TestProbeType; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +public class ScanProfileIOTest { + + @TempDir Path tempDir; + + private void writeProfile(String fileName, String content) throws IOException { + Path path = tempDir.resolve(fileName); + Files.createDirectories(path.getParent()); + Files.writeString(path, content); + } + + @Test + public void testResolveSingleProfileWithoutInheritance() throws IOException { + writeProfile( + "solo.json", + "{" + + "\"inheritedFromProfiles\": []," + + "\"probes\": {\"" + + TestProbeType.class.getName() + + "\": [\"TEST_PROBE_TYPE\"]}" + + "}"); + + ProbeTypeSelector selector = + ScanProfileIO.resolveProbeSelector(tempDir.resolve("solo.json")); + + assertTrue(selector.matches(TestProbeType.TEST_PROBE_TYPE)); + } + + @Test + public void testResolveProfileWithInheritanceCombinesDifferentProbeTypeImplementations() + throws IOException { + writeProfile( + "base.json", + "{" + + "\"probes\": {\"" + + TestProbeType.class.getName() + + "\": [\"TEST_PROBE_TYPE\"]}" + + "}"); + writeProfile( + "combined.json", + "{" + + "\"inheritedFromProfiles\": [\"base.json\"]," + + "\"probes\": {\"" + + SecondTestProbeType.class.getName() + + "\": [\"SECOND_TEST_PROBE_TYPE\"]}" + + "}"); + + ProbeTypeSelector selector = + ScanProfileIO.resolveProbeSelector(tempDir.resolve("combined.json")); + + assertTrue(selector.matches(TestProbeType.TEST_PROBE_TYPE)); + assertTrue(selector.matches(SecondTestProbeType.SECOND_TEST_PROBE_TYPE)); + } + + @Test + public void testProbesAreGroupedByTypeWithMultipleConstantsPerType() throws IOException { + writeProfile( + "grouped.json", + "{" + + "\"probes\": {\"" + + TestProbeType.class.getName() + + "\": [\"TEST_PROBE_TYPE\"], \"" + + SecondTestProbeType.class.getName() + + "\": [\"SECOND_TEST_PROBE_TYPE\"]}" + + "}"); + + ProbeTypeSelector selector = + ScanProfileIO.resolveProbeSelector(tempDir.resolve("grouped.json")); + + assertTrue(selector.matches(TestProbeType.TEST_PROBE_TYPE)); + assertTrue(selector.matches(SecondTestProbeType.SECOND_TEST_PROBE_TYPE)); + } + + @Test + public void testInheritedFromProfilesResolvesRelativeToDeclaringFilesDirectory() + throws IOException { + writeProfile( + "parents/base.json", + "{" + + "\"probes\": {\"" + + TestProbeType.class.getName() + + "\": [\"TEST_PROBE_TYPE\"]}" + + "}"); + writeProfile( + "child.json", + "{\"inheritedFromProfiles\": [\"parents/base.json\"]," + " \"probes\": {}}"); + + ProbeTypeSelector selector = + ScanProfileIO.resolveProbeSelector(tempDir.resolve("child.json")); + + assertTrue(selector.matches(TestProbeType.TEST_PROBE_TYPE)); + } + + @Test + public void testInheritedFromProfilesAcceptsAbsolutePath() throws IOException { + writeProfile( + "base.json", + "{" + + "\"probes\": {\"" + + TestProbeType.class.getName() + + "\": [\"TEST_PROBE_TYPE\"]}" + + "}"); + writeProfile( + "nested/child.json", + "{\"inheritedFromProfiles\": [\"" + + tempDir.resolve("base.json") + .toAbsolutePath() + .toString() + .replace("\\", "\\\\") + + "\"], \"probes\": {}}"); + + ProbeTypeSelector selector = + ScanProfileIO.resolveProbeSelector(tempDir.resolve("nested/child.json")); + + assertTrue(selector.matches(TestProbeType.TEST_PROBE_TYPE)); + } + + @Test + public void testCyclicInheritanceThrows() throws IOException { + writeProfile("a.json", "{\"inheritedFromProfiles\": [\"b.json\"], \"probes\": {}}"); + writeProfile("b.json", "{\"inheritedFromProfiles\": [\"a.json\"], \"probes\": {}}"); + + assertThrows( + IllegalStateException.class, + () -> ScanProfileIO.resolveProbeSelector(tempDir.resolve("a.json"))); + } + + @Test + public void testUnknownInheritedProfileThrows() throws IOException { + writeProfile( + "orphan.json", + "{\"inheritedFromProfiles\": [\"doesNotExist.json\"]," + " \"probes\": {}}"); + + assertThrows( + IOException.class, + () -> ScanProfileIO.resolveProbeSelector(tempDir.resolve("orphan.json"))); + } + + @Test + public void testWildcardMatchesEveryConstantOfThatType() throws IOException { + writeProfile( + "everything.json", + "{\"probes\": {\"" + MultiConstantTestProbeType.class.getName() + "\": [\"*\"]}}"); + + ProbeTypeSelector selector = + ScanProfileIO.resolveProbeSelector(tempDir.resolve("everything.json")); + + assertTrue(selector.matches(MultiConstantTestProbeType.FIRST)); + assertTrue(selector.matches(MultiConstantTestProbeType.SECOND)); + assertTrue(selector.matches(MultiConstantTestProbeType.THIRD)); + } + + @Test + public void testNegationExcludesConstantSelectedByWildcard() throws IOException { + writeProfile( + "mostly.json", + "{\"probes\": {\"" + + MultiConstantTestProbeType.class.getName() + + "\": [\"*\", \"!SECOND\"]}}"); + + ProbeTypeSelector selector = + ScanProfileIO.resolveProbeSelector(tempDir.resolve("mostly.json")); + + assertTrue(selector.matches(MultiConstantTestProbeType.FIRST)); + assertFalse(selector.matches(MultiConstantTestProbeType.SECOND)); + assertTrue(selector.matches(MultiConstantTestProbeType.THIRD)); + } + + @Test + public void testNegationExcludesExplicitlyListedConstant() throws IOException { + writeProfile( + "explicit.json", + "{\"probes\": {\"" + + MultiConstantTestProbeType.class.getName() + + "\": [\"FIRST\", \"SECOND\", \"!FIRST\"]}}"); + + ProbeTypeSelector selector = + ScanProfileIO.resolveProbeSelector(tempDir.resolve("explicit.json")); + + assertFalse(selector.matches(MultiConstantTestProbeType.FIRST)); + assertTrue(selector.matches(MultiConstantTestProbeType.SECOND)); + assertFalse(selector.matches(MultiConstantTestProbeType.THIRD)); + } + + @Test + public void testChildProfileCanExcludeConstantSelectedByParent() throws IOException { + writeProfile( + "base.json", + "{\"probes\": {\"" + MultiConstantTestProbeType.class.getName() + "\": [\"*\"]}}"); + writeProfile( + "child.json", + "{\"inheritedFromProfiles\": [\"base.json\"], \"probes\":" + + " {\"" + + MultiConstantTestProbeType.class.getName() + + "\": [\"!SECOND\"]}}"); + + ProbeTypeSelector selector = + ScanProfileIO.resolveProbeSelector(tempDir.resolve("child.json")); + + assertTrue(selector.matches(MultiConstantTestProbeType.FIRST)); + assertFalse(selector.matches(MultiConstantTestProbeType.SECOND)); + assertTrue(selector.matches(MultiConstantTestProbeType.THIRD)); + } + + @Test + public void testUnknownClassNameNeverMatchesInsteadOfThrowing() throws IOException { + writeProfile("badType.json", "{\"probes\": {\"does.not.Exist\": [\"FOO\"]}}"); + + ProbeTypeSelector selector = + ScanProfileIO.resolveProbeSelector(tempDir.resolve("badType.json")); + + assertFalse(selector.matches(TestProbeType.TEST_PROBE_TYPE)); + } + + @Test + public void testTypoedConstantNameNeverMatchesInsteadOfThrowing() throws IOException { + writeProfile( + "badConstant.json", + "{\"probes\": {\"" + TestProbeType.class.getName() + "\": [\"DOES_NOT_EXIST\"]}}"); + + ProbeTypeSelector selector = + ScanProfileIO.resolveProbeSelector(tempDir.resolve("badConstant.json")); + + assertFalse(selector.matches(TestProbeType.TEST_PROBE_TYPE)); + } + + @Test + public void testUnmentionedTypeNeverMatches() throws IOException { + writeProfile( + "onlyFirst.json", + "{\"probes\": {\"" + TestProbeType.class.getName() + "\": [\"*\"]}}"); + + ProbeTypeSelector selector = + ScanProfileIO.resolveProbeSelector(tempDir.resolve("onlyFirst.json")); + + assertFalse(selector.matches(SecondTestProbeType.SECOND_TEST_PROBE_TYPE)); + } + + @Test + public void testUnknownPropertyThrows() throws IOException { + writeProfile( + "withName.json", + "{\"name\": \"legacy\", \"probes\": {\"" + + TestProbeType.class.getName() + + "\": [\"TEST_PROBE_TYPE\"]}}"); + + assertThrows( + IOException.class, + () -> ScanProfileIO.resolveProbeSelector(tempDir.resolve("withName.json"))); + } +} diff --git a/src/test/java/de/rub/nds/scanner/core/config/SecondTestProbeType.java b/src/test/java/de/rub/nds/scanner/core/config/SecondTestProbeType.java new file mode 100644 index 00000000..2b94118d --- /dev/null +++ b/src/test/java/de/rub/nds/scanner/core/config/SecondTestProbeType.java @@ -0,0 +1,25 @@ +/* + * Scanner Core - A Modular Framework for Probe Definition, Execution, and Result Analysis. + * + * Copyright 2017-2023 Ruhr University Bochum, Paderborn University, Technology Innovation Institute, and Hackmanit GmbH + * + * Licensed under Apache License, Version 2.0 + * http://www.apache.org/licenses/LICENSE-2.0.txt + */ +package de.rub.nds.scanner.core.config; + +import de.rub.nds.scanner.core.probe.ProbeType; + +/** + * A second, distinct {@link ProbeType} implementation used to verify that a single scan profile can + * combine probes from different {@link ProbeType} implementations (e.g. probes from different + * scanner modules). + */ +public enum SecondTestProbeType implements ProbeType { + SECOND_TEST_PROBE_TYPE; + + @Override + public String getName() { + return name(); + } +}