Skip to main content

icebox/core/
safety.rs

1use serde::de::{Deserializer, MapAccess, Visitor};
2use serde::{Deserialize, Serialize};
3use serde_json::Value;
4use std::fmt;
5use std::net::{Ipv4Addr, Ipv6Addr, ToSocketAddrs};
6use std::str::FromStr;
7use std::time::{SystemTime, UNIX_EPOCH};
8
9use crate::core::module::{Capability, Intent, ModuleKind, ModuleResult};
10
11#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
12#[serde(rename_all = "snake_case")]
13pub enum RiskLevel {
14    None = 0,
15    #[default]
16    Low = 1,
17    Medium = 2,
18    High = 3,
19    Critical = 4,
20}
21
22/// Operational governance tiers. Each tier maps to concrete policy behavior:
23/// sandbox containment, a CVSS deny threshold, and explicit-approval gating.
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
25#[serde(rename_all = "snake_case")]
26pub enum Tier {
27    #[default]
28    Fridge,
29    Freezer,
30    DeepFreeze,
31}
32
33impl Tier {
34    pub fn requires_sandbox(&self) -> bool {
35        matches!(self, Tier::Freezer | Tier::DeepFreeze)
36    }
37    pub fn requires_explicit_approval(&self) -> bool {
38        matches!(self, Tier::DeepFreeze)
39    }
40    pub fn cvss_threshold(&self) -> Option<f64> {
41        match self {
42            Tier::Fridge => None,
43            Tier::Freezer => Some(7.0),
44            Tier::DeepFreeze => Some(4.0),
45        }
46    }
47    pub fn as_str(&self) -> &'static str {
48        match self {
49            Tier::Fridge => "fridge",
50            Tier::Freezer => "freezer",
51            Tier::DeepFreeze => "deep_freeze",
52        }
53    }
54}
55
56impl std::fmt::Display for Tier {
57    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
58        f.write_str(self.as_str())
59    }
60}
61
62impl FromStr for Tier {
63    type Err = String;
64    fn from_str(s: &str) -> Result<Self, Self::Err> {
65        match s.to_lowercase().as_str() {
66            "fridge" | "the fridge" => Ok(Tier::Fridge),
67            "freezer" | "the freezer" => Ok(Tier::Freezer),
68            "deep_freeze" | "deepfreeze" | "the deep freezer" => Ok(Tier::DeepFreeze),
69            _ => Err(format!("unknown tier: {s}")),
70        }
71    }
72}
73
74impl RiskLevel {
75    pub fn as_str(&self) -> &'static str {
76        match self {
77            RiskLevel::None => "none",
78            RiskLevel::Low => "low",
79            RiskLevel::Medium => "medium",
80            RiskLevel::High => "high",
81            RiskLevel::Critical => "critical",
82        }
83    }
84
85    pub fn from_kind(kind: ModuleKind) -> RiskLevel {
86        match kind {
87            ModuleKind::Scanner | ModuleKind::Auxiliary | ModuleKind::Analysis => RiskLevel::Low,
88            ModuleKind::Exploit | ModuleKind::Post | ModuleKind::Transform => RiskLevel::Medium,
89            ModuleKind::Payload | ModuleKind::Listener | ModuleKind::Encoder => RiskLevel::High,
90            ModuleKind::Backdoor => RiskLevel::Critical,
91        }
92    }
93}
94
95impl std::str::FromStr for RiskLevel {
96    type Err = String;
97    fn from_str(s: &str) -> Result<Self, Self::Err> {
98        Ok(match s.to_ascii_lowercase().as_str() {
99            "none" => RiskLevel::None,
100            "low" => RiskLevel::Low,
101            "medium" => RiskLevel::Medium,
102            "high" => RiskLevel::High,
103            "critical" => RiskLevel::Critical,
104            other => return Err(format!("unknown risk level: {other}")),
105        })
106    }
107}
108
109#[derive(Debug, Clone, Serialize)]
110pub struct CvssScore {
111    pub cvss_v31: Option<f64>,
112    pub cvss_v40: Option<f64>,
113    pub epss: Option<f64>,
114    pub kev: bool,
115}
116
117impl<'de> Deserialize<'de> for CvssScore {
118    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
119    where
120        D: Deserializer<'de>,
121    {
122        #[derive(Deserialize)]
123        #[serde(field_identifier, rename_all = "snake_case")]
124        enum Field {
125            CvssV31,
126            CvssV40,
127            Epss,
128            Kev,
129        }
130
131        struct CvssVisitor;
132
133        impl<'de> Visitor<'de> for CvssVisitor {
134            type Value = CvssScore;
135
136            fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
137                f.write_str("a CVSS score (number) or an object {cvss_v31, cvss_v40, epss, kev}")
138            }
139
140            fn visit_f64<E: serde::de::Error>(self, v: f64) -> Result<Self::Value, E> {
141                Ok(CvssScore {
142                    cvss_v31: Some(v),
143                    cvss_v40: None,
144                    epss: None,
145                    kev: false,
146                })
147            }
148
149            fn visit_i64<E: serde::de::Error>(self, v: i64) -> Result<Self::Value, E> {
150                Ok(CvssScore {
151                    cvss_v31: Some(v as f64),
152                    cvss_v40: None,
153                    epss: None,
154                    kev: false,
155                })
156            }
157
158            fn visit_u64<E: serde::de::Error>(self, v: u64) -> Result<Self::Value, E> {
159                Ok(CvssScore {
160                    cvss_v31: Some(v as f64),
161                    cvss_v40: None,
162                    epss: None,
163                    kev: false,
164                })
165            }
166
167            fn visit_map<M: MapAccess<'de>>(self, mut map: M) -> Result<Self::Value, M::Error> {
168                let mut cvss_v31 = None;
169                let mut cvss_v40 = None;
170                let mut epss = None;
171                let mut kev = false;
172                while let Some(key) = map.next_key()? {
173                    match key {
174                        Field::CvssV31 => cvss_v31 = map.next_value()?,
175                        Field::CvssV40 => cvss_v40 = map.next_value()?,
176                        Field::Epss => epss = map.next_value()?,
177                        Field::Kev => kev = map.next_value()?,
178                    }
179                }
180                Ok(CvssScore {
181                    cvss_v31,
182                    cvss_v40,
183                    epss,
184                    kev,
185                })
186            }
187        }
188
189        deserializer.deserialize_any(CvssVisitor)
190    }
191}
192
193impl CvssScore {
194    pub fn effective_score(&self) -> f64 {
195        self.cvss_v40.or(self.cvss_v31).unwrap_or(0.0)
196    }
197
198    pub fn severity(&self) -> RiskLevel {
199        let s = self.effective_score();
200        if s >= 9.0 {
201            RiskLevel::Critical
202        } else if s >= 7.0 {
203            RiskLevel::High
204        } else if s >= 4.0 {
205            RiskLevel::Medium
206        } else if s > 0.0 {
207            RiskLevel::Low
208        } else {
209            RiskLevel::None
210        }
211    }
212
213    pub fn weighted_risk(&self) -> f64 {
214        let base = self.effective_score();
215        let epss_boost = self.epss.unwrap_or(0.0) * 2.0;
216        let kev_boost = if self.kev { 3.0 } else { 0.0 };
217        (base + epss_boost + kev_boost).min(10.0)
218    }
219
220    pub fn from_score(score: f64) -> Self {
221        CvssScore {
222            cvss_v31: Some(score),
223            cvss_v40: None,
224            epss: None,
225            kev: false,
226        }
227    }
228
229    pub fn kev(score: f64) -> Self {
230        CvssScore {
231            cvss_v31: Some(score),
232            cvss_v40: None,
233            epss: None,
234            kev: true,
235        }
236    }
237}
238
239#[derive(Debug, Clone, Default, Serialize, Deserialize)]
240pub struct Charter {
241    pub accepted: bool,
242    pub engagement: String,
243    pub rules_of_engagement: Vec<String>,
244}
245
246impl Charter {
247    pub fn accept(engagement: impl Into<String>, roe: Vec<String>) -> Self {
248        Charter {
249            accepted: true,
250            engagement: engagement.into(),
251            rules_of_engagement: roe,
252        }
253    }
254}
255
256#[derive(Debug, Clone, Default, Serialize, Deserialize)]
257pub struct ScopeManager {
258    pub allow: Vec<String>,
259}
260
261impl ScopeManager {
262    pub fn new(allow: Vec<String>) -> Self {
263        ScopeManager { allow }
264    }
265
266    pub fn is_in_scope(&self, target: &str) -> bool {
267        let target = target.trim();
268        let target_ips = resolve_ips(target);
269        for raw in &self.allow {
270            let entry = raw.trim();
271            if entry.is_empty() {
272                continue;
273            }
274            if entry == target {
275                return true;
276            }
277            if let Some(prefix) = entry.strip_suffix('*') {
278                if target.starts_with(prefix) {
279                    return true;
280                }
281            }
282            if entry.contains('/') && ipv4_in_cidr(target, entry) {
283                return true;
284            }
285            for tip in &target_ips {
286                if entry == tip {
287                    return true;
288                }
289                if entry.contains('/') && ipv4_in_cidr(tip, entry) {
290                    return true;
291                }
292            }
293            if !is_literal_or_pattern(entry) {
294                for eip in resolve_ips(entry) {
295                    if eip == target {
296                        return true;
297                    }
298                    for tip in &target_ips {
299                        if eip == *tip {
300                            return true;
301                        }
302                    }
303                }
304            }
305        }
306        false
307    }
308}
309
310fn resolve_ips(host: &str) -> Vec<String> {
311    if host.parse::<Ipv4Addr>().is_ok() || host.parse::<Ipv6Addr>().is_ok() {
312        return vec![host.to_string()];
313    }
314    let mut ips = Vec::new();
315    if let Ok(addrs) = format!("{host}:0").to_socket_addrs() {
316        for a in addrs {
317            ips.push(a.ip().to_string());
318        }
319    }
320    ips
321}
322
323fn is_literal_or_pattern(entry: &str) -> bool {
324    if entry.contains('/') || entry.ends_with('*') {
325        return true;
326    }
327    entry.parse::<Ipv4Addr>().is_ok() || entry.parse::<Ipv6Addr>().is_ok()
328}
329
330fn ipv4_in_cidr(target: &str, cidr: &str) -> bool {
331    let network = match ipnet::Ipv4Net::from_str(cidr) {
332        Ok(n) => n,
333        Err(_) => return false,
334    };
335    let target_ip = match Ipv4Addr::from_str(target) {
336        Ok(a) => a,
337        Err(_) => return false,
338    };
339    network.contains(&target_ip)
340}
341
342pub const DESTRUCTIVE_KEYWORDS: &[&str] = &[
343    "wipe",
344    "format",
345    "destroy",
346    "delete",
347    "drop table",
348    "shutdown",
349    "reboot",
350    "kill",
351    "ransom",
352    "brick",
353    "overwrite",
354    "bruteforce",
355];
356
357pub fn is_destructive(text: &str) -> bool {
358    let lower = text.to_ascii_lowercase();
359    DESTRUCTIVE_KEYWORDS.iter().any(|k| lower.contains(k))
360}
361
362#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
363#[serde(rename_all = "snake_case")]
364pub enum PolicyContext {
365    Cli,
366    Rest,
367    #[default]
368    Autonomous,
369}
370
371impl PolicyContext {
372    pub fn as_str(&self) -> &'static str {
373        match self {
374            PolicyContext::Cli => "cli",
375            PolicyContext::Rest => "rest",
376            PolicyContext::Autonomous => "autonomous",
377        }
378    }
379}
380
381#[derive(Debug, Clone)]
382pub struct PolicyRequest {
383    pub target: String,
384    pub capabilities: Vec<Capability>,
385    pub impact: RiskLevel,
386    pub destructive: bool,
387    pub charter_accepted: bool,
388    pub in_scope: bool,
389    pub approved: bool,
390    pub context: PolicyContext,
391    pub cvss: Option<CvssScore>,
392}
393
394#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
395pub enum PolicyDecision {
396    Allow,
397    RequireApproval(String),
398    Deny(String),
399}
400
401impl PolicyDecision {
402    pub fn reason(&self) -> Option<&str> {
403        match self {
404            PolicyDecision::Allow => None,
405            PolicyDecision::RequireApproval(r) | PolicyDecision::Deny(r) => Some(r),
406        }
407    }
408}
409
410pub trait PolicyEngine {
411    fn evaluate(&self, req: &PolicyRequest) -> PolicyDecision;
412}
413
414#[derive(Debug, Clone, Copy)]
415pub struct DefaultPolicy {
416    pub max_risk: RiskLevel,
417    pub context: PolicyContext,
418}
419
420impl PolicyEngine for DefaultPolicy {
421    fn evaluate(&self, req: &PolicyRequest) -> PolicyDecision {
422        if !req.charter_accepted {
423            return PolicyDecision::Deny(
424                "charter not accepted - run `charter accept` first".into(),
425            );
426        }
427        if !req.in_scope {
428            return PolicyDecision::Deny(format!("target out of scope: {}", req.target));
429        }
430        if req.impact > self.max_risk {
431            return PolicyDecision::Deny(format!(
432                "risk level {} exceeds maximum allowed {}",
433                req.impact.as_str(),
434                self.max_risk.as_str()
435            ));
436        }
437        if (req.destructive || req.impact >= RiskLevel::High) && !req.approved {
438            return PolicyDecision::RequireApproval(
439                "destructive / high-risk action requires explicit approval".into(),
440            );
441        }
442        PolicyDecision::Allow
443    }
444}
445
446#[derive(Debug, Clone)]
447pub struct Preflight {
448    pub target: String,
449    pub charter_accepted: bool,
450    pub in_scope: bool,
451    pub risk: RiskLevel,
452    pub destructive: bool,
453    pub approved: bool,
454    pub capabilities: Vec<Capability>,
455    pub intents: Vec<Intent>,
456    pub context: PolicyContext,
457    pub cvss: Option<CvssScore>,
458}
459
460#[derive(Debug, thiserror::Error, PartialEq)]
461pub enum PreflightError {
462    #[error("destructive / high-risk action requires explicit approval")]
463    ApprovalRequired,
464    #[error("{0}")]
465    Denied(String),
466}
467
468impl Preflight {
469    pub fn to_request(&self) -> PolicyRequest {
470        PolicyRequest {
471            target: self.target.clone(),
472            capabilities: self.capabilities.clone(),
473            impact: self.risk,
474            destructive: self.destructive,
475            charter_accepted: self.charter_accepted,
476            in_scope: self.in_scope,
477            approved: self.approved,
478            context: self.context,
479            cvss: self.cvss.clone(),
480        }
481    }
482
483    pub fn check(&self, policy: &dyn PolicyEngine) -> Result<(), PreflightError> {
484        match policy.evaluate(&self.to_request()) {
485            PolicyDecision::Allow => Ok(()),
486            PolicyDecision::RequireApproval(_) => Err(PreflightError::ApprovalRequired),
487            PolicyDecision::Deny(reason) => Err(PreflightError::Denied(reason)),
488        }
489    }
490}
491
492#[derive(Debug, Clone, Serialize, Deserialize)]
493pub struct DecisionRecord {
494    pub at: u64,
495    pub target: String,
496    pub module: String,
497    pub capabilities: Vec<Capability>,
498    pub intents: Vec<Intent>,
499    pub impact: RiskLevel,
500    pub context: PolicyContext,
501    pub decision: PolicyDecision,
502}
503
504#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
505#[serde(rename_all = "snake_case")]
506pub enum PolicyRule {
507    DenyCapability(Capability),
508    AllowCapability(Capability),
509    MaxRisk(RiskLevel),
510    RequireApproval {
511        capability: Capability,
512        target_pattern: String,
513    },
514    DenyIfCvssAbove(f64),
515    RequireApprovalIf {
516        cvss_above: Option<f64>,
517        epss_above: Option<f64>,
518        kev: bool,
519    },
520    DenyPayload(String),
521}
522
523pub fn target_matches(target: &str, pattern: &str) -> bool {
524    let pattern = pattern.trim();
525    if let Some(prefix) = pattern.strip_suffix('*') {
526        target.starts_with(prefix)
527    } else {
528        target == pattern
529    }
530}
531
532#[derive(Debug, Clone, Serialize, Deserialize)]
533pub struct PolicySet {
534    pub rules: Vec<PolicyRule>,
535    pub version: u64,
536}
537
538impl Default for PolicySet {
539    fn default() -> Self {
540        Self {
541            rules: Vec::new(),
542            version: 1,
543        }
544    }
545}
546
547impl PolicySet {
548    pub fn add_rule(&mut self, rule: PolicyRule) {
549        self.rules.push(rule);
550        self.version += 1;
551    }
552
553    pub fn remove_rule(&mut self, index: usize) -> Option<PolicyRule> {
554        if index >= self.rules.len() {
555            return None;
556        }
557        self.version += 1;
558        Some(self.rules.remove(index))
559    }
560
561    pub fn set_rules(&mut self, rules: Vec<PolicyRule>) {
562        self.rules = rules;
563        self.version += 1;
564    }
565
566    pub fn max_risk(&self, default: RiskLevel) -> RiskLevel {
567        self.rules.iter().fold(default, |acc, r| match r {
568            PolicyRule::MaxRisk(m) => acc.min(*m),
569            _ => acc,
570        })
571    }
572
573    pub fn denied(&self, caps: &[Capability]) -> Option<Capability> {
574        self.rules.iter().find_map(|r| match r {
575            PolicyRule::DenyCapability(c) if caps.contains(c) => Some(*c),
576            _ => None,
577        })
578    }
579
580    pub fn allows(&self, caps: &[Capability]) -> bool {
581        self.rules
582            .iter()
583            .any(|r| matches!(r, PolicyRule::AllowCapability(c) if caps.contains(c)))
584    }
585
586    pub fn deny_cvss_threshold(&self) -> Option<f64> {
587        self.rules
588            .iter()
589            .filter_map(|r| match r {
590                PolicyRule::DenyIfCvssAbove(t) => Some(*t),
591                _ => None,
592            })
593            .min_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
594    }
595
596    pub fn has_cvss_rules(&self) -> bool {
597        self.rules.iter().any(|r| {
598            matches!(
599                r,
600                PolicyRule::DenyIfCvssAbove(_) | PolicyRule::RequireApprovalIf { .. }
601            )
602        })
603    }
604}
605
606#[derive(Debug, Clone)]
607pub struct ConfigPolicy {
608    pub max_risk: RiskLevel,
609    pub context: PolicyContext,
610    pub rules: PolicySet,
611}
612
613impl PolicyEngine for ConfigPolicy {
614    fn evaluate(&self, req: &PolicyRequest) -> PolicyDecision {
615        if let Some(c) = self.rules.denied(&req.capabilities) {
616            return PolicyDecision::Deny(format!("capability {} denied by policy", c.as_str()));
617        }
618        for r in &self.rules.rules {
619            if let PolicyRule::RequireApproval {
620                capability,
621                target_pattern,
622            } = r
623            {
624                if req.capabilities.contains(capability)
625                    && target_matches(&req.target, target_pattern)
626                {
627                    return PolicyDecision::RequireApproval(format!(
628                        "capability {} on {} requires approval",
629                        capability.as_str(),
630                        req.target
631                    ));
632                }
633            }
634        }
635        if let Some(ref cvss) = req.cvss {
636            let score = cvss.effective_score();
637            for r in &self.rules.rules {
638                if let PolicyRule::DenyIfCvssAbove(threshold) = r {
639                    if score > *threshold {
640                        return PolicyDecision::Deny(format!(
641                            "CVSS score {:.1} exceeds deny threshold {:.1}",
642                            score, threshold
643                        ));
644                    }
645                }
646            }
647        }
648        if let Some(ref cvss) = req.cvss {
649            for r in &self.rules.rules {
650                if let PolicyRule::RequireApprovalIf {
651                    cvss_above,
652                    epss_above,
653                    kev,
654                } = r
655                {
656                    let cvss_triggers = cvss_above
657                        .map(|t| cvss.effective_score() > t)
658                        .unwrap_or(false);
659                    let epss_triggers = epss_above
660                        .and_then(|t| cvss.epss.map(|e| e > t))
661                        .unwrap_or(false);
662                    let kev_triggers = *kev && cvss.kev;
663                    if cvss_triggers || epss_triggers || kev_triggers {
664                        return PolicyDecision::RequireApproval(format!(
665                            "CVSS risk (score={:.1}, epss={:?}, kev={}) exceeds policy threshold",
666                            cvss.effective_score(),
667                            cvss.epss,
668                            cvss.kev
669                        ));
670                    }
671                }
672            }
673        }
674        let mut d = DefaultPolicy {
675            max_risk: self.rules.max_risk(self.max_risk),
676            context: self.context,
677        }
678        .evaluate(req);
679        if matches!(d, PolicyDecision::RequireApproval(_)) && self.rules.allows(&req.capabilities) {
680            d = PolicyDecision::Allow;
681        }
682        d
683    }
684}
685
686impl ConfigPolicy {
687    /// Returns the first denied payload pattern found in a module's emitted
688    /// evidence or structured data, or an empty string when none match.
689    /// Used to enforce the (otherwise inert) `DenyPayload` rule post-execution.
690    pub fn denied_payload(&self, result: &ModuleResult) -> String {
691        let mut haystack = String::new();
692        for e in &result.evidence {
693            haystack.push_str(e);
694            haystack.push('\n');
695        }
696        haystack.push_str(&result.data.to_string());
697        for r in &self.rules.rules {
698            if let PolicyRule::DenyPayload(p) = r {
699                if haystack.contains(p) {
700                    return p.clone();
701                }
702            }
703        }
704        String::new()
705    }
706}
707
708pub fn make_config_policy(
709    max_risk: RiskLevel,
710    context: PolicyContext,
711    rules: &PolicySet,
712) -> ConfigPolicy {
713    ConfigPolicy {
714        max_risk,
715        context,
716        rules: rules.clone(),
717    }
718}
719
720#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
721pub struct EvidenceProvenance {
722    pub job_id: Option<u64>,
723}
724
725#[derive(Debug, Clone, Serialize, Deserialize)]
726pub struct Evidence {
727    pub id: String,
728    pub at: u64,
729    pub module: String,
730    pub target: String,
731    pub content: String,
732    pub kind: Option<String>,
733    pub confidence: f64,
734    pub normalized: Option<Value>,
735    pub provenance: EvidenceProvenance,
736}
737
738impl Evidence {
739    pub fn cvss(&self) -> Option<CvssScore> {
740        if let Some(ref norm) = self.normalized {
741            if let Ok(score) = serde_json::from_value::<CvssScore>(norm.clone()) {
742                if score.effective_score() > 0.0 {
743                    return Some(score);
744                }
745            }
746        }
747        DefaultRiskEvaluator::parse_one(&self.content)
748    }
749
750    pub fn new(module: &str, target: &str, content: &str, job_id: Option<u64>, seq: usize) -> Self {
751        let (kind, confidence, normalized) = normalize_evidence(content, module);
752        let at = now_secs();
753        Evidence {
754            id: format!("{at}-{seq}"),
755            at,
756            module: module.to_string(),
757            target: target.to_string(),
758            content: content.to_string(),
759            kind,
760            confidence,
761            normalized,
762            provenance: EvidenceProvenance { job_id },
763        }
764    }
765}
766
767pub trait RiskEvaluator {
768    fn evaluate(&self, evidence: &[Evidence]) -> Option<CvssScore>;
769    fn evaluate_raw(&self, contents: &[String], kind: &Option<String>) -> Option<CvssScore>;
770
771    fn cvss_to_risk_level(score: f64) -> RiskLevel {
772        if score >= 9.0 {
773            RiskLevel::Critical
774        } else if score >= 7.0 {
775            RiskLevel::High
776        } else if score >= 4.0 {
777            RiskLevel::Medium
778        } else if score > 0.0 {
779            RiskLevel::Low
780        } else {
781            RiskLevel::None
782        }
783    }
784}
785
786#[derive(Debug, Clone, Copy)]
787pub struct DefaultRiskEvaluator;
788
789impl DefaultRiskEvaluator {
790    pub fn parse_one(content: &str) -> Option<CvssScore> {
791        let val: serde_json::Value = serde_json::from_str(content).ok()?;
792        let obj = val.as_object()?;
793
794        let cvss_v31 = obj
795            .get("cvss_v31")
796            .or_else(|| obj.get("cvss"))
797            .or_else(|| obj.get("score"))
798            .and_then(|v| v.as_f64());
799        let cvss_v40 = obj
800            .get("cvss_v40")
801            .or_else(|| obj.get("cvss_v4"))
802            .and_then(|v| v.as_f64());
803        let epss = obj.get("epss").and_then(|v| v.as_f64());
804        let kev = obj
805            .get("kev")
806            .or_else(|| obj.get("cisa_kev"))
807            .and_then(|v| v.as_bool())
808            .unwrap_or(false);
809
810        if cvss_v31.is_some() || cvss_v40.is_some() {
811            Some(CvssScore {
812                cvss_v31,
813                cvss_v40,
814                epss,
815                kev,
816            })
817        } else {
818            None
819        }
820    }
821}
822
823impl RiskEvaluator for DefaultRiskEvaluator {
824    fn evaluate(&self, evidence: &[Evidence]) -> Option<CvssScore> {
825        let mut best: Option<CvssScore> = None;
826        for e in evidence {
827            if let Some(score) = Self::parse_one(&e.content) {
828                best = Self::pick_best(best, score);
829            }
830            if let Some(ref norm) = e.normalized {
831                if let Ok(score) = serde_json::from_value::<CvssScore>(norm.clone()) {
832                    best = Self::pick_best(best, score);
833                }
834            }
835        }
836        best
837    }
838
839    fn evaluate_raw(&self, contents: &[String], _kind: &Option<String>) -> Option<CvssScore> {
840        contents
841            .iter()
842            .filter_map(|c| Self::parse_one(c))
843            .max_by(|a, b| {
844                a.weighted_risk()
845                    .partial_cmp(&b.weighted_risk())
846                    .unwrap_or(std::cmp::Ordering::Equal)
847            })
848    }
849}
850
851impl DefaultRiskEvaluator {
852    fn pick_best(best: Option<CvssScore>, candidate: CvssScore) -> Option<CvssScore> {
853        match best {
854            None => Some(candidate),
855            Some(ref current) if candidate.weighted_risk() > current.weighted_risk() => {
856                Some(candidate)
857            }
858            _ => best,
859        }
860    }
861}
862
863pub fn normalize_evidence(content: &str, _module: &str) -> (Option<String>, f64, Option<Value>) {
864    if let Ok(Value::Object(map)) = serde_json::from_str::<Value>(content) {
865        return (
866            infer_kind(map.keys().map(|k| k.as_str())),
867            0.9,
868            Some(Value::Object(map)),
869        );
870    }
871    let lower = content.to_lowercase();
872    let (kind, confidence) = if lower.contains("credential")
873        || lower.contains("password")
874        || lower.contains("hash")
875        || lower.contains("login")
876    {
877        (Some("credential".into()), 0.7)
878    } else if lower.contains("cve") || lower.contains("vuln") || lower.contains("exploit") {
879        (Some("vulnerability".into()), 0.7)
880    } else if lower.contains("open") || lower.contains("listening") || lower.contains("port") {
881        (Some("port".into()), 0.65)
882    } else if lower.contains("service") || lower.contains("banner") {
883        (Some("service".into()), 0.6)
884    } else if lower.contains("error") || lower.contains("fail") {
885        (Some("error".into()), 0.3)
886    } else {
887        (None, 0.5)
888    };
889    (kind, confidence, None)
890}
891
892fn infer_kind<'a>(keys: impl Iterator<Item = &'a str>) -> Option<String> {
893    let lower: Vec<String> = keys.map(|k| k.to_lowercase()).collect();
894    if lower.iter().any(|k| {
895        k.contains("credential")
896            || k.contains("password")
897            || k.contains("user")
898            || k.contains("hash")
899    }) {
900        Some("credential".into())
901    } else if lower
902        .iter()
903        .any(|k| k.contains("cve") || k.contains("vuln"))
904    {
905        Some("vulnerability".into())
906    } else if lower
907        .iter()
908        .any(|k| k.contains("port") || k.contains("service"))
909    {
910        Some("service".into())
911    } else {
912        Some("finding".into())
913    }
914}
915
916#[derive(Debug, Clone, Serialize, Deserialize)]
917pub struct ReasoningTrace {
918    pub at: u64,
919    pub phase: String,
920    pub context_len: usize,
921    pub summary: String,
922    pub actions: Vec<String>,
923}
924
925#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
926pub enum MemoryKind {
927    Fact,
928    Decision,
929    Failure,
930}
931
932impl MemoryKind {
933    pub fn as_str(&self) -> &'static str {
934        match self {
935            MemoryKind::Fact => "fact",
936            MemoryKind::Decision => "decision",
937            MemoryKind::Failure => "failure",
938        }
939    }
940}
941
942#[derive(Debug, Clone, Serialize, Deserialize)]
943pub struct MemoryEntry {
944    pub at: u64,
945    pub kind: MemoryKind,
946    pub text: String,
947}
948
949pub fn now_secs() -> u64 {
950    SystemTime::now()
951        .duration_since(UNIX_EPOCH)
952        .map(|d| d.as_secs())
953        .unwrap_or(0)
954}
955
956#[cfg(test)]
957mod tests {
958    use super::*;
959    use serde_json::json;
960
961    #[test]
962    fn deny_payload_is_enforced_on_evidence() {
963        let mut rules = PolicySet::default();
964        rules.add_rule(PolicyRule::DenyPayload("reverse_shell".into()));
965        let policy = ConfigPolicy {
966            max_risk: RiskLevel::Critical,
967            context: PolicyContext::Cli,
968            rules,
969        };
970        let result = ModuleResult {
971            success: true,
972            evidence: vec!["payload/reverse_shell generated".into()],
973            data: json!({}),
974            ..Default::default()
975        };
976        assert_eq!(policy.denied_payload(&result), "reverse_shell");
977    }
978
979    #[test]
980    fn deny_payload_is_enforced_on_data() {
981        let mut rules = PolicySet::default();
982        rules.add_rule(PolicyRule::DenyPayload("AKIA".into()));
983        let policy = ConfigPolicy {
984            max_risk: RiskLevel::Critical,
985            context: PolicyContext::Cli,
986            rules,
987        };
988        let result = ModuleResult {
989            success: true,
990            evidence: vec![],
991            data: json!({"leak": "AKIAEXAMPLEKEY"}),
992            ..Default::default()
993        };
994        assert_eq!(policy.denied_payload(&result), "AKIA");
995    }
996
997    #[test]
998    fn deny_payload_passes_when_no_match() {
999        let mut rules = PolicySet::default();
1000        rules.add_rule(PolicyRule::DenyPayload("forbidden".into()));
1001        let policy = ConfigPolicy {
1002            max_risk: RiskLevel::Critical,
1003            context: PolicyContext::Cli,
1004            rules,
1005        };
1006        let result = ModuleResult {
1007            success: true,
1008            evidence: vec!["benign finding".into()],
1009            data: json!({}),
1010            ..Default::default()
1011        };
1012        assert_eq!(policy.denied_payload(&result), "");
1013    }
1014}