Skip to content
Draft
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
7 changes: 7 additions & 0 deletions Cargo.lock

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

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
57 changes: 50 additions & 7 deletions cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -229,15 +229,14 @@ pub fn carve_lead_cells(rec: &CarvedRecord) -> Vec<String> {
]
}

/// 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:
Expand Down Expand Up @@ -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![
Expand Down
Loading