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#[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 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}