From b0cedb5a2d7824107afa23004a4827868eb1e5a9 Mon Sep 17 00:00:00 2001 From: Isaac Elbaz Date: Sat, 5 Sep 2026 10:10:44 -0400 Subject: [PATCH] Reject invalid pattern configuration with actionable diagnostics --- Cargo.toml | 1 + README.md | 2 + docs/pattern-schema.md | 25 ++++++++++++ src/patterns.rs | 76 +++++++++++++++++++++++++++++------- tests/pattern_validation.rs | 78 +++++++++++++++++++++++++++++++++++++ 5 files changed, 169 insertions(+), 13 deletions(-) create mode 100644 docs/pattern-schema.md create mode 100644 tests/pattern_validation.rs diff --git a/Cargo.toml b/Cargo.toml index a4af229..a24c64c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,7 @@ include = [ "benches/**", "fixtures/**", "tests/**", + "docs/**", "patterns.toml", "Cargo.toml", "README.md", diff --git a/README.md b/README.md index 3768c28..2df9c45 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,8 @@ The output is a stream of JSONL objects, where each object represents a single f - **Algorithms**: Symbols and function calls associated with specific algorithms (e.g., "AES-GCM") within a library. You can customize this file to add support for new libraries or improve detection for existing ones. +See [pattern configuration](docs/pattern-schema.md) for accepted fields, language +names, and validation behavior. ## Development diff --git a/docs/pattern-schema.md b/docs/pattern-schema.md new file mode 100644 index 0000000..a55f692 --- /dev/null +++ b/docs/pattern-schema.md @@ -0,0 +1,25 @@ +# Pattern configuration + +The supported schema is `"1"`. The optional `[version]` table accepts `schema` +and `updated`; files without version metadata continue to use schema 1. +Unknown fields, unsupported schema versions, empty names, empty language lists, +duplicate library names, and unknown language names are errors. Regex diagnostics +identify the owning library, algorithm, and parameter where applicable. + +Recognized language names are `C`, `C++`, `Java`, `Python`, `Go`, `Swift`, `PHP`, +`ObjC`, `Rust`, `JavaScript`, and `TypeScript`. They remain valid in configuration +when their Cargo features are disabled. `Kotlin` and `Erlang` are reserved names +already used by the bundled catalog; their definitions are validated but scanning +is unavailable until parsers are implemented. + +Library tables accept `name`, `languages`, `patterns`, and `algorithms`. +`[library.patterns]` accepts `include` and `apis`. Algorithm tables accept `name`, +`primitive`, `nistQuantumSecurityLevel`, `symbol_patterns`, and +`parameter_patterns`. Parameter tables accept `name`, `pattern`, and +`default_value`. + +Parameter extraction reads capture group 1. A regex with no capture group cannot +extract a value; a configured `default_value` is used when extraction fails. +Multiple definitions of the same algorithm or parameter remain allowed because +the bundled catalog uses them for alternative signatures. The +`nistQuantumSecurityLevel` field is currently parsed but is not emitted in findings. diff --git a/src/patterns.rs b/src/patterns.rs index 9601017..11544f0 100644 --- a/src/patterns.rs +++ b/src/patterns.rs @@ -54,8 +54,8 @@ impl Language { } #[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] struct RawPatternSet { - #[allow(dead_code)] #[serde(default)] version: Option, #[serde(default)] @@ -63,8 +63,8 @@ struct RawPatternSet { } #[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] struct RawVersion { - #[allow(dead_code)] #[serde(default)] schema: Option, #[allow(dead_code)] @@ -73,6 +73,7 @@ struct RawVersion { } #[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] struct RawLibrary { name: String, languages: Vec, @@ -83,6 +84,7 @@ struct RawLibrary { } #[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] struct RawLibraryPatterns { #[serde(default)] include: Vec, @@ -91,6 +93,7 @@ struct RawLibraryPatterns { } #[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] struct RawAlgorithm { name: String, primitive: Option, @@ -103,6 +106,7 @@ struct RawAlgorithm { } #[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] struct RawParameterPattern { name: String, pattern: String, @@ -165,41 +169,82 @@ pub struct ParameterPattern { impl PatternSet { pub fn from_toml(text: &str) -> Result { let raw: RawPatternSet = toml::from_str(text).context("parse patterns.toml")?; + if let Some(schema) = raw + .version + .as_ref() + .and_then(|version| version.schema.as_deref()) + && schema != "1" + { + bail!("unsupported pattern schema {schema:?}; expected \"1\""); + } let mut libraries = Vec::new(); let mut library_names = HashSet::new(); for lib in raw.library { - let languages = lib - .languages - .into_iter() - .filter_map(|name| Language::from_pattern_name(&name)) - .collect::>(); - if languages.is_empty() { - continue; + if lib.name.trim().is_empty() { + bail!("library name must not be empty"); } if !library_names.insert(lib.name.clone()) { bail!("duplicate library name {:?}", lib.name); } + if lib.languages.is_empty() { + bail!("library {:?}: languages must not be empty", lib.name); + } + let mut languages = Vec::new(); + for name in &lib.languages { + if let Some(language) = Language::from_pattern_name(name) { + if !languages.contains(&language) { + languages.push(language); + } + } else if !matches!(name.as_str(), "Kotlin" | "Erlang") { + bail!("library {:?}: unknown language {name:?}", lib.name); + } + } let mut include_regexes = Vec::new(); let mut api_regexes = Vec::new(); if let Some(p) = lib.patterns { for re in p.include { - include_regexes.push(Regex::new(&re)?); + include_regexes.push(Regex::new(&re).with_context(|| { + format!("library {:?}: invalid include regex {re:?}", lib.name) + })?); } for re in p.apis { - api_regexes.push(Regex::new(&re)?); + api_regexes.push(Regex::new(&re).with_context(|| { + format!("library {:?}: invalid API regex {re:?}", lib.name) + })?); } } let mut algorithms = Vec::new(); for a in lib.algorithms { + if a.name.trim().is_empty() { + bail!("library {:?}: algorithm name must not be empty", lib.name); + } let mut symbol_regexes = Vec::new(); for re in a.symbol_patterns { - symbol_regexes.push(Regex::new(&re)?); + symbol_regexes.push(Regex::new(&re).with_context(|| { + format!( + "library {:?}, algorithm {:?}: invalid symbol regex {re:?}", + lib.name, a.name + ) + })?); } let mut parameter_patterns = Vec::new(); for p in a.parameter_patterns { + if p.name.trim().is_empty() { + bail!( + "library {:?}, algorithm {:?}: parameter name must not be empty", + lib.name, + a.name + ); + } + let regex = Regex::new(&p.pattern).with_context(|| { + format!( + "library {:?}, algorithm {:?}, parameter {:?}: invalid regex {:?}", + lib.name, a.name, p.name, p.pattern + ) + })?; parameter_patterns.push(ParameterPattern { name: p.name, - regex: Regex::new(&p.pattern)?, + regex, default_value: p.default_value.map(toml_value_to_json), }); } @@ -211,6 +256,11 @@ impl PatternSet { parameter_patterns, }); } + // The catalog reserves Kotlin and Erlang for future parsers. Validate + // their definitions too, but do not advertise them as scannable. + if languages.is_empty() { + continue; + } libraries.push(Library { name: lib.name, languages, diff --git a/tests/pattern_validation.rs b/tests/pattern_validation.rs new file mode 100644 index 0000000..41308da --- /dev/null +++ b/tests/pattern_validation.rs @@ -0,0 +1,78 @@ +use cipherscope::{DEFAULT_PATTERNS, patterns::PatternSet}; + +fn error(text: &str) -> String { + format!("{:#}", PatternSet::from_toml(text).unwrap_err()) +} + +#[test] +fn bundled_catalog_remains_valid() { + assert!( + !PatternSet::from_toml(DEFAULT_PATTERNS) + .unwrap() + .libraries + .is_empty() + ); +} + +#[test] +fn rejects_unknown_fields_at_each_schema_level() { + for text in [ + "libary = []", + "[version]\nschmea = '1'", + "[[library]]\nname = 'Test'\nlanguages = ['Python']\nalgoritms = []", + "[[library]]\nname = 'Test'\nlanguages = ['Python']\n[library.patterns]\ninlcude = ['test']", + "[[library]]\nname = 'Test'\nlanguages = ['Python']\n[[library.algorithms]]\nname = 'Test'\nsymbol_paterns = ['test']", + "[[library]]\nname = 'Test'\nlanguages = ['Python']\n[[library.algorithms]]\nname = 'Test'\n[[library.algorithms.parameter_patterns]]\nname = 'size'\npattern = '(256)'\ndefault = 256", + ] { + assert!(error(text).contains("unknown field"), "{text}"); + } +} + +#[test] +fn rejects_unknown_languages_including_partially_valid_lists() { + for languages in ["['Pythno']", "['Python', 'Pythno']"] { + let message = error(&format!( + "[[library]]\nname = 'Test'\nlanguages = {languages}" + )); + assert!(message.contains("unknown language \"Pythno\"")); + assert!(message.contains("Test")); + } +} + +#[test] +fn rejects_unsupported_schema_versions() { + assert!(error("[version]\nschema = '2'").contains("unsupported pattern schema")); + assert!(PatternSet::from_toml("library = []").is_ok()); + assert!(PatternSet::from_toml("[version]\nschema = '1'").is_ok()); +} + +#[test] +fn reserved_languages_remain_accepted_but_inactive() { + let patterns = + PatternSet::from_toml("[[library]]\nname = 'Future'\nlanguages = ['Kotlin', 'Erlang']") + .unwrap(); + assert!(patterns.libraries.is_empty()); + assert!(error("[[library]]\nname = 'Future'\nlanguages = ['Kotlin']\n[library.patterns]\ninclude = ['[']").contains("invalid include regex")); +} + +#[test] +fn regex_errors_identify_the_library_algorithm_and_parameter() { + let message = error( + "[[library]]\nname = 'TestLib'\nlanguages = ['Python']\n[[library.algorithms]]\nname = 'TestAlg'\n[[library.algorithms.parameter_patterns]]\nname = 'keySize'\npattern = '['", + ); + for expected in ["TestLib", "TestAlg", "keySize", "unclosed character class"] { + assert!(message.contains(expected), "{message}"); + } +} + +#[test] +fn rejects_empty_names_and_language_lists() { + for text in [ + "[[library]]\nname = ' '\nlanguages = ['Python']", + "[[library]]\nname = 'Test'\nlanguages = []", + "[[library]]\nname = 'Test'\nlanguages = ['Python']\n[[library.algorithms]]\nname = ''", + "[[library]]\nname = 'Test'\nlanguages = ['Python']\n[[library.algorithms]]\nname = 'Test'\n[[library.algorithms.parameter_patterns]]\nname = ''\npattern = '(256)'", + ] { + assert!(error(text).contains("must not be empty")); + } +}