From c70d3f5af906aaa75f0a95224e65f06b7f89cd51 Mon Sep 17 00:00:00 2001 From: Albert Hui Date: Sun, 2 Aug 2026 07:57:02 +0800 Subject: [PATCH 1/2] test(cli): prove CSV cells emit formula lead-ins unguarded MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sqlite4n6's private csv_escape applies RFC 4180 quoting only; it never neutralizes a spreadsheet formula lead-in. Every cell it renders comes out of the evidence file — carved column values, fragment cells, anomaly notes — and is therefore attacker-controlled. A carved value beginning with =, +, - or @ reaches the examiner's spreadsheet as a live formula. These two tests fail on the current implementation: unguarded formula cell "=cmd|'/c calc'!A1" in row: 3,128,1,freelist-page,0.90,=cmd|'/c calc'!A1 unguarded formula cell "=cmd|'/c calc'!A1" in row: LOW,SQLITE-FREELIST-NONEMPTY,free_pages=4,=cmd|'/c calc'!A1 RED commit: tests only, no implementation change. Co-Authored-By: Claude Opus 5 (1M context) --- cli/src/lib.rs | 44 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/cli/src/lib.rs b/cli/src/lib.rs index a76a54c..95fbc2f 100644 --- a/cli/src/lib.rs +++ b/cli/src/lib.rs @@ -2935,6 +2935,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![ From a9f959742acc6f214e9b1c78cd7414062c72e9fa Mon Sep 17 00:00:00 2001 From: Albert Hui Date: Sun, 2 Aug 2026 07:59:01 +0800 Subject: [PATCH 2/2] fix(cli): guard CSV cells against formula injection via jsonguard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the private csv_escape body with the fleet's shared jsonguard::csv_field, which applies RFC 4180 quoting *and* neutralizes a leading =, +, - or @ with an apostrophe. It also closes two gaps the local escaper had: a lone CR never triggered quoting, and bidi-override and C0 control characters passed straight through into the cell. Every value on this path is carved out of the evidence file — column values, fragment cells, anomaly notes — so all of it is attacker controlled. Adopting the shared sanitizer rather than re-implementing the guard keeps one definition of "safe cell" across the fleet. The jsonl and xlsx views are untouched. Co-Authored-By: Claude Opus 5 (1M context) --- Cargo.lock | 7 +++++++ Cargo.toml | 2 ++ cli/Cargo.toml | 1 + cli/src/lib.rs | 13 ++++++------- 4 files changed, 16 insertions(+), 7 deletions(-) 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 95fbc2f..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: