mirror of
https://github.com/elicpeter/nyx.git
synced 2026-06-18 20:15:14 +02:00
[pitboss] phase 01: Track A.1 — Spec derivation strategy enum + flow-steps-optional fallback
This commit is contained in:
parent
5909fa8c5d
commit
31d9ef725a
21 changed files with 1106 additions and 62 deletions
|
|
@ -0,0 +1,9 @@
|
|||
# Fixture: spec derived via FromCallgraphEntry (rule id matches `*.http.*`,
|
||||
# entry point classified as HttpRoute).
|
||||
from flask import Flask, request
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
@app.route("/echo")
|
||||
def echo():
|
||||
return request.args.get("q", "")
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
# Fixture: spec derived via FromFlowSteps (taint flow with explicit source/sink).
|
||||
import os
|
||||
|
||||
def handle_request(payload):
|
||||
cmd = payload
|
||||
os.system(cmd)
|
||||
11
tests/dynamic_fixtures/spec_strategies/func_summary_walk.rs
Normal file
11
tests/dynamic_fixtures/spec_strategies/func_summary_walk.rs
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
// Fixture: spec derived via FromFuncSummaryWalk (FuncSummary records
|
||||
// `tainted_sink_params` for a param that flows to a sink, without an
|
||||
// in-evidence flow_steps trace).
|
||||
|
||||
fn read_path(_root: &str, name: &str) -> std::io::Result<Vec<u8>> {
|
||||
std::fs::read(name)
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let _ = read_path("/", "/etc/passwd");
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
# Fixture: spec derived via FromRuleNamespace (AST pattern `py.cmdi.os_system`
|
||||
# without a taint flow).
|
||||
import os
|
||||
|
||||
def run_user_command(user_arg):
|
||||
os.system(user_arg)
|
||||
|
|
@ -33,6 +33,7 @@ mod repro_determinism_tests {
|
|||
sink_file: "app.py".into(),
|
||||
sink_line: 10,
|
||||
spec_hash: spec_hash.to_owned(),
|
||||
derivation: nyx_scanner::dynamic::spec::SpecDerivationStrategy::FromFlowSteps,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -163,6 +164,7 @@ mod repro_determinism_tests {
|
|||
sink_file: "src/entry.rs".into(),
|
||||
sink_line: 18,
|
||||
spec_hash: spec_hash.to_owned(),
|
||||
derivation: nyx_scanner::dynamic::spec::SpecDerivationStrategy::FromFlowSteps,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -293,6 +295,7 @@ fn main() {
|
|||
sink_file: "tests/dynamic_fixtures/js/sqli_positive.js".into(),
|
||||
sink_line: 8,
|
||||
spec_hash: spec_hash.to_owned(),
|
||||
derivation: nyx_scanner::dynamic::spec::SpecDerivationStrategy::FromFlowSteps,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -346,6 +349,7 @@ fn main() {
|
|||
sink_file: "tests/dynamic_fixtures/go/sqli_positive.go".into(),
|
||||
sink_line: 12,
|
||||
spec_hash: spec_hash.to_owned(),
|
||||
derivation: nyx_scanner::dynamic::spec::SpecDerivationStrategy::FromFlowSteps,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -399,6 +403,7 @@ fn main() {
|
|||
sink_file: "tests/dynamic_fixtures/java/sqli_positive.java".into(),
|
||||
sink_line: 9,
|
||||
spec_hash: spec_hash.to_owned(),
|
||||
derivation: nyx_scanner::dynamic::spec::SpecDerivationStrategy::FromFlowSteps,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -452,6 +457,7 @@ fn main() {
|
|||
sink_file: "tests/dynamic_fixtures/php/sqli_positive.php".into(),
|
||||
sink_line: 9,
|
||||
spec_hash: spec_hash.to_owned(),
|
||||
derivation: nyx_scanner::dynamic::spec::SpecDerivationStrategy::FromFlowSteps,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
281
tests/spec_derivation_strategies.rs
Normal file
281
tests/spec_derivation_strategies.rs
Normal file
|
|
@ -0,0 +1,281 @@
|
|||
//! Phase 01, Track A.1: integration coverage for
|
||||
//! `HarnessSpec::from_finding_opts` strategy fall-through.
|
||||
//!
|
||||
//! Exercises each `SpecDerivationStrategy` end-to-end:
|
||||
//!
|
||||
//! 1. [`FromFlowSteps`] — explicit flow_steps in evidence.
|
||||
//! 2. [`FromRuleNamespace`] — rule id namespace + sink_caps.
|
||||
//! 3. [`FromFuncSummaryWalk`] — walking `FuncSummary::tainted_sink_params`.
|
||||
//! 4. [`FromCallgraphEntry`] — `*.http.*` rule id → HttpRoute entry.
|
||||
//!
|
||||
//! Also asserts that
|
||||
//! [`crate::evidence::InconclusiveReason::SpecDerivationFailed`] is surfaced
|
||||
//! when no strategy succeeds but the finding had derivable signal.
|
||||
//!
|
||||
//! Gated on `--features dynamic`; the strategy types live in
|
||||
//! `dynamic::spec` but the `InconclusiveReason` payload is always-present.
|
||||
|
||||
#[cfg(feature = "dynamic")]
|
||||
mod spec_strategies {
|
||||
use nyx_scanner::commands::scan::Diag;
|
||||
use nyx_scanner::dynamic::spec::{
|
||||
derive_from_callgraph_entry, derive_from_func_summary, derive_from_rule_namespace,
|
||||
EntryKind, HarnessSpec, PayloadSlot, SpecDerivationStrategy,
|
||||
};
|
||||
use nyx_scanner::dynamic::verify::{verify_finding, VerifyOptions};
|
||||
use nyx_scanner::evidence::{
|
||||
Confidence, Evidence, FlowStep, FlowStepKind, InconclusiveReason, UnsupportedReason,
|
||||
VerifyStatus,
|
||||
};
|
||||
use nyx_scanner::labels::Cap;
|
||||
use nyx_scanner::patterns::{FindingCategory, Severity};
|
||||
use nyx_scanner::summary::FuncSummary;
|
||||
|
||||
fn make_diag(id: &str, path: &str, line: usize) -> Diag {
|
||||
Diag {
|
||||
path: path.into(),
|
||||
line,
|
||||
col: 0,
|
||||
severity: Severity::High,
|
||||
id: id.into(),
|
||||
category: FindingCategory::Security,
|
||||
path_validated: false,
|
||||
guard_kind: None,
|
||||
message: None,
|
||||
labels: vec![],
|
||||
confidence: Some(Confidence::High),
|
||||
evidence: Some(Evidence::default()),
|
||||
rank_score: None,
|
||||
rank_reason: None,
|
||||
suppressed: false,
|
||||
suppression: None,
|
||||
rollup: None,
|
||||
finding_id: String::new(),
|
||||
alternative_finding_ids: vec![],
|
||||
stable_hash: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn source_step(file: &str, function: &str) -> FlowStep {
|
||||
FlowStep {
|
||||
step: 1,
|
||||
kind: FlowStepKind::Source,
|
||||
file: file.into(),
|
||||
line: 4,
|
||||
col: 0,
|
||||
snippet: None,
|
||||
variable: Some("payload".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: 6,
|
||||
col: 0,
|
||||
snippet: Some("os.system".into()),
|
||||
variable: None,
|
||||
callee: Some("os.system".into()),
|
||||
function: None,
|
||||
is_cross_file: false,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Strategy 1: FromFlowSteps ────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn from_flow_steps_strategy_drives_taint_finding() {
|
||||
let mut diag = make_diag(
|
||||
"taint-unsanitised-flow (source 4:0)",
|
||||
"tests/dynamic_fixtures/spec_strategies/flow_steps_taint.py",
|
||||
6,
|
||||
);
|
||||
let mut ev = Evidence::default();
|
||||
ev.flow_steps = vec![
|
||||
source_step("tests/dynamic_fixtures/spec_strategies/flow_steps_taint.py", "handle_request"),
|
||||
sink_step("tests/dynamic_fixtures/spec_strategies/flow_steps_taint.py"),
|
||||
];
|
||||
ev.sink_caps = Cap::SHELL_ESCAPE.bits();
|
||||
diag.evidence = Some(ev);
|
||||
|
||||
let spec = HarnessSpec::from_finding(&diag).expect("flow_steps strategy must succeed");
|
||||
assert_eq!(spec.derivation, SpecDerivationStrategy::FromFlowSteps);
|
||||
assert_eq!(spec.entry_name, "handle_request");
|
||||
assert_eq!(spec.expected_cap, Cap::SHELL_ESCAPE);
|
||||
}
|
||||
|
||||
// ── Strategy 2: FromRuleNamespace ────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn from_rule_namespace_strategy_drives_ast_finding() {
|
||||
let mut diag = make_diag(
|
||||
"py.cmdi.os_system",
|
||||
"tests/dynamic_fixtures/spec_strategies/rule_namespace_cmdi.py",
|
||||
6,
|
||||
);
|
||||
// Empty flow_steps, but sink_caps set on evidence.
|
||||
let mut ev = Evidence::default();
|
||||
ev.sink_caps = Cap::SHELL_ESCAPE.bits();
|
||||
diag.evidence = Some(ev);
|
||||
|
||||
let spec = HarnessSpec::from_finding(&diag).expect("rule-namespace strategy must succeed");
|
||||
assert_eq!(spec.derivation, SpecDerivationStrategy::FromRuleNamespace);
|
||||
assert_eq!(spec.expected_cap, Cap::SHELL_ESCAPE);
|
||||
assert_eq!(spec.toolchain_id, "python-3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_rule_namespace_called_directly_returns_some() {
|
||||
let mut diag = make_diag(
|
||||
"java.deser.readobject",
|
||||
"src/Main.java",
|
||||
12,
|
||||
);
|
||||
let mut ev = Evidence::default();
|
||||
ev.sink_caps = Cap::DESERIALIZE.bits();
|
||||
diag.evidence = Some(ev.clone());
|
||||
let spec = derive_from_rule_namespace(&diag, &ev).expect("must succeed");
|
||||
assert_eq!(spec.derivation, SpecDerivationStrategy::FromRuleNamespace);
|
||||
assert_eq!(spec.expected_cap, Cap::DESERIALIZE);
|
||||
}
|
||||
|
||||
// ── Strategy 3: FromFuncSummaryWalk ──────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn from_func_summary_strategy_picks_first_tainted_param() {
|
||||
let mut diag = make_diag(
|
||||
"cfg-unguarded-sink",
|
||||
"tests/dynamic_fixtures/spec_strategies/func_summary_walk.rs",
|
||||
5,
|
||||
);
|
||||
diag.evidence = Some(Evidence::default());
|
||||
let summary = FuncSummary {
|
||||
name: "read_path".into(),
|
||||
file_path: "tests/dynamic_fixtures/spec_strategies/func_summary_walk.rs".into(),
|
||||
lang: "rust".into(),
|
||||
param_count: 2,
|
||||
param_names: vec!["root".into(), "name".into()],
|
||||
source_caps: 0,
|
||||
sanitizer_caps: 0,
|
||||
sink_caps: Cap::FILE_IO.bits(),
|
||||
propagating_params: vec![],
|
||||
propagates_taint: false,
|
||||
tainted_sink_params: vec![1],
|
||||
param_to_sink: vec![],
|
||||
callees: vec![],
|
||||
container: String::new(),
|
||||
disambig: None,
|
||||
kind: Default::default(),
|
||||
module_path: None,
|
||||
rust_use_map: None,
|
||||
rust_wildcards: None,
|
||||
hierarchy_edges: vec![],
|
||||
entry_kind: None,
|
||||
};
|
||||
let spec =
|
||||
derive_from_func_summary(&diag, diag.evidence.as_ref().unwrap(), Some(&summary))
|
||||
.expect("summary strategy must succeed");
|
||||
assert_eq!(spec.derivation, SpecDerivationStrategy::FromFuncSummaryWalk);
|
||||
assert!(matches!(spec.payload_slot, PayloadSlot::Param(1)));
|
||||
assert_eq!(spec.entry_name, "read_path");
|
||||
}
|
||||
|
||||
// ── Strategy 4: FromCallgraphEntry ───────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn from_callgraph_entry_strategy_marks_http_route() {
|
||||
let mut diag = make_diag(
|
||||
"py.http.flask_route",
|
||||
"tests/dynamic_fixtures/spec_strategies/callgraph_entry_http.py",
|
||||
8,
|
||||
);
|
||||
let mut ev = Evidence::default();
|
||||
ev.sink_caps = Cap::SSRF.bits();
|
||||
diag.evidence = Some(ev);
|
||||
|
||||
let spec = HarnessSpec::from_finding(&diag).expect("callgraph-entry strategy must succeed");
|
||||
assert_eq!(spec.derivation, SpecDerivationStrategy::FromCallgraphEntry);
|
||||
assert!(matches!(spec.entry_kind, EntryKind::HttpRoute));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_callgraph_entry_called_directly_returns_some() {
|
||||
let mut diag = make_diag(
|
||||
"rs.cli.subcommand_parse",
|
||||
"src/main.rs",
|
||||
10,
|
||||
);
|
||||
let mut ev = Evidence::default();
|
||||
ev.sink_caps = Cap::SHELL_ESCAPE.bits();
|
||||
diag.evidence = Some(ev.clone());
|
||||
|
||||
let spec = derive_from_callgraph_entry(&diag, &ev).expect("must succeed");
|
||||
assert_eq!(spec.derivation, SpecDerivationStrategy::FromCallgraphEntry);
|
||||
assert!(matches!(spec.entry_kind, EntryKind::CliSubcommand));
|
||||
}
|
||||
|
||||
// ── Failure path: Inconclusive(SpecDerivationFailed) ─────────────────────
|
||||
|
||||
#[test]
|
||||
fn verify_finding_surfaces_inconclusive_when_strategies_exhaust_signal() {
|
||||
// Rule namespace identifies a known sink class (`cmdi`), but the path
|
||||
// language disagrees with the rule's language and there are no
|
||||
// flow_steps to fall back on. Every strategy bails — but the finding
|
||||
// had usable signal, so the verifier reports Inconclusive.
|
||||
let mut diag = make_diag("py.cmdi.os_system", "src/Main.java", 5);
|
||||
let mut ev = Evidence::default();
|
||||
ev.sink_caps = Cap::SHELL_ESCAPE.bits();
|
||||
diag.evidence = Some(ev);
|
||||
|
||||
let result = verify_finding(&diag, &VerifyOptions::default());
|
||||
assert_eq!(result.status, VerifyStatus::Inconclusive);
|
||||
match result.inconclusive_reason {
|
||||
Some(InconclusiveReason::SpecDerivationFailed { tried, hint }) => {
|
||||
assert_eq!(tried.len(), 4);
|
||||
assert!(!hint.is_empty(), "hint must summarise the failed inputs");
|
||||
}
|
||||
other => panic!("expected SpecDerivationFailed, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verify_finding_surfaces_unsupported_when_no_signal_at_all() {
|
||||
// No evidence struct, no rule namespace, no path. Genuinely
|
||||
// unmodellable → Unsupported(NoFlowSteps).
|
||||
let diag = make_diag("", "", 0);
|
||||
let diag = Diag {
|
||||
evidence: None,
|
||||
..diag
|
||||
};
|
||||
let result = verify_finding(&diag, &VerifyOptions::default());
|
||||
assert_eq!(result.status, VerifyStatus::Unsupported);
|
||||
assert_eq!(result.reason, Some(UnsupportedReason::NoFlowSteps));
|
||||
}
|
||||
|
||||
// ── Strategy ordering ────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn strategy_priority_flow_steps_wins_over_rule_namespace() {
|
||||
// Both signals present: flow_steps wins because it's first in
|
||||
// `HarnessSpec::derivation_strategies()`.
|
||||
let mut diag = make_diag(
|
||||
"py.cmdi.os_system",
|
||||
"tests/dynamic_fixtures/spec_strategies/flow_steps_taint.py",
|
||||
6,
|
||||
);
|
||||
let mut ev = Evidence::default();
|
||||
ev.flow_steps = vec![
|
||||
source_step("tests/dynamic_fixtures/spec_strategies/flow_steps_taint.py", "handle_request"),
|
||||
sink_step("tests/dynamic_fixtures/spec_strategies/flow_steps_taint.py"),
|
||||
];
|
||||
ev.sink_caps = Cap::SHELL_ESCAPE.bits();
|
||||
diag.evidence = Some(ev);
|
||||
let spec = HarnessSpec::from_finding(&diag).unwrap();
|
||||
assert_eq!(spec.derivation, SpecDerivationStrategy::FromFlowSteps);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue