mirror of
https://github.com/elicpeter/nyx.git
synced 2026-06-15 20:05:13 +02:00
Critical bug fixes and recall improvements (#68)
This commit is contained in:
parent
7d0e7320e2
commit
55247b7fcd
352 changed files with 60069 additions and 900 deletions
|
|
@ -753,6 +753,7 @@ mod tests {
|
|||
field_interner: crate::ssa::ir::FieldInterner::default(),
|
||||
field_writes: std::collections::HashMap::new(),
|
||||
synthetic_externals: std::collections::HashSet::new(),
|
||||
slot_scoped_assigns: std::collections::HashSet::new(),
|
||||
};
|
||||
|
||||
(ssa, cfg)
|
||||
|
|
@ -843,6 +844,7 @@ mod tests {
|
|||
field_interner: crate::ssa::ir::FieldInterner::default(),
|
||||
field_writes: std::collections::HashMap::new(),
|
||||
synthetic_externals: std::collections::HashSet::new(),
|
||||
slot_scoped_assigns: std::collections::HashSet::new(),
|
||||
};
|
||||
let demand = DemandState::new(Cap::all());
|
||||
let (step, next) = backward_transfer(&ssa, SsaValue(0), &demand);
|
||||
|
|
@ -876,6 +878,7 @@ mod tests {
|
|||
field_interner: crate::ssa::ir::FieldInterner::default(),
|
||||
field_writes: std::collections::HashMap::new(),
|
||||
synthetic_externals: std::collections::HashSet::new(),
|
||||
slot_scoped_assigns: std::collections::HashSet::new(),
|
||||
};
|
||||
let demand = DemandState::new(Cap::all());
|
||||
let (step, _next) = backward_transfer(&ssa, SsaValue(0), &demand);
|
||||
|
|
@ -964,6 +967,7 @@ mod tests {
|
|||
field_interner: crate::ssa::ir::FieldInterner::default(),
|
||||
field_writes: std::collections::HashMap::new(),
|
||||
synthetic_externals: std::collections::HashSet::new(),
|
||||
slot_scoped_assigns: std::collections::HashSet::new(),
|
||||
};
|
||||
|
||||
let demand = DemandState::new(Cap::all());
|
||||
|
|
@ -1053,6 +1057,7 @@ mod tests {
|
|||
field_interner: crate::ssa::ir::FieldInterner::default(),
|
||||
field_writes: std::collections::HashMap::new(),
|
||||
synthetic_externals: std::collections::HashSet::new(),
|
||||
slot_scoped_assigns: std::collections::HashSet::new(),
|
||||
};
|
||||
|
||||
let ctx = BackwardsCtx::new(&ssa, &cfg, Lang::JavaScript);
|
||||
|
|
|
|||
472
src/taint/mod.rs
472
src/taint/mod.rs
|
|
@ -403,6 +403,86 @@ fn compute_module_aliases_for_summary(
|
|||
crate::ssa::const_prop::collect_module_aliases(ssa, &cp.values)
|
||||
}
|
||||
|
||||
/// Build a per-file cross-package import lookup for Phase 09 cross-file IPA.
|
||||
///
|
||||
/// For each [`crate::resolve::ImportBinding`] whose resolver verdict
|
||||
/// produced a concrete `(resolved_file, exported_name)` pair, builds the
|
||||
/// canonical [`FuncKey`] of the imported function in its own file's
|
||||
/// scan-root-relative namespace and stores it under the caller-file's
|
||||
/// local binding name.
|
||||
///
|
||||
/// Returns an empty map when the file has no resolved imports (non-JS/TS
|
||||
/// files, scans without a `ModuleGraph`, side-effect-only imports, or
|
||||
/// builtin/unresolved specifiers). The caller passes `None` to
|
||||
/// `SsaTaintTransfer::cross_package_imports` in that case.
|
||||
///
|
||||
/// `module_graph` aligns the target [`FuncKey::namespace`] with the
|
||||
/// package-prefixed form that `FuncSummary::func_key_with_resolver`
|
||||
/// produces on the cross-file storage side: when the resolved file lies
|
||||
/// inside a discovered package the namespace becomes
|
||||
/// `"@scope/name::src/file.ts"`, otherwise it falls back to plain
|
||||
/// `normalize_namespace`. Step 0.7 of `resolve_callee_full` looks up
|
||||
/// `(lang, namespace, name)` against `GlobalSummaries::ssa_by_key`
|
||||
/// where the SSA-side keys are now produced via the same
|
||||
/// `namespace_with_package` shape (callers in `crate::ast::ParsedFile`
|
||||
/// pre-compute the package-prefixed namespace before invoking
|
||||
/// `lower_all_functions_from_bodies`), so the two sides agree even
|
||||
/// when two packages share a project-relative file path.
|
||||
///
|
||||
/// `module_graph = None` (single-package scans, non-JS/TS files, unit
|
||||
/// tests, indexed-mode SQLite fallback) collapses to the historical
|
||||
/// `normalize_namespace` behaviour, keeping the migration strictly
|
||||
/// additive for any consumer that does not opt in.
|
||||
///
|
||||
/// The constructed key intentionally leaves `container`, `arity`,
|
||||
/// `disambig`, and `kind` at their defaults — the resolver verdict only
|
||||
/// fixes the `(lang, namespace, name)` triple, and step 0.7 of
|
||||
/// `resolve_callee_full` matches against `GlobalSummaries::ssa_by_key`
|
||||
/// using only those three fields plus an arity hint when available.
|
||||
pub fn build_cross_package_func_keys(
|
||||
resolved_imports: &[crate::resolve::ImportBinding],
|
||||
scan_root: Option<&str>,
|
||||
module_graph: Option<&crate::resolve::ModuleGraph>,
|
||||
caller_lang: Lang,
|
||||
) -> HashMap<String, FuncKey> {
|
||||
let mut out: HashMap<String, FuncKey> = HashMap::new();
|
||||
for binding in resolved_imports {
|
||||
let Some(ref resolved_file) = binding.resolved_file else {
|
||||
continue;
|
||||
};
|
||||
let Some(ref exported_name) = binding.exported_name else {
|
||||
continue;
|
||||
};
|
||||
if exported_name.is_empty()
|
||||
|| exported_name == "*"
|
||||
|| exported_name == "default"
|
||||
|| binding.local_name.is_empty()
|
||||
{
|
||||
// Side-effect / namespace / default imports do not map to a
|
||||
// single named export; step 0.7 needs a concrete leaf name.
|
||||
continue;
|
||||
}
|
||||
let target_lang = resolved_file
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.and_then(Lang::from_extension)
|
||||
.unwrap_or(caller_lang);
|
||||
let abs = resolved_file.to_string_lossy();
|
||||
let namespace = crate::symbol::namespace_with_package(&abs, scan_root, module_graph);
|
||||
let key = FuncKey {
|
||||
lang: target_lang,
|
||||
namespace,
|
||||
container: String::new(),
|
||||
name: exported_name.clone(),
|
||||
arity: None,
|
||||
disambig: None,
|
||||
kind: FuncKind::Function,
|
||||
};
|
||||
out.insert(binding.local_name.clone(), key);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Run taint analysis on all bodies in a file.
|
||||
///
|
||||
/// Uses a unified multi-body analysis for all languages:
|
||||
|
|
@ -432,25 +512,32 @@ pub fn analyse_file(
|
|||
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,
|
||||
caller_namespace,
|
||||
local_summaries,
|
||||
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,
|
||||
)
|
||||
crate::ssa::type_facts::with_file_imports(Some(&file_cfg.local_imports), || {
|
||||
crate::cfg::safe_fields::with_safe_lookup_fields(Some(&file_cfg.safe_lookup_fields), || {
|
||||
let (ssa_summaries, callee_bodies) = lower_all_functions_from_bodies(
|
||||
file_cfg,
|
||||
caller_lang,
|
||||
caller_namespace,
|
||||
local_summaries,
|
||||
global_summaries,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
analyse_file_with_lowered(
|
||||
file_cfg,
|
||||
local_summaries,
|
||||
global_summaries,
|
||||
caller_lang,
|
||||
caller_namespace,
|
||||
interop_edges,
|
||||
extra_labels,
|
||||
&ssa_summaries,
|
||||
&callee_bodies,
|
||||
None,
|
||||
)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Same as [`analyse_file`] but takes pre-lowered SSA summaries + callee
|
||||
|
|
@ -459,6 +546,10 @@ pub fn analyse_file(
|
|||
/// 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.
|
||||
///
|
||||
/// `cross_package_imports` is the optional Phase-09 lookup map built via
|
||||
/// [`build_cross_package_func_keys`]. `None` (the public-API default)
|
||||
/// disables cross-package step 0.7 in `resolve_callee_full`.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn analyse_file_with_lowered(
|
||||
file_cfg: &FileCfg,
|
||||
|
|
@ -470,9 +561,49 @@ pub(crate) fn analyse_file_with_lowered(
|
|||
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>,
|
||||
cross_package_imports: Option<&std::collections::HashMap<String, FuncKey>>,
|
||||
) -> Vec<Finding> {
|
||||
let _span = tracing::debug_span!("taint_analyse_file").entered();
|
||||
|
||||
// Publish the per-file local-import view so the ORM TypeKind gate
|
||||
// inside [`crate::ssa::type_facts::constructor_type`] can read it
|
||||
// during downstream `optimize_ssa_with_param_types` passes. The
|
||||
// outer `analyse_file` already wraps this for its own
|
||||
// `lower_all_functions_from_bodies` pre-pass; wrapping here too
|
||||
// keeps direct callers (e.g. [`crate::ast::analyse_file_fused`])
|
||||
// covered. Idempotent under nesting — the inner guard restores
|
||||
// the outer value on drop.
|
||||
crate::ssa::type_facts::with_file_imports(Some(&file_cfg.local_imports), || {
|
||||
crate::cfg::safe_fields::with_safe_lookup_fields(Some(&file_cfg.safe_lookup_fields), || {
|
||||
analyse_file_with_lowered_inner(
|
||||
file_cfg,
|
||||
local_summaries,
|
||||
global_summaries,
|
||||
caller_lang,
|
||||
caller_namespace,
|
||||
interop_edges,
|
||||
extra_labels,
|
||||
ssa_summaries,
|
||||
callee_bodies,
|
||||
cross_package_imports,
|
||||
)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn analyse_file_with_lowered_inner(
|
||||
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>,
|
||||
cross_package_imports: Option<&std::collections::HashMap<String, FuncKey>>,
|
||||
) -> Vec<Finding> {
|
||||
// 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
|
||||
|
|
@ -551,6 +682,7 @@ pub(crate) fn analyse_file_with_lowered(
|
|||
max_iterations,
|
||||
import_bindings_ref,
|
||||
cross_file_bodies_ref,
|
||||
cross_package_imports,
|
||||
);
|
||||
|
||||
// 4. Deduplicate findings using a richer key that preserves distinct
|
||||
|
|
@ -797,6 +929,34 @@ fn inject_external_type_facts(
|
|||
}
|
||||
}
|
||||
|
||||
/// Apply entry-kind-derived overrides to a body's `param_types` vector.
|
||||
///
|
||||
/// Today only `EntryKind::AppRouteHandler` triggers an override: the first
|
||||
/// formal of a Next.js App Router handler always carries a Web `Request`,
|
||||
/// regardless of the user's TypeScript annotation. Returns `Some(vec)` when
|
||||
/// the override changes the vector, `None` otherwise. Folding the rule into
|
||||
/// one helper keeps the two consumers (`analyse_body_with_seed` and
|
||||
/// `lower_all_functions_from_bodies_inner`) in lockstep.
|
||||
fn entry_kind_param_type_override(
|
||||
entry_kind: Option<&crate::entry_points::EntryKind>,
|
||||
param_types: &[Option<crate::ssa::type_facts::TypeKind>],
|
||||
) -> Option<Vec<Option<crate::ssa::type_facts::TypeKind>>> {
|
||||
if matches!(
|
||||
entry_kind,
|
||||
Some(crate::entry_points::EntryKind::AppRouteHandler { .. })
|
||||
) {
|
||||
let mut pt = param_types.to_vec();
|
||||
if pt.is_empty() {
|
||||
pt.push(Some(crate::ssa::type_facts::TypeKind::Request));
|
||||
} else {
|
||||
pt[0] = Some(crate::ssa::type_facts::TypeKind::Request);
|
||||
}
|
||||
Some(pt)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Analyse a single body with an optional parent seed.
|
||||
///
|
||||
/// Shared logic extracted from `analyse_multi_body` to avoid deep nesting.
|
||||
|
|
@ -818,6 +978,7 @@ fn analyse_body_with_seed(
|
|||
import_bindings: Option<&crate::cfg::ImportBindings>,
|
||||
cross_file_bodies: Option<&std::collections::HashMap<FuncKey, ssa_transfer::CalleeSsaBody>>,
|
||||
parent_var_types: Option<&HashMap<String, crate::ssa::type_facts::TypeKind>>,
|
||||
cross_package_imports: Option<&std::collections::HashMap<String, FuncKey>>,
|
||||
) -> (
|
||||
Vec<Finding>,
|
||||
Option<HashMap<ssa_transfer::BindingKey, crate::taint::domain::VarTaint>>,
|
||||
|
|
@ -853,10 +1014,156 @@ fn analyse_body_with_seed(
|
|||
// so that `cmd -> Runtime.exec(cmd)` picks up `cmd` as a handler param.
|
||||
let is_java_lambda =
|
||||
lang == Lang::Java && body.meta.kind == crate::cfg::BodyKind::AnonymousFunction;
|
||||
// Java methods tagged with a Spring/JaxRs entry-point annotation need
|
||||
// scoped lowering so the formal parameters (`@RequestParam String name`,
|
||||
// `@PathParam Long id`, ...) materialise as `SsaOp::Param` ops that
|
||||
// the entry-point seeding pass paints as `Source(UserInput)`. Restricted
|
||||
// to Java because (a) JS/TS already use scoped lowering above, (b) Go
|
||||
// and Ruby handlers introduce request-OBJECT formals (`r *http.Request`,
|
||||
// implicit `params`) whose Cap::all() seeding triggers FPs at sinks
|
||||
// that take the bare object (e.g. `http.Redirect(w, r, safe, code)`
|
||||
// where `r` is the request, not the URL), and (c) Python free-name
|
||||
// captures (`request`, `b64decode`) bubble up as synthetic externals
|
||||
// and shift source attribution. Java methods don't have those
|
||||
// free-capture shapes (every reference is via explicit qualification),
|
||||
// so the precision-vs-recall trade lands on the precision side.
|
||||
let is_java_entry_method = lang == Lang::Java
|
||||
&& body.meta.kind == crate::cfg::BodyKind::NamedFunction
|
||||
&& body.meta.func_key.as_ref().is_some_and(|k| {
|
||||
let mut k = k.clone();
|
||||
k.namespace = namespace.to_string();
|
||||
ssa_summaries
|
||||
.and_then(|m| m.get(&k))
|
||||
.is_some_and(|s| s.entry_kind.is_some())
|
||||
});
|
||||
// Rust framework handlers (axum, actix-web, Rocket) need scoped
|
||||
// lowering so the typed-extractor formals (`Query<T>`, `Json<T>`,
|
||||
// `Form<T>`, `Path<T>`) materialise as `SsaOp::Param` ops that the
|
||||
// entry-point seeding pass paints as `Source(UserInput)`. The
|
||||
// per-formal seed decision is gated on a recovered `TypeKind` from
|
||||
// `BodyMeta.param_types`: extractor-wrapped formals get
|
||||
// `Some(TypeKind::Int|String|Bool|...)` (or a DTO type) via
|
||||
// `rust_type_to_kind`, while denylist wrappers (`State<T>`,
|
||||
// `Extension<T>`, `Pool<T>`, ...) and bare primitives stay `None`
|
||||
// and are skipped at seed time. This keeps DI handles
|
||||
// server-side without painting the database pool as adversary input.
|
||||
let is_rust_entry_method = lang == Lang::Rust
|
||||
&& body.meta.kind == crate::cfg::BodyKind::NamedFunction
|
||||
&& body.meta.func_key.as_ref().is_some_and(|k| {
|
||||
let mut k = k.clone();
|
||||
k.namespace = namespace.to_string();
|
||||
ssa_summaries.and_then(|m| m.get(&k)).is_some_and(|s| {
|
||||
matches!(
|
||||
s.entry_kind,
|
||||
Some(crate::entry_points::EntryKind::AxumHandler)
|
||||
| Some(crate::entry_points::EntryKind::ActixHandler)
|
||||
| Some(crate::entry_points::EntryKind::RocketRoute)
|
||||
)
|
||||
})
|
||||
});
|
||||
// Python Flask handlers need scoped lowering so the route-bound formal
|
||||
// parameters (`@app.route("/users/<name>")` + `def view(name):`)
|
||||
// materialise as `SsaOp::Param` ops the entry-point seeding pass paints
|
||||
// as `Source(UserInput)`. The per-formal seed decision is gated against
|
||||
// `BodyMeta.param_route_capture`, so only formals whose names appear as
|
||||
// path captures in the routing decorator are painted; implicit globals
|
||||
// (`request`, `g`, `session`) and DI-injected formals stay un-seeded.
|
||||
// Restricted to Flask (`FlaskRoute`) here because FastAPI / Django
|
||||
// free-name capture shapes (`request`, `b64decode`) bubble up as
|
||||
// synthetic externals under scoped lowering and shift source
|
||||
// attribution, while Flask handlers have all formals = path captures
|
||||
// (precision lands cleanly).
|
||||
let is_python_flask_route = lang == Lang::Python
|
||||
&& body.meta.kind == crate::cfg::BodyKind::NamedFunction
|
||||
&& body
|
||||
.meta
|
||||
.param_route_capture
|
||||
.iter()
|
||||
.any(|captured| *captured)
|
||||
&& body.meta.func_key.as_ref().is_some_and(|k| {
|
||||
let mut k = k.clone();
|
||||
k.namespace = namespace.to_string();
|
||||
ssa_summaries.and_then(|m| m.get(&k)).is_some_and(|s| {
|
||||
matches!(
|
||||
s.entry_kind,
|
||||
Some(crate::entry_points::EntryKind::FlaskRoute { .. })
|
||||
)
|
||||
})
|
||||
});
|
||||
// Ruby Sinatra route handlers need scoped lowering so the block
|
||||
// parameters (`get "/u/:name" do |name| ... end`) materialise as
|
||||
// `SsaOp::Param` ops the entry-point seeding pass paints as
|
||||
// `Source(UserInput)`. Sinatra body bodies are anonymous (the
|
||||
// `do_block` AST node has no name field), so `BodyKind` is
|
||||
// `AnonymousFunction`; the gate accepts both anonymous and named.
|
||||
// Per-formal seed decision is gated against
|
||||
// `BodyMeta.param_route_capture`, so only block formals whose
|
||||
// names appear as `:name` segments in the routing path are
|
||||
// painted. Block formals not in the capture set fall back to
|
||||
// existing label rules.
|
||||
let is_ruby_sinatra_route = lang == Lang::Ruby
|
||||
&& matches!(
|
||||
body.meta.kind,
|
||||
crate::cfg::BodyKind::NamedFunction | crate::cfg::BodyKind::AnonymousFunction
|
||||
)
|
||||
&& body
|
||||
.meta
|
||||
.param_route_capture
|
||||
.iter()
|
||||
.any(|captured| *captured)
|
||||
&& body.meta.func_key.as_ref().is_some_and(|k| {
|
||||
let mut k = k.clone();
|
||||
k.namespace = namespace.to_string();
|
||||
ssa_summaries.and_then(|m| m.get(&k)).is_some_and(|s| {
|
||||
matches!(
|
||||
s.entry_kind,
|
||||
Some(crate::entry_points::EntryKind::SinatraRoute { .. })
|
||||
)
|
||||
})
|
||||
});
|
||||
// Python FastAPI / Starlette handlers need scoped lowering so the
|
||||
// route-bound and typed-extractor formals materialise as `SsaOp::Param`
|
||||
// ops that the entry-point seeding pass paints as `Source(UserInput)`.
|
||||
// The per-formal decision in `ssa_transfer` consults BOTH
|
||||
// `BodyMeta.param_route_capture` (for `{name}` brace-segment captures)
|
||||
// and `type_facts.get_type(value)` (for `Annotated[T, Path()/Query()/Body()
|
||||
// /Header()/Cookie()/Form()/File()]` typed extractors). Formals without
|
||||
// either signal — `db: Session = Depends(get_db)`, `request: Request`,
|
||||
// bare `session` — stay un-seeded, matching the Hard Rule 3 policy that
|
||||
// unannotated formals are not adversary input.
|
||||
//
|
||||
// Gated on "at least one formal qualifies" to mirror the Flask gate:
|
||||
// a handler with zero path captures and zero typed extractors gets the
|
||||
// existing label-rule treatment (free-name captures of `request`,
|
||||
// `b64decode`, etc. bubble up as synthetic externals without scoped
|
||||
// lowering shifting attribution).
|
||||
let is_python_fastapi_route = lang == Lang::Python
|
||||
&& body.meta.kind == crate::cfg::BodyKind::NamedFunction
|
||||
&& (body
|
||||
.meta
|
||||
.param_route_capture
|
||||
.iter()
|
||||
.any(|captured| *captured)
|
||||
|| body.meta.param_types.iter().any(|t| t.is_some()))
|
||||
&& body.meta.func_key.as_ref().is_some_and(|k| {
|
||||
let mut k = k.clone();
|
||||
k.namespace = namespace.to_string();
|
||||
ssa_summaries.and_then(|m| m.get(&k)).is_some_and(|s| {
|
||||
matches!(
|
||||
s.entry_kind,
|
||||
Some(crate::entry_points::EntryKind::FastApiRoute { .. })
|
||||
)
|
||||
})
|
||||
});
|
||||
let use_scoped_lowering = !is_toplevel
|
||||
&& (matches!(lang, Lang::JavaScript | Lang::TypeScript)
|
||||
|| has_nonempty_seed
|
||||
|| is_java_lambda);
|
||||
|| is_java_lambda
|
||||
|| is_java_entry_method
|
||||
|| is_rust_entry_method
|
||||
|| is_python_flask_route
|
||||
|| is_python_fastapi_route
|
||||
|| is_ruby_sinatra_route);
|
||||
let ssa_result = if use_scoped_lowering {
|
||||
let func_name = body.meta.name.clone().unwrap_or_else(|| {
|
||||
body.meta
|
||||
|
|
@ -878,11 +1185,28 @@ fn analyse_body_with_seed(
|
|||
|
||||
match ssa_result {
|
||||
Ok(mut ssa_body) => {
|
||||
// App Router handlers carry a Web `Request` as their first
|
||||
// formal. Override `param_types[0]` so the type-fact pass tags
|
||||
// the formal as `TypeKind::Request` and receiver-method reads
|
||||
// (`req.json()`, ...) rewrite to `Request.<method>` for
|
||||
// type-qualified label resolution.
|
||||
let body_entry_kind = body.meta.func_key.as_ref().and_then(|k| {
|
||||
let mut k = k.clone();
|
||||
k.namespace = namespace.to_string();
|
||||
ssa_summaries
|
||||
.and_then(|m| m.get(&k))
|
||||
.and_then(|s| s.entry_kind.clone())
|
||||
});
|
||||
let overridden_param_types =
|
||||
entry_kind_param_type_override(body_entry_kind.as_ref(), &body.meta.param_types);
|
||||
let param_types_ref = overridden_param_types
|
||||
.as_deref()
|
||||
.unwrap_or(body.meta.param_types.as_slice());
|
||||
let mut opt = crate::ssa::optimize_ssa_with_param_types(
|
||||
&mut ssa_body,
|
||||
cfg,
|
||||
Some(lang),
|
||||
&body.meta.param_types,
|
||||
param_types_ref,
|
||||
);
|
||||
// Forward parent-body type facts onto closure-captured Param ops
|
||||
// before any consumer reads `opt.type_facts`. This is the lever
|
||||
|
|
@ -965,6 +1289,16 @@ fn analyse_body_with_seed(
|
|||
&& body.meta.kind == crate::cfg::BodyKind::AnonymousFunction),
|
||||
cross_file_bodies,
|
||||
pointer_facts: pointer_facts.as_ref(),
|
||||
cross_package_imports,
|
||||
// Phase 10 — Next.js entry-point seeding (looked up
|
||||
// above when overriding `param_types`).
|
||||
entry_kind: body_entry_kind,
|
||||
param_route_capture: if body.meta.param_route_capture.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(body.meta.param_route_capture.as_slice())
|
||||
},
|
||||
recording_summary: false,
|
||||
};
|
||||
let (events, block_states) =
|
||||
ssa_transfer::run_ssa_taint_full(&ssa_body, cfg, &transfer);
|
||||
|
|
@ -1098,6 +1432,7 @@ fn analyse_multi_body(
|
|||
max_iterations: usize,
|
||||
import_bindings: Option<&crate::cfg::ImportBindings>,
|
||||
cross_file_bodies: Option<&std::collections::HashMap<FuncKey, ssa_transfer::CalleeSsaBody>>,
|
||||
cross_package_imports: Option<&std::collections::HashMap<String, FuncKey>>,
|
||||
) -> Vec<Finding> {
|
||||
let order = containment_order(&file_cfg.bodies);
|
||||
let mut all_findings: Vec<Finding> = Vec::new();
|
||||
|
|
@ -1144,6 +1479,7 @@ fn analyse_multi_body(
|
|||
import_bindings,
|
||||
cross_file_bodies,
|
||||
parent_var_types,
|
||||
cross_package_imports,
|
||||
);
|
||||
tracing::debug!(
|
||||
body_id = body.meta.id.0,
|
||||
|
|
@ -1340,6 +1676,7 @@ fn analyse_multi_body(
|
|||
import_bindings,
|
||||
cross_file_bodies,
|
||||
parent_var_types,
|
||||
cross_package_imports,
|
||||
);
|
||||
// Phase-B: replace (not append) this body's findings
|
||||
// in the cache. Previous rounds' findings for this
|
||||
|
|
@ -1688,6 +2025,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.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn lower_all_functions_from_bodies(
|
||||
file_cfg: &FileCfg,
|
||||
lang: Lang,
|
||||
|
|
@ -1695,6 +2033,38 @@ pub(crate) fn lower_all_functions_from_bodies(
|
|||
local_summaries: &FuncSummaries,
|
||||
global_summaries: Option<&GlobalSummaries>,
|
||||
locator: Option<&crate::summary::SinkSiteLocator<'_>>,
|
||||
scan_root: Option<&str>,
|
||||
module_graph: Option<&crate::resolve::ModuleGraph>,
|
||||
) -> (
|
||||
std::collections::HashMap<FuncKey, crate::summary::ssa_summary::SsaFuncSummary>,
|
||||
std::collections::HashMap<FuncKey, ssa_transfer::CalleeSsaBody>,
|
||||
) {
|
||||
crate::ssa::type_facts::with_file_imports(Some(&file_cfg.local_imports), || {
|
||||
crate::cfg::safe_fields::with_safe_lookup_fields(Some(&file_cfg.safe_lookup_fields), || {
|
||||
lower_all_functions_from_bodies_inner(
|
||||
file_cfg,
|
||||
lang,
|
||||
namespace,
|
||||
local_summaries,
|
||||
global_summaries,
|
||||
locator,
|
||||
scan_root,
|
||||
module_graph,
|
||||
)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn lower_all_functions_from_bodies_inner(
|
||||
file_cfg: &FileCfg,
|
||||
lang: Lang,
|
||||
namespace: &str,
|
||||
local_summaries: &FuncSummaries,
|
||||
global_summaries: Option<&GlobalSummaries>,
|
||||
locator: Option<&crate::summary::SinkSiteLocator<'_>>,
|
||||
scan_root: Option<&str>,
|
||||
module_graph: Option<&crate::resolve::ModuleGraph>,
|
||||
) -> (
|
||||
std::collections::HashMap<FuncKey, crate::summary::ssa_summary::SsaFuncSummary>,
|
||||
std::collections::HashMap<FuncKey, ssa_transfer::CalleeSsaBody>,
|
||||
|
|
@ -1702,6 +2072,23 @@ pub(crate) fn lower_all_functions_from_bodies(
|
|||
let mut summaries = std::collections::HashMap::new();
|
||||
let mut bodies = std::collections::HashMap::new();
|
||||
|
||||
// Build the file's cross-package import map once and share it
|
||||
// across every body produced from this file. The map mirrors what
|
||||
// `analyse_file_with_lowered` builds at pass-2 entry, but storing
|
||||
// it on each `CalleeSsaBody` lets the inline-analysis frame inside
|
||||
// another file resolve the callee's local import names against
|
||||
// the callee's own package boundary (Phase 09 step 0.7) instead of
|
||||
// skipping the lookup entirely.
|
||||
let cross_package_imports_arc = {
|
||||
let map = build_cross_package_func_keys(
|
||||
&file_cfg.resolved_imports,
|
||||
scan_root,
|
||||
module_graph,
|
||||
lang,
|
||||
);
|
||||
std::sync::Arc::new(map)
|
||||
};
|
||||
|
||||
for body in file_cfg.function_bodies() {
|
||||
let _t_misc = std::time::Instant::now();
|
||||
let func_name = body.meta.name.clone().unwrap_or_else(|| {
|
||||
|
|
@ -1797,6 +2184,15 @@ pub(crate) fn lower_all_functions_from_bodies(
|
|||
param_types_ref,
|
||||
);
|
||||
|
||||
// Phase 10 — annotate entry-point summaries. The pass-2
|
||||
// taint engine reads `entry_kind` to seed the function's
|
||||
// formals as `TaintOrigin::Source` at SSA entry, mirroring
|
||||
// an HTTP handler's adversary-controlled inputs. Always
|
||||
// recorded even on empty summaries so caller-side resolution
|
||||
// sees the entry classification through cross-file lookups.
|
||||
let mut summary = summary;
|
||||
summary.entry_kind = file_cfg.entry_kinds.get(&body.meta.span).cloned();
|
||||
|
||||
// Always insert the summary, even when all fields are empty/default.
|
||||
// An empty summary tells resolve_callee "this function exists and has
|
||||
// no taint effects", preventing fallthrough to the less precise old
|
||||
|
|
@ -1804,18 +2200,34 @@ pub(crate) fn lower_all_functions_from_bodies(
|
|||
// For zero-param functions we only insert when the summary carries
|
||||
// the fresh-container signal (the only observable effect worth
|
||||
// persisting for a parameter-less body).
|
||||
if param_count > 0 || summary.points_to.returns_fresh_alloc {
|
||||
//
|
||||
// An entry-kind tag also keeps the summary in the map even
|
||||
// for zero-param entry points so cross-file resolvers see it.
|
||||
if param_count > 0
|
||||
|| summary.points_to.returns_fresh_alloc
|
||||
|| summary.entry_kind.is_some()
|
||||
{
|
||||
summaries.insert(key.clone(), summary);
|
||||
}
|
||||
perf_lower_record(1, _t_extract.elapsed().as_micros());
|
||||
}
|
||||
|
||||
let _t_opt = std::time::Instant::now();
|
||||
// Override `param_types[0]` for entry-kind-tagged formals (e.g. App
|
||||
// Router handlers receive a Web `Request`). Other entry kinds keep
|
||||
// the ambient param-type vector unchanged. See
|
||||
// `entry_kind_param_type_override` for the full rule set.
|
||||
let entry_kind_for_body = file_cfg.entry_kinds.get(&body.meta.span);
|
||||
let overridden_param_types =
|
||||
entry_kind_param_type_override(entry_kind_for_body, &body.meta.param_types);
|
||||
let param_types_ref = overridden_param_types
|
||||
.as_deref()
|
||||
.unwrap_or(body.meta.param_types.as_slice());
|
||||
let opt = crate::ssa::optimize_ssa_with_param_types(
|
||||
&mut func_ssa,
|
||||
&body.graph,
|
||||
Some(lang),
|
||||
&body.meta.param_types,
|
||||
param_types_ref,
|
||||
);
|
||||
perf_lower_record(2, _t_opt.elapsed().as_micros());
|
||||
|
||||
|
|
@ -1857,6 +2269,7 @@ pub(crate) fn lower_all_functions_from_bodies(
|
|||
param_count,
|
||||
node_meta: std::collections::HashMap::new(),
|
||||
body_graph: Some(body.graph.clone()),
|
||||
cross_package_imports: std::sync::Arc::clone(&cross_package_imports_arc),
|
||||
},
|
||||
);
|
||||
perf_lower_record(6, _t_misc2.elapsed().as_micros());
|
||||
|
|
@ -2256,6 +2669,10 @@ fn augment_summaries_with_child_sinks(
|
|||
auto_seed_handler_params: false,
|
||||
cross_file_bodies: None,
|
||||
pointer_facts: None,
|
||||
cross_package_imports: None,
|
||||
entry_kind: None,
|
||||
param_route_capture: None,
|
||||
recording_summary: false,
|
||||
};
|
||||
|
||||
let (_parent_events, parent_block_states) =
|
||||
|
|
@ -2320,6 +2737,10 @@ fn augment_summaries_with_child_sinks(
|
|||
auto_seed_handler_params: false,
|
||||
cross_file_bodies: None,
|
||||
pointer_facts: None,
|
||||
cross_package_imports: None,
|
||||
entry_kind: None,
|
||||
param_route_capture: None,
|
||||
recording_summary: false,
|
||||
};
|
||||
|
||||
let (child_events, _child_block_states) =
|
||||
|
|
@ -2448,6 +2869,7 @@ type EligibleCalleeBodies = Vec<(FuncKey, ssa_transfer::CalleeSsaBody)>;
|
|||
/// entry) and lowers each body's graph with its recorded entry/params. This
|
||||
/// path is equivalent to what `analyse_file` uses at taint time, so the SSA
|
||||
/// summaries produced here line up exactly with what pass 2 will consult.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn extract_ssa_artifacts_from_file_cfg(
|
||||
file_cfg: &FileCfg,
|
||||
lang: Lang,
|
||||
|
|
@ -2455,6 +2877,8 @@ pub(crate) fn extract_ssa_artifacts_from_file_cfg(
|
|||
local_summaries: &FuncSummaries,
|
||||
global_summaries: Option<&GlobalSummaries>,
|
||||
locator: Option<&crate::summary::SinkSiteLocator<'_>>,
|
||||
scan_root: Option<&str>,
|
||||
module_graph: Option<&crate::resolve::ModuleGraph>,
|
||||
) -> (SsaArtifactSummaries, EligibleCalleeBodies) {
|
||||
let (summaries, bodies) = lower_all_functions_from_bodies(
|
||||
file_cfg,
|
||||
|
|
@ -2463,6 +2887,8 @@ pub(crate) fn extract_ssa_artifacts_from_file_cfg(
|
|||
local_summaries,
|
||||
global_summaries,
|
||||
locator,
|
||||
scan_root,
|
||||
module_graph,
|
||||
);
|
||||
let eligible_bodies = build_eligible_bodies(file_cfg, bodies);
|
||||
(summaries, eligible_bodies)
|
||||
|
|
|
|||
|
|
@ -142,6 +142,27 @@ pub struct CalleeSsaBody {
|
|||
/// bodies.
|
||||
#[serde(skip)]
|
||||
pub body_graph: Option<crate::cfg::Cfg>,
|
||||
/// The callee body's own file-level cross-package import map (Phase 09
|
||||
/// step 0.7 keyset).
|
||||
///
|
||||
/// Populated when the body is freshly lowered with the file's
|
||||
/// [`crate::cfg::FileCfg::resolved_imports`] in scope. Forwarded into
|
||||
/// the inline-analysis child transfer so transitive cross-package
|
||||
/// resolution inside an inlined frame can land in
|
||||
/// `crate::summary::GlobalSummaries::ssa_by_key` using the callee's
|
||||
/// own import view rather than the caller's (which would mis-resolve
|
||||
/// names against the caller's package boundary).
|
||||
///
|
||||
/// Wrapped in `Arc` so every body in a file shares one heap
|
||||
/// allocation; per-file bodies typically count in the tens to
|
||||
/// hundreds, and import maps are append-only after construction.
|
||||
/// `#[serde(skip)]` because the map is reproducible from the file's
|
||||
/// `resolved_imports` and bears no identity on its own; an indexed
|
||||
/// scan that loads a body from SQLite simply skips step 0.7 inside
|
||||
/// the inlined frame (same conservative behaviour as before this
|
||||
/// field existed).
|
||||
#[serde(skip)]
|
||||
pub cross_package_imports: std::sync::Arc<std::collections::HashMap<String, FuncKey>>,
|
||||
}
|
||||
|
||||
/// Populate `node_meta` from the original CFG for cross-file persistence.
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -264,6 +264,10 @@ pub fn extract_ssa_func_summary_full(
|
|||
auto_seed_handler_params: false,
|
||||
cross_file_bodies: None,
|
||||
pointer_facts: None,
|
||||
cross_package_imports: None,
|
||||
entry_kind: None,
|
||||
param_route_capture: None,
|
||||
recording_summary: true,
|
||||
};
|
||||
|
||||
let (events, block_states) = run_ssa_taint_full(ssa, cfg, &transfer);
|
||||
|
|
@ -745,14 +749,36 @@ pub fn extract_ssa_func_summary_full(
|
|||
if event.sink_caps.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let site = match locator {
|
||||
Some(loc) => {
|
||||
loc.site_for_span(cfg[event.sink_node].classification_span(), event.sink_caps)
|
||||
// Preserve the deepest sink attribution across multi-hop summaries.
|
||||
// When `event.primary_sink_site` is populated, the upstream
|
||||
// resolver already pierced through a callee summary to the
|
||||
// dangerous instruction's coordinates; promoting it here means a
|
||||
// grandparent caller of this function sees `line N` of the
|
||||
// innermost helper rather than `line M` of *this* function's
|
||||
// call site to its child. Mark `from_chain = true` so pass-2
|
||||
// emission can distinguish multi-hop chain markers (always
|
||||
// promote into `Finding.primary_location`) from this body's own
|
||||
// locator-resolved sink (only promote across file boundaries).
|
||||
// Falls back to locator-based call-site attribution when the
|
||||
// event is intra-procedural.
|
||||
let site = match event.primary_sink_site.as_ref() {
|
||||
Some(s) => {
|
||||
let mut s = s.clone();
|
||||
s.from_chain = true;
|
||||
s
|
||||
}
|
||||
None => SinkSite::cap_only(event.sink_caps),
|
||||
None => match locator {
|
||||
Some(loc) => loc
|
||||
.site_for_span(cfg[event.sink_node].classification_span(), event.sink_caps),
|
||||
None => SinkSite::cap_only(event.sink_caps),
|
||||
},
|
||||
};
|
||||
let key = site.dedup_key();
|
||||
if !param_sites.iter().any(|s| s.dedup_key() == key) {
|
||||
if let Some(existing) = param_sites.iter_mut().find(|s| s.dedup_key() == key) {
|
||||
if site.from_chain && !existing.from_chain {
|
||||
existing.from_chain = true;
|
||||
}
|
||||
} else {
|
||||
param_sites.push(site);
|
||||
}
|
||||
}
|
||||
|
|
@ -812,6 +838,10 @@ pub fn extract_ssa_func_summary_full(
|
|||
auto_seed_handler_params: false,
|
||||
cross_file_bodies: None,
|
||||
pointer_facts: None,
|
||||
cross_package_imports: None,
|
||||
entry_kind: None,
|
||||
param_route_capture: None,
|
||||
recording_summary: true,
|
||||
};
|
||||
detect_source_to_callback_from_states(
|
||||
ssa,
|
||||
|
|
@ -867,6 +897,11 @@ pub fn extract_ssa_func_summary_full(
|
|||
// caller patches it in.
|
||||
typed_call_receivers: Vec::new(),
|
||||
validated_params_to_return,
|
||||
// Phase-10 entry-point classification is attached post-extraction
|
||||
// by `taint::lower_all_functions_from_bodies` (which has access
|
||||
// to `FileCfg::entry_kinds`). Empty here means the extractor
|
||||
// itself does not carry the tag.
|
||||
entry_kind: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1112,11 +1147,25 @@ fn infer_summary_return_type(
|
|||
continue;
|
||||
}
|
||||
// Only inspect the very last instruction in the returning block.
|
||||
// Mirror the CFG-level `outer_callee` fallback (Phase 08 audit) so a
|
||||
// CFG-rewritten callee (e.g. `req.body.path` displacing `URL` on
|
||||
// `new URL(req.body.path, base)`) still resolves to the original
|
||||
// constructor identifier preserved in `callee_text`.
|
||||
if let Some(inst) = block.body.last()
|
||||
&& let SsaOp::Call { callee, .. } = &inst.op
|
||||
&& let Some(ty) = crate::ssa::type_facts::constructor_type(lang, callee)
|
||||
&& let SsaOp::Call {
|
||||
callee,
|
||||
callee_text,
|
||||
..
|
||||
} = &inst.op
|
||||
{
|
||||
return Some(ty);
|
||||
if let Some(ty) = crate::ssa::type_facts::constructor_type(lang, callee) {
|
||||
return Some(ty);
|
||||
}
|
||||
if let Some(orig) = callee_text.as_deref()
|
||||
&& let Some(ty) = crate::ssa::type_facts::constructor_type(lang, orig)
|
||||
{
|
||||
return Some(ty);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
|
|
|
|||
|
|
@ -87,6 +87,7 @@ mod cross_file_tests {
|
|||
field_writes: std::collections::HashMap::new(),
|
||||
|
||||
synthetic_externals: std::collections::HashSet::new(),
|
||||
slot_scoped_assigns: std::collections::HashSet::new(),
|
||||
},
|
||||
opt: crate::ssa::OptimizeResult {
|
||||
const_values: std::collections::HashMap::new(),
|
||||
|
|
@ -105,6 +106,7 @@ mod cross_file_tests {
|
|||
param_count: 0,
|
||||
node_meta: std::collections::HashMap::new(),
|
||||
body_graph: None,
|
||||
cross_package_imports: std::sync::Arc::new(std::collections::HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -838,6 +840,7 @@ mod primary_sink_location_tests {
|
|||
field_writes: std::collections::HashMap::new(),
|
||||
|
||||
synthetic_externals: std::collections::HashSet::new(),
|
||||
slot_scoped_assigns: std::collections::HashSet::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -862,6 +865,7 @@ mod primary_sink_location_tests {
|
|||
col: 10,
|
||||
snippet: "Command::new(cmd).status()".into(),
|
||||
cap: Cap::SHELL_ESCAPE,
|
||||
from_chain: false,
|
||||
};
|
||||
let summary = SsaFuncSummary {
|
||||
param_to_sink: vec![(0usize, smallvec![site.clone()])],
|
||||
|
|
@ -886,6 +890,8 @@ mod primary_sink_location_tests {
|
|||
&tainted,
|
||||
Cap::SHELL_ESCAPE,
|
||||
&summary.param_to_sink,
|
||||
"caller.rs",
|
||||
false,
|
||||
);
|
||||
assert_eq!(
|
||||
primary_sites.len(),
|
||||
|
|
@ -971,6 +977,7 @@ mod goto_succ_propagation_tests {
|
|||
field_writes: std::collections::HashMap::new(),
|
||||
|
||||
synthetic_externals: std::collections::HashSet::new(),
|
||||
slot_scoped_assigns: std::collections::HashSet::new(),
|
||||
};
|
||||
|
||||
let cfg: Cfg = Graph::new();
|
||||
|
|
@ -1009,6 +1016,10 @@ mod goto_succ_propagation_tests {
|
|||
auto_seed_handler_params: false,
|
||||
cross_file_bodies: None,
|
||||
pointer_facts: None,
|
||||
cross_package_imports: None,
|
||||
entry_kind: None,
|
||||
param_route_capture: None,
|
||||
recording_summary: false,
|
||||
};
|
||||
|
||||
// A non-bottom exit state, the test only cares that *every* succ
|
||||
|
|
@ -1065,6 +1076,7 @@ mod goto_succ_propagation_tests {
|
|||
field_writes: std::collections::HashMap::new(),
|
||||
|
||||
synthetic_externals: std::collections::HashSet::new(),
|
||||
slot_scoped_assigns: std::collections::HashSet::new(),
|
||||
};
|
||||
let cfg: Cfg = Graph::new();
|
||||
let interner = SymbolInterner::new();
|
||||
|
|
@ -1101,6 +1113,10 @@ mod goto_succ_propagation_tests {
|
|||
auto_seed_handler_params: false,
|
||||
cross_file_bodies: None,
|
||||
pointer_facts: None,
|
||||
cross_package_imports: None,
|
||||
entry_kind: None,
|
||||
param_route_capture: None,
|
||||
recording_summary: false,
|
||||
};
|
||||
let exit_state = SsaTaintState::initial();
|
||||
|
||||
|
|
@ -1128,6 +1144,7 @@ mod goto_succ_propagation_tests {
|
|||
field_writes: std::collections::HashMap::new(),
|
||||
|
||||
synthetic_externals: std::collections::HashSet::new(),
|
||||
slot_scoped_assigns: std::collections::HashSet::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1390,6 +1407,7 @@ mod goto_succ_propagation_tests {
|
|||
field_writes: std::collections::HashMap::new(),
|
||||
|
||||
synthetic_externals: std::collections::HashSet::new(),
|
||||
slot_scoped_assigns: std::collections::HashSet::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1517,6 +1535,7 @@ mod receiver_candidates_field_proj_tests {
|
|||
field_writes: std::collections::HashMap::new(),
|
||||
|
||||
synthetic_externals: std::collections::HashSet::new(),
|
||||
slot_scoped_assigns: std::collections::HashSet::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1604,6 +1623,7 @@ mod receiver_candidates_field_proj_tests {
|
|||
field_writes: std::collections::HashMap::new(),
|
||||
|
||||
synthetic_externals: std::collections::HashSet::new(),
|
||||
slot_scoped_assigns: std::collections::HashSet::new(),
|
||||
};
|
||||
let cands =
|
||||
super::super::receiver_candidates_for_type_lookup(SsaValue(0), Some(&body), Lang::Go);
|
||||
|
|
@ -1739,6 +1759,7 @@ mod fanout_merge_tests {
|
|||
col: 5,
|
||||
snippet: "exec(q)".into(),
|
||||
cap: Cap::from_bits(0b0001).unwrap(),
|
||||
from_chain: false,
|
||||
};
|
||||
let unique_a = SinkSite {
|
||||
file_rel: "src/a.rs".into(),
|
||||
|
|
@ -1746,6 +1767,7 @@ mod fanout_merge_tests {
|
|||
col: 3,
|
||||
snippet: "do_a(q)".into(),
|
||||
cap: Cap::from_bits(0b0001).unwrap(),
|
||||
from_chain: false,
|
||||
};
|
||||
let unique_b = SinkSite {
|
||||
file_rel: "src/b.rs".into(),
|
||||
|
|
@ -1753,6 +1775,7 @@ mod fanout_merge_tests {
|
|||
col: 7,
|
||||
snippet: "do_b(q)".into(),
|
||||
cap: Cap::from_bits(0b0001).unwrap(),
|
||||
from_chain: false,
|
||||
};
|
||||
let mut a = empty();
|
||||
a.param_to_sink_sites = vec![(0, smallvec![shared.clone(), unique_a.clone()])];
|
||||
|
|
@ -2008,6 +2031,7 @@ mod field_write_tests {
|
|||
field_interner,
|
||||
field_writes,
|
||||
synthetic_externals: HashSet::new(),
|
||||
slot_scoped_assigns: HashSet::new(),
|
||||
};
|
||||
(body, cache_id)
|
||||
}
|
||||
|
|
@ -2056,6 +2080,10 @@ mod field_write_tests {
|
|||
auto_seed_handler_params: false,
|
||||
cross_file_bodies: None,
|
||||
pointer_facts: Some(pf),
|
||||
cross_package_imports: None,
|
||||
entry_kind: None,
|
||||
param_route_capture: None,
|
||||
recording_summary: false,
|
||||
};
|
||||
|
||||
let mut state = SsaTaintState::initial();
|
||||
|
|
@ -2140,6 +2168,10 @@ mod field_write_tests {
|
|||
auto_seed_handler_params: false,
|
||||
cross_file_bodies: None,
|
||||
pointer_facts: None,
|
||||
cross_package_imports: None,
|
||||
entry_kind: None,
|
||||
param_route_capture: None,
|
||||
recording_summary: false,
|
||||
};
|
||||
let mut state = SsaTaintState::initial();
|
||||
for inst in &body.blocks[0].body {
|
||||
|
|
@ -2208,6 +2240,10 @@ mod field_write_tests {
|
|||
auto_seed_handler_params: false,
|
||||
cross_file_bodies: None,
|
||||
pointer_facts: Some(&pf),
|
||||
cross_package_imports: None,
|
||||
entry_kind: None,
|
||||
param_route_capture: None,
|
||||
recording_summary: false,
|
||||
};
|
||||
|
||||
// Pre-seed `validated_must` on `src` so the synth Assign
|
||||
|
|
@ -2312,6 +2348,7 @@ mod field_write_tests {
|
|||
m
|
||||
},
|
||||
synthetic_externals: HashSet::new(),
|
||||
slot_scoped_assigns: HashSet::new(),
|
||||
};
|
||||
let pf = crate::pointer::analyse_body(&body, crate::cfg::BodyId(0));
|
||||
// v0 is Const → empty pt, the hook should not insert anything.
|
||||
|
|
@ -2354,6 +2391,10 @@ mod field_write_tests {
|
|||
auto_seed_handler_params: false,
|
||||
cross_file_bodies: None,
|
||||
pointer_facts: Some(&pf),
|
||||
cross_package_imports: None,
|
||||
entry_kind: None,
|
||||
param_route_capture: None,
|
||||
recording_summary: false,
|
||||
};
|
||||
|
||||
let mut state = SsaTaintState::initial();
|
||||
|
|
@ -2452,6 +2493,10 @@ mod container_elem_tests {
|
|||
auto_seed_handler_params: false,
|
||||
cross_file_bodies: None,
|
||||
pointer_facts: Some(pf),
|
||||
cross_package_imports: None,
|
||||
entry_kind: None,
|
||||
param_route_capture: None,
|
||||
recording_summary: false,
|
||||
};
|
||||
|
||||
let mut state = SsaTaintState::initial();
|
||||
|
|
@ -2549,6 +2594,7 @@ mod container_elem_tests {
|
|||
field_writes: HashMap::new(),
|
||||
|
||||
synthetic_externals: HashSet::new(),
|
||||
slot_scoped_assigns: HashSet::new(),
|
||||
};
|
||||
|
||||
// Run pointer analysis first to confirm the result of `shift()`
|
||||
|
|
@ -2689,6 +2735,7 @@ mod container_elem_tests {
|
|||
field_writes: HashMap::new(),
|
||||
|
||||
synthetic_externals: HashSet::new(),
|
||||
slot_scoped_assigns: HashSet::new(),
|
||||
};
|
||||
|
||||
let pf = crate::pointer::analyse_body(&body, crate::cfg::BodyId(7));
|
||||
|
|
@ -2731,6 +2778,10 @@ mod container_elem_tests {
|
|||
auto_seed_handler_params: false,
|
||||
cross_file_bodies: None,
|
||||
pointer_facts: Some(&pf),
|
||||
cross_package_imports: None,
|
||||
entry_kind: None,
|
||||
param_route_capture: None,
|
||||
recording_summary: false,
|
||||
};
|
||||
|
||||
// Seed `src` as validated_must before the push fires.
|
||||
|
|
@ -2833,6 +2884,7 @@ mod container_elem_tests {
|
|||
field_writes: HashMap::new(),
|
||||
|
||||
synthetic_externals: HashSet::new(),
|
||||
slot_scoped_assigns: HashSet::new(),
|
||||
};
|
||||
|
||||
let interner = SymbolInterner::new();
|
||||
|
|
@ -2869,6 +2921,10 @@ mod container_elem_tests {
|
|||
auto_seed_handler_params: false,
|
||||
cross_file_bodies: None,
|
||||
pointer_facts: None,
|
||||
cross_package_imports: None,
|
||||
entry_kind: None,
|
||||
param_route_capture: None,
|
||||
recording_summary: false,
|
||||
};
|
||||
let mut state = SsaTaintState::initial();
|
||||
for inst in &body.blocks[0].body {
|
||||
|
|
@ -2960,6 +3016,7 @@ mod cross_call_field_tests {
|
|||
field_writes: HashMap::new(),
|
||||
|
||||
synthetic_externals: HashSet::new(),
|
||||
slot_scoped_assigns: HashSet::new(),
|
||||
};
|
||||
let pf = crate::pointer::analyse_body(&body, crate::cfg::BodyId(7));
|
||||
(body, cache_id, pf)
|
||||
|
|
@ -3334,6 +3391,7 @@ mod field_taint_origin_cap_tests {
|
|||
field_writes: HashMap::new(),
|
||||
|
||||
synthetic_externals: HashSet::new(),
|
||||
slot_scoped_assigns: HashSet::new(),
|
||||
};
|
||||
(body, cache_id, cfg, n_proj)
|
||||
}
|
||||
|
|
@ -3425,6 +3483,10 @@ mod field_taint_origin_cap_tests {
|
|||
auto_seed_handler_params: false,
|
||||
cross_file_bodies: None,
|
||||
pointer_facts: Some(&pf),
|
||||
cross_package_imports: None,
|
||||
entry_kind: None,
|
||||
param_route_capture: None,
|
||||
recording_summary: false,
|
||||
};
|
||||
for inst in &body.blocks[0].body {
|
||||
transfer_inst(inst, &cfg, &body, &transfer, &mut state);
|
||||
|
|
@ -3660,6 +3722,7 @@ mod pointer_lattice_worklist_tests {
|
|||
field_interner,
|
||||
field_writes,
|
||||
synthetic_externals: HashSet::new(),
|
||||
slot_scoped_assigns: HashSet::new(),
|
||||
};
|
||||
|
||||
let mut interner = SymbolInterner::new();
|
||||
|
|
@ -3713,6 +3776,10 @@ mod pointer_lattice_worklist_tests {
|
|||
auto_seed_handler_params: false,
|
||||
cross_file_bodies: None,
|
||||
pointer_facts: Some(pf),
|
||||
cross_package_imports: None,
|
||||
entry_kind: None,
|
||||
param_route_capture: None,
|
||||
recording_summary: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -63,6 +63,10 @@ fn ssa_analyse_rust(src: &[u8]) -> Vec<Finding> {
|
|||
auto_seed_handler_params: false,
|
||||
cross_file_bodies: None,
|
||||
pointer_facts: None,
|
||||
cross_package_imports: None,
|
||||
entry_kind: None,
|
||||
param_route_capture: None,
|
||||
recording_summary: false,
|
||||
};
|
||||
let events = ssa_transfer::run_ssa_taint(&ssa, cfg, &transfer);
|
||||
let mut findings = ssa_transfer::ssa_events_to_findings(&events, &ssa, cfg);
|
||||
|
|
@ -663,6 +667,7 @@ fn cross_file_sink_finding_carries_primary_location() {
|
|||
col: 5,
|
||||
snippet: "Command::new(\"sh\").arg(cmd).status().unwrap();".into(),
|
||||
cap: Cap::SHELL_ESCAPE,
|
||||
from_chain: false,
|
||||
};
|
||||
global.insert(
|
||||
key,
|
||||
|
|
@ -3788,6 +3793,10 @@ fn assert_ssa_integration(src: &[u8]) {
|
|||
auto_seed_handler_params: false,
|
||||
cross_file_bodies: None,
|
||||
pointer_facts: None,
|
||||
cross_package_imports: None,
|
||||
entry_kind: None,
|
||||
param_route_capture: None,
|
||||
recording_summary: false,
|
||||
};
|
||||
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);
|
||||
|
|
@ -3926,6 +3935,10 @@ fn integ_php_echo_simple_var() {
|
|||
auto_seed_handler_params: false,
|
||||
cross_file_bodies: None,
|
||||
pointer_facts: None,
|
||||
cross_package_imports: None,
|
||||
entry_kind: None,
|
||||
param_route_capture: None,
|
||||
recording_summary: false,
|
||||
};
|
||||
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);
|
||||
|
|
@ -3996,6 +4009,10 @@ fn integ_c_curl_handle_ssrf() {
|
|||
auto_seed_handler_params: false,
|
||||
cross_file_bodies: None,
|
||||
pointer_facts: None,
|
||||
cross_package_imports: None,
|
||||
entry_kind: None,
|
||||
param_route_capture: None,
|
||||
recording_summary: false,
|
||||
};
|
||||
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);
|
||||
|
|
@ -5481,6 +5498,8 @@ class Worker {
|
|||
&file_cfg.summaries,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
// Collect containers of every key named "process".
|
||||
|
|
@ -5553,6 +5572,8 @@ function helper(x) {
|
|||
&file_cfg.summaries,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
let helper_keys: Vec<_> = summaries.keys().filter(|k| k.name == "helper").collect();
|
||||
|
|
@ -5776,6 +5797,8 @@ class Reader {
|
|||
&file_cfg.summaries,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
let read_sum = summaries
|
||||
|
|
@ -5821,6 +5844,8 @@ class Maker {
|
|||
&file_cfg.summaries,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
// make() has zero parameters and no fresh-allocation return, so the
|
||||
|
|
@ -6837,6 +6862,55 @@ function handler(req, res) {
|
|||
/// traversal flow alive end-to-end. Pins the precision claim — the
|
||||
/// strip is element-of-array-after-filter scoped, not a wholesale
|
||||
/// kill on any `<arr>.filter` call regardless of callback identity.
|
||||
#[test]
|
||||
fn callee_body_carries_file_cross_package_imports() {
|
||||
// Phase 09: every `CalleeSsaBody` produced from a file's lowering
|
||||
// pipeline should carry the file-level cross-package import map
|
||||
// so the inline-analysis frame can resolve the callee's local
|
||||
// names against the callee's own package boundary (step 0.7
|
||||
// inside an inlined frame).
|
||||
let src = b"export function passthrough(s) { return s; }\n";
|
||||
let lang = tree_sitter::Language::from(tree_sitter_javascript::LANGUAGE);
|
||||
let mut file_cfg = parse_lang(src, "javascript", lang);
|
||||
|
||||
// Inject a synthetic resolved import binding the way the Phase 04
|
||||
// resolver would for `import { helper } from "@scope/util/helper";`.
|
||||
file_cfg
|
||||
.resolved_imports
|
||||
.push(crate::resolve::ImportBinding {
|
||||
local_name: "helper".to_string(),
|
||||
source_module: "@scope/util/helper".to_string(),
|
||||
resolved_file: Some(std::path::PathBuf::from("/scope/util/src/helper.ts")),
|
||||
exported_name: Some("helper".to_string()),
|
||||
});
|
||||
|
||||
let (_summaries, bodies) = super::extract_ssa_artifacts_from_file_cfg(
|
||||
&file_cfg,
|
||||
Lang::JavaScript,
|
||||
"test.js",
|
||||
&file_cfg.summaries,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
assert!(
|
||||
!bodies.is_empty(),
|
||||
"expected at least one eligible body for `passthrough`",
|
||||
);
|
||||
for (_key, body) in &bodies {
|
||||
assert!(
|
||||
!body.cross_package_imports.is_empty(),
|
||||
"every body in a file with resolved imports should carry the file's cross-package import map; got an empty map",
|
||||
);
|
||||
assert!(
|
||||
body.cross_package_imports.contains_key("helper"),
|
||||
"expected the synthetic `helper` binding to surface in the body's cross-package import map",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cve_2026_42353_filter_without_validator_callback_preserves_taint() {
|
||||
let src = br#"
|
||||
|
|
@ -6867,3 +6941,74 @@ function handler(req, res) {
|
|||
"expected taint flow via filter(pickFirst) — pickFirst is not a recognised validator and must not strip taint; got 0 findings",
|
||||
);
|
||||
}
|
||||
|
||||
// ── Phase 09 cross-package namespace migration ─────────────────────────────
|
||||
|
||||
/// `build_cross_package_func_keys` produces a package-prefixed
|
||||
/// [`FuncKey::namespace`] for files inside a discovered monorepo
|
||||
/// package and a plain namespace otherwise.
|
||||
///
|
||||
/// Locks in the migration done as part of the deferred Phase 09 audit:
|
||||
/// SSA summary keys produced by
|
||||
/// [`crate::taint::lower_all_functions_from_bodies`] use
|
||||
/// `namespace_with_package` for their namespace, so the cross-package
|
||||
/// import map's `FuncKey::namespace` must agree for step 0.7 of
|
||||
/// `resolve_callee_full` to land hits in
|
||||
/// [`crate::summary::GlobalSummaries::ssa_by_key`].
|
||||
#[test]
|
||||
fn cross_package_func_keys_namespace_uses_resolver_when_available() {
|
||||
use crate::resolve::{ImportBinding, build_module_graph};
|
||||
use std::path::PathBuf;
|
||||
|
||||
let mut fixture_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||
fixture_root.push("tests/fixtures/resolver");
|
||||
let root = fixture_root
|
||||
.canonicalize()
|
||||
.unwrap_or_else(|_| fixture_root.clone());
|
||||
let graph = build_module_graph(std::slice::from_ref(&root));
|
||||
|
||||
let resolved_file = root.join("packages/util/src/index.ts");
|
||||
let binding = ImportBinding {
|
||||
local_name: "doStuff".to_string(),
|
||||
source_module: "@scope/util".to_string(),
|
||||
resolved_file: Some(resolved_file.clone()),
|
||||
exported_name: Some("doStuff".to_string()),
|
||||
};
|
||||
let scan_root = root.to_string_lossy().to_string();
|
||||
|
||||
let with_resolver = crate::taint::build_cross_package_func_keys(
|
||||
std::slice::from_ref(&binding),
|
||||
Some(&scan_root),
|
||||
Some(&graph),
|
||||
Lang::TypeScript,
|
||||
);
|
||||
let key = with_resolver
|
||||
.get("doStuff")
|
||||
.expect("resolved binding maps to a FuncKey");
|
||||
assert!(
|
||||
key.namespace.starts_with("@scope/util::"),
|
||||
"expected package-prefixed namespace, got {ns}",
|
||||
ns = key.namespace,
|
||||
);
|
||||
assert!(
|
||||
key.namespace.ends_with("packages/util/src/index.ts"),
|
||||
"expected the suffix to remain the scan-root-relative path, got {ns}",
|
||||
ns = key.namespace,
|
||||
);
|
||||
|
||||
let without_resolver = crate::taint::build_cross_package_func_keys(
|
||||
std::slice::from_ref(&binding),
|
||||
Some(&scan_root),
|
||||
None,
|
||||
Lang::TypeScript,
|
||||
);
|
||||
let plain = without_resolver
|
||||
.get("doStuff")
|
||||
.expect("plain binding maps to a FuncKey");
|
||||
assert!(
|
||||
!plain.namespace.contains("::"),
|
||||
"without a resolver the namespace must stay plain, got {ns}",
|
||||
ns = plain.namespace,
|
||||
);
|
||||
assert_eq!(plain.namespace, "packages/util/src/index.ts");
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue