@coo-quack/sensitive-canary 0.6.0 → 0.8.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.
Files changed (37) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +830 -0
  3. package/README.md +269 -43
  4. package/dist/lib/bash-commands.js +405 -0
  5. package/dist/lib/command-tables.js +462 -0
  6. package/dist/lib/default-config.json +570 -0
  7. package/dist/lib/encoding.js +123 -0
  8. package/dist/lib/fail-closed.js +31 -0
  9. package/dist/lib/inspector.js +0 -0
  10. package/dist/lib/rules.js +399 -0
  11. package/dist/lib/shapes.js +161 -0
  12. package/dist/lib/shell.js +436 -0
  13. package/dist/lib/tool-inputs.js +217 -0
  14. package/dist/lib/transcript.js +115 -0
  15. package/dist/lib/validators.js +435 -0
  16. package/dist/pre-tool-use-hook.js +773 -0
  17. package/dist/user-prompt-submit-hook.js +105 -0
  18. package/hooks/hooks.json +1 -1
  19. package/package.json +25 -11
  20. package/src/lib/bash-commands.ts +455 -0
  21. package/src/lib/command-tables.ts +518 -0
  22. package/src/lib/default-config.json +570 -0
  23. package/src/lib/encoding.ts +135 -0
  24. package/src/lib/fail-closed.ts +36 -0
  25. package/src/lib/inspector.ts +0 -0
  26. package/src/lib/rules.ts +482 -267
  27. package/src/lib/shapes.ts +175 -0
  28. package/src/lib/shell.ts +512 -0
  29. package/src/lib/tool-inputs.ts +235 -0
  30. package/src/lib/transcript.ts +142 -0
  31. package/src/lib/validators.ts +435 -0
  32. package/src/pre-tool-use-hook.ts +774 -198
  33. package/src/user-prompt-submit-hook.ts +60 -18
  34. package/src/__tests__/pre-tool-use-hook.test.ts +0 -779
  35. package/src/__tests__/user-prompt-submit-hook.test.ts +0 -297
  36. package/src/lib/__tests__/inspector.test.ts +0 -281
  37. package/src/lib/__tests__/rules.test.ts +0 -448
@@ -0,0 +1,135 @@
1
+ // How a file's bytes are read as text.
2
+ //
3
+ // A file is not labelled with its encoding, so these decide. The decisions are
4
+ // deliberately loose and the caller reads the bytes both ways when the verdict
5
+ // is a guess: the alternative is a wrong guess that decodes a credential into
6
+ // characters no rule matches, which is the whole scan switched off for that
7
+ // file.
8
+
9
+ import fs from "node:fs";
10
+
11
+ // The bytes as little-endian UTF-16, with whether a byte-order mark said so.
12
+ //
13
+ // The mark decides it outright. Without one, the question is whether every NUL
14
+ // falls on the same side of each pair — which it does in UTF-16 text, because
15
+ // the high byte of a Latin character is zero. Requiring most pairs to carry one
16
+ // is too strong: a file whose first few hundred characters are Japanese or
17
+ // Chinese has neither byte zero. One NUL on a consistent side is enough to ask
18
+ // the question; whether the answer is text is what settles it.
19
+ //
20
+ // A guess this loose is wrong sometimes, so `fromBom` marks the ones that are
21
+ // guesses and the caller scans the bytes both ways rather than betting on it.
22
+ export type Utf16Reading = { bytes: Buffer; fromBom: boolean };
23
+
24
+ export function detectUtf16(raw: Buffer): Utf16Reading | null {
25
+ if (raw.length < 4) return null;
26
+ // Swapping is done on a copy: `raw` is a view into the shared read buffer.
27
+ const swapped = (): Buffer =>
28
+ Buffer.from(raw)
29
+ .subarray(0, raw.length & ~1)
30
+ .swap16();
31
+ if (raw[0] === 0xff && raw[1] === 0xfe) return { bytes: raw, fromBom: true };
32
+ if (raw[0] === 0xfe && raw[1] === 0xff)
33
+ return { bytes: swapped(), fromBom: true };
34
+
35
+ // Far enough in to reach a newline or a space. Five hundred pairs of Japanese
36
+ // carry no zero byte at all, and that prefix decided the whole file.
37
+ const pairs = Math.min(raw.length >> 1, 8192);
38
+ let evenNuls = 0;
39
+ let oddNuls = 0;
40
+ for (let i = 0; i < pairs; i++) {
41
+ if (raw[i * 2] === 0) evenNuls++;
42
+ if (raw[i * 2 + 1] === 0) oddNuls++;
43
+ }
44
+ // Several, not one: a single stray NUL is not an encoding.
45
+ const MINIMUM_NULS = 8;
46
+ if (Math.max(evenNuls, oddNuls) < MINIMUM_NULS) return null;
47
+
48
+ // How much the minority side holds is not asked. Characters in the U+xx00
49
+ // rows put a NUL on that side, and Japanese is full of them — `一` is U+4E00,
50
+ // and a full-width space is U+3000 — so any threshold tight enough to exclude
51
+ // a binary also excludes an ordinary Japanese document. The counts cannot
52
+ // separate those two cases, so the question is left to `readsAsText`, and
53
+ // being wrong costs only a pass: the caller reads the bytes both ways
54
+ // whenever the verdict did not come from a byte-order mark.
55
+ //
56
+ // Which side leads picks the order the two byte orders are tried in, not
57
+ // whether they are tried.
58
+ const orderings: Buffer[] =
59
+ oddNuls >= evenNuls ? [raw, swapped()] : [swapped(), raw];
60
+ for (const candidate of orderings) {
61
+ if (readsAsText(candidate)) return { bytes: candidate, fromBom: false };
62
+ }
63
+ return null;
64
+ }
65
+
66
+ // Whether the runs of text between the NUL bytes read as something someone
67
+ // wrote. A `.env` written by a tool that terminates its values, and a log with
68
+ // a stray zero in it, both pass; a JPEG's compressed bytes do not.
69
+ export function readsAsUtf8Text(raw: Buffer): boolean {
70
+ const sample = utf8Runs(raw.subarray(0, 4096));
71
+ if (sample.length === 0) return false;
72
+ let bad = 0;
73
+ for (const ch of sample) {
74
+ const code = ch.codePointAt(0) ?? 0;
75
+ const isControl =
76
+ (code < 0x20 && code !== 0x09 && code !== 0x0a && code !== 0x0d) ||
77
+ code === 0x7f;
78
+ if (isControl || code === 0xfffd) bad++;
79
+ }
80
+ return bad / sample.length < 0.05;
81
+ }
82
+
83
+ // Every run of text between the NUL bytes, joined by newlines so that no rule
84
+ // matches across two unrelated runs.
85
+ export function utf8Runs(raw: Buffer): string {
86
+ const text = raw.toString("utf8");
87
+ return text.indexOf("\0") === -1 ? text : text.split("\0").join("\n");
88
+ }
89
+
90
+ // Whether these bytes decoded as UTF-16 look like something someone wrote. A
91
+ // binary file can have its NULs on one side by chance; text decoded from the
92
+ // wrong encoding is mostly control characters and replacement characters, and
93
+ // this is what separates the two.
94
+ export function readsAsText(le: Buffer): boolean {
95
+ const sample = le.subarray(0, 4096).toString("utf16le");
96
+ if (sample.length === 0) return false;
97
+ let bad = 0;
98
+ for (const ch of sample) {
99
+ const code = ch.codePointAt(0) ?? 0;
100
+ const isControl =
101
+ (code < 0x20 && code !== 0x09 && code !== 0x0a && code !== 0x0d) ||
102
+ code === 0x7f;
103
+ if (isControl || code === 0xfffd || (code >= 0xe000 && code <= 0xf8ff))
104
+ bad++;
105
+ }
106
+ return bad / sample.length < 0.05;
107
+ }
108
+
109
+ // Whether the first few kilobytes hold a NUL byte that UTF-16 does not explain.
110
+ // UTF-16 text is half NUL by construction, and a `.env` written by PowerShell is
111
+ // the file a sweep least wants to skip.
112
+ const BINARY_SNIFF_BYTES = 4096;
113
+
114
+ export function looksBinary(filePath: string): boolean {
115
+ let fd: number | undefined;
116
+ try {
117
+ fd = fs.openSync(filePath, "r");
118
+ const head = Buffer.alloc(BINARY_SNIFF_BYTES);
119
+ const read = fs.readSync(fd, head, 0, head.length, 0);
120
+ const bytes = head.subarray(0, read);
121
+ if (!bytes.includes(0)) return false;
122
+ // Neither reading, rather than the UTF-16 verdict alone. That verdict is
123
+ // deliberately loose — the file scan covers a wrong guess by reading the
124
+ // bytes both ways — and a sweep has no such second chance, so it asks the
125
+ // question the answer is wanted for: does anything here read as text?
126
+ const utf16 = detectUtf16(bytes);
127
+ if (utf16 !== null && readsAsText(utf16.bytes)) return false;
128
+ return !readsAsUtf8Text(bytes);
129
+ } catch {
130
+ // Unreadable. Nothing to sweep, and the named-file path still guards it.
131
+ return true;
132
+ } finally {
133
+ if (fd !== undefined) fs.closeSync(fd);
134
+ }
135
+ }
@@ -0,0 +1,36 @@
1
+ // What both hooks do when the check itself goes wrong.
2
+ //
3
+ // A hook that crashes exits 1, and only exit 2 blocks, so an unforeseen error
4
+ // is a silent pass — the failure this whole tool exists to prevent. What went
5
+ // wrong is unknown at this point, and "unknown" is not "safe": the check did
6
+ // not finish, so the call is stopped rather than let through. `[allow-all]`
7
+ // gets past it, and the message says the check failed rather than claiming a
8
+ // finding.
9
+ //
10
+ // One copy, because two would be two rules: the wording, the exit code and
11
+ // which events are handled all have to be the same in both hooks, and a fix
12
+ // applied to one of two copies is a hook that fails open on the other.
13
+
14
+ export function failClosed(error: unknown): never {
15
+ try {
16
+ process.stderr.write(
17
+ `\n🐤 sensitive-canary: the check could not complete — ${
18
+ error instanceof Error ? error.message : String(error)
19
+ }\n\n` +
20
+ " Nothing was scanned, so nothing can be vouched for. Stopping rather\n" +
21
+ " than passing it through. Add [allow-all] to your prompt to proceed\n" +
22
+ " anyway, and please report this.\n",
23
+ );
24
+ } catch {
25
+ // A closed stderr must not turn the block back into a pass.
26
+ }
27
+ process.exit(2);
28
+ }
29
+
30
+ // Registered by both hooks as their first statement. A rejection that nothing
31
+ // awaited reaches the process the same way an exception does, and either one
32
+ // arriving unhandled is the pass this exists to stop.
33
+ export function blockOnUnhandledError(): void {
34
+ process.on("uncaughtException", failClosed);
35
+ process.on("unhandledRejection", failClosed);
36
+ }
Binary file