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

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