From 2ebf5b5fb0b9b7da6ce5dc182f8eb20840c43c07 Mon Sep 17 00:00:00 2001 From: Albert Hui Date: Sun, 2 Aug 2026 02:53:58 +0800 Subject: [PATCH 1/2] test(report): RED - one Severity, one rank/token vocabulary, no NTFS- squatting Failing tests for three report-layer DRY defects. No implementation. Defect 1 - two Severity clones duplicating forensicnomicon's: - crates/issen-signatures/tests/severity_is_canonical.rs - crates/forensic-pivot/tests/severity_is_canonical.rs - crates/forensic-pivot/tests/severity_gains_canonical_ordering.rs Both clones are the canonical five-tier scale, not a distinct native scale (contrast srum-analysis's Clean/Informational/Suspicious/Critical, which correctly normalizes at its boundary per ADR-0007), so both migrate onto forensicnomicon::report::Severity rather than converting at a boundary. Defect 2 - severity rank/token triplicated and already drifted: - crates/issen-core/tests/severity_vocabulary.rs (the shared vocabulary) - crates/issen-correlation/tests/severity_vocabulary_is_shared.rs - crates/issen-report/tests/severity_vocabulary_is_shared.rs The drift shipped: render_html emits class="severity-info" while the stylesheet only defines .severity-informational, so the Info tier renders unstyled; and Correlation::severity_from_str parses case-sensitively while issen-report's copy is case-insensitive. Defect 3 - issen mints codes inside ntfs-forensic's NTFS- namespace: - crates/issen-correlation/tests/anomaly_code_namespace.rs - crates/issen-disk/src/lib.rs (inline test) RED output: forensic-pivot severity_is_canonical (runtime) thread 'severity_is_the_canonical_forensicnomicon_type' panicked at crates/forensic-pivot/tests/severity_is_canonical.rs:17:5: forensic-pivot must re-export forensicnomicon's Severity, not clone it (got `forensic_pivot::rule::Severity`) test result: FAILED. 1 passed; 1 failed forensic-pivot severity_gains_canonical_ordering (compile) error[E0369]: binary operation `>` cannot be applied to type `Severity` note: `Severity` does not implement `PartialOrd` issen-signatures severity_is_canonical (runtime) thread 'severity_is_the_canonical_forensicnomicon_type' panicked at crates/issen-signatures/tests/severity_is_canonical.rs:19:5: assertion `left == right` failed: issen-signatures must re-export forensicnomicon's Severity, not clone it left: "issen_signatures::matching::results::Severity" right: "forensicnomicon_core::report::Severity" test result: FAILED. 1 passed; 1 failed issen-core severity_vocabulary (compile) error[E0432]: unresolved import `issen_core::severity` error[E0599]: no method named `rank` found for enum `Severity` error[E0599]: no method named `token` found for enum `Severity` issen-correlation severity_vocabulary_is_shared (compile) error[E0432]: unresolved import `issen_core::severity` error[E0599]: no method named `token` found for enum `Severity` issen-report severity_vocabulary_is_shared (compile) error[E0432]: unresolved import `issen_core::severity` --> crates/issen-report/tests/severity_vocabulary_is_shared.rs:10:17 | use issen_core::severity::{self, SeverityExt}; | ^^^^^^^^ could not find `severity` in `issen_core` issen-correlation anomaly_code_namespace (runtime) thread 'timestomp_code_is_not_in_the_ntfs_forensic_namespace' panicked: `NTFS-` is ntfs-forensic's scheme; issen must mint under a prefix it owns (got `NTFS-TIMESTOMP-SI-FN-MISMATCH`) thread 'timestomp_code_does_not_shadow_ntfs_forensics_shipped_code' panicked: a prefix-extension of a shipped code makes one detection look like two under prefix grouping (got `NTFS-TIMESTOMP-SI-FN-MISMATCH`) thread 'timestomp_code_is_minted_under_an_issen_owned_prefix' panicked: expected an issen-owned prefix, got `NTFS-TIMESTOMP-SI-FN-MISMATCH` test result: FAILED. 0 passed; 3 failed issen-disk (runtime) thread 'tests::boot_backup_code_is_not_in_the_ntfs_forensic_namespace' panicked at crates/issen-disk/src/lib.rs:2128:9: `NTFS-` is ntfs-forensic's scheme; issen must mint under a prefix it owns (got `NTFS-BOOT-BACKUP-MISMATCH`) test result: FAILED. 0 passed; 1 failed Co-Authored-By: Claude Opus 5 (1M context) --- .../severity_gains_canonical_ordering.rs | 36 ++++++ .../tests/severity_is_canonical.rs | 44 +++++++ .../issen-core/tests/severity_vocabulary.rs | 108 ++++++++++++++++++ .../tests/anomaly_code_namespace.rs | 52 +++++++++ .../tests/severity_vocabulary_is_shared.rs | 62 ++++++++++ crates/issen-disk/src/lib.rs | 31 +++++ .../tests/severity_vocabulary_is_shared.rs | 94 +++++++++++++++ .../tests/severity_is_canonical.rs | 46 ++++++++ 8 files changed, 473 insertions(+) create mode 100644 crates/forensic-pivot/tests/severity_gains_canonical_ordering.rs create mode 100644 crates/forensic-pivot/tests/severity_is_canonical.rs create mode 100644 crates/issen-core/tests/severity_vocabulary.rs create mode 100644 crates/issen-correlation/tests/anomaly_code_namespace.rs create mode 100644 crates/issen-correlation/tests/severity_vocabulary_is_shared.rs create mode 100644 crates/issen-report/tests/severity_vocabulary_is_shared.rs create mode 100644 crates/issen-signatures/tests/severity_is_canonical.rs diff --git a/crates/forensic-pivot/tests/severity_gains_canonical_ordering.rs b/crates/forensic-pivot/tests/severity_gains_canonical_ordering.rs new file mode 100644 index 00000000..b9b34fef --- /dev/null +++ b/crates/forensic-pivot/tests/severity_gains_canonical_ordering.rs @@ -0,0 +1,36 @@ +//! The clone in `forensic-pivot::rule` derived only `PartialEq`, so pivot rules +//! could not be ranked or thresholded at all. The canonical +//! `forensicnomicon::report::Severity` derives `Ord`; adopting it is what makes +//! these comparisons compile. + +use forensic_pivot::Severity; + +#[test] +fn severity_is_totally_ordered() { + assert!(Severity::Critical > Severity::High); + assert!(Severity::High > Severity::Medium); + assert!(Severity::Medium > Severity::Low); + assert!(Severity::Low > Severity::Info); +} + +#[test] +fn findings_can_be_ranked_by_severity() { + let mut tiers = [ + Severity::Low, + Severity::Critical, + Severity::Info, + Severity::High, + Severity::Medium, + ]; + tiers.sort_unstable(); + assert_eq!( + tiers, + [ + Severity::Info, + Severity::Low, + Severity::Medium, + Severity::High, + Severity::Critical + ] + ); +} diff --git a/crates/forensic-pivot/tests/severity_is_canonical.rs b/crates/forensic-pivot/tests/severity_is_canonical.rs new file mode 100644 index 00000000..87356fad --- /dev/null +++ b/crates/forensic-pivot/tests/severity_is_canonical.rs @@ -0,0 +1,44 @@ +//! `forensic-pivot` must not define its own `Severity`. +//! +//! `rule::Severity` was `Critical/High/Medium/Low/Info` — the exact variant set +//! of `forensicnomicon::report::Severity`, only declared highest-first and +//! without the canonical type's `Ord`/`Display`. A pure duplicate, so it +//! migrates onto the canonical type; nothing here is a distinct native scale +//! that would warrant an ADR-0007 boundary conversion instead. +//! +//! The identity assertion is spelled through `type_name` so it compiles both +//! before and after the migration and fails at runtime naming the clone. + +use forensic_pivot::{PivotRule, Severity}; + +#[test] +fn severity_is_the_canonical_forensicnomicon_type() { + let name = std::any::type_name::(); + assert!( + name.starts_with("forensicnomicon"), + "forensic-pivot must re-export forensicnomicon's Severity, not clone it \ + (got `{name}`)" + ); +} + +#[test] +fn rule_yaml_still_deserializes_every_severity_word() { + // The bundled rule packs spell severity with the variant name; the canonical + // enum uses the same five words, so existing YAML keeps parsing. + for word in ["Critical", "High", "Medium", "Low", "Info"] { + let yaml = format!( + r#" +id: R-1 +name: test rule +description: d +severity: {word} +assertion_level: Observed +default_confidence: 50 +clauses: [] +"# + ); + let rule: PivotRule = + serde_yaml::from_str(&yaml).unwrap_or_else(|e| panic!("`{word}` must parse: {e}")); + assert_eq!(format!("{:?}", rule.severity), word); + } +} diff --git a/crates/issen-core/tests/severity_vocabulary.rs b/crates/issen-core/tests/severity_vocabulary.rs new file mode 100644 index 00000000..113fb097 --- /dev/null +++ b/crates/issen-core/tests/severity_vocabulary.rs @@ -0,0 +1,108 @@ +//! The severity rank/token vocabulary must have exactly ONE definition. +//! +//! `severity_rank` / `severity_token` were copy-pasted into `issen-report` and +//! `issen-correlation` (and the parse half into `issen-report::navigator_output` +//! and `issen-signatures`). Divergence between the copies is not hypothetical: +//! the report's stylesheet shipped a `.severity-informational` rule while its +//! own token function emitted `info`, so the Info tier rendered unstyled. +//! +//! Interim home: `issen_core::severity`. The upstream fix is +//! `forensicnomicon::report::Severity::rank()` / `::token()` — once those land, +//! the inherent methods shadow `SeverityExt` and this module can be deleted +//! without touching a single call site. + +use forensicnomicon::report::Severity; +use issen_core::severity::{self, SeverityExt}; + +#[test] +fn rank_orders_info_lowest_and_critical_highest() { + assert_eq!(Severity::Info.rank(), 0); + assert_eq!(Severity::Low.rank(), 1); + assert_eq!(Severity::Medium.rank(), 2); + assert_eq!(Severity::High.rank(), 3); + assert_eq!(Severity::Critical.rank(), 4); +} + +#[test] +fn rank_agrees_with_the_derived_ord() { + // `Severity` already derives `Ord`; `rank` must never disagree with it. + let ordered = [ + Severity::Info, + Severity::Low, + Severity::Medium, + Severity::High, + Severity::Critical, + ]; + for pair in ordered.windows(2) { + assert!(pair[0] < pair[1], "{:?} < {:?}", pair[0], pair[1]); + assert!( + pair[0].rank() < pair[1].rank(), + "rank must track Ord: {:?} vs {:?}", + pair[0], + pair[1] + ); + } +} + +#[test] +fn token_is_the_lowercase_persisted_form() { + assert_eq!(Severity::Info.token(), "info"); + assert_eq!(Severity::Low.token(), "low"); + assert_eq!(Severity::Medium.token(), "medium"); + assert_eq!(Severity::High.token(), "high"); + assert_eq!(Severity::Critical.token(), "critical"); +} + +#[test] +fn tokens_constant_is_ordered_lowest_to_highest_and_matches_token() { + assert_eq!( + severity::TOKENS, + ["info", "low", "medium", "high", "critical"] + ); + for (idx, tok) in severity::TOKENS.iter().enumerate() { + let parsed = severity::parse(tok).expect("every TOKENS entry must parse"); + assert_eq!(usize::from(parsed.rank()), idx, "TOKENS[{idx}] = {tok}"); + assert_eq!(parsed.token(), *tok); + } +} + +#[test] +fn parse_is_case_insensitive_and_round_trips() { + for tok in severity::TOKENS { + let upper = tok.to_uppercase(); + assert_eq!( + severity::parse(&upper), + severity::parse(tok), + "parse must be case-insensitive for {tok}" + ); + } + // The canonical `Display` is UPPERCASE — it must feed straight back in. + for sev in [ + Severity::Info, + Severity::Low, + Severity::Medium, + Severity::High, + Severity::Critical, + ] { + assert_eq!(severity::parse(&sev.to_string()), Some(sev)); + } +} + +#[test] +fn parse_accepts_informational_as_a_legacy_alias_for_info() { + // Pre-consolidation rows persisted by `issen-signatures` carry + // "informational"; they must keep reading back as `Info`. + assert_eq!(severity::parse("informational"), Some(Severity::Info)); + assert_eq!(severity::parse("Informational"), Some(Severity::Info)); +} + +#[test] +fn parse_rejects_an_unknown_token_and_shows_it_lossily_as_info() { + assert_eq!(severity::parse("catastrophic"), None); + assert_eq!(severity::parse(""), None); + // The lossy half (what `--min-severity` and the Sigma level field need) + // degrades to the lowest tier rather than failing. + assert_eq!(severity::parse_lossy("catastrophic"), Severity::Info); + assert_eq!(severity::parse_lossy("CRITICAL"), Severity::Critical); + assert_eq!(severity::parse_lossy("informational"), Severity::Info); +} diff --git a/crates/issen-correlation/tests/anomaly_code_namespace.rs b/crates/issen-correlation/tests/anomaly_code_namespace.rs new file mode 100644 index 00000000..2250c2ab --- /dev/null +++ b/crates/issen-correlation/tests/anomaly_code_namespace.rs @@ -0,0 +1,52 @@ +//! issen must not mint finding codes inside another crate's `code` namespace. +//! +//! `ntfs-forensic` owns the `NTFS-` scheme and already publishes +//! `NTFS-TIMESTOMP`, `NTFS-ADS`, `NTFS-DELETED-RECORD`, `NTFS-SLACK-RESIDUE`, +//! `NTFS-MFTMIRR-MISMATCH` and `NTFS-LOGFILE-CLEARED` (see its `AnomalyKind` / +//! `ArtifactAnomaly` `code()` methods). issen *consumes* those codes, which +//! makes it exactly the crate that must not also mint into the namespace. +//! +//! The sharp case is `NTFS-TIMESTOMP-SI-FN-MISMATCH`: it is a strict string +//! prefix-extension of ntfs-forensic's shipped `NTFS-TIMESTOMP`, so any +//! consumer grouping by code prefix conflates a correlation-layer Info lead +//! with a filesystem-layer High anomaly. The two detections are related but +//! genuinely different (different inputs, signal sets, and confidence models), +//! so they stay distinct codes — the issen one moves to a prefix issen owns. + +use issen_correlation::timestomp::TIMESTOMP_CODE; + +#[test] +fn timestomp_code_is_not_in_the_ntfs_forensic_namespace() { + assert!( + !TIMESTOMP_CODE.starts_with("NTFS-"), + "`NTFS-` is ntfs-forensic's scheme; issen must mint under a prefix it \ + owns (got `{TIMESTOMP_CODE}`)" + ); +} + +#[test] +fn timestomp_code_does_not_shadow_ntfs_forensics_shipped_code() { + // ntfs-forensic/forensic/src/lib.rs: `AnomalyKind::Timestomp { .. } => "NTFS-TIMESTOMP"`. + const NTFS_FORENSIC_TIMESTOMP: &str = "NTFS-TIMESTOMP"; + assert_ne!(TIMESTOMP_CODE, NTFS_FORENSIC_TIMESTOMP); + assert!( + !TIMESTOMP_CODE.starts_with(NTFS_FORENSIC_TIMESTOMP), + "a prefix-extension of a shipped code makes one detection look like two \ + under prefix grouping (got `{TIMESTOMP_CODE}`)" + ); +} + +#[test] +fn timestomp_code_is_minted_under_an_issen_owned_prefix() { + // issen owns `CORR-` (cross-event correlations) and `HEUR-` (its own rule + // layer — timestomping, location, entropy, size, magic, USN). This detector + // is a single-event heuristic lead, so `HEUR-`. + assert!( + TIMESTOMP_CODE.starts_with("HEUR-") || TIMESTOMP_CODE.starts_with("CORR-"), + "expected an issen-owned prefix, got `{TIMESTOMP_CODE}`" + ); + assert!( + TIMESTOMP_CODE.contains("TIMESTOMP"), + "the code must still name the phenomenon (got `{TIMESTOMP_CODE}`)" + ); +} diff --git a/crates/issen-correlation/tests/severity_vocabulary_is_shared.rs b/crates/issen-correlation/tests/severity_vocabulary_is_shared.rs new file mode 100644 index 00000000..c346f8ad --- /dev/null +++ b/crates/issen-correlation/tests/severity_vocabulary_is_shared.rs @@ -0,0 +1,62 @@ +//! `Correlation`'s severity token/parse pair must come from the one shared +//! vocabulary, not a third hand-rolled copy. +//! +//! `severity_str`/`severity_from_str` here duplicated `severity_token`/ +//! `severity_from_finding_str` in `issen-report`. The copies had already +//! drifted: this one parses case-sensitively (`"Info"` → `None`) while the +//! report's is case-insensitive — and `issen-disk` writes a capitalised +//! `"High"` into event metadata. + +use forensicnomicon::report::Severity; +use issen_core::severity::{self, SeverityExt}; +use issen_correlation::correlation::Correlation; + +const LADDER: [Severity; 5] = [ + Severity::Info, + Severity::Low, + Severity::Medium, + Severity::High, + Severity::Critical, +]; + +#[test] +fn severity_str_is_the_shared_token() { + for sev in LADDER { + let c = Correlation::new("CORR-X", sev); + assert_eq!( + c.severity_str(), + sev.token(), + "Correlation must render the shared token for {sev:?}" + ); + } +} + +#[test] +fn severity_from_str_is_the_shared_parser_and_round_trips() { + for sev in LADDER { + let token = sev.token(); + assert_eq!(Correlation::severity_from_str(token), Some(sev)); + assert_eq!( + Correlation::severity_from_str(token), + severity::parse(token) + ); + } +} + +#[test] +fn severity_from_str_accepts_the_capitalised_form_issen_disk_writes() { + // `issen-disk` persists `severity` metadata as `"High"`; a case-sensitive + // parser silently drops it. + assert_eq!(Correlation::severity_from_str("High"), Some(Severity::High)); + assert_eq!(Correlation::severity_from_str("Info"), Some(Severity::Info)); + assert_eq!( + Correlation::severity_from_str("CRITICAL"), + Some(Severity::Critical) + ); +} + +#[test] +fn severity_from_str_still_rejects_a_non_severity() { + assert_eq!(Correlation::severity_from_str("catastrophic"), None); + assert_eq!(Correlation::severity_from_str(""), None); +} diff --git a/crates/issen-disk/src/lib.rs b/crates/issen-disk/src/lib.rs index f934b47a..f2b7bb52 100644 --- a/crates/issen-disk/src/lib.rs +++ b/crates/issen-disk/src/lib.rs @@ -2109,6 +2109,37 @@ mod tests { ); } + #[test] + fn boot_backup_code_is_not_in_the_ntfs_forensic_namespace() { + // `ntfs-forensic` owns the `NTFS-` scheme and already publishes an + // `ArtifactAnomaly` family for exactly this class of whole-artifact + // integrity check (`NTFS-MFTMIRR-MISMATCH`, `NTFS-LOGFILE-CLEARED`). + // issen consumes those codes, so it must not also mint into them — + // a later ntfs-forensic `$Boot` check would collide outright. + let (src, window) = disk_with_backup_boot(Some(0x0102_0304_0506_0708)); + let events = boot_backup_integrity_events(&src, window, "ev").expect("read boot"); + let code = events[0] + .metadata + .get("code") + .and_then(|v| v.as_str()) + .expect("integrity event carries a `code`") + .to_string(); + + assert!( + !code.starts_with("NTFS-"), + "`NTFS-` is ntfs-forensic's scheme; issen must mint under a prefix \ + it owns (got `{code}`)" + ); + assert!( + code.starts_with("HEUR-") || code.starts_with("CORR-"), + "expected an issen-owned prefix, got `{code}`" + ); + assert!( + events[0].description.contains(&code), + "the description must quote the same code it tags" + ); + } + #[test] fn boot_backup_unreadable_is_not_a_mismatch() { // Primary present, but the backup sector falls outside the data we have diff --git a/crates/issen-report/tests/severity_vocabulary_is_shared.rs b/crates/issen-report/tests/severity_vocabulary_is_shared.rs new file mode 100644 index 00000000..4333fec9 --- /dev/null +++ b/crates/issen-report/tests/severity_vocabulary_is_shared.rs @@ -0,0 +1,94 @@ +//! The HTML report's severity vocabulary must come from the one shared source. +//! +//! `severity_rank`/`severity_token` in `issen-report` were copies of the pair in +//! `issen-correlation` (and the parse half was copied a third time into +//! `navigator_output::severity_of`). The copies drifted, and the drift shipped: +//! `render_html` emits `class="severity-{token}"` — `severity-info` for the +//! bottom tier — while the stylesheet only ever defined +//! `.severity-informational`, so Info findings render unstyled. + +use issen_core::severity::{self, SeverityExt}; +use issen_report::{render_html, FindingRow, ReportConfig, ReportData, ReportSummary}; + +fn report_with_findings(findings: Vec) -> ReportData { + let total_findings = findings.len(); + ReportData { + config: ReportConfig::default(), + generated_at: "2026-08-01T00:00:00Z".to_string(), + events: Vec::new(), + summary: ReportSummary { + total_events: 0, + events_by_source: Vec::new(), + events_by_type: Vec::new(), + time_range: None, + total_findings, + }, + findings, + correlations: Vec::new(), + member_events: std::collections::HashMap::new(), + provenance: Vec::new(), + } +} + +fn finding(severity: &str) -> FindingRow { + FindingRow { + engine: "Timestomp".to_string(), + rule_name: format!("R-{severity}"), + severity: severity.to_string(), + target: "C:/x".to_string(), + description: "d".to_string(), + tags: Vec::new(), + } +} + +/// Every severity class the renderer can emit must have a stylesheet rule. +/// This is the drift that actually shipped. +#[test] +fn every_emitted_severity_class_has_a_matching_css_rule() { + let findings = severity::TOKENS.iter().map(|t| finding(t)).collect(); + let html = render_html(&report_with_findings(findings)); + + for token in severity::TOKENS { + assert!( + html.contains(&format!("class=\"severity-{token}\"")), + "renderer must emit severity-{token}" + ); + assert!( + html.contains(&format!(".severity-{token} ")), + "stylesheet is missing a `.severity-{token}` rule, so that tier \ + renders unstyled" + ); + } +} + +/// The stylesheet must not carry a rule for a token the renderer never emits. +#[test] +fn the_stylesheet_has_no_orphan_severity_rule() { + let html = render_html(&report_with_findings(vec![finding("info")])); + assert!( + !html.contains(".severity-informational"), + "`informational` is not a token the shared vocabulary emits; the \ + canonical bottom tier is `info`" + ); +} + +/// The renderer's tier→class mapping must agree with the shared token fn. +#[test] +fn the_rendered_class_is_the_shared_token() { + for token in severity::TOKENS { + let sev = severity::parse(token).expect("shared token parses"); + let html = render_html(&report_with_findings(vec![finding(token)])); + assert!( + html.contains(&format!("class=\"severity-{}\"", sev.token())), + "tier {sev:?} must render as its shared token" + ); + } +} + +/// A legacy `"informational"` row (written before the consolidation) must still +/// map onto the canonical Info tier rather than falling through to a default. +#[test] +fn a_legacy_informational_row_renders_as_info() { + let html = render_html(&report_with_findings(vec![finding("informational")])); + assert!(html.contains("class=\"severity-info\"")); +} diff --git a/crates/issen-signatures/tests/severity_is_canonical.rs b/crates/issen-signatures/tests/severity_is_canonical.rs new file mode 100644 index 00000000..455082bb --- /dev/null +++ b/crates/issen-signatures/tests/severity_is_canonical.rs @@ -0,0 +1,46 @@ +//! `issen-signatures` must not define its own `Severity`. +//! +//! Its enum was `Informational/Low/Medium/High/Critical` — the canonical +//! `forensicnomicon::report::Severity` scale under a different spelling of the +//! bottom tier, plus a private `from_str_lossy` and a private lowercase +//! `Display`. That is a duplicate, not a distinct scale (contrast +//! `srum-analysis`, whose `Clean/Informational/Suspicious/Critical` is a real +//! native scale and correctly normalizes at its boundary per ADR-0007), so it +//! migrates onto the canonical type rather than converting at a boundary. + +use forensicnomicon::report::Severity as Canonical; +use issen_signatures::matching::results::{MatchSource, ScanFinding, ScanReport, Severity}; + +/// The migration test. Written in constructs that compile both before and +/// after, so the failure is a runtime one naming the offending type rather +/// than a build break. +#[test] +fn severity_is_the_canonical_forensicnomicon_type() { + assert_eq!( + std::any::type_name::(), + std::any::type_name::(), + "issen-signatures must re-export forensicnomicon's Severity, not clone it" + ); +} + +/// Behaviour that must survive the migration: `Ord` drives `max_severity` and +/// the `--min-severity` threshold filter. +#[test] +fn ordering_and_threshold_filtering_survive_the_migration() { + assert!(Severity::Critical > Severity::High); + assert!(Severity::High > Severity::Medium); + assert!(Severity::Medium > Severity::Low); + + let mut report = ScanReport::new("target"); + report.add_finding(ScanFinding { + source: MatchSource::Yara, + severity: Severity::High, + rule_name: "hi".to_string(), + description: String::new(), + matched_indicator: None, + tags: Vec::new(), + }); + assert_eq!(report.max_severity(), Some(Severity::High)); + assert_eq!(report.findings_at_or_above(Severity::Medium).len(), 1); + assert_eq!(report.findings_at_or_above(Severity::Critical).len(), 0); +} From feb291e9ffcbddc085e91567da745155720e5f2e Mon Sep 17 00:00:00 2001 From: Albert Hui Date: Sun, 2 Aug 2026 03:23:33 +0800 Subject: [PATCH 2/2] fix(report): GREEN - one canonical Severity, one severity vocabulary, issen-owned codes Defect 1 - both Severity clones were pure duplicates, both migrated. issen-signatures `Informational/Low/Medium/High/Critical` and forensic-pivot `Critical/High/Medium/Low/Info` are the canonical five-tier scale under different spellings/orderings, not distinct native scales, so neither warrants an ADR-0007 boundary conversion. Both are now `pub use forensicnomicon::report::Severity`. - issen-signatures loses its private `from_str_lossy` and lowercase `Display`; the bottom tier is `Info`, and the persisted token comes from the shared vocabulary (`format!("{}", sev).to_lowercase()` in issen-cli::scanning still yields the same lowercase string, now "info" rather than "informational"). - forensic-pivot gains `Ord` and `Display` it never had - pivot findings could not be ranked at all before. It takes a `forensicnomicon` dependency with the `serde` feature; the five variant names are unchanged so bundled rule YAML keeps parsing. - `#[non_exhaustive]` forces `_` arms in issen-navigator (tree marker, severity colour), its alert mapper, and issen-cli's pivot table. Each escalates rather than silently downgrading an unknown future tier. Defect 2 - the rank/token/parse vocabulary now has one definition. New `issen_core::severity`: `SeverityExt::{rank, token}`, `parse`, `parse_lossy`, `TOKENS`. Deliberately a trait, so when forensicnomicon grows inherent `Severity::rank()`/`token()` those shadow it and this module is a pure deletion - no call site changes. That upstream move is a follow-up; a competing forensicnomicon PR is open this session, so nothing is changed there. Five copies collapse onto it: `severity_rank`/`severity_token` in issen-report (kept as one-line call-site aliases, not a second definition), `Correlation::severity_str`/`severity_from_str`, `severity_from_finding_str`, `navigator_output::severity_of`, and `Severity::from_str_lossy`. Two drifts the copies had already shipped are fixed: - `render_html` emitted `class="severity-info"` while the stylesheet only defined `.severity-informational`, so the Info tier rendered unstyled. - `Correlation::severity_from_str` was case-sensitive while issen-report's copy was case-insensitive, and issen-disk writes a capitalised "High" into event metadata. Both now parse case-insensitively. The bottom-tier token moves from "informational" to "info". `"informational"` stays accepted on every read path - `--min-severity`, the `scan_findings` query filter (which also still matches rows persisted with the old token), and the report parser - so pre-existing case DBs and invocations keep working. `issen_timeline::query_findings` no longer hardcodes its own level ladder. Defect 3 - the two codes describe related but genuinely different detections, so they stay distinct; issen's moves out of ntfs-forensic's namespace. ntfs-forensic owns `NTFS-` and already ships `NTFS-TIMESTOMP`, `NTFS-ADS`, `NTFS-DELETED-RECORD`, `NTFS-SLACK-RESIDUE`, `NTFS-MFTMIRR-MISMATCH` and `NTFS-LOGFILE-CLEARED`. issen consumes those codes, which makes it exactly the crate that must not also mint into the namespace. Reading both implementations, they are not one detection under two names: ntfs-forensic works from parsed `$STANDARD_INFORMATION`/`$FILE_NAME` attributes and fires on strict `$SI.created < $FN.created` OR any whole-second `$SI` stamp, always grading High; issen's works from TimelineEvent metadata, adds the `$SI.modified` ordering signal and a clock-skew tolerance, requires the CONTRAST form of the sub-second tell (`$SI` zeroed while `$FN` retains 100 ns precision), and downgrades on benign-context modifiers - capping at Medium by design as a low-confidence lead. Collapsing them onto one code would make an Info lead indistinguishable from a High filesystem anomaly. The real defect is that the old string was a prefix-extension of a shipped code, so prefix grouping conflated them: NTFS-TIMESTOMP-SI-FN-MISMATCH -> HEUR-TIMESTOMP-SI-FN NTFS-BOOT-BACKUP-MISMATCH -> HEUR-BOOT-BACKUP-MISMATCH `HEUR-` is issen's own detection-layer prefix (18 existing codes spanning timestomping, location, entropy, size, magic and USN). Both codes are now named constants (`TIMESTOMP_CODE`, `issen_disk::BOOT_BACKUP_CODE`) and asserted through the constant rather than a literal, so they cannot drift again. These are consumer-visible contract changes; the Case-001 Szechuan answers doc is updated in the same commit. The `$Boot`-vs-backup check itself belongs upstream in ntfs-forensic's `ArtifactAnomaly` family beside the `$MFTMirr` one (ADR-0016 puts filesystem-structure checks in the FILESYSTEM layer) - a cross-repo follow-up, noted in the constant's doc comment. Gate: cargo build --workspace clean; cargo test for the nine affected crates 1887 passed / 0 failed; cargo clippy --workspace --all-targets -D warnings clean; cargo fmt --check clean. One workspace test fails both here and on origin/main - issen-mem `dispatch::tests::dispatch_windows_netstat_returns_ok`, unrelated to this change and untouched by it. Co-Authored-By: Claude Opus 5 (1M context) --- Cargo.lock | 1 + crates/forensic-pivot/Cargo.toml | 3 + crates/forensic-pivot/src/engine.rs | 2 +- crates/forensic-pivot/src/rule.rs | 17 +- .../tests/severity_is_canonical.rs | 4 +- crates/issen-cli/src/commands/pivot_cmd.rs | 3 + crates/issen-cli/src/commands/scan.rs | 8 +- crates/issen-cli/src/commands/timeline.rs | 5 +- crates/issen-cli/src/lib.rs | 8 +- crates/issen-cli/src/scanning.rs | 9 +- crates/issen-core/src/lib.rs | 1 + crates/issen-core/src/severity.rs | 151 ++++++++++++++++++ crates/issen-correlation/src/correlation.rs | 24 +-- crates/issen-correlation/src/timestomp.rs | 22 ++- crates/issen-disk/src/lib.rs | 21 ++- .../src/investigation/alerts/engine.rs | 5 +- crates/issen-navigator/src/ui.rs | 9 +- crates/issen-report/src/lib.rs | 42 ++--- crates/issen-report/src/navigator_output.rs | 11 +- .../issen-signatures/src/matching/engine.rs | 4 +- .../issen-signatures/src/matching/results.rs | 86 ++++------ crates/issen-timeline/src/findings.rs | 22 +-- .../szechuan-sauce-union-answers.md | 4 +- 23 files changed, 307 insertions(+), 155 deletions(-) create mode 100644 crates/issen-core/src/severity.rs diff --git a/Cargo.lock b/Cargo.lock index d5b5edd4..b5fb94ac 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3404,6 +3404,7 @@ version = "0.1.0" dependencies = [ "anyhow", "flate2", + "forensicnomicon", "reqwest", "serde", "serde_json", diff --git a/crates/forensic-pivot/Cargo.toml b/crates/forensic-pivot/Cargo.toml index 9d77c8e5..535d4c48 100644 --- a/crates/forensic-pivot/Cargo.toml +++ b/crates/forensic-pivot/Cargo.toml @@ -11,6 +11,9 @@ repository.workspace = true [dependencies] anyhow = { workspace = true } flate2 = { workspace = true } +# The canonical `report::Severity` (ADR-0007). `serde` because pivot rules and +# findings are (de)serialized YAML/JSON. +forensicnomicon = { workspace = true, features = ["serde"] } serde = { workspace = true } serde_json = { workspace = true } serde_yaml = { workspace = true } diff --git a/crates/forensic-pivot/src/engine.rs b/crates/forensic-pivot/src/engine.rs index 1f86730d..df302081 100644 --- a/crates/forensic-pivot/src/engine.rs +++ b/crates/forensic-pivot/src/engine.rs @@ -88,7 +88,7 @@ impl PivotEngine { Some(Finding { rule_id: rule.id.clone(), rule_name: rule.name.clone(), - severity: rule.severity.clone(), + severity: rule.severity, assertion_level: rule.assertion_level.clone(), confidence: rule.default_confidence, matched_evidence: matched_ids, diff --git a/crates/forensic-pivot/src/rule.rs b/crates/forensic-pivot/src/rule.rs index 34fe49a9..9ea9520b 100644 --- a/crates/forensic-pivot/src/rule.rs +++ b/crates/forensic-pivot/src/rule.rs @@ -1,16 +1,15 @@ -// RED: stub — types declared but no real logic use crate::evidence::{EvidenceKind, EvidenceSource}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub enum Severity { - Critical, - High, - Medium, - Low, - Info, -} +/// Rule severity — the canonical fleet scale. +/// +/// This was a local `Critical/High/Medium/Low/Info` clone: the exact variant set +/// of [`forensicnomicon::report::Severity`], declared highest-first and deriving +/// only `PartialEq`, so pivot findings could not be ranked or thresholded at +/// all. Adopting the canonical type brings `Ord` and `Display` with it; the +/// five variant names are unchanged, so bundled rule YAML keeps parsing. +pub use forensicnomicon::report::Severity; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub enum AssertionLevel { diff --git a/crates/forensic-pivot/tests/severity_is_canonical.rs b/crates/forensic-pivot/tests/severity_is_canonical.rs index 87356fad..c919e7fb 100644 --- a/crates/forensic-pivot/tests/severity_is_canonical.rs +++ b/crates/forensic-pivot/tests/severity_is_canonical.rs @@ -27,7 +27,7 @@ fn rule_yaml_still_deserializes_every_severity_word() { // enum uses the same five words, so existing YAML keeps parsing. for word in ["Critical", "High", "Medium", "Low", "Info"] { let yaml = format!( - r#" + r" id: R-1 name: test rule description: d @@ -35,7 +35,7 @@ severity: {word} assertion_level: Observed default_confidence: 50 clauses: [] -"# +" ); let rule: PivotRule = serde_yaml::from_str(&yaml).unwrap_or_else(|e| panic!("`{word}` must parse: {e}")); diff --git a/crates/issen-cli/src/commands/pivot_cmd.rs b/crates/issen-cli/src/commands/pivot_cmd.rs index d468d255..94d5da2d 100644 --- a/crates/issen-cli/src/commands/pivot_cmd.rs +++ b/crates/issen-cli/src/commands/pivot_cmd.rs @@ -171,6 +171,9 @@ fn fmt_severity(s: &Severity) -> &'static str { Severity::Medium => "Medium", Severity::Low => "Low", Severity::Info => "Info", + // `Severity` is `#[non_exhaustive]`; a future variant gets a distinct + // sentinel rather than masquerading as a known tier. + _ => "Unknown", // cov:unreachable: five known variants today } } diff --git a/crates/issen-cli/src/commands/scan.rs b/crates/issen-cli/src/commands/scan.rs index c6017476..6261fb5b 100644 --- a/crates/issen-cli/src/commands/scan.rs +++ b/crates/issen-cli/src/commands/scan.rs @@ -8,12 +8,12 @@ use std::path::Path; use anyhow::{Context, Result}; use tracing::info; +use issen_core::severity::SeverityExt; use issen_signatures::engines::ioc_hash::HashFeed; use issen_signatures::engines::ioc_network::NetworkIocStore; use issen_signatures::engines::stix::StixParser; use issen_signatures::engines::yara::YaraEngine; use issen_signatures::matching::engine::ScanEngine; -use issen_signatures::matching::results::Severity; /// Run the scan subcommand. #[allow(clippy::too_many_arguments)] @@ -28,7 +28,7 @@ pub fn run( format: &str, auto_feeds: bool, ) -> Result<()> { - let threshold = Severity::from_str_lossy(min_severity); + let threshold = issen_core::severity::parse_lossy(min_severity); // Build the scan engine — optionally pre-loaded from cached feeds. let mut engine = if auto_feeds { @@ -271,7 +271,7 @@ fn print_text_report( .unwrap_or_default(); println!( " [{severity}] ({source}) {rule}{indicator}", - severity = f.severity, + severity = f.severity.token(), source = f.source, rule = f.rule_name, indicator = indicator_str, @@ -298,7 +298,7 @@ fn print_json_reports( "findings": findings.iter().map(|f| { serde_json::json!({ "source": format!("{}", f.source), - "severity": format!("{}", f.severity), + "severity": f.severity.token(), "rule_name": f.rule_name, "description": f.description, "matched_indicator": f.matched_indicator, diff --git a/crates/issen-cli/src/commands/timeline.rs b/crates/issen-cli/src/commands/timeline.rs index 13e63c5a..57cf7a85 100644 --- a/crates/issen-cli/src/commands/timeline.rs +++ b/crates/issen-cli/src/commands/timeline.rs @@ -2,6 +2,7 @@ use std::io; use std::path::Path; use anyhow::{anyhow, Context, Result}; +use forensicnomicon::report::Severity; use issen_core::timeline::event::TimelineEvent; use issen_correlation::temporal_rule::{ bundled_temporal_rules, evaluate_temporal, TemporalFinding, @@ -188,7 +189,9 @@ fn show_flagged(store: &TimelineStore, min_severity: &str, format: &str) -> Resu // Ensure the table exists (it may not if no scanning was done). findings::create_findings_table(conn).context("Failed to access findings table")?; - let severity_filter = if min_severity == "informational" { + // The bottom tier means "no floor" — parse rather than string-compare, so + // both `info` and the legacy `informational` spelling are recognised. + let severity_filter = if issen_core::severity::parse_lossy(min_severity) == Severity::Info { None } else { Some(min_severity) diff --git a/crates/issen-cli/src/lib.rs b/crates/issen-cli/src/lib.rs index 3ff26383..d2ebcd2b 100644 --- a/crates/issen-cli/src/lib.rs +++ b/crates/issen-cli/src/lib.rs @@ -233,8 +233,8 @@ pub enum Commands { #[arg(long)] flagged: bool, - /// Minimum severity for --flagged output (informational, low, medium, high, critical). - #[arg(long, default_value = "informational")] + /// Minimum severity for --flagged output (info, low, medium, high, critical). + #[arg(long, default_value = "info")] min_severity: String, /// Output format: text or json (json is jsonguard-sanitized). @@ -405,8 +405,8 @@ pub enum Commands { #[arg(long)] stix_bundle: Option>, - /// Minimum severity to report (informational, low, medium, high, critical). - #[arg(long, default_value = "informational")] + /// Minimum severity to report (info, low, medium, high, critical). + #[arg(long, default_value = "info")] min_severity: String, /// Output format: text, json. diff --git a/crates/issen-cli/src/scanning.rs b/crates/issen-cli/src/scanning.rs index 959de6d2..485e4c8e 100644 --- a/crates/issen-cli/src/scanning.rs +++ b/crates/issen-cli/src/scanning.rs @@ -1295,7 +1295,10 @@ detection: .iter() .find(|f| f.engine == "Timestomp") .expect("scan phase must emit a Timestomp FindingRow for an $SI<$FN FileCreate"); - assert_eq!(timestomp.rule_name, "NTFS-TIMESTOMP-SI-FN-MISMATCH"); + assert_eq!( + timestomp.rule_name, + issen_correlation::timestomp::TIMESTOMP_CODE + ); assert!( timestomp.tags.contains("attack.t1070.006"), "timestomp finding must carry the MITRE T1070.006 tag: {}", @@ -1339,8 +1342,8 @@ detection: let rows = issen_timeline::findings::query_findings(store.connection(), None) .expect("query findings"); assert!( - rows.iter() - .any(|r| r.engine == "Timestomp" && r.rule_name == "NTFS-TIMESTOMP-SI-FN-MISMATCH"), + rows.iter().any(|r| r.engine == "Timestomp" + && r.rule_name == issen_correlation::timestomp::TIMESTOMP_CODE), "the timestomp finding must be persisted to scan_findings, got {} rows", rows.len() ); diff --git a/crates/issen-core/src/lib.rs b/crates/issen-core/src/lib.rs index 084ebe8e..bf4b814a 100644 --- a/crates/issen-core/src/lib.rs +++ b/crates/issen-core/src/lib.rs @@ -8,6 +8,7 @@ pub mod container; pub mod coverage; pub mod error; pub mod plugin; +pub mod severity; pub mod timeline; /// CADET forensic-semantic category, re-exported from `forensicnomicon` for diff --git a/crates/issen-core/src/severity.rs b/crates/issen-core/src/severity.rs new file mode 100644 index 00000000..f3ad0088 --- /dev/null +++ b/crates/issen-core/src/severity.rs @@ -0,0 +1,151 @@ +//! The single source of truth for issen's severity rank / token / parse +//! vocabulary over [`forensicnomicon::report::Severity`]. +//! +//! The canonical enum already carries the *ordering* (`Ord`) and the +//! *human-facing* rendering (`Display`, uppercase). What it does not yet carry +//! is the **persisted** form: the lowercase token issen writes into +//! `scan_findings.severity`, the `correlations.severity` column, and the report +//! stylesheet's `.severity-` classes — plus the parse back. +//! +//! Those were hand-rolled three times (`issen-report`, `issen-correlation`, and +//! the parse half again in `issen-report::navigator_output` and +//! `issen-signatures`) and had already drifted apart. One definition lives here. +//! +//! **Interim home.** The right owner is `forensicnomicon` itself — +//! `Severity::rank()` and `Severity::token()` as inherent methods. Once those +//! land upstream they *shadow* [`SeverityExt`], so this module can be deleted +//! without touching a single call site. + +use forensicnomicon::report::Severity; + +/// The canonical lowercase tokens, ordered lowest severity to highest. +/// +/// Index into this array is the tier's [`SeverityExt::rank`]. +pub const TOKENS: [&str; 5] = ["info", "low", "medium", "high", "critical"]; + +/// The pre-consolidation spelling of the bottom tier, still accepted on read. +/// +/// `issen-signatures` persisted `Informational`/`"informational"` before it +/// migrated onto the canonical enum, so case DBs written by earlier runs carry +/// that token in `scan_findings.severity`. +const LEGACY_INFO_ALIAS: &str = "informational"; + +/// Rank and token accessors for [`Severity`]. +/// +/// Deliberately a trait rather than free functions so the eventual upstream +/// inherent methods shadow it and the migration is a pure deletion. +pub trait SeverityExt { + /// Total-ordering rank, `Info` = 0 through `Critical` = 4. + /// + /// Always consistent with the enum's derived `Ord`. + fn rank(self) -> u8; + + /// The lowercase token persisted in `DuckDB` and emitted as a CSS class. + fn token(self) -> &'static str; +} + +impl SeverityExt for Severity { + fn rank(self) -> u8 { + match self { + Severity::Info => 0, + Severity::Low => 1, + Severity::Medium => 2, + Severity::High => 3, + Severity::Critical => 4, + // `Severity` is `#[non_exhaustive]`; an unknown future variant ranks + // above the known set rather than masquerading as Info. + _ => 5, // cov:unreachable: Severity has exactly five known variants today + } + } + + fn token(self) -> &'static str { + match self { + Severity::Info => TOKENS[0], + Severity::Low => TOKENS[1], + Severity::Medium => TOKENS[2], + Severity::High => TOKENS[3], + Severity::Critical => TOKENS[4], + // A future variant gets a distinct sentinel rather than + // masquerading as a known severity. + _ => "unknown", // cov:unreachable: Severity has exactly five known variants today + } + } +} + +/// Parse a severity token, case-insensitively. `None` for anything that is not +/// a severity — the caller decides whether that is an error or a default. +/// +/// Accepts the canonical `Display` form (`"HIGH"`), the persisted token +/// (`"high"`), and the legacy `"informational"` spelling of `Info`. +#[must_use] +pub fn parse(s: &str) -> Option { + match s.to_ascii_lowercase().as_str() { + "info" | LEGACY_INFO_ALIAS => Some(Severity::Info), + "low" => Some(Severity::Low), + "medium" => Some(Severity::Medium), + "high" => Some(Severity::High), + "critical" => Some(Severity::Critical), + _ => None, + } +} + +/// [`parse`], degrading an unrecognized token to the lowest tier. +/// +/// For inputs where refusing is worse than under-grading: a `--min-severity` +/// flag, or a Sigma rule whose `level` field is absent or non-standard. +#[must_use] +pub fn parse_lossy(s: &str) -> Severity { + parse(s).unwrap_or(Severity::Info) +} + +#[cfg(test)] +mod tests { + use super::{parse, parse_lossy, SeverityExt, TOKENS}; + use forensicnomicon::report::Severity; + + const LADDER: [Severity; 5] = [ + Severity::Info, + Severity::Low, + Severity::Medium, + Severity::High, + Severity::Critical, + ]; + + #[test] + fn rank_and_token_agree_with_tokens_order() { + for (idx, sev) in LADDER.iter().enumerate() { + assert_eq!(usize::from(sev.rank()), idx); + assert_eq!(sev.token(), TOKENS[idx]); + } + } + + #[test] + fn rank_never_disagrees_with_ord() { + for pair in LADDER.windows(2) { + assert!(pair[0] < pair[1]); + assert!(pair[0].rank() < pair[1].rank()); + } + } + + #[test] + fn parse_round_trips_token_and_display() { + for sev in LADDER { + assert_eq!(parse(sev.token()), Some(sev)); + assert_eq!(parse(&sev.to_string()), Some(sev)); + } + } + + #[test] + fn parse_accepts_the_legacy_informational_alias() { + assert_eq!(parse("informational"), Some(Severity::Info)); + assert_eq!(parse("Informational"), Some(Severity::Info)); + } + + #[test] + fn parse_rejects_a_non_severity_and_parse_lossy_floors_it() { + assert_eq!(parse("catastrophic"), None); + assert_eq!(parse(""), None); + assert_eq!(parse_lossy("catastrophic"), Severity::Info); + assert_eq!(parse_lossy(""), Severity::Info); + } +} diff --git a/crates/issen-correlation/src/correlation.rs b/crates/issen-correlation/src/correlation.rs index 65180a29..ad58b436 100644 --- a/crates/issen-correlation/src/correlation.rs +++ b/crates/issen-correlation/src/correlation.rs @@ -11,6 +11,7 @@ //! `correlation_members` tables, keyed on `timeline.id`. use forensicnomicon::report::Severity; +use issen_core::severity::SeverityExt; /// The host/dump scope a correlation's members share. /// @@ -186,29 +187,16 @@ impl Correlation { /// The stable lowercase severity token persisted in the `severity` column. #[must_use] pub fn severity_str(&self) -> &'static str { - match self.severity { - Severity::Info => "info", - Severity::Low => "low", - Severity::Medium => "medium", - Severity::High => "high", - Severity::Critical => "critical", - // `forensicnomicon::report::Severity` is `#[non_exhaustive]`; a future - // variant maps to a distinct sentinel rather than masquerading as info. - _ => "unknown", // cov:unreachable: Severity has exactly five known variants today - } + self.severity.token() } /// Parse a persisted severity token back into a [`Severity`]. + /// + /// Case-insensitive: `issen-disk` writes a capitalised `"High"` into event + /// metadata, which the previous case-sensitive copy silently dropped. #[must_use] pub fn severity_from_str(s: &str) -> Option { - match s { - "info" => Some(Severity::Info), - "low" => Some(Severity::Low), - "medium" => Some(Severity::Medium), - "high" => Some(Severity::High), - "critical" => Some(Severity::Critical), - _ => None, - } + issen_core::severity::parse(s) } } diff --git a/crates/issen-correlation/src/timestomp.rs b/crates/issen-correlation/src/timestomp.rs index 1196209a..e09e76ae 100644 --- a/crates/issen-correlation/src/timestomp.rs +++ b/crates/issen-correlation/src/timestomp.rs @@ -25,9 +25,25 @@ use forensicnomicon::report::{Category, Finding, Severity, Source}; use issen_core::timeline::event::{EventType, TimelineEvent}; use jiff::Timestamp; -/// Stable, scheme-prefixed finding code (published contract — never change). -/// Matches the Case 001 capability-gaps sub-plan (Workstream C2). -pub const TIMESTOMP_CODE: &str = "NTFS-TIMESTOMP-SI-FN-MISMATCH"; +/// Stable, scheme-prefixed finding code. +/// +/// `HEUR-` is issen's own detection-layer prefix (timestomping, location, +/// entropy, size, magic, USN). It is deliberately **not** `NTFS-`: that scheme +/// belongs to `ntfs-forensic`, which already ships `NTFS-TIMESTOMP` for the +/// filesystem-layer `$SI`/`$FN` check. The previous code here, +/// `NTFS-TIMESTOMP-SI-FN-MISMATCH`, was a string prefix-extension of it, so a +/// consumer grouping by code prefix conflated this correlation-layer Info lead +/// with that analyzer's flat `High` anomaly. +/// +/// The two remain **distinct detections**, not one duplicated under two names: +/// `ntfs-forensic` reads parsed `$STANDARD_INFORMATION`/`$FILE_NAME` attributes +/// and fires on a strict `$SI.created < $FN.created` *or* any whole-second `$SI` +/// stamp, always grading `High`; this one reads `TimelineEvent` metadata, adds +/// the `$SI.modified` ordering signal and a clock-skew tolerance, requires the +/// *contrast* form of the sub-second tell (`$SI` zeroed **while** `$FN` retains +/// 100 ns precision), and downgrades on benign-context modifiers — capping at +/// `Medium` by design. +pub const TIMESTOMP_CODE: &str = "HEUR-TIMESTOMP-SI-FN"; /// Detect `$SI`/`$FN` birth-time inconsistency on a single `FileCreate` event. /// diff --git a/crates/issen-disk/src/lib.rs b/crates/issen-disk/src/lib.rs index f2b7bb52..290a5fbb 100644 --- a/crates/issen-disk/src/lib.rs +++ b/crates/issen-disk/src/lib.rs @@ -1936,6 +1936,21 @@ fn parse_boot_at(source: &dyn DataSource, offset: u64) -> Result Vec let severity = match anomaly.severity { Severity::Critical => AlertSeverity::Critical, Severity::High | Severity::Medium => AlertSeverity::Warning, - Severity::Low | Severity::Informational => AlertSeverity::Info, + Severity::Low | Severity::Info => AlertSeverity::Info, + // `Severity` is `#[non_exhaustive]`; an unknown future tier + // escalates rather than being downgraded to Info. + _ => AlertSeverity::Critical, // cov:unreachable: five known variants today }; alerts.push(Alert { diff --git a/crates/issen-navigator/src/ui.rs b/crates/issen-navigator/src/ui.rs index f0e1754c..0694c04a 100644 --- a/crates/issen-navigator/src/ui.rs +++ b/crates/issen-navigator/src/ui.rs @@ -247,7 +247,10 @@ fn draw_file_list(frame: &mut Frame, area: Rect, app: &mut App) { let marker = match app.anomaly_index.max_severity(idx) { Some(Severity::Critical | Severity::High) => "\u{1f6a8} ", // 🚨 Some(Severity::Medium) => "\u{1f7e1} ", // 🟡 - Some(Severity::Low | Severity::Informational) => "\u{1f535} ", // 🔵 + Some(Severity::Low | Severity::Info) => "\u{1f535} ", // 🔵 + // `Severity` is `#[non_exhaustive]`; an unknown future tier is + // marked as noteworthy rather than silently unmarked. + Some(_) => "\u{1f6a8} ", // cov:unreachable: five known variants today None => "", }; @@ -517,7 +520,9 @@ fn draw_detail_panel(frame: &mut Frame, area: Rect, app: &mut App) { Severity::High => Color::LightRed, Severity::Medium => Color::Yellow, Severity::Low => Color::Blue, - Severity::Informational => Color::DarkGray, + Severity::Info => Color::DarkGray, + // `Severity` is `#[non_exhaustive]`. + _ => Color::Red, // cov:unreachable: five known variants today }; lines.push(Line::from(vec![ diff --git a/crates/issen-report/src/lib.rs b/crates/issen-report/src/lib.rs index 2c067ead..8865f9f9 100644 --- a/crates/issen-report/src/lib.rs +++ b/crates/issen-report/src/lib.rs @@ -32,6 +32,7 @@ pub use mermaid::{ }; use forensicnomicon::report::Severity; +use issen_core::severity::SeverityExt; use issen_correlation::correlation::Correlation; // --------------------------------------------------------------------------- @@ -477,47 +478,26 @@ pub struct RuleGroup { pub instances: Vec, } +// The severity rank/token/parse vocabulary is defined once, in +// `issen_core::severity`. These are call-site aliases over it, not a second +// definition — the bodies below must stay one-liners. + /// Total ordering rank for a severity (`Info` lowest, `Critical` highest). #[must_use] fn severity_rank(s: Severity) -> u8 { - match s { - Severity::Info => 0, - Severity::Low => 1, - Severity::Medium => 2, - Severity::High => 3, - Severity::Critical => 4, - // `Severity` is `#[non_exhaustive]`; an unknown future variant ranks - // above the known set rather than masquerading as Info. - _ => 5, // cov:unreachable: Severity has exactly five known variants today - } + s.rank() } /// Lowercase severity token for CSS classes / display. #[must_use] fn severity_token(s: Severity) -> &'static str { - match s { - Severity::Info => "info", - Severity::Low => "low", - Severity::Medium => "medium", - Severity::High => "high", - Severity::Critical => "critical", - // `Severity` is `#[non_exhaustive]`; an unknown future variant gets a - // distinct sentinel rather than masquerading as a known severity. - _ => "unknown", // cov:unreachable: Severity has exactly five known variants today - } + s.token() } /// Parse a `scan_findings.severity` token into a [`Severity`]. #[must_use] fn severity_from_finding_str(s: &str) -> Option { - match s.to_ascii_lowercase().as_str() { - "critical" => Some(Severity::Critical), - "high" => Some(Severity::High), - "medium" => Some(Severity::Medium), - "low" => Some(Severity::Low), - "info" | "informational" => Some(Severity::Info), - _ => None, - } + issen_core::severity::parse(s) } /// Format a nanoseconds-since-epoch instant as a readable UTC string. @@ -902,7 +882,7 @@ td {{ font-family: "SF Mono", "Fira Code", "Consolas", monospace; word-break: br .severity-high {{ color: var(--severity-high); font-weight: bold; }} .severity-medium {{ color: var(--severity-medium); }} .severity-low {{ color: var(--severity-low); }} -.severity-informational {{ color: var(--severity-info); }} +.severity-info {{ color: var(--severity-info); }} .attack-note {{ color: #8899aa; font-size: 0.82rem; margin-bottom: 12px; }} .attack-chain {{ display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 8px 0; }} .attack-node {{ padding: 10px 14px; border-radius: 6px; color: #fff; font-weight: bold; font-size: 0.85rem; white-space: nowrap; }} @@ -2581,7 +2561,7 @@ mod tests { fn render_html_appendix_surfaces_medium_findings_and_collapses_info_low() { let mut findings = vec![FindingRow { engine: "Timestomp".to_string(), - rule_name: "NTFS-TIMESTOMP-SI-FN-MISMATCH".to_string(), + rule_name: "HEUR-TIMESTOMP-SI-FN".to_string(), severity: "medium".to_string(), target: "FileShare/Secret/Beth_Secret.txt".to_string(), description: "SI Vec { } /// Map an issen severity string to a `forensicnomicon` [`Severity`]. +/// +/// A persisted token is free text as far as this layer is concerned, so an +/// unrecognized one floors to `Info` rather than dropping the finding. fn severity_of(s: &str) -> Severity { - match s.to_lowercase().as_str() { - "critical" => Severity::Critical, - "high" => Severity::High, - "medium" => Severity::Medium, - "low" => Severity::Low, - _ => Severity::Info, - } + issen_core::severity::parse_lossy(s) } /// Convert a [`FindingRow`] into a `forensicnomicon` [`Finding`] carrying its diff --git a/crates/issen-signatures/src/matching/engine.rs b/crates/issen-signatures/src/matching/engine.rs index 94d59be2..e54a0ca9 100644 --- a/crates/issen-signatures/src/matching/engine.rs +++ b/crates/issen-signatures/src/matching/engine.rs @@ -204,7 +204,9 @@ impl ScanEngine { for m in sigma.evaluate(event) { findings.push(ScanFinding { source: MatchSource::Sigma, - severity: Severity::from_str_lossy(&m.level), + // A Sigma `level` is free text and may be absent or + // non-standard, so it degrades to the lowest tier. + severity: issen_core::severity::parse_lossy(&m.level), rule_name: m.rule_title.clone(), description: m .description diff --git a/crates/issen-signatures/src/matching/results.rs b/crates/issen-signatures/src/matching/results.rs index 14940377..abea80cf 100644 --- a/crates/issen-signatures/src/matching/results.rs +++ b/crates/issen-signatures/src/matching/results.rs @@ -31,40 +31,18 @@ impl fmt::Display for MatchSource { } } -/// Severity level for a scan finding. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub enum Severity { - Informational, - Low, - Medium, - High, - Critical, -} - -impl Severity { - /// Parse a severity string (case-insensitive). - pub fn from_str_lossy(s: &str) -> Self { - match s.to_lowercase().as_str() { - "critical" => Self::Critical, - "high" => Self::High, - "medium" => Self::Medium, - "low" => Self::Low, - _ => Self::Informational, - } - } -} - -impl fmt::Display for Severity { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Informational => write!(f, "informational"), - Self::Low => write!(f, "low"), - Self::Medium => write!(f, "medium"), - Self::High => write!(f, "high"), - Self::Critical => write!(f, "critical"), - } - } -} +/// Severity level for a scan finding — the canonical fleet scale. +/// +/// This was a local `Informational/Low/Medium/High/Critical` clone with its own +/// `from_str_lossy` and lowercase `Display`. It is the same five-tier scale as +/// [`forensicnomicon::report::Severity`] (spelling the bottom tier +/// `Informational` rather than `Info`), not a distinct native scale, so it is +/// the canonical type rather than something normalized at a boundary. +/// +/// Parse with [`issen_core::severity::parse_lossy`]; the persisted lowercase +/// token is [`issen_core::severity::SeverityExt::token`] (the canonical +/// `Display` is uppercase). +pub use forensicnomicon::report::Severity; /// A single scan finding from any engine. #[derive(Debug, Clone)] @@ -141,6 +119,7 @@ impl ScanReport { #[cfg(test)] mod tests { use super::*; + use issen_core::severity::{self, SeverityExt}; fn sample_finding(source: MatchSource, severity: Severity, name: &str) -> ScanFinding { ScanFinding { @@ -199,11 +178,7 @@ mod tests { #[test] fn test_findings_at_or_above() { let mut report = ScanReport::new("test"); - report.add_finding(sample_finding( - MatchSource::Yara, - Severity::Informational, - "r1", - )); + report.add_finding(sample_finding(MatchSource::Yara, Severity::Info, "r1")); report.add_finding(sample_finding(MatchSource::Sigma, Severity::High, "r2")); report.add_finding(sample_finding( MatchSource::HashIoc, @@ -216,7 +191,7 @@ mod tests { let high_plus = report.findings_at_or_above(Severity::High); assert_eq!(high_plus.len(), 2); // High + Critical - let all = report.findings_at_or_above(Severity::Informational); + let all = report.findings_at_or_above(Severity::Info); assert_eq!(all.len(), 5); } @@ -225,26 +200,29 @@ mod tests { assert!(Severity::Critical > Severity::High); assert!(Severity::High > Severity::Medium); assert!(Severity::Medium > Severity::Low); - assert!(Severity::Low > Severity::Informational); + assert!(Severity::Low > Severity::Info); } #[test] - fn test_severity_from_str_lossy() { - assert_eq!(Severity::from_str_lossy("critical"), Severity::Critical); - assert_eq!(Severity::from_str_lossy("HIGH"), Severity::High); - assert_eq!(Severity::from_str_lossy("Medium"), Severity::Medium); - assert_eq!(Severity::from_str_lossy("low"), Severity::Low); - assert_eq!( - Severity::from_str_lossy("informational"), - Severity::Informational - ); - assert_eq!(Severity::from_str_lossy("unknown"), Severity::Informational); + fn test_severity_parse_lossy() { + // The parse half now lives in the shared vocabulary; `"informational"` + // stays accepted so pre-migration `--min-severity` invocations and + // persisted rows keep resolving. + assert_eq!(severity::parse_lossy("critical"), Severity::Critical); + assert_eq!(severity::parse_lossy("HIGH"), Severity::High); + assert_eq!(severity::parse_lossy("Medium"), Severity::Medium); + assert_eq!(severity::parse_lossy("low"), Severity::Low); + assert_eq!(severity::parse_lossy("informational"), Severity::Info); + assert_eq!(severity::parse_lossy("unknown"), Severity::Info); } #[test] - fn test_severity_display() { - assert_eq!(format!("{}", Severity::Critical), "critical"); - assert_eq!(format!("{}", Severity::Informational), "informational"); + fn test_severity_token_is_the_persisted_form() { + // The canonical `Display` is uppercase; the persisted/CSS token is + // lowercase and comes from the shared vocabulary. + assert_eq!(format!("{}", Severity::Critical), "CRITICAL"); + assert_eq!(Severity::Critical.token(), "critical"); + assert_eq!(Severity::Info.token(), "info"); } #[test] diff --git a/crates/issen-timeline/src/findings.rs b/crates/issen-timeline/src/findings.rs index 4b90f2c6..7ec232e1 100644 --- a/crates/issen-timeline/src/findings.rs +++ b/crates/issen-timeline/src/findings.rs @@ -5,6 +5,7 @@ // is created lazily alongside the timeline schema. use duckdb::Connection; +use issen_core::severity::SeverityExt; use crate::store::TimelineStoreError; @@ -100,22 +101,25 @@ pub fn insert_findings( /// Query findings, optionally filtered by minimum severity. /// -/// Severity ordering: critical > high > medium > low > informational. +/// Severity ordering: critical > high > medium > low > info. +/// +/// `min_severity` is parsed through the shared vocabulary, so both the canonical +/// `"info"` and the pre-consolidation `"informational"` spelling resolve to the +/// same floor. Rows written before the consolidation carry `"informational"`, so +/// the bottom tier matches either token. pub fn query_findings( conn: &Connection, min_severity: Option<&str>, ) -> Result, TimelineStoreError> { - let severity_levels = ["informational", "low", "medium", "high", "critical"]; - let sql = if let Some(min_sev) = min_severity { - let min_idx = severity_levels + let min_idx = usize::from(issen_core::severity::parse_lossy(min_sev).rank()); + let mut allowed: Vec = issen_core::severity::TOKENS[min_idx..] .iter() - .position(|&s| s == min_sev.to_lowercase()) - .unwrap_or(0); - let allowed: Vec = severity_levels[min_idx..] - .iter() - .map(|s| format!("'{}'", s)) + .map(|s| format!("'{s}'")) .collect(); + if min_idx == 0 { + allowed.push("'informational'".to_string()); + } format!( "SELECT evidence_source_id, artifact_path, engine, severity, rule_name, description, matched_indicator, tags diff --git a/tests/data/dfirmadness-szechuan-sauce/szechuan-sauce-writeups/szechuan-sauce-union-answers.md b/tests/data/dfirmadness-szechuan-sauce/szechuan-sauce-writeups/szechuan-sauce-union-answers.md index f22dd372..11c2d0a5 100644 --- a/tests/data/dfirmadness-szechuan-sauce/szechuan-sauce-writeups/szechuan-sauce-union-answers.md +++ b/tests/data/dfirmadness-szechuan-sauce/szechuan-sauce-writeups/szechuan-sauce-union-answers.md @@ -52,7 +52,7 @@ issen measures most of the disk-leg answers and the memory-leg essentials end-to - **Disk leg — the common artifacts are wired and produce on the real image:** $MFT (693k events), $UsnJrnl/$J (82k), Registry hives (211k), EVTX (85k), plus **Shimcache** (140, via the SYSTEM-hive AppCompatCache decoder), **UserAssist** (52, with `coreupdater.exe`’s GUI run-count), and **LNK / Jump Lists** (18 + 15, including the `Secret.lnk` / `Beth_Secret.lnk` / `SECRET_beth.lnk` staging shortcuts → `C:\FileShare\`). **Amcache** decodes the legacy `Root\File` schema (Win8 / Server 2012 R2) as of `winreg-artifacts 0.2.2` — 136 file entries on the DC, validated against the regipy oracle. **Prefetch** is *searched-absent* on the DC (Server 2012 R2 ships prefetch disabled), and this is now reported in the coverage line rather than silently dropped. - **Evidence-of-execution is MEASURED, not merely inferred:** `coreupdater.exe` surfaces as UserAssist `ProcessExec` (run metadata), a `coreupdater` `ServiceInstall` (EVTX 7045), and the full MFT + USN file lifecycle; its presence + SHA-1 also ride the Amcache/Shimcache inventory. - **Registry named values (PRE-3):** OS build (F1), timezone (F3), computer name, and the host **network config (Q9: `10.42.85.10` / `255.255.255.0` / gw `10.42.85.100`, `CITADEL-DC01` / domain `C137.local`)** are extracted as `system-info` events, validated against the regipy oracle. -- **Timestomp (B8) is MEASURED:** the bare-pipeline Scan stage flags `FileShare/Secret/Beth_Secret.txt` at **Medium** (`NTFS-TIMESTOMP-SI-FN-MISMATCH`) — the single elevated `$SI`/`$FN` timestomp standing out from the benign copy-leads. +- **Timestomp (B8) is MEASURED:** the bare-pipeline Scan stage flags `FileShare/Secret/Beth_Secret.txt` at **Medium** (`HEUR-TIMESTOMP-SI-FN`) — the single elevated `$SI`/`$FN` timestomp standing out from the benign copy-leads. - **Memory leg — MEASURED:** the process list (40 processes incl. `coreupdater.exe` 3644, `spoolsv.exe` 3724) and the **netstat C2 row (`coreupdater.exe` → `203.78.103.109:443`)** are recovered from `citadeldc01.mem`. The remaining **OUT** findings are answered from the write-ups only: OSINT/attribution (Q7), tool naming (Hydra/Metasploit, Q6.8/F38), pcap byte-level transfer (Q9/Q11), offline password cracking (B6), and `$I`/`$R` **content** carving (B7 — the deleted *names* and metadata are recovered; the original file *bytes* are not). @@ -127,7 +127,7 @@ Last interactive logoff around **03:00 network clock**; at memory-capture time a **Can you recover the original file about Beth’s secrets?** Yes — from the recycle bin. Original name `SECRET_beth.txt`; original contents “Earth Beth is the real Beth.” (recovered from `$Recycle.Bin\S-1-…-500` `$R` data in W8). The replacement `Beth_Secret.txt` carries a different secret. — F24, F43; W2, W8. **OUT** (issen recognizes recycle-bin paths but has no `$I`/`$R` content carver). -**What file was timestomped?** `Beth_Secret.txt`, stomped with Meterpreter to match `PortalGunPlans.txt`. The bare-pipeline Scan stage flags it `FileShare/Secret/Beth_Secret.txt` at **Medium** (`NTFS-TIMESTOMP-SI-FN-MISMATCH`) — the MFT parser rides all four `$FN` and `$SI` MACE values on the `FileCreate` event, and the detector fires the strict `$SI`<`$FN` ordering plus the sub-second-zeroing corroborator (a whole-second `$SI` back-date against a 100 ns-precise `$FN`). It is the single elevated timestomp among the benign copy-leads. — F24-adjacent; W2. **MEASURED-BY-ISSEN.** +**What file was timestomped?** `Beth_Secret.txt`, stomped with Meterpreter to match `PortalGunPlans.txt`. The bare-pipeline Scan stage flags it `FileShare/Secret/Beth_Secret.txt` at **Medium** (`HEUR-TIMESTOMP-SI-FN`) — the MFT parser rides all four `$FN` and `$SI` MACE values on the `FileCreate` event, and the detector fires the strict `$SI`<`$FN` ordering plus the sub-second-zeroing corroborator (a whole-second `$SI` back-date against a 100 ns-precise `$FN`). It is the single elevated timestomp among the benign copy-leads. — F24-adjacent; W2. **MEASURED-BY-ISSEN.** ---