diff --git a/Cargo.lock b/Cargo.lock index cc7abf7..4581b73 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -633,6 +633,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jsonguard" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd01eca4cd153fbfbe7963679c361528ecd994e262488b3e1773bcd09cc10729" + [[package]] name = "libbz2-rs-sys" version = "0.2.5" @@ -976,6 +982,7 @@ dependencies = [ "clap", "forensicnomicon", "image", + "jsonguard", "rust_xlsxwriter", "sqlite-core", "sqlite-forensic", diff --git a/Cargo.toml b/Cargo.toml index 635dd81..47593cf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,8 @@ sqlite-forensic = { version = "0.10.3", path = "forensic" } clap = { version = "4", features = ["derive"] } # SHA-256 content hashing for recovered BLOBs (RustCrypto — audited, never hand-rolled). sha2 = "0.10" +# Output sanitization: RFC 4180 CSV quoting + spreadsheet formula guard. +jsonguard = "0.2" [workspace.lints.rust] unsafe_code = "forbid" diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 6f9190b..417be89 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -25,6 +25,7 @@ sqlite-core = { workspace = true } sqlite-forensic = { workspace = true } forensicnomicon = { workspace = true } clap = { workspace = true } +jsonguard = { workspace = true } # XLSX export (`carve --xlsx`): writes the rebuilt tables to a spreadsheet with # image blobs embedded in-cell. Both are pure Rust (no C deps) — the workspace # forbids unsafe and ships a single static binary. `image` enables only the diff --git a/cli/src/lib.rs b/cli/src/lib.rs index a76a54c..1ae5572 100644 --- a/cli/src/lib.rs +++ b/cli/src/lib.rs @@ -229,15 +229,14 @@ pub fn carve_lead_cells(rec: &CarvedRecord) -> Vec { ] } -/// CSV escape: wrap in double quotes and double any embedded quote when the cell -/// contains a comma, quote, or newline. +/// CSV escape via the fleet's shared `jsonguard` sanitizer: RFC 4180 quoting +/// plus a spreadsheet formula guard. Every cell rendered here is carved from +/// evidence, so a value beginning `=`, `+`, `-` or `@` would otherwise execute +/// when the examiner opens the file; `jsonguard` prefixes an apostrophe and +/// strips bidi-override and C0 control characters that misrepresent a cell. #[must_use] pub fn csv_escape(s: &str) -> String { - if s.contains(',') || s.contains('"') || s.contains('\n') { - format!("\"{}\"", s.replace('"', "\"\"")) - } else { - s.to_string() - } + jsonguard::csv_field(s).value } /// JSON-escape a string for the hand-rolled JSONL writer (no serde dependency: @@ -2935,6 +2934,50 @@ mod tests { assert!(lines[1].contains("\"a,b\""), "{}", lines[1]); } + /// A carved cell is attacker-controlled by definition — it is whatever the + /// application (or an adversary using it) stored in the database. A value + /// beginning with `=`, `+`, `-` or `@` becomes a live formula the moment + /// the examiner opens the CSV in a spreadsheet, so the lead-in must be + /// neutralized before the cell reaches the file. + #[test] + fn carve_csv_neutralizes_formula_lead_ins() { + for lead in ['=', '+', '-', '@'] { + let payload = format!("{lead}cmd|'/c calc'!A1"); + let records = vec![rec( + 1, + 0.9, + RecoverySource::FreelistPage, + vec![Value::Text(payload.clone())], + )]; + let lines = render_carve(&records, &[], OutputFormat::Csv, false); + for cell in lines[1].split(',') { + assert!( + !cell.starts_with(lead), + "unguarded formula cell {cell:?} in row: {}", + lines[1] + ); + } + } + } + + /// Anomaly notes embed values read out of the evidence file, so the audit + /// CSV carries the same exposure as the carve CSV. + #[test] + fn audit_csv_neutralizes_formula_lead_ins() { + for lead in ['=', '+', '-', '@'] { + let mut a = anomaly(); + a.note = format!("{lead}cmd|'/c calc'!A1"); + let lines = render_audit(&[a], OutputFormat::Csv); + for cell in lines[1].split(',') { + assert!( + !cell.starts_with(lead), + "unguarded formula cell {cell:?} in row: {}", + lines[1] + ); + } + } + } + #[test] fn carve_jsonl_is_one_object_per_record() { let records = vec![