mirror of
https://github.com/elicpeter/nyx.git
synced 2026-06-09 19:45:13 +02:00
feat(dynamic, eval): enhance hardening validation, CI budget tuning, and source-keyed target-dir isolation
This commit is contained in:
parent
2e456c15d1
commit
c2cd6f009e
12 changed files with 234 additions and 17 deletions
|
|
@ -822,7 +822,7 @@ pub fn callers_of(cg: &CallGraph, callee: &FuncKey) -> Vec<FuncKey> {
|
|||
/// Used by the chain composer to widen file-scoped reach: a sink inside
|
||||
/// `internal_helper.py` whose enclosing function is reached only through
|
||||
/// `routes.py` is *reachable* in the chain sense, but the file-local
|
||||
/// match in [`crate::chain::edges::locate_reach`] / [`crate::chain::search::compose_chain`]
|
||||
/// match in `chain::edges::locate_reach` / `chain::search::compose_chain`
|
||||
/// misses it. This helper produces the closure once so callers can
|
||||
/// resolve reach in O(1) afterwards.
|
||||
///
|
||||
|
|
@ -864,7 +864,7 @@ pub fn callers_transitive(cg: &CallGraph, callee: &FuncKey) -> std::collections:
|
|||
/// namespace that contains at least one transitive caller. Built once
|
||||
/// per scan so the chain composer can widen a finding's
|
||||
/// `Reach::Reachable` decision beyond the file-local heuristic in
|
||||
/// [`crate::chain::edges::locate_reach`] without re-running BFS per
|
||||
/// `chain::edges::locate_reach` without re-running BFS per
|
||||
/// finding.
|
||||
///
|
||||
/// Map shape: `callee_namespace → { caller_namespace, … }`. A file
|
||||
|
|
@ -877,7 +877,7 @@ pub fn callers_transitive(cg: &CallGraph, callee: &FuncKey) -> std::collections:
|
|||
/// (typical in production scans), [`FileReachMap::reaches`] applies
|
||||
/// [`crate::symbol::normalize_namespace`] to its arguments before
|
||||
/// lookup so absolute host paths (the convention on
|
||||
/// [`crate::commands::scan::Diag::path`]) and project-relative paths
|
||||
/// [`crate::commands::scan::Diag`]'s `path`) and project-relative paths
|
||||
/// (the convention on call-graph [`FuncKey::namespace`] and
|
||||
/// [`crate::surface::SourceLocation::file`]) both resolve to the
|
||||
/// stored keys.
|
||||
|
|
|
|||
|
|
@ -67,8 +67,16 @@ impl BuildPool for RustPool {
|
|||
}
|
||||
};
|
||||
|
||||
let lock_hash = hash_files(workdir, &["Cargo.lock", "Cargo.toml"]);
|
||||
let target_dir = match pool_cache_dir("rust", &lock_hash) {
|
||||
// Key the shared target dir on the manifest *and* every `src/` file,
|
||||
// not the manifest alone. Two fixtures built for the same cap share a
|
||||
// `Cargo.toml` (identical lock hash) but differ only in their source;
|
||||
// a manifest-only key routed both into the same `release/nyx_harness`
|
||||
// slot, letting cargo skip the second fixture's relink so the copy
|
||||
// below shipped the *first* fixture's binary — cross-fixture verdict
|
||||
// corruption (a vuln / benign pair confirming identically). Folding
|
||||
// the source hash in gives each distinct harness its own target dir.
|
||||
let build_hash = hash_build_inputs(workdir);
|
||||
let target_dir = match pool_cache_dir("rust", &build_hash) {
|
||||
Some(d) => d,
|
||||
None => {
|
||||
return PoolCompileResult {
|
||||
|
|
@ -245,6 +253,51 @@ fn hash_files(workdir: &Path, files: &[&str]) -> String {
|
|||
)
|
||||
}
|
||||
|
||||
/// Hash of every input that determines the compiled `nyx_harness` binary: the
|
||||
/// Cargo manifest/lock *plus* every `.rs` file under `src/`. Used to key the
|
||||
/// shared `CARGO_TARGET_DIR` so source-distinct harnesses never share a
|
||||
/// `release/nyx_harness` slot (see the call site in [`RustPool::compile_batch`]
|
||||
/// for why manifest-only keying corrupted cross-fixture verdicts). Mirrors
|
||||
/// [`crate::dynamic::build_sandbox::compute_rust_lockfile_hash`].
|
||||
fn hash_build_inputs(workdir: &Path) -> String {
|
||||
let manifest = hash_files(workdir, &["Cargo.lock", "Cargo.toml"]);
|
||||
let src_dir = workdir.join("src");
|
||||
let mut rs_files: Vec<PathBuf> = Vec::new();
|
||||
collect_rs_files(&src_dir, &src_dir, &mut rs_files);
|
||||
rs_files.sort();
|
||||
let mut h = Hasher::new();
|
||||
for rel in &rs_files {
|
||||
if let Ok(content) = std::fs::read(src_dir.join(rel)) {
|
||||
h.update(rel.to_string_lossy().as_bytes());
|
||||
h.update(b"\0");
|
||||
h.update(&content);
|
||||
}
|
||||
}
|
||||
let out = h.finalize();
|
||||
format!(
|
||||
"{manifest}-{:016x}",
|
||||
u64::from_le_bytes(out.as_bytes()[..8].try_into().unwrap())
|
||||
)
|
||||
}
|
||||
|
||||
/// Recursively collect `.rs` file paths (relative to `root`) under `dir`.
|
||||
fn collect_rs_files(root: &Path, dir: &Path, out: &mut Vec<PathBuf>) {
|
||||
let entries = match std::fs::read_dir(dir) {
|
||||
Ok(e) => e,
|
||||
Err(_) => return,
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
collect_rs_files(root, &path, out);
|
||||
} else if path.extension().and_then(|e| e.to_str()) == Some("rs")
|
||||
&& let Ok(rel) = path.strip_prefix(root)
|
||||
{
|
||||
out.push(rel.to_path_buf());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
@ -260,6 +313,47 @@ mod tests {
|
|||
assert_ne!(h1, h3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_hash_differs_for_same_manifest_distinct_source() {
|
||||
// A vuln / benign pair built for the same cap ships an identical
|
||||
// Cargo.toml but a different `src/entry.rs`. The shared target-dir key
|
||||
// must differ between them, else cargo skips the second relink and the
|
||||
// pool copies out the first fixture's binary (cross-fixture verdict
|
||||
// corruption — the cmdi / data-exfil Rust regression).
|
||||
let manifest = b"[package]\nname=\"nyx_harness\"\nversion=\"0.0.0\"\n";
|
||||
|
||||
let vuln = tempfile::TempDir::new().unwrap();
|
||||
std::fs::create_dir_all(vuln.path().join("src")).unwrap();
|
||||
std::fs::write(vuln.path().join("Cargo.toml"), manifest).unwrap();
|
||||
std::fs::write(vuln.path().join("src/main.rs"), b"fn main(){}\n").unwrap();
|
||||
std::fs::write(
|
||||
vuln.path().join("src/entry.rs"),
|
||||
b"pub fn run(){ /*vuln*/ }\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let benign = tempfile::TempDir::new().unwrap();
|
||||
std::fs::create_dir_all(benign.path().join("src")).unwrap();
|
||||
std::fs::write(benign.path().join("Cargo.toml"), manifest).unwrap();
|
||||
std::fs::write(benign.path().join("src/main.rs"), b"fn main(){}\n").unwrap();
|
||||
std::fs::write(
|
||||
benign.path().join("src/entry.rs"),
|
||||
b"pub fn run(){ /*benign*/ }\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Identical manifests collide under the old manifest-only key …
|
||||
assert_eq!(
|
||||
hash_files(vuln.path(), &["Cargo.lock", "Cargo.toml"]),
|
||||
hash_files(benign.path(), &["Cargo.lock", "Cargo.toml"]),
|
||||
);
|
||||
// … but the source-aware key separates them.
|
||||
assert_ne!(
|
||||
hash_build_inputs(vuln.path()),
|
||||
hash_build_inputs(benign.path())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_dest_arg_is_an_error_not_a_panic() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
|
|
|
|||
|
|
@ -1369,6 +1369,7 @@ fn run_nonce() -> [u8; 32] {
|
|||
|
||||
/// Fill `buf` from the OS CSPRNG. Returns `false` (caller falls back to the
|
||||
/// time + pid mixing) when no source is available on the platform.
|
||||
#[cfg_attr(not(unix), allow(unused_variables))]
|
||||
fn read_os_entropy(buf: &mut [u8]) -> bool {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
|
|
|
|||
|
|
@ -589,8 +589,10 @@ pub fn install_pre_exec(
|
|||
}
|
||||
|
||||
fn run_pre_exec_in_child(plan: &PreExecPlan) -> HardeningOutcome {
|
||||
let mut outcome = HardeningOutcome::default();
|
||||
outcome.profile = plan.profile;
|
||||
let mut outcome = HardeningOutcome {
|
||||
profile: plan.profile,
|
||||
..Default::default()
|
||||
};
|
||||
let ablation = plan.ablation.unwrap_or_default();
|
||||
|
||||
// ── Always-on: PR_SET_NO_NEW_PRIVS + RLIMIT_AS ───────────────────────
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ unsafe extern "C" {
|
|||
}
|
||||
|
||||
/// Compose the cap-aware syscall allowlist: the `BASE` set unconditionally
|
||||
/// + every `CAP[i]` whose bit is set in `caps`. Names are deduped via a
|
||||
/// plus every `CAP[i]` whose bit is set in `caps`. Names are deduped via a
|
||||
/// `BTreeSet` and resolved to numbers via [`syscall_number`]. Unknown
|
||||
/// names (not in the per-arch table) are silently dropped.
|
||||
pub fn allowed_syscall_numbers(caps: u32) -> Vec<u32> {
|
||||
|
|
|
|||
|
|
@ -1031,6 +1031,7 @@ pub fn verify_finding(diag: &Diag, opts: &VerifyOptions) -> VerifyResult {
|
|||
pub fn summarize_hardening(
|
||||
outcome: &crate::dynamic::sandbox::SandboxOutcome,
|
||||
) -> Option<HardeningSummary> {
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
use crate::dynamic::sandbox::HardeningRecord;
|
||||
let record = outcome.hardening_outcome.as_ref()?;
|
||||
match record {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue