new capacity bits (#67)

This commit is contained in:
Eli Peter 2026-05-07 01:29:31 -04:00 committed by GitHub
parent afaffc0df6
commit 7d0e7320e2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
261 changed files with 10591 additions and 231 deletions

View file

@ -277,7 +277,14 @@ pub fn ssa_events_to_findings(
ssa: &SsaBody,
cfg: &Cfg,
) -> Vec<crate::taint::Finding> {
type FindingDedupKey = (usize, usize, Option<(String, u32, u32)>);
// The dedup key includes `cap_bits` so the multi-gate dispatch can
// co-emit separate findings for distinct capabilities at the same
// (origin, sink) pair (e.g. PHP `header("Location: " . $url)` fires
// both HEADER_INJECTION and OPEN_REDIRECT, attributed by the gate
// filters' per-cap masks). Single-cap call sites are unaffected:
// every event in that case carries the same `sink_caps`, so the key
// collapses identically with or without the extra component.
type FindingDedupKey = (usize, usize, Option<(String, u32, u32)>, u32);
let mut findings = Vec::new();
let mut seen: HashSet<FindingDedupKey> = HashSet::new();
@ -345,12 +352,14 @@ pub fn ssa_events_to_findings(
.as_ref()
.map(|l| (l.file_rel.clone(), l.line, l.col));
for (val, caps, origins) in &event.tainted_values {
let cap_specificity = (*caps & event.sink_caps).bits().count_ones() as u8;
let effective_caps = event.sink_caps & *caps;
let cap_specificity = effective_caps.bits().count_ones() as u8;
for origin in origins {
if seen.insert((
origin.node.index(),
event.sink_node.index(),
loc_key.clone(),
effective_caps.bits(),
)) {
let hop_count = block_distance(ssa, origin.node, event.sink_node);
let flow_steps = reconstruct_flow_path(*val, origin, event.sink_node, ssa, cfg);

View file

@ -21,7 +21,7 @@ pub(super) const MAX_INLINE_BLOCKS: usize = 500;
/// Compact cache key: per-arg-position cap bits (sorted, non-empty
/// only). Origin identity is not part of the key.
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub(crate) struct ArgTaintSig(pub(super) SmallVec<[(usize, u16); 4]>);
pub(crate) struct ArgTaintSig(pub(super) SmallVec<[(usize, u32); 4]>);
/// Call-site-adapted result of inline-analyzing a callee. Built fresh
/// per call site so origins point to the current caller's chain.
@ -79,7 +79,7 @@ pub(crate) struct ReturnShape {
impl CachedInlineShape {
/// Cap bits of the return value, or zero if this shape records "no
/// return taint". Used by [`inline_cache_fingerprint`].
fn return_caps_bits(&self) -> u16 {
fn return_caps_bits(&self) -> u32 {
self.0.as_ref().map(|s| s.caps.bits()).unwrap_or(0)
}
}
@ -101,7 +101,7 @@ pub(crate) fn inline_cache_clear_epoch(cache: &mut InlineCache) {
#[allow(dead_code)]
pub(crate) fn inline_cache_fingerprint(
cache: &InlineCache,
) -> HashMap<(FuncKey, ArgTaintSig), u16> {
) -> HashMap<(FuncKey, ArgTaintSig), u32> {
cache
.iter()
.map(|(k, v)| (k.clone(), v.return_caps_bits()))

View file

@ -105,6 +105,18 @@ pub struct SsaTaintTransfer<'a> {
/// Type facts from type analysis.
/// Used for type-aware sink filtering (e.g., suppress SQL injection for int-typed values).
pub type_facts: Option<&'a crate::ssa::type_facts::TypeFactResult>,
/// XML-parser config facts. Used to suppress XXE bits at parse-class
/// sinks whose receiver was provably hardened
/// (`setFeature(FEATURE_SECURE_PROCESSING, true)`, etc.). Strictly
/// additive: `None` falls back to the existing flat / gated XXE
/// classification.
pub xml_parser_config: Option<&'a crate::ssa::xml_config::XmlParserConfigResult>,
/// XPath-receiver config facts. Used to suppress XPATH_INJECTION at
/// `evaluate` / `compile` sinks whose receiver was provably bound to
/// an `XPathVariableResolver` (parameterised-XPath shape). Strictly
/// additive: `None` falls back to the existing flat / gated XPATH
/// classification.
pub xpath_config: Option<&'a crate::ssa::xpath_config::XPathConfigResult>,
/// Precise per-function SSA summaries for intra-file callee resolution.
/// Checked before legacy FuncSummary resolution.
///
@ -1207,6 +1219,85 @@ fn apply_branch_predicates(
}
}
// RelativeUrlValidated: TRUE branch is the validated path
// (`x.startsWith("/")` succeeded → `x` cannot redirect off-host).
// Cap-aware: clear `Cap::OPEN_REDIRECT` only; non-redirect sinks
// (XSS / SQLi / FILE_IO) downstream still fire on residual taint.
if kind == PredicateKind::RelativeUrlValidated && polarity {
for var in condition_vars {
let mut to_clear: SmallVec<[SsaValue; 4]> = SmallVec::new();
for (val, _) in state.values.iter() {
if let Some(name) = ssa
.value_defs
.get(val.0 as usize)
.and_then(|vd| vd.var_name.as_deref())
{
if name == var {
to_clear.push(*val);
}
}
}
for val in to_clear {
if let Some(taint) = state.get(val).cloned() {
let new_caps = taint.caps & !Cap::OPEN_REDIRECT;
if new_caps.is_empty() {
state.remove(val);
} else {
state.set(
val,
VarTaint {
caps: new_caps,
origins: taint.origins,
uses_summary: taint.uses_summary,
},
);
}
}
}
}
}
// HostAllowlistValidated: TRUE branch is the validated path
// (`new URL(x).host === ALLOWED` succeeded → `x` cannot redirect off-host).
// Cap-aware: clear `Cap::OPEN_REDIRECT` only; non-redirect sinks downstream
// still fire on the residual taint caps. Mirrors the
// `RelativeUrlValidated` handler exactly, the only difference is the
// recogniser shape (multi-statement parse + host comparison instead of
// inline leading-slash check).
if kind == PredicateKind::HostAllowlistValidated && polarity {
for var in condition_vars {
let mut to_clear: SmallVec<[SsaValue; 4]> = SmallVec::new();
for (val, _) in state.values.iter() {
if let Some(name) = ssa
.value_defs
.get(val.0 as usize)
.and_then(|vd| vd.var_name.as_deref())
{
if name == var {
to_clear.push(*val);
}
}
}
for val in to_clear {
if let Some(taint) = state.get(val).cloned() {
let new_caps = taint.caps & !Cap::OPEN_REDIRECT;
if new_caps.is_empty() {
state.remove(val);
} else {
state.set(
val,
VarTaint {
caps: new_caps,
origins: taint.origins,
uses_summary: taint.uses_summary,
},
);
}
}
}
}
}
// ShellMetaValidated: inverted polarity, the FALSE branch (no metachar
// found) is the validated path; the TRUE branch is the rejection path.
//
@ -2203,6 +2294,8 @@ fn inline_analyse_callee(
receiver_seed: receiver_seed.as_ref(),
const_values: Some(&callee_body.opt.const_values),
type_facts: Some(&callee_body.opt.type_facts),
xml_parser_config: Some(&callee_body.opt.xml_parser_config),
xpath_config: Some(&callee_body.opt.xpath_config),
ssa_summaries: transfer.ssa_summaries,
extra_labels: transfer.extra_labels,
base_aliases: Some(&callee_body.opt.alias_result),
@ -5891,6 +5984,34 @@ fn collect_block_events(
sink_caps &= !Cap::DATA_EXFIL;
}
// Receiver-type-incompatibility stripping. When the receiver's type
// proves a structurally-attached cap cannot apply (e.g. an
// `LdapClient` receiver carrying an `HTML_ESCAPE` Sink label that was
// attached to the CFG node by a `*.send`/`*.json`-style suffix
// matcher), drop the offending bits *before* the type-qualified-
// resolution branch below, so that branch is reachable on the
// remaining empty `sink_caps` and can re-anchor a precise sink class
// (`LdapClient.search` → `Cap::LDAP_INJECTION`). Both the
// flow-sensitive type from `path_env` and the static type from
// `type_facts` are consulted; the static path is what enables
// closure-captured receivers (parent body → child body via
// [`crate::taint::inject_external_type_facts`]) to participate.
if let SsaOp::Call {
receiver: Some(rv), ..
} = &inst.op
{
if let Some(ref env) = state.path_env {
if let Some(kind) = env.get(*rv).types.as_singleton() {
sink_caps &= !receiver_incompatible_sink_caps(&kind, sink_caps);
}
}
if let Some(tf) = transfer.type_facts {
if let Some(kind) = tf.get_type(*rv) {
sink_caps &= !receiver_incompatible_sink_caps(kind, sink_caps);
}
}
}
// Type-qualified sink resolution: when normal sink resolution found nothing,
// try using the receiver's inferred type to construct a qualified callee name.
if sink_caps.is_empty() {
@ -5954,6 +6075,39 @@ fn collect_block_events(
}
}
// ADD XXE on opt-in. When the receiver was constructed
// with an explicit external-entity opt-in
// (`new XMLParser({ processEntities: true })`,
// `lxml.etree.XMLParser(resolve_entities=True)`), the subsequent
// `parser.parse(xml)` is an XXE flow even though the callee
// carries no flat XXE rule (fast-xml-parser and lxml are
// XXE-safe by default). Runs BEFORE the empty check below so a
// previously-empty sink_caps becomes non-empty and downstream
// emission proceeds. The complementary `xxe_safe` suppress path
// still runs after this; a call where the receiver was both
// opt-in AND later hardened by a setter results in net-zero
// (suppress strips what we added).
if let SsaOp::Call {
receiver: Some(rv),
callee: callee_str,
..
} = &inst.op
{
if let Some(xc) = transfer.xml_parser_config {
if xc.is_unsafe_explicit(*rv) {
let suffix = callee_str
.rsplit(['.', ':'])
.next()
.unwrap_or(callee_str.as_str());
// `feed` covers Python lxml incremental parsing
// (`parser.feed(body); parser.close()`).
if matches!(suffix, "parse" | "parseString" | "parseFromString" | "feed") {
sink_caps |= Cap::XXE;
}
}
}
}
if sink_caps.is_empty() {
// Callback pattern: check if callee has source_to_callback and the
// actual callback argument has a matching param_to_sink.
@ -6055,17 +6209,89 @@ fn collect_block_events(
continue;
}
// Receiver type incompatibility check.
// If the receiver's flow-sensitive type proves it cannot be the kind
// of object the sink expects (e.g., Int receiver → not an HTTP response
// sink), strip those sink caps.
if let Some(ref env) = state.path_env {
if sink_caps.is_empty() {
continue;
}
// XXE config-fact suppression. A parse-class sink whose receiver
// was provably hardened (`setFeature(FEATURE_SECURE_PROCESSING,
// true)`, `setExpandEntityReferences(false)`, etc.) is not an XXE
// flow. Drop the bit before downstream sink emission. Runs after
// type-qualified resolution / module alias resolution so the XXE
// bit added by `XmlParser.parse` resolution is visible here.
if sink_caps.intersects(Cap::XXE) {
if let SsaOp::Call {
receiver: Some(rv), ..
} = &inst.op
{
if let Some(kind) = env.get(*rv).types.as_singleton() {
sink_caps &= !receiver_incompatible_sink_caps(&kind, sink_caps);
if let Some(xc) = transfer.xml_parser_config {
if crate::ssa::xml_config::xxe_safe(Some(*rv), xc) {
sink_caps &= !Cap::XXE;
}
}
}
}
if sink_caps.is_empty() {
continue;
}
// XPath resolver-binding suppression. An XPath `evaluate` /
// `compile` sink whose receiver was provably bound to an
// `XPathVariableResolver` is treated as parameterised and the
// XPATH_INJECTION bit is stripped. Mirrors the XXE config-fact
// shape above. Only fires when the receiver also carries
// `TypeKind::XPathClient` (gates the suppression behind
// type-fact disambiguation so a generic `obj.evaluate(...)`
// matched as XPATH_INJECTION via name-only labelling does not
// accidentally clear).
if sink_caps.intersects(Cap::XPATH_INJECTION) {
if let SsaOp::Call {
receiver: Some(rv), ..
} = &inst.op
{
if let Some(xpc) = transfer.xpath_config {
let receiver_is_xpath = transfer
.type_facts
.and_then(|tf| tf.get_type(*rv))
.map(|kind| matches!(kind, crate::ssa::type_facts::TypeKind::XPathClient))
.unwrap_or(false);
if receiver_is_xpath && crate::ssa::xpath_config::xpath_safe(Some(*rv), xpc) {
sink_caps &= !Cap::XPATH_INJECTION;
}
}
}
}
if sink_caps.is_empty() {
continue;
}
// Prototype-pollution suppression (flow-sensitive).
// `Object.create(null)` produces a `NullPrototypeObject`-typed
// value; subscript writes to such an object cannot pollute
// `Object.prototype` because there is no prototype chain.
// Receiver SsaValue is read off the synthetic `__index_set__`
// Call op; phi joins downgrade to `Unknown` via `TypeFact::meet`
// so an if/else where only one branch initialises with
// `Object.create(null)` keeps the PROTOTYPE_POLLUTION bit on
// the unsafe path.
if sink_caps.intersects(Cap::PROTOTYPE_POLLUTION) {
if let SsaOp::Call {
callee,
receiver: Some(rv),
..
} = &inst.op
{
if callee == "__index_set__" {
let receiver_is_null_proto = transfer
.type_facts
.and_then(|tf| tf.get_type(*rv))
.map(|kind| {
matches!(kind, crate::ssa::type_facts::TypeKind::NullPrototypeObject)
})
.unwrap_or(false);
if receiver_is_null_proto {
sink_caps &= !Cap::PROTOTYPE_POLLUTION;
}
}
}
}
@ -6436,7 +6662,7 @@ fn pick_primary_sink_sites(
return Vec::new();
};
let mut out: Vec<SinkSite> = Vec::new();
let mut seen: HashSet<(String, u32, u32, u16)> = HashSet::new();
let mut seen: HashSet<(String, u32, u32, u32)> = HashSet::new();
for (param_idx, sites) in param_to_sink_sites {
let Some(arg_vals) = args.get(*param_idx) else {
continue;
@ -6475,7 +6701,7 @@ fn pick_primary_sink_sites_from_resolved(
return Vec::new();
}
let mut out: Vec<SinkSite> = Vec::new();
let mut seen: HashSet<(String, u32, u32, u16)> = HashSet::new();
let mut seen: HashSet<(String, u32, u32, u32)> = HashSet::new();
for (_, sites) in param_to_sink_sites {
for site in sites {
if site.line == 0 {
@ -8127,13 +8353,36 @@ fn type_safe_for_taint_sink(kind: &crate::ssa::type_facts::TypeKind, cap: Cap) -
fn receiver_incompatible_sink_caps(kind: &crate::ssa::type_facts::TypeKind, sink_caps: Cap) -> Cap {
use crate::ssa::type_facts::TypeKind;
let mut remove = Cap::empty();
// HTML_ESCAPE requires HTTP response-like receiver
if sink_caps.intersects(Cap::HTML_ESCAPE) {
// HTML_ESCAPE / OPEN_REDIRECT / HEADER_INJECTION all require an HTTP
// response-like receiver: each is a write-side rule that fires when
// attacker data is rendered into / written onto the response stream
// (`*.send` / `*.redirect` / `*.setHeader` / etc.). Receivers proven
// to be a different class — directory-service connections (LDAP),
// database connections, file handles, in-memory collections, query-
// builder objects, URL values, HTTP clients (request-side), and so on
// — cannot host these sinks even when a same-named matcher
// (`*.send`, `*.set`, `*.append`) attaches the label by suffix.
let response_like_caps = Cap::HTML_ESCAPE | Cap::OPEN_REDIRECT | Cap::HEADER_INJECTION;
if sink_caps.intersects(response_like_caps) {
match kind {
TypeKind::HttpResponse => {} // compatible
TypeKind::Unknown | TypeKind::Object => {} // could be response
_ => {
remove |= Cap::HTML_ESCAPE;
remove |= sink_caps & response_like_caps;
}
}
}
// LDAP_INJECTION strictly requires a directory-service receiver.
// Non-LdapClient receivers carrying the cap by accident (e.g. a
// generic `*.search` suffix matcher firing on a Vec/HashMap) get the
// bit stripped. Unknown/Object stay untouched so type-fact gaps
// don't silently drop real sinks.
if sink_caps.intersects(Cap::LDAP_INJECTION) {
match kind {
TypeKind::LdapClient => {} // compatible
TypeKind::Unknown | TypeKind::Object => {} // could be ldap
_ => {
remove |= Cap::LDAP_INJECTION;
}
}
}
@ -9364,7 +9613,7 @@ fn resolve_callee_full(
}
// 0.5) Cross-file SSA summaries (GlobalSummaries.ssa_by_key) with
// optional Phase-6 hierarchy fan-out.
// optional class-hierarchy fan-out.
//
// When the call has an authoritative receiver type AND
// `GlobalSummaries::install_hierarchy` has been called AND the
@ -9468,7 +9717,7 @@ fn resolve_callee_full(
}
}
// 2) Global same-language (FuncSummary path) with Phase-6 hierarchy
// 2) Global same-language (FuncSummary path) with class-hierarchy
// fan-out. Same semantics as step 0.5 but on coarse FuncSummary
// entries, the SSA path missed because no implementer had an SSA
// summary, so we widen the FuncSummary lookup symmetrically.

View file

@ -246,6 +246,8 @@ pub fn extract_ssa_func_summary_full(
receiver_seed: None,
const_values: None,
type_facts: local_type_facts_ref,
xml_parser_config: None,
xpath_config: None,
ssa_summaries,
extra_labels: None,
base_aliases: None,
@ -792,6 +794,8 @@ pub fn extract_ssa_func_summary_full(
receiver_seed: None,
const_values: None,
type_facts: local_type_facts_ref,
xml_parser_config: None,
xpath_config: None,
ssa_summaries,
extra_labels: None,
base_aliases: None,

View file

@ -93,6 +93,8 @@ mod cross_file_tests {
type_facts: crate::ssa::type_facts::TypeFactResult {
facts: std::collections::HashMap::new(),
},
xml_parser_config: crate::ssa::xml_config::XmlParserConfigResult::default(),
xpath_config: crate::ssa::xpath_config::XPathConfigResult::default(),
alias_result: crate::ssa::alias::BaseAliasResult::empty(),
points_to: crate::ssa::heap::PointsToResult::empty(),
module_aliases: std::collections::HashMap::new(),
@ -251,7 +253,7 @@ mod inline_cache_epoch_tests {
ArgTaintSig(SmallVec::new())
}
fn shape(caps_bits: u16) -> CachedInlineShape {
fn shape(caps_bits: u32) -> CachedInlineShape {
CachedInlineShape(Some(ReturnShape {
caps: Cap::from_bits_retain(caps_bits),
internal_origins: SmallVec::new(),
@ -448,7 +450,7 @@ mod binding_key_tests {
// ── seed_lookup ────────────────────────────────────────────────────
fn taint(caps: u16) -> VarTaint {
fn taint(caps: u32) -> VarTaint {
VarTaint {
caps: Cap::from_bits_truncate(caps),
origins: smallvec![],
@ -989,6 +991,8 @@ mod goto_succ_propagation_tests {
receiver_seed: None,
const_values: None,
type_facts: None,
xml_parser_config: None,
xpath_config: None,
ssa_summaries: None,
extra_labels: None,
base_aliases: None,
@ -1079,6 +1083,8 @@ mod goto_succ_propagation_tests {
receiver_seed: None,
const_values: None,
type_facts: None,
xml_parser_config: None,
xpath_config: None,
ssa_summaries: None,
extra_labels: None,
base_aliases: None,
@ -1516,10 +1522,10 @@ mod receiver_candidates_field_proj_tests {
#[test]
fn field_proj_receiver_walks_to_typed_root_in_go() {
// Go is not Rust, so pre-Phase-4 the candidate walk would have
// returned ONLY the immediate receiver (v2 = FieldProj). With
// We walk through FieldProj.receiver to recover v0 (the
// typed root `c`).
// Go is not Rust, so before the FieldProj walk fix the candidate
// walk would have returned ONLY the immediate receiver
// (v2 = FieldProj). We now walk through FieldProj.receiver to
// recover v0 (the typed root `c`).
let body = body_with_field_proj_chain();
let cands =
super::super::receiver_candidates_for_type_lookup(SsaValue(2), Some(&body), Lang::Go);
@ -1709,7 +1715,7 @@ mod fanout_merge_tests {
];
let m = merge_resolved_summaries_fanout(a, b);
let mut sorted: Vec<(usize, u16)> = m
let mut sorted: Vec<(usize, u32)> = m
.param_to_sink
.iter()
.map(|(i, c)| (*i, c.bits()))
@ -2032,6 +2038,8 @@ mod field_write_tests {
receiver_seed: None,
const_values: None,
type_facts: None,
xml_parser_config: None,
xpath_config: None,
ssa_summaries: None,
extra_labels: None,
base_aliases: None,
@ -2114,6 +2122,8 @@ mod field_write_tests {
receiver_seed: None,
const_values: None,
type_facts: None,
xml_parser_config: None,
xpath_config: None,
ssa_summaries: None,
extra_labels: None,
base_aliases: None,
@ -2180,6 +2190,8 @@ mod field_write_tests {
receiver_seed: None,
const_values: None,
type_facts: None,
xml_parser_config: None,
xpath_config: None,
ssa_summaries: None,
extra_labels: None,
base_aliases: None,
@ -2324,6 +2336,8 @@ mod field_write_tests {
receiver_seed: None,
const_values: None,
type_facts: None,
xml_parser_config: None,
xpath_config: None,
ssa_summaries: None,
extra_labels: None,
base_aliases: None,
@ -2420,6 +2434,8 @@ mod container_elem_tests {
receiver_seed: None,
const_values: None,
type_facts: None,
xml_parser_config: None,
xpath_config: None,
ssa_summaries: None,
extra_labels: None,
base_aliases: None,
@ -2697,6 +2713,8 @@ mod container_elem_tests {
receiver_seed: None,
const_values: None,
type_facts: None,
xml_parser_config: None,
xpath_config: None,
ssa_summaries: None,
extra_labels: None,
base_aliases: None,
@ -2833,6 +2851,8 @@ mod container_elem_tests {
receiver_seed: None,
const_values: None,
type_facts: None,
xml_parser_config: None,
xpath_config: None,
ssa_summaries: None,
extra_labels: None,
base_aliases: None,
@ -3387,6 +3407,8 @@ mod field_taint_origin_cap_tests {
receiver_seed: None,
const_values: None,
type_facts: None,
xml_parser_config: None,
xpath_config: None,
ssa_summaries: None,
extra_labels: None,
base_aliases: None,
@ -3673,6 +3695,8 @@ mod pointer_lattice_worklist_tests {
receiver_seed: None,
const_values: None,
type_facts: None,
xml_parser_config: None,
xpath_config: None,
ssa_summaries: None,
extra_labels: None,
base_aliases: None,