mirror of
https://github.com/elicpeter/nyx.git
synced 2026-06-09 19:45:13 +02:00
[pitboss] phase 01: M1 — Spec extraction + --verify plumbing (no sandbox)
This commit is contained in:
parent
cb8688219a
commit
a10aba5d1f
25 changed files with 808 additions and 66 deletions
|
|
@ -9,6 +9,12 @@ export interface StartScanBody {
|
|||
scan_root?: string;
|
||||
mode?: ScanMode;
|
||||
engine_profile?: EngineProfile;
|
||||
/**
|
||||
* Run dynamic verification on findings after the static pass. Default false.
|
||||
* Backend currently accepts the field as a no-op; verification engine lands
|
||||
* in milestone M1 (see .pitboss/dynamic/context.md).
|
||||
*/
|
||||
verify?: boolean;
|
||||
}
|
||||
|
||||
export function useStartScan() {
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ export function NewScanModal({ open, onClose }: NewScanModalProps) {
|
|||
const [scanRoot, setScanRoot] = useState('');
|
||||
const [mode, setMode] = useState<ScanMode>('full');
|
||||
const [engineProfile, setEngineProfile] = useState<EngineProfile>('balanced');
|
||||
const [verify, setVerify] = useState(false);
|
||||
|
||||
const handleStart = async () => {
|
||||
const root = scanRoot.trim();
|
||||
|
|
@ -45,6 +46,7 @@ export function NewScanModal({ open, onClose }: NewScanModalProps) {
|
|||
if (root && root !== defaultRoot) body.scan_root = root;
|
||||
if (mode !== 'full') body.mode = mode;
|
||||
body.engine_profile = engineProfile;
|
||||
if (verify) body.verify = true;
|
||||
const payload = Object.keys(body).length ? body : undefined;
|
||||
try {
|
||||
await startScan.mutateAsync(payload);
|
||||
|
|
@ -105,6 +107,25 @@ export function NewScanModal({ open, onClose }: NewScanModalProps) {
|
|||
</select>
|
||||
<span className="form-hint">{PROFILE_HINTS[engineProfile]}</span>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Dynamic Verification</label>
|
||||
<div className="toggle-inline">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="new-scan-verify"
|
||||
checked={verify}
|
||||
onChange={(e) => setVerify(e.target.checked)}
|
||||
/>
|
||||
<label htmlFor="new-scan-verify">
|
||||
Build a harness and try to fire each finding's payload in a
|
||||
sandbox.
|
||||
</label>
|
||||
</div>
|
||||
<span className="form-hint">
|
||||
Opt-in for now; will become the default once calibrated. Adds
|
||||
wall-clock time per finding.
|
||||
</span>
|
||||
</div>
|
||||
<div className="scan-modal-actions">
|
||||
<button className="btn btn-sm" onClick={onClose}>
|
||||
Cancel
|
||||
|
|
|
|||
|
|
@ -102,6 +102,7 @@ fn parse_timeout_diag(path: &Path, timeout_ms: u64) -> Diag {
|
|||
rollup: None,
|
||||
finding_id: String::new(),
|
||||
alternative_finding_ids: Vec::new(),
|
||||
stable_hash: 0,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -706,6 +707,7 @@ fn build_taint_diag(
|
|||
rollup: None,
|
||||
finding_id: finding.finding_id.clone(),
|
||||
alternative_finding_ids: finding.alternative_finding_ids.to_vec(),
|
||||
stable_hash: 0,
|
||||
};
|
||||
|
||||
// Post-fill explanation and confidence limiters
|
||||
|
|
@ -1363,6 +1365,7 @@ impl<'a> ParsedSource<'a> {
|
|||
rollup: None,
|
||||
finding_id: String::new(),
|
||||
alternative_finding_ids: Vec::new(),
|
||||
stable_hash: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1984,6 +1987,7 @@ impl<'a> ParsedFile<'a> {
|
|||
rollup: None,
|
||||
finding_id: String::new(),
|
||||
alternative_finding_ids: Vec::new(),
|
||||
stable_hash: 0,
|
||||
});
|
||||
}
|
||||
} // end for body in bodies (CFG structural analyses)
|
||||
|
|
@ -2064,6 +2068,7 @@ impl<'a> ParsedFile<'a> {
|
|||
rollup: None,
|
||||
finding_id: String::new(),
|
||||
alternative_finding_ids: Vec::new(),
|
||||
stable_hash: 0,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1037,6 +1037,7 @@ fn auth_finding_to_diag(finding: &checks::AuthFinding, tree: &Tree, file_path: &
|
|||
rollup: None,
|
||||
finding_id: String::new(),
|
||||
alternative_finding_ids: Vec::new(),
|
||||
stable_hash: 0,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -429,6 +429,15 @@ pub enum Commands {
|
|||
/// Deprecated: use --mode cfg
|
||||
#[arg(long, hide = true)]
|
||||
cfg_only: bool,
|
||||
|
||||
/// Build a harness and dynamically verify each finding in a sandbox.
|
||||
///
|
||||
/// Requires the binary to be built with `--features dynamic`. Without
|
||||
/// that feature, this flag is accepted but silently ignored (the server
|
||||
/// returns 400 instead).
|
||||
#[cfg_attr(not(feature = "dynamic"), arg(hide = true))]
|
||||
#[arg(long, help_heading = "Dynamic")]
|
||||
verify: bool,
|
||||
},
|
||||
|
||||
/// Manage project indexes
|
||||
|
|
|
|||
|
|
@ -97,6 +97,7 @@ pub fn handle_command(
|
|||
high_only,
|
||||
ast_only,
|
||||
cfg_only,
|
||||
verify,
|
||||
} => {
|
||||
// ── Apply profile first (CLI flags override after) ──────────
|
||||
if let Some(ref name) = profile {
|
||||
|
|
@ -307,6 +308,16 @@ pub fn handle_command(
|
|||
// resolved straight from config; no CLI overrides yet.
|
||||
let _ = crate::utils::detector_options::install(config.detectors.clone());
|
||||
|
||||
// ── Dynamic verification ────────────────────────────────────
|
||||
#[cfg(feature = "dynamic")]
|
||||
if verify {
|
||||
config.scanner.verify = true;
|
||||
}
|
||||
// Without the dynamic feature, --verify is silently accepted (no-op).
|
||||
// The server returns 400 instead; see server/routes/scans.rs.
|
||||
#[cfg(not(feature = "dynamic"))]
|
||||
let _ = verify;
|
||||
|
||||
// ── --explain-engine: print resolved config and exit ────────
|
||||
if explain_engine {
|
||||
print_engine_explanation(config, engine_profile);
|
||||
|
|
|
|||
|
|
@ -180,6 +180,59 @@ pub struct Diag {
|
|||
/// no alternative paths.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub alternative_finding_ids: Vec<String>,
|
||||
/// Blake3 hash of `(rule_id, path, line, col, sink_caps)` truncated to
|
||||
/// 64 bits. Stable across scans for the same sink location and rule.
|
||||
/// Always present (no feature gate); enables M6.5 baseline diffing.
|
||||
/// Zero until the post-pass in `scan::handle` computes it.
|
||||
#[serde(default, skip_serializing_if = "is_zero_u64")]
|
||||
pub stable_hash: u64,
|
||||
}
|
||||
|
||||
fn is_zero_u64(v: &u64) -> bool {
|
||||
*v == 0
|
||||
}
|
||||
|
||||
impl Default for Diag {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
path: String::new(),
|
||||
line: 0,
|
||||
col: 0,
|
||||
severity: crate::patterns::Severity::Low,
|
||||
id: String::new(),
|
||||
category: crate::patterns::FindingCategory::Security,
|
||||
path_validated: false,
|
||||
guard_kind: None,
|
||||
message: None,
|
||||
labels: vec![],
|
||||
confidence: None,
|
||||
evidence: None,
|
||||
rank_score: None,
|
||||
rank_reason: None,
|
||||
suppressed: false,
|
||||
suppression: None,
|
||||
rollup: None,
|
||||
finding_id: String::new(),
|
||||
alternative_finding_ids: vec![],
|
||||
stable_hash: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Blake3 of `(rule_id, path, line, col, sink_caps)`, truncated to 64 bits.
|
||||
pub fn compute_stable_hash(diag: &Diag) -> u64 {
|
||||
let mut h = blake3::Hasher::new();
|
||||
h.update(diag.id.as_bytes());
|
||||
h.update(b"\0");
|
||||
h.update(diag.path.as_bytes());
|
||||
h.update(b"\0");
|
||||
h.update(&(diag.line as u64).to_le_bytes());
|
||||
h.update(&(diag.col as u64).to_le_bytes());
|
||||
let sink_caps = diag.evidence.as_ref().map_or(0u32, |e| e.sink_caps);
|
||||
h.update(&sink_caps.to_le_bytes());
|
||||
let out = h.finalize();
|
||||
let bytes = out.as_bytes();
|
||||
u64::from_le_bytes(bytes[..8].try_into().unwrap())
|
||||
}
|
||||
|
||||
/// Rollup data for grouped findings (e.g. 38 occurrences of `rs.quality.unwrap`).
|
||||
|
|
@ -413,6 +466,23 @@ pub fn handle(
|
|||
|
||||
tracing::debug!("Emitting {:?} issues (post-filter).", diags.len());
|
||||
|
||||
// ── Compute stable_hash for every surviving finding ──────────────────
|
||||
for diag in &mut diags {
|
||||
diag.stable_hash = compute_stable_hash(diag);
|
||||
}
|
||||
|
||||
// ── Dynamic verification (feature-gated) ─────────────────────────────
|
||||
#[cfg(feature = "dynamic")]
|
||||
if config.scanner.verify {
|
||||
let opts = crate::dynamic::verify::VerifyOptions::from_config(config);
|
||||
for diag in &mut diags {
|
||||
let result = crate::dynamic::verify::verify_finding(diag, &opts);
|
||||
if let Some(ref mut ev) = diag.evidence {
|
||||
ev.dynamic_verdict = Some(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Output ──────────────────────────────────────────────────────────
|
||||
match format {
|
||||
OutputFormat::Json => {
|
||||
|
|
@ -2989,6 +3059,7 @@ fn rollup_findings(
|
|||
}),
|
||||
finding_id: String::new(),
|
||||
alternative_finding_ids: Vec::new(),
|
||||
stable_hash: 0,
|
||||
};
|
||||
|
||||
rollups.push(rollup_diag);
|
||||
|
|
@ -3171,6 +3242,7 @@ mod dedup_taint_flow_tests {
|
|||
rollup: None,
|
||||
finding_id: String::new(),
|
||||
alternative_finding_ids: Vec::new(),
|
||||
stable_hash: 0,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -3338,6 +3410,7 @@ mod scc_tagging_tests {
|
|||
rollup: None,
|
||||
finding_id: String::new(),
|
||||
alternative_finding_ids: Vec::new(),
|
||||
stable_hash: 0,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -3629,6 +3702,7 @@ fn severity_filter_applied_at_output_stage() {
|
|||
rollup: None,
|
||||
finding_id: String::new(),
|
||||
alternative_finding_ids: Vec::new(),
|
||||
stable_hash: 0,
|
||||
},
|
||||
Diag {
|
||||
path: "src/main.rs".into(),
|
||||
|
|
@ -3650,6 +3724,7 @@ fn severity_filter_applied_at_output_stage() {
|
|||
rollup: None,
|
||||
finding_id: String::new(),
|
||||
alternative_finding_ids: Vec::new(),
|
||||
stable_hash: 0,
|
||||
},
|
||||
];
|
||||
|
||||
|
|
@ -3700,6 +3775,7 @@ mod prioritize_tests {
|
|||
rollup: None,
|
||||
finding_id: String::new(),
|
||||
alternative_finding_ids: Vec::new(),
|
||||
stable_hash: 0,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -4133,6 +4209,7 @@ mod prioritize_tests {
|
|||
}),
|
||||
finding_id: String::new(),
|
||||
alternative_finding_ids: Vec::new(),
|
||||
stable_hash: 0,
|
||||
};
|
||||
let json = serde_json::to_string(&d).unwrap();
|
||||
assert!(json.contains("\"rollup\""));
|
||||
|
|
|
|||
|
|
@ -852,6 +852,7 @@ pub mod index {
|
|||
rollup: None,
|
||||
finding_id: String::new(),
|
||||
alternative_finding_ids: Vec::new(),
|
||||
stable_hash: 0,
|
||||
})
|
||||
})?;
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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![],
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -154,6 +154,89 @@ pub struct SymbolicVerdict {
|
|||
pub cutoff_notes: Vec<String>,
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Dynamic verification verdict types (always present; not feature-gated)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Why dynamic verification cannot be attempted for a finding.
|
||||
///
|
||||
/// Typed so that callers can pattern-match on the reason rather than parsing
|
||||
/// strings. Serializes as PascalCase (e.g. `"BackendUnavailable"`).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub enum UnsupportedReason {
|
||||
/// The binary was not built with `--features dynamic`, or no backend
|
||||
/// implementation exists yet for this platform.
|
||||
BackendUnavailable,
|
||||
/// The entry kind (e.g. `HttpRoute`, `CliSubcommand`) is not yet supported;
|
||||
/// only `EntryKind::Function` is driven in current milestones.
|
||||
EntryKindUnsupported,
|
||||
/// Finding confidence is below `Medium`; dynamic verification is not
|
||||
/// attempted for low-confidence findings to avoid noise.
|
||||
ConfidenceTooLow,
|
||||
/// The finding has no `flow_steps` from which to derive an entry point.
|
||||
NoFlowSteps,
|
||||
/// No payload corpus exists for the sink capability.
|
||||
NoPayloadsForCap,
|
||||
/// A `HarnessSpec` could not be derived from the finding (missing entry
|
||||
/// function, unresolvable language, or zero sink capability bits).
|
||||
SpecDerivationFailed,
|
||||
}
|
||||
|
||||
/// High-level outcome of a dynamic verification attempt.
|
||||
///
|
||||
/// Serializes as PascalCase (`"Confirmed"`, `"NotConfirmed"`, etc.).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
pub enum VerifyStatus {
|
||||
/// Sink fired with at least one payload. The 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 positive".
|
||||
NotConfirmed,
|
||||
/// Could not build, run, or observe (toolchain missing, sandbox refused,
|
||||
/// timeout on every attempt, etc.).
|
||||
Inconclusive,
|
||||
/// Dynamic verification was not attempted. See `reason` for the typed cause.
|
||||
Unsupported,
|
||||
}
|
||||
|
||||
/// Summary of a single payload attempt.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AttemptSummary {
|
||||
pub payload_label: String,
|
||||
pub exit_code: Option<i32>,
|
||||
pub timed_out: bool,
|
||||
pub triggered: bool,
|
||||
}
|
||||
|
||||
/// Result of a dynamic verification attempt for one finding.
|
||||
///
|
||||
/// Always present when `config.scanner.verify` is true and the `dynamic`
|
||||
/// feature is enabled. The `status` field is the high-level verdict;
|
||||
/// `reason` carries the typed `UnsupportedReason` when status is
|
||||
/// `Unsupported`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct VerifyResult {
|
||||
/// Stable ID of the finding this result is for.
|
||||
pub finding_id: String,
|
||||
/// High-level outcome.
|
||||
pub status: VerifyStatus,
|
||||
/// Label of the payload that triggered, when `status == Confirmed`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub triggered_payload: Option<String>,
|
||||
/// Typed reason for `Unsupported` status.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub reason: Option<UnsupportedReason>,
|
||||
/// Free-form error detail (used for `Inconclusive` status).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub detail: Option<String>,
|
||||
/// Per-attempt log.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub attempts: Vec<AttemptSummary>,
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Evidence
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
|
@ -241,6 +324,12 @@ pub struct Evidence {
|
|||
/// summary path that did not preserve destination metadata.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub data_exfil_field: Option<String>,
|
||||
|
||||
/// Result of dynamic verification for this finding, when
|
||||
/// `config.scanner.verify` is true and the `dynamic` feature is enabled.
|
||||
/// Always `None` in static-only scans and in non-dynamic builds.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub dynamic_verdict: Option<VerifyResult>,
|
||||
}
|
||||
|
||||
fn is_zero_cap_bits(v: &u32) -> bool {
|
||||
|
|
@ -266,6 +355,7 @@ impl Evidence {
|
|||
&& self.symbolic.is_none()
|
||||
&& self.sink_caps == 0
|
||||
&& self.engine_notes.is_empty()
|
||||
&& self.dynamic_verdict.is_none()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -809,6 +899,7 @@ mod tests {
|
|||
rollup: None,
|
||||
finding_id: String::new(),
|
||||
alternative_finding_ids: Vec::new(),
|
||||
stable_hash: 0,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
14
src/fmt.rs
14
src/fmt.rs
|
|
@ -763,6 +763,7 @@ mod tests {
|
|||
rollup: None,
|
||||
finding_id: String::new(),
|
||||
alternative_finding_ids: Vec::new(),
|
||||
stable_hash: 0,
|
||||
},
|
||||
Diag {
|
||||
path: "src/b.rs".into(),
|
||||
|
|
@ -784,6 +785,7 @@ mod tests {
|
|||
rollup: None,
|
||||
finding_id: String::new(),
|
||||
alternative_finding_ids: Vec::new(),
|
||||
stable_hash: 0,
|
||||
},
|
||||
];
|
||||
let output = render_console(&diags, "test-project", None);
|
||||
|
|
@ -819,6 +821,7 @@ mod tests {
|
|||
rollup: None,
|
||||
finding_id: String::new(),
|
||||
alternative_finding_ids: Vec::new(),
|
||||
stable_hash: 0,
|
||||
}];
|
||||
let output = render_console(&diags, "proj", None);
|
||||
let stripped = strip_ansi(&output);
|
||||
|
|
@ -854,6 +857,7 @@ mod tests {
|
|||
rollup: None,
|
||||
finding_id: String::new(),
|
||||
alternative_finding_ids: Vec::new(),
|
||||
stable_hash: 0,
|
||||
},
|
||||
Diag {
|
||||
path: "src/a.rs".into(),
|
||||
|
|
@ -875,6 +879,7 @@ mod tests {
|
|||
rollup: None,
|
||||
finding_id: String::new(),
|
||||
alternative_finding_ids: Vec::new(),
|
||||
stable_hash: 0,
|
||||
},
|
||||
];
|
||||
let output = render_console(&diags, "proj", None);
|
||||
|
|
@ -908,6 +913,7 @@ mod tests {
|
|||
rollup: None,
|
||||
finding_id: String::new(),
|
||||
alternative_finding_ids: Vec::new(),
|
||||
stable_hash: 0,
|
||||
};
|
||||
let json = serde_json::to_string(&d).unwrap();
|
||||
assert!(
|
||||
|
|
@ -938,6 +944,7 @@ mod tests {
|
|||
rollup: None,
|
||||
finding_id: String::new(),
|
||||
alternative_finding_ids: Vec::new(),
|
||||
stable_hash: 0,
|
||||
};
|
||||
let json = serde_json::to_string(&d).unwrap();
|
||||
assert!(
|
||||
|
|
@ -972,6 +979,7 @@ mod tests {
|
|||
rollup: None,
|
||||
finding_id: String::new(),
|
||||
alternative_finding_ids: Vec::new(),
|
||||
stable_hash: 0,
|
||||
};
|
||||
let json = serde_json::to_string(&d).unwrap();
|
||||
assert!(
|
||||
|
|
@ -1065,6 +1073,7 @@ mod tests {
|
|||
rollup: None,
|
||||
finding_id: String::new(),
|
||||
alternative_finding_ids: Vec::new(),
|
||||
stable_hash: 0,
|
||||
};
|
||||
let output = render_diag(&d, 120);
|
||||
let stripped = strip_ansi(&output);
|
||||
|
|
@ -1111,6 +1120,7 @@ mod tests {
|
|||
rollup: None,
|
||||
finding_id: String::new(),
|
||||
alternative_finding_ids: Vec::new(),
|
||||
stable_hash: 0,
|
||||
};
|
||||
let output = render_diag(&d, 100);
|
||||
let stripped = strip_ansi(&output);
|
||||
|
|
@ -1143,6 +1153,7 @@ mod tests {
|
|||
rollup: None,
|
||||
finding_id: String::new(),
|
||||
alternative_finding_ids: Vec::new(),
|
||||
stable_hash: 0,
|
||||
};
|
||||
let output = render_diag(&d, 100);
|
||||
let stripped = strip_ansi(&output);
|
||||
|
|
@ -1179,6 +1190,7 @@ mod tests {
|
|||
rollup: None,
|
||||
finding_id: String::new(),
|
||||
alternative_finding_ids: Vec::new(),
|
||||
stable_hash: 0,
|
||||
};
|
||||
let output = render_diag(&d, 100);
|
||||
let stripped = strip_ansi(&output);
|
||||
|
|
@ -1211,6 +1223,7 @@ mod tests {
|
|||
rollup: None,
|
||||
finding_id: String::new(),
|
||||
alternative_finding_ids: Vec::new(),
|
||||
stable_hash: 0,
|
||||
};
|
||||
let json = serde_json::to_string(&d).unwrap();
|
||||
assert!(
|
||||
|
|
@ -1257,6 +1270,7 @@ mod tests {
|
|||
rollup: None,
|
||||
finding_id: String::new(),
|
||||
alternative_finding_ids: Vec::new(),
|
||||
stable_hash: 0,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -368,6 +368,7 @@ mod tests {
|
|||
rollup: None,
|
||||
finding_id: String::new(),
|
||||
alternative_finding_ids: Vec::new(),
|
||||
stable_hash: 0,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -85,6 +85,7 @@ pub fn scan_ejs_file(path: &Path, bytes: &[u8]) -> Vec<Diag> {
|
|||
rollup: None,
|
||||
finding_id: String::new(),
|
||||
alternative_finding_ids: Vec::new(),
|
||||
stable_hash: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -360,6 +360,7 @@ mod tests {
|
|||
rollup: None,
|
||||
finding_id: String::new(),
|
||||
alternative_finding_ids: Vec::new(),
|
||||
stable_hash: 0,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -619,6 +619,7 @@ mod tests {
|
|||
rollup: None,
|
||||
finding_id: String::new(),
|
||||
alternative_finding_ids: Vec::new(),
|
||||
stable_hash: 0,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -880,6 +880,7 @@ mod tests {
|
|||
rollup: None,
|
||||
finding_id: String::new(),
|
||||
alternative_finding_ids: Vec::new(),
|
||||
stable_hash: 0,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -34,6 +34,10 @@ struct StartScanRequest {
|
|||
mode: Option<String>,
|
||||
/// Engine-depth profile: "fast" | "balanced" | "deep".
|
||||
engine_profile: Option<String>,
|
||||
/// Run dynamic verification on findings after the static pass. Default false.
|
||||
/// Requires the binary to be built with `--features dynamic`; returns 400
|
||||
/// when the feature is absent and `verify: true` is requested.
|
||||
verify: Option<bool>,
|
||||
#[allow(dead_code)]
|
||||
languages: Option<Vec<String>>,
|
||||
#[allow(dead_code)]
|
||||
|
|
@ -93,6 +97,19 @@ async fn start_scan(
|
|||
apply_engine_profile(&mut config, profile)?;
|
||||
}
|
||||
|
||||
if req.verify == Some(true) {
|
||||
#[cfg(feature = "dynamic")]
|
||||
{
|
||||
config.scanner.verify = true;
|
||||
}
|
||||
#[cfg(not(feature = "dynamic"))]
|
||||
{
|
||||
return Err(bad_request(
|
||||
"binary built without --features dynamic; cannot use verify",
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let event_tx = state.event_tx.clone();
|
||||
let db_pool = state.db_pool.clone();
|
||||
let database_dir = state.database_dir.clone();
|
||||
|
|
|
|||
|
|
@ -248,6 +248,16 @@ pub struct ScannerConfig {
|
|||
/// subsystem still carries the stable detection; flipping to `true`
|
||||
/// enables the taint-based path alongside it.
|
||||
pub enable_auth_as_taint: bool,
|
||||
|
||||
/// Run dynamic verification on each finding after the static pass.
|
||||
///
|
||||
/// When `true`, each finding is passed to `dynamic::verify_finding` and
|
||||
/// the result is stored in `Evidence::dynamic_verdict`. Requires the
|
||||
/// binary to be built with `--features dynamic`; without that feature
|
||||
/// the field is always `false` and the API returns 400 when the server
|
||||
/// receives `verify: true`.
|
||||
#[serde(default)]
|
||||
pub verify: bool,
|
||||
}
|
||||
impl Default for ScannerConfig {
|
||||
fn default() -> Self {
|
||||
|
|
@ -285,6 +295,7 @@ impl Default for ScannerConfig {
|
|||
enable_auth_analysis: true,
|
||||
enable_panic_recovery: false,
|
||||
enable_auth_as_taint: false,
|
||||
verify: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -102,6 +102,7 @@ fn make_diag(
|
|||
rollup: None,
|
||||
finding_id: String::new(),
|
||||
alternative_finding_ids: vec![],
|
||||
stable_hash: 0,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
102
tests/dynamic_layering.rs
Normal file
102
tests/dynamic_layering.rs
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
//! Layering boundary test: ensures the dynamic module is only referenced from
|
||||
//! the allowed crossing points in the static codebase.
|
||||
//!
|
||||
//! The dynamic module is feature-gated (`--features dynamic`). Call sites
|
||||
//! outside the allowed set create an implicit dependency on the feature flag
|
||||
//! that the static-analysis path must never have. This test fails fast when
|
||||
//! new code accidentally reaches into `crate::dynamic` from a module that
|
||||
//! should remain feature-agnostic.
|
||||
//!
|
||||
//! # Allowed crossings
|
||||
//!
|
||||
//! | File | Reason |
|
||||
//! |------------------------------|-------------------------------------------|
|
||||
//! | `src/main.rs` | binary entry point; wires --features dynamic|
|
||||
//! | `src/lib.rs` | crate root; `#[cfg(feature="dynamic")]` mod|
|
||||
//! | `src/commands/scan.rs` | enrichment loop lives here |
|
||||
//! | `src/server/` (any file) | server start_scan verify wiring |
|
||||
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Files/prefixes that are allowed to reference `crate::dynamic` (or
|
||||
/// `dynamic::`) directly. Paths are relative to `src/` (no leading `src/`).
|
||||
const ALLOWED: &[&str] = &[
|
||||
"main.rs",
|
||||
"lib.rs",
|
||||
"commands/scan.rs",
|
||||
"server/",
|
||||
// The dynamic module itself is obviously allowed.
|
||||
"dynamic/",
|
||||
];
|
||||
|
||||
fn collect_rs_files(dir: &Path, out: &mut Vec<PathBuf>) {
|
||||
let Ok(entries) = fs::read_dir(dir) else {
|
||||
return;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
collect_rs_files(&path, out);
|
||||
} else if path.extension().and_then(|e| e.to_str()) == Some("rs") {
|
||||
out.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_allowed(path: &Path, src_root: &Path) -> bool {
|
||||
let rel = path
|
||||
.strip_prefix(src_root)
|
||||
.unwrap_or(path)
|
||||
.to_string_lossy();
|
||||
ALLOWED
|
||||
.iter()
|
||||
.any(|allowed| rel.starts_with(allowed) || rel.as_ref() == *allowed)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dynamic_module_only_referenced_from_allowed_files() {
|
||||
let src_root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src");
|
||||
|
||||
let mut files = Vec::new();
|
||||
collect_rs_files(&src_root, &mut files);
|
||||
|
||||
let mut violations: Vec<String> = Vec::new();
|
||||
|
||||
for path in &files {
|
||||
if is_allowed(path, &src_root) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let content = fs::read_to_string(path).unwrap_or_default();
|
||||
// Look for any reference to the dynamic module.
|
||||
// Exclude `// dynamic` style comments and doc strings.
|
||||
for (lineno, line) in content.lines().enumerate() {
|
||||
let trimmed = line.trim();
|
||||
// Skip comment lines.
|
||||
if trimmed.starts_with("//") || trimmed.starts_with("*") {
|
||||
continue;
|
||||
}
|
||||
if trimmed.contains("crate::dynamic")
|
||||
|| trimmed.contains("dynamic::")
|
||||
|| trimmed.contains("use crate::dynamic")
|
||||
{
|
||||
let rel = path
|
||||
.strip_prefix(&src_root)
|
||||
.unwrap_or(path)
|
||||
.display()
|
||||
.to_string();
|
||||
violations.push(format!("{}:{}: {}", rel, lineno + 1, trimmed));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !violations.is_empty() {
|
||||
panic!(
|
||||
"Files outside allowed crossings reference `crate::dynamic`:\n{}\n\
|
||||
Add the file to ALLOWED in tests/dynamic_layering.rs if the \
|
||||
reference is intentional.",
|
||||
violations.join("\n")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -69,6 +69,7 @@ fn high_confidence_taint_diag(path: &str, line: u32) -> Diag {
|
|||
rollup: None,
|
||||
finding_id: String::new(),
|
||||
alternative_finding_ids: Vec::new(),
|
||||
stable_hash: 0,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ fn diag(severity: Severity, id: &str, conf: Option<Confidence>) -> Diag {
|
|||
rollup: None,
|
||||
finding_id: String::new(),
|
||||
alternative_finding_ids: Vec::new(),
|
||||
stable_hash: 0,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue