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