Catch CSV / formula injection before your export ships.
A CSV export feels like the safest feature you can build — you are just writing
rows of text. But the moment a user opens that file in Excel or Google Sheets, any
cell whose value starts with =, +, -, @ (or a tab / carriage return) is
executed as a formula. An attacker who can influence one field — a display
name, a note, a company — can smuggle in =HYPERLINK(...) to exfiltrate the row,
=IMPORTXML(...) to phone home, or a DDE payload that runs a shell command on the
machine that opens the file. This is CSV / formula injection
(OWASP CSV Injection,
CWE-1236), and it has real CVEs.
csvfend is a zero-config, language-agnostic static gate: it flags code that
writes CSV/spreadsheet output but never neutralizes those trigger characters.
$ csvfend .
● 1 formula-injectable CSV write(s):
exporters/users.py:14 CSV written from dynamic data with no formula-injection neutralization in this file.
↳ Any cell whose value starts with = + - @ (or a tab/carriage return) is treated as a
formula when the file is opened in Excel or Google Sheets — an attacker can exfiltrate
data via HYPERLINK/IMPORTXML or run commands via DDE. Prefix any such cell with a single
quote ('), or reject it, before writing. [OWASP CSV Injection / CWE-1236]
[CF001 Python/csv]
1 blocker · 0 warnings
Exit code 1 on a finding, so it drops straight into pre-commit or CI. It runs no
code, makes no network calls, needs no configuration, and ships as one static Go
binary.
The detection is file-scoped and honest about its heuristic. For each source file csvfend asks two questions:
- Does this file write CSV or spreadsheet output? — it contains a high-signal
writer token: Python
csv.writer/writerow/DictWriter/.to_csv(, Gocsv.NewWriter/.WriteAll(, Nodecsv-stringify/fast-csv/papaparse/json2csv, PHPfputcsv, JavaCSVWriter/writeNext(opencsv), RubyCSV.open/CSV.generate. - Does it neutralize formula-trigger characters? — it contains an explicit
hardening marker: an
escape_formula/sanitize_csv-style helper, a hand-rolled check for a leading= + - @(e.g.startswith('=')), or a char-class of the trigger characters.
If (1) is true and (2) is false, that's the finding. The safe marker is a
short-circuit: the instant csvfend sees neutralization, it trusts the whole
file and stays quiet. Quoting is deliberately not treated as safe —
QUOTE_ALL only wraps a cell in quotes; a leading = still executes.
| Rule | Severity | What |
|---|---|---|
| CF001 | blocker | CSV written from dynamic data with no formula neutralization in the file |
| CF002 | warning | A spreadsheet (.to_excel / xlsx) write path with no neutralization — same bug class, often narrower exposure |
go install github.com/jay-tank/csvfend@latestOr build from source: go build -o csvfend .
csvfend # scan the current directory
csvfend ./exporters # scan a path
csvfend --strict # treat CF002 warnings as failures too
csvfend --json # machine-readable output- run: go run github.com/jay-tank/csvfend@latest ./ --strictExit codes: 0 clean · 1 a formula-injectable CSV write (or any finding under
--strict) · 2 usage error.
csvfend reads each file on its own, so it can be wrong when your neutralization lives in a shared helper module. Two ways to suppress:
- Per file: add a
csvfend:ignorecomment anywhere in the file. - Per path: list a path substring in a
.csvfendignorefile in the scanned root (one per line,#for comments).
csvfend answers one question well — "does this CSV/spreadsheet writer neutralize formula-trigger characters at all?" — across languages, as a fast static gate. It is a substring/line scanner, not a data-flow engine: it does not prove the written data is attacker-controlled, and because the safe marker is file-scoped, a file that neutralizes one writer is trusted for all writers in that file. That is a deliberate trade favoring low false-positives. When it is wrong, tell it so and move on.
MIT © Jay Tank