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    /// Load policy rules from a YAML file on disk.
606    pub fn load_yaml(path: impl AsRef<std::path::Path>) -> Result<Self, String> {
607        let contents =
608            std::fs::read_to_string(path.as_ref()).map_err(|e| format!("read error: {e}"))?;
609        Self::load_yaml_str(&contents)
610    }
611
612    /// Parse policy rules from a YAML string.
613    pub fn load_yaml_str(s: &str) -> Result<Self, String> {
614        serde_yaml::from_str::<PolicySet>(s).map_err(|e| format!("YAML parse error: {e}"))
615    }
616}
617
618#[derive(Debug, Clone)]
619pub struct ConfigPolicy {
620    pub max_risk: RiskLevel,
621    pub context: PolicyContext,
622    pub rules: PolicySet,
623}
624
625impl PolicyEngine for ConfigPolicy {
626    fn evaluate(&self, req: &PolicyRequest) -> PolicyDecision {
627        if let Some(c) = self.rules.denied(&req.capabilities) {
628            return PolicyDecision::Deny(format!("capability {} denied by policy", c.as_str()));
629        }
630        for r in &self.rules.rules {
631            if let PolicyRule::RequireApproval {
632                capability,
633                target_pattern,
634            } = r
635            {
636                if req.capabilities.contains(capability)
637                    && target_matches(&req.target, target_pattern)
638                {
639                    return PolicyDecision::RequireApproval(format!(
640                        "capability {} on {} requires approval",
641                        capability.as_str(),
642                        req.target
643                    ));
644                }
645            }
646        }
647        if let Some(ref cvss) = req.cvss {
648            let score = cvss.effective_score();
649            for r in &self.rules.rules {
650                if let PolicyRule::DenyIfCvssAbove(threshold) = r {
651                    if score > *threshold {
652                        return PolicyDecision::Deny(format!(
653                            "CVSS score {:.1} exceeds deny threshold {:.1}",
654                            score, threshold
655                        ));
656                    }
657                }
658            }
659        }
660        if let Some(ref cvss) = req.cvss {
661            for r in &self.rules.rules {
662                if let PolicyRule::RequireApprovalIf {
663                    cvss_above,
664                    epss_above,
665                    kev,
666                } = r
667                {
668                    let cvss_triggers = cvss_above
669                        .map(|t| cvss.effective_score() > t)
670                        .unwrap_or(false);
671                    let epss_triggers = epss_above
672                        .and_then(|t| cvss.epss.map(|e| e > t))
673                        .unwrap_or(false);
674                    let kev_triggers = *kev && cvss.kev;
675                    if cvss_triggers || epss_triggers || kev_triggers {
676                        return PolicyDecision::RequireApproval(format!(
677                            "CVSS risk (score={:.1}, epss={:?}, kev={}) exceeds policy threshold",
678                            cvss.effective_score(),
679                            cvss.epss,
680                            cvss.kev
681                        ));
682                    }
683                }
684            }
685        }
686        let mut d = DefaultPolicy {
687            max_risk: self.rules.max_risk(self.max_risk),
688            context: self.context,
689        }
690        .evaluate(req);
691        if matches!(d, PolicyDecision::RequireApproval(_)) && self.rules.allows(&req.capabilities) {
692            d = PolicyDecision::Allow;
693        }
694        d
695    }
696}
697
698impl ConfigPolicy {
699    /// Returns the first denied payload pattern found in a module's emitted
700    /// evidence or structured data, or an empty string when none match.
701    /// Enforced proactively via dry_run when supported; post-execution fallback otherwise.
702    pub fn denied_payload(&self, result: &ModuleResult) -> String {
703        let mut haystack = String::new();
704        for e in &result.evidence {
705            haystack.push_str(e);
706            haystack.push('\n');
707        }
708        haystack.push_str(&result.data.to_string());
709        for r in &self.rules.rules {
710            if let PolicyRule::DenyPayload(p) = r {
711                if haystack.contains(p) {
712                    return p.clone();
713                }
714            }
715        }
716        String::new()
717    }
718
719    pub fn has_deny_payload(&self) -> bool {
720        self.rules
721            .rules
722            .iter()
723            .any(|r| matches!(r, PolicyRule::DenyPayload(_)))
724    }
725}
726
727pub fn make_config_policy(
728    max_risk: RiskLevel,
729    context: PolicyContext,
730    rules: &PolicySet,
731) -> ConfigPolicy {
732    ConfigPolicy {
733        max_risk,
734        context,
735        rules: rules.clone(),
736    }
737}
738
739#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
740pub struct EvidenceProvenance {
741    pub job_id: Option<u64>,
742}
743
744#[derive(Debug, Clone, Serialize, Deserialize)]
745pub struct Evidence {
746    pub id: String,
747    pub at: u64,
748    pub module: String,
749    pub target: String,
750    pub content: String,
751    pub kind: Option<String>,
752    pub confidence: f64,
753    pub normalized: Option<Value>,
754    pub provenance: EvidenceProvenance,
755}
756
757impl Evidence {
758    pub fn cvss(&self) -> Option<CvssScore> {
759        if let Some(ref norm) = self.normalized {
760            if let Ok(score) = serde_json::from_value::<CvssScore>(norm.clone()) {
761                if score.effective_score() > 0.0 {
762                    return Some(score);
763                }
764            }
765        }
766        DefaultRiskEvaluator::parse_one(&self.content)
767    }
768
769    pub fn new(module: &str, target: &str, content: &str, job_id: Option<u64>, seq: usize) -> Self {
770        let (kind, confidence, normalized) = normalize_evidence(content, module);
771        let at = now_secs();
772        Evidence {
773            id: format!("{at}-{seq}"),
774            at,
775            module: module.to_string(),
776            target: target.to_string(),
777            content: content.to_string(),
778            kind,
779            confidence,
780            normalized,
781            provenance: EvidenceProvenance { job_id },
782        }
783    }
784}
785
786pub trait RiskEvaluator {
787    fn evaluate(&self, evidence: &[Evidence]) -> Option<CvssScore>;
788    fn evaluate_raw(&self, contents: &[String], kind: &Option<String>) -> Option<CvssScore>;
789
790    fn cvss_to_risk_level(score: f64) -> RiskLevel {
791        if score >= 9.0 {
792            RiskLevel::Critical
793        } else if score >= 7.0 {
794            RiskLevel::High
795        } else if score >= 4.0 {
796            RiskLevel::Medium
797        } else if score > 0.0 {
798            RiskLevel::Low
799        } else {
800            RiskLevel::None
801        }
802    }
803}
804
805#[derive(Debug, Clone, Copy)]
806pub struct DefaultRiskEvaluator;
807
808impl DefaultRiskEvaluator {
809    pub fn parse_one(content: &str) -> Option<CvssScore> {
810        let val: serde_json::Value = serde_json::from_str(content).ok()?;
811        let obj = val.as_object()?;
812
813        let cvss_v31 = obj
814            .get("cvss_v31")
815            .or_else(|| obj.get("cvss"))
816            .or_else(|| obj.get("score"))
817            .and_then(|v| v.as_f64());
818        let cvss_v40 = obj
819            .get("cvss_v40")
820            .or_else(|| obj.get("cvss_v4"))
821            .and_then(|v| v.as_f64());
822        let epss = obj.get("epss").and_then(|v| v.as_f64());
823        let kev = obj
824            .get("kev")
825            .or_else(|| obj.get("cisa_kev"))
826            .and_then(|v| v.as_bool())
827            .unwrap_or(false);
828
829        if cvss_v31.is_some() || cvss_v40.is_some() {
830            Some(CvssScore {
831                cvss_v31,
832                cvss_v40,
833                epss,
834                kev,
835            })
836        } else {
837            None
838        }
839    }
840}
841
842impl RiskEvaluator for DefaultRiskEvaluator {
843    fn evaluate(&self, evidence: &[Evidence]) -> Option<CvssScore> {
844        let mut best: Option<CvssScore> = None;
845        for e in evidence {
846            if let Some(score) = Self::parse_one(&e.content) {
847                best = Self::pick_best(best, score);
848            }
849            if let Some(ref norm) = e.normalized {
850                if let Ok(score) = serde_json::from_value::<CvssScore>(norm.clone()) {
851                    best = Self::pick_best(best, score);
852                }
853            }
854        }
855        best
856    }
857
858    fn evaluate_raw(&self, contents: &[String], _kind: &Option<String>) -> Option<CvssScore> {
859        contents
860            .iter()
861            .filter_map(|c| Self::parse_one(c))
862            .max_by(|a, b| {
863                a.weighted_risk()
864                    .partial_cmp(&b.weighted_risk())
865                    .unwrap_or(std::cmp::Ordering::Equal)
866            })
867    }
868}
869
870impl DefaultRiskEvaluator {
871    fn pick_best(best: Option<CvssScore>, candidate: CvssScore) -> Option<CvssScore> {
872        match best {
873            None => Some(candidate),
874            Some(ref current) if candidate.weighted_risk() > current.weighted_risk() => {
875                Some(candidate)
876            }
877            _ => best,
878        }
879    }
880}
881
882pub fn normalize_evidence(content: &str, _module: &str) -> (Option<String>, f64, Option<Value>) {
883    if let Ok(Value::Object(map)) = serde_json::from_str::<Value>(content) {
884        return (
885            infer_kind(map.keys().map(|k| k.as_str())),
886            0.9,
887            Some(Value::Object(map)),
888        );
889    }
890    let lower = content.to_lowercase();
891    let (kind, confidence) = if lower.contains("credential")
892        || lower.contains("password")
893        || lower.contains("hash")
894        || lower.contains("login")
895    {
896        (Some("credential".into()), 0.7)
897    } else if lower.contains("cve") || lower.contains("vuln") || lower.contains("exploit") {
898        (Some("vulnerability".into()), 0.7)
899    } else if lower.contains("open") || lower.contains("listening") || lower.contains("port") {
900        (Some("port".into()), 0.65)
901    } else if lower.contains("service") || lower.contains("banner") {
902        (Some("service".into()), 0.6)
903    } else if lower.contains("error") || lower.contains("fail") {
904        (Some("error".into()), 0.3)
905    } else {
906        (None, 0.5)
907    };
908    (kind, confidence, None)
909}
910
911fn infer_kind<'a>(keys: impl Iterator<Item = &'a str>) -> Option<String> {
912    let lower: Vec<String> = keys.map(|k| k.to_lowercase()).collect();
913    if lower.iter().any(|k| {
914        k.contains("credential")
915            || k.contains("password")
916            || k.contains("user")
917            || k.contains("hash")
918    }) {
919        Some("credential".into())
920    } else if lower
921        .iter()
922        .any(|k| k.contains("cve") || k.contains("vuln"))
923    {
924        Some("vulnerability".into())
925    } else if lower
926        .iter()
927        .any(|k| k.contains("port") || k.contains("service"))
928    {
929        Some("service".into())
930    } else {
931        Some("finding".into())
932    }
933}
934
935#[derive(Debug, Clone, Serialize, Deserialize)]
936pub struct ReasoningTrace {
937    pub at: u64,
938    pub phase: String,
939    pub context_len: usize,
940    pub summary: String,
941    pub actions: Vec<String>,
942}
943
944#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
945pub enum MemoryKind {
946    Fact,
947    Decision,
948    Failure,
949}
950
951impl MemoryKind {
952    pub fn as_str(&self) -> &'static str {
953        match self {
954            MemoryKind::Fact => "fact",
955            MemoryKind::Decision => "decision",
956            MemoryKind::Failure => "failure",
957        }
958    }
959}
960
961#[derive(Debug, Clone, Serialize, Deserialize)]
962pub struct MemoryEntry {
963    pub at: u64,
964    pub kind: MemoryKind,
965    pub text: String,
966}
967
968pub fn now_secs() -> u64 {
969    SystemTime::now()
970        .duration_since(UNIX_EPOCH)
971        .map(|d| d.as_secs())
972        .unwrap_or(0)
973}
974
975#[cfg(test)]
976mod tests {
977    use super::*;
978    use serde_json::json;
979
980    #[test]
981    fn deny_payload_is_enforced_on_evidence() {
982        let mut rules = PolicySet::default();
983        rules.add_rule(PolicyRule::DenyPayload("reverse_shell".into()));
984        let policy = ConfigPolicy {
985            max_risk: RiskLevel::Critical,
986            context: PolicyContext::Cli,
987            rules,
988        };
989        let result = ModuleResult {
990            success: true,
991            evidence: vec!["payload/reverse_shell generated".into()],
992            data: json!({}),
993            ..Default::default()
994        };
995        assert_eq!(policy.denied_payload(&result), "reverse_shell");
996    }
997
998    #[test]
999    fn deny_payload_is_enforced_on_data() {
1000        let mut rules = PolicySet::default();
1001        rules.add_rule(PolicyRule::DenyPayload("AKIA".into()));
1002        let policy = ConfigPolicy {
1003            max_risk: RiskLevel::Critical,
1004            context: PolicyContext::Cli,
1005            rules,
1006        };
1007        let result = ModuleResult {
1008            success: true,
1009            evidence: vec![],
1010            data: json!({"leak": "AKIAEXAMPLEKEY"}),
1011            ..Default::default()
1012        };
1013        assert_eq!(policy.denied_payload(&result), "AKIA");
1014    }
1015
1016    #[test]
1017    fn deny_payload_passes_when_no_match() {
1018        let mut rules = PolicySet::default();
1019        rules.add_rule(PolicyRule::DenyPayload("forbidden".into()));
1020        let policy = ConfigPolicy {
1021            max_risk: RiskLevel::Critical,
1022            context: PolicyContext::Cli,
1023            rules,
1024        };
1025        let result = ModuleResult {
1026            success: true,
1027            evidence: vec!["benign finding".into()],
1028            data: json!({}),
1029            ..Default::default()
1030        };
1031        assert_eq!(policy.denied_payload(&result), "");
1032    }
1033}