mirror of
https://github.com/elicpeter/nyx.git
synced 2026-06-30 20:39:39 +02:00
Dynamic (#77)
This commit is contained in:
parent
55247b7fcd
commit
991c84a1eb
1464 changed files with 225448 additions and 1985 deletions
197
src/chain/score.rs
Normal file
197
src/chain/score.rs
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
//! 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)
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue