[pitboss] phase 01: M1 — Spec extraction + --verify plumbing (no sandbox)

This commit is contained in:
pitboss 2026-05-11 21:19:03 -04:00
parent cb8688219a
commit a10aba5d1f
25 changed files with 808 additions and 66 deletions

View file

@ -10,6 +10,11 @@
use crate::labels::Cap;
/// Bump when the corpus content changes in a way that invalidates previously-
/// computed [`crate::dynamic::spec::HarnessSpec::spec_hash`] values (e.g.
/// payloads renamed, oracle semantics changed, new cap entries added).
pub const CORPUS_VERSION: u32 = 1;
/// A single payload + the oracle that confirms it fired.
#[derive(Debug, Clone)]
pub struct Payload {
@ -37,6 +42,35 @@ pub enum Oracle {
}
/// Pick the payload set for a given cap. Empty slice = unsupported cap.
///
/// # Cap coverage (update when adding/removing Cap bits)
///
/// | Cap | Supported | Notes |
/// |--------------------|-----------|--------------------------------|
/// | SQL_QUERY | yes | SQLI payloads |
/// | CODE_EXEC | yes | command injection echo marker |
/// | FILE_IO | yes | path traversal to /etc/passwd |
/// | SSRF | yes | OOB callback probe |
/// | HTML_ESCAPE | yes | XSS script marker |
/// | ENV_VAR | no | source-only cap; no sink oracle|
/// | SHELL_ESCAPE | no | sanitizer cap; no sink oracle |
/// | URL_ENCODE | no | sanitizer cap; no sink oracle |
/// | JSON_PARSE | no | no reliable oracle |
/// | FMT_STRING | no | no reliable oracle |
/// | DESERIALIZE | no | no reliable oracle |
/// | CRYPTO | no | no reliable oracle |
/// | UNAUTHORIZED_ID | no | auth bypass; no oracle |
/// | DATA_EXFIL | no | exfil; no oracle |
/// | LDAP_INJECTION | no | no oracle |
/// | XPATH_INJECTION | no | no oracle |
/// | HEADER_INJECTION | no | no oracle |
/// | OPEN_REDIRECT | no | no oracle |
/// | SSTI | no | no oracle |
/// | XXE | no | no oracle |
/// | PROTOTYPE_POLLUTION| no | JS-runtime; no oracle |
///
/// When adding a new `Cap` bit: add a row above, update this function, and
/// bump [`CORPUS_VERSION`] if you add payload support.
pub fn payloads_for(cap: Cap) -> &'static [Payload] {
if cap.contains(Cap::SQL_QUERY) {
return SQLI;
@ -56,6 +90,48 @@ pub fn payloads_for(cap: Cap) -> &'static [Payload] {
&[]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn supported_caps_have_payloads() {
assert!(!payloads_for(Cap::SQL_QUERY).is_empty());
assert!(!payloads_for(Cap::CODE_EXEC).is_empty());
assert!(!payloads_for(Cap::FILE_IO).is_empty());
assert!(!payloads_for(Cap::SSRF).is_empty());
assert!(!payloads_for(Cap::HTML_ESCAPE).is_empty());
}
#[test]
fn unsupported_caps_return_empty() {
let unsupported = [
Cap::ENV_VAR,
Cap::SHELL_ESCAPE,
Cap::URL_ENCODE,
Cap::JSON_PARSE,
Cap::FMT_STRING,
Cap::DESERIALIZE,
Cap::CRYPTO,
Cap::UNAUTHORIZED_ID,
Cap::DATA_EXFIL,
Cap::LDAP_INJECTION,
Cap::XPATH_INJECTION,
Cap::HEADER_INJECTION,
Cap::OPEN_REDIRECT,
Cap::SSTI,
Cap::XXE,
Cap::PROTOTYPE_POLLUTION,
];
for cap in unsupported {
assert!(
payloads_for(cap).is_empty(),
"expected {cap:?} to return empty payloads; update coverage table if adding support"
);
}
}
}
const SQLI: &[Payload] = &[Payload {
bytes: b"' OR 1=1-- NYX",
label: "sqli-or-1",

View file

@ -1,42 +1,8 @@
//! Verdict types returned by the dynamic layer.
//! Verdict types for dynamic verification results.
//!
//! Kept separate from the run pipeline so the CLI / JSON output side can
//! depend on this without pulling in sandbox or harness deps.
//! The canonical definitions live in [`crate::evidence`] so they are always
//! present regardless of the `dynamic` feature flag. This module re-exports
//! them for use inside the dynamic pipeline without requiring callers to reach
//! into `evidence` directly.
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum VerifyStatus {
/// Sink fired with at least one payload. Static finding is exploitable
/// against the live target.
Confirmed,
/// All payloads ran cleanly. Either the path is infeasible at runtime
/// or the corpus is too narrow. Treat as "static-only" not "false".
NotConfirmed,
/// Could not build, run, or observe (toolchain missing, sandbox refused,
/// timeout on every attempt, etc.).
Inconclusive,
/// We do not yet know how to drive this finding (missing language
/// support, unsupported entry kind, no payloads for cap).
Unsupported,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VerifyResult {
pub finding_id: String,
pub status: VerifyStatus,
/// Label of the payload that triggered, when [`VerifyStatus::Confirmed`].
pub triggered_payload: Option<String>,
/// Free-form note for inconclusive/unsupported cases.
pub reason: Option<String>,
/// Per-attempt log (payload label, exit code, timed_out flag).
pub attempts: Vec<AttemptSummary>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AttemptSummary {
pub payload_label: String,
pub exit_code: Option<i32>,
pub timed_out: bool,
pub triggered: bool,
}
pub use crate::evidence::{AttemptSummary, UnsupportedReason, VerifyResult, VerifyStatus};

View file

@ -5,14 +5,39 @@
//! which entry point to drive, which parameter carries the payload, what
//! sink (cap) we expect to hit, and which language toolchain to use.
//!
//! Construction is total but may return `None` when the finding lacks the
//! evidence required to drive it dynamically (no source span, no callable
//! entry, sink in dead code, etc.). Those findings stay static-only.
//! Construction is total but may return `Err` when the finding lacks the
//! evidence required to drive it dynamically (confidence too low, no source
//! span, no callable entry, sink in dead code, etc.). Those findings stay
//! static-only.
//!
//! # Versioning
//!
//! [`SPEC_FORMAT_VERSION`] is baked into every [`HarnessSpec::spec_hash`].
//! Bump it — and update `compute_spec_hash` — whenever any field changes
//! meaning, the hash inputs change, or the corpus changes in a way that
//! would invalidate previously-computed hashes.
use crate::commands::scan::Diag;
use crate::dynamic::corpus::CORPUS_VERSION;
use crate::evidence::{Confidence, FlowStepKind, UnsupportedReason};
use crate::labels::Cap;
use crate::symbol::Lang;
use serde::{Deserialize, Serialize};
use std::path::Path;
/// Bump whenever [`HarnessSpec`] fields change meaning or the spec hash
/// inputs change. Downstream tools should reject specs with an unrecognised
/// version.
pub const SPEC_FORMAT_VERSION: u32 = 1;
/// Identifies the entry point extracted from a taint flow.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct EntryRef {
/// Project-relative path of the file containing the entry function.
pub file: String,
/// Name of the entry function (unqualified).
pub function: String,
}
/// What kind of entry point the harness should call.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
@ -47,7 +72,7 @@ pub enum PayloadSlot {
/// Self-contained recipe for building and running a single harness.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HarnessSpec {
/// Stable id of the source finding (`Diag::id` plus location hash).
/// Stable id of the source finding (`Diag::stable_hash` as hex).
pub finding_id: String,
/// Project-relative path to the file holding the entry point.
pub entry_file: String,
@ -57,6 +82,9 @@ pub struct HarnessSpec {
pub entry_kind: EntryKind,
/// Source language (drives toolchain selection).
pub lang: Lang,
/// Toolchain identifier string (e.g. `"rust-stable"`, `"node-20"`).
/// Informational; harness builder may override for local installs.
pub toolchain_id: String,
/// Where the payload is injected.
pub payload_slot: PayloadSlot,
/// Sink capability we expect to fire (drives oracle + corpus pick).
@ -65,17 +93,287 @@ pub struct HarnessSpec {
/// Populated later from `Evidence::engine_notes` when available.
#[serde(default)]
pub constraint_hints: Vec<String>,
/// Blake3 hash (16 hex chars) of the spec's key fields, version-pinned.
/// Stable across identical specs; used for deduplication and caching.
pub spec_hash: String,
}
impl HarnessSpec {
/// Build a spec from a finding. Returns `None` when the finding cannot
/// be driven dynamically (missing entry, ambient sink, etc.).
/// Build a spec from a finding. Returns `Err` with a typed reason when
/// the finding cannot be driven dynamically.
///
/// Stub: real impl will read `Diag::evidence.flow_steps` to pick the
/// outermost entry function and walk the source span back to a parameter.
pub fn from_finding(_diag: &Diag) -> Option<Self> {
// TODO(dynamic): map flow_steps[0] -> entry function, evidence.source_span -> PayloadSlot,
// evidence.sink_caps -> expected_cap.
None
/// Conditions for `None` return:
/// - Confidence below `Medium`
/// - No `flow_steps` in evidence
/// - No callable entry (source step missing a `function` annotation)
/// - Unknown language (file extension unrecognised)
/// - Zero sink capability bits
pub fn from_finding(diag: &Diag) -> Result<Self, UnsupportedReason> {
// Require at least Medium confidence to attempt dynamic verification.
match diag.confidence {
Some(c) if c >= Confidence::Medium => {}
_ => return Err(UnsupportedReason::ConfidenceTooLow),
}
let evidence = diag.evidence.as_ref().ok_or(UnsupportedReason::NoFlowSteps)?;
if evidence.flow_steps.is_empty() {
return Err(UnsupportedReason::NoFlowSteps);
}
let entry = outermost_entry(&evidence.flow_steps)
.ok_or(UnsupportedReason::SpecDerivationFailed)?;
let ext = Path::new(&entry.file)
.extension()
.and_then(|e| e.to_str())
.unwrap_or("");
let lang = Lang::from_extension(ext).ok_or(UnsupportedReason::SpecDerivationFailed)?;
let expected_cap = Cap::from_bits_truncate(evidence.sink_caps);
if expected_cap.is_empty() {
return Err(UnsupportedReason::SpecDerivationFailed);
}
let toolchain_id = toolchain_id_for_lang(lang).to_owned();
let mut spec = HarnessSpec {
finding_id: format!("{:016x}", diag.stable_hash),
entry_file: entry.file,
entry_name: entry.function,
entry_kind: EntryKind::Function,
lang,
toolchain_id,
payload_slot: PayloadSlot::Param(0),
expected_cap,
constraint_hints: vec![],
spec_hash: String::new(),
};
spec.spec_hash = compute_spec_hash(&spec);
Ok(spec)
}
}
/// Walk `flow_steps` and return the entry point: the enclosing function of
/// the first `Source` step that has a function annotation. This is the
/// outermost callable that receives the tainted input.
pub fn outermost_entry(steps: &[crate::evidence::FlowStep]) -> Option<EntryRef> {
for step in steps {
if matches!(step.kind, FlowStepKind::Source) {
if let Some(ref func) = step.function {
if !func.is_empty() {
return Some(EntryRef {
file: step.file.clone(),
function: func.clone(),
});
}
}
}
}
None
}
/// Default toolchain label for a language (informational; harness builder
/// may override for locally-installed compilers/runtimes).
fn toolchain_id_for_lang(lang: Lang) -> &'static str {
match lang {
Lang::Rust => "rust-stable",
Lang::C => "gcc-stable",
Lang::Cpp => "g++-stable",
Lang::Java => "java-21",
Lang::Go => "go-stable",
Lang::Php => "php-8",
Lang::Python => "python-3",
Lang::Ruby => "ruby-3",
Lang::TypeScript | Lang::JavaScript => "node-20",
}
}
/// Blake3 hash of the spec's key fields, truncated to 8 bytes and hex-encoded.
///
/// Inputs (in order):
/// `SPEC_FORMAT_VERSION` (u32 LE), entry_file, entry_name, payload_slot tag
/// + value, expected_cap bits (u32 LE), sorted constraint_hints,
/// toolchain_id, `CORPUS_VERSION` (u32 LE).
///
/// Bump [`SPEC_FORMAT_VERSION`] when the inputs or semantics change.
fn compute_spec_hash(spec: &HarnessSpec) -> String {
let mut h = blake3::Hasher::new();
h.update(&SPEC_FORMAT_VERSION.to_le_bytes());
h.update(spec.entry_file.as_bytes());
h.update(b"\0");
h.update(spec.entry_name.as_bytes());
h.update(b"\0");
// Payload slot: tag byte + optional value
match &spec.payload_slot {
PayloadSlot::Param(n) => {
h.update(&[0u8]);
h.update(&(*n as u64).to_le_bytes());
}
PayloadSlot::QueryParam(s) => {
h.update(&[1u8]);
h.update(s.as_bytes());
}
PayloadSlot::HttpBody => {
h.update(&[2u8]);
}
PayloadSlot::EnvVar(s) => {
h.update(&[3u8]);
h.update(s.as_bytes());
}
PayloadSlot::Argv(n) => {
h.update(&[4u8]);
h.update(&(*n as u64).to_le_bytes());
}
PayloadSlot::Stdin => {
h.update(&[5u8]);
}
}
h.update(&spec.expected_cap.bits().to_le_bytes());
let mut hints = spec.constraint_hints.clone();
hints.sort_unstable();
for hint in &hints {
h.update(hint.as_bytes());
h.update(b"\0");
}
h.update(spec.toolchain_id.as_bytes());
h.update(b"\0");
h.update(&CORPUS_VERSION.to_le_bytes());
let out = h.finalize();
let bytes = out.as_bytes();
format!("{:016x}", u64::from_le_bytes(bytes[..8].try_into().unwrap()))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::evidence::{Evidence, FlowStep, FlowStepKind};
fn source_step(file: &str, function: &str) -> FlowStep {
FlowStep {
step: 1,
kind: FlowStepKind::Source,
file: file.into(),
line: 1,
col: 0,
snippet: None,
variable: Some("x".into()),
callee: None,
function: Some(function.into()),
is_cross_file: false,
}
}
fn sink_step(file: &str) -> FlowStep {
FlowStep {
step: 2,
kind: FlowStepKind::Sink,
file: file.into(),
line: 10,
col: 0,
snippet: None,
variable: None,
callee: None,
function: None,
is_cross_file: false,
}
}
#[test]
fn outermost_entry_picks_source_step() {
let steps = vec![source_step("src/main.rs", "handle_request"), sink_step("src/main.rs")];
let entry = outermost_entry(&steps).unwrap();
assert_eq!(entry.file, "src/main.rs");
assert_eq!(entry.function, "handle_request");
}
#[test]
fn outermost_entry_none_when_no_source() {
let steps = vec![sink_step("src/main.rs")];
assert!(outermost_entry(&steps).is_none());
}
#[test]
fn outermost_entry_none_when_source_has_no_function() {
let mut step = source_step("src/main.rs", "");
step.function = None;
let steps = vec![step, sink_step("src/main.rs")];
assert!(outermost_entry(&steps).is_none());
}
#[test]
fn from_finding_err_low_confidence() {
let diag = crate::commands::scan::Diag {
confidence: Some(Confidence::Low),
..Default::default()
};
assert_eq!(
HarnessSpec::from_finding(&diag).unwrap_err(),
UnsupportedReason::ConfidenceTooLow
);
}
#[test]
fn from_finding_err_no_flow_steps() {
let diag = crate::commands::scan::Diag {
confidence: Some(Confidence::Medium),
evidence: Some(Evidence::default()),
..Default::default()
};
assert_eq!(
HarnessSpec::from_finding(&diag).unwrap_err(),
UnsupportedReason::NoFlowSteps
);
}
#[test]
fn from_finding_ok_rust_medium_confidence() {
use crate::labels::Cap;
let evidence = Evidence {
flow_steps: vec![
source_step("src/handler.rs", "process"),
sink_step("src/handler.rs"),
],
sink_caps: Cap::SQL_QUERY.bits(),
..Default::default()
};
let diag = crate::commands::scan::Diag {
confidence: Some(Confidence::Medium),
evidence: Some(evidence),
..Default::default()
};
let spec = HarnessSpec::from_finding(&diag).unwrap();
assert_eq!(spec.lang, Lang::Rust);
assert_eq!(spec.entry_name, "process");
assert_eq!(spec.toolchain_id, "rust-stable");
assert!(!spec.spec_hash.is_empty());
}
#[test]
fn spec_hash_is_deterministic() {
use crate::labels::Cap;
let evidence = Evidence {
flow_steps: vec![
source_step("src/handler.rs", "process"),
sink_step("src/handler.rs"),
],
sink_caps: Cap::SQL_QUERY.bits(),
..Default::default()
};
let diag = crate::commands::scan::Diag {
confidence: Some(Confidence::High),
evidence: Some(evidence),
..Default::default()
};
let s1 = HarnessSpec::from_finding(&diag).unwrap();
let s2 = HarnessSpec::from_finding(&diag).unwrap();
assert_eq!(s1.spec_hash, s2.spec_hash);
}
}

View file

@ -8,29 +8,52 @@ use crate::dynamic::report::{AttemptSummary, VerifyResult, VerifyStatus};
use crate::dynamic::runner::{run_spec, RunError};
use crate::dynamic::sandbox::SandboxOptions;
use crate::dynamic::spec::HarnessSpec;
use crate::evidence::UnsupportedReason;
use crate::utils::config::Config;
#[derive(Debug, Clone, Default)]
pub struct VerifyOptions {
pub sandbox: SandboxOptions,
}
impl VerifyOptions {
/// Build `VerifyOptions` from scanner config.
///
/// Currently forwards sandbox timeout from `config.scanner`; future
/// milestones will add image/resource limits here.
pub fn from_config(_config: &Config) -> Self {
Self {
sandbox: SandboxOptions::default(),
}
}
}
/// 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 {
let finding_id = diag.id.clone();
// Use the stable hash to identify the finding so the VerifyResult's
// finding_id matches HarnessSpec::finding_id (both use the same hex form).
let finding_id = format!("{:016x}", diag.stable_hash);
let Some(spec) = HarnessSpec::from_finding(diag) else {
return VerifyResult {
finding_id,
status: VerifyStatus::Unsupported,
triggered_payload: None,
reason: Some("no harness spec derivable from finding".into()),
attempts: vec![],
};
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),
detail: None,
attempts: vec![],
};
}
};
// Spec derivable, but no backend implementation exists yet.
// Phase M1 always lands here; real execution starts in Phase M2.
let _ = &opts.sandbox;
match run_spec(&spec, &opts.sandbox) {
Ok(run) => {
let attempts = run
@ -50,6 +73,7 @@ pub fn verify_finding(diag: &Diag, opts: &VerifyOptions) -> VerifyResult {
status: VerifyStatus::Confirmed,
triggered_payload: Some(run.attempts[i].payload_label.to_string()),
reason: None,
detail: None,
attempts,
},
None => VerifyResult {
@ -57,6 +81,7 @@ pub fn verify_finding(diag: &Diag, opts: &VerifyOptions) -> VerifyResult {
status: VerifyStatus::NotConfirmed,
triggered_payload: None,
reason: None,
detail: None,
attempts,
},
}
@ -65,21 +90,24 @@ pub fn verify_finding(diag: &Diag, opts: &VerifyOptions) -> VerifyResult {
finding_id,
status: VerifyStatus::Unsupported,
triggered_payload: None,
reason: Some("no payload corpus for sink cap".into()),
reason: Some(UnsupportedReason::NoPayloadsForCap),
detail: None,
attempts: vec![],
},
Err(RunError::Harness(e)) => VerifyResult {
Err(RunError::Harness(_)) => VerifyResult {
finding_id,
status: VerifyStatus::Inconclusive,
status: VerifyStatus::Unsupported,
triggered_payload: None,
reason: Some(format!("harness build failed: {e:?}")),
reason: Some(UnsupportedReason::BackendUnavailable),
detail: None,
attempts: vec![],
},
Err(RunError::Sandbox(e)) => VerifyResult {
finding_id,
status: VerifyStatus::Inconclusive,
triggered_payload: None,
reason: Some(format!("sandbox failed: {e:?}")),
reason: None,
detail: Some(format!("sandbox failed: {e:?}")),
attempts: vec![],
},
}