agent-sanitizer 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +217 -0
- package/SECURITY.md +31 -0
- package/THREAT-MODEL.md +173 -0
- package/bin/sanitize-cli.mjs +423 -0
- package/package.json +157 -0
- package/src/cf-charset.mjs +43 -0
- package/src/confusables.mjs +199 -0
- package/src/gates.mjs +71 -0
- package/src/html.mjs +2233 -0
- package/src/index.mjs +166 -0
- package/src/instructions.mjs +530 -0
- package/src/invisible.mjs +976 -0
- package/src/joining-type.mjs +616 -0
- package/src/layer1.mjs +177 -0
- package/src/output.mjs +788 -0
- package/src/prompt.mjs +154 -0
- package/src/rehydrate.mjs +646 -0
- package/src/standardized-variants.mjs +1335 -0
- package/src/view-map.mjs +354 -0
- package/types/cf-charset.d.mts +14 -0
- package/types/confusables.d.mts +85 -0
- package/types/gates.d.mts +37 -0
- package/types/html.d.mts +117 -0
- package/types/index.d.mts +33 -0
- package/types/instructions.d.mts +137 -0
- package/types/invisible.d.mts +98 -0
- package/types/joining-type.d.mts +22 -0
- package/types/layer1.d.mts +35 -0
- package/types/output.d.mts +195 -0
- package/types/prompt.d.mts +28 -0
- package/types/rehydrate.d.mts +54 -0
- package/types/standardized-variants.d.mts +22 -0
- package/types/view-map.d.mts +170 -0
|
@@ -0,0 +1,530 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Instruction-file scanner + auto-cleaner for hidden-Unicode injection.
|
|
3
|
+
*
|
|
4
|
+
* Agent instruction files (CLAUDE.md / AGENTS.md / SKILL.md / any `.claude`
|
|
5
|
+
* markdown) load directly as model context, bypassing a tool-output sanitizer,
|
|
6
|
+
* so invisible Unicode pasted into them reaches the model raw — invisible in an
|
|
7
|
+
* editor but read as instructions. This module finds runs of payload-capable
|
|
8
|
+
* invisible characters, decodes the common encodings (Unicode-tag → ASCII,
|
|
9
|
+
* zero-width binary), catches scattered threshold-evasion payloads, and (via
|
|
10
|
+
* {@link cleanFile}) strips them.
|
|
11
|
+
*
|
|
12
|
+
* The target file set is CALLER-SUPPLIED: pass the globs your agent's
|
|
13
|
+
* instruction files live under (e.g. `["CLAUDE.md", "AGENTS.md",
|
|
14
|
+
* ".claude/**\/*.md", "**\/SKILL.md"]`), so no agent's convention is baked in.
|
|
15
|
+
*/
|
|
16
|
+
import {
|
|
17
|
+
readFileSync,
|
|
18
|
+
writeFileSync,
|
|
19
|
+
globSync,
|
|
20
|
+
renameSync,
|
|
21
|
+
lstatSync,
|
|
22
|
+
fstatSync,
|
|
23
|
+
realpathSync,
|
|
24
|
+
openSync,
|
|
25
|
+
fsyncSync,
|
|
26
|
+
fchmodSync,
|
|
27
|
+
closeSync,
|
|
28
|
+
unlinkSync,
|
|
29
|
+
constants,
|
|
30
|
+
} from "node:fs";
|
|
31
|
+
import { randomBytes } from "node:crypto";
|
|
32
|
+
import { join, relative, resolve, isAbsolute, dirname, sep } from "node:path";
|
|
33
|
+
import {
|
|
34
|
+
LONG_RUN_RE,
|
|
35
|
+
SCATTERED_THRESHOLD,
|
|
36
|
+
countPayloadInvisible,
|
|
37
|
+
stripInvisible,
|
|
38
|
+
} from "./invisible.mjs";
|
|
39
|
+
|
|
40
|
+
// Prefix on any decoded tag-character payload. The decoded text is
|
|
41
|
+
// attacker-controlled and flows into the scan report, which itself reaches model
|
|
42
|
+
// context — so it must be framed as DATA, never re-presented as a live
|
|
43
|
+
// instruction the model might follow.
|
|
44
|
+
const UNTRUSTED_PREFIX = "untrusted data, not instructions: ";
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Render decoded tag-character bytes as a NEUTRAL, quoted, escaped string so the
|
|
48
|
+
* scan report can never re-inject them. Only U+E0020–U+E007E decode to their
|
|
49
|
+
* printable ASCII (0x20–0x7E); every other tag byte (the C0 controls SOH…US and
|
|
50
|
+
* DEL that U+E0001–U+E001F / U+E007F would otherwise map to raw) is rendered as a
|
|
51
|
+
* visible `\xNN` escape rather than emitted as an actual control byte. Backslash
|
|
52
|
+
* and the surrounding quote are escaped in the SAME pass so an inserted escape
|
|
53
|
+
* can never be re-touched (CLAUDE.md: incomplete-string-escaping).
|
|
54
|
+
* @param {number[]} asciiCodes raw decoded bytes (cp − 0xE0000), each 0x01–0x7F
|
|
55
|
+
* @returns {string}
|
|
56
|
+
*/
|
|
57
|
+
function neutralizeTagBytes(asciiCodes) {
|
|
58
|
+
let out = "";
|
|
59
|
+
for (const code of asciiCodes) {
|
|
60
|
+
if (code === 0x5c) out += "\\\\";
|
|
61
|
+
else if (code === 0x22) out += '\\"';
|
|
62
|
+
else if (code >= 0x20 && code <= 0x7e) out += String.fromCharCode(code);
|
|
63
|
+
else out += `\\x${code.toString(16).toUpperCase().padStart(2, "0")}`;
|
|
64
|
+
}
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Decode a run of invisible characters to its likely payload. Recognizes the
|
|
70
|
+
* two common smuggling encodings — Unicode tag characters (U+E0001–U+E007F map
|
|
71
|
+
* directly to ASCII) and zero-width binary (ZWSP=0, ZWNJ=1, ZWJ=separator) —
|
|
72
|
+
* and otherwise reports the raw code points. The tag-character payload is
|
|
73
|
+
* rendered as a neutral, quoted/escaped `untrusted data, not instructions: "…"`
|
|
74
|
+
* string (see {@link neutralizeTagBytes}) so the report can never re-inject the
|
|
75
|
+
* hidden instruction, and only U+E0020–U+E007E map to raw printable ASCII.
|
|
76
|
+
* @param {string} run
|
|
77
|
+
* @returns {{ method: string, decoded: string }}
|
|
78
|
+
*/
|
|
79
|
+
export function decodeRun(run) {
|
|
80
|
+
const cps = [...run].map((ch) => /** @type {number} */ (ch.codePointAt(0)));
|
|
81
|
+
|
|
82
|
+
// Tag characters U+E0001-U+E007F: raw ASCII byte is cp − 0xE0000 (0x01–0x7F).
|
|
83
|
+
const tagBytes = cps
|
|
84
|
+
.filter((cp) => cp >= 0xe0001 && cp <= 0xe007f)
|
|
85
|
+
.map((cp) => cp - 0xe0000);
|
|
86
|
+
|
|
87
|
+
// Zero-width binary encoding: ZWSP=0, ZWNJ=1, ZWJ=group separator.
|
|
88
|
+
const ZW_BIT = new Map([
|
|
89
|
+
[0x200b, "0"],
|
|
90
|
+
[0x200c, "1"],
|
|
91
|
+
[0x200d, "|"],
|
|
92
|
+
]);
|
|
93
|
+
|
|
94
|
+
const zwCount = cps.filter((cp) => ZW_BIT.has(cp)).length;
|
|
95
|
+
|
|
96
|
+
// Only take the tag-characters branch when tag chars are the MAJORITY of the
|
|
97
|
+
// run. A run that is overwhelmingly zero-width bits plus ONE stray tag char is
|
|
98
|
+
// a zero-width-binary payload, not a tag payload — labeling it "Unicode tag
|
|
99
|
+
// characters → ASCII" buries the real (binary) payload behind the wrong
|
|
100
|
+
// method. Reporting accuracy only: the strip removes the whole run regardless.
|
|
101
|
+
if (tagBytes.length > 0 && tagBytes.length > cps.length / 2) {
|
|
102
|
+
// A run can carry BOTH tag-ASCII and zero-width chars; the strip removes the
|
|
103
|
+
// whole run regardless, but the operator-facing `decoded` must reflect the
|
|
104
|
+
// zero-width portion too rather than silently dropping it.
|
|
105
|
+
const note = zwCount > 0 ? ` + ${zwCount} zero-width char(s)` : "";
|
|
106
|
+
return {
|
|
107
|
+
method: "Unicode tag characters → ASCII",
|
|
108
|
+
decoded: `${UNTRUSTED_PREFIX}"${neutralizeTagBytes(tagBytes)}"${note}`,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Zero-width-binary branch: the whole run is ZW bits, OR ZW bits are the
|
|
113
|
+
// majority (so a run of many bits plus a stray tag/other char is decoded as
|
|
114
|
+
// the binary payload it actually is, not mislabeled). Decode only the ZW code
|
|
115
|
+
// points; a `+ N other char(s)` note keeps any non-ZW portion visible.
|
|
116
|
+
if (zwCount > 0 && zwCount > cps.length / 2) {
|
|
117
|
+
const bits = cps
|
|
118
|
+
.filter((cp) => ZW_BIT.has(cp))
|
|
119
|
+
.map((cp) => ZW_BIT.get(cp))
|
|
120
|
+
.join("");
|
|
121
|
+
const otherCount = cps.length - zwCount;
|
|
122
|
+
const note = otherCount > 0 ? ` + ${otherCount} other char(s)` : "";
|
|
123
|
+
return {
|
|
124
|
+
method: "zero-width binary encoding",
|
|
125
|
+
decoded: `[${zwCount} zero-width chars: ${bits.slice(0, 80)}]${note}`,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Neither class holds a strict majority (e.g. a 50/50 tag + zero-width run),
|
|
130
|
+
// or the run mixes both classes without one dominating. Raw-dumping U+…
|
|
131
|
+
// codepoints here would bury BOTH payloads; instead decode each recognized
|
|
132
|
+
// sub-encoding and concatenate, keeping any unrecognized remainder visible as
|
|
133
|
+
// a `+ N other char(s)` note.
|
|
134
|
+
if (tagBytes.length > 0 || zwCount > 0) {
|
|
135
|
+
const parts = [];
|
|
136
|
+
if (tagBytes.length > 0)
|
|
137
|
+
parts.push(`${UNTRUSTED_PREFIX}"${neutralizeTagBytes(tagBytes)}"`);
|
|
138
|
+
if (zwCount > 0) {
|
|
139
|
+
const bits = cps
|
|
140
|
+
.filter((cp) => ZW_BIT.has(cp))
|
|
141
|
+
.map((cp) => ZW_BIT.get(cp))
|
|
142
|
+
.join("");
|
|
143
|
+
parts.push(`[${zwCount} zero-width chars: ${bits.slice(0, 80)}]`);
|
|
144
|
+
}
|
|
145
|
+
const otherCount = cps.length - tagBytes.length - zwCount;
|
|
146
|
+
const note = otherCount > 0 ? ` + ${otherCount} other char(s)` : "";
|
|
147
|
+
return {
|
|
148
|
+
method: "mixed tag + zero-width encodings",
|
|
149
|
+
decoded: parts.join(" ") + note,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Unknown: no recognized sub-encoding present; report the raw code points.
|
|
154
|
+
return {
|
|
155
|
+
method: "invisible Unicode sequence",
|
|
156
|
+
decoded: cps
|
|
157
|
+
.map((cp) => `U+${cp.toString(16).toUpperCase().padStart(4, "0")}`)
|
|
158
|
+
.join(" "),
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Scan a file's text for hidden-Unicode injection. Reports each long invisible
|
|
164
|
+
* run (with its decoded payload) plus a single scattered-chars finding when the
|
|
165
|
+
* non-run invisible count crosses the threshold-evasion floor.
|
|
166
|
+
* @param {string} content
|
|
167
|
+
* @returns {Array<{ line: number | null, charCount: number, method: string, decoded: string }>}
|
|
168
|
+
* `line` is the 1-based line of a long-run finding, or `null` for the
|
|
169
|
+
* whole-file scattered-chars finding (not tied to a single line).
|
|
170
|
+
*/
|
|
171
|
+
export function scanText(content) {
|
|
172
|
+
const findings = [];
|
|
173
|
+
LONG_RUN_RE.lastIndex = 0;
|
|
174
|
+
let match;
|
|
175
|
+
let runChars = 0;
|
|
176
|
+
while ((match = LONG_RUN_RE.exec(content)) !== null) {
|
|
177
|
+
const lineNum = content.slice(0, match.index).split("\n").length;
|
|
178
|
+
const charCount = [...match[0]].length;
|
|
179
|
+
runChars += charCount;
|
|
180
|
+
findings.push({ line: lineNum, charCount, ...decodeRun(match[0]) });
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Threshold-evasion: scattered invisible chars not in a long run can still be
|
|
184
|
+
// a payload. Always evaluated; chars already in a run are excluded so they
|
|
185
|
+
// aren't double-counted. countPayloadInvisible is invisible.mjs's carve-out
|
|
186
|
+
// counter (the SSOT the stripper itself gates on): it discounts every
|
|
187
|
+
// invisible that does real rendering work — emoji presentation selectors
|
|
188
|
+
// (VS16 *and* VS15) on a real pictograph, emoji-sequence ZWJ, and linguistic
|
|
189
|
+
// ZWNJ/ZWJ between cursive letters or after a virama. A hand-rolled emoji-only
|
|
190
|
+
// mirror lived here before and over-counted linguistic joiners and VS15, so a
|
|
191
|
+
// ZWNJ-dense Persian doc or a doc of text-presentation hearts (❤︎) tripped a
|
|
192
|
+
// scattered false positive on content stripInvisible would preserve.
|
|
193
|
+
//
|
|
194
|
+
// Asymmetry (deliberate, benign): the minuend discounts preserved
|
|
195
|
+
// selectors/joiners EVERYWHERE, while `runChars` is each run's RAW length. A
|
|
196
|
+
// long run is ≥LONG_RUN_THRESHOLD *consecutive* invisibles, and a preserved
|
|
197
|
+
// selector/joiner is always flanked by a visible neighbor — so it cannot sit
|
|
198
|
+
// inside such a run, and the two counts describe disjoint chars in practice.
|
|
199
|
+
// In the pathological case that they don't (e.g. a run of stacked VS16), the
|
|
200
|
+
// raw `runChars` subtracts at most a few more than the minuend added, biasing
|
|
201
|
+
// `scattered` slightly LOW — a false negative, the precision-favoring
|
|
202
|
+
// direction, never a spurious finding. `scattered` may even go negative; the
|
|
203
|
+
// `>=` gate treats that as "no scatter", which is correct.
|
|
204
|
+
const scattered = countPayloadInvisible(content) - runChars;
|
|
205
|
+
if (scattered >= SCATTERED_THRESHOLD) {
|
|
206
|
+
findings.push({
|
|
207
|
+
line: null, // whole-file finding: scattered chars aren't tied to one line
|
|
208
|
+
charCount: scattered,
|
|
209
|
+
method: "scattered invisible chars (possible threshold evasion)",
|
|
210
|
+
decoded: `[${scattered} invisible chars distributed across file]`,
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return findings;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* True when `realChild` is `realRoot` itself or lives beneath it. Both inputs
|
|
219
|
+
* must already be realpath-resolved absolute paths. Containment is tested with
|
|
220
|
+
* `relative(root, child)`: the result is "" when they are the same path, and
|
|
221
|
+
* for a true descendant it is a forward path with no `..` segment and is not
|
|
222
|
+
* itself absolute — so a sibling like `/proj-evil` (relative => `../proj-evil`)
|
|
223
|
+
* is correctly rejected.
|
|
224
|
+
* @param {string} realRoot
|
|
225
|
+
* @param {string} realChild
|
|
226
|
+
* @returns {boolean}
|
|
227
|
+
*/
|
|
228
|
+
function isContained(realRoot, realChild) {
|
|
229
|
+
const rel = relative(realRoot, realChild);
|
|
230
|
+
return (
|
|
231
|
+
rel === "" ||
|
|
232
|
+
(!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel))
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Classify a glob match for containment: resolve it to a real (symlink-
|
|
238
|
+
* followed) path and decide whether to keep, drop, or reject it. Three
|
|
239
|
+
* outcomes are kept distinct:
|
|
240
|
+
*
|
|
241
|
+
* - The realpath is contained in `realRoot` → KEEP.
|
|
242
|
+
* - The match is contained LEXICALLY (the glob pattern itself, followed
|
|
243
|
+
* literally with no symlink resolution, never leaves `literalRoot`) but its
|
|
244
|
+
* REALPATH escapes `realRoot` — an in-tree symlink (e.g. a planted
|
|
245
|
+
* `CLAUDE.md -> /etc/passwd`) whose target lives outside the tree → SKIP.
|
|
246
|
+
* One planted symlink must not abort scanning every other instruction
|
|
247
|
+
* file; treat it like the existing dangling-symlink case.
|
|
248
|
+
* - The match ESCAPES LEXICALLY — the glob pattern itself reaches outside
|
|
249
|
+
* `literalRoot` via `..` or an absolute path outside the tree, with no
|
|
250
|
+
* symlink involved → THROW. That is a caller misconfiguration (a scanner
|
|
251
|
+
* pointed outside its own tree) that must surface loudly, not be silently
|
|
252
|
+
* skipped.
|
|
253
|
+
* - The path cannot be resolved at all (ENOENT/EACCES — a dangling symlink or
|
|
254
|
+
* unreadable entry that still lives inside the tree) → return false to SKIP
|
|
255
|
+
* it, matching scanInstructionFiles' existing skip-on-unreadable behavior.
|
|
256
|
+
*
|
|
257
|
+
* A genuine resolution failure is never allowed to masquerade as a
|
|
258
|
+
* containment pass: an unresolvable path is skipped, only a successfully
|
|
259
|
+
* resolved path is classified as kept/skipped/thrown above.
|
|
260
|
+
* @param {string} absPath absolute path to a glob match
|
|
261
|
+
* @param {string} realRoot realpath of the scan root
|
|
262
|
+
* @param {string} literalRoot `cwd`, resolved but NOT symlink-followed
|
|
263
|
+
* @param {string} pattern the glob that produced this match
|
|
264
|
+
* @returns {boolean} true to keep the match, false to skip it
|
|
265
|
+
*/
|
|
266
|
+
function keepContained(absPath, realRoot, literalRoot, pattern) {
|
|
267
|
+
let real;
|
|
268
|
+
try {
|
|
269
|
+
real = realpathSync(absPath);
|
|
270
|
+
} catch {
|
|
271
|
+
return false; // dangling/unreadable in-cwd match: skip, do not abort
|
|
272
|
+
}
|
|
273
|
+
if (isContained(realRoot, real)) return true;
|
|
274
|
+
// The glob pattern itself never left the scan root lexically, so the escape
|
|
275
|
+
// is caused by an in-tree symlink resolving outside the tree, not by the
|
|
276
|
+
// caller's glob configuration. Skip this one match, do not abort the scan.
|
|
277
|
+
if (isContained(literalRoot, absPath)) return false;
|
|
278
|
+
throw new Error(
|
|
279
|
+
`instruction-file path escapes scan root: pattern ${JSON.stringify(
|
|
280
|
+
pattern,
|
|
281
|
+
)} matched ${JSON.stringify(absPath)} which resolves to ${JSON.stringify(
|
|
282
|
+
real,
|
|
283
|
+
)} outside ${JSON.stringify(realRoot)}`,
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Expand `globs` (relative to `cwd`) to absolute file paths, skipping
|
|
289
|
+
* `node_modules`. The glob set is the caller's instruction-file convention.
|
|
290
|
+
*
|
|
291
|
+
* Containment is enforced per match (see {@link keepContained}): a match whose
|
|
292
|
+
* glob pattern itself escapes `cwd` — via `..` or an absolute-path glob
|
|
293
|
+
* outside the tree — THROWS, since reaching outside the tree is a caller
|
|
294
|
+
* misconfiguration. A match that lexically stays inside `cwd` but resolves
|
|
295
|
+
* (via an in-tree symlink) to a target outside the tree, or that simply
|
|
296
|
+
* cannot be resolved (a dangling symlink or unreadable entry inside the
|
|
297
|
+
* tree), is SKIPPED, so one bad symlink never aborts scanning the rest of the
|
|
298
|
+
* project.
|
|
299
|
+
* @param {string[]} globs
|
|
300
|
+
* @param {{ cwd?: string }} [options]
|
|
301
|
+
* @returns {string[]}
|
|
302
|
+
*/
|
|
303
|
+
export function findInstructionFiles(globs, { cwd = process.cwd() } = {}) {
|
|
304
|
+
const literalRoot = resolve(cwd);
|
|
305
|
+
const realRoot = realpathSync(literalRoot);
|
|
306
|
+
const seen = new Set();
|
|
307
|
+
for (const pattern of globs)
|
|
308
|
+
for (const name of globSync(pattern, {
|
|
309
|
+
cwd,
|
|
310
|
+
exclude: (entry) => entry === "node_modules",
|
|
311
|
+
})) {
|
|
312
|
+
// globSync returns absolute paths verbatim for an absolute pattern and
|
|
313
|
+
// cwd-relative names otherwise; joining an already-absolute name would
|
|
314
|
+
// double the prefix into a nonexistent path (the absolute-glob miss bug).
|
|
315
|
+
const absPath = isAbsolute(name) ? name : join(cwd, name);
|
|
316
|
+
if (keepContained(absPath, realRoot, literalRoot, pattern))
|
|
317
|
+
seen.add(absPath);
|
|
318
|
+
}
|
|
319
|
+
return [...seen];
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Scan every instruction file matched by `globs` and return only those with
|
|
324
|
+
* findings, each path reported relative to `cwd`. Unreadable/missing files are
|
|
325
|
+
* skipped. Pure scan — no mutation; pair with {@link cleanFile} to strip.
|
|
326
|
+
* @param {string[]} globs
|
|
327
|
+
* @param {{ cwd?: string }} [options]
|
|
328
|
+
* @returns {Array<{ file: string, findings: ReturnType<typeof scanText> }>}
|
|
329
|
+
*/
|
|
330
|
+
export function scanInstructionFiles(globs, { cwd = process.cwd() } = {}) {
|
|
331
|
+
const out = [];
|
|
332
|
+
for (const file of findInstructionFiles(globs, { cwd })) {
|
|
333
|
+
let content;
|
|
334
|
+
try {
|
|
335
|
+
content = readFileSync(file, "utf-8");
|
|
336
|
+
} catch {
|
|
337
|
+
continue; // missing or unreadable
|
|
338
|
+
}
|
|
339
|
+
const findings = scanText(content);
|
|
340
|
+
if (findings.length > 0) out.push({ file: relative(cwd, file), findings });
|
|
341
|
+
}
|
|
342
|
+
return out;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Atomically replace `absPath`'s contents with `data`, preserving `mode`.
|
|
347
|
+
*
|
|
348
|
+
* Writes to a sibling temp in the same directory, then `rename`s it over the
|
|
349
|
+
* original (same dir => same filesystem => the rename is atomic, not a
|
|
350
|
+
* cross-device copy). The temp name is UNPREDICTABLE (`tmpName()` defaults to
|
|
351
|
+
* crypto-random) and the temp is created exclusively (O_CREAT|O_EXCL): if the
|
|
352
|
+
* path already exists — including an attacker-planted symlink at a guessable
|
|
353
|
+
* temp name — the open fails (EEXIST) and does NOT follow the link to clobber
|
|
354
|
+
* its target. On the rare collision we fail loud rather than retry into a
|
|
355
|
+
* different attacker-controlled path.
|
|
356
|
+
*
|
|
357
|
+
* Crash-safety (matching the doc claim): the temp fd is `fsync`ed before the
|
|
358
|
+
* rename and the directory fd is `fsync`ed after it, so a power loss can't leave
|
|
359
|
+
* the renamed name pointing at unflushed/empty data or lose the rename itself.
|
|
360
|
+
* The EXACT `mode` is applied with `fchmod` (openSync's create mode is
|
|
361
|
+
* umask-masked, so it alone would drop bits), and a failed write/sync `unlink`s
|
|
362
|
+
* the temp before rethrowing so no partial temp leaks. `tmpName` and `remove`
|
|
363
|
+
* are injectable fault-injection seams for tests (force a known temp path; drive
|
|
364
|
+
* a cleanup-unlink failure); production callers never pass them.
|
|
365
|
+
* @param {string} absPath
|
|
366
|
+
* @param {string} data
|
|
367
|
+
* @param {number} mode
|
|
368
|
+
* @param {() => string} [tmpName]
|
|
369
|
+
* @param {(path: string) => void} [remove]
|
|
370
|
+
*/
|
|
371
|
+
export function atomicReplaceFile(
|
|
372
|
+
absPath,
|
|
373
|
+
data,
|
|
374
|
+
mode,
|
|
375
|
+
tmpName = () => `.${randomBytes(12).toString("hex")}.tmp`,
|
|
376
|
+
remove = unlinkSync,
|
|
377
|
+
) {
|
|
378
|
+
const dir = dirname(absPath);
|
|
379
|
+
const tmp = join(dir, tmpName());
|
|
380
|
+
// Exclusive create: EEXIST (incl. a planted symlink at the temp name)
|
|
381
|
+
// propagates directly — no temp of ours exists yet, so nothing to clean up
|
|
382
|
+
// and we must never unlink the attacker's pre-existing path.
|
|
383
|
+
const fd = openSync(
|
|
384
|
+
tmp,
|
|
385
|
+
constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL,
|
|
386
|
+
mode,
|
|
387
|
+
);
|
|
388
|
+
try {
|
|
389
|
+
// writeFileSync(fd, …) loops until every byte is written (a bare writeSync
|
|
390
|
+
// can short-write a large payload); it does not close the caller's fd.
|
|
391
|
+
writeFileSync(fd, data);
|
|
392
|
+
// Preserve the EXACT mode: openSync's create mode is umask-masked, fchmod is
|
|
393
|
+
// not, so this restores bits (e.g. group-write) the umask would have dropped.
|
|
394
|
+
fchmodSync(fd, mode);
|
|
395
|
+
// Flush the temp's bytes before the rename, else a crash can expose the
|
|
396
|
+
// renamed name pointing at empty/partial content.
|
|
397
|
+
fsyncSync(fd);
|
|
398
|
+
} catch (err) {
|
|
399
|
+
closeSync(fd);
|
|
400
|
+
try {
|
|
401
|
+
remove(tmp);
|
|
402
|
+
} catch {
|
|
403
|
+
// Best-effort cleanup only: rethrow the ORIGINAL failure below, not a
|
|
404
|
+
// secondary unlink error, so the real cause stays loud.
|
|
405
|
+
}
|
|
406
|
+
throw err;
|
|
407
|
+
}
|
|
408
|
+
closeSync(fd);
|
|
409
|
+
renameSync(tmp, absPath);
|
|
410
|
+
// fsync the DIRECTORY so the rename (a directory metadata change) is durable
|
|
411
|
+
// across a crash, not just the file's data blocks.
|
|
412
|
+
const dirFd = openSync(dir, constants.O_RDONLY);
|
|
413
|
+
try {
|
|
414
|
+
fsyncSync(dirFd);
|
|
415
|
+
} finally {
|
|
416
|
+
closeSync(dirFd);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Strip payload-capable invisible characters from `absPath` in place. Returns
|
|
422
|
+
* `true` when the file's bytes actually changed (a payload {@link scanText}
|
|
423
|
+
* flags was removed), `false` when {@link scanText} reports nothing, and `null`
|
|
424
|
+
* when scan flagged a payload but {@link stripInvisible} removes nothing — a
|
|
425
|
+
* fail-closed signal that the flagged run was PRESERVED (e.g. a well-formed
|
|
426
|
+
* emoji-tag sequence the stripper keeps), so the caller must not treat it as
|
|
427
|
+
* cleaned. `true` means and only means "bytes changed".
|
|
428
|
+
*
|
|
429
|
+
* Contract (scan/clean coherence): clean strips exactly what scan flags. A
|
|
430
|
+
* write happens ONLY when `scanText` reports a finding, so the "scan, then
|
|
431
|
+
* clean what scan flagged" workflow never silently rewrites a file scan called
|
|
432
|
+
* clean. A handful of sub-threshold invisible chars (which scan ignores) are
|
|
433
|
+
* left untouched — by design, the scanner's definition of a payload is the
|
|
434
|
+
* single source of truth for what gets removed.
|
|
435
|
+
*
|
|
436
|
+
* Refuses to follow symlinks: instruction files must be regular files. The read
|
|
437
|
+
* fd is opened with `O_NOFOLLOW`, so a symlinked path (which could redirect the
|
|
438
|
+
* read/write to a target outside the tree) makes the OPEN itself fail — closing
|
|
439
|
+
* the lstat→open TOCTOU window a separate stat would leave, in which the path
|
|
440
|
+
* could be swapped to a symlink between the check and the read.
|
|
441
|
+
*
|
|
442
|
+
* Non-UTF-8 safety (O9): the file is read as raw BYTES and required to round-trip
|
|
443
|
+
* losslessly through UTF-8 before any rewrite. `readFileSync(…, "utf-8")`
|
|
444
|
+
* silently maps invalid bytes to U+FFFD, which a naive strip-and-rewrite would
|
|
445
|
+
* then persist file-wide — so a non-UTF-8 file fails loud and is left untouched.
|
|
446
|
+
*
|
|
447
|
+
* Lost-update / TOCTOU guard: the on-path file is re-checked against the fstat
|
|
448
|
+
* snapshot taken right after open (inode, size, mtime, and not-a-symlink) before
|
|
449
|
+
* the rename; a concurrent write or symlink swap between our read and our write
|
|
450
|
+
* fails loud rather than silently clobbering the other writer.
|
|
451
|
+
*
|
|
452
|
+
* The write is atomic (see {@link atomicReplaceFile}): stripped content goes to
|
|
453
|
+
* a temp file in the same directory which is then `rename`d over the original
|
|
454
|
+
* (preserving the original file mode), fsync'd for crash-safety.
|
|
455
|
+
*
|
|
456
|
+
* Throws if the file cannot be read or written (the caller decides whether an
|
|
457
|
+
* unwritable contaminated file is fatal or falls back to alerting).
|
|
458
|
+
* @param {string} absPath
|
|
459
|
+
* @param {(path: string) => import("node:fs").Stats} [lstat] injectable
|
|
460
|
+
* pre-rename recheck stat (fault-injection seam, mirrors
|
|
461
|
+
* {@link atomicReplaceFile}'s `tmpName`): lets a test drive the concurrent
|
|
462
|
+
* write/symlink-swap that the TOCTOU guard exists to catch, which is otherwise
|
|
463
|
+
* unreachable from this fully-synchronous path. Defaults to `lstatSync`.
|
|
464
|
+
* @returns {boolean}
|
|
465
|
+
*/
|
|
466
|
+
export function cleanFile(absPath, lstat = lstatSync) {
|
|
467
|
+
let fd;
|
|
468
|
+
try {
|
|
469
|
+
fd = openSync(absPath, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
470
|
+
} catch (err) {
|
|
471
|
+
// O_NOFOLLOW on a symlink fails ELOOP (some libc report EMLINK); surface
|
|
472
|
+
// the same "regular files only" contract the old lstat check did. Other
|
|
473
|
+
// errors (ENOENT/EACCES/…) propagate unchanged.
|
|
474
|
+
const code = /** @type {NodeJS.ErrnoException} */ (err).code;
|
|
475
|
+
if (code === "ELOOP" || code === "EMLINK")
|
|
476
|
+
throw new Error(
|
|
477
|
+
`refusing to clean through a symlink (instruction files must be regular files): ${JSON.stringify(
|
|
478
|
+
absPath,
|
|
479
|
+
)}`,
|
|
480
|
+
{ cause: err },
|
|
481
|
+
);
|
|
482
|
+
throw err;
|
|
483
|
+
}
|
|
484
|
+
try {
|
|
485
|
+
const before = fstatSync(fd);
|
|
486
|
+
if (!before.isFile())
|
|
487
|
+
throw new Error(
|
|
488
|
+
`refusing to clean a non-regular file (instruction files must be regular files): ${JSON.stringify(
|
|
489
|
+
absPath,
|
|
490
|
+
)}`,
|
|
491
|
+
);
|
|
492
|
+
|
|
493
|
+
// Read raw bytes and require a lossless UTF-8 round-trip (see O9 above).
|
|
494
|
+
const raw = readFileSync(fd);
|
|
495
|
+
const original = raw.toString("utf-8");
|
|
496
|
+
if (!Buffer.from(original, "utf-8").equals(raw))
|
|
497
|
+
throw new Error(
|
|
498
|
+
`refusing to clean a file that is not valid UTF-8 (round-trip mismatch would corrupt it): ${JSON.stringify(
|
|
499
|
+
absPath,
|
|
500
|
+
)}`,
|
|
501
|
+
);
|
|
502
|
+
|
|
503
|
+
// Scan is the SSOT for what counts as a payload: don't rewrite a file scan
|
|
504
|
+
// would not flag, even if stripInvisible would technically remove a char.
|
|
505
|
+
if (scanText(original).length === 0) return false;
|
|
506
|
+
|
|
507
|
+
const stripped = stripInvisible(original);
|
|
508
|
+
// Re-verify the on-path file against the open-time snapshot before writing:
|
|
509
|
+
// an inode/size/mtime change (or a swap to a symlink) means someone modified
|
|
510
|
+
// it under us, so fail loud rather than clobber their write (lost update).
|
|
511
|
+
const after = lstat(absPath);
|
|
512
|
+
if (
|
|
513
|
+
after.isSymbolicLink() ||
|
|
514
|
+
after.ino !== before.ino ||
|
|
515
|
+
after.size !== before.size ||
|
|
516
|
+
after.mtimeMs !== before.mtimeMs
|
|
517
|
+
)
|
|
518
|
+
throw new Error(
|
|
519
|
+
`instruction file changed between read and write, refusing to clobber (possible concurrent write or symlink swap): ${JSON.stringify(
|
|
520
|
+
absPath,
|
|
521
|
+
)}`,
|
|
522
|
+
);
|
|
523
|
+
// `before.mode` is the opened regular file's mode. A crash before the rename
|
|
524
|
+
// leaves the original intact; after it the new content is fully present.
|
|
525
|
+
atomicReplaceFile(absPath, stripped, before.mode);
|
|
526
|
+
return true;
|
|
527
|
+
} finally {
|
|
528
|
+
closeSync(fd);
|
|
529
|
+
}
|
|
530
|
+
}
|