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_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..c919e7fb --- /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-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-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/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-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..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-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/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-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); +} 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.** ---