mirror of
https://github.com/elicpeter/nyx.git
synced 2026-06-12 19:55:14 +02:00
feat(dynamic): improve sandbox hardening and build caching
This commit is contained in:
parent
7468d2214b
commit
20093972a9
8 changed files with 345 additions and 45 deletions
71
src/ast.rs
71
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<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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue