From 4624b4e198b16a245c6b521456cc4b79844ae445 Mon Sep 17 00:00:00 2001 From: Albert Hui Date: Sun, 2 Aug 2026 10:53:03 +0800 Subject: [PATCH 1/6] test(cli): RED - supertimeline CSV is emitted with no escaping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `emit_csv` interpolated every field straight into the row. Only `description` got quote-doubling, and even that was wrapped in unconditional quotes rather than escaped, so: - a description beginning with `=`, `+`, `-` or `@` reaches the file as a live formula (these values come from evidence, so they are attacker-chosen); - a comma in a tag or in `EventType::Other(..)` adds a column, silently shifting every later field of that row; - a double quote in a tag is emitted raw. Extract `csv_row` / `CSV_HEADER` from `emit_csv` unchanged (Humble Object, so the row is assertable without capturing stdout), then assert the properties the emitter should hold. Rows are split with the `csv` crate — an independent RFC 4180 reader already in issen-cli's dependency set, not a splitter written for this test — so the column-structure claim is checked by the same class of parser a spreadsheet uses. 4 of the 6 new tests fail: csv_formula_prefixed_description_is_guarded description beginning with a formula character must be guarded with a leading apostrophe; got "=cmd|'/C calc'!A0" csv_comma_in_tag_does_not_break_columns left: 6 right: 5 csv_comma_in_event_type_does_not_break_columns left: 6 right: 5 csv_quote_in_tag_is_escaped left: 6 right: 5 Co-Authored-By: Claude Opus 5 (1M context) --- .../issen-cli/src/commands/supertimeline.rs | 138 +++++++++++++++++- 1 file changed, 131 insertions(+), 7 deletions(-) diff --git a/crates/issen-cli/src/commands/supertimeline.rs b/crates/issen-cli/src/commands/supertimeline.rs index e05cf117..90013447 100644 --- a/crates/issen-cli/src/commands/supertimeline.rs +++ b/crates/issen-cli/src/commands/supertimeline.rs @@ -72,15 +72,28 @@ fn emit_jsonl(events: &[TimelineEvent]) { } } +/// Column header for the CSV output. Kept beside [`csv_row`] so the two cannot +/// drift apart. +const CSV_HEADER: &str = "timestamp,event_type,source,description,tags"; + +/// Render one timeline event as a CSV record. +/// +/// Every field is attacker-influenced: `description` and `tags` are built from +/// evidence bytes, and `event_type` carries a parser-supplied string in its +/// `Other(..)` variant. +fn csv_row(ev: &TimelineEvent) -> String { + let ts = ev.timestamp_ns; + let et = format!("{:?}", ev.event_type); + let src = format!("{}", ev.source); + let desc = ev.description.replace('"', "\"\""); + let tags = ev.tags.join("|"); + format!("{ts},{et},{src},\"{desc}\",{tags}") +} + fn emit_csv(events: &[TimelineEvent]) { - println!("timestamp,event_type,source,description,tags"); + println!("{CSV_HEADER}"); for ev in events { - let ts = ev.timestamp_ns; - let et = format!("{:?}", ev.event_type); - let src = format!("{}", ev.source); - let desc = ev.description.replace('"', "\"\""); - let tags = ev.tags.join("|"); - println!("{ts},{et},{src},\"{desc}\",{tags}"); + println!("{}", csv_row(ev)); } } @@ -144,8 +157,119 @@ pub(crate) fn emit_narrative( #[cfg(test)] mod tests { use super::*; + use issen_core::artifacts::ArtifactType; + use issen_core::timeline::event::EventType; use tempfile::TempDir; + // ── CSV emission ────────────────────────────────────────────────────────── + + /// Build an event whose evidence-derived fields carry `desc` and `tags`. + fn csv_event(desc: &str, tags: &[&str]) -> TimelineEvent { + let mut ev = TimelineEvent::new( + 1_700_000_000_000_000_000, + "2023-11-14T22:13:20Z".to_string(), + EventType::FileCreate, + ArtifactType::UsnJournal, + "/$Extend/$UsnJrnl:$J".to_string(), + desc.to_string(), + "evidence-001".to_string(), + ); + ev.tags = tags.iter().map(|t| (*t).to_string()).collect(); + ev + } + + /// Split an emitted row with the `csv` crate — an independent RFC 4180 + /// reader, not a splitter written for this test — so a claim about column + /// structure is checked by the same class of parser a spreadsheet uses. + fn parse_row(row: &str) -> Vec { + let mut rdr = csv::ReaderBuilder::new() + .has_headers(false) + .flexible(true) + .from_reader(row.as_bytes()); + let record = rdr + .records() + .next() + .expect("one record") + .expect("record parses as RFC 4180"); + record.iter().map(ToString::to_string).collect() + } + + /// A description beginning with `=` is a live formula the moment an examiner + /// opens the CSV in Excel or LibreOffice, and descriptions come from + /// evidence. It must be neutralised before it reaches the file. + #[test] + fn csv_formula_prefixed_description_is_guarded() { + for payload in [ + "=cmd|'/C calc'!A0", + "+1+1", + "-2+3", + "@SUM(1+1)*cmd|'/C calc'!A0", + ] { + let row = csv_row(&csv_event(payload, &[])); + let fields = parse_row(&row); + assert_eq!(fields.len(), 5, "row must have 5 columns: {row}"); + assert!( + fields[3].starts_with('\''), + "description beginning with a formula character must be guarded \ + with a leading apostrophe; got {:?} from row {row}", + fields[3] + ); + } + } + + /// `tags` is joined and interpolated raw. A comma inside a tag adds a column, + /// silently shifting every later field of that row. + #[test] + fn csv_comma_in_tag_does_not_break_columns() { + let row = csv_row(&csv_event( + "ran calc.exe", + &["persistence", "T1547,evasion"], + )); + let fields = parse_row(&row); + assert_eq!( + fields.len(), + 5, + "a comma inside a tag must stay inside the tags column, not split it: {row}" + ); + assert_eq!(fields[4], "persistence|T1547,evasion"); + } + + /// A double quote inside a tag is emitted raw, so the field is neither a + /// clean bare field nor a well-formed quoted one. + #[test] + fn csv_quote_in_tag_is_escaped() { + let row = csv_row(&csv_event("ran calc.exe", &["said \"hi\", then left"])); + let fields = parse_row(&row); + assert_eq!(fields.len(), 5, "row must have 5 columns: {row}"); + assert_eq!(fields[4], "said \"hi\", then left"); + } + + /// `event_type` renders `EventType::Other(String)`, whose payload is + /// parser-supplied. A comma in it breaks the row the same way. + #[test] + fn csv_comma_in_event_type_does_not_break_columns() { + let mut ev = csv_event("ran calc.exe", &[]); + ev.event_type = EventType::Other("Carved:sqlite,wal".to_string()); + let fields = parse_row(&csv_row(&ev)); + assert_eq!( + fields.len(), + 5, + "a comma in the event type must not add a column" + ); + } + + /// The header must describe the same number of columns the rows carry. + #[test] + fn csv_header_column_count_matches_rows() { + let header = parse_row(CSV_HEADER); + let row = parse_row(&csv_row(&csv_event("ran calc.exe", &["exec"]))); + assert_eq!( + header.len(), + row.len(), + "header/row column count must agree" + ); + } + /// Minimal synthetic USN V2 record (filename + FILE_CREATE reason) — mirrors /// the `$J` fixture used by the integration tests. fn usn_v2_create(filename: &str) -> Vec { From 76403334cb80fdeddcb4e58853183772fbe4e778 Mon Sep 17 00:00:00 2001 From: Albert Hui Date: Sun, 2 Aug 2026 10:54:39 +0800 Subject: [PATCH 2/6] fix(cli): GREEN - guard every supertimeline CSV field via jsonguard::csv_field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Route all five fields through `jsonguard::csv_field`, which applies the RFC 4180 quoting and the `= + - @` spreadsheet formula guard in one place. No field is exempt, so a column added later cannot reach the file unguarded — the reason the guard is applied per-field rather than to the two that happen to be riskiest today. issen already depended on jsonguard, so this adopts the fleet guard rather than hand-rolling one. CSV is a machine view, so values are emitted whole — no `cap_display` truncation on this path. Caveat, unchanged by this commit: `csv_field` keys the formula guard off the first character, so a payload whose first character is CR or LF (`\r=cmd|...`) is quoted but not prefixed, and a spreadsheet that strips the leading newline still sees a formula. jsonguard PR #5 fixes this and is unmerged; 0.2.4 is the newest published version and this workspace's lock resolves jsonguard to 0.2.3, whose `text.rs` is byte-identical to 0.2.4. The bypass closes when 0.2.5 publishes and the lock moves — not here. 6 passed. Co-Authored-By: Claude Opus 5 (1M context) --- .../issen-cli/src/commands/supertimeline.rs | 24 +++++++++++++------ 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/crates/issen-cli/src/commands/supertimeline.rs b/crates/issen-cli/src/commands/supertimeline.rs index 90013447..0b1de57d 100644 --- a/crates/issen-cli/src/commands/supertimeline.rs +++ b/crates/issen-cli/src/commands/supertimeline.rs @@ -80,14 +80,24 @@ const CSV_HEADER: &str = "timestamp,event_type,source,description,tags"; /// /// Every field is attacker-influenced: `description` and `tags` are built from /// evidence bytes, and `event_type` carries a parser-supplied string in its -/// `Other(..)` variant. +/// `Other(..)` variant. So every field goes through `jsonguard::csv_field`, +/// which applies the RFC 4180 quoting *and* the spreadsheet formula guard — +/// no field is exempt, so a new column cannot be added unguarded. +/// +/// CSV is a machine view: values are emitted whole, never truncated. fn csv_row(ev: &TimelineEvent) -> String { - let ts = ev.timestamp_ns; - let et = format!("{:?}", ev.event_type); - let src = format!("{}", ev.source); - let desc = ev.description.replace('"', "\"\""); - let tags = ev.tags.join("|"); - format!("{ts},{et},{src},\"{desc}\",{tags}") + let fields = [ + ev.timestamp_ns.to_string(), + format!("{:?}", ev.event_type), + ev.source.to_string(), + ev.description.clone(), + ev.tags.join("|"), + ]; + fields + .iter() + .map(|f| jsonguard::csv_field(f).value) + .collect::>() + .join(",") } fn emit_csv(events: &[TimelineEvent]) { From a9943c01522cb7ddff590efd322256ff64d25a6a Mon Sep 17 00:00:00 2001 From: Albert Hui Date: Sun, 2 Aug 2026 10:55:33 +0800 Subject: [PATCH 3/6] test(cli): RED - `timeline --format csv` has no spreadsheet formula guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second instance of the defect fixed in supertimeline, in the same crate and on the more commonly used command. `write_csv` writes through `csv::Writer`, so RFC 4180 quoting is correct and columns do not break — but the writer applies no formula guard, and `path` / `description` are built from evidence bytes. A filename or description beginning with `=`, `+`, `-` or `@` reaches the file bare and is a live formula when the examiner opens it in Excel or LibreOffice. csv_formula_prefixed_field_is_guarded path beginning with a formula character must be guarded; got "=cmd|'/C calc'!A0" `csv_separators_in_values_round_trip` passes as written — it is a regression guard for the writer swap the fix requires, not evidence of a defect. Co-Authored-By: Claude Opus 5 (1M context) --- .../issen-cli/src/commands/timeline_format.rs | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/crates/issen-cli/src/commands/timeline_format.rs b/crates/issen-cli/src/commands/timeline_format.rs index e6f2f381..c54265f5 100644 --- a/crates/issen-cli/src/commands/timeline_format.rs +++ b/crates/issen-cli/src/commands/timeline_format.rs @@ -148,6 +148,60 @@ mod tests { ); } + /// `path` and `description` are built from evidence bytes, so a value + /// beginning with `=`, `+`, `-` or `@` is chosen by whoever wrote the + /// artifact — and becomes a live formula the moment the examiner opens the + /// CSV in Excel or LibreOffice. `csv::Writer` quotes correctly but applies + /// no formula guard. + #[test] + fn csv_formula_prefixed_field_is_guarded() { + for payload in [ + "=cmd|'/C calc'!A0", + "+1+1", + "-2+3", + "@SUM(1+1)*cmd|'/C calc'!A0", + ] { + let mut row = make_row("FileCreate", 1_705_314_225_000_000_000, payload); + row.description = payload.to_string(); + let mut out = Vec::new(); + write_csv(&[row], &TimeRenderConfig::default(), &mut out).unwrap(); + let text = String::from_utf8(out).unwrap(); + let mut rdr = csv::ReaderBuilder::new() + .has_headers(true) + .from_reader(text.as_bytes()); + let rec = rdr.records().next().expect("a data row").unwrap(); + assert_eq!(rec.len(), 6, "row must have 6 columns: {text}"); + assert!( + rec[3].starts_with('\''), + "path beginning with a formula character must be guarded; got {:?}", + &rec[3] + ); + assert!( + rec[4].starts_with('\''), + "description beginning with a formula character must be guarded; got {:?}", + &rec[4] + ); + } + } + + /// Replacing the writer must not cost the RFC 4180 escaping it did do: + /// commas, quotes and newlines still have to survive a round-trip. + #[test] + fn csv_separators_in_values_round_trip() { + let mut row = make_row("FileCreate", 1_705_314_225_000_000_000, r"C:\a,b\c.exe"); + row.description = "said \"hi\", then\nleft".to_string(); + let mut out = Vec::new(); + write_csv(&[row], &TimeRenderConfig::default(), &mut out).unwrap(); + let text = String::from_utf8(out).unwrap(); + let mut rdr = csv::ReaderBuilder::new() + .has_headers(true) + .from_reader(text.as_bytes()); + let rec = rdr.records().next().expect("a data row").unwrap(); + assert_eq!(rec.len(), 6, "row must have 6 columns: {text}"); + assert_eq!(&rec[3], r"C:\a,b\c.exe"); + assert_eq!(&rec[4], "said \"hi\", then\nleft"); + } + // ---- Bodyfile tests ---- #[test] From 1495d2117dde5895e87256f7115c7c203acb23dc Mon Sep 17 00:00:00 2001 From: Albert Hui Date: Sun, 2 Aug 2026 10:56:46 +0800 Subject: [PATCH 4/6] fix(cli): GREEN - one guarded CSV record builder for both timeline emitters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `write_csv` now builds rows with `csv_record`, which runs every field through `jsonguard::csv_field` — the RFC 4180 quoting `csv::Writer` already did, plus the `= + - @` formula guard it did not. `supertimeline::csv_row` calls the same helper, so issen has one CSV emission mechanism rather than two that guard differently. Replacing `csv::Writer` costs none of its escaping: commas, embedded quotes and embedded newlines still round-trip (`csv_separators_in_values_round_trip`, which reads the output back with the `csv` crate). The CR/LF caveat recorded on the supertimeline fix applies here identically — it is a jsonguard-side gap (PR #5, unmerged), not a call-site one. 13 passed across both modules. Co-Authored-By: Claude Opus 5 (1M context) --- .../issen-cli/src/commands/supertimeline.rs | 31 +++++----- .../issen-cli/src/commands/timeline_format.rs | 57 +++++++++++++------ 2 files changed, 55 insertions(+), 33 deletions(-) diff --git a/crates/issen-cli/src/commands/supertimeline.rs b/crates/issen-cli/src/commands/supertimeline.rs index 0b1de57d..da204e2c 100644 --- a/crates/issen-cli/src/commands/supertimeline.rs +++ b/crates/issen-cli/src/commands/supertimeline.rs @@ -17,6 +17,8 @@ use issen_correlation::temporal_rule::{bundled_temporal_rules, evaluate_temporal use issen_fswalker::orchestrator::run_auto; use issen_fswalker::progress::ProgressReporter; +use crate::commands::timeline_format::csv_record; + /// Run the supertimeline command. /// /// # Errors @@ -80,24 +82,23 @@ const CSV_HEADER: &str = "timestamp,event_type,source,description,tags"; /// /// Every field is attacker-influenced: `description` and `tags` are built from /// evidence bytes, and `event_type` carries a parser-supplied string in its -/// `Other(..)` variant. So every field goes through `jsonguard::csv_field`, -/// which applies the RFC 4180 quoting *and* the spreadsheet formula guard — -/// no field is exempt, so a new column cannot be added unguarded. +/// `Other(..)` variant. So the row is built by [`csv_record`], which guards +/// every field — none is exempt, so a column added later cannot reach the file +/// unguarded. /// /// CSV is a machine view: values are emitted whole, never truncated. fn csv_row(ev: &TimelineEvent) -> String { - let fields = [ - ev.timestamp_ns.to_string(), - format!("{:?}", ev.event_type), - ev.source.to_string(), - ev.description.clone(), - ev.tags.join("|"), - ]; - fields - .iter() - .map(|f| jsonguard::csv_field(f).value) - .collect::>() - .join(",") + let ts = ev.timestamp_ns.to_string(); + let event_type = format!("{:?}", ev.event_type); + let source = ev.source.to_string(); + let tags = ev.tags.join("|"); + csv_record([ + ts.as_str(), + event_type.as_str(), + source.as_str(), + ev.description.as_str(), + tags.as_str(), + ]) } fn emit_csv(events: &[TimelineEvent]) { diff --git a/crates/issen-cli/src/commands/timeline_format.rs b/crates/issen-cli/src/commands/timeline_format.rs index c54265f5..60d2ce86 100644 --- a/crates/issen-cli/src/commands/timeline_format.rs +++ b/crates/issen-cli/src/commands/timeline_format.rs @@ -4,6 +4,19 @@ use anyhow::Result; use issen_timeline::query::TimelineRow; use issen_timeline::temporal::{render_at, TimeRenderConfig}; +/// Render one CSV record, guarding every field. +/// +/// `jsonguard::csv_field` applies the RFC 4180 quoting *and* the `= + - @` +/// spreadsheet formula guard that a plain CSV writer does not. Applied to every +/// field, so a column added later cannot reach the file unguarded. +pub(crate) fn csv_record<'a>(fields: impl IntoIterator) -> String { + fields + .into_iter() + .map(|f| jsonguard::csv_field(f).value) + .collect::>() + .join(",") +} + /// Write timeline events in CSV format. /// /// The `timestamp` column is rendered through `render_cfg` (timezone / format / @@ -15,26 +28,34 @@ pub fn write_csv( render_cfg: &TimeRenderConfig, out: &mut impl Write, ) -> Result<()> { - let mut wtr = csv::Writer::from_writer(out); - wtr.write_record([ - "timestamp", - "event_type", - "source", - "path", - "description", - "evidence_source", - ])?; + writeln!( + out, + "{}", + csv_record([ + "timestamp", + "event_type", + "source", + "path", + "description", + "evidence_source", + ]) + )?; for row in events { - wtr.write_record([ - &render_at(row.timestamp_ns, render_cfg), - &row.event_type, - &row.source, - &row.artifact_path, - &row.description, - &row.evidence_source, - ])?; + let timestamp = render_at(row.timestamp_ns, render_cfg); + writeln!( + out, + "{}", + csv_record([ + timestamp.as_str(), + row.event_type.as_str(), + row.source.as_str(), + row.artifact_path.as_str(), + row.description.as_str(), + row.evidence_source.as_str(), + ]) + )?; } - wtr.flush()?; + out.flush()?; Ok(()) } From f043b2795beb6bc19c9dae7704766cb079a332b9 Mon Sep 17 00:00:00 2001 From: Albert Hui Date: Sun, 2 Aug 2026 13:40:18 +0800 Subject: [PATCH 5/6] test(lints): RED - deny expect_used, correctness and suspicious workspace-wide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit issen denied `unwrap_used` but not `expect_used`, so `.expect(..)` stayed a sanctioned way to panic in crates contracted never to. ADR-0012's recipe requires both, plus the `correctness` and `suspicious` group denials, which were also absent. All 66 members inherit via `lints.workspace = true` and none override with their own `[lints]` table, so the root table is the effective config for every crate. The pragmatic allows move to `priority = 1`, matching the fleet recipe. That is load-bearing rather than cosmetic: cargo ignores table order, so a lint group and an individual lint at the same priority is a hard error — clippy refuses to run at all rather than silently picking a precedence. The failing signal here is clippy, not a test. `cargo clippy --workspace --all-targets --keep-going` goes from clean to 510 findings across 60 files: 18 in production code, 435 inside `#[cfg(test)]` modules and 47 in `tests/`/`examples/` files. (Without `--keep-going` cargo stops at the first failing crate and reports 1.) Co-Authored-By: Claude Opus 5 (1M context) --- Cargo.toml | 30 +++++++++++++++++++++--------- 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index b7ab31c4..e2ba8acb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -283,17 +283,29 @@ unsafe_code = "deny" [workspace.lints.clippy] all = { level = "warn", priority = -1 } pedantic = { level = "warn", priority = -1 } +correctness = "deny" +suspicious = "deny" +# Both halves of the panic pair, always together. Denying `unwrap_used` alone +# leaves `.expect(..)` a sanctioned way to panic in a crate contracted never to +# — the gap that let a bare `.expect("4 bytes")` through review in a sibling +# repo. See ADR-0012 (Paranoid Gatekeeper). unwrap_used = "deny" +expect_used = "deny" # Pragmatic allows (fleet standard — see ~/.claude/CLAUDE.md Paranoid Gatekeeper): # pedantic lints that are noise for application/orchestration code and were # missing from this workspace, so the pedantic warn-set drifted far above the # documented baseline. -module_name_repetitions = "allow" -must_use_candidate = "allow" -missing_errors_doc = "allow" -missing_panics_doc = "allow" -doc_markdown = "allow" -cast_possible_truncation = "allow" -cast_possible_wrap = "allow" -cast_sign_loss = "allow" -cast_precision_loss = "allow" +# +# `priority = 1` is load-bearing, not decoration: these must outrank the +# `correctness`/`suspicious` group denials above. Cargo ignores table order, so +# a group and a lint that share a priority are a hard error, not a silent +# precedence choice. +module_name_repetitions = { level = "allow", priority = 1 } +must_use_candidate = { level = "allow", priority = 1 } +missing_errors_doc = { level = "allow", priority = 1 } +missing_panics_doc = { level = "allow", priority = 1 } +doc_markdown = { level = "allow", priority = 1 } +cast_possible_truncation = { level = "allow", priority = 1 } +cast_possible_wrap = { level = "allow", priority = 1 } +cast_sign_loss = { level = "allow", priority = 1 } +cast_precision_loss = { level = "allow", priority = 1 } From 4315d2d9ced4ce8c2dd03902a743ac30d80b3edc Mon Sep 17 00:00:00 2001 From: Albert Hui Date: Sun, 2 Aug 2026 13:40:44 +0800 Subject: [PATCH 6/6] fix(lints): GREEN - remove every production `expect()`; sanction the test ones MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `cargo clippy --workspace --all-targets --keep-going` is clean (0 findings). No `#[allow]` was added to silence a production site; each was fixed where it was. 25 production sites, by cause: 8 `Mutex::lock().expect("mutex poisoned")` in `DataSource::read_at` (qcow2, aff4, vhd, dmg, dd, vmdk, iso, vhdx). Reachable, and the most consequential of the set: `read_at` already returns `Result`, so the failure had an error channel and was panicking anyway. A poisoned lock means an earlier read panicked while holding it — with the disk legs running under rayon, that turned one failed read of one image into a panic on every subsequent read of it. Now an `RtError::Io` naming the source that poisoned. 7 literal IOC regexes recompiled per call in `issen-signatures` STIX extraction. Replaced by one `IOC_PATTERNS` table compiled once behind a `OnceLock`. A pattern that fails to compile is dropped rather than panicking, and `every_ioc_pattern_compiles` is the new test that makes that safe — it fails the build if the table ever shrinks, so a malformed pattern can no longer cost an IOC class silently. Also stops rebuilding seven regexes for every pattern in a bundle. 6 `env::var(..).expect(..)` in the three aggregator `build.rs` scripts. `main` now returns `Result`, and the message names the missing variable (`VarError` alone does not). 2 `ProgressStyle::with_template(..).expect(..)` in issen-mft-tree. The bar is cosmetic, so it falls back to indicatif's default styling rather than taking an MFT parse down. 1 `FieldRegistry::resolve("logon-type").expect(..)` in `presets::logons`. The field is now a named `LOGON_TYPE` const that `FIELDS` itself contains, so the preset references the definition instead of looking its name up at runtime — registry and presets cannot disagree, and there is nothing left to fail. 1 `sugar()` in `timeline_query` returns `Result`. Dropping an unresolvable sugar filter would have silently widened the analyst's result set, so it reports instead. 2 `serde_json::to_string_pretty(..).expect(..)` in the `frequency` and `processes` `--json` paths. Both enclosing `run`s already return `anyhow::Result<()>`. The uac PAM credential-staging regex now returns `Option`, and a `None` pushes a `RootkitFinding` saying the scan did not run. Returning an empty `Vec` there would have read as "no credential staging found" — the opposite of what happened. 482 test-side sites took the fleet-sanctioned opt-out instead: `#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))]` in 11 crate roots, and a plain `#![allow(..)]` in 19 `tests/`/`examples/` files, which are separate crates the crate-root attribute cannot reach. Gate: build, clippy `--all-targets --keep-going` (0), `fmt --check`. Tests pass except the pre-existing `issen-mem dispatch::tests::dispatch_windows_netstat_returns_ok`, which fails identically on origin/main and touches no crate changed here. Co-Authored-By: Claude Opus 5 (1M context) --- crates/forensic-pivot/src/lib.rs | 2 + crates/forensic-pivot/tests/phase2_feeds.rs | 3 + crates/forensic-pivot/tests/phase3_loader.rs | 3 + .../forensic-pivot/tests/phase4_downloader.rs | 3 + .../forensic-pivot/tests/phase5_downloader.rs | 3 + crates/issen-aff4/src/lib.rs | 11 +- crates/issen-carvers/build.rs | 9 +- crates/issen-cli/examples/profile_scan.rs | 3 + crates/issen-cli/src/commands/frequency.rs | 14 +- crates/issen-cli/src/commands/processes.rs | 14 +- .../issen-cli/src/commands/timeline_query.rs | 32 ++-- .../tests/browser_profile_discovery.rs | 3 + crates/issen-core/src/lib.rs | 3 +- crates/issen-dd/src/lib.rs | 9 +- crates/issen-dd/tests/corpus.rs | 4 + crates/issen-disk/examples/probe_mft.rs | 3 + .../tests/extract_user_artifacts.rs | 3 + crates/issen-disk/tests/parity_read.rs | 3 + crates/issen-dmg/src/lib.rs | 9 +- crates/issen-iso/src/lib.rs | 9 +- crates/issen-iso/tests/corpus.rs | 4 + crates/issen-mft-tree/src/parse.rs | 7 +- crates/issen-parsers/build.rs | 9 +- crates/issen-providers/build.rs | 9 +- crates/issen-qcow2/src/lib.rs | 9 +- crates/issen-report/src/lib.rs | 2 + crates/issen-signatures/src/engines/stix.rs | 145 +++++++++++------- .../examples/measure_load_rss.rs | 3 + .../examples/profile_correlate.rs | 3 + .../examples/stream_window_probe.rs | 3 + crates/issen-timeline/src/lib.rs | 2 + crates/issen-timeline/src/tquery.rs | 25 +-- .../tests/tquery_real_szechuan.rs | 3 + crates/issen-vhd/src/lib.rs | 9 +- crates/issen-vhdx/src/lib.rs | 11 +- crates/issen-vmdk/src/lib.rs | 9 +- crates/issen-wsl/tests/hybrid_path_tests.rs | 3 + crates/issen-wsl/tests/session_tests.rs | 3 + crates/parsers/issen-parser-evtx/src/lib.rs | 2 + crates/parsers/issen-parser-pe/src/lib.rs | 3 +- crates/parsers/issen-parser-srum/src/lib.rs | 3 +- .../issen-parser-uac/src/parsers/rootkit.rs | 38 ++++- .../tests/integration_test.rs | 3 + .../issen-parser-velociraptor/src/lib.rs | 2 + .../tests/integration_test.rs | 3 + 45 files changed, 333 insertions(+), 123 deletions(-) diff --git a/crates/forensic-pivot/src/lib.rs b/crates/forensic-pivot/src/lib.rs index 0b96db88..e1119168 100644 --- a/crates/forensic-pivot/src/lib.rs +++ b/crates/forensic-pivot/src/lib.rs @@ -1,3 +1,5 @@ +// Tests opt out of the panic lints (fleet standard) — unwrap/expect in test code. +#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))] pub mod adapters; pub mod downloader; pub mod engine; diff --git a/crates/forensic-pivot/tests/phase2_feeds.rs b/crates/forensic-pivot/tests/phase2_feeds.rs index 2343a5ea..a746fc3b 100644 --- a/crates/forensic-pivot/tests/phase2_feeds.rs +++ b/crates/forensic-pivot/tests/phase2_feeds.rs @@ -1,3 +1,6 @@ +// Integration tests are their own crate, so the workspace `cfg_attr(test, ..)` +// opt-out in the library root does not reach here. +#![allow(clippy::unwrap_used, clippy::expect_used)] use forensic_pivot::{cache_path_for_feed, is_stale, FeedKind, FeedSpec, SyncManifest}; use std::path::Path; diff --git a/crates/forensic-pivot/tests/phase3_loader.rs b/crates/forensic-pivot/tests/phase3_loader.rs index 3ca5e77c..d3bc2578 100644 --- a/crates/forensic-pivot/tests/phase3_loader.rs +++ b/crates/forensic-pivot/tests/phase3_loader.rs @@ -1,3 +1,6 @@ +// Integration tests are their own crate, so the workspace `cfg_attr(test, ..)` +// opt-out in the library root does not reach here. +#![allow(clippy::unwrap_used, clippy::expect_used)] use forensic_pivot::{ bundled_rules, load_rules_from_dir, load_rules_from_yaml_str, AssertionLevel, PivotRule, Severity, diff --git a/crates/forensic-pivot/tests/phase4_downloader.rs b/crates/forensic-pivot/tests/phase4_downloader.rs index 75f4b85e..5b526b4c 100644 --- a/crates/forensic-pivot/tests/phase4_downloader.rs +++ b/crates/forensic-pivot/tests/phase4_downloader.rs @@ -1,3 +1,6 @@ +// Integration tests are their own crate, so the workspace `cfg_attr(test, ..)` +// opt-out in the library root does not reach here. +#![allow(clippy::unwrap_used, clippy::expect_used)] use forensic_pivot::{ load_manifest, prepare_feed_cache, save_manifest, stale_feeds, FeedKind, FeedSpec, SyncManifest, }; diff --git a/crates/forensic-pivot/tests/phase5_downloader.rs b/crates/forensic-pivot/tests/phase5_downloader.rs index 79aa2491..d30635ce 100644 --- a/crates/forensic-pivot/tests/phase5_downloader.rs +++ b/crates/forensic-pivot/tests/phase5_downloader.rs @@ -1,3 +1,6 @@ +// Integration tests are their own crate, so the workspace `cfg_attr(test, ..)` +// opt-out in the library root does not reach here. +#![allow(clippy::unwrap_used, clippy::expect_used)] use forensic_pivot::{downloader::download_feed, loader::default_feeds, FeedKind, FeedSpec}; use tempfile::TempDir; diff --git a/crates/issen-aff4/src/lib.rs b/crates/issen-aff4/src/lib.rs index 772ed097..1c323469 100644 --- a/crates/issen-aff4/src/lib.rs +++ b/crates/issen-aff4/src/lib.rs @@ -3,6 +3,8 @@ //! Wraps [`aff4::Aff4Reader`] and exposes the virtual disk as a [`DataSource`] //! for downstream forensic parsers. +// Tests opt out of the panic lints (fleet standard) — unwrap/expect in test code. +#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))] use std::fs::File; use std::io::{Read, Seek, SeekFrom}; use std::path::Path; @@ -142,7 +144,14 @@ impl DataSource for Aff4DataSource { } fn read_at(&self, offset: u64, buf: &mut [u8]) -> Result { - let mut guard = self.reader.lock().expect("mutex poisoned"); + // A poisoned lock means an earlier read panicked while holding it, so + // the reader's position is unknown. `read_at` has an error channel — + // use it, rather than cascading the panic into every later read. + let mut guard = self.reader.lock().map_err(|_| { + RtError::Io(std::io::Error::other( + "Aff4DataSource: reader mutex poisoned", + )) + })?; guard.seek(SeekFrom::Start(offset)).map_err(RtError::Io)?; let mut total = 0; while total < buf.len() { diff --git a/crates/issen-carvers/build.rs b/crates/issen-carvers/build.rs index 131a3a28..55d6ae63 100644 --- a/crates/issen-carvers/build.rs +++ b/crates/issen-carvers/build.rs @@ -11,8 +11,9 @@ use std::env; use std::fs; use std::path::Path; -fn main() { - let manifest_dir = env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR set by cargo"); +fn main() -> Result<(), Box> { + let manifest_dir = env::var("CARGO_MANIFEST_DIR") + .map_err(|e| format!("CARGO_MANIFEST_DIR (set by cargo): {e}"))?; let manifest_path = Path::new(&manifest_dir).join("Cargo.toml"); println!("cargo:rerun-if-changed=Cargo.toml"); @@ -41,7 +42,7 @@ fn main() { out.push_str(" as _;\n"); } - let out_dir = env::var("OUT_DIR").expect("OUT_DIR set by cargo"); + let out_dir = env::var("OUT_DIR").map_err(|e| format!("OUT_DIR (set by cargo): {e}"))?; let anchors_path = Path::new(&out_dir).join("anchors.rs"); fs::write(&anchors_path, out) .unwrap_or_else(|e| panic!("write {}: {e}", anchors_path.display())); @@ -51,4 +52,6 @@ fn main() { let manifest_list_path = Path::new(&out_dir).join("anchored_crates.txt"); fs::write(&manifest_list_path, deps.join("\n")) .unwrap_or_else(|e| panic!("write {}: {e}", manifest_list_path.display())); + + Ok(()) } diff --git a/crates/issen-cli/examples/profile_scan.rs b/crates/issen-cli/examples/profile_scan.rs index 4e2acc63..fafd91e5 100644 --- a/crates/issen-cli/examples/profile_scan.rs +++ b/crates/issen-cli/examples/profile_scan.rs @@ -4,6 +4,9 @@ //! cargo build --release --example profile_scan -p issen-cli //! ./target/release/examples/profile_scan +// Integration tests are their own crate, so the workspace `cfg_attr(test, ..)` +// opt-out in the library root does not reach here. +#![allow(clippy::unwrap_used, clippy::expect_used)] use std::path::Path; use std::time::Instant; diff --git a/crates/issen-cli/src/commands/frequency.rs b/crates/issen-cli/src/commands/frequency.rs index cd256368..850e4911 100644 --- a/crates/issen-cli/src/commands/frequency.rs +++ b/crates/issen-cli/src/commands/frequency.rs @@ -38,7 +38,7 @@ pub fn run( anomalies.sort_by_key(|a| a.count); if json { - print_json(&anomalies, total_analyzed); + print_json(&anomalies, total_analyzed)?; } else { print_summary(&anomalies, total_analyzed); } @@ -46,7 +46,7 @@ pub fn run( Ok(()) } -fn print_json(anomalies: &[FrequencyAnomaly], total_analyzed: usize) { +fn print_json(anomalies: &[FrequencyAnomaly], total_analyzed: usize) -> anyhow::Result<()> { let arr: Vec = anomalies .iter() .map(|a| { @@ -62,12 +62,10 @@ fn print_json(anomalies: &[FrequencyAnomaly], total_analyzed: usize) { "anomalies": arr, "total_analyzed": total_analyzed, }); - // Serializing an in-memory `json!` value is infallible; `expect` documents - // that and satisfies the `unwrap_used = deny` lint. - println!( - "{}", - serde_json::to_string_pretty(&out).expect("serialize JSON value") - ); + // Serializing an in-memory `json!` value cannot fail today; report it rather + // than panic if that ever stops holding, so `--json` never dies mid-pipe. + println!("{}", serde_json::to_string_pretty(&out)?); + Ok(()) } fn print_summary(anomalies: &[FrequencyAnomaly], total_analyzed: usize) { diff --git a/crates/issen-cli/src/commands/processes.rs b/crates/issen-cli/src/commands/processes.rs index 08d4b588..c67ae94f 100644 --- a/crates/issen-cli/src/commands/processes.rs +++ b/crates/issen-cli/src/commands/processes.rs @@ -47,7 +47,7 @@ pub fn run( } if json { - print_json(&processes); + print_json(&processes)?; } else { print_summary(&processes); } @@ -74,7 +74,7 @@ fn enrich_with_sessions( } } -fn print_json(processes: &[ProcessEvent]) { +fn print_json(processes: &[ProcessEvent]) -> anyhow::Result<()> { let arr: Vec = processes .iter() .map(|p| { @@ -100,12 +100,10 @@ fn print_json(processes: &[ProcessEvent]) { "processes": arr, "total_count": processes.len(), }); - // Serializing an in-memory `json!` value is infallible; `expect` documents - // that and satisfies the `unwrap_used = deny` lint. - println!( - "{}", - serde_json::to_string_pretty(&out).expect("serialize JSON value") - ); + // Serializing an in-memory `json!` value cannot fail today; report it rather + // than panic if that ever stops holding, so `--json` never dies mid-pipe. + println!("{}", serde_json::to_string_pretty(&out)?); + Ok(()) } fn print_summary(processes: &[ProcessEvent]) { diff --git a/crates/issen-cli/src/commands/timeline_query.rs b/crates/issen-cli/src/commands/timeline_query.rs index 45657e39..4141c833 100644 --- a/crates/issen-cli/src/commands/timeline_query.rs +++ b/crates/issen-cli/src/commands/timeline_query.rs @@ -110,14 +110,26 @@ fn parse_field(spec: &str) -> Result { } /// Build a sugar filter (`--ip`, `--user`, `--service`) as an exact-match field. -fn sugar(name: &str, value: &str) -> FieldFilter { - FieldFilter { - // Sugar names are registry constants, so resolve cannot fail here; if it - // ever did, that is a programmer error and panicking is correct. - field: FieldRegistry::resolve(name).expect("sugar name is a registry field"), +/// +/// # Errors +/// +/// Returns an error if `name` is not in the field registry. Every caller passes +/// a registry name, so this reports a flag wired to a field that no longer +/// exists — dropping the filter instead would silently widen the analyst's +/// result set. +fn sugar(name: &str, value: &str) -> Result { + let field = FieldRegistry::resolve(name).ok_or_else(|| { + anyhow::anyhow!( + "internal: sugar flag is wired to '{name}', which is not a registry field. \ + Valid fields: {}", + FieldRegistry::valid_names() + ) + })?; + Ok(FieldFilter { + field, op: FieldOp::Eq, value: value.to_string(), - } + }) } /// Translate validated args into a [`TypedQuery`]. Fails loud on conflicting @@ -128,13 +140,13 @@ fn build_query(args: &QueryArgs) -> Result<(TypedQuery, Vec)> { fields.push(parse_field(spec)?); } if let Some(ip) = &args.ip { - fields.push(sugar("ip", ip)); + fields.push(sugar("ip", ip)?); } if let Some(user) = &args.user { - fields.push(sugar("user", user)); + fields.push(sugar("user", user)?); } if let Some(service) = &args.service { - fields.push(sugar("service", service)); + fields.push(sugar("service", service)?); } // --logon-type N,N,N is OR semantics; Phase 1 supports a single value via // exact match (the deck's multi-value B4/B5 case is the intent-verb's job). @@ -147,7 +159,7 @@ fn build_query(args: &QueryArgs) -> Result<(TypedQuery, Vec)> { Phase 1 accepts a single logon type" ); } - fields.push(sugar("logon-type", lt)); + fields.push(sugar("logon-type", lt)?); } // Aggregation modes are mutually exclusive. diff --git a/crates/issen-cli/tests/browser_profile_discovery.rs b/crates/issen-cli/tests/browser_profile_discovery.rs index 4d377a88..49a50ed6 100644 --- a/crates/issen-cli/tests/browser_profile_discovery.rs +++ b/crates/issen-cli/tests/browser_profile_discovery.rs @@ -13,6 +13,9 @@ //! These tests pin that behaviour end-to-end: a non-Default profile is //! discovered and parsed, and a per-file `History` is not double-counted. +// Integration tests are their own crate, so the workspace `cfg_attr(test, ..)` +// opt-out in the library root does not reach here. +#![allow(clippy::unwrap_used, clippy::expect_used)] use std::path::Path; use issen_cli::commands; diff --git a/crates/issen-core/src/lib.rs b/crates/issen-core/src/lib.rs index 084ebe8e..552bd5ad 100644 --- a/crates/issen-core/src/lib.rs +++ b/crates/issen-core/src/lib.rs @@ -1,5 +1,6 @@ #![allow(clippy::doc_markdown, clippy::missing_errors_doc)] - +// Tests opt out of the panic lints (fleet standard) — unwrap/expect in test code. +#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))] pub mod artifacts; pub mod carve; pub mod classify; diff --git a/crates/issen-dd/src/lib.rs b/crates/issen-dd/src/lib.rs index c9fe970e..a1b556d4 100644 --- a/crates/issen-dd/src/lib.rs +++ b/crates/issen-dd/src/lib.rs @@ -4,6 +4,8 @@ //! it directly through [`std::fs::File`] (which is `Read + Seek`) to provide a //! [`DataSource`] for random-access reads over `.dd`, `.img`, `.raw`, `.bin`. +// Tests opt out of the panic lints (fleet standard) — unwrap/expect in test code. +#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))] use std::fs::File; use std::io::Read; use std::io::{Seek, SeekFrom}; @@ -92,7 +94,12 @@ impl DataSource for DdDataSource { } fn read_at(&self, offset: u64, buf: &mut [u8]) -> Result { - let mut guard = self.reader.lock().expect("DdDataSource mutex poisoned"); + // A poisoned lock means an earlier read panicked while holding it, so + // the reader's position is unknown. `read_at` has an error channel — + // use it, rather than cascading the panic into every later read. + let mut guard = self.reader.lock().map_err(|_| { + RtError::Io(std::io::Error::other("DdDataSource: reader mutex poisoned")) + })?; guard.seek(SeekFrom::Start(offset)).map_err(RtError::Io)?; let mut total = 0; while total < buf.len() { diff --git a/crates/issen-dd/tests/corpus.rs b/crates/issen-dd/tests/corpus.rs index 2b71a287..737b60f8 100644 --- a/crates/issen-dd/tests/corpus.rs +++ b/crates/issen-dd/tests/corpus.rs @@ -1,3 +1,7 @@ +// Integration tests are their own crate, so the workspace `cfg_attr(test, ..)` +// opt-out in the library root does not reach here. +#![allow(clippy::unwrap_used, clippy::expect_used)] + /// Corpus integration tests for DdDataSource against a real raw disk image. /// /// ext4.raw is from the log2timeline/dfvfs project (Apache-2.0): diff --git a/crates/issen-disk/examples/probe_mft.rs b/crates/issen-disk/examples/probe_mft.rs index 4fe7f862..f0c816df 100644 --- a/crates/issen-disk/examples/probe_mft.rs +++ b/crates/issen-disk/examples/probe_mft.rs @@ -4,6 +4,9 @@ //! //! Usage: cargo run --release -p issen-disk --example probe_mft -- +// Integration tests are their own crate, so the workspace `cfg_attr(test, ..)` +// opt-out in the library root does not reach here. +#![allow(clippy::unwrap_used, clippy::expect_used)] use issen_disk::{find_ntfs_partitions, DataSourceReader}; use issen_ewf::EwfDataSource; use ntfs_core::{NtfsFs, OffsetReader}; diff --git a/crates/issen-disk/tests/extract_user_artifacts.rs b/crates/issen-disk/tests/extract_user_artifacts.rs index 96129466..ceab45b3 100644 --- a/crates/issen-disk/tests/extract_user_artifacts.rs +++ b/crates/issen-disk/tests/extract_user_artifacts.rs @@ -16,6 +16,9 @@ //! env var or the in-repo corpus path and skips cleanly when absent (CI), like //! `parity_read.rs`. +// Integration tests are their own crate, so the workspace `cfg_attr(test, ..)` +// opt-out in the library root does not reach here. +#![allow(clippy::unwrap_used, clippy::expect_used)] use std::path::PathBuf; use issen_disk::{extract_subdir_sweep, find_ntfs_partitions}; diff --git a/crates/issen-disk/tests/parity_read.rs b/crates/issen-disk/tests/parity_read.rs index d386381d..b568b84a 100644 --- a/crates/issen-disk/tests/parity_read.rs +++ b/crates/issen-disk/tests/parity_read.rs @@ -10,6 +10,9 @@ //! //! The default path is the Windows `hosts` file; override with NTFS_FORENSIC_PATH. +// Integration tests are their own crate, so the workspace `cfg_attr(test, ..)` +// opt-out in the library root does not reach here. +#![allow(clippy::unwrap_used, clippy::expect_used)] use std::path::Path; use issen_disk::{extract_files, find_ntfs_partitions}; diff --git a/crates/issen-dmg/src/lib.rs b/crates/issen-dmg/src/lib.rs index 429304f3..43bff7f4 100644 --- a/crates/issen-dmg/src/lib.rs +++ b/crates/issen-dmg/src/lib.rs @@ -91,7 +91,14 @@ impl DataSource for DmgDataSource { } fn read_at(&self, offset: u64, buf: &mut [u8]) -> Result { - let mut guard = self.reader.lock().expect("mutex poisoned"); + // A poisoned lock means an earlier read panicked while holding it, so + // the reader's position is unknown. `read_at` has an error channel — + // use it, rather than cascading the panic into every later read. + let mut guard = self.reader.lock().map_err(|_| { + RtError::Io(std::io::Error::other( + "DmgDataSource: reader mutex poisoned", + )) + })?; guard.seek(SeekFrom::Start(offset)).map_err(RtError::Io)?; let mut total = 0; while total < buf.len() { diff --git a/crates/issen-iso/src/lib.rs b/crates/issen-iso/src/lib.rs index a4de10f7..4d6755c7 100644 --- a/crates/issen-iso/src/lib.rs +++ b/crates/issen-iso/src/lib.rs @@ -212,7 +212,14 @@ impl DataSource for IsoDataSource { } fn read_at(&self, offset: u64, buf: &mut [u8]) -> Result { - let mut guard = self.reader.lock().expect("mutex poisoned"); + // A poisoned lock means an earlier read panicked while holding it, so + // the reader's position is unknown. `read_at` has an error channel — + // use it, rather than cascading the panic into every later read. + let mut guard = self.reader.lock().map_err(|_| { + RtError::Io(std::io::Error::other( + "IsoDataSource: reader mutex poisoned", + )) + })?; guard.seek(SeekFrom::Start(offset)).map_err(RtError::Io)?; let mut total = 0; while total < buf.len() { diff --git a/crates/issen-iso/tests/corpus.rs b/crates/issen-iso/tests/corpus.rs index 36b752c6..26572bf4 100644 --- a/crates/issen-iso/tests/corpus.rs +++ b/crates/issen-iso/tests/corpus.rs @@ -1,3 +1,7 @@ +// Integration tests are their own crate, so the workspace `cfg_attr(test, ..)` +// opt-out in the library root does not reach here. +#![allow(clippy::unwrap_used, clippy::expect_used)] + /// Corpus integration tests for IsoDataSource against a real Ubuntu ISO. /// /// The Ubuntu 20.04 (Focal) netboot mini.iso is created by Canonical using diff --git a/crates/issen-mft-tree/src/parse.rs b/crates/issen-mft-tree/src/parse.rs index e7d5e365..60213403 100644 --- a/crates/issen-mft-tree/src/parse.rs +++ b/crates/issen-mft-tree/src/parse.rs @@ -57,7 +57,9 @@ impl FileTree { ProgressStyle::with_template( " Parsing MFT [{bar:40.cyan/dim}] {pos}/{len} entries ({percent}%)", ) - .expect("valid template") + // The bar is cosmetic; a malformed template must not take the MFT + // parse down with it. Fall back to indicatif's default styling. + .unwrap_or_else(|_| ProgressStyle::default_bar()) .progress_chars("##-"), ); @@ -221,7 +223,8 @@ impl FileTree { let pb2 = ProgressBar::new_spinner(); pb2.set_style( ProgressStyle::with_template(" {spinner:.cyan} Building directory tree...") - .expect("valid template"), + // Cosmetic, as above. + .unwrap_or_else(|_| ProgressStyle::default_spinner()), ); pb2.enable_steady_tick(std::time::Duration::from_millis(80)); diff --git a/crates/issen-parsers/build.rs b/crates/issen-parsers/build.rs index 8b62fa4b..473413c9 100644 --- a/crates/issen-parsers/build.rs +++ b/crates/issen-parsers/build.rs @@ -11,8 +11,9 @@ use std::env; use std::fs; use std::path::Path; -fn main() { - let manifest_dir = env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR set by cargo"); +fn main() -> Result<(), Box> { + let manifest_dir = env::var("CARGO_MANIFEST_DIR") + .map_err(|e| format!("CARGO_MANIFEST_DIR (set by cargo): {e}"))?; let manifest_path = Path::new(&manifest_dir).join("Cargo.toml"); println!("cargo:rerun-if-changed=Cargo.toml"); @@ -41,7 +42,7 @@ fn main() { out.push_str(" as _;\n"); } - let out_dir = env::var("OUT_DIR").expect("OUT_DIR set by cargo"); + let out_dir = env::var("OUT_DIR").map_err(|e| format!("OUT_DIR (set by cargo): {e}"))?; let anchors_path = Path::new(&out_dir).join("anchors.rs"); fs::write(&anchors_path, out) .unwrap_or_else(|e| panic!("write {}: {e}", anchors_path.display())); @@ -51,4 +52,6 @@ fn main() { let manifest_list_path = Path::new(&out_dir).join("anchored_crates.txt"); fs::write(&manifest_list_path, deps.join("\n")) .unwrap_or_else(|e| panic!("write {}: {e}", manifest_list_path.display())); + + Ok(()) } diff --git a/crates/issen-providers/build.rs b/crates/issen-providers/build.rs index 8b62fa4b..473413c9 100644 --- a/crates/issen-providers/build.rs +++ b/crates/issen-providers/build.rs @@ -11,8 +11,9 @@ use std::env; use std::fs; use std::path::Path; -fn main() { - let manifest_dir = env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR set by cargo"); +fn main() -> Result<(), Box> { + let manifest_dir = env::var("CARGO_MANIFEST_DIR") + .map_err(|e| format!("CARGO_MANIFEST_DIR (set by cargo): {e}"))?; let manifest_path = Path::new(&manifest_dir).join("Cargo.toml"); println!("cargo:rerun-if-changed=Cargo.toml"); @@ -41,7 +42,7 @@ fn main() { out.push_str(" as _;\n"); } - let out_dir = env::var("OUT_DIR").expect("OUT_DIR set by cargo"); + let out_dir = env::var("OUT_DIR").map_err(|e| format!("OUT_DIR (set by cargo): {e}"))?; let anchors_path = Path::new(&out_dir).join("anchors.rs"); fs::write(&anchors_path, out) .unwrap_or_else(|e| panic!("write {}: {e}", anchors_path.display())); @@ -51,4 +52,6 @@ fn main() { let manifest_list_path = Path::new(&out_dir).join("anchored_crates.txt"); fs::write(&manifest_list_path, deps.join("\n")) .unwrap_or_else(|e| panic!("write {}: {e}", manifest_list_path.display())); + + Ok(()) } diff --git a/crates/issen-qcow2/src/lib.rs b/crates/issen-qcow2/src/lib.rs index 2a9a9b0b..ccd36dcd 100644 --- a/crates/issen-qcow2/src/lib.rs +++ b/crates/issen-qcow2/src/lib.rs @@ -96,7 +96,14 @@ impl DataSource for Qcow2DataSource { } fn read_at(&self, offset: u64, buf: &mut [u8]) -> Result { - let mut guard = self.reader.lock().expect("mutex poisoned"); + // A poisoned lock means an earlier read panicked while holding it, so + // the reader's position is unknown. `read_at` has an error channel — + // use it, rather than cascading the panic into every later read. + let mut guard = self.reader.lock().map_err(|_| { + RtError::Io(std::io::Error::other( + "Qcow2DataSource: reader mutex poisoned", + )) + })?; guard.seek(SeekFrom::Start(offset)).map_err(RtError::Io)?; let mut total = 0; while total < buf.len() { diff --git a/crates/issen-report/src/lib.rs b/crates/issen-report/src/lib.rs index 2c067ead..2cdb65bf 100644 --- a/crates/issen-report/src/lib.rs +++ b/crates/issen-report/src/lib.rs @@ -5,6 +5,8 @@ //! includes summary statistics, a sortable events table, and a findings //! section for scan results. +// Tests opt out of the panic lints (fleet standard) — unwrap/expect in test code. +#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))] use std::fmt::Write as FmtWrite; use std::path::Path; diff --git a/crates/issen-signatures/src/engines/stix.rs b/crates/issen-signatures/src/engines/stix.rs index e8dc7fa4..d83231f4 100644 --- a/crates/issen-signatures/src/engines/stix.rs +++ b/crates/issen-signatures/src/engines/stix.rs @@ -5,6 +5,7 @@ // domain names, and URLs from STIX patterns. use std::path::Path; +use std::sync::OnceLock; use regex::Regex; use serde::Deserialize; @@ -43,6 +44,74 @@ pub enum ExtractedIoc { Url(String), } +/// One IOC class: the STIX pattern that locates it, and the variant it yields. +struct IocPattern { + /// Regex source, compiled once by [`ioc_extractors`]. + source: &'static str, + /// Constructor for the [`ExtractedIoc`] variant this pattern produces. + make: fn(String) -> ExtractedIoc, +} + +/// Every IOC class `extract_iocs_from_pattern` recognises, in output order. +/// +/// One table rather than seven hand-written blocks: adding a class is a row, and +/// no class can be given different quoting or capture handling than its peers. +const IOC_PATTERNS: &[IocPattern] = &[ + // file:hashes.'SHA-256' = '...' or file:hashes.SHA-256 = '...' + IocPattern { + source: r"(?i)file:hashes\.'?SHA-?256'?\s*=\s*'([^']+)'", + make: ExtractedIoc::Sha256, + }, + // file:hashes.'SHA-1' = '...' + IocPattern { + source: r"(?i)file:hashes\.'?SHA-?1'?\s*=\s*'([^']+)'", + make: ExtractedIoc::Sha1, + }, + // file:hashes.MD5 = '...' or file:hashes.'MD5' = '...' + IocPattern { + source: r"(?i)file:hashes\.'?MD5'?\s*=\s*'([^']+)'", + make: ExtractedIoc::Md5, + }, + IocPattern { + source: r"ipv4-addr:value\s*=\s*'([^']+)'", + make: ExtractedIoc::Ipv4, + }, + IocPattern { + source: r"ipv6-addr:value\s*=\s*'([^']+)'", + make: ExtractedIoc::Ipv6, + }, + IocPattern { + source: r"domain-name:value\s*=\s*'([^']+)'", + make: ExtractedIoc::Domain, + }, + IocPattern { + source: r"url:value\s*=\s*'([^']+)'", + make: ExtractedIoc::Url, + }, +]; + +/// A compiled [`IocPattern`]: its regex paired with the variant it builds. +type IocExtractor = (Regex, fn(String) -> ExtractedIoc); + +/// [`IOC_PATTERNS`] compiled once, in declaration order. +/// +/// A pattern that fails to compile is dropped rather than panicking, so a STIX +/// bundle can never take the process down. That silence is only safe because +/// `every_ioc_pattern_compiles` fails the build if the table ever shrinks — +/// the guarantee lives in the test rather than in a runtime `expect`. +/// +/// Compiling once also stops the seven regexes being rebuilt for every pattern +/// in a bundle. +fn ioc_extractors() -> &'static [IocExtractor] { + static COMPILED: OnceLock> = OnceLock::new(); + COMPILED.get_or_init(|| { + IOC_PATTERNS + .iter() + .filter_map(|p| Regex::new(p.source).ok().map(|re| (re, p.make))) + .collect() + }) +} + /// A parsed STIX 2.1 indicator with extracted IOCs. #[derive(Debug, Clone)] pub struct StixIndicator { @@ -154,65 +223,13 @@ impl StixParser { #[must_use] pub fn extract_iocs_from_pattern(pattern: &str) -> Vec { let mut iocs = Vec::new(); - - // SHA-256: file:hashes.'SHA-256' = '...' or file:hashes.SHA-256 = '...' - let sha256_re = - Regex::new(r"(?i)file:hashes\.'?SHA-?256'?\s*=\s*'([^']+)'").expect("valid regex"); - for cap in sha256_re.captures_iter(pattern) { - if let Some(val) = cap.get(1) { - iocs.push(ExtractedIoc::Sha256(val.as_str().to_string())); - } - } - - // SHA-1: file:hashes.'SHA-1' = '...' - let sha1_re = - Regex::new(r"(?i)file:hashes\.'?SHA-?1'?\s*=\s*'([^']+)'").expect("valid regex"); - for cap in sha1_re.captures_iter(pattern) { - if let Some(val) = cap.get(1) { - iocs.push(ExtractedIoc::Sha1(val.as_str().to_string())); + for (re, make) in ioc_extractors() { + for cap in re.captures_iter(pattern) { + if let Some(val) = cap.get(1) { + iocs.push(make(val.as_str().to_string())); + } } } - - // MD5: file:hashes.MD5 = '...' or file:hashes.'MD5' = '...' - let md5_re = Regex::new(r"(?i)file:hashes\.'?MD5'?\s*=\s*'([^']+)'").expect("valid regex"); - for cap in md5_re.captures_iter(pattern) { - if let Some(val) = cap.get(1) { - iocs.push(ExtractedIoc::Md5(val.as_str().to_string())); - } - } - - // IPv4: ipv4-addr:value = '...' - let ipv4_re = Regex::new(r"ipv4-addr:value\s*=\s*'([^']+)'").expect("valid regex"); - for cap in ipv4_re.captures_iter(pattern) { - if let Some(val) = cap.get(1) { - iocs.push(ExtractedIoc::Ipv4(val.as_str().to_string())); - } - } - - // IPv6: ipv6-addr:value = '...' - let ipv6_re = Regex::new(r"ipv6-addr:value\s*=\s*'([^']+)'").expect("valid regex"); - for cap in ipv6_re.captures_iter(pattern) { - if let Some(val) = cap.get(1) { - iocs.push(ExtractedIoc::Ipv6(val.as_str().to_string())); - } - } - - // Domain: domain-name:value = '...' - let domain_re = Regex::new(r"domain-name:value\s*=\s*'([^']+)'").expect("valid regex"); - for cap in domain_re.captures_iter(pattern) { - if let Some(val) = cap.get(1) { - iocs.push(ExtractedIoc::Domain(val.as_str().to_string())); - } - } - - // URL: url:value = '...' - let url_re = Regex::new(r"url:value\s*=\s*'([^']+)'").expect("valid regex"); - for cap in url_re.captures_iter(pattern) { - if let Some(val) = cap.get(1) { - iocs.push(ExtractedIoc::Url(val.as_str().to_string())); - } - } - iocs } } @@ -225,6 +242,20 @@ impl StixParser { mod tests { use super::*; + /// `ioc_extractors` drops a pattern that fails to compile instead of + /// panicking, so a malformed one would cost that IOC class silently — a + /// bundle's SHA-256 indicators would simply stop being extracted, with no + /// error anywhere. This is the check that makes the drop safe: it fails + /// here, at build time, rather than in a case. + #[test] + fn every_ioc_pattern_compiles() { + assert_eq!( + ioc_extractors().len(), + IOC_PATTERNS.len(), + "an IOC pattern failed to compile; its class would be dropped silently" + ); + } + /// Helper: build a minimal STIX bundle JSON string with the given objects. fn bundle_json(objects_json: &str) -> String { format!( diff --git a/crates/issen-timeline/examples/measure_load_rss.rs b/crates/issen-timeline/examples/measure_load_rss.rs index ac936a01..bc7af40e 100644 --- a/crates/issen-timeline/examples/measure_load_rss.rs +++ b/crates/issen-timeline/examples/measure_load_rss.rs @@ -8,6 +8,9 @@ //! Run: cargo run --release --example measure_load_rss -- /tmp/case001.duckdb //! For the true peak, wrap it: /usr/bin/time -l cargo run ... (macOS) +// Integration tests are their own crate, so the workspace `cfg_attr(test, ..)` +// opt-out in the library root does not reach here. +#![allow(clippy::unwrap_used, clippy::expect_used)] use issen_timeline::events::EventQuery; use issen_timeline::store::TimelineStore; diff --git a/crates/issen-timeline/examples/profile_correlate.rs b/crates/issen-timeline/examples/profile_correlate.rs index 457adb75..e4b27f8d 100644 --- a/crates/issen-timeline/examples/profile_correlate.rs +++ b/crates/issen-timeline/examples/profile_correlate.rs @@ -3,6 +3,9 @@ //! cargo build --release --example profile_correlate -p issen-timeline //! ./target/release/examples/profile_correlate +// Integration tests are their own crate, so the workspace `cfg_attr(test, ..)` +// opt-out in the library root does not reach here. +#![allow(clippy::unwrap_used, clippy::expect_used)] use std::path::Path; use std::time::Instant; diff --git a/crates/issen-timeline/examples/stream_window_probe.rs b/crates/issen-timeline/examples/stream_window_probe.rs index 161ceae1..b4bdcf20 100644 --- a/crates/issen-timeline/examples/stream_window_probe.rs +++ b/crates/issen-timeline/examples/stream_window_probe.rs @@ -16,6 +16,9 @@ //! cross-product (proc_disk_match) resident separately — that set is the dump's //! processes, bounded and small. This probe measures the relational-rule window. +// Integration tests are their own crate, so the workspace `cfg_attr(test, ..)` +// opt-out in the library root does not reach here. +#![allow(clippy::unwrap_used, clippy::expect_used)] use std::collections::VecDeque; use issen_timeline::events::{EventQuery, StoredEvent}; diff --git a/crates/issen-timeline/src/lib.rs b/crates/issen-timeline/src/lib.rs index def53e6b..b04a3f92 100644 --- a/crates/issen-timeline/src/lib.rs +++ b/crates/issen-timeline/src/lib.rs @@ -35,6 +35,8 @@ clippy::manual_contains, clippy::unnecessary_literal_bound )] +// Tests opt out of the panic lints (fleet standard) — unwrap/expect in test code. +#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))] pub mod correlate_runner; pub mod correlations; pub mod epoch; diff --git a/crates/issen-timeline/src/tquery.rs b/crates/issen-timeline/src/tquery.rs index 8fdcfa08..01fb4d8e 100644 --- a/crates/issen-timeline/src/tquery.rs +++ b/crates/issen-timeline/src/tquery.rs @@ -56,6 +56,18 @@ pub struct Field { /// `metadata.$.X` JSON path into a first-class, discoverable name. pub struct FieldRegistry; +/// The `logon-type` field, named on its own so a preset that filters on it +/// references the definition rather than looking the name up at runtime. It is +/// an entry of [`FIELDS`] below, so the registry and the presets cannot +/// disagree about it — and `presets::logons` needs no fallible lookup. +const LOGON_TYPE: Field = Field { + name: "logon-type", + aliases: &["logontype", "logon_type"], + json_key: "LogonType", + ftype: FieldType::LogonType, + populated_by: "LogonSuccess (EventLog 4624)", +}; + const FIELDS: &[Field] = &[ Field { name: "ip", @@ -71,13 +83,7 @@ const FIELDS: &[Field] = &[ ftype: FieldType::Text, populated_by: "LogonSuccess/Logoff (EventLog)", }, - Field { - name: "logon-type", - aliases: &["logontype", "logon_type"], - json_key: "LogonType", - ftype: FieldType::LogonType, - populated_by: "LogonSuccess (EventLog 4624)", - }, + LOGON_TYPE, Field { name: "service", aliases: &["service-name", "servicename"], @@ -903,7 +909,7 @@ impl TypedQuery { /// special case. All values are constants here; analyst input still binds as a /// parameter when it reaches [`TypedQuery::run`]. pub mod presets { - use super::{FieldInFilter, FieldRegistry, Mode, TypedQuery}; + use super::{FieldInFilter, Mode, TypedQuery}; fn ev(types: &[&str]) -> Vec { types.iter().map(|s| (*s).to_string()).collect() @@ -917,8 +923,7 @@ pub mod presets { TypedQuery { event_types: ev(&["LogonSuccess"]), in_filters: vec![FieldInFilter { - field: FieldRegistry::resolve("logon-type") - .expect("logon-type is a registry field"), + field: &super::LOGON_TYPE, values: ev(&["2", "10", "11"]), }], exclude_machine_accounts: true, diff --git a/crates/issen-timeline/tests/tquery_real_szechuan.rs b/crates/issen-timeline/tests/tquery_real_szechuan.rs index d446503e..3324c243 100644 --- a/crates/issen-timeline/tests/tquery_real_szechuan.rs +++ b/crates/issen-timeline/tests/tquery_real_szechuan.rs @@ -6,6 +6,9 @@ //! //! Env-gated: skips cleanly when the DB is absent (large artifact, gitignored). +// Integration tests are their own crate, so the workspace `cfg_attr(test, ..)` +// opt-out in the library root does not reach here. +#![allow(clippy::unwrap_used, clippy::expect_used)] use std::path::PathBuf; use duckdb::Connection; diff --git a/crates/issen-vhd/src/lib.rs b/crates/issen-vhd/src/lib.rs index bc355fcf..da494de2 100644 --- a/crates/issen-vhd/src/lib.rs +++ b/crates/issen-vhd/src/lib.rs @@ -96,7 +96,14 @@ impl DataSource for VhdDataSource { } fn read_at(&self, offset: u64, buf: &mut [u8]) -> Result { - let mut guard = self.reader.lock().expect("mutex poisoned"); + // A poisoned lock means an earlier read panicked while holding it, so + // the reader's position is unknown. `read_at` has an error channel — + // use it, rather than cascading the panic into every later read. + let mut guard = self.reader.lock().map_err(|_| { + RtError::Io(std::io::Error::other( + "VhdDataSource: reader mutex poisoned", + )) + })?; guard.seek(SeekFrom::Start(offset)).map_err(RtError::Io)?; let mut total = 0; while total < buf.len() { diff --git a/crates/issen-vhdx/src/lib.rs b/crates/issen-vhdx/src/lib.rs index d1b45470..8d0eaaf5 100644 --- a/crates/issen-vhdx/src/lib.rs +++ b/crates/issen-vhdx/src/lib.rs @@ -4,6 +4,8 @@ //! Issen pipeline, enabling random-access reads over Microsoft VHDX virtual //! disk images. +// Tests opt out of the panic lints (fleet standard) — unwrap/expect in test code. +#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))] use std::fs::File; use std::io::{Seek, SeekFrom}; use std::path::Path; @@ -159,7 +161,14 @@ impl DataSource for VhdxDataSource { } fn read_at(&self, offset: u64, buf: &mut [u8]) -> Result { - let mut guard = self.reader.lock().expect("VhdxDataSource mutex poisoned"); + // A poisoned lock means an earlier read panicked while holding it, so + // the reader's position is unknown. `read_at` has an error channel — + // use it, rather than cascading the panic into every later read. + let mut guard = self.reader.lock().map_err(|_| { + RtError::Io(std::io::Error::other( + "VhdxDataSource: reader mutex poisoned", + )) + })?; guard.seek(SeekFrom::Start(offset)).map_err(RtError::Io)?; let mut total = 0; while total < buf.len() { diff --git a/crates/issen-vmdk/src/lib.rs b/crates/issen-vmdk/src/lib.rs index 8ee72016..97c82dff 100644 --- a/crates/issen-vmdk/src/lib.rs +++ b/crates/issen-vmdk/src/lib.rs @@ -125,7 +125,14 @@ impl DataSource for VmdkDataSource { } fn read_at(&self, offset: u64, buf: &mut [u8]) -> Result { - let mut guard = self.reader.lock().expect("mutex poisoned"); + // A poisoned lock means an earlier read panicked while holding it, so + // the reader's position is unknown. `read_at` has an error channel — + // use it, rather than cascading the panic into every later read. + let mut guard = self.reader.lock().map_err(|_| { + RtError::Io(std::io::Error::other( + "VmdkDataSource: reader mutex poisoned", + )) + })?; guard.seek(SeekFrom::Start(offset)).map_err(RtError::Io)?; let mut total = 0; while total < buf.len() { diff --git a/crates/issen-wsl/tests/hybrid_path_tests.rs b/crates/issen-wsl/tests/hybrid_path_tests.rs index 9ca510af..4d9d60dd 100644 --- a/crates/issen-wsl/tests/hybrid_path_tests.rs +++ b/crates/issen-wsl/tests/hybrid_path_tests.rs @@ -4,6 +4,9 @@ //! Native WSL paths (/home/..., /etc/...) have no Windows equivalent. //! Native Windows paths (C:\...) have no WSL equivalent. +// Integration tests are their own crate, so the workspace `cfg_attr(test, ..)` +// opt-out in the library root does not reach here. +#![allow(clippy::unwrap_used, clippy::expect_used)] use issen_wsl::hybrid_path::HybridPath; // ── Test 1: /mnt/c/... is recognized as DrvFs ──────────────────────────────── diff --git a/crates/issen-wsl/tests/session_tests.rs b/crates/issen-wsl/tests/session_tests.rs index f63745cc..1d2e8353 100644 --- a/crates/issen-wsl/tests/session_tests.rs +++ b/crates/issen-wsl/tests/session_tests.rs @@ -1,5 +1,8 @@ //! RED tests for WslSession — correlating EVTX events into WSL sessions. +// Integration tests are their own crate, so the workspace `cfg_attr(test, ..)` +// opt-out in the library root does not reach here. +#![allow(clippy::unwrap_used, clippy::expect_used)] use issen_wsl::session::{build_sessions, SessionEvent, SessionEventKind}; fn make_event(kind: SessionEventKind, ts_ns: i64, pid: u32, distro: Option<&str>) -> SessionEvent { diff --git a/crates/parsers/issen-parser-evtx/src/lib.rs b/crates/parsers/issen-parser-evtx/src/lib.rs index 2cafa2b0..624f8310 100644 --- a/crates/parsers/issen-parser-evtx/src/lib.rs +++ b/crates/parsers/issen-parser-evtx/src/lib.rs @@ -40,6 +40,8 @@ //! Wraps the `evtx` crate to parse `.evtx` files and emit [`TimelineEvent`]s //! via the [`ForensicParser`] trait. +// Tests opt out of the panic lints (fleet standard) — unwrap/expect in test code. +#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))] use evtx::err::{ChunkError, DeserializationError, EvtxError}; use evtx::EvtxParser as EvtxCrateParser; use issen_core::artifacts::ArtifactType; diff --git a/crates/parsers/issen-parser-pe/src/lib.rs b/crates/parsers/issen-parser-pe/src/lib.rs index b6124d42..68afcf8d 100644 --- a/crates/parsers/issen-parser-pe/src/lib.rs +++ b/crates/parsers/issen-parser-pe/src/lib.rs @@ -11,7 +11,8 @@ clippy::trivially_copy_pass_by_ref, clippy::unnecessary_literal_bound )] - +// Tests opt out of the panic lints (fleet standard) — unwrap/expect in test code. +#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))] pub mod detections; pub mod parser; pub mod wiring; diff --git a/crates/parsers/issen-parser-srum/src/lib.rs b/crates/parsers/issen-parser-srum/src/lib.rs index 46142ed4..c11908d4 100644 --- a/crates/parsers/issen-parser-srum/src/lib.rs +++ b/crates/parsers/issen-parser-srum/src/lib.rs @@ -13,7 +13,8 @@ clippy::missing_panics_doc, clippy::must_use_candidate )] - +// Tests opt out of the panic lints (fleet standard) — unwrap/expect in test code. +#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))] use issen_core::artifacts::ArtifactType; use issen_core::classify; use issen_core::error::RtError; diff --git a/crates/parsers/issen-parser-uac/src/parsers/rootkit.rs b/crates/parsers/issen-parser-uac/src/parsers/rootkit.rs index dd5dd0e4..71a2b066 100644 --- a/crates/parsers/issen-parser-uac/src/parsers/rootkit.rs +++ b/crates/parsers/issen-parser-uac/src/parsers/rootkit.rs @@ -236,13 +236,21 @@ pub fn check_kernel_taint(content: &str) -> Vec { findings } -/// Returns the compiled PAM credential staging regex (lazily initialised). -fn pam_cred_regex() -> &'static regex::Regex { - static RE: std::sync::OnceLock = std::sync::OnceLock::new(); - // The pattern is a compile-time-constant literal, so it cannot fail to - // compile — `expect` documents that, matching the fleet regex convention - // (issen-signatures) and satisfying the `unwrap_used = deny` lint. - RE.get_or_init(|| regex::Regex::new(r"^\d+:\d+:\w+:[^\n]+").expect("valid regex")) +/// The PAM credential-staging pattern: `UID:counter:fieldname:value`. +const PAM_CRED_PATTERN: &str = r"^\d+:\d+:\w+:[^\n]+"; + +/// Returns the compiled PAM credential staging regex (lazily initialised), or +/// `None` if [`PAM_CRED_PATTERN`] failed to compile. +/// +/// The pattern is a literal, so `None` would mean the detector itself is +/// broken, not that the evidence is unusual. That distinction is why this +/// returns `Option` rather than panicking: a detector that cannot run must be +/// reported, and [`scan_pam_credential_staging`] reports it as a finding rather +/// than returning an empty scan an examiner would read as "nothing staged". +fn pam_cred_regex() -> Option<&'static regex::Regex> { + static RE: std::sync::OnceLock> = std::sync::OnceLock::new(); + RE.get_or_init(|| regex::Regex::new(PAM_CRED_PATTERN).ok()) + .as_ref() } /// Scan temp-like directories for PAM hook credential staging files. @@ -263,9 +271,23 @@ pub fn scan_pam_credential_staging(root: &std::path::Path) -> Vec