nyx/src/chain/score.rs
2026-06-05 10:16:30 -05:00

197 lines
7 KiB
Rust

//! Phase 25 — scoring for composed exploit chains.
//!
//! `score(path) = sum(impact) * product(feasibility)`
//!
//! The impact term is the sum of per-member [`ImpactCategory`] weights
//! (each member contributes the weight of the *standalone* category its
//! primary cap maps to, or `0` when the cap has no standalone impact —
//! the cap still contributes adjacency to the final implied impact via
//! the composer). The feasibility term is the product of every
//! member's [`Feasibility::score`].
//!
//! # Threshold
//!
//! [`min_score_default`] is the in-code fallback when `[chain] min_score`
//! is unset in `nyx.toml`. Path search drops any composed chain whose
//! score is strictly below the configured threshold.
use crate::chain::edges::ChainEdge;
use crate::chain::feasibility::Feasibility;
use crate::chain::impact::ImpactCategory;
use serde::{Deserialize, Serialize};
/// Per-impact-category numeric weight contributed to the additive
/// impact term. The relative ordering matches the design doc's
/// criticality ranking; absolute values are kept simple integers so
/// the resulting `score` stays human-comparable.
///
/// `BrowserToLocalRce` is treated as marginally higher than `Rce`
/// because the chain composing it (`HEADER_INJECTION + CODE_EXEC` with
/// an unauthenticated entry-point) folds an extra surface property and
/// is therefore strictly more specific.
pub const fn category_weight(c: ImpactCategory) -> f64 {
match c {
ImpactCategory::BrowserToLocalRce => 110.0,
ImpactCategory::Rce => 100.0,
ImpactCategory::SessionHijack => 80.0,
ImpactCategory::InternalNetworkAccess => 60.0,
ImpactCategory::InfoDisclosure => 50.0,
}
}
/// `f64` cap floor for the multiplicative feasibility term. Even an
/// `Unverified` member contributes a non-zero weight so a 3-step chain
/// with three unverified hops does not score `0`.
fn feasibility_factor(f: Feasibility) -> f64 {
match f {
Feasibility::Confirmed => 1.0,
Feasibility::InconclusiveHighConf => 0.5,
Feasibility::Unverified => 0.1,
}
}
/// Compute the chain score for a path.
///
/// `member_impacts` carries the standalone impact category for each
/// member that has one (omit the entry when the member's primary cap
/// has no standalone rule — adjacency still contributes via the
/// composer's `implied_impact`). `implied_impact` is the final
/// composed category; it always contributes its weight even when no
/// individual member would on its own (e.g. the `OPEN_REDIRECT +
/// UNAUTHORIZED_ID → SessionHijack` rule).
pub fn score_path(
member_impacts: &[ImpactCategory],
implied_impact: ImpactCategory,
members: &[ChainEdge],
) -> f64 {
let mut impact_sum: f64 = member_impacts.iter().copied().map(category_weight).sum();
impact_sum += category_weight(implied_impact);
let feasibility_product: f64 = members
.iter()
.map(|e| feasibility_factor(e.feasibility))
.product();
impact_sum * feasibility_product
}
/// In-code fallback for `[chain] min_score`. Set so a single
/// `Unverified` `InfoDisclosure` finding (score = 50 * 0.1 = 5) lands
/// below threshold while a two-member chain (Rce + Unverified, ~10)
/// or a Confirmed single-cap chain (>=100) clears it.
pub const fn min_score_default() -> f64 {
9.5
}
/// `[chain]` section of `nyx.toml`. Persisted via
/// [`crate::utils::config::ChainConfig`].
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct ChainScoreConfig {
/// Path-search threshold. Chains below this score are dropped.
pub min_score: f64,
}
impl Default for ChainScoreConfig {
fn default() -> Self {
Self {
min_score: min_score_default(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::chain::edges::{ChainEdge, FindingRef};
use crate::chain::feasibility::Feasibility;
use crate::chain::impact::ImpactCategory;
use crate::labels::Cap;
use crate::surface::SourceLocation;
fn edge(feas: Feasibility) -> ChainEdge {
ChainEdge {
finding: FindingRef {
finding_id: "f".into(),
stable_hash: 0,
location: SourceLocation::new("a.py", 1, 1),
rule_id: "r".into(),
cap_bits: Cap::CODE_EXEC.bits(),
},
primary_cap: Cap::CODE_EXEC,
reach: crate::chain::edges::Reach::Unreachable,
feasibility: feas,
}
}
#[test]
fn single_confirmed_rce_clears_default_threshold() {
let s = score_path(
&[ImpactCategory::Rce],
ImpactCategory::Rce,
&[edge(Feasibility::Confirmed)],
);
// 100 (member) + 100 (implied) = 200 * 1.0 = 200
assert!(s > min_score_default());
assert!((s - 200.0).abs() < f64::EPSILON);
}
#[test]
fn unverified_single_member_below_threshold() {
// 50 + 50 = 100 * 0.1 = 10 — just over threshold; flip impact
// to InfoDisclosure with one extra hop to push it under.
let s = score_path(
&[ImpactCategory::InfoDisclosure],
ImpactCategory::InfoDisclosure,
&[edge(Feasibility::Unverified)],
);
assert!(s > min_score_default()); // 50+50=100 * 0.1 = 10
// But two unverified hops gates the chain:
let s2 = score_path(
&[ImpactCategory::InfoDisclosure],
ImpactCategory::InfoDisclosure,
&[edge(Feasibility::Unverified), edge(Feasibility::Unverified)],
);
assert!(s2 < min_score_default()); // 100 * 0.01 = 1.0
}
#[test]
fn feasibility_dampens_score() {
let confirmed = score_path(
&[ImpactCategory::Rce],
ImpactCategory::Rce,
&[edge(Feasibility::Confirmed), edge(Feasibility::Confirmed)],
);
let inconclusive = score_path(
&[ImpactCategory::Rce],
ImpactCategory::Rce,
&[
edge(Feasibility::Confirmed),
edge(Feasibility::InconclusiveHighConf),
],
);
let unverified = score_path(
&[ImpactCategory::Rce],
ImpactCategory::Rce,
&[edge(Feasibility::Confirmed), edge(Feasibility::Unverified)],
);
assert!(confirmed > inconclusive);
assert!(inconclusive > unverified);
}
#[test]
fn category_weights_strictly_ordered() {
assert!(
category_weight(ImpactCategory::BrowserToLocalRce)
> category_weight(ImpactCategory::Rce)
);
assert!(
category_weight(ImpactCategory::Rce) > category_weight(ImpactCategory::SessionHijack)
);
assert!(
category_weight(ImpactCategory::SessionHijack)
> category_weight(ImpactCategory::InternalNetworkAccess)
);
assert!(
category_weight(ImpactCategory::InternalNetworkAccess)
> category_weight(ImpactCategory::InfoDisclosure)
);
}
}