Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions crates/forensic-pivot/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
2 changes: 1 addition & 1 deletion crates/forensic-pivot/src/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
17 changes: 8 additions & 9 deletions crates/forensic-pivot/src/rule.rs
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down
36 changes: 36 additions & 0 deletions crates/forensic-pivot/tests/severity_gains_canonical_ordering.rs
Original file line number Diff line number Diff line change
@@ -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
]
);
}
44 changes: 44 additions & 0 deletions crates/forensic-pivot/tests/severity_is_canonical.rs
Original file line number Diff line number Diff line change
@@ -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::<Severity>();
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);
}
}
3 changes: 3 additions & 0 deletions crates/issen-cli/src/commands/pivot_cmd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}

Expand Down
8 changes: 4 additions & 4 deletions crates/issen-cli/src/commands/scan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand All @@ -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 {
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
5 changes: 4 additions & 1 deletion crates/issen-cli/src/commands/timeline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down
8 changes: 4 additions & 4 deletions crates/issen-cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -405,8 +405,8 @@ pub enum Commands {
#[arg(long)]
stix_bundle: Option<Vec<PathBuf>>,

/// 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.
Expand Down
9 changes: 6 additions & 3 deletions crates/issen-cli/src/scanning.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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: {}",
Expand Down Expand Up @@ -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()
);
Expand Down
1 change: 1 addition & 0 deletions crates/issen-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading