From be4e2cc9b086009df683e907859659295057c0cf Mon Sep 17 00:00:00 2001 From: hyperpolymath <6759885+hyperpolymath@users.noreply.github.com> Date: Wed, 28 Oct 2026 23:31:52 +0000 Subject: [PATCH 1/4] fix(bridge): handle empty and nested lockfiles Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- README.adoc | 33 +++- data/canonical-rule-ids.json | 283 ++++++++++++++++++++++++++ man/panic-attack.1 | 9 + src/bridge/lockfile.rs | 371 ++++++++++++++++++++++++++++++++--- src/bridge/mod.rs | 131 +++++++++++-- src/main.rs | 14 +- src/types.rs | 17 ++ 7 files changed, 808 insertions(+), 50 deletions(-) create mode 100644 data/canonical-rule-ids.json diff --git a/README.adoc b/README.adoc index 0cf231d..ff2f193 100644 --- a/README.adoc +++ b/README.adoc @@ -70,7 +70,7 @@ panic-attack exists to address this by combining: panic-attack provides: * **49-language static analysis** across multiple families -* **Weak point detection** (25 categories) +* **Weak point detection** (30 canonical categories; see the PA rule-ID table below) * **Attack simulation (6 axes)**: CPU, memory, disk, network, concurrency, time * **miniKanren logic engine** for taint analysis and cross-language reasoning * **Signature detection** (use-after-free, deadlock, etc.) @@ -187,6 +187,35 @@ Consult `man/panic-attack.1` or `panic-attack help` for every command, and use ` `just test-elixir` runs the Elixir harnesses that cover the CLI surface so documentation, help text, and automation flags stay aligned. +=== Patch Bridge CLI contract (`--features http`) + +Build with `cargo build --release --features http`. Global flags are placed +before the subcommand. `bridge triage` discovers supported lockfiles +recursively under the project directory (excluding `.git`, `target`, and +vendor/build caches); use repeatable `--lockfile FILE` options to select an +explicit set instead: + +[source,shell] +---- +panic-attack --quiet bridge triage ./project --output bridge-report.json +panic-attack --quiet bridge triage ./project --lockfile ./project/src/rust/Cargo.lock --output bridge-report.json +---- + +`--output FILE` writes the JSON `BridgeReport` to that file. Without it, +`--quiet` writes JSON to stdout; normal mode prints a human-readable summary +(and writes JSON only when `--output` is supplied). A valid lockfile with zero +external dependencies produces a valid empty report; no supported lockfile +is an error. An unreadable discovered lockfile fails closed rather than +being counted as a clean scan. + +`--offline` skips OSV and returns **no vulnerability data**. The JSON field +`vulnerability_data` is `offline-unavailable`; an empty `cves` array in that +report is *not* evidence of zero vulnerabilities and must never be treated as +clean. Online reports use `queried`; dependency-free projects use +`no-dependencies`. See `data/canonical-rule-ids.json` for the published, +versioned PA rule-ID/CWE/semantic-class mapping used for cross-tool +verdict-grid de-duplication. + == Integration & Groove panic-attack can emit PanLL-friendly exports (event chains, system images, temporal diffs) per `docs/panll-export.adoc`, and it also advertises its static-analysis capability via the Gossamer groove protocol. Start the discovery endpoint with `panic-attack groove --port 7600` and check the JSON manifest by curling `http://localhost:7600/.well-known/groove` (the health endpoint sits at `/health`). @@ -206,7 +235,7 @@ Current state: **v2.5.0** * v2.5.5 cohort landed 2026-06-02: context-aware FP suppression via `test_context` / `comment_marker` / `ffi_kind` / `jit_context` modules — `// panic-attack: accepted` inline markers, automatic PanicPath suppression in test-only code, build.zig/build.rs auto-accept, Cranelift/LLVM/Wasm/JS JIT detection. See `PROOF-PROGRAMME.md` for the formal soundness landscape * 0 warnings * 38 CLI subcommands -* 49 supported languages (25 canonical weak-point categories PA001–PA025; `PA001b` SARIF subvariant collapses two allocation flavors under the same canonical rule) +* 49 supported languages (30 canonical weak-point categories PA001–PA029; `PA001b` is a distinct stable rule ID grouped with PA001 under one allocation semantic class for cross-tool de-duplication) See link:ROADMAP.adoc[ROADMAP.adoc] for full capability breakdown and milestones. diff --git a/data/canonical-rule-ids.json b/data/canonical-rule-ids.json new file mode 100644 index 0000000..99c3ccd --- /dev/null +++ b/data/canonical-rule-ids.json @@ -0,0 +1,283 @@ +{ + "schema_version": "1.0.0", + "title": "panic-attack canonical rule-ID table", + "description": "Public rule ID contract matching WeakPointCategory::rule_id at source commit 410a8f821ea8297833162565b50a298f787815da. CWE links are semantic associations, not assertions of exact CWE identity; [] means no direct CWE mapping is claimed. PA001 and PA001b intentionally share a semantic class and CWE and should deduplicate for class-level verdict grids.", + "cwe_catalog": "https://cwe.mitre.org/", + "source_commit": "410a8f821ea8297833162565b50a298f787815da", + "deduplication": { + "primary_key": "semantic_class", + "preserve_rule_ids": [ + "PA001", + "PA001b" + ], + "note": "Preserve the canonical rule ID for audit/report display; group findings by semantic_class for cross-tool verdict-grid comparison. CWE associations are supporting metadata, not a unique deduplication key." + }, + "rules": [ + { + "rule_id": "PA001", + "category": "UncheckedAllocation", + "name": "unchecked-allocation", + "cwe_ids": [ + "CWE-789" + ], + "semantic_class": "unbounded-memory-allocation" + }, + { + "rule_id": "PA001b", + "category": "UnboundedAllocation", + "name": "unbounded-allocation", + "cwe_ids": [ + "CWE-789" + ], + "semantic_class": "unbounded-memory-allocation" + }, + { + "rule_id": "PA002", + "category": "UnboundedLoop", + "name": "unbounded-loop", + "cwe_ids": [ + "CWE-834" + ], + "semantic_class": "unbounded-iteration" + }, + { + "rule_id": "PA003", + "category": "BlockingIO", + "name": "blocking-io", + "cwe_ids": [ + "CWE-400" + ], + "semantic_class": "blocking-resource-consumption" + }, + { + "rule_id": "PA004", + "category": "UnsafeCode", + "name": "unsafe-code", + "cwe_ids": [], + "semantic_class": "unsafe-memory-or-type-operation" + }, + { + "rule_id": "PA005", + "category": "PanicPath", + "name": "panic-path", + "cwe_ids": [ + "CWE-248" + ], + "semantic_class": "unhandled-runtime-panic" + }, + { + "rule_id": "PA006", + "category": "RaceCondition", + "name": "race-condition", + "cwe_ids": [ + "CWE-362" + ], + "semantic_class": "concurrent-access-race" + }, + { + "rule_id": "PA007", + "category": "DeadlockPotential", + "name": "deadlock-potential", + "cwe_ids": [ + "CWE-833" + ], + "semantic_class": "concurrency-deadlock" + }, + { + "rule_id": "PA008", + "category": "ResourceLeak", + "name": "resource-leak", + "cwe_ids": [ + "CWE-772" + ], + "semantic_class": "unreleased-resource" + }, + { + "rule_id": "PA009", + "category": "CommandInjection", + "name": "command-injection", + "cwe_ids": [ + "CWE-78" + ], + "semantic_class": "improper-neutralization-of-command-input" + }, + { + "rule_id": "PA010", + "category": "UnsafeDeserialization", + "name": "unsafe-deserialization", + "cwe_ids": [ + "CWE-502" + ], + "semantic_class": "unsafe-deserialization" + }, + { + "rule_id": "PA011", + "category": "DynamicCodeExecution", + "name": "dynamic-code-execution", + "cwe_ids": [ + "CWE-95" + ], + "semantic_class": "improper-neutralization-of-eval-input" + }, + { + "rule_id": "PA012", + "category": "UnsafeFFI", + "name": "unsafe-ffi", + "cwe_ids": [], + "semantic_class": "unsafe-foreign-function-boundary" + }, + { + "rule_id": "PA013", + "category": "AtomExhaustion", + "name": "atom-exhaustion", + "cwe_ids": [ + "CWE-400" + ], + "semantic_class": "unbounded-resource-consumption" + }, + { + "rule_id": "PA014", + "category": "InsecureProtocol", + "name": "insecure-protocol", + "cwe_ids": [ + "CWE-319", + "CWE-326" + ], + "semantic_class": "insecure-communications-or-cryptography" + }, + { + "rule_id": "PA015", + "category": "ExcessivePermissions", + "name": "excessive-permissions", + "cwe_ids": [ + "CWE-732" + ], + "semantic_class": "incorrect-permission-assignment" + }, + { + "rule_id": "PA016", + "category": "PathTraversal", + "name": "path-traversal", + "cwe_ids": [ + "CWE-22" + ], + "semantic_class": "improper-limitation-of-pathname" + }, + { + "rule_id": "PA017", + "category": "HardcodedSecret", + "name": "hardcoded-secret", + "cwe_ids": [ + "CWE-798" + ], + "semantic_class": "hardcoded-credential" + }, + { + "rule_id": "PA018", + "category": "UncheckedError", + "name": "unchecked-error", + "cwe_ids": [ + "CWE-390", + "CWE-391" + ], + "semantic_class": "ignored-or-unchecked-error-condition" + }, + { + "rule_id": "PA019", + "category": "InfiniteRecursion", + "name": "infinite-recursion", + "cwe_ids": [ + "CWE-674" + ], + "semantic_class": "uncontrolled-recursion" + }, + { + "rule_id": "PA020", + "category": "UnsafeTypeCoercion", + "name": "unsafe-type-coercion", + "cwe_ids": [ + "CWE-704" + ], + "semantic_class": "incorrect-type-conversion" + }, + { + "rule_id": "PA021", + "category": "ProofDrift", + "name": "proof-drift", + "cwe_ids": [], + "semantic_class": "unverified-or-drifted-formal-proof" + }, + { + "rule_id": "PA022", + "category": "CryptoMisuse", + "name": "crypto-misuse", + "cwe_ids": [ + "CWE-327", + "CWE-328", + "CWE-330" + ], + "semantic_class": "cryptographic-primitive-or-validation-misuse" + }, + { + "rule_id": "PA023", + "category": "SupplyChain", + "name": "supply-chain", + "cwe_ids": [ + "CWE-1104", + "CWE-1395" + ], + "semantic_class": "untrusted-or-vulnerable-component" + }, + { + "rule_id": "PA024", + "category": "InputBoundary", + "name": "input-boundary", + "cwe_ids": [ + "CWE-20", + "CWE-502" + ], + "semantic_class": "unvalidated-input-at-deserialization-boundary" + }, + { + "rule_id": "PA025", + "category": "MutationGap", + "name": "mutation-gap", + "cwe_ids": [], + "semantic_class": "insufficient-test-or-mutation-assurance" + }, + { + "rule_id": "PA026", + "category": "LicensePolicy", + "name": "license-policy", + "cwe_ids": [], + "semantic_class": "dependency-license-policy-violation" + }, + { + "rule_id": "PA027", + "category": "Advisory", + "name": "advisory", + "cwe_ids": [ + "CWE-1395" + ], + "semantic_class": "known-vulnerable-component-version" + }, + { + "rule_id": "PA028", + "category": "BannedDep", + "name": "banned-dep", + "cwe_ids": [ + "CWE-1357" + ], + "semantic_class": "prohibited-or-insufficiently-trustworthy-component" + }, + { + "rule_id": "PA029", + "category": "UnknownSource", + "name": "unknown-source", + "cwe_ids": [ + "CWE-1357" + ], + "semantic_class": "unverified-component-provenance" + } + ] +} diff --git a/man/panic-attack.1 b/man/panic-attack.1 index 4fa939c..a3e140d 100644 --- a/man/panic-attack.1 +++ b/man/panic-attack.1 @@ -75,6 +75,15 @@ Shows the man-style help text for panic-attack or one of its subcommands. .TP .B diagnostics [--manifest PATH] Runs self-diagnostics that check for AI manifest validity, required artifact directories, and the presence of Hypatia/gitbot-fleet watchers. This command returns non-zero if any check fails so automation can detect regressions. +.TP +.B bridge triage [DIR] [--output FILE] [--offline] [--lockfile FILE] +Requires a binary built with `--features http`. Discovers supported lockfiles recursively beneath DIR (excluding .git, target, and vendor/build caches); `--lockfile FILE` may be repeated to select exact lockfiles. Global flags such as --quiet precede the subcommand: `panic-attack --quiet bridge triage DIR --output report.json`. `--output FILE` writes the JSON BridgeReport. A valid lockfile with zero external dependencies returns a valid empty report; no supported lockfile is an error. Unreadable discovered lockfiles fail closed. +.TP +.B bridge triage --offline +Skips the OSV vulnerability query and returns NO vulnerability data. The JSON `vulnerability_data` field is `offline-unavailable`; an empty CVE list is not a clean bill of health and must not be interpreted as one. +.TP +.B bridge status [DIR] +Displays the Patch Bridge mitigation registry for DIR. .SH EXAMPLES .TP .B panic-attack help amuck diff --git a/src/bridge/lockfile.rs b/src/bridge/lockfile.rs index b642128..00eec33 100644 --- a/src/bridge/lockfile.rs +++ b/src/bridge/lockfile.rs @@ -6,43 +6,295 @@ use super::LockedDependency; use anyhow::{Context, Result}; -use std::path::Path; +use std::path::{Path, PathBuf}; +use walkdir::WalkDir; // ============================================================================ // Discovery // ============================================================================ -/// Discover and parse all lockfiles found under `dir`. -/// -/// Tries Cargo.lock, mix.lock, package-lock.json, and requirements.txt in -/// order. All successful parses are merged into a single list. Errors from -/// individual parsers are logged as warnings and skipped so one malformed -/// lockfile does not abort triage of the whole project. +/// A lockfile name the bridge understands, paired with its parser. type LockfileParser = fn(&Path) -> Result>; -pub fn discover_and_parse(dir: &Path) -> Vec { - let mut all = Vec::new(); - - let candidates: &[(&str, LockfileParser)] = &[ - ("Cargo.lock", parse_cargo_lock), - ("mix.lock", parse_mix_lock), - ("package-lock.json", parse_package_lock_json), - ("requirements.txt", parse_requirements_txt), - ]; - - for (filename, parser) in candidates { - let path = dir.join(filename); - if path.exists() { - match parser(&path) { - Ok(deps) => all.extend(deps), - Err(e) => { - log::warn!("[bridge] Failed to parse {}: {}", path.display(), e); +/// Every lockfile kind the bridge reads, in parse order. The base name is the +/// only thing discovery keys on — a `Cargo.lock` by any other name is not +/// parsed, even when passed explicitly via `--lockfile`. +const LOCKFILE_KINDS: &[(&str, LockfileParser)] = &[ + ("Cargo.lock", parse_cargo_lock), + ("mix.lock", parse_mix_lock), + ("package-lock.json", parse_package_lock_json), + ("requirements.txt", parse_requirements_txt), +]; + +/// Human-readable list of supported lockfiles, for error messages. +pub const SUPPORTED_LOCKFILES: &str = + "Cargo.lock (Rust), mix.lock (Elixir), package-lock.json (npm), requirements.txt (Python)"; + +/// Directories the recursive search does not descend into. +/// +/// A lockfile under one of these describes somebody else's dependency graph +/// (a vendored or installed copy) or a stale duplicate of this project's own, +/// so reading it would report vulnerabilities the project cannot ship. Kept +/// aligned with `bridge::reachability`'s scan exclusions, plus the build and +/// package caches that are known to hold copied lockfiles. +const LOCKFILE_SKIP_DIRS: &[&str] = &[ + ".git", + "target", + "node_modules", + ".yarn", + "vendor", + "deps", + "_deps", + "_build", + "build", + "dist", + "out", + "obj", + "zig-cache", + ".zig-cache", + "zig-out", + ".stack-work", + "dist-newstyle", + "__pycache__", + ".venv", + "venv", + ".elixir_ls", + ".lexical", + ".machine_readable", + "external_corpora", + "third_party", + "corpus", +]; + +/// What lockfile discovery found for one project. +/// +/// The two empty-dependency states are kept apart on purpose: +/// +/// - `lockfiles` empty → nothing was found, so the project's dependency +/// surface is **unknown** (callers report an error). +/// - `lockfiles` non-empty, `dependencies` empty → the project has a lockfile +/// that declares no external dependencies, which is a **complete answer** +/// for a dependency-free project (see `BridgeReport::empty`). +#[derive(Debug, Clone, Default)] +pub struct LockfileDiscovery { + /// Lockfiles found on disk (or named with `--lockfile`), sorted by path so + /// a run is reproducible. + pub lockfiles: Vec, + /// The union of the dependencies those lockfiles declare, de-duplicated by + /// `(ecosystem, name, version)`. `declared_by` on each entry holds the + /// lockfile paths as supplied by the walker or explicit command-line input. + pub dependencies: Vec, + /// Lockfiles that exist but could not be parsed. + /// + /// Discovery treats these as warnings — one malformed lockfile should not + /// abort triage of a whole project — but a caller that needs full coverage + /// must check this list: a lockfile that failed to parse contributes + /// nothing to `dependencies`, so a zero count here is not "clean". + pub unreadable: Vec, +} + +impl LockfileDiscovery { + /// Whether any recognised lockfile was found at all. + pub fn has_lockfiles(&self) -> bool { + !self.lockfiles.is_empty() + } + + /// Whether no external dependency was declared by any readable lockfile. + pub fn is_empty(&self) -> bool { + self.dependencies.is_empty() + } + + /// Number of distinct locked dependencies. + pub fn len(&self) -> usize { + self.dependencies.len() + } +} + +/// Discover and parse every lockfile under `dir`, recursively. +/// +/// The project root is searched first, then nested directories, so workspace +/// and monorepo layouts that keep the lockfile in a member directory +/// (`src/rust/Cargo.lock`, `frontend/package-lock.json`) are picked up without +/// any flag. Use [`parse_lockfiles`] (the `--lockfile` flag) to name files +/// explicitly, or [`discover_and_parse_root_only`] to pin the search to `dir`. +/// +/// Successful parses are merged into one de-duplicated list. Parse errors are +/// logged as warnings and recorded in [`LockfileDiscovery::unreadable`] so one +/// malformed lockfile does not abort triage of the whole project. +pub fn discover_and_parse(dir: &Path) -> LockfileDiscovery { + discover_from(dir, true) +} + +/// As [`discover_and_parse`], but `dir` alone is searched — the pre-0.3 +/// behaviour. Kept for callers that must not pick up a nested sub-project. +pub fn discover_and_parse_root_only(dir: &Path) -> LockfileDiscovery { + discover_from(dir, false) +} + +fn discover_from(dir: &Path, recursive: bool) -> LockfileDiscovery { + parse_found(lockfile_paths(dir, recursive)) +} + +/// Paths of the lockfiles under `dir`, sorted and de-duplicated. +pub fn lockfile_paths(dir: &Path, recursive: bool) -> Vec { + let names: Vec<&str> = LOCKFILE_KINDS.iter().map(|(name, _)| *name).collect(); + let mut found: Vec = Vec::new(); + + if recursive { + for entry in WalkDir::new(dir) + .into_iter() + .filter_entry(|e| e.depth() == 0 || !is_skipped_dir(e.path())) + .filter_map(|e| e.ok()) + { + if !entry.file_type().is_file() { + continue; + } + let path = entry.path(); + if !path + .file_name() + .and_then(|n| n.to_str()) + .is_some_and(|n| names.contains(&n)) + { + continue; + } + found.push(path.to_path_buf()); + } + } else { + for name in names { + let path = dir.join(name); + if path.is_file() { + found.push(path); + } + } + } + + found.sort(); + found.dedup(); + found +} + +/// Parse exactly these lockfiles, with no searching. +/// +/// This is the `--lockfile` path, and it is deliberately stricter than +/// discovery: a lockfile the user named that is missing, unrecognised, or +/// unparseable is an error. Silently dropping it would turn "could not read" +/// into "no dependencies", which is the failure mode this API exists to avoid. +/// +/// # Errors +/// Returns an error if a path is not a readable, supported lockfile. +pub fn parse_lockfiles(paths: &[PathBuf]) -> Result { + let mut discovery = LockfileDiscovery::default(); + + for path in paths { + if !path.is_file() { + anyhow::bail!( + "lockfile not found or not a file: {} (supported: {})", + path.display(), + SUPPORTED_LOCKFILES + ); + } + let Some(deps) = parse_one(path)? else { + anyhow::bail!( + "unsupported lockfile: {} (recognised base names: {})", + path.display(), + SUPPORTED_LOCKFILES + ); + }; + discovery.push(path, deps); + } + + discovery.finish(); + Ok(discovery) +} + +fn parse_found(paths: Vec) -> LockfileDiscovery { + let mut discovery = LockfileDiscovery::default(); + + for path in paths { + discovery.lockfiles.push(path.clone()); + match parse_one(&path) { + Ok(Some(deps)) => discovery.push(&path, deps), + // Discovery only collects recognised names, so `None` here means + // the file vanished between the walk and the read. Skip it. + Ok(None) => {} + Err(e) => { + log::warn!("[bridge] Failed to parse {}: {}", path.display(), e); + discovery.unreadable.push(path); + } + } + } + + discovery.finish(); + discovery +} + +impl LockfileDiscovery { + /// Record one parsed lockfile's dependencies, each tagged with the lockfile + /// it came from. + fn push(&mut self, lockfile: &Path, deps: Vec) { + for mut dep in deps { + dep.declared_by = vec![lockfile.to_path_buf()]; + self.dependencies.push(dep); + } + } + + /// Merge lockfiles that declare the same package version, so a dependency + /// shared by several members of a workspace is queried once and lists every + /// lockfile that declares it. + fn finish(&mut self) { + let mut seen: std::collections::HashMap<(String, String, String), usize> = + std::collections::HashMap::new(); + let mut merged: Vec = Vec::with_capacity(self.dependencies.len()); + + for dep in self.dependencies.drain(..) { + let key = ( + dep.ecosystem.clone(), + dep.name.clone(), + dep.version.clone(), + ); + match seen.get(&key) { + Some(&at) => { + for path in dep.declared_by { + if !merged[at].declared_by.contains(&path) { + merged[at].declared_by.push(path); + } + } + } + None => { + seen.insert(key, merged.len()); + merged.push(dep); } } } + + self.dependencies = merged; + self.lockfiles.sort(); + self.lockfiles.dedup(); + self.unreadable.sort(); + self.unreadable.dedup(); } +} - all +/// Whether a lockfile name is one we read, and which parser handles it. +fn parser_for(path: &Path) -> Option { + let name = path.file_name().and_then(|n| n.to_str())?; + LOCKFILE_KINDS + .iter() + .find(|(candidate, _)| *candidate == name) + .map(|(_, parser)| *parser) +} + +fn parse_one(path: &Path) -> Result>> { + match parser_for(path) { + Some(parser) => parser(path).map(Some), + None => Ok(None), + } +} + +/// Whether `path` is a directory the recursive lockfile search skips. +fn is_skipped_dir(path: &Path) -> bool { + let name = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); + LOCKFILE_SKIP_DIRS.contains(&name) } // ============================================================================ @@ -57,16 +309,30 @@ pub fn parse_cargo_lock(path: &Path) -> Result> { let content = std::fs::read_to_string(path) .with_context(|| format!("Failed to read {}", path.display()))?; + let has_supported_version = content.lines().any(|line| { + matches!(line.trim(), "version = 3" | "version = 4") + }); + if !has_supported_version { + anyhow::bail!("{} is not a supported Cargo.lock (expected version = 3 or 4)", path.display()); + } + let mut deps = Vec::new(); let mut current_name: Option = None; let mut current_version: Option = None; let mut current_source: Option = None; + let mut package_seen = false; for line in content.lines() { let trimmed = line.trim(); if trimmed == "[[package]]" { - if let (Some(name), Some(version)) = (current_name.take(), current_version.take()) { + if package_seen { + let name = current_name.take().ok_or_else(|| { + anyhow::anyhow!("{} contains a package without a name", path.display()) + })?; + let version = current_version.take().ok_or_else(|| { + anyhow::anyhow!("{} contains a package without a version", path.display()) + })?; if current_source .as_ref() .is_some_and(|s| s.contains("registry")) @@ -79,6 +345,7 @@ pub fn parse_cargo_lock(path: &Path) -> Result> { }); } } + package_seen = true; current_name = None; current_version = None; current_source = None; @@ -94,7 +361,13 @@ pub fn parse_cargo_lock(path: &Path) -> Result> { } } - if let (Some(name), Some(version)) = (current_name, current_version) { + if package_seen { + let name = current_name.ok_or_else(|| { + anyhow::anyhow!("{} contains a package without a name", path.display()) + })?; + let version = current_version.ok_or_else(|| { + anyhow::anyhow!("{} contains a package without a version", path.display()) + })?; if current_source .as_ref() .is_some_and(|s| s.contains("registry")) @@ -653,7 +926,8 @@ checksum = "def456" let mut tmp = tempfile::NamedTempFile::new().unwrap(); write!( tmp, - r#"[[package]] + r#"version = 4 +[[package]] name = "my-local-dep" version = "0.1.0" "# @@ -746,13 +1020,50 @@ sqlalchemy[asyncio]==2.0.0; python_version >= "3.8" let mut f = std::fs::File::create(dir.path().join("Cargo.lock")).unwrap(); write!( f, - "[[package]]\nname = \"serde\"\nversion = \"1.0.0\"\nsource = \"registry+https://github.com/rust-lang/crates.io-index\"\n" + "version = 4\n[[package]]\nname = \"serde\"\nversion = \"1.0.0\"\nsource = \"registry+https://github.com/rust-lang/crates.io-index\"\n" ) .unwrap(); let deps = discover_and_parse(dir.path()); + assert_eq!(deps.lockfiles.len(), 1); assert_eq!(deps.len(), 1); - assert_eq!(deps[0].name, "serde"); + assert_eq!(deps.dependencies[0].name, "serde"); + } + + #[test] + fn recursive_discovery_finds_nested_workspace_lockfiles_but_skips_build_trees() { + let dir = tempfile::TempDir::new().unwrap(); + let nested = dir.path().join("src/rust"); + std::fs::create_dir_all(&nested).unwrap(); + std::fs::write( + nested.join("Cargo.lock"), + "[[package]]\nname = \"serde\"\nversion = \"1.0.0\"\nsource = \"registry+https://github.com/rust-lang/crates.io-index\"\n", + ) + .unwrap(); + let ignored = dir.path().join("target/generated"); + std::fs::create_dir_all(&ignored).unwrap(); + std::fs::write(ignored.join("Cargo.lock"), "not a lockfile").unwrap(); + + let found = discover_and_parse(dir.path()); + assert_eq!(found.lockfiles.len(), 1); + assert!(found.lockfiles[0].ends_with("src/rust/Cargo.lock")); + assert_eq!(found.dependencies.len(), 1); + assert!(!found.dependencies[0].declared_by.is_empty()); + } + + #[test] + fn empty_valid_lockfile_is_distinct_from_missing_lockfile() { + let dir = tempfile::TempDir::new().unwrap(); + std::fs::write(dir.path().join("Cargo.lock"), "version = 4\n").unwrap(); + let found = discover_and_parse(dir.path()); + assert!(found.has_lockfiles()); + assert!(found.is_empty()); + assert!(found.unreadable.is_empty()); + + let no_lock = tempfile::TempDir::new().unwrap(); + let missing = discover_and_parse(no_lock.path()); + assert!(!missing.has_lockfiles()); + assert!(missing.is_empty()); } // ------------------------------------------------------------------ diff --git a/src/bridge/mod.rs b/src/bridge/mod.rs index 66e5ee3..dd3a5b6 100644 --- a/src/bridge/mod.rs +++ b/src/bridge/mod.rs @@ -144,7 +144,7 @@ pub struct ImportSite { /// bumping the parent dependency past the affected version. /// /// JSON wire format uses `phantom-declared` / `phantom-transitive` / -/// `unreachable` / `reachable`. See `schema_version` 0.2.0. +/// `unreachable` / `reachable`. See `schema_version` 0.3.0. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum ReachabilityStatus { @@ -187,11 +187,26 @@ pub struct AssessedCve { pub action: String, } +/// Whether the report actually queried a vulnerability feed. +/// +/// `OfflineUnavailable` explicitly means the CVE list is empty because the +/// feed was not queried. It must never be interpreted as a clean result. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum VulnerabilityDataStatus { + Queried, + NoDependencies, + OfflineUnavailable, +} + /// The complete Patch Bridge triage report. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct BridgeReport { /// Schema version for forward compatibility pub schema_version: String, + /// `offline-unavailable` means NO vulnerability data was obtained, not zero known CVEs. + #[serde(default = "default_vulnerability_data_status")] + pub vulnerability_data: VulnerabilityDataStatus, /// Project path that was assessed pub project: PathBuf, /// Total dependencies scanned @@ -210,11 +225,16 @@ pub struct BridgeReport { pub informational: usize, } +fn default_vulnerability_data_status() -> VulnerabilityDataStatus { + VulnerabilityDataStatus::Queried +} + impl BridgeReport { /// Create an empty report for a project with no vulnerabilities. pub fn empty(project: &Path, total_deps: usize) -> Self { Self { - schema_version: "0.2.0".to_string(), + schema_version: "0.3.0".to_string(), + vulnerability_data: VulnerabilityDataStatus::Queried, project: project.to_path_buf(), total_dependencies: total_deps, vulnerable_dependencies: 0, @@ -259,32 +279,70 @@ impl BridgeReport { /// 4. Classify: mitigable / unmitigable / informational /// 5. Return structured report pub fn triage(project_dir: &Path, offline: bool) -> anyhow::Result { - // Step 1: Discover and parse all lockfiles (Cargo.lock, mix.lock, - // package-lock.json, requirements.txt). At least one must exist. - let deps = lockfile::discover_and_parse(project_dir); - if deps.is_empty() { + triage_with_discovery(project_dir, offline, None) +} + +/// Run triage using explicitly named lockfiles instead of discovery. +pub fn triage_with_lockfiles( + project_dir: &Path, + offline: bool, + paths: &[PathBuf], +) -> anyhow::Result { + let discovery = lockfile::parse_lockfiles(paths)?; + triage_with_discovery(project_dir, offline, Some(discovery)) +} + +fn triage_with_discovery( + project_dir: &Path, + offline: bool, + explicit: Option, +) -> anyhow::Result { + // Distinguish no lockfile (unknown dependency surface) from a valid + // lockfile containing zero external dependencies (a complete answer). + let discovery = explicit.unwrap_or_else(|| lockfile::discover_and_parse(project_dir)); + if !discovery.has_lockfiles() { + anyhow::bail!( + "No supported lockfile found in {}. Supported: {}.", + project_dir.display(), + lockfile::SUPPORTED_LOCKFILES + ); + } + // A lockfile that exists but cannot be parsed is not a clean result. + if !discovery.unreadable.is_empty() { anyhow::bail!( - "No supported lockfile found in {}. \ - Supported: Cargo.lock (Rust), mix.lock (Elixir), \ - package-lock.json (npm), requirements.txt (Python).", - project_dir.display() + "Could not parse {} lockfile(s) under {}; refusing to report a clean result: {}", + discovery.unreadable.len(), + project_dir.display(), + discovery + .unreadable + .iter() + .map(|p| p.display().to_string()) + .collect::>() + .join(", ") ); } - let total_deps = deps.len(); + let total_deps = discovery.len(); - if deps.is_empty() { - return Ok(BridgeReport::empty(project_dir, 0)); + if discovery.is_empty() { + let mut report = BridgeReport::empty(project_dir, 0); + report.vulnerability_data = VulnerabilityDataStatus::NoDependencies; + return Ok(report); } + let deps = discovery.dependencies; // Step 2: Query for vulnerabilities let vulns = if offline { - Vec::new() // Offline mode: skip API, rely on local advisory DB + Vec::new() // Offline mode: no local advisory DB is queried or available. } else { intelligence::query_osv_batch(&deps)? }; if vulns.is_empty() { - return Ok(BridgeReport::empty(project_dir, total_deps)); + let mut report = BridgeReport::empty(project_dir, total_deps); + if offline { + report.vulnerability_data = VulnerabilityDataStatus::OfflineUnavailable; + } + return Ok(report); } // Direct-vs-transitive lookup table for the project's Cargo.toml. @@ -316,7 +374,8 @@ pub fn triage(project_dir: &Path, offline: bool) -> anyhow::Result } let mut report = BridgeReport { - schema_version: "0.2.0".to_string(), + schema_version: "0.3.0".to_string(), + vulnerability_data: VulnerabilityDataStatus::Queried, project: project_dir.to_path_buf(), total_dependencies: total_deps, vulnerable_dependencies: 0, @@ -330,3 +389,43 @@ pub fn triage(project_dir: &Path, offline: bool) -> anyhow::Result Ok(report) } + +#[cfg(test)] +mod triage_tests { + use super::*; + + #[test] + fn dependency_free_lockfile_returns_valid_empty_report() -> anyhow::Result<()> { + let dir = tempfile::TempDir::new()?; + std::fs::write(dir.path().join("Cargo.lock"), "version = 4\n")?; + + let report = triage(dir.path(), true)?; + assert_eq!(report.total_dependencies, 0); + assert!(report.cves.is_empty()); + assert_eq!(report.vulnerability_data, VulnerabilityDataStatus::NoDependencies); + Ok(()) + } + + #[test] + fn missing_lockfile_is_not_confused_with_empty_lockfile() -> anyhow::Result<()> { + let dir = tempfile::TempDir::new()?; + let error = triage(dir.path(), true).expect_err("missing lockfile must fail"); + assert!(error.to_string().contains("No supported lockfile found")); + Ok(()) + } + + #[test] + fn offline_report_marks_vulnerability_data_unavailable() -> anyhow::Result<()> { + let dir = tempfile::TempDir::new()?; + std::fs::write( + dir.path().join("Cargo.lock"), + "[[package]]\nname = \"serde\"\nversion = \"1.0.0\"\nsource = \"registry+https://github.com/rust-lang/crates.io-index\"\n", + )?; + + let report = triage(dir.path(), true)?; + assert_eq!(report.total_dependencies, 1); + assert!(report.cves.is_empty()); + assert_eq!(report.vulnerability_data, VulnerabilityDataStatus::OfflineUnavailable); + Ok(()) + } +} diff --git a/src/main.rs b/src/main.rs index 0e36280..c68c2c2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1051,10 +1051,15 @@ enum BridgeAction { #[arg(short, long)] output: Option, - /// Skip network API calls (offline mode) + /// Skip network API calls. The report will contain NO vulnerability data, + /// not a clean bill of health (marked vulnerability_data="offline-unavailable"). #[arg(long, default_value_t = false)] offline: bool, + /// Use this supported lockfile instead of recursive discovery; may be repeated. + #[arg(long = "lockfile", value_name = "FILE", action = clap::ArgAction::Append)] + lockfiles: Vec, + /// Update the mitigation registry with new findings #[arg(long, default_value_t = false)] register: bool, @@ -2513,6 +2518,7 @@ fn run_main() -> Result<()> { dir, output, offline, + lockfiles, register, } => { // Reject path traversal sequences BEFORE any disk I/O. @@ -2533,7 +2539,11 @@ fn run_main() -> Result<()> { qprintln!(cli.quiet, "Patch Bridge triage: {}", project_dir.display()); - let report = bridge::triage(&project_dir, offline)?; + let report = if lockfiles.is_empty() { + bridge::triage(&project_dir, offline)? + } else { + bridge::triage_with_lockfiles(&project_dir, offline, &lockfiles)? + }; qprintln!( cli.quiet, diff --git a/src/types.rs b/src/types.rs index 8fa2daa..1d95bd4 100644 --- a/src/types.rs +++ b/src/types.rs @@ -1133,6 +1133,23 @@ mod rule_id_tests { let actual: Vec<&str> = WeakPointCategory::ALL.iter().map(|c| c.rule_id()).collect(); assert_eq!(actual, expected); } + + #[test] + fn published_rule_table_matches_the_canonical_ids() -> Result<(), Box> { + let table: serde_json::Value = + serde_json::from_str(include_str!("../data/canonical-rule-ids.json"))?; + let rules = table["rules"] + .as_array() + .ok_or("canonical rule table has no rules array")?; + assert_eq!(rules.len(), WeakPointCategory::ALL.len()); + for (category, row) in WeakPointCategory::ALL.iter().zip(rules) { + assert_eq!(row["rule_id"].as_str(), Some(category.rule_id())); + let category_name = format!("{category:?}"); + assert_eq!(row["category"].as_str(), Some(category_name.as_str())); + assert!(row["semantic_class"].as_str().is_some_and(|s| !s.is_empty())); + } + Ok(()) + } } #[cfg(test)] From 9ea2403a40518c6555a9006b5aa7a114e2ad637d Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Wed, 28 Oct 2026 23:43:20 +0000 Subject: [PATCH 2/4] docs: clarify bridge lockfile discovery, triage errors, and report status --- src/bridge/lockfile.rs | 51 +++++++++++++++++++++++++++++++----------- src/bridge/mod.rs | 39 +++++++++++++++++++++++++++----- 2 files changed, 71 insertions(+), 19 deletions(-) diff --git a/src/bridge/lockfile.rs b/src/bridge/lockfile.rs index 00eec33..622b676 100644 --- a/src/bridge/lockfile.rs +++ b/src/bridge/lockfile.rs @@ -100,6 +100,9 @@ impl LockfileDiscovery { } /// Whether no external dependency was declared by any readable lockfile. + /// + /// Also true when no lockfiles were found or all failed to parse; check + /// `lockfiles` and `unreadable` before treating this as dependency-free. pub fn is_empty(&self) -> bool { self.dependencies.is_empty() } @@ -112,15 +115,15 @@ impl LockfileDiscovery { /// Discover and parse every lockfile under `dir`, recursively. /// -/// The project root is searched first, then nested directories, so workspace -/// and monorepo layouts that keep the lockfile in a member directory -/// (`src/rust/Cargo.lock`, `frontend/package-lock.json`) are picked up without +/// Lockfiles in workspace or monorepo member directories +/// (`src/rust/Cargo.lock`, `frontend/package-lock.json`) are discovered without /// any flag. Use [`parse_lockfiles`] (the `--lockfile` flag) to name files /// explicitly, or [`discover_and_parse_root_only`] to pin the search to `dir`. /// -/// Successful parses are merged into one de-duplicated list. Parse errors are -/// logged as warnings and recorded in [`LockfileDiscovery::unreadable`] so one -/// malformed lockfile does not abort triage of the whole project. +/// Uses the exclusions and best-effort traversal of [`lockfile_paths`]. +/// Successful parses are merged by ecosystem, name and version. Parse errors +/// are recorded in [`LockfileDiscovery::unreadable`] rather than returned; +/// callers must inspect that list before treating an empty result as complete. pub fn discover_and_parse(dir: &Path) -> LockfileDiscovery { discover_from(dir, true) } @@ -131,11 +134,17 @@ pub fn discover_and_parse_root_only(dir: &Path) -> LockfileDiscovery { discover_from(dir, false) } +/// Collect dependencies and parse failures from [`lockfile_paths`]. fn discover_from(dir: &Path, recursive: bool) -> LockfileDiscovery { parse_found(lockfile_paths(dir, recursive)) } /// Paths of the lockfiles under `dir`, sorted and de-duplicated. +/// +/// With `recursive = false`, checks only `dir` itself. Recursive searches skip +/// nested build, vendor and cache names in `LOCKFILE_SKIP_DIRS`. Directory +/// traversal errors are ignored, so an empty result need not mean no lockfiles +/// exist. Only recognised base names are included. pub fn lockfile_paths(dir: &Path, recursive: bool) -> Vec { let names: Vec<&str> = LOCKFILE_KINDS.iter().map(|(name, _)| *name).collect(); let mut found: Vec = Vec::new(); @@ -180,8 +189,13 @@ pub fn lockfile_paths(dir: &Path, recursive: bool) -> Vec { /// unparseable is an error. Silently dropping it would turn "could not read" /// into "no dependencies", which is the failure mode this API exists to avoid. /// +/// Dependencies are merged by ecosystem, name and version and tagged with the +/// supplied paths. The returned `lockfiles` list is currently left empty, even +/// for non-empty input, so [`LockfileDiscovery::has_lockfiles`] returns false. +/// /// # Errors -/// Returns an error if a path is not a readable, supported lockfile. +/// Returns an error if a path is missing, is not a file, has an unsupported base +/// name, or its parser fails to read or parse it. pub fn parse_lockfiles(paths: &[PathBuf]) -> Result { let mut discovery = LockfileDiscovery::default(); @@ -207,6 +221,8 @@ pub fn parse_lockfiles(paths: &[PathBuf]) -> Result { Ok(discovery) } +/// Merge parsed dependencies, retaining all supplied paths in `lockfiles` and +/// recording parser errors in `unreadable` rather than returning them. fn parse_found(paths: Vec) -> LockfileDiscovery { let mut discovery = LockfileDiscovery::default(); @@ -238,9 +254,10 @@ impl LockfileDiscovery { } } - /// Merge lockfiles that declare the same package version, so a dependency - /// shared by several members of a workspace is queried once and lists every - /// lockfile that declares it. + /// Merge lockfiles that declare the same package version in an ecosystem, + /// so a dependency shared by several members of a workspace is queried once + /// and lists every lockfile that declares it. + /// Sort and de-duplicate the `lockfiles` and `unreadable` path lists. fn finish(&mut self) { let mut seen: std::collections::HashMap<(String, String, String), usize> = std::collections::HashMap::new(); @@ -284,6 +301,10 @@ fn parser_for(path: &Path) -> Option { .map(|(_, parser)| *parser) } +/// Parse a supported base name, returning `Ok(None)` for an unrecognised name. +/// +/// # Errors +/// Propagates the selected parser's read and format errors. fn parse_one(path: &Path) -> Result>> { match parser_for(path) { Some(parser) => parser(path).map(Some), @@ -291,7 +312,7 @@ fn parse_one(path: &Path) -> Result>> { } } -/// Whether `path` is a directory the recursive lockfile search skips. +/// Whether the final component of `path` matches an excluded directory name. fn is_skipped_dir(path: &Path) -> bool { let name = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); LOCKFILE_SKIP_DIRS.contains(&name) @@ -303,8 +324,12 @@ fn is_skipped_dir(path: &Path) -> bool { /// Parse a Cargo.lock file into a list of locked dependencies. /// -/// Handles Cargo.lock v3/v4 format. Each `[[package]]` entry becomes a -/// LockedDependency. Local packages (no registry source) are skipped. +/// Reads Cargo.lock v3/v4 package entries whose source contains `registry`. +/// Returns an empty list if no such packages are present. +/// +/// # Errors +/// Returns file-read or UTF-8 errors, or an error if no supported version line +/// is present or a package entry lacks a name or version, even if it is skipped. pub fn parse_cargo_lock(path: &Path) -> Result> { let content = std::fs::read_to_string(path) .with_context(|| format!("Failed to read {}", path.display()))?; diff --git a/src/bridge/mod.rs b/src/bridge/mod.rs index dd3a5b6..2c29b17 100644 --- a/src/bridge/mod.rs +++ b/src/bridge/mod.rs @@ -225,12 +225,17 @@ pub struct BridgeReport { pub informational: usize, } +/// Treat reports without a `vulnerability_data` field as having queried a feed. fn default_vulnerability_data_status() -> VulnerabilityDataStatus { VulnerabilityDataStatus::Queried } impl BridgeReport { - /// Create an empty report for a project with no vulnerabilities. + /// Create a report with no assessed CVEs and zero finding counts. + /// + /// `total_deps` supplies the dependency count. No feed is queried here; + /// `vulnerability_data` defaults to `Queried`, so callers must change it + /// when reporting offline data or a project with no dependencies. pub fn empty(project: &Path, total_deps: usize) -> Self { Self { schema_version: "0.3.0".to_string(), @@ -273,16 +278,32 @@ impl BridgeReport { /// Run the full Patch Bridge triage on a project directory. /// -/// 1. Parse lockfile to extract dependencies -/// 2. Query OSV API for known vulnerabilities -/// 3. For each vulnerable dep, check reachability (import scanning) -/// 4. Classify: mitigable / unmitigable / informational -/// 5. Return structured report +/// Recursively discovers supported lockfiles and merges their dependencies, +/// then queries OSV, checks Rust import reachability and classifies findings. +/// A parsed result with no dependencies returns an empty report marked +/// `NoDependencies` without querying OSV. +/// +/// With `offline = true`, skips OSV and returns no CVEs, marking reports with +/// dependencies as `OfflineUnavailable`; this is not evidence of zero +/// vulnerabilities. +/// Unreadable source files are skipped during reachability scanning. +/// +/// # Errors +/// Returns an error if no supported lockfile is found or any discovered +/// lockfile fails to parse. Propagates OSV request, response-read and JSON errors. pub fn triage(project_dir: &Path, offline: bool) -> anyhow::Result { triage_with_discovery(project_dir, offline, None) } /// Run triage using explicitly named lockfiles instead of discovery. +/// +/// Paths are used as supplied, relative to the process working directory when +/// relative, rather than resolved against `project_dir`. +/// +/// # Errors +/// Propagates [`lockfile::parse_lockfiles`] errors. Currently also rejects +/// successfully parsed input as having no lockfiles, because the returned +/// discovery has an empty `lockfiles` list. pub fn triage_with_lockfiles( project_dir: &Path, offline: bool, @@ -292,6 +313,12 @@ pub fn triage_with_lockfiles( triage_with_discovery(project_dir, offline, Some(discovery)) } +/// Assess `explicit` discovery, or discover recursively when it is `None`. +/// Uses the offline and report-status behaviour described by [`triage`]. +/// +/// # Errors +/// Rejects missing lockfiles or recorded parse failures and propagates OSV +/// request, response-read and JSON errors. fn triage_with_discovery( project_dir: &Path, offline: bool, From 21494bcf22bea81d79e3233dd095a7b229f57e94 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Wed, 28 Oct 2026 23:48:04 +0000 Subject: [PATCH 3/4] fix: validate lockfiles, resolve nested Cargo dependencies, and distinguish PA011 DOM sinks from eval --- Cargo.lock | 1 + Cargo.toml | 3 +- data/canonical-rule-ids.json | 16 +- src/bridge/lockfile.rs | 470 +++++++++++++++++++++++++---------- src/bridge/mod.rs | 170 ++++++++++++- tests/pattern_tests.rs | 80 ++++++ 6 files changed, 591 insertions(+), 149 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 99f7a16..3c51b81 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3066,6 +3066,7 @@ dependencies = [ "serde_json", "sha2", "tempfile", + "toml_edit", "tree-sitter", "tree-sitter-php", "ureq", diff --git a/Cargo.toml b/Cargo.toml index a7855bc..a412e58 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -42,6 +42,7 @@ sha2 = "0.11" hex = "0.4" getrandom = "0.4" walkdir = "2.5" +toml_edit = { version = "0.23", default-features = false, features = ["parse"], optional = true } dirs = "7.0" ed25519-dalek = { version = "3.0", optional = true } ureq = { version = "3.3", optional = true } @@ -49,7 +50,7 @@ ureq = { version = "3.3", optional = true } [features] default = [] signing = ["ed25519-dalek"] -http = ["ureq"] +http = ["ureq", "dep:toml_edit"] sarif = [] # Native GUI viewer (eframe/egui). Opt-in because eframe raises MSRV above the # 1.90 baseline; default builds remain pure-CLI and MSRV-clean. diff --git a/data/canonical-rule-ids.json b/data/canonical-rule-ids.json index 99c3ccd..00e7506 100644 --- a/data/canonical-rule-ids.json +++ b/data/canonical-rule-ids.json @@ -10,7 +10,7 @@ "PA001", "PA001b" ], - "note": "Preserve the canonical rule ID for audit/report display; group findings by semantic_class for cross-tool verdict-grid comparison. CWE associations are supporting metadata, not a unique deduplication key." + "note": "Preserve the canonical rule ID for audit/report display; group findings by semantic_class for cross-tool verdict-grid comparison. Apply the first matching semantic_class_overrides entry (description_prefix matches the generated finding description) and use its semantic_class and cwe_ids before falling back to the rule metadata; this keeps PA011 DOM sinks separate from eval input. CWE associations are supporting metadata, not a unique deduplication key." }, "rules": [ { @@ -117,7 +117,19 @@ "cwe_ids": [ "CWE-95" ], - "semantic_class": "improper-neutralization-of-eval-input" + "semantic_class": "improper-neutralization-of-eval-input", + "semantic_class_overrides": [ + { + "description_prefix": "DOM manipulation (innerHTML/document.write) in ", + "semantic_class": "improper-neutralization-of-html-input", + "cwe_ids": ["CWE-79"] + }, + { + "description_prefix": "dangerouslySetInnerHTML (XSS risk) in ", + "semantic_class": "improper-neutralization-of-html-input", + "cwe_ids": ["CWE-79"] + } + ] }, { "rule_id": "PA012", diff --git a/src/bridge/lockfile.rs b/src/bridge/lockfile.rs index 00eec33..28ad9f0 100644 --- a/src/bridge/lockfile.rs +++ b/src/bridge/lockfile.rs @@ -120,23 +120,24 @@ impl LockfileDiscovery { /// /// Successful parses are merged into one de-duplicated list. Parse errors are /// logged as warnings and recorded in [`LockfileDiscovery::unreadable`] so one -/// malformed lockfile does not abort triage of the whole project. -pub fn discover_and_parse(dir: &Path) -> LockfileDiscovery { +/// malformed lockfile does not abort discovery. Directory traversal errors are +/// returned to the caller because an incomplete walk cannot establish coverage. +pub fn discover_and_parse(dir: &Path) -> Result { discover_from(dir, true) } /// As [`discover_and_parse`], but `dir` alone is searched — the pre-0.3 /// behaviour. Kept for callers that must not pick up a nested sub-project. -pub fn discover_and_parse_root_only(dir: &Path) -> LockfileDiscovery { +pub fn discover_and_parse_root_only(dir: &Path) -> Result { discover_from(dir, false) } -fn discover_from(dir: &Path, recursive: bool) -> LockfileDiscovery { - parse_found(lockfile_paths(dir, recursive)) +fn discover_from(dir: &Path, recursive: bool) -> Result { + Ok(parse_found(lockfile_paths(dir, recursive)?)) } /// Paths of the lockfiles under `dir`, sorted and de-duplicated. -pub fn lockfile_paths(dir: &Path, recursive: bool) -> Vec { +pub fn lockfile_paths(dir: &Path, recursive: bool) -> Result> { let names: Vec<&str> = LOCKFILE_KINDS.iter().map(|(name, _)| *name).collect(); let mut found: Vec = Vec::new(); @@ -144,8 +145,8 @@ pub fn lockfile_paths(dir: &Path, recursive: bool) -> Vec { for entry in WalkDir::new(dir) .into_iter() .filter_entry(|e| e.depth() == 0 || !is_skipped_dir(e.path())) - .filter_map(|e| e.ok()) { + let entry = entry.with_context(|| format!("Failed to traverse {}", dir.display()))?; if !entry.file_type().is_file() { continue; } @@ -162,15 +163,21 @@ pub fn lockfile_paths(dir: &Path, recursive: bool) -> Vec { } else { for name in names { let path = dir.join(name); - if path.is_file() { - found.push(path); + match std::fs::metadata(&path) { + Ok(metadata) if metadata.is_file() => found.push(path), + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(error) + .with_context(|| format!("Failed to inspect {}", path.display())) + } } } } found.sort(); found.dedup(); - found + Ok(found) } /// Parse exactly these lockfiles, with no searching. @@ -214,9 +221,8 @@ fn parse_found(paths: Vec) -> LockfileDiscovery { discovery.lockfiles.push(path.clone()); match parse_one(&path) { Ok(Some(deps)) => discovery.push(&path, deps), - // Discovery only collects recognised names, so `None` here means - // the file vanished between the walk and the read. Skip it. - Ok(None) => {} + // Do not count a discovered file as valid if no parser handled it. + Ok(None) => discovery.unreadable.push(path), Err(e) => { log::warn!("[bridge] Failed to parse {}: {}", path.display(), e); discovery.unreadable.push(path); @@ -229,9 +235,39 @@ fn parse_found(paths: Vec) -> LockfileDiscovery { } impl LockfileDiscovery { + /// Build Cargo reachability context using the root manifest and each + /// discovered Cargo.lock's neighbouring manifest/workspace. Parent graphs + /// use their own direct dependencies so independent workspaces do not + /// accidentally create dependency paths across lockfiles. + pub(super) fn cargo_context( + &self, + project_dir: &Path, + ) -> ( + std::collections::HashSet, + std::collections::HashMap, + ) { + let mut direct = collect_direct_cargo_dependencies(project_dir); + let mut parents = std::collections::HashMap::new(); + for path in &self.lockfiles { + if path.file_name().is_none_or(|name| name != "Cargo.lock") { + continue; + } + let Some(dir) = path.parent() else { + continue; + }; + let local_direct = collect_direct_cargo_dependencies(dir); + for (child, parent) in collect_cargo_parents(dir, &local_direct) { + parents.entry(child).or_insert(parent); + } + direct.extend(local_direct); + } + (direct, parents) + } + /// Record one parsed lockfile's dependencies, each tagged with the lockfile /// it came from. fn push(&mut self, lockfile: &Path, deps: Vec) { + self.lockfiles.push(lockfile.to_path_buf()); for mut dep in deps { dep.declared_by = vec![lockfile.to_path_buf()]; self.dependencies.push(dep); @@ -247,11 +283,7 @@ impl LockfileDiscovery { let mut merged: Vec = Vec::with_capacity(self.dependencies.len()); for dep in self.dependencies.drain(..) { - let key = ( - dep.ecosystem.clone(), - dep.name.clone(), - dep.version.clone(), - ); + let key = (dep.ecosystem.clone(), dep.name.clone(), dep.version.clone()); match seen.get(&key) { Some(&at) => { for path in dep.declared_by { @@ -309,72 +341,51 @@ pub fn parse_cargo_lock(path: &Path) -> Result> { let content = std::fs::read_to_string(path) .with_context(|| format!("Failed to read {}", path.display()))?; - let has_supported_version = content.lines().any(|line| { - matches!(line.trim(), "version = 3" | "version = 4") - }); - if !has_supported_version { - anyhow::bail!("{} is not a supported Cargo.lock (expected version = 3 or 4)", path.display()); + let document: toml_edit::DocumentMut = content + .parse() + .with_context(|| format!("Parsing {}", path.display()))?; + if !matches!( + document.get("version").and_then(|v| v.as_integer()), + Some(3 | 4) + ) { + anyhow::bail!( + "{} is not a supported Cargo.lock (expected version = 3 or 4)", + path.display() + ); } - let mut deps = Vec::new(); - let mut current_name: Option = None; - let mut current_version: Option = None; - let mut current_source: Option = None; - let mut package_seen = false; - - for line in content.lines() { - let trimmed = line.trim(); - - if trimmed == "[[package]]" { - if package_seen { - let name = current_name.take().ok_or_else(|| { - anyhow::anyhow!("{} contains a package without a name", path.display()) - })?; - let version = current_version.take().ok_or_else(|| { - anyhow::anyhow!("{} contains a package without a version", path.display()) - })?; - if current_source - .as_ref() - .is_some_and(|s| s.contains("registry")) - { - deps.push(LockedDependency { - name, - version, - ecosystem: "crates.io".to_string(), - declared_by: Vec::new(), - }); - } - } - package_seen = true; - current_name = None; - current_version = None; - current_source = None; - continue; + let Some(packages) = document.get("package") else { + // The supported version header alone describes a valid empty lockfile. + if document.iter().any(|(key, _)| key != "version") { + anyhow::bail!("{} has no package array", path.display()); } - - if let Some(rest) = trimmed.strip_prefix("name = ") { - current_name = Some(unquote(rest)); - } else if let Some(rest) = trimmed.strip_prefix("version = ") { - current_version = Some(unquote(rest)); - } else if let Some(rest) = trimmed.strip_prefix("source = ") { - current_source = Some(unquote(rest)); - } - } - - if package_seen { - let name = current_name.ok_or_else(|| { - anyhow::anyhow!("{} contains a package without a name", path.display()) - })?; - let version = current_version.ok_or_else(|| { - anyhow::anyhow!("{} contains a package without a version", path.display()) - })?; - if current_source - .as_ref() - .is_some_and(|s| s.contains("registry")) - { + return Ok(deps); + }; + let packages = packages + .as_array_of_tables() + .with_context(|| format!("{} has an invalid package array", path.display()))?; + for package in packages { + let name = package + .get("name") + .and_then(|v| v.as_str()) + .filter(|v| !v.is_empty()) + .with_context(|| format!("{} contains a package without a name", path.display()))?; + let version = package + .get("version") + .and_then(|v| v.as_str()) + .filter(|v| !v.is_empty()) + .with_context(|| format!("{} contains a package without a version", path.display()))?; + let source = + match package.get("source") { + Some(source) => Some(source.as_str().with_context(|| { + format!("{} has an invalid package source", path.display()) + })?), + None => None, + }; + if source.is_some_and(|s| s.contains("registry")) { deps.push(LockedDependency { - name, - version, + name: name.to_string(), + version: version.to_string(), ecosystem: "crates.io".to_string(), declared_by: Vec::new(), }); @@ -400,26 +411,77 @@ pub fn parse_mix_lock(path: &Path) -> Result> { let content = std::fs::read_to_string(path) .with_context(|| format!("Failed to read {}", path.display()))?; + let body = content + .lines() + .filter(|line| !line.trim_start().starts_with('#')) + .collect::>() + .join("\n"); + let body = body + .trim() + .strip_prefix("%{") + .and_then(|s| s.trim_end().strip_suffix('}')) + .with_context(|| format!("{} is not a supported mix.lock map", path.display()))?; let mut deps = Vec::new(); - - for line in content.lines() { - let trimmed = line.trim(); - - // Skip blank lines, comment, and the map delimiters - if trimmed.is_empty() || trimmed.starts_with('#') || trimmed == "%{" || trimmed == "}" { + for line in body.lines() { + let line = line.trim(); + if line.is_empty() { continue; } - - // Match lines like: "cowboy": {:hex, :cowboy, "2.10.0", ...} - // We need the outer key (package name) and the version string. - if let Some(dep) = parse_mix_lock_line(trimmed) { - deps.push(dep); + let entry = line.trim_end_matches(',').trim(); + let (key, value) = entry + .split_once(':') + .with_context(|| format!("{} contains an invalid mix.lock entry", path.display()))?; + let quoted_key = key.trim(); + let value = value.trim(); + if quoted_key.len() < 3 + || !quoted_key.starts_with('"') + || !quoted_key.ends_with('"') + || !balanced_mix_tuple(value) + { + anyhow::bail!("{} contains an invalid mix.lock entry", path.display()); + } + if value.starts_with("{:hex,") { + deps.push(parse_mix_lock_line(entry).with_context(|| { + format!("{} contains an invalid Hex dependency", path.display()) + })?); + } else if !value.starts_with("{:git,") && !value.starts_with("{:path,") { + anyhow::bail!("{} contains an unsupported mix.lock entry", path.display()); } } Ok(deps) } +// Validate nested tuple/list delimiters without evaluating Elixir input. +fn balanced_mix_tuple(value: &str) -> bool { + if !value.starts_with('{') || !value.ends_with('}') { + return false; + } + let mut stack = Vec::new(); + let mut quoted = false; + let mut escaped = false; + for ch in value.chars() { + if quoted { + if escaped { + escaped = false; + } else if ch == '\\' { + escaped = true; + } else if ch == '"' { + quoted = false; + } + continue; + } + match ch { + '"' => quoted = true, + '{' => stack.push('}'), + '[' => stack.push(']'), + '}' | ']' if stack.pop() != Some(ch) => return false, + _ => {} + } + } + !quoted && stack.is_empty() +} + /// Parse a single mix.lock entry line into a LockedDependency. /// /// Returns None for git/path/non-hex entries. @@ -447,7 +509,11 @@ fn parse_mix_lock_line(line: &str) -> Option { return None; } - let version = fields[2].trim().trim_matches('"').to_string(); + let version = fields[2] + .trim() + .strip_prefix('"')? + .strip_suffix('"')? + .to_string(); if version.is_empty() || version.starts_with(':') { return None; } @@ -468,7 +534,7 @@ fn parse_mix_lock_line(line: &str) -> Option { /// /// npm lockfile v2/v3 uses a flat `packages` map where each key is /// `node_modules/` and the value has a `version` field. We skip the -/// root entry (empty key or no version) and dev-only packages. +/// root entry (empty key) and workspace links. Entries must declare versions. pub fn parse_package_lock_json(path: &Path) -> Result> { let content = std::fs::read_to_string(path) .with_context(|| format!("Failed to read {}", path.display()))?; @@ -476,50 +542,92 @@ pub fn parse_package_lock_json(path: &Path) -> Result> { let root: serde_json::Value = serde_json::from_str(&content).with_context(|| "Parsing package-lock.json")?; + let version = root + .get("lockfileVersion") + .and_then(|v| v.as_u64()) + .with_context(|| format!("{} has no lockfileVersion", path.display()))?; let mut deps = Vec::new(); - - // v2/v3 lockfile: flat `packages` object - if let Some(packages) = root.get("packages").and_then(|v| v.as_object()) { - for (key, pkg) in packages { - // Skip root entry and entries without a version - if key.is_empty() || !key.starts_with("node_modules/") { - continue; - } - let version = match pkg.get("version").and_then(|v| v.as_str()) { - Some(v) => v.to_string(), - None => continue, - }; - // Extract package name from node_modules/ key - let name = key.trim_start_matches("node_modules/").to_string(); - if name.is_empty() { - continue; + match version { + 2 | 3 => { + let packages = root + .get("packages") + .and_then(|v| v.as_object()) + .with_context(|| format!("{} has no packages object", path.display()))?; + for (key, pkg) in packages { + if !pkg.is_object() { + anyhow::bail!("Invalid npm package entry: {key}"); + } + if key.is_empty() { + continue; + } + // Workspace targets live outside node_modules; links point to them. + if pkg.get("link").and_then(|v| v.as_bool()) == Some(true) { + continue; + } + let version = pkg + .get("version") + .and_then(|v| v.as_str()) + .filter(|v| !v.is_empty()) + .with_context(|| format!("npm package {key} has no version"))?; + if !key.starts_with("node_modules/") { + continue; + } + let name = key.trim_start_matches("node_modules/"); + if name.is_empty() { + anyhow::bail!("Invalid npm package path: {key}"); + } + deps.push(LockedDependency { + name: name.to_string(), + version: version.to_string(), + ecosystem: "npm".to_string(), + declared_by: Vec::new(), + }); } - deps.push(LockedDependency { - name, - version, - ecosystem: "npm".to_string(), - declared_by: Vec::new(), - }); } - } else if let Some(dependencies) = root.get("dependencies").and_then(|v| v.as_object()) { - // v1 lockfile: nested `dependencies` object - for (name, pkg) in dependencies { - let version = match pkg.get("version").and_then(|v| v.as_str()) { - Some(v) => v.to_string(), - None => continue, - }; - deps.push(LockedDependency { - name: name.clone(), - version, - ecosystem: "npm".to_string(), - declared_by: Vec::new(), - }); + 1 => { + // npm v1 omits `dependencies` when the project has none. + if let Some(dependencies) = root.get("dependencies") { + let dependencies = dependencies.as_object().with_context(|| { + format!("{} has an invalid dependencies object", path.display()) + })?; + collect_npm_v1_dependencies(dependencies, &mut deps)?; + } } + _ => anyhow::bail!("Unsupported npm lockfileVersion: {version}"), } Ok(deps) } +fn collect_npm_v1_dependencies( + packages: &serde_json::Map, + deps: &mut Vec, +) -> Result<()> { + for (name, pkg) in packages { + let version = pkg + .get("version") + .and_then(|v| v.as_str()) + .filter(|v| !v.is_empty()) + .with_context(|| format!("npm package {name} has no version"))?; + if name.is_empty() { + anyhow::bail!("Empty npm dependency name"); + } + deps.push(LockedDependency { + name: name.clone(), + version: version.to_string(), + ecosystem: "npm".to_string(), + declared_by: Vec::new(), + }); + if let Some(children) = pkg.get("dependencies") { + let children = children + .as_object() + .context("Invalid nested npm dependencies")?; + collect_npm_v1_dependencies(children, deps)?; + } + } + Ok(()) +} + // ============================================================================ // requirements.txt (Python / PyPI) // ============================================================================ @@ -530,11 +638,14 @@ pub fn parse_package_lock_json(path: &Path) -> Result> { /// and ignores git/URL requirements, comments, and environment markers. /// Only `==` pins produce reliable version-exact OSV queries; `>=`/`~=` /// entries are skipped because they represent ranges, not locked versions. +/// Non-empty files with no supported pins return an error rather than claiming +/// the project has no dependencies. pub fn parse_requirements_txt(path: &Path) -> Result> { let content = std::fs::read_to_string(path) .with_context(|| format!("Failed to read {}", path.display()))?; let mut deps = Vec::new(); + let mut has_unlocked_entries = false; for line in content.lines() { // Strip inline comments and whitespace @@ -545,7 +656,11 @@ pub fn parse_requirements_txt(path: &Path) -> Result> { line }; - if line.is_empty() || line.starts_with('-') || line.starts_with("git+") { + if line.is_empty() { + continue; + } + if line.starts_with('-') || line.starts_with("git+") { + has_unlocked_entries = true; continue; } @@ -568,7 +683,18 @@ pub fn parse_requirements_txt(path: &Path) -> Result> { .trim() .to_string(); - if !name.is_empty() && !version.is_empty() { + if name.is_empty() + || !name + .chars() + .all(|c| c.is_ascii_alphanumeric() || "-_.".contains(c)) + || version.is_empty() + || version.contains(['=', '<', '>', '~', '!', ' ']) + { + anyhow::bail!("{} contains an invalid requirement pin", path.display()); + } + if version.contains('*') { + has_unlocked_entries = true; + } else { deps.push(LockedDependency { name, version, @@ -576,8 +702,16 @@ pub fn parse_requirements_txt(path: &Path) -> Result> { declared_by: Vec::new(), }); } + } else { + has_unlocked_entries = true; } } + if deps.is_empty() && has_unlocked_entries { + anyhow::bail!( + "{} contains no supported exact requirement pins", + path.display() + ); + } Ok(deps) } @@ -1024,7 +1158,7 @@ sqlalchemy[asyncio]==2.0.0; python_version >= "3.8" ) .unwrap(); - let deps = discover_and_parse(dir.path()); + let deps = discover_and_parse(dir.path()).unwrap(); assert_eq!(deps.lockfiles.len(), 1); assert_eq!(deps.len(), 1); assert_eq!(deps.dependencies[0].name, "serde"); @@ -1037,14 +1171,14 @@ sqlalchemy[asyncio]==2.0.0; python_version >= "3.8" std::fs::create_dir_all(&nested).unwrap(); std::fs::write( nested.join("Cargo.lock"), - "[[package]]\nname = \"serde\"\nversion = \"1.0.0\"\nsource = \"registry+https://github.com/rust-lang/crates.io-index\"\n", + "version = 4\n[[package]]\nname = \"serde\"\nversion = \"1.0.0\"\nsource = \"registry+https://github.com/rust-lang/crates.io-index\"\n", ) .unwrap(); let ignored = dir.path().join("target/generated"); std::fs::create_dir_all(&ignored).unwrap(); std::fs::write(ignored.join("Cargo.lock"), "not a lockfile").unwrap(); - let found = discover_and_parse(dir.path()); + let found = discover_and_parse(dir.path()).unwrap(); assert_eq!(found.lockfiles.len(), 1); assert!(found.lockfiles[0].ends_with("src/rust/Cargo.lock")); assert_eq!(found.dependencies.len(), 1); @@ -1055,17 +1189,83 @@ sqlalchemy[asyncio]==2.0.0; python_version >= "3.8" fn empty_valid_lockfile_is_distinct_from_missing_lockfile() { let dir = tempfile::TempDir::new().unwrap(); std::fs::write(dir.path().join("Cargo.lock"), "version = 4\n").unwrap(); - let found = discover_and_parse(dir.path()); + let found = discover_and_parse(dir.path()).unwrap(); assert!(found.has_lockfiles()); assert!(found.is_empty()); assert!(found.unreadable.is_empty()); let no_lock = tempfile::TempDir::new().unwrap(); - let missing = discover_and_parse(no_lock.path()); + let missing = discover_and_parse(no_lock.path()).unwrap(); assert!(!missing.has_lockfiles()); assert!(missing.is_empty()); } + #[test] + fn discovery_returns_traversal_errors() { + let dir = tempfile::TempDir::new().unwrap(); + let error = discover_and_parse(&dir.path().join("missing")).unwrap_err(); + assert!(error.to_string().contains("Failed to traverse")); + } + + #[test] + fn nested_cargo_context_finds_direct_and_transitive_dependencies() { + let dir = tempfile::TempDir::new().unwrap(); + let nested = dir.path().join("src/rust"); + std::fs::create_dir_all(&nested).unwrap(); + write_cargo_toml(&nested, "[dependencies]\nparent = \"1\"\n"); + std::fs::write( + nested.join("Cargo.lock"), + r#"version = 4 +[[package]] +name = "parent" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "child", +] +[[package]] +name = "child" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +"#, + ) + .unwrap(); + let discovery = discover_and_parse(dir.path()).unwrap(); + let (direct, parents) = discovery.cargo_context(dir.path()); + let declared = super::super::reachability::check_reachability_with_manifest( + dir.path(), + "parent", + &direct, + &parents, + ) + .unwrap(); + assert_eq!( + declared.status, + super::super::ReachabilityStatus::PhantomDeclared + ); + let transitive = super::super::reachability::check_reachability_with_manifest( + dir.path(), + "child", + &direct, + &parents, + ) + .unwrap(); + assert_eq!( + transitive.status, + super::super::ReachabilityStatus::PhantomTransitive + ); + assert_eq!(transitive.parent_dep.as_deref(), Some("parent")); + + // Explicit files can live outside project_dir; no recursive discovery + // should be needed to locate their neighbouring manifests/graphs. + let other = tempfile::TempDir::new().unwrap(); + let explicit = parse_lockfiles(&[nested.join("Cargo.lock")]).unwrap(); + let (explicit_direct, explicit_parents) = explicit.cargo_context(other.path()); + assert!(explicit.has_lockfiles()); + assert_eq!(explicit_direct, direct); + assert_eq!(explicit_parents, parents); + } + // ------------------------------------------------------------------ // Direct-dependency parser (regression for #47) // ------------------------------------------------------------------ diff --git a/src/bridge/mod.rs b/src/bridge/mod.rs index dd3a5b6..1beedc0 100644 --- a/src/bridge/mod.rs +++ b/src/bridge/mod.rs @@ -299,7 +299,10 @@ fn triage_with_discovery( ) -> anyhow::Result { // Distinguish no lockfile (unknown dependency surface) from a valid // lockfile containing zero external dependencies (a complete answer). - let discovery = explicit.unwrap_or_else(|| lockfile::discover_and_parse(project_dir)); + let discovery = match explicit { + Some(discovery) => discovery, + None => lockfile::discover_and_parse(project_dir)?, + }; if !discovery.has_lockfiles() { anyhow::bail!( "No supported lockfile found in {}. Supported: {}.", @@ -328,13 +331,13 @@ fn triage_with_discovery( report.vulnerability_data = VulnerabilityDataStatus::NoDependencies; return Ok(report); } - let deps = discovery.dependencies; + let deps = &discovery.dependencies; // Step 2: Query for vulnerabilities let vulns = if offline { Vec::new() // Offline mode: no local advisory DB is queried or available. } else { - intelligence::query_osv_batch(&deps)? + intelligence::query_osv_batch(deps)? }; if vulns.is_empty() { @@ -345,13 +348,10 @@ fn triage_with_discovery( return Ok(report); } - // Direct-vs-transitive lookup table for the project's Cargo.toml. + // Direct-vs-transitive lookup tables for discovered Cargo workspaces. // Collected once (not per-CVE) to avoid re-parsing the manifest for // every vulnerable dep — see #47. - let direct_deps = lockfile::collect_direct_cargo_dependencies(project_dir); - // Parent-dep map from Cargo.lock: for any transitive crate, identify - // which direct dep's closure pulls it in (best-effort, first match wins). - let parent_map = lockfile::collect_cargo_parents(project_dir, &direct_deps); + let (direct_deps, parent_map) = discovery.cargo_context(project_dir); // Step 3 & 4: For each vuln, check reachability and classify let mut assessed = Vec::new(); @@ -402,7 +402,10 @@ mod triage_tests { let report = triage(dir.path(), true)?; assert_eq!(report.total_dependencies, 0); assert!(report.cves.is_empty()); - assert_eq!(report.vulnerability_data, VulnerabilityDataStatus::NoDependencies); + assert_eq!( + report.vulnerability_data, + VulnerabilityDataStatus::NoDependencies + ); Ok(()) } @@ -419,13 +422,158 @@ mod triage_tests { let dir = tempfile::TempDir::new()?; std::fs::write( dir.path().join("Cargo.lock"), - "[[package]]\nname = \"serde\"\nversion = \"1.0.0\"\nsource = \"registry+https://github.com/rust-lang/crates.io-index\"\n", + "version = 4\n[[package]]\nname = \"serde\"\nversion = \"1.0.0\"\nsource = \"registry+https://github.com/rust-lang/crates.io-index\"\n", )?; let report = triage(dir.path(), true)?; assert_eq!(report.total_dependencies, 1); assert!(report.cves.is_empty()); - assert_eq!(report.vulnerability_data, VulnerabilityDataStatus::OfflineUnavailable); + assert_eq!( + report.vulnerability_data, + VulnerabilityDataStatus::OfflineUnavailable + ); + Ok(()) + } + #[test] + fn structurally_valid_empty_lockfiles_report_no_dependencies() -> anyhow::Result<()> { + for (name, content) in [ + ("Cargo.lock", "version = 3\n"), + ("mix.lock", "%{}\n"), + ( + "package-lock.json", + r#"{"name":"empty","lockfileVersion":1}"#, + ), + ( + "package-lock.json", + r#"{"lockfileVersion":1,"dependencies":{}}"#, + ), + ( + "package-lock.json", + r#"{"lockfileVersion":2,"packages":{}}"#, + ), + ( + "package-lock.json", + r#"{"lockfileVersion":3,"packages":{"":{"name":"demo","version":"1.0.0"}}}"#, + ), + ("requirements.txt", "# No external dependencies\n\n"), + ] { + let dir = tempfile::TempDir::new()?; + let path = dir.path().join(name); + std::fs::write(&path, content)?; + for report in [ + triage(dir.path(), true)?, + triage_with_lockfiles(dir.path(), true, &[path])?, + ] { + assert_eq!( + report.vulnerability_data, + VulnerabilityDataStatus::NoDependencies, + "{name}: {content}" + ); + } + } + Ok(()) + } + + #[test] + fn malformed_or_unsupported_lockfiles_never_report_no_dependencies() -> anyhow::Result<()> { + for (name, content) in [ + ("Cargo.lock", ""), + ("Cargo.lock", "version = 4\nnot toml"), + ("Cargo.lock", "version = 4\npackage = 1"), + ("Cargo.lock", "version = 4\n[[package]]\nname = \"broken\""), + ("Cargo.lock", "[[package]]\nname = \"broken\"\nversion = 4"), + ("Cargo.lock", "version = 4\n[packages]"), + ("mix.lock", ""), + ("mix.lock", "%{\ninvalid entry\n}"), + ("mix.lock", "%{\n\"broken\": {:hex, :broken, \"1.0\"\n}"), + ("mix.lock", "%{\n\"broken\": {:hex, :broken, 1.0},\n}"), + ("package-lock.json", "{}"), + ("package-lock.json", "[]"), + ("package-lock.json", "null"), + ( + "package-lock.json", + r#"{"lockfileVersion":99,"packages":{}}"#, + ), + ( + "package-lock.json", + r#"{"lockfileVersion":3,"packages":[]}"#, + ), + ("package-lock.json", r#"{"lockfileVersion":3}"#), + ( + "package-lock.json", + r#"{"lockfileVersion":3,"packages":{"node_modules/broken":{}}}"#, + ), + ( + "package-lock.json", + r#"{"lockfileVersion":1,"dependencies":{"broken":{"version":42}}}"#, + ), + ("requirements.txt", "not a requirement"), + ("requirements.txt", "requests>=2.0"), + ("requirements.txt", "-r other.txt"), + ("requirements.txt", "requests=="), + ("requirements.txt", "==1.0"), + ("requirements.txt", "requests===1.0"), + ] { + let dir = tempfile::TempDir::new()?; + let path = dir.path().join(name); + std::fs::write(&path, content)?; + let error = triage(dir.path(), true).expect_err(content); + assert!( + error.to_string().contains("Could not parse"), + "{name}: {content}: {error}" + ); + assert!( + triage_with_lockfiles(dir.path(), true, &[path]).is_err(), + "{name}: {content}" + ); + } + Ok(()) + } + + #[test] + fn explicit_lockfile_does_not_trigger_recursive_discovery() -> anyhow::Result<()> { + let project = tempfile::TempDir::new()?; + std::fs::write(project.path().join("Cargo.lock"), "broken")?; + let external = tempfile::TempDir::new()?; + let path = external.path().join("requirements.txt"); + std::fs::write(&path, "requests==2.0.0\n")?; + let report = triage_with_lockfiles(project.path(), true, &[path])?; + assert_eq!(report.total_dependencies, 1); + assert_eq!( + report.vulnerability_data, + VulnerabilityDataStatus::OfflineUnavailable + ); + Ok(()) + } + + #[test] + fn missing_project_directory_is_a_traversal_error() -> anyhow::Result<()> { + let dir = tempfile::TempDir::new()?; + let error = triage(&dir.path().join("missing"), true).unwrap_err(); + assert!(error.to_string().contains("Failed to traverse")); + Ok(()) + } + + #[cfg(unix)] + #[test] + fn unreadable_subdirectory_prevents_no_dependencies_report() -> anyhow::Result<()> { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::TempDir::new()?; + std::fs::write(dir.path().join("Cargo.lock"), "version = 4\n")?; + let blocked = dir.path().join("nested"); + std::fs::create_dir(&blocked)?; + std::fs::write(blocked.join("requirements.txt"), "requests==2.0.0\n")?; + std::fs::set_permissions(&blocked, std::fs::Permissions::from_mode(0o000))?; + // Root can bypass mode bits; exercise this case when the OS enforces them. + let enforced = std::fs::read_dir(&blocked).is_err(); + let result = triage(dir.path(), true); + std::fs::set_permissions(&blocked, std::fs::Permissions::from_mode(0o700))?; + if enforced { + assert!(result + .unwrap_err() + .to_string() + .contains("Failed to traverse")); + } Ok(()) } } diff --git a/tests/pattern_tests.rs b/tests/pattern_tests.rs index 2ac28dd..6b56ee0 100644 --- a/tests/pattern_tests.rs +++ b/tests/pattern_tests.rs @@ -322,3 +322,83 @@ function App() { ); Ok(()) } + +#[test] +fn pa011_semantic_classes_separate_eval_from_dom_sinks() -> Result<(), Box> { + let dir = TempDir::new()?; + let file = write_test_file( + &dir, + "sinks.js", + r#" +eval(userInput); +element.innerHTML = userInput; +document.write(userInput); +const component =
; +"#, + )?; + let report = assail::analyze(&file)?; + let table: serde_json::Value = + serde_json::from_str(include_str!("../data/canonical-rule-ids.json"))?; + let rule = table["rules"] + .as_array() + .ok_or("missing canonical rules array")? + .iter() + .find(|rule| rule["rule_id"] == "PA011") + .ok_or("missing PA011 rule")?; + let mut classes = Vec::new(); + for finding in report + .weak_points + .iter() + .filter(|wp| wp.category == WeakPointCategory::DynamicCodeExecution) + { + assert_eq!(finding.category.rule_id(), "PA011"); + let mut selected = rule; + for entry in rule["semantic_class_overrides"] + .as_array() + .ok_or("missing PA011 overrides")? + { + if finding.description.starts_with( + entry["description_prefix"] + .as_str() + .ok_or("missing description prefix")?, + ) { + selected = entry; + break; + } + } + classes.push( + selected["semantic_class"] + .as_str() + .ok_or("missing semantic class")?, + ); + if finding.description.starts_with("eval() usage") { + assert_eq!( + selected["semantic_class"], + "improper-neutralization-of-eval-input" + ); + assert_eq!(selected["cwe_ids"], serde_json::json!(["CWE-95"])); + } else { + assert_eq!( + selected["semantic_class"], + "improper-neutralization-of-html-input" + ); + assert_eq!(selected["cwe_ids"], serde_json::json!(["CWE-79"])); + } + } + assert_eq!(classes.len(), 3); + assert_eq!( + classes + .iter() + .filter(|c| **c == "improper-neutralization-of-eval-input") + .count(), + 1 + ); + assert_eq!( + classes + .iter() + .filter(|c| **c == "improper-neutralization-of-html-input") + .count(), + 2 + ); + Ok(()) +} From a6dd08f98977b391837e97f6ba858723cfe38a5e Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Wed, 28 Oct 2026 23:49:20 +0000 Subject: [PATCH 4/4] test: add required Cargo.lock versions to bridge fixtures and apply Rust formatting --- src/bridge/lockfile.rs | 19 +++++++++---------- src/bridge/mod.rs | 12 +++++++++--- src/types.rs | 4 +++- 3 files changed, 21 insertions(+), 14 deletions(-) diff --git a/src/bridge/lockfile.rs b/src/bridge/lockfile.rs index 622b676..c2b9c63 100644 --- a/src/bridge/lockfile.rs +++ b/src/bridge/lockfile.rs @@ -264,11 +264,7 @@ impl LockfileDiscovery { let mut merged: Vec = Vec::with_capacity(self.dependencies.len()); for dep in self.dependencies.drain(..) { - let key = ( - dep.ecosystem.clone(), - dep.name.clone(), - dep.version.clone(), - ); + let key = (dep.ecosystem.clone(), dep.name.clone(), dep.version.clone()); match seen.get(&key) { Some(&at) => { for path in dep.declared_by { @@ -334,11 +330,14 @@ pub fn parse_cargo_lock(path: &Path) -> Result> { let content = std::fs::read_to_string(path) .with_context(|| format!("Failed to read {}", path.display()))?; - let has_supported_version = content.lines().any(|line| { - matches!(line.trim(), "version = 3" | "version = 4") - }); + let has_supported_version = content + .lines() + .any(|line| matches!(line.trim(), "version = 3" | "version = 4")); if !has_supported_version { - anyhow::bail!("{} is not a supported Cargo.lock (expected version = 3 or 4)", path.display()); + anyhow::bail!( + "{} is not a supported Cargo.lock (expected version = 3 or 4)", + path.display() + ); } let mut deps = Vec::new(); @@ -1062,7 +1061,7 @@ sqlalchemy[asyncio]==2.0.0; python_version >= "3.8" std::fs::create_dir_all(&nested).unwrap(); std::fs::write( nested.join("Cargo.lock"), - "[[package]]\nname = \"serde\"\nversion = \"1.0.0\"\nsource = \"registry+https://github.com/rust-lang/crates.io-index\"\n", + "version = 4\n[[package]]\nname = \"serde\"\nversion = \"1.0.0\"\nsource = \"registry+https://github.com/rust-lang/crates.io-index\"\n", ) .unwrap(); let ignored = dir.path().join("target/generated"); diff --git a/src/bridge/mod.rs b/src/bridge/mod.rs index 2c29b17..d337dd9 100644 --- a/src/bridge/mod.rs +++ b/src/bridge/mod.rs @@ -429,7 +429,10 @@ mod triage_tests { let report = triage(dir.path(), true)?; assert_eq!(report.total_dependencies, 0); assert!(report.cves.is_empty()); - assert_eq!(report.vulnerability_data, VulnerabilityDataStatus::NoDependencies); + assert_eq!( + report.vulnerability_data, + VulnerabilityDataStatus::NoDependencies + ); Ok(()) } @@ -446,13 +449,16 @@ mod triage_tests { let dir = tempfile::TempDir::new()?; std::fs::write( dir.path().join("Cargo.lock"), - "[[package]]\nname = \"serde\"\nversion = \"1.0.0\"\nsource = \"registry+https://github.com/rust-lang/crates.io-index\"\n", + "version = 4\n[[package]]\nname = \"serde\"\nversion = \"1.0.0\"\nsource = \"registry+https://github.com/rust-lang/crates.io-index\"\n", )?; let report = triage(dir.path(), true)?; assert_eq!(report.total_dependencies, 1); assert!(report.cves.is_empty()); - assert_eq!(report.vulnerability_data, VulnerabilityDataStatus::OfflineUnavailable); + assert_eq!( + report.vulnerability_data, + VulnerabilityDataStatus::OfflineUnavailable + ); Ok(()) } } diff --git a/src/types.rs b/src/types.rs index 1d95bd4..1c7b7ea 100644 --- a/src/types.rs +++ b/src/types.rs @@ -1146,7 +1146,9 @@ mod rule_id_tests { assert_eq!(row["rule_id"].as_str(), Some(category.rule_id())); let category_name = format!("{category:?}"); assert_eq!(row["category"].as_str(), Some(category_name.as_str())); - assert!(row["semantic_class"].as_str().is_some_and(|s| !s.is_empty())); + assert!(row["semantic_class"] + .as_str() + .is_some_and(|s| !s.is_empty())); } Ok(()) }