use super::capability_scoring::graph_risk_context;
use super::{
Finding, RecommendedAction, Severity, ThreatCategory, MAX_RISK_SCORE, RISK_THRESHOLD_APPROVAL,
RISK_THRESHOLD_BLOCK,
};
use crate::artifact_graph::ArtifactGraph;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FindingSummary {
pub total_findings: usize,
pub by_severity: SeverityCounts,
pub by_category: Vec<(ThreatCategory, usize)>,
pub risk_score: u32,
pub recommended_action: RecommendedAction,
pub score_breakdown: Vec<RiskFactor>,
pub action_triggers: Vec<ActionTrigger>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SeverityCounts {
pub low: usize,
pub medium: usize,
pub high: usize,
pub critical: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RiskFactor {
pub factor: String,
pub contribution: u32,
pub rationale: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ActionTrigger {
pub action: RecommendedAction,
pub factor: String,
pub rationale: String,
}
impl FindingSummary {
#[must_use]
pub fn from_findings(findings: &[Finding]) -> Self {
Self::from_findings_and_graph(findings, &ArtifactGraph::new())
}
#[must_use]
pub fn from_findings_and_graph(findings: &[Finding], artifact_graph: &ArtifactGraph) -> Self {
let (by_severity, category_map, mut factor_map, findings_score) =
aggregate_findings(findings);
let (graph_score, graph_action, graph_factors, mut action_triggers) =
graph_risk_context(artifact_graph);
for factor in graph_factors {
factor_map
.entry(factor.factor.clone())
.and_modify(|existing| existing.contribution += factor.contribution)
.or_insert(factor);
}
let total_score = findings_score + graph_score as f32;
let risk_score = normalize_score(total_score);
let recommended_action = select_recommended_action(risk_score, findings, graph_action);
let mut by_category: Vec<_> = category_map.into_iter().collect();
by_category.sort_by_key(|(category, _)| *category);
let mut score_breakdown: Vec<_> = factor_map.into_values().collect();
score_breakdown.sort_by(|left, right| {
right
.contribution
.cmp(&left.contribution)
.then_with(|| left.factor.cmp(&right.factor))
});
action_triggers.sort_by(|left, right| {
right
.action
.cmp(&left.action)
.then_with(|| left.factor.cmp(&right.factor))
});
Self {
total_findings: findings.len(),
by_severity,
by_category,
risk_score,
recommended_action,
score_breakdown,
action_triggers,
}
}
#[must_use]
pub fn with_risk_adjustment(&self, delta: i32) -> Self {
let mut adjusted = self.clone();
let new_score = i64::from(self.risk_score)
.saturating_add(i64::from(delta))
.clamp(0, i64::from(MAX_RISK_SCORE)) as u32;
adjusted.risk_score = new_score;
let new_action = downgraded_action_for_score(self.recommended_action, new_score);
adjusted.recommended_action = new_action;
adjusted.action_triggers.retain(|t| t.action <= new_action);
adjusted
}
}
fn downgraded_action_for_score(original: RecommendedAction, score: u32) -> RecommendedAction {
let score_tier = score_to_action(score);
score_tier.min(original)
}
type FactorMap = std::collections::HashMap<String, RiskFactor>;
type CategoryMap = std::collections::HashMap<ThreatCategory, usize>;
fn aggregate_findings(findings: &[Finding]) -> (SeverityCounts, CategoryMap, FactorMap, f32) {
let mut by_severity = SeverityCounts::default();
let mut category_map = CategoryMap::new();
let mut factor_map = FactorMap::new();
let mut total_score: f32 = 0.0;
for finding in findings {
match finding.severity {
Severity::Low => by_severity.low += 1,
Severity::Medium => by_severity.medium += 1,
Severity::High => by_severity.high += 1,
Severity::Critical => by_severity.critical += 1,
}
*category_map.entry(finding.category).or_insert(0) += 1;
total_score += finding.weighted_score();
accumulate_evidence_factor(&mut factor_map, finding);
accumulate_artifact_factor(&mut factor_map, finding);
}
(by_severity, category_map, factor_map, total_score)
}
fn select_recommended_action(
risk_score: u32,
findings: &[Finding],
graph_action: RecommendedAction,
) -> RecommendedAction {
let score_based = score_to_action(risk_score);
let finding_based = findings.iter().fold(RecommendedAction::Log, |acc, f| {
acc.max(f.recommended_action)
});
score_based.max(finding_based).max(graph_action)
}
fn accumulate_evidence_factor(factors: &mut FactorMap, finding: &Finding) {
let key = format!("evidence:{}", finding.evidence_kind);
let weight = finding.evidence_kind.weight();
factors
.entry(key.clone())
.and_modify(|f| f.contribution += weight)
.or_insert(RiskFactor {
factor: key,
contribution: weight,
rationale: finding.evidence_kind.description().to_string(),
});
}
fn accumulate_artifact_factor(factors: &mut FactorMap, finding: &Finding) {
let key = format!("artifact:{}", finding.artifact_kind);
factors
.entry(key.clone())
.and_modify(|f| f.contribution += 1)
.or_insert(RiskFactor {
factor: key,
contribution: 1,
rationale: "Risk observed in this artifact class".to_string(),
});
}
fn normalize_score(total: f32) -> u32 {
if total.is_finite() {
return total.clamp(0.0, MAX_RISK_SCORE as f32).round() as u32;
}
debug_assert!(
total.is_finite(),
"normalize_score: total must be finite (sanitized in builder); got {total}",
);
tracing::warn!(
total = ?total,
"normalize_score received non-finite total; defaulting to MAX_RISK_SCORE",
);
MAX_RISK_SCORE
}
fn score_to_action(risk_score: u32) -> RecommendedAction {
if risk_score >= RISK_THRESHOLD_BLOCK {
RecommendedAction::Block
} else if risk_score >= RISK_THRESHOLD_APPROVAL {
RecommendedAction::RequireApproval
} else {
RecommendedAction::Log
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn score_breakdown_and_action_triggers_use_factor_tie_breaker() {
let mut breakdown = [
RiskFactor {
factor: "z_factor".into(),
contribution: 10,
rationale: "x".into(),
},
RiskFactor {
factor: "a_factor".into(),
contribution: 10,
rationale: "x".into(),
},
];
breakdown.sort_by(|left, right| {
right
.contribution
.cmp(&left.contribution)
.then_with(|| left.factor.cmp(&right.factor))
});
assert_eq!(breakdown[0].factor, "a_factor");
assert_eq!(breakdown[1].factor, "z_factor");
let mut triggers = [
ActionTrigger {
action: RecommendedAction::Block,
factor: "z_trig".into(),
rationale: "x".into(),
},
ActionTrigger {
action: RecommendedAction::Block,
factor: "a_trig".into(),
rationale: "x".into(),
},
];
triggers.sort_by(|left, right| {
right
.action
.cmp(&left.action)
.then_with(|| left.factor.cmp(&right.factor))
});
assert_eq!(triggers[0].factor, "a_trig");
assert_eq!(triggers[1].factor, "z_trig");
}
fn summary_with(score: u32, action: RecommendedAction) -> FindingSummary {
FindingSummary {
total_findings: 0,
by_severity: SeverityCounts::default(),
by_category: Vec::new(),
risk_score: score,
recommended_action: action,
score_breakdown: Vec::new(),
action_triggers: Vec::new(),
}
}
#[test]
fn with_risk_adjustment_downgrades_action_when_score_crosses_threshold() {
let s = summary_with(55, RecommendedAction::Block);
let adjusted = s.with_risk_adjustment(-20); assert_eq!(adjusted.risk_score, 35);
assert_eq!(
adjusted.recommended_action,
RecommendedAction::RequireApproval,
"score below RISK_THRESHOLD_BLOCK must drop Block down to RequireApproval"
);
}
#[test]
fn with_risk_adjustment_drops_to_log_below_approval_threshold() {
let s = summary_with(25, RecommendedAction::RequireApproval);
let adjusted = s.with_risk_adjustment(-20); assert_eq!(adjusted.risk_score, 5);
assert_eq!(adjusted.recommended_action, RecommendedAction::Log);
}
#[test]
fn with_risk_adjustment_never_upgrades_action() {
let s = summary_with(10, RecommendedAction::Log);
let adjusted = s.with_risk_adjustment(100); assert_eq!(adjusted.risk_score, 100);
assert_eq!(
adjusted.recommended_action,
RecommendedAction::Log,
"calibration must not upgrade past the original action"
);
}
#[test]
fn with_risk_adjustment_clamps_score() {
let s = summary_with(5, RecommendedAction::Log);
let adjusted = s.with_risk_adjustment(-100);
assert_eq!(adjusted.risk_score, 0);
assert_eq!(adjusted.recommended_action, RecommendedAction::Log);
}
#[test]
fn normalize_score_finite_input_clamps_and_rounds() {
assert_eq!(normalize_score(0.0), 0);
assert_eq!(normalize_score(50.4), 50);
assert_eq!(normalize_score(50.6), 51);
assert_eq!(normalize_score(100.0), 100);
assert_eq!(normalize_score(150.0), 100);
assert_eq!(normalize_score(-10.0), 0);
}
#[test]
#[cfg(not(debug_assertions))]
fn normalize_score_non_finite_input_falls_back_to_block() {
assert_eq!(normalize_score(f32::NAN), 100);
assert_eq!(normalize_score(f32::INFINITY), 100);
assert_eq!(normalize_score(f32::NEG_INFINITY), 100);
}
}