mirror of
https://github.com/elicpeter/nyx.git
synced 2026-07-03 20:41:00 +02:00
Dynamic (#77)
This commit is contained in:
parent
55247b7fcd
commit
991c84a1eb
1464 changed files with 225448 additions and 1985 deletions
333
src/chain/impact.rs
Normal file
333
src/chain/impact.rs
Normal file
|
|
@ -0,0 +1,333 @@
|
|||
//! Phase 24 — impact lattice for the exploit-chain composer.
|
||||
//!
|
||||
//! Each [`ImpactRule`] is a `(source_cap, adjacent_cap, result)` triple
|
||||
//! drawn from the design doc's lattice:
|
||||
//!
|
||||
//! | Rule | Result |
|
||||
//! |-------------------------------|-------------------------|
|
||||
//! | `CODE_EXEC` | `Rce` |
|
||||
//! | `DESERIALIZE` | `Rce` |
|
||||
//! | `SSRF` | `InternalNetworkAccess` |
|
||||
//! | `OPEN_REDIRECT + UNAUTHORIZED_ID` | `SessionHijack` |
|
||||
//! | `HEADER_INJECTION + CODE_EXEC` | `BrowserToLocalRce` |
|
||||
//! | `FILE_IO + DATA_EXFIL` | `InfoDisclosure` |
|
||||
//!
|
||||
//! The doc spells some lattice nodes with surface-level handles
|
||||
//! (`UserSession`, `Cors`, `NoAuth`, `LocalListener`,
|
||||
//! `SensitiveFileIo`, `PathTraversal`). Those nodes do not map 1:1
|
||||
//! onto [`Cap`] bits, so the table above uses the closest [`Cap`]
|
||||
//! approximations:
|
||||
//!
|
||||
//! - `UserSession` → [`Cap::UNAUTHORIZED_ID`] (request-bound caller
|
||||
//! identifier carrier)
|
||||
//! - `Cors + NoAuth` → [`Cap::HEADER_INJECTION`] (the CORS-relaxing
|
||||
//! header is the structural marker; the no-auth side is folded into
|
||||
//! Phase 25's surface-property check on [`crate::surface::EntryPoint::auth_required`])
|
||||
//! - `LocalListener` → no cap; folded into Phase 25's surface check
|
||||
//! ([`crate::surface::DataStoreKind::Sql`] /
|
||||
//! [`crate::surface::ExternalServiceKind::HttpApi`] etc.)
|
||||
//! - `SensitiveFileIo` → [`Cap::DATA_EXFIL`] (egress-of-sensitive-data
|
||||
//! carrier)
|
||||
//! - `PathTraversal` → [`Cap::FILE_IO`]
|
||||
//!
|
||||
//! # Exhaustiveness
|
||||
//!
|
||||
//! Pattern-matching exhaustively on [`Cap`] is impossible — it is a
|
||||
//! `bitflags!` struct over `u32`, not a closed enum. This module
|
||||
//! adopts the [`crate::dynamic::corpus`] pattern instead: every Cap
|
||||
//! bit belongs to exactly one of [`IMPACT_LATTICE_COVERED`] or
|
||||
//! [`IMPACT_LATTICE_UNCOVERED`], with a const assertion that the
|
||||
//! union equals [`Cap::all`]. Adding a new `Cap` bit without
|
||||
//! updating one of those constants fails to compile.
|
||||
|
||||
use crate::labels::Cap;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Impact category produced by a successful chain composition.
|
||||
///
|
||||
/// Phase 24 enumerates the categories the doc's lattice produces.
|
||||
/// Phase 25's scoring pass attaches a severity to each category and
|
||||
/// folds them into the final [`crate::chain::ChainGraph`] output.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ImpactCategory {
|
||||
/// Remote code execution.
|
||||
Rce,
|
||||
/// Browser-mediated path to local code execution (e.g. permissive
|
||||
/// CORS plus an unauthenticated endpoint that hands off to a
|
||||
/// `CODE_EXEC` sink).
|
||||
BrowserToLocalRce,
|
||||
/// Session-token hijack via an attacker-controlled redirect that
|
||||
/// keeps the user's auth identity in the request flow.
|
||||
SessionHijack,
|
||||
/// SSRF that lands on an internal/local listener.
|
||||
InternalNetworkAccess,
|
||||
/// Sensitive data egress through a path-traversal-like primitive.
|
||||
InfoDisclosure,
|
||||
}
|
||||
|
||||
/// One rule in the impact lattice.
|
||||
///
|
||||
/// `adjacent_cap` is `None` for self-sufficient rules
|
||||
/// (`CODE_EXEC → Rce`, `DESERIALIZE → Rce`, `SSRF → InternalNetworkAccess`)
|
||||
/// and `Some(cap)` for rules that need a second co-located finding
|
||||
/// (`OPEN_REDIRECT + UNAUTHORIZED_ID → SessionHijack`, etc.).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ImpactRule {
|
||||
pub source_cap: Cap,
|
||||
pub adjacent_cap: Option<Cap>,
|
||||
pub result: ImpactCategory,
|
||||
}
|
||||
|
||||
/// The default impact lattice from the design doc.
|
||||
///
|
||||
/// Order matters for [`lookup_impact`]: more specific rules
|
||||
/// (`adjacent_cap.is_some()`) appear before the broader fallbacks so a
|
||||
/// `CODE_EXEC + ...` finding pair is classified as
|
||||
/// `BrowserToLocalRce` before the standalone `CODE_EXEC → Rce`
|
||||
/// fallback fires.
|
||||
pub static IMPACT_LATTICE: &[ImpactRule] = &[
|
||||
// ── 2-cap rules (most specific first) ─────────────────────────
|
||||
ImpactRule {
|
||||
source_cap: Cap::OPEN_REDIRECT,
|
||||
adjacent_cap: Some(Cap::UNAUTHORIZED_ID),
|
||||
result: ImpactCategory::SessionHijack,
|
||||
},
|
||||
ImpactRule {
|
||||
source_cap: Cap::HEADER_INJECTION,
|
||||
adjacent_cap: Some(Cap::CODE_EXEC),
|
||||
result: ImpactCategory::BrowserToLocalRce,
|
||||
},
|
||||
ImpactRule {
|
||||
source_cap: Cap::FILE_IO,
|
||||
adjacent_cap: Some(Cap::DATA_EXFIL),
|
||||
result: ImpactCategory::InfoDisclosure,
|
||||
},
|
||||
// ── 1-cap rules ───────────────────────────────────────────────
|
||||
ImpactRule {
|
||||
source_cap: Cap::CODE_EXEC,
|
||||
adjacent_cap: None,
|
||||
result: ImpactCategory::Rce,
|
||||
},
|
||||
ImpactRule {
|
||||
source_cap: Cap::DESERIALIZE,
|
||||
adjacent_cap: None,
|
||||
result: ImpactCategory::Rce,
|
||||
},
|
||||
ImpactRule {
|
||||
source_cap: Cap::SSRF,
|
||||
adjacent_cap: None,
|
||||
result: ImpactCategory::InternalNetworkAccess,
|
||||
},
|
||||
];
|
||||
|
||||
/// Caps that participate in at least one impact rule (either as
|
||||
/// `source_cap` or as `adjacent_cap`). Update when adding a rule.
|
||||
pub const IMPACT_LATTICE_COVERED: u32 = Cap::CODE_EXEC.bits()
|
||||
| Cap::DESERIALIZE.bits()
|
||||
| Cap::SSRF.bits()
|
||||
| Cap::OPEN_REDIRECT.bits()
|
||||
| Cap::UNAUTHORIZED_ID.bits()
|
||||
| Cap::HEADER_INJECTION.bits()
|
||||
| Cap::FILE_IO.bits()
|
||||
| Cap::DATA_EXFIL.bits();
|
||||
|
||||
/// Caps that do not participate in any impact rule today. Adding a
|
||||
/// rule that consumes one of these caps requires moving it into
|
||||
/// [`IMPACT_LATTICE_COVERED`] above.
|
||||
pub const IMPACT_LATTICE_UNCOVERED: u32 = Cap::ENV_VAR.bits()
|
||||
| Cap::HTML_ESCAPE.bits()
|
||||
| Cap::SHELL_ESCAPE.bits()
|
||||
| Cap::URL_ENCODE.bits()
|
||||
| Cap::JSON_PARSE.bits()
|
||||
| Cap::FMT_STRING.bits()
|
||||
| Cap::SQL_QUERY.bits()
|
||||
| Cap::CRYPTO.bits()
|
||||
| Cap::LDAP_INJECTION.bits()
|
||||
| Cap::XPATH_INJECTION.bits()
|
||||
| Cap::SSTI.bits()
|
||||
| Cap::XXE.bits()
|
||||
| Cap::PROTOTYPE_POLLUTION.bits();
|
||||
|
||||
const _: () = assert!(
|
||||
IMPACT_LATTICE_COVERED | IMPACT_LATTICE_UNCOVERED == Cap::all().bits(),
|
||||
"Cap bit missing from impact lattice coverage; \
|
||||
add to IMPACT_LATTICE_COVERED or IMPACT_LATTICE_UNCOVERED and decide \
|
||||
whether it should participate in a chain rule",
|
||||
);
|
||||
|
||||
const _: () = assert!(
|
||||
IMPACT_LATTICE_COVERED & IMPACT_LATTICE_UNCOVERED == 0,
|
||||
"Cap bit appears in both IMPACT_LATTICE_COVERED and IMPACT_LATTICE_UNCOVERED",
|
||||
);
|
||||
|
||||
/// Union of every cap bit referenced by an [`IMPACT_LATTICE`] rule, as
|
||||
/// `source_cap` or `adjacent_cap`. Computed at compile time.
|
||||
#[allow(dead_code)] // Called from a const assertion; MSRV lints may miss const-eval uses.
|
||||
const fn rule_coverage_bits() -> u32 {
|
||||
let mut acc: u32 = 0;
|
||||
let mut i = 0;
|
||||
while i < IMPACT_LATTICE.len() {
|
||||
let rule = IMPACT_LATTICE[i];
|
||||
acc |= rule.source_cap.bits();
|
||||
acc |= match rule.adjacent_cap {
|
||||
Some(a) => a.bits(),
|
||||
None => 0,
|
||||
};
|
||||
i += 1;
|
||||
}
|
||||
acc
|
||||
}
|
||||
|
||||
const _: () = assert!(
|
||||
rule_coverage_bits() == IMPACT_LATTICE_COVERED,
|
||||
"IMPACT_LATTICE_COVERED claims a cap bit that no IMPACT_LATTICE rule references; \
|
||||
drop it from IMPACT_LATTICE_COVERED or add a rule that consumes it",
|
||||
);
|
||||
|
||||
/// Precomputed standalone-rule table indexed by `Cap` bit position.
|
||||
///
|
||||
/// Built once at compile time from [`IMPACT_LATTICE`]. `Cap` is a
|
||||
/// `bitflags!` u32, so each cap occupies one bit position 0..32; the
|
||||
/// table stores the standalone [`ImpactCategory`] (if any) for that
|
||||
/// position. [`lookup_impact`] uses this to short-circuit its
|
||||
/// second-pass and third-pass walks in O(1).
|
||||
static STANDALONE_BY_BIT: [Option<ImpactCategory>; 32] = build_standalone_table();
|
||||
|
||||
const fn build_standalone_table() -> [Option<ImpactCategory>; 32] {
|
||||
let mut table = [None; 32];
|
||||
let mut i = 0;
|
||||
while i < IMPACT_LATTICE.len() {
|
||||
let rule = IMPACT_LATTICE[i];
|
||||
if rule.adjacent_cap.is_none() {
|
||||
let bit = rule.source_cap.bits().trailing_zeros() as usize;
|
||||
table[bit] = Some(rule.result);
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
table
|
||||
}
|
||||
|
||||
fn standalone_lookup(cap: Cap) -> Option<ImpactCategory> {
|
||||
let bits = cap.bits();
|
||||
if bits == 0 || bits.count_ones() != 1 {
|
||||
return None;
|
||||
}
|
||||
STANDALONE_BY_BIT[bits.trailing_zeros() as usize]
|
||||
}
|
||||
|
||||
/// Look up an [`ImpactCategory`] for a (source, adjacent) cap pair.
|
||||
///
|
||||
/// `adjacent` is `None` when the caller has not yet found a partner
|
||||
/// finding. Returns the most-specific matching rule.
|
||||
///
|
||||
/// Phase 25's path search calls this once per candidate path with the
|
||||
/// path's primary and secondary caps; multiple cap matches choose the
|
||||
/// first rule in [`IMPACT_LATTICE`] order (specific before fallback).
|
||||
///
|
||||
/// The standalone-rule walks (second + third pass) are O(1) via
|
||||
/// `STANDALONE_BY_BIT`. The two-cap walk (first pass) stays linear
|
||||
/// because the 2-cap subset is small (today: three rules); promote
|
||||
/// to a sorted-pair binary search if the lattice grows past ~16
|
||||
/// pair-rules.
|
||||
pub fn lookup_impact(source: Cap, adjacent: Option<Cap>) -> Option<ImpactCategory> {
|
||||
// First pass: exact source + matching adjacency (or both ways).
|
||||
if let Some(adj) = adjacent {
|
||||
for rule in IMPACT_LATTICE {
|
||||
if let Some(rule_adj) = rule.adjacent_cap {
|
||||
let direct = rule.source_cap == source && rule_adj == adj;
|
||||
let swapped = rule.source_cap == adj && rule_adj == source;
|
||||
if direct || swapped {
|
||||
return Some(rule.result);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Second pass: standalone rule on source_cap (O(1) table lookup).
|
||||
if let Some(cat) = standalone_lookup(source) {
|
||||
return Some(cat);
|
||||
}
|
||||
// Third pass: if `adjacent` is given but the pair didn't hit,
|
||||
// try the standalone rule on adjacent_cap so a CODE_EXEC + UNRELATED
|
||||
// pair still reaches `Rce`.
|
||||
if let Some(adj) = adjacent
|
||||
&& let Some(cat) = standalone_lookup(adj)
|
||||
{
|
||||
return Some(cat);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn cmdi_alone_maps_to_rce() {
|
||||
assert_eq!(
|
||||
lookup_impact(Cap::CODE_EXEC, None),
|
||||
Some(ImpactCategory::Rce)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_alone_maps_to_rce() {
|
||||
assert_eq!(
|
||||
lookup_impact(Cap::DESERIALIZE, None),
|
||||
Some(ImpactCategory::Rce)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ssrf_alone_maps_to_internal_network_access() {
|
||||
assert_eq!(
|
||||
lookup_impact(Cap::SSRF, None),
|
||||
Some(ImpactCategory::InternalNetworkAccess)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_redirect_plus_user_session_maps_to_session_hijack() {
|
||||
assert_eq!(
|
||||
lookup_impact(Cap::OPEN_REDIRECT, Some(Cap::UNAUTHORIZED_ID)),
|
||||
Some(ImpactCategory::SessionHijack)
|
||||
);
|
||||
// Argument order should not matter.
|
||||
assert_eq!(
|
||||
lookup_impact(Cap::UNAUTHORIZED_ID, Some(Cap::OPEN_REDIRECT)),
|
||||
Some(ImpactCategory::SessionHijack)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cors_plus_codeexec_maps_to_browser_local_rce() {
|
||||
assert_eq!(
|
||||
lookup_impact(Cap::HEADER_INJECTION, Some(Cap::CODE_EXEC)),
|
||||
Some(ImpactCategory::BrowserToLocalRce)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_traversal_plus_sensitive_io_maps_to_info_disclosure() {
|
||||
assert_eq!(
|
||||
lookup_impact(Cap::FILE_IO, Some(Cap::DATA_EXFIL)),
|
||||
Some(ImpactCategory::InfoDisclosure)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_cap_returns_none() {
|
||||
assert_eq!(lookup_impact(Cap::HTML_ESCAPE, None), None);
|
||||
assert_eq!(lookup_impact(Cap::CRYPTO, None), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pair_with_uncovered_adjacency_falls_through_to_standalone() {
|
||||
// CODE_EXEC + CRYPTO: CRYPTO has no rule, so we fall back to
|
||||
// the standalone CODE_EXEC → Rce rule.
|
||||
assert_eq!(
|
||||
lookup_impact(Cap::CODE_EXEC, Some(Cap::CRYPTO)),
|
||||
Some(ImpactCategory::Rce)
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue