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:
Eli Peter 2026-04-29 00:58:38 -04:00 committed by GitHub
parent 79c29b394d
commit 82f18184b1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
348 changed files with 48731 additions and 2925 deletions

View file

@ -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);

View file

@ -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)]

View file

@ -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

View file

@ -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));
}
}

View file

@ -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

View file

@ -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,
);
}