2026-05-05 03:43:45 -04:00
|
|
|
//! Top-level entry point for the dynamic layer.
|
|
|
|
|
//!
|
|
|
|
|
//! The CLI subcommand and any library consumer call [`verify_finding`].
|
|
|
|
|
//! It is the only function the rest of the crate needs to know about.
|
|
|
|
|
|
|
|
|
|
use crate::commands::scan::Diag;
|
2026-05-11 22:56:43 -04:00
|
|
|
use crate::dynamic::corpus::payloads_for;
|
2026-05-05 03:43:45 -04:00
|
|
|
use crate::dynamic::report::{AttemptSummary, VerifyResult, VerifyStatus};
|
|
|
|
|
use crate::dynamic::runner::{run_spec, RunError};
|
|
|
|
|
use crate::dynamic::sandbox::SandboxOptions;
|
|
|
|
|
use crate::dynamic::spec::HarnessSpec;
|
2026-05-11 22:56:43 -04:00
|
|
|
use crate::dynamic::telemetry::{self, TelemetryEvent};
|
|
|
|
|
use crate::dynamic::toolchain;
|
|
|
|
|
use crate::evidence::{InconclusiveReason, UnsupportedReason};
|
2026-05-11 21:19:03 -04:00
|
|
|
use crate::utils::config::Config;
|
2026-05-11 22:56:43 -04:00
|
|
|
use std::path::Path;
|
|
|
|
|
use std::time::Instant;
|
2026-05-05 03:43:45 -04:00
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Default)]
|
|
|
|
|
pub struct VerifyOptions {
|
|
|
|
|
pub sandbox: SandboxOptions,
|
2026-05-11 22:56:43 -04:00
|
|
|
/// Project root for repro artifact symlinks (optional).
|
|
|
|
|
pub project_root: Option<std::path::PathBuf>,
|
2026-05-05 03:43:45 -04:00
|
|
|
}
|
|
|
|
|
|
2026-05-11 21:19:03 -04:00
|
|
|
impl VerifyOptions {
|
|
|
|
|
/// Build `VerifyOptions` from scanner config.
|
2026-05-12 00:05:11 -04:00
|
|
|
pub fn from_config(config: &Config) -> Self {
|
|
|
|
|
use crate::dynamic::sandbox::SandboxBackend;
|
|
|
|
|
let backend = match config.scanner.verify_backend.as_str() {
|
|
|
|
|
"docker" => SandboxBackend::Docker,
|
|
|
|
|
"process" => SandboxBackend::Process,
|
|
|
|
|
_ => SandboxBackend::Auto,
|
|
|
|
|
};
|
2026-05-11 21:19:03 -04:00
|
|
|
Self {
|
2026-05-12 00:05:11 -04:00
|
|
|
sandbox: SandboxOptions {
|
|
|
|
|
backend,
|
|
|
|
|
..SandboxOptions::default()
|
|
|
|
|
},
|
2026-05-11 22:56:43 -04:00
|
|
|
project_root: None,
|
2026-05-11 21:19:03 -04:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-05 03:43:45 -04:00
|
|
|
/// Try to dynamically confirm a static finding.
|
|
|
|
|
///
|
|
|
|
|
/// Never fails: every error path collapses into a [`VerifyStatus`] so the
|
|
|
|
|
/// caller can treat dynamic verification as best-effort enrichment.
|
|
|
|
|
pub fn verify_finding(diag: &Diag, opts: &VerifyOptions) -> VerifyResult {
|
2026-05-11 21:19:03 -04:00
|
|
|
let finding_id = format!("{:016x}", diag.stable_hash);
|
2026-05-05 03:43:45 -04:00
|
|
|
|
2026-05-11 21:19:03 -04:00
|
|
|
let spec = match HarnessSpec::from_finding(diag) {
|
|
|
|
|
Ok(s) => s,
|
|
|
|
|
Err(reason) => {
|
|
|
|
|
return VerifyResult {
|
|
|
|
|
finding_id,
|
|
|
|
|
status: VerifyStatus::Unsupported,
|
|
|
|
|
triggered_payload: None,
|
|
|
|
|
reason: Some(reason),
|
2026-05-11 22:56:43 -04:00
|
|
|
inconclusive_reason: None,
|
2026-05-11 21:19:03 -04:00
|
|
|
detail: None,
|
|
|
|
|
attempts: vec![],
|
2026-05-11 22:56:43 -04:00
|
|
|
toolchain_match: None,
|
2026-05-11 21:19:03 -04:00
|
|
|
};
|
|
|
|
|
}
|
2026-05-05 03:43:45 -04:00
|
|
|
};
|
|
|
|
|
|
2026-05-11 23:24:37 -04:00
|
|
|
// Scan the entry file's directory for sensitive files (§17.3 mount filter).
|
|
|
|
|
// If the entry file itself matches a sensitive pattern, refuse to run it:
|
|
|
|
|
// the harness would copy it into the workdir and expose secrets.
|
|
|
|
|
{
|
|
|
|
|
let entry_path = Path::new(&spec.entry_file);
|
|
|
|
|
let scan_dir = entry_path
|
|
|
|
|
.parent()
|
|
|
|
|
.filter(|p| !p.as_os_str().is_empty())
|
|
|
|
|
.unwrap_or(Path::new("."));
|
|
|
|
|
let notes = crate::dynamic::mount_filter::scan_sensitive_files(scan_dir);
|
|
|
|
|
for note in ¬es {
|
|
|
|
|
let note_abs = scan_dir.join(¬e.path);
|
|
|
|
|
if entry_path == note_abs {
|
|
|
|
|
return VerifyResult {
|
|
|
|
|
finding_id,
|
|
|
|
|
status: VerifyStatus::Unsupported,
|
|
|
|
|
triggered_payload: None,
|
|
|
|
|
reason: Some(UnsupportedReason::RequiredFileRedactedForSecrets(
|
|
|
|
|
note.path.clone(),
|
|
|
|
|
)),
|
|
|
|
|
inconclusive_reason: None,
|
|
|
|
|
detail: None,
|
|
|
|
|
attempts: vec![],
|
|
|
|
|
toolchain_match: None,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-12 00:57:45 -04:00
|
|
|
// Resolve toolchain information (lang-aware: §22.2).
|
|
|
|
|
use crate::symbol::Lang;
|
|
|
|
|
let toolchain_res = match spec.lang {
|
|
|
|
|
Lang::Rust => toolchain::resolve_rust(Path::new(".")),
|
|
|
|
|
_ => toolchain::resolve_python(Path::new(".")),
|
|
|
|
|
};
|
2026-05-11 22:56:43 -04:00
|
|
|
let toolchain_match = if toolchain_res.toolchain_drift { "drift" } else { "exact" };
|
|
|
|
|
|
|
|
|
|
let start = Instant::now();
|
|
|
|
|
let result = run_spec(&spec, &opts.sandbox);
|
|
|
|
|
let elapsed = start.elapsed();
|
|
|
|
|
|
2026-05-11 23:24:37 -04:00
|
|
|
// Extract build_attempts before result is consumed by build_verdict.
|
|
|
|
|
let build_attempts = match &result {
|
|
|
|
|
Ok(run) => run.build_attempts,
|
|
|
|
|
Err(RunError::BuildFailed { attempts, .. }) => *attempts,
|
|
|
|
|
_ => 1,
|
|
|
|
|
};
|
|
|
|
|
|
2026-05-11 22:56:43 -04:00
|
|
|
let verdict = build_verdict(
|
|
|
|
|
&finding_id,
|
|
|
|
|
&spec,
|
|
|
|
|
result,
|
|
|
|
|
toolchain_match,
|
|
|
|
|
opts,
|
|
|
|
|
elapsed,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Emit telemetry (best-effort; never affects verdict).
|
|
|
|
|
let event = TelemetryEvent::new(
|
|
|
|
|
&spec,
|
|
|
|
|
verdict.status,
|
|
|
|
|
verdict.inconclusive_reason,
|
|
|
|
|
toolchain_match,
|
|
|
|
|
elapsed,
|
2026-05-11 23:24:37 -04:00
|
|
|
build_attempts,
|
2026-05-11 22:56:43 -04:00
|
|
|
);
|
|
|
|
|
telemetry::emit(&event);
|
|
|
|
|
|
|
|
|
|
verdict
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn build_verdict(
|
|
|
|
|
finding_id: &str,
|
|
|
|
|
spec: &HarnessSpec,
|
|
|
|
|
result: Result<crate::dynamic::runner::RunOutcome, RunError>,
|
|
|
|
|
toolchain_match: &str,
|
|
|
|
|
opts: &VerifyOptions,
|
|
|
|
|
_elapsed: std::time::Duration,
|
|
|
|
|
) -> VerifyResult {
|
|
|
|
|
match result {
|
2026-05-05 03:43:45 -04:00
|
|
|
Ok(run) => {
|
2026-05-11 22:56:43 -04:00
|
|
|
let attempts: Vec<AttemptSummary> = run
|
2026-05-05 03:43:45 -04:00
|
|
|
.attempts
|
|
|
|
|
.iter()
|
|
|
|
|
.map(|a| AttemptSummary {
|
|
|
|
|
payload_label: a.payload_label.to_string(),
|
|
|
|
|
exit_code: a.outcome.exit_code,
|
|
|
|
|
timed_out: a.outcome.timed_out,
|
|
|
|
|
triggered: a.triggered,
|
2026-05-11 22:56:43 -04:00
|
|
|
sink_hit: a.outcome.sink_hit,
|
2026-05-05 03:43:45 -04:00
|
|
|
})
|
|
|
|
|
.collect();
|
|
|
|
|
|
2026-05-11 22:56:43 -04:00
|
|
|
if let Some(i) = run.triggered_by {
|
|
|
|
|
let triggered_payload = run.attempts[i].payload_label.to_string();
|
|
|
|
|
let payloads = payloads_for(spec.expected_cap);
|
|
|
|
|
let vuln_payloads: Vec<_> = payloads.iter().filter(|p| !p.is_benign).collect();
|
|
|
|
|
let payload_bytes = vuln_payloads
|
|
|
|
|
.get(i)
|
|
|
|
|
.map(|p| p.bytes)
|
|
|
|
|
.unwrap_or(b"");
|
|
|
|
|
|
|
|
|
|
// Emit repro artifact.
|
|
|
|
|
let repro_result = crate::dynamic::repro::write(
|
|
|
|
|
spec,
|
|
|
|
|
&opts.sandbox,
|
|
|
|
|
&run.attempts[i].outcome,
|
|
|
|
|
&VerifyResult {
|
|
|
|
|
finding_id: finding_id.to_owned(),
|
|
|
|
|
status: VerifyStatus::Confirmed,
|
|
|
|
|
triggered_payload: Some(triggered_payload.clone()),
|
|
|
|
|
reason: None,
|
|
|
|
|
inconclusive_reason: None,
|
|
|
|
|
detail: None,
|
|
|
|
|
attempts: attempts.clone(),
|
|
|
|
|
toolchain_match: Some(toolchain_match.to_owned()),
|
|
|
|
|
},
|
|
|
|
|
&run.harness_source,
|
|
|
|
|
&run.entry_source,
|
|
|
|
|
payload_bytes,
|
|
|
|
|
run.attempts[i].payload_label,
|
|
|
|
|
opts.project_root.as_deref(),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// If repro write fails, downgrade to NonReproducible.
|
|
|
|
|
if repro_result.is_err() {
|
|
|
|
|
return VerifyResult {
|
|
|
|
|
finding_id: finding_id.to_owned(),
|
|
|
|
|
status: VerifyStatus::Inconclusive,
|
|
|
|
|
triggered_payload: None,
|
|
|
|
|
reason: None,
|
|
|
|
|
inconclusive_reason: Some(InconclusiveReason::NonReproducible),
|
|
|
|
|
detail: Some(format!("repro write failed: {}", repro_result.unwrap_err())),
|
|
|
|
|
attempts,
|
|
|
|
|
toolchain_match: Some(toolchain_match.to_owned()),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
VerifyResult {
|
|
|
|
|
finding_id: finding_id.to_owned(),
|
2026-05-05 03:43:45 -04:00
|
|
|
status: VerifyStatus::Confirmed,
|
2026-05-11 22:56:43 -04:00
|
|
|
triggered_payload: Some(triggered_payload),
|
2026-05-05 03:43:45 -04:00
|
|
|
reason: None,
|
2026-05-11 22:56:43 -04:00
|
|
|
inconclusive_reason: None,
|
2026-05-11 21:19:03 -04:00
|
|
|
detail: None,
|
2026-05-05 03:43:45 -04:00
|
|
|
attempts,
|
2026-05-11 22:56:43 -04:00
|
|
|
toolchain_match: Some(toolchain_match.to_owned()),
|
|
|
|
|
}
|
|
|
|
|
} else if run.oracle_collision {
|
|
|
|
|
// Oracle fired but probe didn't — likely collision.
|
|
|
|
|
VerifyResult {
|
|
|
|
|
finding_id: finding_id.to_owned(),
|
|
|
|
|
status: VerifyStatus::Inconclusive,
|
|
|
|
|
triggered_payload: None,
|
|
|
|
|
reason: None,
|
|
|
|
|
inconclusive_reason: Some(InconclusiveReason::OracleCollisionSuspected),
|
|
|
|
|
detail: Some("oracle fired but sink-reachability probe did not".to_owned()),
|
|
|
|
|
attempts,
|
|
|
|
|
toolchain_match: Some(toolchain_match.to_owned()),
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
VerifyResult {
|
|
|
|
|
finding_id: finding_id.to_owned(),
|
2026-05-05 03:43:45 -04:00
|
|
|
status: VerifyStatus::NotConfirmed,
|
|
|
|
|
triggered_payload: None,
|
|
|
|
|
reason: None,
|
2026-05-11 22:56:43 -04:00
|
|
|
inconclusive_reason: None,
|
2026-05-11 21:19:03 -04:00
|
|
|
detail: None,
|
2026-05-05 03:43:45 -04:00
|
|
|
attempts,
|
2026-05-11 22:56:43 -04:00
|
|
|
toolchain_match: Some(toolchain_match.to_owned()),
|
|
|
|
|
}
|
2026-05-05 03:43:45 -04:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Err(RunError::NoPayloadsForCap) => VerifyResult {
|
2026-05-11 22:56:43 -04:00
|
|
|
finding_id: finding_id.to_owned(),
|
2026-05-05 03:43:45 -04:00
|
|
|
status: VerifyStatus::Unsupported,
|
|
|
|
|
triggered_payload: None,
|
2026-05-11 21:19:03 -04:00
|
|
|
reason: Some(UnsupportedReason::NoPayloadsForCap),
|
2026-05-11 22:56:43 -04:00
|
|
|
inconclusive_reason: None,
|
2026-05-11 21:19:03 -04:00
|
|
|
detail: None,
|
2026-05-05 03:43:45 -04:00
|
|
|
attempts: vec![],
|
2026-05-11 22:56:43 -04:00
|
|
|
toolchain_match: None,
|
2026-05-05 03:43:45 -04:00
|
|
|
},
|
2026-05-11 22:56:43 -04:00
|
|
|
Err(RunError::Harness(e)) => {
|
|
|
|
|
// Typed `Unsupported(reason)` carries its semantics in `reason`; the
|
|
|
|
|
// free-form `detail` is reserved for `Inconclusive`/unexpected paths
|
|
|
|
|
// (cf. §10 decision 14 and the verify_result_json_shape contract).
|
|
|
|
|
let (reason, detail) = match &e {
|
|
|
|
|
crate::dynamic::harness::HarnessError::Unsupported(r) => (Some(r.clone()), None),
|
|
|
|
|
_ => (Some(UnsupportedReason::BackendUnavailable), Some(format!("{e}"))),
|
|
|
|
|
};
|
|
|
|
|
VerifyResult {
|
|
|
|
|
finding_id: finding_id.to_owned(),
|
|
|
|
|
status: VerifyStatus::Unsupported,
|
|
|
|
|
triggered_payload: None,
|
|
|
|
|
reason,
|
|
|
|
|
inconclusive_reason: None,
|
|
|
|
|
detail,
|
|
|
|
|
attempts: vec![],
|
|
|
|
|
toolchain_match: None,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Err(RunError::BuildFailed { stderr, attempts: build_att }) => VerifyResult {
|
|
|
|
|
finding_id: finding_id.to_owned(),
|
|
|
|
|
status: VerifyStatus::Inconclusive,
|
2026-05-05 03:43:45 -04:00
|
|
|
triggered_payload: None,
|
2026-05-11 22:56:43 -04:00
|
|
|
reason: None,
|
|
|
|
|
inconclusive_reason: Some(InconclusiveReason::BuildFailed),
|
|
|
|
|
detail: Some(format!("build failed after {build_att} attempts: {stderr}")),
|
2026-05-05 03:43:45 -04:00
|
|
|
attempts: vec![],
|
2026-05-11 22:56:43 -04:00
|
|
|
toolchain_match: None,
|
2026-05-05 03:43:45 -04:00
|
|
|
},
|
|
|
|
|
Err(RunError::Sandbox(e)) => VerifyResult {
|
2026-05-11 22:56:43 -04:00
|
|
|
finding_id: finding_id.to_owned(),
|
2026-05-05 03:43:45 -04:00
|
|
|
status: VerifyStatus::Inconclusive,
|
|
|
|
|
triggered_payload: None,
|
2026-05-11 21:19:03 -04:00
|
|
|
reason: None,
|
2026-05-11 22:56:43 -04:00
|
|
|
inconclusive_reason: Some(InconclusiveReason::SandboxError),
|
2026-05-11 21:19:03 -04:00
|
|
|
detail: Some(format!("sandbox failed: {e:?}")),
|
2026-05-05 03:43:45 -04:00
|
|
|
attempts: vec![],
|
2026-05-11 22:56:43 -04:00
|
|
|
toolchain_match: None,
|
2026-05-05 03:43:45 -04:00
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
}
|