Skip to content

fix(format): escape and formula-guard the CSV filename column - #6

Merged
h4x0r merged 5 commits into
mainfrom
fix/csv-formula-guard
Aug 4, 2026
Merged

fix(format): escape and formula-guard the CSV filename column#6
h4x0r merged 5 commits into
mainfrom
fix/csv-formula-guard

Conversation

@h4x0r

@h4x0r h4x0r commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

write_csv (src/format/csv.rs:6) wrote every field with a bare write! — no quoting, no formula guard. Size, hash and entropy are machine-generated and safe by construction. The filename column is not: it is chosen by whoever wrote the file.

Two defects in that one column, both closed here.

RED — fb670e2 (tests only)

Verified independently against unfixed write_csv, on a detached worktree at origin/main with only the test commit applied:

running 2 tests
test csv_filename_neutralizes_formula_lead_ins ... FAILED
test csv_filename_with_comma_stays_one_field ... FAILED

---- csv_filename_neutralizes_formula_lead_ins stdout ----
thread 'csv_filename_neutralizes_formula_lead_ins' panicked at tests/format_tests.rs:42:9:
unguarded formula filename "=cmd|'-c calc'!A1.txt" in row: 11,d74981efa70a0c880b8d8c1985d075dbcbf679b99a5f9914e5aaf96b831a9e24,=cmd|'-c calc'!A1.txt

---- csv_filename_with_comma_stays_one_field stdout ----
thread 'csv_filename_with_comma_stays_one_field' panicked at tests/format_tests.rs:62:9:
comma-bearing filename must be quoted as "/evidence/report,final.txt": 11,d74981efa70a0c880b8d8c1985d075dbcbf679b99a5f9914e5aaf96b831a9e24,/evidence/report,final.txt

test result: FAILED. 0 passed; 2 failed; 0 ignored; 0 measured; 27 filtered out

The formula test uses a relative path deliberately. An absolute path begins with a separator, which pushes the lead-in off position 0 and would make the test pass against the unfixed code. Run over a directory the path renders relative, so the lead-in really does land at the start of the cell.

GREEN — c0cc534

Routes the filename through jsonguard::csv_field, the fleet's shared sanitizer, rather than hand-rolling an escaper. Only the filename column is routed; the machine-generated columns keep their bare write!.

test csv_filename_neutralizes_formula_lead_ins ... ok
test csv_filename_with_comma_stays_one_field ... ok
test result: ok. 8 passed; 0 failed; 0 ignored; 0 measured; 21 filtered out

What jsonguard 0.2.4 actually does — measured, not assumed

jsonguard = "0.2" resolves to 0.2.4, the latest published (2026-07-25). Probed directly rather than taken on trust:

input output
report,final.txt "report,final.txt" RFC 4180 quoted
report"final.txt "report""final.txt" quote doubled
=cmd|'-c calc'!A1.txt '=cmd|'-c calc'!A1.txt guarded
+cmd / -cmd / @cmd '+cmd / '-cmd / '@cmd guarded
a\nb.txt "a\nb.txt" preserved and quoted
a\rb.txt "a\rb.txt" preserved and quoted
證據.txt 證據.txt untouched

Correction worth recording: the published 0.2.4 does not have an open CR/LF record-splitting hole. text.rs:85-91 explicitly preserves \n and \r so they trigger quoting, and needs_csv_quoting (text.rs:79) matches on , " \n \r. CR and LF are quoted, so they cannot break the record structure. I have not inspected jsonguard PR #5, so I cannot say what it does change — only that this particular bypass is closed in what we actually resolve to.

Two residual gaps in 0.2.4, neither introduced here

1. The formula guard only fires on character 0. A leading space slips past:

" =cmd"  ->  " =cmd"   (unmutated, no apostrophe)

csv_field guards only when cleaned.chars().next() is one of = + - @. Any character that is neither stripped nor a lead-in shifts the payload off position 0 and the guard does not fire. Whether a given spreadsheet then trims the leading space and evaluates the cell is untested here — flagging the mechanism, not claiming a working exploit.

2. Control characters are silently deleted, and lossy does not say so.

"a\tb.txt"  ->  "ab.txt"      mutated
"a\0b.txt"  ->  "ab.txt"      mutated
"\t"        ->  ""            filename becomes an empty cell

is_display_unsafe (text.rs:19-29) filters out U+0000..=U+001F, U+007F, U+0080..=U+009F and the bidi controls. The Guarded.lossy flag is set only by as_utf8_lossy() — invalid UTF-8 — so this stripping is invisible in the return value.

For a forensic tool that is worth naming: the CSV can report a filename that differs from the filename on disk, with nothing signalling the difference. It matters most on Linux and NTFS, where control characters in names are legal. This is a jsonguard change, not a blazehash one, and it is strictly better than the current state of emitting the raw byte sequence unescaped — so it is recorded here rather than fixed here.

write_csv also currently discards Guarded.lossy entirely. Surfacing it (a warning, or a column) is a follow-up.

Note for whoever merges second

This branch's Cargo.lock adds jsonguard on top of the stale lock. #5 re-syncs that lock. Whichever lands second will need its Cargo.lock regenerated — a re-resolution, no version changes.

Provenance

The two commits were authored on an existing local branch earlier in this session. I did not re-write them: their RED is sharper than the one I was briefed to write (it covers the comma-shifts-columns case as well as the formula lead-in, and it is deliberate about the relative path). I verified both independently — re-running the RED against unfixed code to confirm it genuinely fails, then the GREEN — and probed jsonguard 0.2.4's real behaviour rather than restating the caveat I was handed.

Gates on this branch: cargo build, cargo test, cargo clippy --all-targets -- -D warnings, cargo fmt --check all pass. cargo deny check fails on RUSTSEC-2026-0222 (wasmtime 25.0.3 via yara-x 0.9.0) — pre-existing on main, unrelated, detailed in #5.

🤖 Generated with Claude Code

h4x0r and others added 2 commits August 2, 2026 08:51
write_csv writes every field with a bare write!, applying no escaping of
any kind. The size, hash and entropy columns are machine-generated and
safe by construction, but the filename is chosen by whoever wrote the
file.

Two defects, both in that one column:

  - A file named "=cmd|..." executes as a formula when the examiner opens
    the CSV. Run over a directory the path renders relative, so the
    lead-in lands at the start of the cell.
  - A filename may legally contain a comma or a quote on every platform
    this tool runs on. Emitted raw, the comma acts as a delimiter and
    silently shifts every later column.

These two tests fail on the current implementation:

    unguarded formula filename "=cmd|'-c calc'!A1.txt" in row:
    11,d74981efa70a0c880b8d8c1985d075dbcbf679b99a5f9914e5aaf96b831a9e24,=cmd|'-c calc'!A1.txt

    comma-bearing filename must be quoted as "/evidence/report,final.txt":
    11,d74981efa70a0c880b8d8c1985d075dbcbf679b99a5f9914e5aaf96b831a9e24,/evidence/report,final.txt

The formula test uses a relative path deliberately. An absolute path
begins with a separator, which masks the lead-in and would make the test
pass against the unfixed code.

RED commit: tests only, no implementation change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Route the filename through jsonguard::csv_field, the fleet's shared
sanitizer. It applies RFC 4180 quoting and neutralizes a leading =, +, -
or @ with an apostrophe.

The filename is the only attacker-chosen column here; size, hash and
entropy are machine-generated and safe by construction, so they keep
their bare write!.

This closes both defects at once: a comma or quote in a filename can no
longer shift the columns, and a file named "=cmd|..." no longer executes
when the examiner opens the file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@socket-security

socket-security Bot commented Aug 2, 2026

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Addedjsonguard@​0.2.58610097100100

View full report

@socket-security

socket-security Bot commented Aug 2, 2026

Copy link
Copy Markdown

Warning

Review the following alerts detected in dependencies.

According to your organization's Security Policy, it is recommended to resolve "Warn" alerts. Learn more about Socket for GitHub.

Action Severity Alert  (click "▶" to expand/collapse)
Warn High
Potentially malicious package (AI signal): cargo jsonguard is 90.0% likely malicious

Notes: This fragment is a highly suspicious/malicious spreadsheet-style payload combining (1) client-side command execution attempts via cmd/DDE constructs and (2) exfiltration of spreadsheet cell contents to an attacker-controlled domain via WEBSERVICE and IMPORTXML URL query parameters, with an additional lure link via HYPERLINK. Treat as actively dangerous when opened/evaluated in spreadsheet software.

Confidence: 0.90

Severity: 1.00

From: Cargo.lockcargo/jsonguard@0.2.5

ℹ Read more on: This package | This alert | What is AI-detected potential malware?

Next steps: Take a moment to review the security alert above. Review the linked package source code to understand the potential risk. Ensure the package is not malicious before proceeding. If you're unsure how to proceed, reach out to your security team or ask the Socket team for help at support@socket.dev.

Suggestion: Given the AI system's identification of this package as malware, extreme caution is advised. It is recommended to avoid downloading or installing this package until the threat is confirmed or flagged as a false positive.

Mark the package as acceptable risk. To ignore this alert only in this pull request, reply with the comment @SocketSecurity ignore cargo/jsonguard@0.2.5. You can also ignore all packages with @SocketSecurity ignore-all. To ignore an alert for all future pull requests, use Socket's Dashboard to change the triage state of this alert.

View full report

The GREEN commit added a dependency and no supply-chain record for it, so CI
caught what the local gate did not:

    Vetting Failed!
    1 unvetted dependencies:
      jsonguard:0.2.4 missing ["safe-to-deploy"]

cargo-vet named the right mechanism itself:

    NOTE: this project trusts Albert Hui (h4x0r) - consider cargo vet trust jsonguard

`jsonguard` is ours and comes from crates.io, which is ADR-0018 case 2. Verified
against the registry first — one owner, and every published version from 0.1.0
through 0.2.4 is theirs:

    owners:  h4x0r | Albert Hui | user
    0.2.4 -> h4x0r  2026-07-25
    0.2.3 -> h4x0r  2026-06-14
    0.2.2 .. 0.1.0 -> h4x0r  2026-05-21

    $ cargo vet trust jsonguard h4x0r --criteria safe-to-deploy

Both records are load-bearing. Dropping the `imports.lock` publisher entry and
keeping only the audit reproduces the original failure exactly, so it is not
regenerable cache here:

    Vetting Failed!
    1 unvetted dependencies:
      jsonguard:0.2.4 missing ["safe-to-deploy"]

With both:

    Vetting Succeeded (170 fully audited, 5 partially audited, 629 exempted)

Cargo.lock is deliberately untouched. This branch still carries the stale lock
it inherited from main; re-syncing it belongs to the lock PR, not here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@h4x0r

h4x0r commented Aug 2, 2026

Copy link
Copy Markdown
Contributor Author

Correction to the jsonguard section above — quoting is not formula-guarding

The section above says the CR/LF hole is closed. That is true only for record-splitting, and stating it that way understates a real defect. Reconciled against the source, the precise picture:

// text.rs:95-98
let guarded = match cleaned.chars().next() {
    Some('=' | '+' | '-' | '@') => alloc::format!("'{cleaned}"),
    _ => cleaned,
};

The formula guard fires only on character 0. Anything that occupies position 0 without being stripped defeats it. There are two protections and they are independent — a field can get one and not the other:

input quoted? formula-guarded? net
=cmd no (not needed) yes safe
\t=cmd no yes — tab is stripped first, so = lands at position 0 safe
\r=cmd yes (needs_csv_quoting matches \r) nonext() is '\r' record structure intact, guard defeated
" =cmd" (leading space) no — space is not in needs_csv_quoting no — space is not a lead-in neither protection applies; emitted completely bare

So my original probe measured the quoting correctly and drew too narrow a conclusion from it. CR and LF do not split the record — and they also do not get formula-guarded. Whether a given spreadsheet strips a leading newline or space and then evaluates the cell is untested here; the mechanism is what is being reported, not a demonstrated exploit.

The leading-space row is the worst case: it is the only input in the table that receives neither protection.

None of this is introduced by this PR, and csv_field remains the right call over hand-rolling an escaper — it closes the unescaped-comma and bare-= cases that write_csv had before. These are upstream jsonguard gaps, recorded here so the guarantee is not overstated.

And the more serious one, restated plainly

Of the two residual gaps, the control-character stripping is the one that matters most for a forensic tool, and it is worth separating from the injection story entirely:

"a\tb.txt"  ->  "ab.txt"
"a\0b.txt"  ->  "ab.txt"
"\t"        ->  ""          (filename becomes an empty cell)

is_display_unsafe (text.rs:19-29) strips U+0000..=U+001F, U+007F, U+0080..=U+009F and the bidi controls. Guarded.lossy is set only by as_utf8_lossy() — invalid UTF-8 — so the stripping is flagged nowhere, and write_csv discards .lossy regardless.

Tabs and other control characters are legal in filenames on Linux and NTFS. A forensic tool that reports a filename differing from the one on disk, presented as an observation, is a more serious failure than the injection vector this function exists to prevent: the injection is a hazard to the examiner's spreadsheet, this is a defect in the evidence itself. It belongs upstream in jsonguard, not patched around here.

The lock pinned jsonguard 0.2.4, whose formula guard fires only on character 0.
A value leading with whitespace or an invisible format character therefore
reached the cell unguarded -- " =cmd" got neither quoting nor an apostrophe,
and the zero-width family (U+200B, U+200C, U+200D, U+2060, U+FEFF, U+180E,
U+00AD) behaved the same.

0.2.5 keys the guard on the first VISIBLE character, skipping Unicode
whitespace and the whole General_Category Cf range. The requirement was already
jsonguard = "0.2", a caret that admits 0.2.5, so this is a lock refresh with no
manifest change.

Full suite passes unchanged -- no expected-CSV fixture shifted, which was the
risk worth checking: values leading with whitespace now receive an apostrophe
they previously did not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@h4x0r

h4x0r commented Aug 2, 2026

Copy link
Copy Markdown
Contributor Author

Correction: the jsonguard 0.2.5 commit here is not a two-line lock bump

d782e92's message describes a jsonguard 0.2.4 → 0.2.5 refresh. That is accurate but badly incomplete for this repo: the commit is +79 / −1999, removing 157 packages and adding 7.

Cause. This branch's Cargo.lock was already stale against its manifest — the same drift PR #5 exists to fix, dating from #3 (ba519d9), which cut the OpenDAL backend closure from Cargo.toml and never regenerated the lock. Running cargo update -p jsonguard re-resolved the whole graph as a side effect, so this commit now silently contains PR #5's entire re-sync.

The message was written for the other five consumer repos, where the change genuinely is two lines, and applied here without checking the diff. That is a defect in the commit record, not in the code.

What it actually contains:

  • jsonguard 0.2.4 → 0.2.5 (the intended change)
  • the full lock re-resolution: 157 packages removed, 7 added — whole trees leaving with their roots (axum, compio, cacache, bson, bindgen and others)

Consequence for merge order. #5 and #6 now overlap on Cargo.lock and will conflict. #5's value was that a reviewer could confirm "strict subset, nothing added" at a glance; that property does not survive being duplicated here.

Suggested handling: merge #5 first, then rebase this branch — the re-resolution becomes a no-op and this PR reduces to the intended two-line bump. Alternatively merge this first and close #5 as subsumed, though that loses #5's isolated, reviewable diff.

Not force-pushed or amended, deliberately — the history stands and this note corrects it.

`[policy.ewf] audit-as-crates-io = false` asserts ewf is a first-party
crate that needs no audit. It is not: ewf resolves as
`source = "registry+https://github.com/rust-lang/crates.io-index"`, both
before and after the jsonguard bump. "ewf is ours" is true of the
project; it was not true of this artifact's provenance, so the entry
waived auditing for a real crates.io download.

ADR-0018 has a mechanism for ours-but-consumed-from-crates.io, and it is
the second one: `cargo vet trust`. Switching to it also retires the
now-redundant `[[exemptions.ewf]]`, so two overlapping suppressions
become one record bound to a publisher identity that cargo-vet checks
against crates.io -- setting the id to a wrong value makes vet report
`ewf:0.2.3 missing`, so the record is load-bearing rather than decorative.

Also refreshes imports.lock, which cached jsonguard's publisher record
only up to 0.2.4. CI runs `cargo vet --locked`, which may not update that
cache, so vet could not establish who published 0.2.5 and the existing
`[[trusted.jsonguard]]` rule had nothing to match. That is why this
failed while the trust entry looked correct.
@h4x0r
h4x0r marked this pull request as ready for review August 4, 2026 04:27
@h4x0r
h4x0r merged commit 7edaaa0 into main Aug 4, 2026
8 of 9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant