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
30 changes: 21 additions & 9 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
2 changes: 2 additions & 0 deletions crates/forensic-pivot/src/lib.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
3 changes: 3 additions & 0 deletions crates/forensic-pivot/tests/phase2_feeds.rs
Original file line number Diff line number Diff line change
@@ -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;

Expand Down
3 changes: 3 additions & 0 deletions crates/forensic-pivot/tests/phase3_loader.rs
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
3 changes: 3 additions & 0 deletions crates/forensic-pivot/tests/phase4_downloader.rs
Original file line number Diff line number Diff line change
@@ -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,
};
Expand Down
3 changes: 3 additions & 0 deletions crates/forensic-pivot/tests/phase5_downloader.rs
Original file line number Diff line number Diff line change
@@ -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;

Expand Down
11 changes: 10 additions & 1 deletion crates/issen-aff4/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -142,7 +144,14 @@ impl DataSource for Aff4DataSource {
}

fn read_at(&self, offset: u64, buf: &mut [u8]) -> Result<usize, RtError> {
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() {
Expand Down
9 changes: 6 additions & 3 deletions crates/issen-carvers/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<dyn std::error::Error>> {
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");

Expand Down Expand Up @@ -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()));
Expand All @@ -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(())
}
3 changes: 3 additions & 0 deletions crates/issen-cli/examples/profile_scan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@
//! cargo build --release --example profile_scan -p issen-cli
//! ./target/release/examples/profile_scan <case.duckdb>

// 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;

Expand Down
14 changes: 6 additions & 8 deletions crates/issen-cli/src/commands/frequency.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,15 +38,15 @@ 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);
}

Ok(())
}

fn print_json(anomalies: &[FrequencyAnomaly], total_analyzed: usize) {
fn print_json(anomalies: &[FrequencyAnomaly], total_analyzed: usize) -> anyhow::Result<()> {
let arr: Vec<serde_json::Value> = anomalies
.iter()
.map(|a| {
Expand All @@ -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) {
Expand Down
14 changes: 6 additions & 8 deletions crates/issen-cli/src/commands/processes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ pub fn run(
}

if json {
print_json(&processes);
print_json(&processes)?;
} else {
print_summary(&processes);
}
Expand All @@ -74,7 +74,7 @@ fn enrich_with_sessions(
}
}

fn print_json(processes: &[ProcessEvent]) {
fn print_json(processes: &[ProcessEvent]) -> anyhow::Result<()> {
let arr: Vec<serde_json::Value> = processes
.iter()
.map(|p| {
Expand All @@ -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]) {
Expand Down
149 changes: 142 additions & 7 deletions crates/issen-cli/src/commands/supertimeline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -72,15 +74,37 @@ 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. 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 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]) {
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));
}
}

Expand Down Expand Up @@ -144,8 +168,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<String> {
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<u8> {
Expand Down
Loading
Loading