diff --git a/src/ast.rs b/src/ast.rs index 1f1cdf40..7e1e791b 100644 --- a/src/ast.rs +++ b/src/ast.rs @@ -2008,13 +2008,14 @@ impl<'a> ParsedFile<'a> { }) }) .unwrap_or(0); + let cf_category = FindingCategory::for_structural_rule(&cf.rule_id); out.push(Diag { path: self.source.path.to_string_lossy().into_owned(), line: point.row + 1, col: point.column + 1, severity: cf.severity, id: cf.rule_id, - category: FindingCategory::Security, + category: cf_category, path_validated: false, guard_kind: None, message: Some(cf.message), @@ -2091,7 +2092,7 @@ impl<'a> ParsedFile<'a> { col: point.column + 1, severity: sf.severity, id: sf.rule_id.clone(), - category: FindingCategory::Security, + category: FindingCategory::for_structural_rule(&sf.rule_id), path_validated: false, guard_kind: None, message: Some(sf.message.clone()), @@ -2364,7 +2365,10 @@ pub fn perf_stage_breakdown_fused( TaintSuppressionCtx::build(&parsed.file_cfg, &parsed.source.tree, &taint_diags); let _filtered: Vec<_> = ast_findings .into_iter() - .filter(|d| !suppression.should_suppress(&d.id, d.line)) + .filter(|d| { + !suppression.should_suppress(&d.id, d.line) + && !suppression.is_redundant_ast_pattern(&d.id, d.line) + }) .collect(); let t_suppr = s_suppr.elapsed().as_micros(); @@ -5480,6 +5484,14 @@ struct TaintSuppressionCtx { /// 11 inline analysis but the sink's enclosing scope has no /// labelled Sanitizer of its own. interproc_sanitizer_callers: HashSet>, + /// Union of resolved sink-cap bits across every taint / structural + /// flow finding (`taint-*`, `cfg-unguarded-sink`) at each line. Used + /// by [`Self::is_redundant_ast_pattern`] to drop an AST-pattern finding + /// that merely restates a flow the taint engine already reported at the + /// same line with the same cap — the flow finding carries strictly more + /// evidence (source, path, sanitizer state), so keeping the bare pattern + /// alongside it is pure duplicate noise. + taint_finding_caps_by_line: HashMap, } impl TaintSuppressionCtx { @@ -5678,6 +5690,20 @@ impl TaintSuppressionCtx { .map(|d| d.line) .collect(); + // Cap bits per line for every flow-backed finding (taint-* and the + // structural unguarded-sink finding), so a redundant AST pattern at + // the same line+cap can be dropped in favour of the richer flow. + let mut taint_finding_caps_by_line: HashMap = HashMap::new(); + for d in taint_diags { + if d.id.starts_with("taint-") || d.id == "cfg-unguarded-sink" { + if let Some(caps) = d.evidence.as_ref().map(|e| e.sink_caps) { + if caps != 0 { + *taint_finding_caps_by_line.entry(d.line).or_default() |= caps; + } + } + } + } + // Per-function partition of taint findings. Maps each finding's // line to the enclosing function scope by reusing // `sink_func_at_line` (the same span/function mapping the Sink-side @@ -5701,9 +5727,30 @@ impl TaintSuppressionCtx { engine_validated_funcs, source_killed_funcs, interproc_sanitizer_callers, + taint_finding_caps_by_line, } } + /// Returns `true` when an AST pattern finding is a redundant restatement + /// of a flow the taint engine already reported at the same line. + /// + /// The taint / structural flow finding carries source + path evidence the + /// bare pattern lacks, so when both fire at the same line for the same + /// cap the pattern is pure duplicate noise. This is the + /// taint-found-it-UNSAFE counterpart to [`Self::should_suppress`]'s + /// taint-found-it-SAFE logic: there, no flow finding means the pattern + /// may carry unique signal; here, a same-cap flow finding means it does + /// not. Cap-matched (not line-only) so a pattern whose cap differs from + /// the co-located flow's cap — a genuinely distinct sink — is preserved. + fn is_redundant_ast_pattern(&self, pattern_id: &str, line: usize) -> bool { + let Some(cap) = pattern_category_cap(pattern_id) else { + return false; + }; + self.taint_finding_caps_by_line + .get(&line) + .is_some_and(|caps| caps & cap.bits() != 0) + } + /// Returns `true` if this AST pattern finding should be suppressed. fn should_suppress(&self, pattern_id: &str, line: usize) -> bool { // Condition 1: pattern category maps to a Cap taint models @@ -5832,11 +5879,10 @@ pub fn run_rules_on_bytes( let suppression = TaintSuppressionCtx::build(&parsed.file_cfg, &parsed.source.tree, &out); let ast_findings = parsed.source.run_ast_queries(cfg); - out.extend( - ast_findings - .into_iter() - .filter(|d| !suppression.should_suppress(&d.id, d.line)), - ); + out.extend(ast_findings.into_iter().filter(|d| { + !suppression.should_suppress(&d.id, d.line) + && !suppression.is_redundant_ast_pattern(&d.id, d.line) + })); } if cfg.scanner.mode == AnalysisMode::Full { out.extend(parsed.run_auth_analyses(cfg, global_summaries, scan_root)); @@ -6030,11 +6076,10 @@ pub fn analyse_file_fused( if needs_cfg && cfg.scanner.mode == AnalysisMode::Full { let suppression = TaintSuppressionCtx::build(&parsed.file_cfg, &parsed.source.tree, &out); - out.extend( - ast_findings - .into_iter() - .filter(|d| !suppression.should_suppress(&d.id, d.line)), - ); + out.extend(ast_findings.into_iter().filter(|d| { + !suppression.should_suppress(&d.id, d.line) + && !suppression.is_redundant_ast_pattern(&d.id, d.line) + })); } else { out.extend(ast_findings); } diff --git a/src/dynamic/build_sandbox.rs b/src/dynamic/build_sandbox.rs index 6d45ff75..5d80bebd 100644 --- a/src/dynamic/build_sandbox.rs +++ b/src/dynamic/build_sandbox.rs @@ -28,6 +28,8 @@ use crate::symbol::Lang; use blake3::Hasher; use directories::ProjectDirs; use std::collections::HashMap; +use std::fs::{File, OpenOptions}; +use std::io::{self, Write}; use std::path::{Path, PathBuf}; use std::process::Command; use std::sync::{Arc, Mutex, OnceLock}; @@ -50,6 +52,7 @@ use std::time::{Duration, Instant}; pub fn prepare_rust(spec: &HarnessSpec, workdir: &Path) -> Result { let lockfile_hash = compute_rust_lockfile_hash(workdir); let cache_path = build_cache_path(&lockfile_hash, "rust", &spec.toolchain_id)?; + let _cache_guard = acquire_cache_build_lock(&cache_path)?; // Cache hit: binary already compiled and stored. let binary = cache_path.join("nyx_harness"); @@ -250,9 +253,12 @@ impl From for BuildError { pub fn prepare_python(spec: &HarnessSpec, workdir: &Path) -> Result { let lockfile_hash = compute_lockfile_hash(workdir); let cache_path = build_cache_path(&lockfile_hash, "python", &spec.toolchain_id)?; + let _cache_guard = acquire_cache_build_lock(&cache_path)?; - // Check cache hit: venv exists and pyvenv.cfg is present. - if cache_path.join("pyvenv.cfg").exists() { + // Check cache hit under the inter-process cache lock. `pyvenv.cfg` can + // appear before `ensurepip` finishes, so only the Nyx completion marker + // means other nextest workers may consume this venv. + if python_cache_ready(&cache_path) { return Ok(BuildResult { venv_path: cache_path, cache_hit: true, @@ -271,8 +277,11 @@ pub fn prepare_python(spec: &HarnessSpec, workdir: &Path) -> Result { + std::fs::write(python_cache_done_path(&cache_path), b"done")?; return Ok(BuildResult { venv_path: cache_path, cache_hit: false, @@ -430,6 +439,87 @@ fn create_build_cache_dir(path: &Path) -> std::io::Result<()> { Ok(()) } +const PYTHON_CACHE_DONE: &str = ".python_cache_done"; + +fn python_cache_done_path(cache_path: &Path) -> PathBuf { + cache_path.join(PYTHON_CACHE_DONE) +} + +fn python_cache_ready(cache_path: &Path) -> bool { + python_cache_done_path(cache_path).exists() + && cache_path.join("pyvenv.cfg").exists() + && cache_path.join("bin").join("python").exists() +} + +struct CacheBuildLock { + _file: File, +} + +fn acquire_cache_build_lock(cache_path: &Path) -> io::Result { + let parent = cache_path.parent().ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + format!("cache path has no parent: {}", cache_path.display()), + ) + })?; + std::fs::create_dir_all(parent)?; + let name = cache_path + .file_name() + .and_then(|n| n.to_str()) + .ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + format!("cache path has no file name: {}", cache_path.display()), + ) + })?; + let lock_path = parent.join(format!(".{name}.lock")); + let mut file = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(&lock_path)?; + lock_file_exclusive(&file)?; + file.set_len(0)?; + writeln!( + file, + "pid={} cache={}", + std::process::id(), + cache_path.display() + )?; + Ok(CacheBuildLock { _file: file }) +} + +#[cfg(unix)] +fn lock_file_exclusive(file: &File) -> io::Result<()> { + use std::os::fd::AsRawFd; + + unsafe extern "C" { + fn flock(fd: i32, operation: i32) -> i32; + } + + const LOCK_EX: i32 = 2; + loop { + // SAFETY: `file.as_raw_fd()` is a live file descriptor owned by `file`. + // `flock(2)` only reads the scalar fd/operation arguments and the + // return value is checked. + let ret = unsafe { flock(file.as_raw_fd(), LOCK_EX) }; + if ret == 0 { + return Ok(()); + } + let err = io::Error::last_os_error(); + if err.kind() == io::ErrorKind::Interrupted { + continue; + } + return Err(err); + } +} + +#[cfg(not(unix))] +fn lock_file_exclusive(_file: &File) -> io::Result<()> { + Ok(()) +} + // ── Ruby build sandbox ─────────────────────────────────────────────────────── /// Prepare Ruby dependencies for `spec` in `workdir`. @@ -448,6 +538,10 @@ pub fn prepare_ruby(spec: &HarnessSpec, workdir: &Path) -> Result String { pub fn prepare_node(spec: &HarnessSpec, workdir: &Path) -> Result { let lockfile_hash = compute_node_lockfile_hash(workdir); let cache_path = build_cache_path(&lockfile_hash, "node", &spec.toolchain_id)?; + let _cache_guard = acquire_cache_build_lock(&cache_path)?; // Cache hit: node_modules already installed. Restore to fresh workdir if // a different finding shares the same cache key but got a new workdir. @@ -766,6 +861,7 @@ fn compute_node_lockfile_hash(workdir: &Path) -> String { pub fn prepare_go(spec: &HarnessSpec, workdir: &Path) -> Result { let lockfile_hash = compute_go_source_hash(workdir); let cache_path = build_cache_path(&lockfile_hash, "go", &spec.toolchain_id)?; + let _cache_guard = acquire_cache_build_lock(&cache_path)?; let binary = cache_path.join("nyx_harness"); if binary.exists() { @@ -969,6 +1065,10 @@ pub fn prepare_java(spec: &HarnessSpec, workdir: &Path) -> Result) -> Stri pub fn prepare_php(spec: &HarnessSpec, workdir: &Path) -> Result { let lockfile_hash = compute_php_lockfile_hash(workdir); let cache_path = build_cache_path(&lockfile_hash, "php", &spec.toolchain_id)?; + let _cache_guard = acquire_cache_build_lock(&cache_path)?; if cache_path.join(".php_cache_done").exists() { let cached_vendor = cache_path.join("vendor"); @@ -1476,6 +1577,7 @@ pub fn prepare_c( let static_link = static_link_for_profile(profile); let source_hash = compute_c_source_hash(workdir, static_link); let cache_path = build_cache_path(&source_hash, "c", &spec.toolchain_id)?; + let _cache_guard = acquire_cache_build_lock(&cache_path)?; let binary = cache_path.join("nyx_harness"); if binary.exists() { @@ -1646,6 +1748,7 @@ fn compute_c_source_hash(workdir: &Path, static_link: bool) -> String { pub fn prepare_cpp(spec: &HarnessSpec, workdir: &Path) -> Result { let source_hash = compute_cpp_source_hash(workdir); let cache_path = build_cache_path(&source_hash, "cpp", &spec.toolchain_id)?; + let _cache_guard = acquire_cache_build_lock(&cache_path)?; let binary = cache_path.join("nyx_harness"); if binary.exists() { @@ -2119,6 +2222,19 @@ mod tests { assert_ne!(h1, h2, "hash must change when requirements.txt changes"); } + #[test] + fn python_cache_ready_requires_completion_marker() { + let dir = tempfile::TempDir::new().unwrap(); + let cache = dir.path().join("venv"); + std::fs::create_dir_all(cache.join("bin")).unwrap(); + std::fs::write(cache.join("pyvenv.cfg"), "").unwrap(); + std::fs::write(cache.join("bin").join("python"), "").unwrap(); + + assert!(!python_cache_ready(&cache)); + std::fs::write(python_cache_done_path(&cache), b"done").unwrap(); + assert!(python_cache_ready(&cache)); + } + #[test] fn node_lockfile_hash_stable() { let dir = tempfile::TempDir::new().unwrap(); diff --git a/src/dynamic/sandbox/docker.rs b/src/dynamic/sandbox/docker.rs index cf723993..8ccbbe19 100644 --- a/src/dynamic/sandbox/docker.rs +++ b/src/dynamic/sandbox/docker.rs @@ -39,6 +39,11 @@ use super::{HostPort, NetworkPolicy}; /// through every layer. pub const WORK_MOUNT_PATH: &str = "/work"; +/// Writable temp directory inside the workdir mount. Runtime containers keep +/// the image root read-only, so language runtimes that honour TMPDIR should +/// spill under the declared harness workdir instead of `/tmp`. +pub const WORK_TMP_PATH: &str = "/work/.nyx-tmp"; + /// Container-side mount point root for `StubHarness` filesystem stubs. /// Each stub is mounted at `STUB_MOUNT_ROOT/` where `` is its index in /// the harness's stub list. diff --git a/src/dynamic/sandbox/mod.rs b/src/dynamic/sandbox/mod.rs index 7e359896..06bdf1fe 100644 --- a/src/dynamic/sandbox/mod.rs +++ b/src/dynamic/sandbox/mod.rs @@ -9,7 +9,8 @@ //! //! - **`docker`**: default when docker is available. Runs the harness inside //! a container with `--cap-drop=ALL`, `--security-opt -//! no-new-privileges:true`, and `--network none`. Containers are reused +//! no-new-privileges:true`, a read-only image root, and `--network none`. +//! The harness workdir is the only writable runtime mount. Containers are reused //! within a single spec_hash via `docker exec` to amortise image //! cold-start cost. //! - **`process`**: fallback for hosts without docker; gated behind @@ -838,6 +839,9 @@ fn harness_needs_host_deps(harness: &BuiltHarness) -> bool { "package.json", "Gemfile", "composer.json", + "pom.xml", + "build.gradle", + "build.gradle.kts", ]; MANIFESTS .iter() @@ -1037,6 +1041,39 @@ fn start_container( // against the same toolchain is free. docker::ensure_image_pulled(image); + prepare_container_tmp(workdir)?; + let run_args = build_container_run_args(name, workdir, image, policy, fs_stub_roots); + + let status = std::process::Command::new(docker_bin()) + .args(&run_args) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .map_err(SandboxError::Spawn)?; + + if !status.success() { + return Err(SandboxError::BackendUnavailable(SandboxBackend::Docker)); + } + + // Apply OOB egress filter on Linux when the OOB listener is active. + // This restricts the bridge-networked container to only reach the + // host on the OOB port; all other egress is dropped (§17.2). + #[cfg(target_os = "linux")] + if let NetworkPolicy::OobOutbound { listener } = policy { + apply_oob_egress_filter(name, listener.port()); + } + #[cfg(not(target_os = "linux"))] + let _ = policy; // policy already consumed structurally above + Ok(()) +} + +fn build_container_run_args( + name: &str, + workdir: &Path, + image: &str, + policy: &NetworkPolicy, + fs_stub_roots: &[PathBuf], +) -> Vec { let workdir_mount = format!( "{}:{}:rw", workdir.to_string_lossy(), @@ -1052,8 +1089,9 @@ fn start_container( "--cap-drop=ALL".into(), "--security-opt".into(), "no-new-privileges:true".into(), - "--tmpfs".into(), - "/tmp:size=128m,exec".into(), + "--read-only".into(), + "--workdir".into(), + docker::WORK_MOUNT_PATH.into(), // Bind-mount the host workdir at the fixed `/work` path // read-write so harness code can reference `/work/...` without // threading the host tempdir through every layer. The mount @@ -1089,28 +1127,7 @@ fn start_container( } } run_args.extend([image.into(), "sleep".into(), "300".into()]); - - let status = std::process::Command::new(docker_bin()) - .args(&run_args) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .status() - .map_err(SandboxError::Spawn)?; - - if !status.success() { - return Err(SandboxError::BackendUnavailable(SandboxBackend::Docker)); - } - - // Apply OOB egress filter on Linux when the OOB listener is active. - // This restricts the bridge-networked container to only reach the - // host on the OOB port; all other egress is dropped (§17.2). - #[cfg(target_os = "linux")] - if let NetworkPolicy::OobOutbound { listener } = policy { - apply_oob_egress_filter(name, listener.port()); - } - #[cfg(not(target_os = "linux"))] - let _ = policy; // policy already consumed structurally above - Ok(()) + run_args } /// Build the inner-container command args for `docker exec`. @@ -1133,6 +1150,8 @@ fn build_container_exec_args(command: &[String]) -> Vec { if base == "java" { args.push("java".to_owned()); + args.push(format!("-Djava.io.tmpdir={}", docker::WORK_TMP_PATH)); + args.push("-XX:+PerfDisableSharedMem".to_owned()); let mut i = 1; while i < command.len() { if command[i] == "-cp" || command[i] == "-classpath" { @@ -1176,6 +1195,24 @@ fn build_container_exec_args(command: &[String]) -> Vec { args } +fn prepare_container_tmp(workdir: &Path) -> Result<(), SandboxError> { + let tmp = workdir.join(".nyx-tmp"); + std::fs::create_dir_all(&tmp).map_err(SandboxError::Io)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + // Docker exec runs harnesses as an unprivileged uid. The bind-mounted + // workdir is the only writable filesystem surface, so make it + // traversable/writable by that uid while keeping the image root + // read-only. + std::fs::set_permissions(workdir, std::fs::Permissions::from_mode(0o777)) + .map_err(SandboxError::Io)?; + std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o777)) + .map_err(SandboxError::Io)?; + } + Ok(()) +} + /// Execute the harness inside an already-running container. fn exec_in_container( container_name: &str, @@ -1202,6 +1239,12 @@ fn exec_in_container( "65534:65534".into(), "-e".into(), format!("NYX_PAYLOAD_B64={payload_b64}"), + "-e".into(), + format!("TMPDIR={}", docker::WORK_TMP_PATH), + "-e".into(), + format!("TMP={}", docker::WORK_TMP_PATH), + "-e".into(), + format!("TEMP={}", docker::WORK_TMP_PATH), ]; // Mirror the process backend's `NYX_PAYLOAD` raw env var when the // payload bytes are valid UTF-8 (most curated payloads are ASCII). @@ -1381,7 +1424,7 @@ fn detect_image_for_harness(harness: &BuiltHarness) -> String { /// harness path. /// /// Only reachable on Linux (see [`harness_is_native_binary`]). On other platforms -/// the dispatch in [`run`] routes compiled harnesses to [`run_process`]. +/// the dispatch in [`run`] routes compiled harnesses to the process backend. fn run_native_binary_docker( harness: &BuiltHarness, payload_bytes: &[u8], @@ -1479,6 +1522,12 @@ fn exec_native_binary_in_container( "65534:65534".into(), "-e".into(), format!("NYX_PAYLOAD_B64={payload_b64}"), + "-e".into(), + format!("TMPDIR={}", docker::WORK_TMP_PATH), + "-e".into(), + format!("TMP={}", docker::WORK_TMP_PATH), + "-e".into(), + format!("TEMP={}", docker::WORK_TMP_PATH), ]; for (k, v) in &harness.env { cmd_args.push("-e".into()); @@ -2098,10 +2147,40 @@ mod tests { ]; assert_eq!( build_container_exec_args(&cmd), - vec!["java", "-cp", "/work:/work/lib/*", "NyxHarness"] + vec![ + "java", + "-Djava.io.tmpdir=/work/.nyx-tmp", + "-XX:+PerfDisableSharedMem", + "-cp", + "/work:/work/lib/*", + "NyxHarness", + ] ); } + #[test] + fn docker_run_args_keep_root_read_only_and_tmp_unmounted() { + let args = build_container_run_args( + "nyx-test", + std::path::Path::new("/tmp/nyx-harness/abc123"), + "python:3-slim", + &NetworkPolicy::None, + &[], + ); + + assert!(args.iter().any(|arg| arg == "--read-only")); + assert!( + args.windows(2) + .any(|pair| pair[0] == "--workdir" && pair[1] == docker::WORK_MOUNT_PATH) + ); + assert!( + args.windows(2) + .any(|pair| pair[0] == "-v" && pair[1] == "/tmp/nyx-harness/abc123:/work:rw") + ); + assert!(!args.iter().any(|arg| arg == "--tmpfs")); + assert!(!args.iter().any(|arg| arg.starts_with("/tmp:"))); + } + #[test] fn build_container_exec_args_empty() { assert!(build_container_exec_args(&[]).is_empty()); @@ -2148,6 +2227,25 @@ mod tests { assert!(reg.contains_key(&name)); } + #[test] + fn harness_needs_host_deps_detects_java_manifests() { + let dir = tempfile::TempDir::new().expect("tempdir"); + std::fs::write(dir.path().join("pom.xml"), "\n").expect("write pom"); + let harness = BuiltHarness { + workdir: dir.path().to_path_buf(), + command: vec![ + "java".to_owned(), + "-cp".to_owned(), + ".:lib/*".to_owned(), + "NyxHarness".to_owned(), + ], + env: vec![], + source: String::new(), + entry_source: String::new(), + }; + assert!(harness_needs_host_deps(&harness)); + } + #[test] fn harness_is_native_binary_absolute_path() { let abs = "/home/ci/.cache/nyx/dynamic/build-cache/abc123-rust-stable/nyx_harness"; diff --git a/src/dynamic/sandbox/process_linux.rs b/src/dynamic/sandbox/process_linux.rs index a8a7b30b..86823c5e 100644 --- a/src/dynamic/sandbox/process_linux.rs +++ b/src/dynamic/sandbox/process_linux.rs @@ -1,7 +1,7 @@ //! Phase 17 (Track E.1) — Linux process backend hardening. //! -//! Owns the `pre_exec` sequence applied to every harness child started by -//! [`super::run_process`] on Linux: +//! Owns the Linux `pre_exec` sequence applied to every process-backend +//! harness child: //! //! 1. `prctl(PR_SET_NO_NEW_PRIVS)` — block setuid / file-cap escalation. //! 2. `setrlimit(RLIMIT_CPU)` — cap CPU time so a runaway payload exits. diff --git a/src/patterns/mod.rs b/src/patterns/mod.rs index 01e1f3dd..3e7d1c11 100644 --- a/src/patterns/mod.rs +++ b/src/patterns/mod.rs @@ -220,6 +220,31 @@ impl std::fmt::Display for FindingCategory { } } +impl FindingCategory { + /// Category for a structural / state-machine finding identified by its + /// rule id. + /// + /// Resource-management and error-handling defects (`state-resource-leak`, + /// `cfg-resource-leak`, `cfg-error-fallthrough`) are *reliability* bugs, + /// not security vulnerabilities: a leaked file handle or an unhandled + /// error path is a correctness/robustness issue, not an exploitable flow. + /// Emitting them as `Security` floods security reports (and security + /// benchmarks) with non-security noise. Everything else routed through + /// the structural/state pipeline — taint sinks (`cfg-unguarded-sink`), + /// authorization gaps (`cfg-auth-gap`, `state-unauthed-access`) and + /// memory-safety state errors (`state-use-after-close`, + /// `state-double-close`) — stays `Security`. + pub fn for_structural_rule(rule_id: &str) -> FindingCategory { + match rule_id { + "state-resource-leak" + | "state-resource-leak-possible" + | "cfg-resource-leak" + | "cfg-error-fallthrough" => FindingCategory::Reliability, + _ => FindingCategory::Security, + } + } +} + /// Vulnerability class that a pattern detects. #[derive(Debug, Copy, Clone, Eq, PartialEq, Serialize, Deserialize)] pub enum PatternCategory { diff --git a/tests/dynamic_python_build_pool.rs b/tests/dynamic_python_build_pool.rs index f5f24327..831e0af7 100644 --- a/tests/dynamic_python_build_pool.rs +++ b/tests/dynamic_python_build_pool.rs @@ -90,7 +90,7 @@ fn mk_spec() -> HarnessSpec { fn write_project(workdir: &Path) { // Empty requirements: venv creation succeeds offline; the cached - // `pyvenv.cfg` turns every later call into a cache hit. + // `.python_cache_done` marker turns every later call into a cache hit. std::fs::write(workdir.join("requirements.txt"), "").unwrap(); } diff --git a/tests/eval_corpus/tabulate.py b/tests/eval_corpus/tabulate.py index f9fb3d77..e87fad43 100644 --- a/tests/eval_corpus/tabulate.py +++ b/tests/eval_corpus/tabulate.py @@ -454,6 +454,17 @@ def main() -> int: scan_data = load_json(args.scan) findings = scan_data if isinstance(scan_data, list) else scan_data.get("findings", []) + # Score only Security-category findings against the security ground truth. + # Reliability defects (resource leaks, error-handling fallthrough) and + # Quality findings are real bugs but not the injection / crypto / auth + # vulns the corpus ground truth enumerates, so counting them as security + # false-positives is a category error that wrecks precision with pure + # noise. Findings with no explicit category (legacy fixtures) default to + # Security and are kept. + findings = [ + f for f in findings + if f.get("category", "Security") not in ("Reliability", "Quality") + ] if lang_filter: findings = [f for f in findings if lang_of(f) in lang_filter]