mirror of
https://github.com/elicpeter/nyx.git
synced 2026-07-03 20:41:00 +02:00
Prerelease cleanup (#46)
* feat: Add const_bound_vars tracking to prevent false positives in ownership checks
* feat: Introduce field interner and typed bounded vars for enhanced type tracking
* feat: Add typed_call_receivers and typed_bounded_dto_fields for enhanced type tracking
* feat: Centralize method name extraction with bare_method_name helper
* feat: Implement Phase-6 hierarchy fan-out for runtime virtual dispatch
* feat: Enhance C++ taint tracking with additional container operations and inline method resolution
* feat: Introduce field-sensitive points-to analysis for enhanced resource tracking
* feat: Implement Pointer-Phase 6 subscript handling for enhanced container analysis
* test: Add comprehensive tests for JavaScript control flow constructs and lattice operations
* docs: Update advanced analysis documentation with field-sensitive points-to and hierarchy fan-out details
* test: Add comprehensive tests for lattice algebra laws and SSA edge cases
* feat: Add destructured session user handling and safe user ID access patterns
* feat: Implement row-population reverse-walk for enhanced authorization checks
* feat: Enhance authorization checks with local alias chain for self-actor types
* feat: Introduce ActiveRecord query safety checks and enhance snippet extraction
* feat: Implement chained method call inner-gate rebinding for SSRF prevention
* feat: Add observability and error modules, enhance debug functionality, and implement theme context
* feat: Remove Auth Analysis page and update navigation to redirect to Explorer
* feat: Optimize SSA lowering by sharing results between taint engine and artifact extractor
* feat: Optimize SSA lowering by sharing results between taint engine and artifact extractor
* feat: Reset path-safe-suppressed spans before lowering to maintain analysis integrity
* fix(ssa): ungate debug_assert_bfs_ordering for release-tests build
The helper at src/ssa/lower.rs was gated `#[cfg(debug_assertions)]` while
the unit test at the bottom of the file was gated only `#[cfg(test)]`.
Since `cfg(test)` is set in release builds with `--tests` but
`cfg(debug_assertions)` is not, `cargo build --release --tests` failed
with E0425. Removing the gate fixes the build; the body is `debug_assert!`
only, so the helper is free in release. Also drop the gate at the call
site to avoid a `dead_code` warning when the lib is built without
`--tests`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test(closure-capture): flip JS/TS fixtures to required-finding
The JS and TS closure-capture fixtures pinned the old broken behaviour
via `forbidden_findings: [{ "id_prefix": "taint-" }]`. The engine now
correctly traces taint through the closure boundary (env source captured
by an arrow function, sunk via `child_process.exec` inside the body), so
the formerly-forbidden finding is a true positive.
Match the Python sibling's shape — `required_findings` with
`id_prefix` + `min_count` plus a small `noise_budget` — and rewrite the
companion READMEs and the phase8_fragility_tests doc-comments from
"known gap" to "regression guard".
Verified:
- cargo test --release --test phase8_fragility_tests → 8/8 pass
- cargo test --release --lib bfs_assertion → pass
- corpus benchmark F1 = 0.9976 (TP=205, FP=1, FN=0) — unchanged
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat: Add OWASP mapping and baseline mutation hooks for enhanced security analysis
* feat: Introduce health module and enhance health score computation with calibration tests
* feat: Add expectations configuration and cleanup .gitignore for log files
* feat: Implement theme selection and enhance settings panel for triage sync
* feat: Suppress false positives for strcpy calls with literal sources in AST
* feat: Update analyse_function_ssa to return body CFG for accurate analysis
* feat: Add bug report and feature request templates for improved issue tracking
* feat: removed dev scripts
* feat: update README.md for clarity and consistency in fixture descriptions
* feat: removed dev docs
* feat: clean up error handling and UI elements for improved user experience
* feat: adjust button sizes in HeaderBar for better UI consistency
* feat: enhance taint analysis with additional context for sanitizer and taint findings
* cargo fmt
* prettier
* refactor: simplify conditional checks and improve code readability in AST and screenshot capture scripts
* feat: add script to frame PNG screenshots with brand gradient
* feat: add fuzzing support with new targets and CI workflows
* refactor: streamline match expressions and improve formatting in CLI and output handling
* feat: enhance configuration display with detailed output options
* feat: stage demo configuration for improved CLI screenshot output
* feat: expose merge_configs function for user-configurable settings
* refactor: simplify code structure and improve readability in config handling
* refactor: improve descriptions for vulnerability patterns in various languages
* feat: update MIT License section with additional usage details and copyright information
* feat: update screenshots
* refactor: update build process and paths for frontend assets
* feat: add cross-file taint fuzzing target and supporting dictionary
* refactor: clean up formatting and comments in fuzz configuration and example files
* refactor: remove outdated comments and clean up CI configuration files
* chore: update changelog dates and improve formatting in documentation
* refactor: update Cargo.toml and CI configuration for improved packaging and build process
* refactor: enhance quote-stripping logic to prevent panics and add regression tests
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
79c29b394d
commit
82f18184b1
348 changed files with 48731 additions and 2925 deletions
|
|
@ -251,6 +251,7 @@ pub fn backward_transfer(
|
|||
callee,
|
||||
args,
|
||||
receiver,
|
||||
..
|
||||
} => {
|
||||
// For Call ops the full demand transfer depends on callee
|
||||
// metadata (summary or body). The driver handles that —
|
||||
|
|
@ -272,6 +273,15 @@ pub fn backward_transfer(
|
|||
flat,
|
||||
)
|
||||
}
|
||||
SsaOp::FieldProj { receiver, .. } => {
|
||||
// Field projection: demand for `obj.f` flows to `obj`. Treated
|
||||
// structurally like a single-operand Assign for the backwards
|
||||
// walk — sufficient until Phase 4 introduces field-sensitive
|
||||
// demand discrimination.
|
||||
let mut next: SmallVec<[(SsaValue, DemandState); 4]> = SmallVec::new();
|
||||
next.push((*receiver, demand.clone()));
|
||||
(BackwardStep::Assign, next)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -740,6 +750,8 @@ mod tests {
|
|||
],
|
||||
cfg_node_map: std::collections::HashMap::new(),
|
||||
exception_edges: Vec::new(),
|
||||
field_interner: crate::ssa::ir::FieldInterner::default(),
|
||||
field_writes: std::collections::HashMap::new(),
|
||||
};
|
||||
|
||||
(ssa, cfg)
|
||||
|
|
@ -786,6 +798,8 @@ mod tests {
|
|||
value_defs: vec![make_value_def(BlockId(0), c_node)],
|
||||
cfg_node_map: std::collections::HashMap::new(),
|
||||
exception_edges: Vec::new(),
|
||||
field_interner: crate::ssa::ir::FieldInterner::default(),
|
||||
field_writes: std::collections::HashMap::new(),
|
||||
};
|
||||
let demand = DemandState::new(Cap::all());
|
||||
let (step, next) = backward_transfer(&ssa, SsaValue(0), &demand);
|
||||
|
|
@ -816,6 +830,8 @@ mod tests {
|
|||
value_defs: vec![make_value_def(BlockId(0), p_node)],
|
||||
cfg_node_map: std::collections::HashMap::new(),
|
||||
exception_edges: Vec::new(),
|
||||
field_interner: crate::ssa::ir::FieldInterner::default(),
|
||||
field_writes: std::collections::HashMap::new(),
|
||||
};
|
||||
let demand = DemandState::new(Cap::all());
|
||||
let (step, _next) = backward_transfer(&ssa, SsaValue(0), &demand);
|
||||
|
|
@ -901,6 +917,8 @@ mod tests {
|
|||
],
|
||||
cfg_node_map: std::collections::HashMap::new(),
|
||||
exception_edges: Vec::new(),
|
||||
field_interner: crate::ssa::ir::FieldInterner::default(),
|
||||
field_writes: std::collections::HashMap::new(),
|
||||
};
|
||||
|
||||
let demand = DemandState::new(Cap::all());
|
||||
|
|
@ -987,6 +1005,8 @@ mod tests {
|
|||
],
|
||||
cfg_node_map: std::collections::HashMap::new(),
|
||||
exception_edges: Vec::new(),
|
||||
field_interner: crate::ssa::ir::FieldInterner::default(),
|
||||
field_writes: std::collections::HashMap::new(),
|
||||
};
|
||||
|
||||
let ctx = BackwardsCtx::new(&ssa, &cfg, Lang::JavaScript);
|
||||
|
|
|
|||
739
src/taint/mod.rs
739
src/taint/mod.rs
|
|
@ -71,6 +71,45 @@ fn js_ts_pass2_cap() -> usize {
|
|||
if o == 0 { JS_TS_PASS2_SAFETY_CAP } else { o }
|
||||
}
|
||||
|
||||
// ── Perf-audit sub-stage timers (lower_all_functions_from_bodies) ───────
|
||||
//
|
||||
// Slot layout (µs):
|
||||
// [0] lower_to_ssa_with_params (per-body sum)
|
||||
// [1] extract_ssa_func_summary (per-body sum, includes per-param probes)
|
||||
// [2] optimize_ssa_with_param_types (per-body sum)
|
||||
// [3] typed_call_receivers + pointer fact extraction (per-body sum)
|
||||
// [4] augment_summaries_with_child_sinks
|
||||
// [5] rerun_extraction_with_augmented_summaries
|
||||
// [6] per-body misc (FuncKey resolve, HashMap insert, interner ctor)
|
||||
//
|
||||
// Active only when the slot is `Some`. Production code path leaves it
|
||||
// `None`, making instrumentation cost a single thread-local borrow + a
|
||||
// `match Option::None` per measured chunk — sub-nanosecond.
|
||||
thread_local! {
|
||||
static PERF_LOWER_TIMINGS: std::cell::Cell<Option<[u128; 7]>> =
|
||||
const { std::cell::Cell::new(None) };
|
||||
}
|
||||
|
||||
#[doc(hidden)]
|
||||
pub fn perf_lower_timings_start() {
|
||||
PERF_LOWER_TIMINGS.with(|c| c.set(Some([0; 7])));
|
||||
}
|
||||
|
||||
#[doc(hidden)]
|
||||
pub fn perf_lower_timings_take() -> Option<[u128; 7]> {
|
||||
PERF_LOWER_TIMINGS.with(|c| c.replace(None))
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn perf_lower_record(slot: usize, micros: u128) {
|
||||
PERF_LOWER_TIMINGS.with(|c| {
|
||||
if let Some(mut t) = c.get() {
|
||||
t[slot] = t[slot].saturating_add(micros);
|
||||
c.set(Some(t));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Test-only override for the Gauss-Seidel toggle. Values:
|
||||
///
|
||||
/// * `0` — respect `NYX_JS_GAUSS_SEIDEL` env var (default production
|
||||
|
|
@ -298,17 +337,20 @@ pub fn analyse_file(
|
|||
interop_edges: &[InteropEdge],
|
||||
extra_labels: Option<&[crate::labels::RuntimeLabelRule]>,
|
||||
) -> Vec<Finding> {
|
||||
let _span = tracing::debug_span!("taint_analyse_file").entered();
|
||||
|
||||
// Clear the file-level path-safe-suppressed sink-span set before this
|
||||
// file's analysis. Populated by `is_path_safe_for_sink` and consumed
|
||||
// by the state-analysis pass via `take_path_safe_suppressed_spans` to
|
||||
// suppress `state-unauthed-access` on sinks already proven path-safe.
|
||||
// Reset BEFORE lowering: per-parameter probes inside
|
||||
// `lower_all_functions_from_bodies` may record path-safe sink spans
|
||||
// (via `record_path_safe_suppressed_span`). Resetting here keeps the
|
||||
// historical contract that "the span set starts empty for each file"
|
||||
// while letting both the probe phase and the taint flow phase
|
||||
// accumulate into the same set, which is what
|
||||
// `take_path_safe_suppressed_spans` then drains for state analysis.
|
||||
// The all-validated span set (cap-agnostic, drained by AST-pattern
|
||||
// suppression in `TaintSuppressionCtx::build`) follows the same
|
||||
// lifecycle.
|
||||
ssa_transfer::reset_path_safe_suppressed_spans();
|
||||
|
||||
// 1. Lower all function bodies: produce SSA summaries + cached bodies.
|
||||
// No locator: pass-2 intra-file summaries are transient (not persisted)
|
||||
// and behavior depends on SinkSite.cap only, which is always populated.
|
||||
ssa_transfer::reset_all_validated_spans();
|
||||
// No locator: pass-2 intra-file summaries are transient (not persisted)
|
||||
// and behavior depends on SinkSite.cap only, which is always populated.
|
||||
let (ssa_summaries, callee_bodies) = lower_all_functions_from_bodies(
|
||||
file_cfg,
|
||||
caller_lang,
|
||||
|
|
@ -317,10 +359,52 @@ pub fn analyse_file(
|
|||
global_summaries,
|
||||
None,
|
||||
);
|
||||
analyse_file_with_lowered(
|
||||
file_cfg,
|
||||
local_summaries,
|
||||
global_summaries,
|
||||
caller_lang,
|
||||
caller_namespace,
|
||||
interop_edges,
|
||||
extra_labels,
|
||||
&ssa_summaries,
|
||||
&callee_bodies,
|
||||
)
|
||||
}
|
||||
|
||||
/// Same as [`analyse_file`] but takes pre-lowered SSA summaries + callee
|
||||
/// bodies. Used by [`crate::ast::analyse_file_fused`] to share a single
|
||||
/// `lower_all_functions_from_bodies` invocation across the taint engine and
|
||||
/// the SSA-artifact extractor; the bare [`analyse_file`] entry-point keeps
|
||||
/// its prior signature for any caller that does not have a pre-lowered
|
||||
/// result handy.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn analyse_file_with_lowered(
|
||||
file_cfg: &FileCfg,
|
||||
local_summaries: &FuncSummaries,
|
||||
global_summaries: Option<&GlobalSummaries>,
|
||||
caller_lang: Lang,
|
||||
caller_namespace: &str,
|
||||
interop_edges: &[InteropEdge],
|
||||
extra_labels: Option<&[crate::labels::RuntimeLabelRule]>,
|
||||
ssa_summaries: &std::collections::HashMap<FuncKey, crate::summary::ssa_summary::SsaFuncSummary>,
|
||||
callee_bodies: &std::collections::HashMap<FuncKey, ssa_transfer::CalleeSsaBody>,
|
||||
) -> Vec<Finding> {
|
||||
let _span = tracing::debug_span!("taint_analyse_file").entered();
|
||||
|
||||
// NOTE: the path-safe-suppressed span set is reset by the caller, not
|
||||
// here. Per-parameter probes inside the lowering phase
|
||||
// (`lower_all_functions_from_bodies`) can already publish spans via
|
||||
// `record_path_safe_suppressed_span`; resetting here would wipe them
|
||||
// before `take_path_safe_suppressed_spans` drains the set for state
|
||||
// analysis. Both `analyse_file` (which lowers internally) and
|
||||
// `analyse_file_fused` (which lowers up-front) reset the set before
|
||||
// their lowering call.
|
||||
|
||||
let ssa_sums_ref = if ssa_summaries.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(&ssa_summaries)
|
||||
Some(ssa_summaries)
|
||||
};
|
||||
|
||||
// 2. Context-sensitive inline analysis setup. Toggle lives at
|
||||
|
|
@ -329,7 +413,7 @@ pub fn analyse_file(
|
|||
let context_sensitive = crate::utils::analysis_options::current().context_sensitive;
|
||||
let inline_cache = std::cell::RefCell::new(std::collections::HashMap::new());
|
||||
let callee_bodies_ref = if context_sensitive && !callee_bodies.is_empty() {
|
||||
Some(&callee_bodies)
|
||||
Some(callee_bodies)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
|
@ -592,7 +676,12 @@ fn analyse_body_with_seed(
|
|||
|
||||
match ssa_result {
|
||||
Ok(mut ssa_body) => {
|
||||
let opt = crate::ssa::optimize_ssa(&mut ssa_body, cfg, Some(lang));
|
||||
let opt = crate::ssa::optimize_ssa_with_param_types(
|
||||
&mut ssa_body,
|
||||
cfg,
|
||||
Some(lang),
|
||||
&body.meta.param_types,
|
||||
);
|
||||
if tracing::enabled!(tracing::Level::TRACE) {
|
||||
tracing::trace!(
|
||||
func = body.meta.name.as_deref().unwrap_or("<anon>"),
|
||||
|
|
@ -619,6 +708,17 @@ fn analyse_body_with_seed(
|
|||
} else {
|
||||
Some(static_map)
|
||||
};
|
||||
// Pointer-Phase 3 / W1+W2+W3: per-body field-sensitive points-to
|
||||
// facts. Computed only when `NYX_POINTER_ANALYSIS=1`; the
|
||||
// per-body `analyse_body` cost is amortised across the three
|
||||
// hooks (W1 field-write read-back, W2 container ELEM cells,
|
||||
// W3 cross-call resolver). Strict-additive: `None` keeps
|
||||
// pointer-disabled behaviour bit-identical.
|
||||
let pointer_facts = if crate::pointer::is_enabled() {
|
||||
Some(crate::pointer::analyse_body(&ssa_body, body.meta.id))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let transfer = ssa_transfer::SsaTaintTransfer {
|
||||
lang,
|
||||
namespace,
|
||||
|
|
@ -654,6 +754,7 @@ fn analyse_body_with_seed(
|
|||
|| (lang == Lang::Java
|
||||
&& body.meta.kind == crate::cfg::BodyKind::AnonymousFunction),
|
||||
cross_file_bodies,
|
||||
pointer_facts: pointer_facts.as_ref(),
|
||||
};
|
||||
let (events, block_states) =
|
||||
ssa_transfer::run_ssa_taint_full(&ssa_body, cfg, &transfer);
|
||||
|
|
@ -1342,7 +1443,7 @@ pub(crate) fn extract_intra_file_ssa_summaries(
|
|||
/// resistant identity we have: same-name methods on different classes, same-
|
||||
/// name overloads with different arity, and anonymous bodies at distinct
|
||||
/// source spans all get distinct keys.
|
||||
fn lower_all_functions_from_bodies(
|
||||
pub(crate) fn lower_all_functions_from_bodies(
|
||||
file_cfg: &FileCfg,
|
||||
lang: Lang,
|
||||
namespace: &str,
|
||||
|
|
@ -1357,6 +1458,7 @@ fn lower_all_functions_from_bodies(
|
|||
let mut bodies = std::collections::HashMap::new();
|
||||
|
||||
for body in file_cfg.function_bodies() {
|
||||
let _t_misc = std::time::Instant::now();
|
||||
let func_name = body.meta.name.clone().unwrap_or_else(|| {
|
||||
body.meta
|
||||
.func_key
|
||||
|
|
@ -1367,6 +1469,9 @@ fn lower_all_functions_from_bodies(
|
|||
|
||||
let interner = SymbolInterner::from_cfg(&body.graph);
|
||||
let formal_params = &body.meta.params;
|
||||
perf_lower_record(6, _t_misc.elapsed().as_micros());
|
||||
|
||||
let _t_lower = std::time::Instant::now();
|
||||
let mut func_ssa = match crate::ssa::lower_to_ssa_with_params(
|
||||
&body.graph,
|
||||
body.entry,
|
||||
|
|
@ -1377,6 +1482,7 @@ fn lower_all_functions_from_bodies(
|
|||
Ok(ssa) => ssa,
|
||||
Err(_) => continue,
|
||||
};
|
||||
perf_lower_record(0, _t_lower.elapsed().as_micros());
|
||||
|
||||
let param_count = if !formal_params.is_empty() {
|
||||
formal_params.len()
|
||||
|
|
@ -1413,6 +1519,7 @@ fn lower_all_functions_from_bodies(
|
|||
// to avoid cluttering `GlobalSummaries` with trivially-empty
|
||||
// entries.
|
||||
{
|
||||
let _t_extract = std::time::Instant::now();
|
||||
let mod_aliases = compute_module_aliases_for_summary(&func_ssa, lang);
|
||||
let mod_aliases_ref = if mod_aliases.is_empty() {
|
||||
None
|
||||
|
|
@ -1443,10 +1550,59 @@ fn lower_all_functions_from_bodies(
|
|||
if param_count > 0 || summary.points_to.returns_fresh_alloc {
|
||||
summaries.insert(key.clone(), summary);
|
||||
}
|
||||
perf_lower_record(1, _t_extract.elapsed().as_micros());
|
||||
}
|
||||
|
||||
let opt = crate::ssa::optimize_ssa(&mut func_ssa, &body.graph, Some(lang));
|
||||
let _t_opt = std::time::Instant::now();
|
||||
let opt = crate::ssa::optimize_ssa_with_param_types(
|
||||
&mut func_ssa,
|
||||
&body.graph,
|
||||
Some(lang),
|
||||
&body.meta.param_types,
|
||||
);
|
||||
perf_lower_record(2, _t_opt.elapsed().as_micros());
|
||||
|
||||
let _t_typed = std::time::Instant::now();
|
||||
// Phase 2 (typed call-graph devirtualisation): walk every SSA
|
||||
// method call in this body, look up the receiver SSA value's
|
||||
// [`crate::ssa::type_facts::TypeKind`] in the just-computed
|
||||
// `opt.type_facts`, and record `(call_ordinal, container_name)`
|
||||
// on the matching summary so Phase 3 in `build_call_graph` can
|
||||
// narrow the indirect-method-call edge to the receiver-typed
|
||||
// container. Free-function calls (`receiver: None`) and
|
||||
// unknown receiver types are silently skipped — the bare-name
|
||||
// resolution path applies unchanged in that case.
|
||||
let typed_receivers = collect_typed_call_receivers(&func_ssa, &body.graph, &opt.type_facts);
|
||||
if !typed_receivers.is_empty() {
|
||||
// The summary may not have been inserted above (zero-param,
|
||||
// no-fresh-alloc bodies are skipped). Force-insert in that
|
||||
// case so the receiver-type info reaches Phase 3 — without
|
||||
// it, the cross-file devirtualisation signal would be lost
|
||||
// for any method invoked inside a parameterless caller.
|
||||
let entry = summaries.entry(key.clone()).or_default();
|
||||
entry.typed_call_receivers = typed_receivers;
|
||||
}
|
||||
|
||||
// Pointer-Phase 5 / W3: populate `field_points_to` from the
|
||||
// body's pointer facts when the analysis is enabled. Strict
|
||||
// opt-in via `NYX_POINTER_ANALYSIS=1`; off-by-default keeps
|
||||
// bit-for-bit identity with the pre-W3 behaviour.
|
||||
//
|
||||
// `extract_field_points_to` covers both reads (via
|
||||
// `SsaOp::FieldProj` walks) and writes (via the W1
|
||||
// `field_writes` side-table on the body) in a single pass.
|
||||
if crate::pointer::is_enabled() {
|
||||
let facts = crate::pointer::analyse_body(&func_ssa, body.meta.id);
|
||||
let fpt = crate::pointer::extract_field_points_to(&func_ssa, &facts);
|
||||
if !fpt.is_empty() {
|
||||
let entry = summaries.entry(key.clone()).or_default();
|
||||
entry.field_points_to = fpt;
|
||||
}
|
||||
}
|
||||
|
||||
perf_lower_record(3, _t_typed.elapsed().as_micros());
|
||||
|
||||
let _t_misc2 = std::time::Instant::now();
|
||||
bodies.insert(
|
||||
key,
|
||||
ssa_transfer::CalleeSsaBody {
|
||||
|
|
@ -1457,8 +1613,72 @@ fn lower_all_functions_from_bodies(
|
|||
body_graph: Some(body.graph.clone()),
|
||||
},
|
||||
);
|
||||
perf_lower_record(6, _t_misc2.elapsed().as_micros());
|
||||
}
|
||||
|
||||
// ── Closure-capture summary augmentation ─────────────────────────
|
||||
//
|
||||
// Lift child-body sinks into the parent's `param_to_sink` for
|
||||
// every parent body with lexically contained children. This
|
||||
// handles the direct-wrapper case
|
||||
// `f(x) { return new Promise((res, rej) => sink(x)) }` — the
|
||||
// executor's gated http.get sink becomes visible to callers of
|
||||
// `f` via `f.summary.param_to_sink`.
|
||||
//
|
||||
// Without this pass, `f.summary.param_to_sink` stays empty
|
||||
// because the sink lives in a separately-extracted child body
|
||||
// that the parent's pass-1 probe never sees. The
|
||||
// lexical-containment propagation in `analyse_multi_body`
|
||||
// carries seeded taint into child bodies for the production
|
||||
// analysis path, but the single-body summary extractor in
|
||||
// `extract_ssa_func_summary` does not. This pass reproduces that
|
||||
// propagation at summary-extraction time so cross-call
|
||||
// resolution sees the sink at every caller of `f`.
|
||||
//
|
||||
// Strict-additive: only ADDs `param_to_sink` entries — never
|
||||
// removes or modifies existing data — so it cannot regress
|
||||
// detection. Bounded: each parent-param probe runs each child
|
||||
// body's analysis exactly once.
|
||||
let _t_aug = std::time::Instant::now();
|
||||
augment_summaries_with_child_sinks(
|
||||
file_cfg,
|
||||
lang,
|
||||
namespace,
|
||||
local_summaries,
|
||||
global_summaries,
|
||||
&bodies,
|
||||
&mut summaries,
|
||||
);
|
||||
perf_lower_record(4, _t_aug.elapsed().as_micros());
|
||||
|
||||
// ── Second extraction pass: transitive cross-function summary lift ───
|
||||
//
|
||||
// The augment pass populates direct sink-wrapper summaries
|
||||
// (`f(x) { Promise(() => sink(x)) }`). This second pass then
|
||||
// re-runs every body's per-parameter probe with the augmented
|
||||
// `summaries` map plumbed through to the probe transfer's
|
||||
// `ssa_summaries` field, so callers of those wrappers (e.g. an
|
||||
// `addFileDataIfNeeded` whose body calls a `downloadFileFromURI`
|
||||
// sink wrapper) see the augmented `param_to_sink` at step 0 of
|
||||
// `resolve_callee_full` and propagate it onto their own summary.
|
||||
//
|
||||
// OR-merge: only adds `param_to_sink` / `param_to_sink_param`
|
||||
// entries to existing summaries. Existing entries (return
|
||||
// transforms, source caps, augment-populated sinks, etc.) are
|
||||
// preserved. Strict-additive — cannot regress detection.
|
||||
let _t_rerun = std::time::Instant::now();
|
||||
rerun_extraction_with_augmented_summaries(
|
||||
file_cfg,
|
||||
lang,
|
||||
namespace,
|
||||
local_summaries,
|
||||
global_summaries,
|
||||
locator,
|
||||
&bodies,
|
||||
&mut summaries,
|
||||
);
|
||||
perf_lower_record(5, _t_rerun.elapsed().as_micros());
|
||||
|
||||
if !summaries.is_empty() {
|
||||
tracing::debug!(
|
||||
count = summaries.len(),
|
||||
|
|
@ -1470,6 +1690,478 @@ fn lower_all_functions_from_bodies(
|
|||
(summaries, bodies)
|
||||
}
|
||||
|
||||
/// Second extraction pass: re-runs `extract_ssa_func_summary_full` for
|
||||
/// every body with the augmented `summaries` map plumbed through.
|
||||
///
|
||||
/// Only sink-related fields (`param_to_sink`, `param_to_sink_param`)
|
||||
/// are merged into existing summaries; other fields stay as-produced
|
||||
/// by the first pass. Bounded: one re-extraction per body.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn rerun_extraction_with_augmented_summaries(
|
||||
file_cfg: &FileCfg,
|
||||
lang: Lang,
|
||||
namespace: &str,
|
||||
local_summaries: &FuncSummaries,
|
||||
global_summaries: Option<&GlobalSummaries>,
|
||||
locator: Option<&crate::summary::SinkSiteLocator<'_>>,
|
||||
bodies: &std::collections::HashMap<FuncKey, ssa_transfer::CalleeSsaBody>,
|
||||
summaries: &mut std::collections::HashMap<FuncKey, crate::summary::ssa_summary::SsaFuncSummary>,
|
||||
) {
|
||||
use crate::ssa::ir::SsaOp;
|
||||
use crate::state::symbol::SymbolInterner;
|
||||
|
||||
// Fast-out: rerun matters only when at least one body in the file has
|
||||
// an SSA summary entry that *another* body in the same file might
|
||||
// resolve a Call to. If no SSA summaries were produced, nothing to
|
||||
// re-extract. This is the dominant case for files of unrelated
|
||||
// functions or with all-cross-file callees.
|
||||
if summaries.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Snapshot the augmented summaries map so the probes resolve
|
||||
// callees against a stable view (the merge below mutates
|
||||
// `summaries` as we iterate).
|
||||
let augmented_snapshot: std::collections::HashMap<
|
||||
FuncKey,
|
||||
crate::summary::ssa_summary::SsaFuncSummary,
|
||||
> = summaries.clone();
|
||||
|
||||
// Set of bare callee names known to have an in-file SsaFuncSummary.
|
||||
// `extract_ssa_func_summary_full` only consults `ssa_summaries` at
|
||||
// Call resolution time, so a body with no Call to any of these names
|
||||
// produces a summary identical to its first-pass output.
|
||||
//
|
||||
// SSA `Call::callee` carries the bare method name after lowering
|
||||
// decomposes chained-receiver calls, which matches `FuncKey::name`.
|
||||
// Borrows `augmented_snapshot` (immutable view) so the loop below can
|
||||
// freely mutate `summaries`.
|
||||
let in_file_names: std::collections::HashSet<&str> =
|
||||
augmented_snapshot.keys().map(|k| k.name.as_str()).collect();
|
||||
|
||||
for body in file_cfg.function_bodies() {
|
||||
let Some(parent_key) = body.meta.func_key.clone() else {
|
||||
continue;
|
||||
};
|
||||
let mut key = parent_key;
|
||||
key.namespace = namespace.to_string();
|
||||
|
||||
let Some(callee) = bodies.get(&key) else {
|
||||
continue;
|
||||
};
|
||||
if callee.param_count == 0 {
|
||||
continue;
|
||||
}
|
||||
let Some(parent_cfg) = callee.body_graph.as_ref() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
// Narrow: rerun only bodies whose SSA references at least one
|
||||
// in-file summary by name. Bodies with no in-file Call cannot
|
||||
// benefit from the augmented `ssa_summaries` view, so their
|
||||
// re-extraction is a strict no-op.
|
||||
let has_in_file_call = callee.ssa.blocks.iter().any(|b| {
|
||||
b.body.iter().any(|inst| {
|
||||
if let SsaOp::Call { callee: name, .. } = &inst.op {
|
||||
in_file_names.contains(name.as_str())
|
||||
} else {
|
||||
false
|
||||
}
|
||||
})
|
||||
});
|
||||
if !has_in_file_call {
|
||||
continue;
|
||||
}
|
||||
|
||||
let interner = SymbolInterner::from_cfg(parent_cfg);
|
||||
let mod_aliases = compute_module_aliases_for_summary(&callee.ssa, lang);
|
||||
let mod_aliases_ref = if mod_aliases.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(&mod_aliases)
|
||||
};
|
||||
|
||||
let new_summary = ssa_transfer::extract_ssa_func_summary_full(
|
||||
&callee.ssa,
|
||||
parent_cfg,
|
||||
local_summaries,
|
||||
global_summaries,
|
||||
lang,
|
||||
namespace,
|
||||
&interner,
|
||||
callee.param_count,
|
||||
mod_aliases_ref,
|
||||
locator,
|
||||
Some(&body.meta.params),
|
||||
Some(&augmented_snapshot),
|
||||
);
|
||||
|
||||
// OR-merge sink-only fields into the existing summary.
|
||||
let entry = summaries.entry(key).or_default();
|
||||
merge_sink_fields(entry, &new_summary);
|
||||
}
|
||||
}
|
||||
|
||||
/// OR-merge `param_to_sink` and `param_to_sink_param` from `src` into
|
||||
/// `dst`. Existing entries are preserved; only NEW entries are added.
|
||||
fn merge_sink_fields(
|
||||
dst: &mut crate::summary::ssa_summary::SsaFuncSummary,
|
||||
src: &crate::summary::ssa_summary::SsaFuncSummary,
|
||||
) {
|
||||
for (idx, sites) in &src.param_to_sink {
|
||||
if let Some((_, dst_sites)) = dst.param_to_sink.iter_mut().find(|(i, _)| i == idx) {
|
||||
for site in sites {
|
||||
let key = site.dedup_key();
|
||||
if !dst_sites.iter().any(|s| s.dedup_key() == key) {
|
||||
dst_sites.push(site.clone());
|
||||
}
|
||||
}
|
||||
} else {
|
||||
dst.param_to_sink.push((*idx, sites.clone()));
|
||||
}
|
||||
}
|
||||
for &(idx, pos, caps) in &src.param_to_sink_param {
|
||||
if !dst
|
||||
.param_to_sink_param
|
||||
.iter()
|
||||
.any(|(i, p, c)| *i == idx && *p == pos && *c == caps)
|
||||
{
|
||||
dst.param_to_sink_param.push((idx, pos, caps));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Walk lexical-containment children of every parent body and lift
|
||||
/// their sinks into the parent's [`SsaFuncSummary::param_to_sink`].
|
||||
///
|
||||
/// For each parent body P with at least one lexically contained
|
||||
/// child:
|
||||
/// - For each formal parameter `p_i` of P:
|
||||
/// - Seed a probe with `{ p_i → Cap::all() }`, run P's SSA
|
||||
/// analysis, extract P's exit state.
|
||||
/// - For every descendant child body C of P, run C's SSA
|
||||
/// analysis with the parent's exit state seeded as
|
||||
/// `global_seed`. Collect sink events.
|
||||
/// - For each event whose `sink_caps` is non-empty, append a
|
||||
/// cap-only [`SinkSite`] under `p_i` on P's summary
|
||||
/// (deduplicated by cap-mask so repeat probes don't inflate
|
||||
/// the entry).
|
||||
///
|
||||
/// Strict-additive: only inserts new `param_to_sink` entries; never
|
||||
/// modifies `param_return_paths`, `points_to`, `source_caps`, etc.
|
||||
fn augment_summaries_with_child_sinks(
|
||||
file_cfg: &FileCfg,
|
||||
lang: Lang,
|
||||
namespace: &str,
|
||||
local_summaries: &FuncSummaries,
|
||||
global_summaries: Option<&GlobalSummaries>,
|
||||
bodies: &std::collections::HashMap<FuncKey, ssa_transfer::CalleeSsaBody>,
|
||||
summaries: &mut std::collections::HashMap<FuncKey, crate::summary::ssa_summary::SsaFuncSummary>,
|
||||
) {
|
||||
use crate::cfg::BodyId;
|
||||
use crate::labels::{Cap, SourceKind};
|
||||
use crate::summary::SinkSite;
|
||||
use crate::taint::domain::{TaintOrigin, VarTaint};
|
||||
use ssa_transfer::BindingKey;
|
||||
|
||||
// ── Build lexical-containment relationships ──────────────────────
|
||||
// Map parent BodyId → list of descendant body indices. Reverse-walk
|
||||
// each body's `parent_body_id` chain so a grand-child's sinks are
|
||||
// attributed to every ancestor in its containment chain.
|
||||
let body_id_to_idx: std::collections::HashMap<BodyId, usize> = file_cfg
|
||||
.bodies
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, b)| (b.meta.id, i))
|
||||
.collect();
|
||||
let mut descendants: std::collections::HashMap<BodyId, Vec<usize>> =
|
||||
std::collections::HashMap::new();
|
||||
for (idx, body) in file_cfg.bodies.iter().enumerate() {
|
||||
// Walk up the parent chain, registering this body as a descendant
|
||||
// of every ancestor.
|
||||
let mut cur = body.meta.parent_body_id;
|
||||
while let Some(pid) = cur {
|
||||
descendants.entry(pid).or_default().push(idx);
|
||||
cur = body_id_to_idx
|
||||
.get(&pid)
|
||||
.and_then(|i| file_cfg.bodies[*i].meta.parent_body_id);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Map each parent body to its FuncKey and the SSA body cache ──
|
||||
// Skip bodies with no formal params (nothing to probe) and bodies
|
||||
// whose SSA was never lowered (lowering errors logged earlier).
|
||||
for parent_body in &file_cfg.bodies {
|
||||
let Some(parent_key) = parent_body.meta.func_key.clone() else {
|
||||
continue;
|
||||
};
|
||||
let mut parent_key = parent_key;
|
||||
parent_key.namespace = namespace.to_string();
|
||||
|
||||
let Some(parent_callee) = bodies.get(&parent_key) else {
|
||||
continue;
|
||||
};
|
||||
if parent_callee.param_count == 0 {
|
||||
continue;
|
||||
}
|
||||
let Some(child_indices) = descendants.get(&parent_body.meta.id) else {
|
||||
continue;
|
||||
};
|
||||
if child_indices.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let parent_ssa = &parent_callee.ssa;
|
||||
let parent_cfg = match parent_callee.body_graph.as_ref() {
|
||||
Some(g) => g,
|
||||
None => continue,
|
||||
};
|
||||
let parent_interner = crate::state::symbol::SymbolInterner::from_cfg(parent_cfg);
|
||||
|
||||
// Collect (formal_param_idx, var_name, ssa_value) for the parent's
|
||||
// formal params — mirrors `extract_ssa_func_summary`'s param scan.
|
||||
let mut parent_param_info: Vec<(usize, String)> = Vec::new();
|
||||
for block in &parent_ssa.blocks {
|
||||
for inst in block.phis.iter().chain(block.body.iter()) {
|
||||
if let crate::ssa::ir::SsaOp::Param { index } = &inst.op {
|
||||
if *index < parent_callee.param_count {
|
||||
if let Some(name) = inst.var_name.as_ref() {
|
||||
parent_param_info.push((*index, name.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (param_idx, param_name) in &parent_param_info {
|
||||
// Seed parent's probe with this single param tainted to all caps.
|
||||
let mut seed: std::collections::HashMap<BindingKey, VarTaint> =
|
||||
std::collections::HashMap::new();
|
||||
seed.insert(
|
||||
BindingKey::new(param_name.as_str(), BodyId(0)),
|
||||
VarTaint {
|
||||
caps: Cap::all(),
|
||||
origins: smallvec::SmallVec::from_elem(
|
||||
TaintOrigin {
|
||||
node: petgraph::graph::NodeIndex::new(0),
|
||||
source_kind: SourceKind::UserInput,
|
||||
source_span: None,
|
||||
},
|
||||
1,
|
||||
),
|
||||
uses_summary: false,
|
||||
},
|
||||
);
|
||||
|
||||
let parent_transfer = ssa_transfer::SsaTaintTransfer {
|
||||
lang,
|
||||
namespace,
|
||||
interner: &parent_interner,
|
||||
local_summaries,
|
||||
global_summaries,
|
||||
interop_edges: &[],
|
||||
owner_body_id: BodyId(0),
|
||||
parent_body_id: None,
|
||||
global_seed: Some(&seed),
|
||||
param_seed: None,
|
||||
receiver_seed: None,
|
||||
const_values: None,
|
||||
type_facts: None,
|
||||
ssa_summaries: Some(summaries),
|
||||
extra_labels: None,
|
||||
base_aliases: None,
|
||||
callee_bodies: None,
|
||||
inline_cache: None,
|
||||
context_depth: 0,
|
||||
callback_bindings: None,
|
||||
points_to: None,
|
||||
dynamic_pts: None,
|
||||
import_bindings: None,
|
||||
promisify_aliases: None,
|
||||
module_aliases: None,
|
||||
static_map: None,
|
||||
auto_seed_handler_params: false,
|
||||
cross_file_bodies: None,
|
||||
pointer_facts: None,
|
||||
};
|
||||
|
||||
let (_parent_events, parent_block_states) =
|
||||
ssa_transfer::run_ssa_taint_full(parent_ssa, parent_cfg, &parent_transfer);
|
||||
let parent_exit = ssa_transfer::extract_ssa_exit_state(
|
||||
&parent_block_states,
|
||||
parent_ssa,
|
||||
parent_cfg,
|
||||
&parent_transfer,
|
||||
BodyId(0),
|
||||
);
|
||||
if parent_exit.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
for &child_idx in child_indices {
|
||||
let child_body = &file_cfg.bodies[child_idx];
|
||||
let Some(child_key) = child_body.meta.func_key.clone() else {
|
||||
continue;
|
||||
};
|
||||
let mut child_key = child_key;
|
||||
child_key.namespace = namespace.to_string();
|
||||
let Some(child_callee) = bodies.get(&child_key) else {
|
||||
continue;
|
||||
};
|
||||
let child_ssa = &child_callee.ssa;
|
||||
let Some(child_cfg) = child_callee.body_graph.as_ref() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let child_interner = crate::state::symbol::SymbolInterner::from_cfg(child_cfg);
|
||||
|
||||
let child_transfer = ssa_transfer::SsaTaintTransfer {
|
||||
lang,
|
||||
namespace,
|
||||
interner: &child_interner,
|
||||
local_summaries,
|
||||
global_summaries,
|
||||
interop_edges: &[],
|
||||
owner_body_id: BodyId(0),
|
||||
parent_body_id: None,
|
||||
global_seed: Some(&parent_exit),
|
||||
param_seed: None,
|
||||
receiver_seed: None,
|
||||
const_values: None,
|
||||
type_facts: None,
|
||||
ssa_summaries: Some(summaries),
|
||||
extra_labels: None,
|
||||
base_aliases: None,
|
||||
callee_bodies: None,
|
||||
inline_cache: None,
|
||||
context_depth: 0,
|
||||
callback_bindings: None,
|
||||
points_to: None,
|
||||
dynamic_pts: None,
|
||||
import_bindings: None,
|
||||
promisify_aliases: None,
|
||||
module_aliases: None,
|
||||
static_map: None,
|
||||
auto_seed_handler_params: false,
|
||||
cross_file_bodies: None,
|
||||
pointer_facts: None,
|
||||
};
|
||||
|
||||
let (child_events, _child_block_states) =
|
||||
ssa_transfer::run_ssa_taint_full(child_ssa, child_cfg, &child_transfer);
|
||||
|
||||
if child_events.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Aggregate sink caps across all child events into one
|
||||
// entry per parent param (cap-only SinkSite — the
|
||||
// exact location lives in the child body's CFG and is
|
||||
// not directly addressable from the parent's summary).
|
||||
let mut union_caps = Cap::empty();
|
||||
for ev in &child_events {
|
||||
union_caps |= ev.sink_caps;
|
||||
}
|
||||
if union_caps.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let entry = summaries.entry(parent_key.clone()).or_default();
|
||||
let new_site = SinkSite::cap_only(union_caps);
|
||||
let new_key = new_site.dedup_key();
|
||||
if let Some((_, sites)) = entry
|
||||
.param_to_sink
|
||||
.iter_mut()
|
||||
.find(|(i, _)| *i == *param_idx)
|
||||
{
|
||||
if !sites.iter().any(|s| s.dedup_key() == new_key) {
|
||||
sites.push(new_site);
|
||||
}
|
||||
} else {
|
||||
entry
|
||||
.param_to_sink
|
||||
.push((*param_idx, smallvec::smallvec![new_site]));
|
||||
}
|
||||
|
||||
// Mirror cap-only attribution into `param_to_sink_param`
|
||||
// so the call-site emission path that consults it (the
|
||||
// engine's primary sink-site picker uses
|
||||
// `param_to_sink_param` for arg-position filtering)
|
||||
// sees this captured-flow sink. Position 0 is a
|
||||
// best-effort placeholder — the actual filtering at
|
||||
// the caller is by SSRF cap, not arg position, when
|
||||
// the wrapper is itself non-gated.
|
||||
if !entry
|
||||
.param_to_sink_param
|
||||
.iter()
|
||||
.any(|(i, _, c)| *i == *param_idx && *c == union_caps)
|
||||
{
|
||||
entry.param_to_sink_param.push((*param_idx, 0, union_caps));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Walk every SSA `Call` instruction in `ssa` and produce
|
||||
/// `(call_ordinal, container_name)` entries for those whose receiver
|
||||
/// SSA value has a [`crate::ssa::type_facts::TypeKind`] with a
|
||||
/// non-empty [`crate::ssa::type_facts::TypeKind::container_name`].
|
||||
///
|
||||
/// Free-function calls (`receiver: None`) and unknown receiver types
|
||||
/// are skipped — the cross-file call-graph builder will fall back to
|
||||
/// today's name-only resolution for those, preserving the
|
||||
/// "subset of today's targets, never a superset" invariant from
|
||||
/// `docs/typed-call-graph-prompt.md`.
|
||||
///
|
||||
/// Ordinals are pulled from the underlying CFG node's
|
||||
/// [`crate::cfg::CallMeta::call_ordinal`] so they line up with
|
||||
/// [`crate::summary::CalleeSite::ordinal`] at consumer time. Calls
|
||||
/// whose CFG node has no recoverable ordinal (synthetic / removed
|
||||
/// nodes) are silently dropped.
|
||||
fn collect_typed_call_receivers(
|
||||
ssa: &crate::ssa::ir::SsaBody,
|
||||
cfg: &crate::cfg::Cfg,
|
||||
type_facts: &crate::ssa::type_facts::TypeFactResult,
|
||||
) -> Vec<(u32, String)> {
|
||||
use crate::ssa::ir::SsaOp;
|
||||
|
||||
let mut out: Vec<(u32, String)> = Vec::new();
|
||||
let mut seen: std::collections::HashSet<u32> = std::collections::HashSet::new();
|
||||
|
||||
for block in &ssa.blocks {
|
||||
for inst in block.body.iter() {
|
||||
let SsaOp::Call { receiver, .. } = &inst.op else {
|
||||
continue;
|
||||
};
|
||||
let Some(receiver_val) = receiver else {
|
||||
continue; // free-function call — no devirtualisation possible
|
||||
};
|
||||
let Some(kind) = type_facts.get_type(*receiver_val) else {
|
||||
continue; // type unknown — fall back to name-only resolution
|
||||
};
|
||||
let Some(container) = kind.container_name() else {
|
||||
continue; // scalar/unknown type — no useful container
|
||||
};
|
||||
let Some(node_info) = cfg.node_weight(inst.cfg_node) else {
|
||||
continue;
|
||||
};
|
||||
let ordinal = node_info.call.call_ordinal;
|
||||
// A single SSA call instruction maps 1:1 with a CFG call
|
||||
// node, so each ordinal should appear at most once. The
|
||||
// dedup guard exists in case lowering ever introduces a
|
||||
// second SSA Call sharing a cfg_node — first wins.
|
||||
if !seen.insert(ordinal) {
|
||||
continue;
|
||||
}
|
||||
out.push((ordinal, container));
|
||||
}
|
||||
}
|
||||
|
||||
out.sort_by_key(|(ord, _)| *ord);
|
||||
out
|
||||
}
|
||||
|
||||
/// Maximum blocks for a callee body to be eligible for cross-file persistence.
|
||||
const MAX_CROSS_FILE_BODY_BLOCKS: usize = 100;
|
||||
|
||||
|
|
@ -1497,7 +2189,21 @@ pub(crate) fn extract_ssa_artifacts_from_file_cfg(
|
|||
global_summaries,
|
||||
locator,
|
||||
);
|
||||
let eligible_bodies = build_eligible_bodies(file_cfg, bodies);
|
||||
(summaries, eligible_bodies)
|
||||
}
|
||||
|
||||
/// Filter pre-lowered SSA bodies down to the cross-file-eligible subset and
|
||||
/// populate per-node metadata against the original CFG.
|
||||
///
|
||||
/// Split out from [`extract_ssa_artifacts_from_file_cfg`] so callers that
|
||||
/// already hold a freshly-lowered `bodies` map (specifically
|
||||
/// `analyse_file_fused`, which now lowers once and feeds both the taint
|
||||
/// engine and this filter) don't pay for a second lowering pass.
|
||||
pub(crate) fn build_eligible_bodies(
|
||||
file_cfg: &FileCfg,
|
||||
bodies: std::collections::HashMap<FuncKey, ssa_transfer::CalleeSsaBody>,
|
||||
) -> EligibleCalleeBodies {
|
||||
let mut eligible_bodies = Vec::new();
|
||||
if crate::symex::cross_file_symex_enabled() {
|
||||
for (key, mut body) in bodies {
|
||||
|
|
@ -1531,8 +2237,7 @@ pub(crate) fn extract_ssa_artifacts_from_file_cfg(
|
|||
eligible_bodies.push((key, body));
|
||||
}
|
||||
}
|
||||
|
||||
(summaries, eligible_bodies)
|
||||
eligible_bodies
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
|
|||
|
|
@ -201,6 +201,19 @@ pub(super) fn reconstruct_flow_path(
|
|||
}
|
||||
current = pick_tainted_operand(&vals, origin, ssa);
|
||||
}
|
||||
SsaOp::FieldProj { receiver, .. } => {
|
||||
// Treat field projection as a one-step assignment for
|
||||
// flow-step reconstruction: taint reaching `obj.f` came
|
||||
// from `obj`. Phase 4 will refine the witness rendering
|
||||
// to include the field name in the step.
|
||||
steps.push(FlowStepRaw {
|
||||
cfg_node: inst.cfg_node,
|
||||
var_name: inst.var_name.clone(),
|
||||
op_kind: FlowStepKind::Assignment,
|
||||
});
|
||||
let single: SmallVec<[SsaValue; 4]> = smallvec::smallvec![*receiver];
|
||||
current = pick_tainted_operand(&single, origin, ssa);
|
||||
}
|
||||
SsaOp::Const(_) | SsaOp::Nop | SsaOp::Undef => break,
|
||||
}
|
||||
}
|
||||
|
|
@ -272,6 +285,16 @@ pub fn ssa_events_to_findings(
|
|||
// Suppress findings where all tainted variables were validated
|
||||
// (passed through an allowlist, type-check, or validation branch).
|
||||
if event.all_validated {
|
||||
let span = cfg[event.sink_node].ast.span;
|
||||
// Cap-agnostic: record the validated sink span so the
|
||||
// AST-pattern suppression gate (`TaintSuppressionCtx`) has
|
||||
// positive evidence that the SSA engine reached this sink
|
||||
// and proved safety. Without this, validation/dominator/
|
||||
// early-return-style safe code is indistinguishable from
|
||||
// silent engine failure when the function emitted no
|
||||
// findings and contains no labelled Sanitizer.
|
||||
crate::taint::ssa_transfer::state::record_all_validated_span(span);
|
||||
|
||||
// Mirror the path-safety pathway: when the SSA engine has
|
||||
// already proved every tainted input to a privileged
|
||||
// FILE_IO sink passed through validation, publish the sink
|
||||
|
|
@ -283,7 +306,6 @@ pub fn ssa_events_to_findings(
|
|||
// currently fires on; broadening would risk stretching
|
||||
// validator-name heuristics into unrelated finding classes.
|
||||
if event.sink_caps.intersects(Cap::FILE_IO) {
|
||||
let span = cfg[event.sink_node].ast.span;
|
||||
crate::taint::ssa_transfer::state::record_path_safe_suppressed_span(span);
|
||||
}
|
||||
continue;
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -13,8 +13,9 @@
|
|||
use crate::abstract_interp::{self, AbstractState};
|
||||
use crate::cfg::BodyId;
|
||||
use crate::constraint;
|
||||
use crate::pointer::LocId;
|
||||
use crate::ssa::heap::HeapState;
|
||||
use crate::ssa::ir::SsaValue;
|
||||
use crate::ssa::ir::{FieldId, SsaValue};
|
||||
use crate::state::lattice::Lattice;
|
||||
use crate::state::symbol::SymbolId;
|
||||
use crate::taint::domain::{PredicateSummary, SmallBitSet, TaintOrigin, VarTaint};
|
||||
|
|
@ -153,6 +154,29 @@ thread_local! {
|
|||
/// Reset at start of `analyse_file`, drained before state analysis.
|
||||
static PATH_SAFE_SUPPRESSED_SPANS: RefCell<std::collections::HashSet<(usize, usize)>> =
|
||||
RefCell::new(std::collections::HashSet::new());
|
||||
|
||||
/// File-level set of CFG sink spans where the SSA engine emitted an
|
||||
/// `all_validated` event — every tainted input to the sink passed
|
||||
/// through a recognised validation/sanitisation predicate before
|
||||
/// reaching it. Distinct from `PATH_SAFE_SUPPRESSED_SPANS`, which
|
||||
/// is FILE_IO-scoped and feeds state analysis: this set is
|
||||
/// cap-agnostic and feeds AST-pattern suppression, providing
|
||||
/// positive evidence that the engine reached the sink and proved
|
||||
/// safety so that downstream AST-pattern findings on the same line
|
||||
/// can be safely silenced.
|
||||
///
|
||||
/// Without this signal the suppression gate has to fall back to
|
||||
/// "function emitted at least one taint-unsanitised-flow finding"
|
||||
/// or "function contains a labelled Sanitizer node" — both of
|
||||
/// which miss validated/dominated/early-return safety where the
|
||||
/// engine cleared the flow without firing or hitting an explicit
|
||||
/// sanitiser.
|
||||
///
|
||||
/// Reset at start of `analyse_file` (mirrors the existing
|
||||
/// path-safe span lifecycle); drained inside
|
||||
/// `TaintSuppressionCtx::build`.
|
||||
static ALL_VALIDATED_SPANS: RefCell<std::collections::HashSet<(usize, usize)>> =
|
||||
RefCell::new(std::collections::HashSet::new());
|
||||
}
|
||||
|
||||
/// Record an engine note for the body currently being analysed. Safe to
|
||||
|
|
@ -201,6 +225,33 @@ pub fn take_path_safe_suppressed_spans() -> std::collections::HashSet<(usize, us
|
|||
PATH_SAFE_SUPPRESSED_SPANS.with(|c| std::mem::take(&mut *c.borrow_mut()))
|
||||
}
|
||||
|
||||
/// Record a sink CFG-node span where the SSA engine proved every
|
||||
/// tainted input was validated (`SsaTaintEvent::all_validated`).
|
||||
/// Cap-agnostic — fires for any sink the engine evaluated and cleared.
|
||||
/// Consumed by `TaintSuppressionCtx::build` as positive evidence that
|
||||
/// taint analysis reached this line and proved safety, so AST-pattern
|
||||
/// findings on the same line can be suppressed without misclassifying
|
||||
/// silent engine failures as "safe by validation".
|
||||
pub(crate) fn record_all_validated_span(span: (usize, usize)) {
|
||||
ALL_VALIDATED_SPANS.with(|c| {
|
||||
c.borrow_mut().insert(span);
|
||||
});
|
||||
}
|
||||
|
||||
/// Reset the file-level all-validated sink-span set. Called at the
|
||||
/// start of `analyse_file` alongside `reset_path_safe_suppressed_spans`
|
||||
/// so each file scan starts clean.
|
||||
pub fn reset_all_validated_spans() {
|
||||
ALL_VALIDATED_SPANS.with(|c| c.borrow_mut().clear());
|
||||
}
|
||||
|
||||
/// Take the file-level all-validated sink-span set, leaving it empty.
|
||||
/// Called inside `TaintSuppressionCtx::build` to attribute each
|
||||
/// validated sink to its enclosing function for the suppression gate.
|
||||
pub fn take_all_validated_spans() -> std::collections::HashSet<(usize, usize)> {
|
||||
ALL_VALIDATED_SPANS.with(|c| std::mem::take(&mut *c.borrow_mut()))
|
||||
}
|
||||
|
||||
/// Stable identity for a variable binding at body boundaries.
|
||||
///
|
||||
/// Translates between independent per-body `SymbolId` spaces.
|
||||
|
|
@ -246,6 +297,52 @@ pub fn seed_lookup<'a>(
|
|||
|
||||
// ── SSA Taint State ─────────────────────────────────────────────────────
|
||||
|
||||
/// Compact key for a heap-field taint cell.
|
||||
///
|
||||
/// `(loc, field)` — `loc` is the abstract location of the *parent*
|
||||
/// (interned by the body's [`crate::pointer::LocInterner`]), `field`
|
||||
/// is the [`FieldId`] of the projected field. The pair survives lattice
|
||||
/// joins / leq comparisons by `Ord`-derived sort.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||
pub struct FieldTaintKey {
|
||||
pub loc: LocId,
|
||||
pub field: FieldId,
|
||||
}
|
||||
|
||||
/// Pointer-Phase 4 / W4: per-field-cell taint record.
|
||||
///
|
||||
/// Carries the union of writers' taint for the abstract field cell plus
|
||||
/// two validation channels:
|
||||
/// * `validated_must` — set when *every* writer recorded a value that was
|
||||
/// `validated_must` in its own SSA scope. Lattice join intersects
|
||||
/// (`AND`) — matching the symbol-keyed [`SsaTaintState::validated_must`]
|
||||
/// semantics for "validated on every path".
|
||||
/// * `validated_may` — set when *any* writer recorded a `validated_may`
|
||||
/// value. Lattice join unions (`OR`) — matching the symbol-keyed
|
||||
/// [`SsaTaintState::validated_may`] semantics for "validated on some
|
||||
/// path".
|
||||
///
|
||||
/// Cells with `taint.caps == empty()` never get inserted (see
|
||||
/// [`SsaTaintState::add_field`]) so the lattice bottom remains `[]`.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct FieldCell {
|
||||
pub taint: VarTaint,
|
||||
pub validated_must: bool,
|
||||
pub validated_may: bool,
|
||||
}
|
||||
|
||||
impl FieldCell {
|
||||
/// Construct a cell with no validation bits — convenience for the
|
||||
/// pre-W4 callers that don't propagate symbol-level validation.
|
||||
pub fn unvalidated(taint: VarTaint) -> Self {
|
||||
Self {
|
||||
taint,
|
||||
validated_must: false,
|
||||
validated_may: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Taint state keyed by SsaValue instead of SymbolId.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct SsaTaintState {
|
||||
|
|
@ -268,6 +365,20 @@ pub struct SsaTaintState {
|
|||
/// interpretation is disabled (`analysis.engine.abstract_interpretation
|
||||
/// = false`).
|
||||
pub abstract_state: Option<AbstractState>,
|
||||
/// Pointer-Phase 3: per-heap-field taint cells, keyed by
|
||||
/// `(parent_loc, field)`. Sorted by [`FieldTaintKey`] for O(n)
|
||||
/// merge-join. Populated only when the body's
|
||||
/// [`crate::pointer::PointsToFacts`] is available
|
||||
/// (`NYX_POINTER_ANALYSIS=1`); empty otherwise so the lattice join
|
||||
/// is a strict no-op for pointer-disabled runs. Field reads
|
||||
/// (`SsaOp::FieldProj`) consult the cells; field writes record into
|
||||
/// them. Cross-call propagation lands in Phase 5 via the
|
||||
/// field-granularity `PointsToSummary`.
|
||||
///
|
||||
/// Cell shape (Phase 4 / W4): [`FieldCell`] carries `taint` plus
|
||||
/// `validated_must` / `validated_may` flags so validation flows
|
||||
/// through abstract field / element identity.
|
||||
pub field_taint: SmallVec<[(FieldTaintKey, FieldCell); 4]>,
|
||||
}
|
||||
|
||||
impl SsaTaintState {
|
||||
|
|
@ -288,6 +399,64 @@ impl SsaTaintState {
|
|||
} else {
|
||||
None
|
||||
},
|
||||
field_taint: SmallVec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Pointer-Phase 3: read the field cell at `key`. Returns `None`
|
||||
/// when no cell has been recorded (caller should treat as
|
||||
/// untainted). O(log n) on the sorted [`field_taint`] list.
|
||||
pub fn get_field(&self, key: FieldTaintKey) -> Option<&FieldCell> {
|
||||
self.field_taint
|
||||
.binary_search_by_key(&key, |(k, _)| *k)
|
||||
.ok()
|
||||
.map(|idx| &self.field_taint[idx].1)
|
||||
}
|
||||
|
||||
/// Pointer-Phase 3 / W4: union `t` into the field cell at `key`,
|
||||
/// recording per-write `validated_must` / `validated_may` channels.
|
||||
///
|
||||
/// Maintains sorted invariant. No-op when `t.caps` is empty (so the
|
||||
/// lattice bottom stays `[]`). When the cell already exists, the
|
||||
/// validation channels merge with the lattice-join semantics —
|
||||
/// `must` AND-intersects, `may` OR-unions — matching the symbol-
|
||||
/// keyed [`SsaTaintState::validated_must`] / `validated_may`
|
||||
/// semantics so a write coming through a non-validated path tears
|
||||
/// down `must` while preserving `may` of any earlier validated path.
|
||||
pub fn add_field(
|
||||
&mut self,
|
||||
key: FieldTaintKey,
|
||||
t: VarTaint,
|
||||
validated_must: bool,
|
||||
validated_may: bool,
|
||||
) {
|
||||
if t.caps.is_empty() {
|
||||
return;
|
||||
}
|
||||
match self.field_taint.binary_search_by_key(&key, |(k, _)| *k) {
|
||||
Ok(idx) => {
|
||||
let cell = &mut self.field_taint[idx].1;
|
||||
cell.taint.caps |= t.caps;
|
||||
cell.taint.uses_summary |= t.uses_summary;
|
||||
let merged = merge_origins(&cell.taint.origins, &t.origins);
|
||||
cell.taint.origins = merged;
|
||||
// Lattice-join semantics on a fresh write joining an
|
||||
// existing cell: must AND-intersects (a single un-
|
||||
// validated writer breaks the invariant); may OR-unions.
|
||||
cell.validated_must &= validated_must;
|
||||
cell.validated_may |= validated_may;
|
||||
}
|
||||
Err(idx) => self.field_taint.insert(
|
||||
idx,
|
||||
(
|
||||
key,
|
||||
FieldCell {
|
||||
taint: t,
|
||||
validated_must,
|
||||
validated_may,
|
||||
},
|
||||
),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -337,6 +506,7 @@ impl Lattice for SsaTaintState {
|
|||
(Some(a), Some(b)) => Some(a.join(b)),
|
||||
_ => None,
|
||||
};
|
||||
let field_taint = merge_join_field_taint(&self.field_taint, &other.field_taint);
|
||||
SsaTaintState {
|
||||
values,
|
||||
validated_must,
|
||||
|
|
@ -345,6 +515,7 @@ impl Lattice for SsaTaintState {
|
|||
heap,
|
||||
path_env,
|
||||
abstract_state,
|
||||
field_taint,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -361,6 +532,9 @@ impl Lattice for SsaTaintState {
|
|||
if !self.heap.leq(&other.heap) {
|
||||
return false;
|
||||
}
|
||||
if !field_taint_leq(&self.field_taint, &other.field_taint) {
|
||||
return false;
|
||||
}
|
||||
// path_env: None (Top) ≥ everything; Some(a) ≤ None only if a is Top-equivalent
|
||||
match (&self.path_env, &other.path_env) {
|
||||
(None, Some(_)) => return false, // Top is NOT ≤ constrained
|
||||
|
|
@ -389,6 +563,126 @@ impl Lattice for SsaTaintState {
|
|||
}
|
||||
}
|
||||
|
||||
/// Pointer-Phase 3 / W4: merge-join two sorted `field_taint` lists.
|
||||
/// Same shape as [`merge_join_ssa_vars`] but keyed on [`FieldTaintKey`]:
|
||||
/// * `taint.caps` — OR-union
|
||||
/// * `taint.origins` — merged with cap-respecting de-dup
|
||||
/// * `taint.uses_summary` — OR-union
|
||||
/// * `validated_must` — AND-intersect (matches the symbol-keyed
|
||||
/// `validated_must` lattice: a path that didn't validate this cell
|
||||
/// breaks the invariant)
|
||||
/// * `validated_may` — OR-union (any path's validation contributes)
|
||||
pub(super) fn merge_join_field_taint(
|
||||
a: &[(FieldTaintKey, FieldCell)],
|
||||
b: &[(FieldTaintKey, FieldCell)],
|
||||
) -> SmallVec<[(FieldTaintKey, FieldCell); 4]> {
|
||||
let mut result = SmallVec::with_capacity(a.len().max(b.len()));
|
||||
let (mut i, mut j) = (0, 0);
|
||||
while i < a.len() && j < b.len() {
|
||||
match a[i].0.cmp(&b[j].0) {
|
||||
std::cmp::Ordering::Less => {
|
||||
// Cell present only in `a` — counterpart in `b` is the
|
||||
// lattice bottom (no validation, no taint), so:
|
||||
// must = a.must AND false = false
|
||||
// may = a.may OR false = a.may
|
||||
let mut cell = a[i].1.clone();
|
||||
cell.validated_must = false;
|
||||
result.push((a[i].0, cell));
|
||||
i += 1;
|
||||
}
|
||||
std::cmp::Ordering::Greater => {
|
||||
let mut cell = b[j].1.clone();
|
||||
cell.validated_must = false;
|
||||
result.push((b[j].0, cell));
|
||||
j += 1;
|
||||
}
|
||||
std::cmp::Ordering::Equal => {
|
||||
let caps = a[i].1.taint.caps | b[j].1.taint.caps;
|
||||
let origins = merge_origins(&a[i].1.taint.origins, &b[j].1.taint.origins);
|
||||
let uses_summary = a[i].1.taint.uses_summary || b[j].1.taint.uses_summary;
|
||||
let validated_must = a[i].1.validated_must && b[j].1.validated_must;
|
||||
let validated_may = a[i].1.validated_may || b[j].1.validated_may;
|
||||
result.push((
|
||||
a[i].0,
|
||||
FieldCell {
|
||||
taint: VarTaint {
|
||||
caps,
|
||||
origins,
|
||||
uses_summary,
|
||||
},
|
||||
validated_must,
|
||||
validated_may,
|
||||
},
|
||||
));
|
||||
i += 1;
|
||||
j += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
while i < a.len() {
|
||||
let mut cell = a[i].1.clone();
|
||||
cell.validated_must = false;
|
||||
result.push((a[i].0, cell));
|
||||
i += 1;
|
||||
}
|
||||
while j < b.len() {
|
||||
let mut cell = b[j].1.clone();
|
||||
cell.validated_must = false;
|
||||
result.push((b[j].0, cell));
|
||||
j += 1;
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// `a ≤ b` for sorted `field_taint` lists. Used by the convergence
|
||||
/// check in [`Lattice::leq`]. Per-cell criteria:
|
||||
///
|
||||
/// * `taint.caps` — `a ⊆ b` (sub-state on caps; matches per-SSA-value
|
||||
/// `ssa_vars_leq`).
|
||||
/// * `validated_must` — `a.must ⊇ b.must` (super-state on must; same
|
||||
/// shape as the symbol-keyed `validated_must` leq).
|
||||
/// * `validated_may` — `a.may ⊆ b.may` (sub-state on may).
|
||||
///
|
||||
/// When `b` lacks a key present in `a`, `b`'s side is the lattice
|
||||
/// bottom: no caps, no validation. `a`'s caps must also be empty
|
||||
/// AND `a.validated_must == false` for `a ≤ b` to hold, otherwise `a`
|
||||
/// is strictly greater than `b` on that cell.
|
||||
pub(super) fn field_taint_leq(
|
||||
a: &[(FieldTaintKey, FieldCell)],
|
||||
b: &[(FieldTaintKey, FieldCell)],
|
||||
) -> bool {
|
||||
let mut j = 0;
|
||||
for (key, ca) in a {
|
||||
while j < b.len() && b[j].0 < *key {
|
||||
j += 1;
|
||||
}
|
||||
if j >= b.len() || b[j].0 != *key {
|
||||
// Key absent in b ⇒ b's value is bottom for this cell;
|
||||
// a's caps must also be empty AND a.must = false.
|
||||
if !ca.taint.caps.is_empty() || ca.validated_must {
|
||||
return false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let cb = &b[j].1;
|
||||
// Caps: a ⊆ b.
|
||||
if (ca.taint.caps - cb.taint.caps).bits() != 0 {
|
||||
return false;
|
||||
}
|
||||
// Must: a ⊇ b — every must-validated key in b is must-validated
|
||||
// in a. Equivalently: !cb.must OR ca.must.
|
||||
if cb.validated_must && !ca.validated_must {
|
||||
return false;
|
||||
}
|
||||
// May: a ⊆ b — every may-validated key in a is may-validated
|
||||
// in b. Equivalently: !ca.may OR cb.may.
|
||||
if ca.validated_may && !cb.validated_may {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Merge-join two sorted SSA var lists.
|
||||
pub(super) fn merge_join_ssa_vars(
|
||||
a: &[(SsaValue, VarTaint)],
|
||||
|
|
@ -756,3 +1050,383 @@ mod origin_cap_tests {
|
|||
set_max_origins_override(0);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod field_taint_tests {
|
||||
//! Pointer-Phase 3: tests for the heap-field taint cells on
|
||||
//! [`SsaTaintState`]. Cover get/add round-trip, lattice join
|
||||
//! (cap union + origin merge), and `leq` convergence semantics.
|
||||
use super::*;
|
||||
use crate::labels::Cap;
|
||||
use crate::pointer::LocId;
|
||||
use crate::ssa::ir::FieldId;
|
||||
use crate::taint::domain::TaintOrigin;
|
||||
use smallvec::SmallVec;
|
||||
|
||||
fn key(loc_raw: u32, field_raw: u32) -> FieldTaintKey {
|
||||
FieldTaintKey {
|
||||
loc: LocId(loc_raw),
|
||||
field: FieldId(field_raw),
|
||||
}
|
||||
}
|
||||
|
||||
fn taint(caps: Cap) -> VarTaint {
|
||||
VarTaint {
|
||||
caps,
|
||||
origins: SmallVec::new(),
|
||||
uses_summary: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience helper: pre-W4 `add_field` calls didn't carry
|
||||
/// validation channels. The new signature accepts them explicitly;
|
||||
/// pre-W4 tests pass `(false, false)` to preserve the original
|
||||
/// semantics.
|
||||
fn add(s: &mut SsaTaintState, k: FieldTaintKey, t: VarTaint) {
|
||||
s.add_field(k, t, false, false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_field_round_trips() {
|
||||
let mut s = SsaTaintState::initial();
|
||||
let k = key(1, 7);
|
||||
add(&mut s, k, taint(Cap::ENV_VAR));
|
||||
let got = s.get_field(k).expect("field cell present");
|
||||
assert!(got.taint.caps.contains(Cap::ENV_VAR));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_field_unions_caps() {
|
||||
let mut s = SsaTaintState::initial();
|
||||
let k = key(1, 7);
|
||||
add(&mut s, k, taint(Cap::ENV_VAR));
|
||||
add(&mut s, k, taint(Cap::ENV_VAR));
|
||||
let got = s.get_field(k).unwrap();
|
||||
assert!(got.taint.caps.contains(Cap::ENV_VAR));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_field_skips_empty_caps() {
|
||||
let mut s = SsaTaintState::initial();
|
||||
let k = key(2, 3);
|
||||
add(&mut s, k, taint(Cap::empty()));
|
||||
assert!(s.get_field(k).is_none(), "empty caps must not insert");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lattice_join_unions_keys_and_caps() {
|
||||
let k1 = key(1, 7);
|
||||
let k2 = key(2, 9);
|
||||
let mut a = SsaTaintState::initial();
|
||||
let mut b = SsaTaintState::initial();
|
||||
add(&mut a, k1, taint(Cap::ENV_VAR));
|
||||
add(&mut b, k1, taint(Cap::ENV_VAR));
|
||||
add(&mut b, k2, taint(Cap::FILE_IO));
|
||||
let joined = a.join(&b);
|
||||
let v1 = joined.get_field(k1).unwrap();
|
||||
assert!(v1.taint.caps.contains(Cap::ENV_VAR));
|
||||
let v2 = joined.get_field(k2).unwrap();
|
||||
assert!(v2.taint.caps.contains(Cap::FILE_IO));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lattice_leq_detects_strict_increase() {
|
||||
// a is empty; b has a cell. Empty ≤ any state, so a.leq(b)
|
||||
// holds. b ≤ a fails because b has a cell with non-empty caps
|
||||
// that a lacks.
|
||||
let mut b = SsaTaintState::initial();
|
||||
add(&mut b, key(1, 7), taint(Cap::ENV_VAR));
|
||||
let a = SsaTaintState::initial();
|
||||
assert!(a.leq(&b), "empty state ≤ state with a field cell");
|
||||
assert!(!b.leq(&a), "state with a field cell is NOT ≤ empty state");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lattice_leq_holds_when_caps_subset() {
|
||||
let k = key(3, 4);
|
||||
let mut a = SsaTaintState::initial();
|
||||
let mut b = SsaTaintState::initial();
|
||||
add(&mut a, k, taint(Cap::ENV_VAR));
|
||||
add(&mut b, k, taint(Cap::ENV_VAR | Cap::FILE_IO));
|
||||
assert!(a.leq(&b));
|
||||
assert!(!b.leq(&a));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_origins_via_join_dedups_by_node() {
|
||||
use petgraph::graph::NodeIndex;
|
||||
let k = key(1, 1);
|
||||
let o1 = TaintOrigin {
|
||||
node: NodeIndex::new(5),
|
||||
source_kind: crate::labels::SourceKind::UserInput,
|
||||
source_span: Some((0, 1)),
|
||||
};
|
||||
let o2 = TaintOrigin {
|
||||
node: NodeIndex::new(7),
|
||||
source_kind: crate::labels::SourceKind::EnvironmentConfig,
|
||||
source_span: Some((10, 11)),
|
||||
};
|
||||
let mut t1 = taint(Cap::ENV_VAR);
|
||||
t1.origins.push(o1);
|
||||
let mut t2 = taint(Cap::ENV_VAR);
|
||||
t2.origins.push(o1);
|
||||
t2.origins.push(o2);
|
||||
|
||||
let mut a = SsaTaintState::initial();
|
||||
let mut b = SsaTaintState::initial();
|
||||
add(&mut a, k, t1);
|
||||
add(&mut b, k, t2);
|
||||
let joined = a.join(&b);
|
||||
let cell = joined.get_field(k).unwrap();
|
||||
// Both origins survive; the duplicate o1 dedups.
|
||||
assert_eq!(cell.taint.origins.len(), 2);
|
||||
let nodes: Vec<_> = cell.taint.origins.iter().map(|o| o.node).collect();
|
||||
assert!(nodes.contains(&NodeIndex::new(5)));
|
||||
assert!(nodes.contains(&NodeIndex::new(7)));
|
||||
}
|
||||
|
||||
/// W4 audit: `merge_join_field_taint` AND-intersects
|
||||
/// `validated_must` when joining cells with the same key. Two
|
||||
/// states whose paths each independently must-validate the cell
|
||||
/// keep `must = true`; if either path doesn't validate, `must`
|
||||
/// drops to false on the join.
|
||||
#[test]
|
||||
fn lattice_validated_must_intersects_on_join() {
|
||||
let k = key(1, 7);
|
||||
let mut a = SsaTaintState::initial();
|
||||
let mut b = SsaTaintState::initial();
|
||||
a.add_field(k, taint(Cap::ENV_VAR), true, true);
|
||||
b.add_field(k, taint(Cap::ENV_VAR), true, true);
|
||||
let joined_aa = a.join(&b);
|
||||
let cell = joined_aa.get_field(k).unwrap();
|
||||
assert!(cell.validated_must, "a.must AND b.must = true");
|
||||
assert!(cell.validated_may);
|
||||
|
||||
// Now make `b`'s validated_must false — must should drop to
|
||||
// false on the join, may stays at OR.
|
||||
let mut c = SsaTaintState::initial();
|
||||
c.add_field(k, taint(Cap::ENV_VAR), false, true);
|
||||
let joined_ac = a.join(&c);
|
||||
let cell2 = joined_ac.get_field(k).unwrap();
|
||||
assert!(!cell2.validated_must, "a.must AND c.must = false");
|
||||
assert!(cell2.validated_may, "a.may OR c.may = true");
|
||||
}
|
||||
|
||||
/// W4 audit: `merge_join_field_taint` OR-unions `validated_may`
|
||||
/// — any path's may-validation contributes to the joined cell.
|
||||
#[test]
|
||||
fn lattice_validated_may_unions_on_join() {
|
||||
let k = key(1, 7);
|
||||
let mut a = SsaTaintState::initial();
|
||||
let mut b = SsaTaintState::initial();
|
||||
a.add_field(k, taint(Cap::ENV_VAR), false, false);
|
||||
b.add_field(k, taint(Cap::ENV_VAR), false, true);
|
||||
let joined = a.join(&b);
|
||||
let cell = joined.get_field(k).unwrap();
|
||||
assert!(!cell.validated_must);
|
||||
assert!(cell.validated_may, "a.may OR b.may = true");
|
||||
}
|
||||
|
||||
/// W4 audit: when one side of the join lacks the key, the
|
||||
/// counterpart's validated_must drops to false (intersection with
|
||||
/// the lattice bottom's `must = false`); validated_may is preserved
|
||||
/// (`OR false = self`). Caps and origins are preserved.
|
||||
#[test]
|
||||
fn lattice_validated_consistent_with_taint_join() {
|
||||
let k = key(2, 11);
|
||||
let mut a = SsaTaintState::initial();
|
||||
let b = SsaTaintState::initial();
|
||||
a.add_field(k, taint(Cap::ENV_VAR), true, true);
|
||||
let joined = a.join(&b);
|
||||
let cell = joined.get_field(k).unwrap();
|
||||
assert!(
|
||||
!cell.validated_must,
|
||||
"joined with empty side must drop validated_must"
|
||||
);
|
||||
assert!(
|
||||
cell.validated_may,
|
||||
"joined with empty side keeps validated_may"
|
||||
);
|
||||
assert!(cell.taint.caps.contains(Cap::ENV_VAR));
|
||||
|
||||
// Symmetric: empty.join(populated) yields the same cell shape.
|
||||
let joined2 = b.join(&a);
|
||||
let cell2 = joined2.get_field(k).unwrap();
|
||||
assert!(!cell2.validated_must);
|
||||
assert!(cell2.validated_may);
|
||||
}
|
||||
|
||||
/// W4 audit: `field_taint_leq` respects both validation channels.
|
||||
/// `must` is super-state (a.must ⊇ b.must); `may` is sub-state
|
||||
/// (a.may ⊆ b.may). A state strictly "smaller" on caps but
|
||||
/// strictly "larger" on may must NOT compare ≤.
|
||||
#[test]
|
||||
fn lattice_leq_respects_validated_channels() {
|
||||
let k = key(3, 5);
|
||||
|
||||
// Case 1: a has must=true, b has must=false. a.must ⊇ b.must
|
||||
// holds (true ⊇ false), so a ≤ b on this channel. But b's
|
||||
// caps must dominate a's for a ≤ b overall.
|
||||
let mut a = SsaTaintState::initial();
|
||||
let mut b = SsaTaintState::initial();
|
||||
a.add_field(k, taint(Cap::ENV_VAR), true, false);
|
||||
b.add_field(k, taint(Cap::ENV_VAR), false, false);
|
||||
assert!(
|
||||
a.leq(&b),
|
||||
"must super-state and equal caps: a ≤ b should hold"
|
||||
);
|
||||
// Reverse: b.must=false, a.must=true — for b ≤ a, we need
|
||||
// b.must ⊇ a.must which is false ⊇ true = false. So b ≤ a
|
||||
// must fail.
|
||||
assert!(!b.leq(&a), "b lacks the must invariant a holds");
|
||||
|
||||
// Case 2: a has may=true, b has may=false. a.may ⊆ b.may
|
||||
// requires true ⊆ false = false, so a ≤ b must fail.
|
||||
let mut a2 = SsaTaintState::initial();
|
||||
let mut b2 = SsaTaintState::initial();
|
||||
a2.add_field(k, taint(Cap::ENV_VAR), false, true);
|
||||
b2.add_field(k, taint(Cap::ENV_VAR), false, false);
|
||||
assert!(!a2.leq(&b2), "a.may=true is NOT ⊆ b.may=false");
|
||||
}
|
||||
|
||||
/// Pointer-Phase 3 / A8 audit: the field_taint lattice is monotone
|
||||
/// and converges under a deterministic enumeration of inputs.
|
||||
/// Caps grow (OR), `uses_summary` grows (OR), origins grow modulo
|
||||
/// the cap (merge_origins is bounded). Joins must:
|
||||
/// 1. Be commutative: join(a, b) == join(b, a).
|
||||
/// 2. Be associative: join(join(a, b), c) == join(a, join(b, c)).
|
||||
/// 3. Be idempotent: join(a, a) == a.
|
||||
/// 4. Reach a fixed point in at most |unique cells| iterations.
|
||||
#[test]
|
||||
fn lattice_converges_under_deterministic_enumeration() {
|
||||
use crate::labels::Cap;
|
||||
use petgraph::graph::NodeIndex;
|
||||
|
||||
// Build N distinct (key, taint) pairs.
|
||||
let inputs: Vec<(FieldTaintKey, VarTaint)> = (0..6)
|
||||
.map(|i| {
|
||||
let key = FieldTaintKey {
|
||||
loc: LocId(1 + (i % 3) as u32),
|
||||
field: FieldId((i % 4) as u32),
|
||||
};
|
||||
let taint = VarTaint {
|
||||
caps: if i % 2 == 0 {
|
||||
Cap::ENV_VAR
|
||||
} else {
|
||||
Cap::FILE_IO
|
||||
},
|
||||
origins: smallvec::SmallVec::from_iter([TaintOrigin {
|
||||
node: NodeIndex::new(i + 10),
|
||||
source_kind: crate::labels::SourceKind::UserInput,
|
||||
source_span: Some((i * 5, i * 5 + 2)),
|
||||
}]),
|
||||
uses_summary: false,
|
||||
};
|
||||
(key, taint)
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Build a list of states, each with a single (key, taint) pair.
|
||||
let states: Vec<SsaTaintState> = inputs
|
||||
.iter()
|
||||
.map(|(k, t)| {
|
||||
let mut s = SsaTaintState::initial();
|
||||
add(&mut s, *k, t.clone());
|
||||
s
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Compute LUB by folding `join` over `states`.
|
||||
let lub = states
|
||||
.iter()
|
||||
.skip(1)
|
||||
.fold(states[0].clone(), |acc, s| acc.join(s));
|
||||
|
||||
// 1. Commutativity: join(a, b) == join(b, a).
|
||||
for i in 0..states.len() {
|
||||
for j in (i + 1)..states.len() {
|
||||
let ab = states[i].join(&states[j]);
|
||||
let ba = states[j].join(&states[i]);
|
||||
assert_eq!(
|
||||
ab, ba,
|
||||
"join must commute: states[{i}] ⊕ states[{j}] != states[{j}] ⊕ states[{i}]",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Associativity: ((a ⊕ b) ⊕ c) == (a ⊕ (b ⊕ c)).
|
||||
for i in 0..states.len() {
|
||||
for j in 0..states.len() {
|
||||
for k in 0..states.len() {
|
||||
let a = &states[i];
|
||||
let b = &states[j];
|
||||
let c = &states[k];
|
||||
let left = a.join(b).join(c);
|
||||
let right = a.join(&b.join(c));
|
||||
assert_eq!(
|
||||
left, right,
|
||||
"join must associate: states[{i},{j},{k}] left vs right",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Idempotency: lub ⊕ lub == lub, lub ⊕ s == lub for every input s.
|
||||
let lub_lub = lub.join(&lub);
|
||||
assert_eq!(lub, lub_lub, "lub must be idempotent under self-join");
|
||||
for (i, s) in states.iter().enumerate() {
|
||||
let merged = lub.join(s);
|
||||
assert_eq!(
|
||||
lub, merged,
|
||||
"lub.join(states[{i}]) must equal lub (s ≤ lub)",
|
||||
);
|
||||
}
|
||||
|
||||
// 4. Convergence within a bounded number of iterations. The
|
||||
// worklist tightens after each input is folded in; once every
|
||||
// unique key has been seen, further folds are no-ops.
|
||||
let mut acc = SsaTaintState::initial();
|
||||
let mut iter_count = 0;
|
||||
loop {
|
||||
iter_count += 1;
|
||||
if iter_count > inputs.len() + 4 {
|
||||
panic!("lattice did not converge within bounded iterations");
|
||||
}
|
||||
let mut next = acc.clone();
|
||||
for s in &states {
|
||||
next = next.join(s);
|
||||
}
|
||||
if next.field_taint == acc.field_taint {
|
||||
break;
|
||||
}
|
||||
acc = next;
|
||||
}
|
||||
assert_eq!(
|
||||
acc, lub,
|
||||
"iterative fold must converge to the lub regardless of order",
|
||||
);
|
||||
}
|
||||
|
||||
/// `field_taint_leq` is the soundness gate for worklist
|
||||
/// convergence: once `next ≤ acc`, the worklist halts. Pin that
|
||||
/// `leq` is consistent with `join` — i.e. `s.leq(s.join(t))` holds
|
||||
/// for any `s, t`. Without this, the worklist could loop
|
||||
/// indefinitely on inputs whose join produces a state not
|
||||
/// dominated by both inputs.
|
||||
#[test]
|
||||
fn lattice_leq_consistent_with_join() {
|
||||
use crate::labels::Cap;
|
||||
let mut a = SsaTaintState::initial();
|
||||
let mut b = SsaTaintState::initial();
|
||||
add(&mut a, key(1, 7), taint(Cap::ENV_VAR));
|
||||
add(&mut b, key(1, 7), taint(Cap::FILE_IO));
|
||||
add(&mut b, key(2, 9), taint(Cap::SHELL_ESCAPE));
|
||||
let j = a.join(&b);
|
||||
assert!(a.leq(&j), "a ≤ a ⊕ b");
|
||||
assert!(b.leq(&j), "b ≤ a ⊕ b");
|
||||
// Reflexive: x ≤ x.
|
||||
assert!(a.leq(&a));
|
||||
assert!(b.leq(&b));
|
||||
assert!(j.leq(&j));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -50,6 +50,49 @@ pub fn extract_ssa_func_summary(
|
|||
module_aliases: Option<&HashMap<SsaValue, SmallVec<[String; 2]>>>,
|
||||
locator: Option<&crate::summary::SinkSiteLocator<'_>>,
|
||||
formal_param_names: Option<&[String]>,
|
||||
) -> crate::summary::ssa_summary::SsaFuncSummary {
|
||||
extract_ssa_func_summary_full(
|
||||
ssa,
|
||||
cfg,
|
||||
local_summaries,
|
||||
global_summaries,
|
||||
lang,
|
||||
namespace,
|
||||
interner,
|
||||
param_count,
|
||||
module_aliases,
|
||||
locator,
|
||||
formal_param_names,
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
/// Like [`extract_ssa_func_summary`] but allows passing an in-progress
|
||||
/// `ssa_summaries` map so the per-parameter probes can resolve callee
|
||||
/// SSA summaries via step 0 of `resolve_callee_full`.
|
||||
///
|
||||
/// This enables transitive cross-function summary propagation: when a
|
||||
/// caller's body references a callee whose summary was just augmented
|
||||
/// by the closure-capture lift pass, the caller's probe sees the
|
||||
/// augmented `param_to_sink` and can propagate it onto the caller's
|
||||
/// own summary. Used by `lower_all_functions_from_bodies`'s second
|
||||
/// extraction pass after `augment_summaries_with_child_sinks`.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn extract_ssa_func_summary_full(
|
||||
ssa: &SsaBody,
|
||||
cfg: &Cfg,
|
||||
local_summaries: &FuncSummaries,
|
||||
global_summaries: Option<&GlobalSummaries>,
|
||||
lang: Lang,
|
||||
namespace: &str,
|
||||
interner: &crate::state::symbol::SymbolInterner,
|
||||
param_count: usize,
|
||||
module_aliases: Option<&HashMap<SsaValue, SmallVec<[String; 2]>>>,
|
||||
locator: Option<&crate::summary::SinkSiteLocator<'_>>,
|
||||
formal_param_names: Option<&[String]>,
|
||||
ssa_summaries: Option<
|
||||
&HashMap<crate::symbol::FuncKey, crate::summary::ssa_summary::SsaFuncSummary>,
|
||||
>,
|
||||
) -> crate::summary::ssa_summary::SsaFuncSummary {
|
||||
use crate::summary::SinkSite;
|
||||
use crate::summary::ssa_summary::{SsaFuncSummary, TaintTransform};
|
||||
|
|
@ -143,7 +186,7 @@ pub fn extract_ssa_func_summary(
|
|||
receiver_seed: None,
|
||||
const_values: None,
|
||||
type_facts: None,
|
||||
ssa_summaries: None,
|
||||
ssa_summaries,
|
||||
extra_labels: None,
|
||||
base_aliases: None,
|
||||
callee_bodies: None,
|
||||
|
|
@ -158,6 +201,7 @@ pub fn extract_ssa_func_summary(
|
|||
static_map: None,
|
||||
auto_seed_handler_params: false,
|
||||
cross_file_bodies: None,
|
||||
pointer_facts: None,
|
||||
};
|
||||
|
||||
let (events, block_states) = run_ssa_taint_full(ssa, cfg, &transfer);
|
||||
|
|
@ -358,15 +402,49 @@ pub fn extract_ssa_func_summary(
|
|||
source_kind: SourceKind::UserInput,
|
||||
source_span: None,
|
||||
};
|
||||
let probe_taint = VarTaint {
|
||||
caps: Cap::all(),
|
||||
origins: SmallVec::from_elem(origin, 1),
|
||||
uses_summary: false,
|
||||
};
|
||||
seed.insert(
|
||||
BindingKey::new(var_name.as_str(), BodyId(0)),
|
||||
VarTaint {
|
||||
caps: Cap::all(),
|
||||
origins: SmallVec::from_elem(origin, 1),
|
||||
uses_summary: false,
|
||||
},
|
||||
probe_taint.clone(),
|
||||
);
|
||||
|
||||
// Phantom-Param prefix seeding. SSA lowering of arrow / nested
|
||||
// function bodies often exposes free-identifier member-access
|
||||
// expressions (e.g. `file._source.uri`) as their own
|
||||
// [`SsaOp::Param`] ops with composite `var_name`s like
|
||||
// `"file._source.uri"`. These phantom Params are the values
|
||||
// actually used as call arguments — not the formal-param SSA
|
||||
// value the seed targets. Without this, the per-param probe
|
||||
// misses cross-call sinks because the call's arg SSA value is
|
||||
// a phantom Param with no seed entry, so `transfer_inst::Param`
|
||||
// leaves it untainted and `collect_tainted_sink_values`
|
||||
// observes empty caps despite the formal param being seeded.
|
||||
//
|
||||
// Seed every phantom Param whose `var_name` begins with
|
||||
// `formal_var_name + "."` with the same caps the formal param
|
||||
// received: semantically "if `file` is tainted, then every
|
||||
// observable field path on `file` is tainted too". Bounded
|
||||
// by SSA size; cap-equivalent to direct seeding.
|
||||
let prefix = format!("{}.", var_name);
|
||||
for block in &ssa.blocks {
|
||||
for inst in block.phis.iter().chain(block.body.iter()) {
|
||||
if let SsaOp::Param { .. } = &inst.op {
|
||||
if let Some(name) = inst.var_name.as_ref() {
|
||||
if name.starts_with(&prefix) {
|
||||
seed.insert(
|
||||
BindingKey::new(name.as_str(), BodyId(0)),
|
||||
probe_taint.clone(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let (return_caps, events, _, per_return_obs) = run_probe(seed);
|
||||
|
||||
// Subtract baseline source_caps — we only want param-contributed caps
|
||||
|
|
@ -493,7 +571,7 @@ pub fn extract_ssa_func_summary(
|
|||
receiver_seed: None,
|
||||
const_values: None,
|
||||
type_facts: None,
|
||||
ssa_summaries: None,
|
||||
ssa_summaries,
|
||||
extra_labels: None,
|
||||
base_aliases: None,
|
||||
callee_bodies: None,
|
||||
|
|
@ -508,6 +586,7 @@ pub fn extract_ssa_func_summary(
|
|||
static_map: None,
|
||||
auto_seed_handler_params: false,
|
||||
cross_file_bodies: None,
|
||||
pointer_facts: None,
|
||||
};
|
||||
detect_source_to_callback_from_states(
|
||||
ssa,
|
||||
|
|
@ -551,6 +630,16 @@ pub fn extract_ssa_func_summary(
|
|||
param_return_paths,
|
||||
return_path_facts,
|
||||
points_to,
|
||||
// Pointer-Phase 5 extension — empty until the field-granularity
|
||||
// extractor is wired (`NYX_POINTER_ANALYSIS=1` only). Default
|
||||
// path stays bit-identical to today.
|
||||
field_points_to: crate::summary::points_to::FieldPointsToSummary::empty(),
|
||||
// Populated post-extraction in
|
||||
// `taint::lower_all_functions_from_bodies` once SSA optimisation
|
||||
// has computed `opt.type_facts`. Empty here means the
|
||||
// extractor itself doesn't carry receiver-type info — the
|
||||
// caller patches it in.
|
||||
typed_call_receivers: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -909,6 +998,7 @@ pub(crate) fn extract_container_flow_summary(
|
|||
callee,
|
||||
args,
|
||||
receiver,
|
||||
..
|
||||
} = &inst.op
|
||||
{
|
||||
let op = match classify_container_op(callee, lang) {
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -60,6 +60,7 @@ fn ssa_analyse_rust(src: &[u8]) -> Vec<Finding> {
|
|||
static_map: None,
|
||||
auto_seed_handler_params: false,
|
||||
cross_file_bodies: None,
|
||||
pointer_facts: None,
|
||||
};
|
||||
let events = ssa_transfer::run_ssa_taint(&ssa, cfg, &transfer);
|
||||
let mut findings = ssa_transfer::ssa_events_to_findings(&events, &ssa, cfg);
|
||||
|
|
@ -1584,6 +1585,142 @@ fn cpp_source_to_sink() {
|
|||
);
|
||||
}
|
||||
|
||||
/// Phase 2 (cpp-precision): `c_str()` is a const accessor on `std::string`
|
||||
/// that returns a pointer to the same buffer. It must propagate taint from
|
||||
/// the receiver to the result so the downstream sink fires.
|
||||
#[test]
|
||||
fn cpp_c_str_propagates_taint() {
|
||||
let src = b"#include <cstdlib>\n#include <string>\nint main() {\n char* input = std::getenv(\"X\");\n std::string s = input;\n std::system(s.c_str());\n return 0;\n}\n";
|
||||
let lang = tree_sitter::Language::from(tree_sitter_cpp::LANGUAGE);
|
||||
let file_cfg = parse_lang(src, "cpp", lang);
|
||||
let summaries = &file_cfg.summaries;
|
||||
let findings = analyse_file(&file_cfg, summaries, None, Lang::Cpp, "test.cpp", &[], None);
|
||||
assert!(
|
||||
!findings.is_empty(),
|
||||
"C++: tainted s.c_str() into system() must fire (Phase 2 c_str passthrough)",
|
||||
);
|
||||
}
|
||||
|
||||
/// Phase 2: `std::move(x)` returns its argument unchanged in terms of
|
||||
/// data flow — the rvalue cast is a representation move, not a sanitiser.
|
||||
/// Default propagation collects argument taint into the result.
|
||||
#[test]
|
||||
fn cpp_std_move_propagates_taint() {
|
||||
let src = b"#include <cstdlib>\n#include <string>\n#include <utility>\nint main() {\n char* input = std::getenv(\"X\");\n std::string s = input;\n std::string moved = std::move(s);\n std::system(moved.c_str());\n return 0;\n}\n";
|
||||
let lang = tree_sitter::Language::from(tree_sitter_cpp::LANGUAGE);
|
||||
let file_cfg = parse_lang(src, "cpp", lang);
|
||||
let summaries = &file_cfg.summaries;
|
||||
let findings = analyse_file(&file_cfg, summaries, None, Lang::Cpp, "test.cpp", &[], None);
|
||||
assert!(
|
||||
!findings.is_empty(),
|
||||
"C++: taint must flow through std::move() into the sink",
|
||||
);
|
||||
}
|
||||
|
||||
/// Phase 2: `static_cast<T>(x)` is parsed as a call expression by
|
||||
/// tree-sitter-cpp; default propagation transports taint from the casted
|
||||
/// argument to the result.
|
||||
#[test]
|
||||
fn cpp_static_cast_propagates_taint() {
|
||||
let src = b"#include <cstdlib>\nint main() {\n char* input = std::getenv(\"X\");\n const char* casted = static_cast<const char*>(input);\n std::system(casted);\n return 0;\n}\n";
|
||||
let lang = tree_sitter::Language::from(tree_sitter_cpp::LANGUAGE);
|
||||
let file_cfg = parse_lang(src, "cpp", lang);
|
||||
let summaries = &file_cfg.summaries;
|
||||
let findings = analyse_file(&file_cfg, summaries, None, Lang::Cpp, "test.cpp", &[], None);
|
||||
assert!(
|
||||
!findings.is_empty(),
|
||||
"C++: taint must flow through static_cast<T>() into the sink",
|
||||
);
|
||||
}
|
||||
|
||||
/// Phase 5 (cpp-precision): a fluent builder chain whose host
|
||||
/// argument is tainted should fire on the terminal `.connect()`
|
||||
/// SSRF sink. The chained `.host(...)` / `.port(...)` calls return
|
||||
/// the receiver, and default Call-arg propagation puts the tainted
|
||||
/// argument on the chain so it reaches the terminal sink.
|
||||
#[test]
|
||||
fn cpp_builder_chain_user_host_fires() {
|
||||
let src = b"#include <cstdlib>\n#include <string>\nclass Socket {\npublic:\n static Socket builder() { return Socket(); }\n Socket& host(const std::string& h) { host_ = h; return *this; }\n Socket& port(int p) { port_ = p; return *this; }\n void connect() {}\nprivate:\n std::string host_;\n int port_ = 0;\n};\nint main() {\n char* h = std::getenv(\"X\");\n Socket::builder().host(h).port(80).connect();\n return 0;\n}\n";
|
||||
let lang = tree_sitter::Language::from(tree_sitter_cpp::LANGUAGE);
|
||||
let file_cfg = parse_lang(src, "cpp", lang);
|
||||
let summaries = &file_cfg.summaries;
|
||||
let findings = analyse_file(&file_cfg, summaries, None, Lang::Cpp, "test.cpp", &[], None);
|
||||
assert!(
|
||||
!findings.is_empty(),
|
||||
"C++: tainted host through fluent builder chain must reach terminal connect() (Phase 5)",
|
||||
);
|
||||
}
|
||||
|
||||
/// Phase 5: a fluent builder chain with a hardcoded host literal
|
||||
/// must NOT fire on the terminal connect() sink — the chain carries
|
||||
/// no taint.
|
||||
#[test]
|
||||
fn cpp_builder_chain_const_host_silent() {
|
||||
let src = b"#include <string>\nclass Socket {\npublic:\n static Socket builder() { return Socket(); }\n Socket& host(const std::string& h) { host_ = h; return *this; }\n Socket& port(int p) { port_ = p; return *this; }\n void connect() {}\nprivate:\n std::string host_;\n int port_ = 0;\n};\nint main() {\n Socket::builder().host(\"api.example.com\").port(80).connect();\n return 0;\n}\n";
|
||||
let lang = tree_sitter::Language::from(tree_sitter_cpp::LANGUAGE);
|
||||
let file_cfg = parse_lang(src, "cpp", lang);
|
||||
let summaries = &file_cfg.summaries;
|
||||
let findings = analyse_file(&file_cfg, summaries, None, Lang::Cpp, "test.cpp", &[], None);
|
||||
assert!(
|
||||
findings.is_empty(),
|
||||
"C++: builder chain with literal host must NOT fire (Phase 5 negative)",
|
||||
);
|
||||
}
|
||||
|
||||
/// Phase 4 (cpp-precision): inline member-function bodies inside a
|
||||
/// `class_specifier` must be extracted as separate functions and
|
||||
/// intra-file calls must resolve to their bodies. Pre-Phase-4, the
|
||||
/// `class_specifier` AST kind was unmapped in cpp KINDS, so the CFG
|
||||
/// walker treated the entire class as a leaf `Seq` node and never
|
||||
/// descended into inline methods.
|
||||
#[test]
|
||||
fn cpp_inline_class_method_resolves() {
|
||||
let src = b"#include <cstdlib>\nclass Inner {\npublic:\n void run(const char* arg) { std::system(arg); }\n};\nint main() {\n char* input = std::getenv(\"X\");\n Inner inner;\n inner.run(input);\n return 0;\n}\n";
|
||||
let lang = tree_sitter::Language::from(tree_sitter_cpp::LANGUAGE);
|
||||
let file_cfg = parse_lang(src, "cpp", lang);
|
||||
let summaries = &file_cfg.summaries;
|
||||
let findings = analyse_file(&file_cfg, summaries, None, Lang::Cpp, "test.cpp", &[], None);
|
||||
assert!(
|
||||
!findings.is_empty(),
|
||||
"C++: tainted arg through inline class method must reach system() (Phase 4)",
|
||||
);
|
||||
}
|
||||
|
||||
/// Phase 3 (cpp-precision): a tainted argument passed through an
|
||||
/// identity-style lambda (`auto echo = [](const char* s) { return s; }`)
|
||||
/// must reach the downstream sink. This is handled by the same default
|
||||
/// Call-arg propagation as `std::move`/`static_cast`; pinning the
|
||||
/// behaviour here so future engine work doesn't silently regress
|
||||
/// identity lambdas.
|
||||
#[test]
|
||||
fn cpp_identity_lambda_propagates_taint() {
|
||||
let src = b"#include <cstdlib>\nint main() {\n char* input = std::getenv(\"X\");\n auto echo = [](const char* s) { return s; };\n std::system(echo(input));\n return 0;\n}\n";
|
||||
let lang = tree_sitter::Language::from(tree_sitter_cpp::LANGUAGE);
|
||||
let file_cfg = parse_lang(src, "cpp", lang);
|
||||
let summaries = &file_cfg.summaries;
|
||||
let findings = analyse_file(&file_cfg, summaries, None, Lang::Cpp, "test.cpp", &[], None);
|
||||
assert!(
|
||||
!findings.is_empty(),
|
||||
"C++: taint must flow through identity lambda echo() into system()",
|
||||
);
|
||||
}
|
||||
|
||||
/// Phase 2: `std::vector<char>::data()` is a Load-style container op that
|
||||
/// returns a pointer to the underlying buffer; `system(v.data())` should
|
||||
/// fire when `v` is tainted.
|
||||
#[test]
|
||||
fn cpp_vector_data_propagates_taint() {
|
||||
let src = b"#include <cstdlib>\n#include <vector>\nint main() {\n char* input = std::getenv(\"X\");\n std::vector<char> v(input, input + 8);\n std::system(v.data());\n return 0;\n}\n";
|
||||
let lang = tree_sitter::Language::from(tree_sitter_cpp::LANGUAGE);
|
||||
let file_cfg = parse_lang(src, "cpp", lang);
|
||||
let summaries = &file_cfg.summaries;
|
||||
let findings = analyse_file(&file_cfg, summaries, None, Lang::Cpp, "test.cpp", &[], None);
|
||||
assert!(
|
||||
!findings.is_empty(),
|
||||
"C++: taint must flow through v.data() into the sink",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn php_source_to_sink() {
|
||||
let src =
|
||||
|
|
@ -3646,6 +3783,7 @@ fn assert_ssa_integration(src: &[u8]) {
|
|||
static_map: None,
|
||||
auto_seed_handler_params: false,
|
||||
cross_file_bodies: None,
|
||||
pointer_facts: None,
|
||||
};
|
||||
let events = ssa_transfer::run_ssa_taint(&ssa, the_cfg, &ssa_xfer);
|
||||
let mut ssa_findings = ssa_transfer::ssa_events_to_findings(&events, &ssa, the_cfg);
|
||||
|
|
@ -3781,6 +3919,7 @@ fn integ_php_echo_simple_var() {
|
|||
static_map: None,
|
||||
auto_seed_handler_params: false,
|
||||
cross_file_bodies: None,
|
||||
pointer_facts: None,
|
||||
};
|
||||
let events = ssa_transfer::run_ssa_taint(&ssa, the_cfg, &ssa_xfer);
|
||||
let mut ssa_findings = ssa_transfer::ssa_events_to_findings(&events, &ssa, the_cfg);
|
||||
|
|
@ -3848,6 +3987,7 @@ fn integ_c_curl_handle_ssrf() {
|
|||
static_map: None,
|
||||
auto_seed_handler_params: false,
|
||||
cross_file_bodies: None,
|
||||
pointer_facts: None,
|
||||
};
|
||||
let events = ssa_transfer::run_ssa_taint(&ssa, the_cfg, &ssa_xfer);
|
||||
let mut ssa_findings = ssa_transfer::ssa_events_to_findings(&events, &ssa, the_cfg);
|
||||
|
|
@ -5584,3 +5724,381 @@ fn link_alternative_paths_three_way_group() {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Typed call-graph devirtualisation — Phase 2 (typed_call_receivers)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Phase 2: when a method call's receiver was constructed from a known
|
||||
/// constructor (`File::open` → `FileHandle`), the SSA-extraction
|
||||
/// pipeline must record `(call_ordinal, "FileHandle")` on the
|
||||
/// caller's [`crate::summary::ssa_summary::SsaFuncSummary::typed_call_receivers`]
|
||||
/// so Phase 3 can devirtualise the cross-file edge.
|
||||
///
|
||||
/// Uses Java because `FileInputStream` / `FileOutputStream` are part
|
||||
/// of the [`crate::ssa::type_facts::constructor_type`] table for Java
|
||||
/// and yield [`crate::ssa::type_facts::TypeKind::FileHandle`] without
|
||||
/// any framework annotation plumbing.
|
||||
#[test]
|
||||
fn typed_call_receivers_populated_for_constructor_typed_receiver() {
|
||||
let src = br#"
|
||||
class Reader {
|
||||
void read() {
|
||||
FileInputStream f = new FileInputStream("/etc/passwd");
|
||||
f.close();
|
||||
}
|
||||
}
|
||||
"#;
|
||||
let lang = tree_sitter::Language::from(tree_sitter_java::LANGUAGE);
|
||||
let file_cfg = parse_lang(src, "java", lang);
|
||||
|
||||
let (summaries, _bodies) = super::extract_ssa_artifacts_from_file_cfg(
|
||||
&file_cfg,
|
||||
Lang::Java,
|
||||
"Reader.java",
|
||||
&file_cfg.summaries,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
let read_sum = summaries
|
||||
.iter()
|
||||
.find(|(k, _)| k.name == "read")
|
||||
.map(|(_, s)| s)
|
||||
.expect("read() summary must be extracted");
|
||||
|
||||
let containers: Vec<&str> = read_sum
|
||||
.typed_call_receivers
|
||||
.iter()
|
||||
.map(|(_, c)| c.as_str())
|
||||
.collect();
|
||||
assert!(
|
||||
containers.contains(&"FileHandle"),
|
||||
"FileInputStream-typed receiver must surface as `FileHandle` container; got {:?}",
|
||||
read_sum.typed_call_receivers,
|
||||
);
|
||||
}
|
||||
|
||||
/// Phase 2 negative control: free-function calls (no receiver) must
|
||||
/// never appear in `typed_call_receivers`. Even when the callee is a
|
||||
/// known type-producing constructor, it sits in the body as a Call
|
||||
/// with `receiver = None` and is not a candidate for devirtualisation.
|
||||
#[test]
|
||||
fn typed_call_receivers_skips_free_function_calls() {
|
||||
// `new FileInputStream(...)` is a constructor invocation with no
|
||||
// receiver — exactly the shape we want to ignore.
|
||||
let src = br#"
|
||||
class Maker {
|
||||
void make() {
|
||||
new FileInputStream("/tmp/x");
|
||||
}
|
||||
}
|
||||
"#;
|
||||
let lang = tree_sitter::Language::from(tree_sitter_java::LANGUAGE);
|
||||
let file_cfg = parse_lang(src, "java", lang);
|
||||
|
||||
let (summaries, _) = super::extract_ssa_artifacts_from_file_cfg(
|
||||
&file_cfg,
|
||||
Lang::Java,
|
||||
"Maker.java",
|
||||
&file_cfg.summaries,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
// make() has zero parameters and no fresh-allocation return, so the
|
||||
// generic insertion gate skips it. The phase-2 patch only force-
|
||||
// inserts when `typed_call_receivers` is non-empty — which it
|
||||
// isn't here, since `new FileInputStream(...)` is a free-function-
|
||||
// shaped constructor call (no SSA receiver). So either the
|
||||
// summary is absent, or — if some other side effect inserted it —
|
||||
// its `typed_call_receivers` is empty. Both forms prove no
|
||||
// spurious typed entry was recorded.
|
||||
let typed = summaries
|
||||
.iter()
|
||||
.find(|(k, _)| k.name == "make")
|
||||
.map(|(_, s)| s.typed_call_receivers.clone())
|
||||
.unwrap_or_default();
|
||||
assert!(
|
||||
typed.is_empty(),
|
||||
"constructor-invocation Call has no receiver and must not surface a typed entry; \
|
||||
got {typed:?}",
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression: nested arrow functions inside `return new Promise((res,rej)
|
||||
/// => { ... })` must be lifted as separate bodies. Before the Kind::Return
|
||||
/// arm in cfg/mod.rs called `collect_nested_function_nodes`, only the
|
||||
/// outer function (`downloadFromUri`) was extracted — the executor and
|
||||
/// its inner callbacks were silently swallowed, hiding the inner gated
|
||||
/// http.get sink from classification. Motivated by CVE-2025-64430.
|
||||
#[test]
|
||||
fn cve_2025_64430_promise_executor_extracted_as_body() {
|
||||
let src = br#"
|
||||
const downloadFromUri = (uri) => {
|
||||
return new Promise((res, rej) => {
|
||||
http.get(uri, response => { response.on('data', () => {}); }).on('error', e => rej(e));
|
||||
});
|
||||
};
|
||||
"#;
|
||||
let lang = tree_sitter::Language::from(tree_sitter_javascript::LANGUAGE);
|
||||
let file_cfg = parse_lang(src, "javascript", lang);
|
||||
let names: Vec<Option<String>> = file_cfg
|
||||
.bodies
|
||||
.iter()
|
||||
.map(|b| b.meta.name.clone())
|
||||
.collect();
|
||||
assert!(
|
||||
file_cfg.bodies.len() >= 3,
|
||||
"expected at least 3 bodies (top-level + downloadFromUri + Promise executor), \
|
||||
got {}: {:?}",
|
||||
file_cfg.bodies.len(),
|
||||
names
|
||||
);
|
||||
}
|
||||
|
||||
/// End-to-end: cross-function flow through a Promise-wrapping helper.
|
||||
/// Caller passes a labeled-source value (`req.body.uri`) to a wrapper
|
||||
/// whose body is `return new Promise((res, rej) => http.get(uri))`.
|
||||
/// The wrapper's SSA summary's `param_to_sink` must include SSRF (via
|
||||
/// the closure-capture summary-augmentation pass in
|
||||
/// `lower_all_functions_from_bodies`), so the caller's
|
||||
/// `wrapper(req.body.uri)` call resolves to a SSRF sink.
|
||||
/// Motivated by CVE-2025-64430.
|
||||
#[test]
|
||||
fn cve_2025_64430_promise_wrapper_via_summary_param_to_sink() {
|
||||
let src = br#"
|
||||
const downloadFromUri = uri => {
|
||||
return new Promise((res, rej) => {
|
||||
http.get(uri, response => { response.on('data', () => {}); }).on('error', e => rej(e));
|
||||
});
|
||||
};
|
||||
const handler = (req) => {
|
||||
downloadFromUri(req.body.uri);
|
||||
};
|
||||
"#;
|
||||
let lang = tree_sitter::Language::from(tree_sitter_javascript::LANGUAGE);
|
||||
let file_cfg = parse_lang(src, "javascript", lang);
|
||||
let summaries = &file_cfg.summaries;
|
||||
let findings = analyse_file(
|
||||
&file_cfg,
|
||||
summaries,
|
||||
None,
|
||||
Lang::JavaScript,
|
||||
"test.js",
|
||||
&[],
|
||||
None,
|
||||
);
|
||||
assert!(
|
||||
!findings.is_empty(),
|
||||
"expected SSRF flow finding via Promise-wrapper summary; got 0",
|
||||
);
|
||||
}
|
||||
|
||||
/// End-to-end smoke check: when a JS/TS handler param is recognised as
|
||||
/// user-input-bearing (`is_js_ts_handler_param_name`), Promise-executor
|
||||
/// closure capture via lexical containment must propagate the seeded
|
||||
/// taint into the executor body so the inner gated http.get sink fires.
|
||||
/// Without the Kind::Return fix the executor was never extracted as a
|
||||
/// body and the sink was invisible to classification. Motivated by
|
||||
/// CVE-2025-64430.
|
||||
#[test]
|
||||
fn cve_2025_64430_promise_executor_sink_via_lexical_containment() {
|
||||
let src = br#"
|
||||
const f = (input) => {
|
||||
return new Promise((res, rej) => {
|
||||
http.get(input);
|
||||
});
|
||||
};
|
||||
"#;
|
||||
let lang = tree_sitter::Language::from(tree_sitter_javascript::LANGUAGE);
|
||||
let file_cfg = parse_lang(src, "javascript", lang);
|
||||
let summaries = &file_cfg.summaries;
|
||||
let findings = analyse_file(
|
||||
&file_cfg,
|
||||
summaries,
|
||||
None,
|
||||
Lang::JavaScript,
|
||||
"test.js",
|
||||
&[],
|
||||
None,
|
||||
);
|
||||
assert!(
|
||||
!findings.is_empty(),
|
||||
"expected SSRF Sink finding in Promise executor capturing `input`; got 0",
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression: `wrapper(req.body.uri)` where wrapper passes its first
|
||||
/// param to a gated SSRF sink must fire. The CFG's first_member_label
|
||||
/// rebinds info.call.callee to `"req.body.uri"` (so the source label
|
||||
/// applies) and preserves the actual function name in `outer_callee`.
|
||||
/// resolve_sink_info has to consult outer_callee when the inner callee
|
||||
/// has no sink so the wrapper's `param_to_sink: [(0, SSRF)]` summary
|
||||
/// fires. Motivated by CVE-2025-64430.
|
||||
#[test]
|
||||
fn cve_2025_64430_wrapper_with_member_source_arg_fires() {
|
||||
let src = br#"
|
||||
const helper = (uri) => {
|
||||
http.get(uri);
|
||||
};
|
||||
|
||||
const handler = (req) => {
|
||||
helper(req.body.uri);
|
||||
};
|
||||
"#;
|
||||
let lang = tree_sitter::Language::from(tree_sitter_javascript::LANGUAGE);
|
||||
let file_cfg = parse_lang(src, "javascript", lang);
|
||||
let summaries = &file_cfg.summaries;
|
||||
let findings = analyse_file(
|
||||
&file_cfg,
|
||||
summaries,
|
||||
None,
|
||||
Lang::JavaScript,
|
||||
"test.js",
|
||||
&[],
|
||||
None,
|
||||
);
|
||||
assert!(
|
||||
!findings.is_empty(),
|
||||
"expected at least one SSRF flow finding through wrapper; got 0",
|
||||
);
|
||||
}
|
||||
|
||||
/// Two-hop transitive cross-function summary propagation. The chain is
|
||||
/// `handler(req) -> helper(req.body) -> downloadFromUri(x.url) ->
|
||||
/// Promise(http.get(uri))`.
|
||||
///
|
||||
/// The augment pass populates `downloadFromUri.summary.param_to_sink:
|
||||
/// [(0, SSRF)]` (single-hop closure-capture lift). For the handler's
|
||||
/// `helper(req.body)` call to fire, `helper.summary.param_to_sink` must
|
||||
/// also contain `[(0, SSRF)]` — but that requires `helper`'s probe to
|
||||
/// see `downloadFromUri`'s augmented summary at resolution time.
|
||||
///
|
||||
/// Because the probe currently runs with `ssa_summaries=None`,
|
||||
/// `helper.summary.param_to_sink` stays empty and the handler call site
|
||||
/// reports nothing. A second extraction pass that re-runs probes with
|
||||
/// the augmented summaries map plumbed through closes the gap. Mirrors
|
||||
/// the upstream Parse Server CVE chain (`addFileDataIfNeeded` →
|
||||
/// `downloadFileFromURI` → executor). Motivated by CVE-2025-64430.
|
||||
#[test]
|
||||
fn cve_2025_64430_two_hop_transitive_summary_propagation() {
|
||||
let src = br#"
|
||||
const downloadFromUri = uri => {
|
||||
return new Promise((res, rej) => {
|
||||
http.get(uri, response => { response.on('data', () => {}); }).on('error', e => rej(e));
|
||||
});
|
||||
};
|
||||
const helper = file => {
|
||||
downloadFromUri(file._source.uri);
|
||||
};
|
||||
const handler = (req) => {
|
||||
helper(req.body);
|
||||
};
|
||||
"#;
|
||||
let lang = tree_sitter::Language::from(tree_sitter_javascript::LANGUAGE);
|
||||
let file_cfg = parse_lang(src, "javascript", lang);
|
||||
let summaries = &file_cfg.summaries;
|
||||
let findings = analyse_file(
|
||||
&file_cfg,
|
||||
summaries,
|
||||
None,
|
||||
Lang::JavaScript,
|
||||
"test.js",
|
||||
&[],
|
||||
None,
|
||||
);
|
||||
assert!(
|
||||
!findings.is_empty(),
|
||||
"expected SSRF flow finding via two-hop transitive summary propagation; got 0",
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression for the multi-line method-chain form
|
||||
/// `http\n .get(uri, ...)\n .on('error', ...)`. Tree-sitter parses
|
||||
/// this with whitespace embedded in the inner member-expression's
|
||||
/// source text (`"http\n .get"`), so the chained-call inner-gate
|
||||
/// rebinding's classification lookup missed the gated `http.get` sink.
|
||||
/// `find_chained_inner_call` now strips whitespace from the inner
|
||||
/// callee text before classification. Without this, the upstream
|
||||
/// Parse Server fixture (CVE-2025-64430 vulnerable.js) does not fire
|
||||
/// even after the transitive summary propagation fix.
|
||||
#[test]
|
||||
fn cve_2025_64430_multiline_chained_get_classifies_inner_sink() {
|
||||
let src = br#"
|
||||
const downloadFromUri = uri => {
|
||||
return new Promise((res, rej) => {
|
||||
http
|
||||
.get(uri, response => { response.on('data', () => {}); })
|
||||
.on('error', e => rej(e));
|
||||
});
|
||||
};
|
||||
const helper = file => {
|
||||
downloadFromUri(file._source.uri);
|
||||
};
|
||||
const handler = (req) => {
|
||||
helper(req.body);
|
||||
};
|
||||
"#;
|
||||
let lang = tree_sitter::Language::from(tree_sitter_javascript::LANGUAGE);
|
||||
let file_cfg = parse_lang(src, "javascript", lang);
|
||||
let summaries = &file_cfg.summaries;
|
||||
let findings = analyse_file(
|
||||
&file_cfg,
|
||||
summaries,
|
||||
None,
|
||||
Lang::JavaScript,
|
||||
"test.js",
|
||||
&[],
|
||||
None,
|
||||
);
|
||||
assert!(
|
||||
!findings.is_empty(),
|
||||
"expected SSRF flow finding through multi-line chained http.get; got 0",
|
||||
);
|
||||
}
|
||||
|
||||
/// Three-hop transitive propagation: handler -> middle -> helper ->
|
||||
/// downloadFromUri (Promise wrapper) -> http.get. The second extraction
|
||||
/// pass must lift `downloadFromUri.summary.param_to_sink` (single-hop
|
||||
/// from augment) onto `helper.summary.param_to_sink`, then onto
|
||||
/// `middle.summary.param_to_sink`, then handler's call site picks it up.
|
||||
///
|
||||
/// Today the second-pass runs only once (no fixed-point), so depth-3+
|
||||
/// is expected to NOT fire — guards against accidental fixed-point
|
||||
/// regression that would mask an over-eager rewrite. Marked
|
||||
/// `#[ignore]` so it documents the depth limit without breaking CI.
|
||||
/// Motivated by CVE-2025-64430 corner case; remove the `#[ignore]` and
|
||||
/// any guarding `assert!` polarity if a fixed-point is added later.
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn cve_2025_64430_three_hop_transitive_documents_depth_limit() {
|
||||
let src = br#"
|
||||
const downloadFromUri = uri => {
|
||||
return new Promise((res, rej) => {
|
||||
http.get(uri, response => { response.on('data', () => {}); }).on('error', e => rej(e));
|
||||
});
|
||||
};
|
||||
const helper = file => {
|
||||
downloadFromUri(file._source.uri);
|
||||
};
|
||||
const middle = data => {
|
||||
helper(data);
|
||||
};
|
||||
const handler = (req) => {
|
||||
middle(req.body);
|
||||
};
|
||||
"#;
|
||||
let lang = tree_sitter::Language::from(tree_sitter_javascript::LANGUAGE);
|
||||
let file_cfg = parse_lang(src, "javascript", lang);
|
||||
let summaries = &file_cfg.summaries;
|
||||
let _findings = analyse_file(
|
||||
&file_cfg,
|
||||
summaries,
|
||||
None,
|
||||
Lang::JavaScript,
|
||||
"test.js",
|
||||
&[],
|
||||
None,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue