feat(dynamic): improve sandbox hardening and build caching

This commit is contained in:
elipeter 2026-06-03 12:26:10 -05:00
parent 7468d2214b
commit 20093972a9
8 changed files with 345 additions and 45 deletions

View file

@ -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<Option<String>>,
/// 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<usize, u32>,
}
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<usize, u32> = 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);
}

View file

@ -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<BuildResult, BuildError> {
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<std::io::Error> for BuildError {
pub fn prepare_python(spec: &HarnessSpec, workdir: &Path) -> Result<BuildResult, BuildError> {
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<BuildResult,
}
let start = Instant::now();
let _ = std::fs::remove_dir_all(&cache_path);
std::fs::create_dir_all(&cache_path)?;
match build_venv(&cache_path, workdir, spec) {
Ok(()) => {
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<CacheBuildLock> {
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<BuildResult, B
let lockfile_hash = compute_ruby_lockfile_hash(workdir);
let cache_path = build_cache_path(&lockfile_hash, "ruby", &spec.toolchain_id).ok();
let _cache_guard = cache_path
.as_deref()
.map(acquire_cache_build_lock)
.transpose()?;
if let Some(cache_path) = &cache_path
&& cache_path.join(".ruby_cache_done").exists()
@ -617,6 +711,7 @@ fn compute_ruby_lockfile_hash(workdir: &Path) -> String {
pub fn prepare_node(spec: &HarnessSpec, workdir: &Path) -> Result<BuildResult, BuildError> {
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<BuildResult, BuildError> {
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<BuildResult, B
let target_release = java_target_release(&spec.toolchain_id);
let source_hash = compute_java_source_hash(workdir, target_release);
let cache_path = build_cache_path(&source_hash, "java", &spec.toolchain_id).ok();
let _cache_guard = cache_path
.as_deref()
.map(acquire_cache_build_lock)
.transpose()?;
if let Some(cache_path) = &cache_path {
let cached_classes = collect_class_files(cache_path);
@ -1349,6 +1449,7 @@ fn compute_java_source_hash(workdir: &Path, target_release: Option<u32>) -> Stri
pub fn prepare_php(spec: &HarnessSpec, workdir: &Path) -> Result<BuildResult, BuildError> {
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<BuildResult, BuildError> {
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();

View file

@ -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/<n>` where `<n>` is its index in
/// the harness's stub list.

View file

@ -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<String> {
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<String> {
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<String> {
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"), "<project />\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";

View file

@ -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.

View file

@ -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 {

View file

@ -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();
}

View file

@ -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]