@coo-quack/sensitive-canary 0.7.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.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +791 -0
- package/README.md +142 -45
- package/dist/lib/bash-commands.js +405 -0
- package/dist/lib/command-tables.js +462 -0
- package/dist/lib/default-config.json +570 -0
- package/dist/lib/encoding.js +123 -0
- package/dist/lib/fail-closed.js +31 -0
- package/dist/lib/inspector.js +0 -0
- package/dist/lib/rules.js +399 -0
- package/dist/lib/shapes.js +161 -0
- package/dist/lib/shell.js +436 -0
- package/dist/lib/tool-inputs.js +217 -0
- package/dist/lib/transcript.js +115 -0
- package/dist/lib/validators.js +435 -0
- package/dist/pre-tool-use-hook.js +773 -0
- package/dist/user-prompt-submit-hook.js +105 -0
- package/hooks/hooks.json +1 -1
- package/package.json +25 -11
- package/src/lib/bash-commands.ts +455 -0
- package/src/lib/command-tables.ts +518 -0
- package/src/lib/default-config.json +155 -46
- package/src/lib/encoding.ts +135 -0
- package/src/lib/fail-closed.ts +36 -0
- package/src/lib/inspector.ts +0 -0
- package/src/lib/rules.ts +202 -365
- package/src/lib/shapes.ts +175 -0
- package/src/lib/shell.ts +512 -0
- package/src/lib/tool-inputs.ts +235 -0
- package/src/lib/transcript.ts +142 -0
- package/src/lib/validators.ts +435 -0
- package/src/pre-tool-use-hook.ts +774 -198
- package/src/user-prompt-submit-hook.ts +60 -18
- package/src/__tests__/pre-tool-use-hook.test.ts +0 -779
- package/src/__tests__/user-prompt-submit-hook.test.ts +0 -297
- package/src/lib/__tests__/inspector.test.ts +0 -289
- package/src/lib/__tests__/rules.test.ts +0 -1370
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// Whether a value that matched a rule is a credential after all.
|
|
2
|
+
//
|
|
3
|
+
// A rule finds a shape. These decide what the shape is standing for: a name
|
|
4
|
+
// that points at a secret, a slot waiting to be filled, a reference to a value
|
|
5
|
+
// in code. Each one waves a match through, so each is a way past every rule it
|
|
6
|
+
// runs over, and each is written to be narrower than the shape it answers for.
|
|
7
|
+
|
|
8
|
+
// A value whose shape says it is not a credential, whatever its name suggests.
|
|
9
|
+
// `TOKEN_ENDPOINT`, `secret_name`, `VAULT_TOKEN_PATH` and `TOKEN_HEADER_NAME`
|
|
10
|
+
// all assign something that points at a secret rather than being one, and
|
|
11
|
+
// blocking them made Terraform, Kubernetes manifests and OAuth configuration
|
|
12
|
+
// unreadable — fourteen of the twenty-six wrong blocks in a survey of six
|
|
13
|
+
// hundred real files.
|
|
14
|
+
export function isNotSecretShaped(value: string): boolean {
|
|
15
|
+
const v = value.trim();
|
|
16
|
+
// A URL or a URN. Credentials embedded in one are the connection-string
|
|
17
|
+
// rule's business, and a URL carrying a token in its query is left alone
|
|
18
|
+
// here so the `?` case still reaches the other rules.
|
|
19
|
+
if (
|
|
20
|
+
/^[a-z][a-z0-9+.-]*:\/\//i.test(v) &&
|
|
21
|
+
!v.includes("?") &&
|
|
22
|
+
!v.includes("@")
|
|
23
|
+
)
|
|
24
|
+
return true;
|
|
25
|
+
if (/^urn:/i.test(v)) return true;
|
|
26
|
+
// A filesystem path.
|
|
27
|
+
if (/^[~.]?\/[^\s]*$/.test(v)) return true;
|
|
28
|
+
// The name of a variable rather than its value.
|
|
29
|
+
if (/^[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+$/.test(v)) return true;
|
|
30
|
+
// An HTTP header name.
|
|
31
|
+
if (/^[A-Z][A-Za-z0-9]*(?:-[A-Z][A-Za-z0-9]*)+$/.test(v)) return true;
|
|
32
|
+
// A number.
|
|
33
|
+
if (/^\d+$/.test(v)) return true;
|
|
34
|
+
// A dotted lower-case identifier, as a storage key or a setting name.
|
|
35
|
+
if (/^[a-z][a-z0-9]*(?:\.[a-z0-9]+)+$/.test(v)) return true;
|
|
36
|
+
// A reference to a value in code rather than the value: `process.env.API_KEY`,
|
|
37
|
+
// `user.password_digest`, `self.api_key`, `response.data.accessToken`. Of the
|
|
38
|
+
// distinct values `env-assignment` matched across thirty thousand real files,
|
|
39
|
+
// two in five were one of these.
|
|
40
|
+
if (
|
|
41
|
+
/^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)+$/.test(v) &&
|
|
42
|
+
v.split(".").every(readsAsWords)
|
|
43
|
+
)
|
|
44
|
+
return true;
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Whether a name is built out of words rather than out of random characters.
|
|
49
|
+
//
|
|
50
|
+
// Dotted credentials exist — a JWT, and the dotted forms several vendors issue —
|
|
51
|
+
// so the shape alone cannot stand for "this is code". What separates them is
|
|
52
|
+
// that a name is words: `connectionString` and `password_digest` run several
|
|
53
|
+
// letters between one boundary and the next, where a random segment changes
|
|
54
|
+
// case or slips in a digit every character or two.
|
|
55
|
+
//
|
|
56
|
+
// Measured over 147,643 dotted identifiers taken from the source on this
|
|
57
|
+
// machine, 0.06% fall below the threshold below, and the ones that do are JWTs.
|
|
58
|
+
export const MIN_MEAN_WORD_LENGTH = 2.5;
|
|
59
|
+
|
|
60
|
+
// Short segments are words by default: `env`, `data`, `id`. The statistic needs
|
|
61
|
+
// something to average over before it says anything.
|
|
62
|
+
export const SHORTEST_MEASURABLE_SEGMENT = 8;
|
|
63
|
+
|
|
64
|
+
export function readsAsWords(segment: string): boolean {
|
|
65
|
+
if (segment.length < SHORTEST_MEASURABLE_SEGMENT) return true;
|
|
66
|
+
// A leading capital belongs to the lowercase run after it, so `toLowerCase`
|
|
67
|
+
// is three words and not five runs of one case.
|
|
68
|
+
const words = segment.match(/[A-Z]+(?![a-z])|[A-Z]?[a-z]+|\d+/g);
|
|
69
|
+
if (words === null || words.length === 0) return false;
|
|
70
|
+
return segment.length / words.length >= MIN_MEAN_WORD_LENGTH;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// A key that says where a secret lives, or what it is called, rather than what
|
|
74
|
+
// it is. The rule fires on the keyword anywhere in the name, so
|
|
75
|
+
// `SECRET_MANAGER_PROJECT` reads as a secret because of its first word, when its
|
|
76
|
+
// last one says it holds a project.
|
|
77
|
+
const DESCRIBES_A_SECRET =
|
|
78
|
+
/\b[A-Za-z0-9_]*_(?:PROJECT|NAME|PATH|FILE|DIR|URL|URI|ENDPOINT|HOST|PORT|ID|TYPE|HEADER|PREFIX|SUFFIX|FIELD|COLUMN|TABLE|ENV|REGION|BUCKET|ARN|VERSION|TTL|TIMEOUT|LENGTH|COUNT|ENABLED|ALGORITHM|ISSUER|AUDIENCE|SCOPE|PROVIDER|BACKEND|SOURCE)\b[ \t]*[:=]/i;
|
|
79
|
+
|
|
80
|
+
export function keyDescribesRatherThanHolds(matchText: string): boolean {
|
|
81
|
+
return DESCRIBES_A_SECRET.test(matchText);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// A value written to be replaced. Half of a realistic `.env.example` was being
|
|
85
|
+
// blocked on its contents, which is the block most likely to get the tool turned
|
|
86
|
+
// off — the file is meant to be committed and read.
|
|
87
|
+
//
|
|
88
|
+
// Only secret rules consult this. "todo@company.com" is a real address, and
|
|
89
|
+
// AWS's own documented key ends in EXAMPLE and is still a key, so `example` is
|
|
90
|
+
// deliberately absent from the marker list where it would matter.
|
|
91
|
+
|
|
92
|
+
// A word that only ever appears in a value nobody typed.
|
|
93
|
+
const PLACEHOLDER_MARKERS =
|
|
94
|
+
/^(?:changeme|change|me|replace|insert|set|with|real|this|your|my|here|todo|tbd|fixme|dummy|placeholder|insecure|sample|example|test|fake|redacted|value|x{3,})$/i;
|
|
95
|
+
|
|
96
|
+
// A word that can make up the rest of such a value, but never marks one alone.
|
|
97
|
+
const PLACEHOLDER_FILLER =
|
|
98
|
+
/^(?:api|key|keys|token|tokens|secret|secrets|password|passwd|pwd|pass|base|url|uri|host|hostname|name|user|username|id|access|refresh|client|auth|sk|pk|in|production|development|staging|local|dev|the|a|of|for|and|[0-9]+)$/i;
|
|
99
|
+
|
|
100
|
+
// The scheme is bounded: an unbounded `\w+` in front of a literal that usually
|
|
101
|
+
// is not there makes the match quadratic in the length of the value, and a
|
|
102
|
+
// value is as long as whoever wrote the text wants. A scheme is a word.
|
|
103
|
+
const GENERIC_CREDENTIALS =
|
|
104
|
+
/\w{1,32}:\/\/(?:your[_-]?)?(?:user|username)(?:name)?:(?:your[_-]?)?(?:password|passwd|pwd)@/i;
|
|
105
|
+
|
|
106
|
+
const GENERIC_HOST =
|
|
107
|
+
/^(?:localhost|127\.0\.0\.1|0\.0\.0\.0|host|hostname|db|database|example\.(?:com|org|net))\b/i;
|
|
108
|
+
|
|
109
|
+
export function isPlaceholder(value: string, following = ""): boolean {
|
|
110
|
+
const v = value.trim();
|
|
111
|
+
if (!v) return false;
|
|
112
|
+
// Filler on its own: `xxxxxxxxxxxx`.
|
|
113
|
+
if (/^[Xx]+$/.test(v)) return true;
|
|
114
|
+
// A slot rather than a value: `<your-token>`, `${TOKEN}`, `{{ token }}`.
|
|
115
|
+
if (/^[<{[]/.test(v) && /[>}\]]$/.test(v)) return true;
|
|
116
|
+
// A shell or template reference, which holds nothing at all.
|
|
117
|
+
if (/^\$\{?[A-Za-z_][A-Za-z0-9_]*\}?$/.test(v)) return true;
|
|
118
|
+
// An unexpanded reference anywhere inside a connection string: no character
|
|
119
|
+
// of the credentials has been substituted yet.
|
|
120
|
+
if (
|
|
121
|
+
/:\/\/[^@\s]*(?:\$\{[A-Za-z_][^}]*\}|\$[A-Za-z_][A-Za-z0-9_]*|\{\})[^@\s]*@/.test(
|
|
122
|
+
v,
|
|
123
|
+
)
|
|
124
|
+
)
|
|
125
|
+
return true;
|
|
126
|
+
// A user and a password that are the same word, and that word names the
|
|
127
|
+
// service: `postgres:postgres@`, `root:root@`, `guest:guest@` are what a
|
|
128
|
+
// compose file and a quickstart ship with.
|
|
129
|
+
const samePair = v.match(/:\/\/([A-Za-z]{3,12}):([A-Za-z]{3,12})@/);
|
|
130
|
+
if (
|
|
131
|
+
samePair &&
|
|
132
|
+
samePair[1]?.toLowerCase() === samePair[2]?.toLowerCase() &&
|
|
133
|
+
/^(?:postgres|postgresql|mysql|mariadb|mongo|mongodb|redis|root|guest|admin|user|test|rabbitmq)$/i.test(
|
|
134
|
+
samePair[1] ?? "",
|
|
135
|
+
)
|
|
136
|
+
)
|
|
137
|
+
return true;
|
|
138
|
+
// The default the django template generates, which ships in every new project.
|
|
139
|
+
if (/^django-insecure-/i.test(v)) return true;
|
|
140
|
+
// A connection string where the user, the password and the host are all the
|
|
141
|
+
// words for them. Each of the three matters: `root:secret@` is a password
|
|
142
|
+
// people set, and `user:password@prod.corp.internal` names real infrastructure.
|
|
143
|
+
// The rule that finds these stops at the `@`, so the host arrives as the text
|
|
144
|
+
// that follows rather than as part of the value.
|
|
145
|
+
//
|
|
146
|
+
// Matched once. Asking three times ran the same backtracking three times.
|
|
147
|
+
const credentials = GENERIC_CREDENTIALS.exec(v);
|
|
148
|
+
if (credentials !== null) {
|
|
149
|
+
const afterAt = v.slice(credentials.index + credentials[0].length);
|
|
150
|
+
if (GENERIC_HOST.test(afterAt || following)) return true;
|
|
151
|
+
}
|
|
152
|
+
// Otherwise every part of the value has to be one of these words, and at
|
|
153
|
+
// least one has to be a marker. Testing whether the value *contains* a marker
|
|
154
|
+
// was a way through: `changeme_` in front of a live key disabled the rule.
|
|
155
|
+
const parts = v.split(/[-_.\s]+/).filter(Boolean);
|
|
156
|
+
if (parts.length === 0) return false;
|
|
157
|
+
if (!parts.some((part) => PLACEHOLDER_MARKERS.test(part))) return false;
|
|
158
|
+
return parts.every(
|
|
159
|
+
(part) => PLACEHOLDER_MARKERS.test(part) || PLACEHOLDER_FILLER.test(part),
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Shannon entropy (bits per character; ≈0–8 for byte-sized alphabets)
|
|
164
|
+
export function entropy(str: string): number {
|
|
165
|
+
if (str.length === 0) return 0;
|
|
166
|
+
const freq: Record<string, number> = {};
|
|
167
|
+
for (const ch of str) freq[ch] = (freq[ch] ?? 0) + 1;
|
|
168
|
+
let h = 0;
|
|
169
|
+
const n = str.length;
|
|
170
|
+
for (const count of Object.values(freq)) {
|
|
171
|
+
const p = count / n;
|
|
172
|
+
h -= p * Math.log2(p);
|
|
173
|
+
}
|
|
174
|
+
return h;
|
|
175
|
+
}
|
package/src/lib/shell.ts
ADDED
|
@@ -0,0 +1,512 @@
|
|
|
1
|
+
// Shell syntax, and nothing about what any particular command means.
|
|
2
|
+
//
|
|
3
|
+
// Splitting a command line into segments and tokens, quote removal, heredoc
|
|
4
|
+
// bodies, and the substitutions whose inner text is a command line of its own.
|
|
5
|
+
// Everything here answers "what are the pieces of this command line". What a
|
|
6
|
+
// piece does with its operands is bash-commands.ts.
|
|
7
|
+
|
|
8
|
+
// One token of a command line, with quotes removed.
|
|
9
|
+
//
|
|
10
|
+
// `redirect` records where the token came from, which quote removal otherwise
|
|
11
|
+
// destroys: `cat > out` writes a file and `cat ">" secrets` reads one, yet both
|
|
12
|
+
// yield a token whose text is `>`. Reading the text alone, the second looked
|
|
13
|
+
// like a redirection and the operand after it was skipped as an output target —
|
|
14
|
+
// so `grep ">" secrets`, an ordinary way to search for a `>` character, went
|
|
15
|
+
// unscanned. Only the source form separates the two.
|
|
16
|
+
export interface ShellToken {
|
|
17
|
+
value: string;
|
|
18
|
+
// True for a redirection operator the tokenizer read from the source: `<`,
|
|
19
|
+
// `>`, `<<`, `>>`, `<<<`, each emitted on its own with any file-descriptor
|
|
20
|
+
// prefix dropped. The token after one is a target or a heredoc delimiter
|
|
21
|
+
// rather than an operand.
|
|
22
|
+
redirect: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Where reading continues after `s[i]`, and the quote state there.
|
|
26
|
+
//
|
|
27
|
+
// `consumed` marks a position that was quoting syntax rather than content: an
|
|
28
|
+
// opening or closing quote, or a backslash and the character it escapes. A
|
|
29
|
+
// caller skips those and inspects only the rest.
|
|
30
|
+
interface QuoteStep {
|
|
31
|
+
next: number;
|
|
32
|
+
quote: string | null;
|
|
33
|
+
consumed: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// The quoting rule, for the scanners that only need to step over it: a quote
|
|
37
|
+
// character opens a run and its twin closes it, and a backslash escapes the next
|
|
38
|
+
// character everywhere except inside single quotes, where it is literal.
|
|
39
|
+
//
|
|
40
|
+
// Three of them spelled that out for themselves — as `quote === '"' && ch ===
|
|
41
|
+
// "\\"`, as `ch === "\\" && quote !== "'"`, and as `i += quote === "'" ? 1 : 2`.
|
|
42
|
+
// They agreed, which is the point: three spellings of one rule agree until
|
|
43
|
+
// someone corrects one of them.
|
|
44
|
+
//
|
|
45
|
+
// `tokenizeCommand` is not one of the three and keeps its own reading, because
|
|
46
|
+
// it does not step over a quoted run — it builds the token's text out of it,
|
|
47
|
+
// dropping the quotes, decoding `$'…'` escapes and keeping a backslash literal
|
|
48
|
+
// inside single quotes. Returning a position cannot say what the text became.
|
|
49
|
+
function stepQuote(s: string, i: number, quote: string | null): QuoteStep {
|
|
50
|
+
const ch = s[i] as string;
|
|
51
|
+
|
|
52
|
+
if (ch === "\\" && quote !== "'") {
|
|
53
|
+
return { next: i + 2, quote, consumed: true };
|
|
54
|
+
}
|
|
55
|
+
if (quote !== null) {
|
|
56
|
+
return ch === quote
|
|
57
|
+
? { next: i + 1, quote: null, consumed: true }
|
|
58
|
+
: { next: i + 1, quote, consumed: false };
|
|
59
|
+
}
|
|
60
|
+
if (ch === "'" || ch === '"') {
|
|
61
|
+
return { next: i + 1, quote: ch, consumed: true };
|
|
62
|
+
}
|
|
63
|
+
return { next: i + 1, quote: null, consumed: false };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Variable names referenced by the command, including expansion forms that carry
|
|
67
|
+
// a suffix such as `${TOKEN:-fallback}` or `${TOKEN#prefix}`.
|
|
68
|
+
//
|
|
69
|
+
// Every `$` that a name follows counts, rather than each expansion being matched
|
|
70
|
+
// whole. Matching `${NAME…}` as a unit meant the suffix was consumed by the
|
|
71
|
+
// pattern that skipped to the closing brace, and a name inside the suffix went
|
|
72
|
+
// with it: `${A:-$TOKEN}` prints `$TOKEN` when `A` is unset, and named only `A`.
|
|
73
|
+
// An unclosed `${NAME` now yields its name too, which errs towards scanning.
|
|
74
|
+
export function extractEnvVarNames(command: string): string[] {
|
|
75
|
+
const names = new Set<string>();
|
|
76
|
+
for (const match of command.matchAll(/\$\{?([A-Za-z_][A-Za-z0-9_]*)/g)) {
|
|
77
|
+
const name = match[1];
|
|
78
|
+
if (name) names.add(name);
|
|
79
|
+
}
|
|
80
|
+
return [...names];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Split a command line into segments (at |, ;, &, &&, || and newlines) and each
|
|
84
|
+
// segment into tokens with quotes removed. Redirection operators become tokens of
|
|
85
|
+
// their own so that `wc -l <f` and `wc -l < f` tokenize alike. Substitutions are
|
|
86
|
+
// left in place; extractSubstitutions handles them against the raw string.
|
|
87
|
+
export function tokenizeCommand(command: string): ShellToken[][] {
|
|
88
|
+
const segments: ShellToken[][] = [];
|
|
89
|
+
let tokens: ShellToken[] = [];
|
|
90
|
+
let current = "";
|
|
91
|
+
let hasCurrent = false;
|
|
92
|
+
let i = 0;
|
|
93
|
+
|
|
94
|
+
const endToken = (): void => {
|
|
95
|
+
if (hasCurrent) {
|
|
96
|
+
tokens.push({ value: current, redirect: false });
|
|
97
|
+
current = "";
|
|
98
|
+
hasCurrent = false;
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
const endSegment = (): void => {
|
|
102
|
+
endToken();
|
|
103
|
+
if (tokens.length > 0) segments.push(tokens);
|
|
104
|
+
tokens = [];
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
while (i < command.length) {
|
|
108
|
+
const ch = command[i] as string;
|
|
109
|
+
|
|
110
|
+
if (ch === "\\") {
|
|
111
|
+
const next = command[i + 1];
|
|
112
|
+
if (next !== undefined && next !== "\n") {
|
|
113
|
+
current += next;
|
|
114
|
+
hasCurrent = true;
|
|
115
|
+
}
|
|
116
|
+
i += next === undefined ? 1 : 2;
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (
|
|
121
|
+
ch === "'" ||
|
|
122
|
+
ch === '"' ||
|
|
123
|
+
(ch === "$" && (command[i + 1] === "'" || command[i + 1] === '"'))
|
|
124
|
+
) {
|
|
125
|
+
// $'...' (ANSI-C) and $"..." (locale) are quoting syntax: the `$` is not
|
|
126
|
+
// part of the token. Inside $'...', backslash escapes are decoded.
|
|
127
|
+
let quote = ch;
|
|
128
|
+
let ansiC = false;
|
|
129
|
+
if (ch === "$") {
|
|
130
|
+
quote = command[i + 1] as string;
|
|
131
|
+
ansiC = quote === "'";
|
|
132
|
+
i += 2;
|
|
133
|
+
} else {
|
|
134
|
+
i++;
|
|
135
|
+
}
|
|
136
|
+
hasCurrent = true;
|
|
137
|
+
while (i < command.length && command[i] !== quote) {
|
|
138
|
+
if (command[i] === "\\" && command[i + 1] !== undefined) {
|
|
139
|
+
if (quote === '"' || ansiC) {
|
|
140
|
+
current += ansiC
|
|
141
|
+
? decodeAnsiCEscape(command, i)
|
|
142
|
+
: (command[i + 1] as string);
|
|
143
|
+
i += ansiC ? ansiCEscapeLength(command, i) : 2;
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
// plain single quotes keep backslashes literal
|
|
147
|
+
}
|
|
148
|
+
current += command[i];
|
|
149
|
+
i++;
|
|
150
|
+
}
|
|
151
|
+
i++; // closing quote, or end of input for an unbalanced one
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (ch === "|" || ch === ";" || ch === "&" || ch === "\n") {
|
|
156
|
+
endSegment();
|
|
157
|
+
while (i < command.length && /[|;&\n\s]/.test(command[i] as string)) i++;
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// A substitution standing among the operands is one word to the command, so
|
|
162
|
+
// it is consumed whole and no token is emitted for it. Ending the segment
|
|
163
|
+
// here instead would cut the operand list in two: `cat <(echo hi) secrets`
|
|
164
|
+
// leaves `secrets` in a segment of its own, where it reads as a command name
|
|
165
|
+
// and its own reading goes unseen.
|
|
166
|
+
//
|
|
167
|
+
// The inner command is still reached: extractSubstitutions walks the raw
|
|
168
|
+
// string for these forms, and the paths are deduplicated.
|
|
169
|
+
if ((ch === "$" || ch === "<" || ch === ">") && command[i + 1] === "(") {
|
|
170
|
+
endToken();
|
|
171
|
+
i = findSubstitutionEnd(command, i + 2, ")") + 1;
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// A subshell holds a command line of its own. Without this, `(cat secrets)`
|
|
176
|
+
// tokenized as `(cat` and `secrets)`, naming neither a command this hook
|
|
177
|
+
// classifies nor a path that exists, and the read went unseen.
|
|
178
|
+
if (ch === "(" || ch === ")") {
|
|
179
|
+
endSegment();
|
|
180
|
+
i++;
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (ch === "<" || ch === ">") {
|
|
185
|
+
// A file-descriptor prefix belongs to the operator, not to a token of its
|
|
186
|
+
// own, so `cmd 2>err` tokenizes like `cmd >err`. Left as a token, the `2`
|
|
187
|
+
// would read as an operand of the command — a filename, or a subcommand
|
|
188
|
+
// for whatever later decides what a command does with its operands. It
|
|
189
|
+
// names neither, so it is dropped. Only digits written against the
|
|
190
|
+
// operator count, leaving `sort 1 >out` alone.
|
|
191
|
+
if (hasCurrent && /^\d+$/.test(current)) {
|
|
192
|
+
current = "";
|
|
193
|
+
hasCurrent = false;
|
|
194
|
+
}
|
|
195
|
+
endToken();
|
|
196
|
+
let op = ch;
|
|
197
|
+
i++;
|
|
198
|
+
while (i < command.length && command[i] === ch) {
|
|
199
|
+
op += ch;
|
|
200
|
+
i++;
|
|
201
|
+
}
|
|
202
|
+
tokens.push({ value: op, redirect: true });
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (ch === " " || ch === "\t" || ch === "\r") {
|
|
207
|
+
endToken();
|
|
208
|
+
i++;
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
current += ch;
|
|
213
|
+
hasCurrent = true;
|
|
214
|
+
i++;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
endSegment();
|
|
218
|
+
return segments;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Length of the ANSI-C escape starting at `command[i]` (a backslash), so the
|
|
222
|
+
// tokenizer can skip the whole sequence: \xHH is 4 chars, anything else is 2.
|
|
223
|
+
function ansiCEscapeLength(command: string, i: number): number {
|
|
224
|
+
return command[i + 1] === "x" &&
|
|
225
|
+
/^[0-9A-Fa-f]{2}$/.test(command.slice(i + 2, i + 4))
|
|
226
|
+
? 4
|
|
227
|
+
: 2;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Decode the ANSI-C escape starting at `command[i]` (a backslash). Covers the
|
|
231
|
+
// escapes that appear in paths: \\, \', \", \xHH and the common letter escapes.
|
|
232
|
+
function decodeAnsiCEscape(command: string, i: number): string {
|
|
233
|
+
const esc = command[i + 1] as string;
|
|
234
|
+
if (esc === "x" && /^[0-9A-Fa-f]{2}$/.test(command.slice(i + 2, i + 4))) {
|
|
235
|
+
return String.fromCharCode(
|
|
236
|
+
Number.parseInt(command.slice(i + 2, i + 4), 16),
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
const simple: Record<string, string> = {
|
|
240
|
+
"\\": "\\",
|
|
241
|
+
"'": "'",
|
|
242
|
+
'"': '"',
|
|
243
|
+
n: "\n",
|
|
244
|
+
t: "\t",
|
|
245
|
+
r: "\r",
|
|
246
|
+
"0": "\0",
|
|
247
|
+
};
|
|
248
|
+
return simple[esc] ?? esc;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// One heredoc delimiter introduced by a command line. `allowTabs` marks the
|
|
252
|
+
// `<<-` form, whose closing delimiter may be tab-indented.
|
|
253
|
+
interface HeredocDelimiter {
|
|
254
|
+
delim: string;
|
|
255
|
+
allowTabs: boolean;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// The delimiter word starting at `line[from]`, with quote removal applied the way
|
|
259
|
+
// the shell does it: `<<EOF`, `<<'EOF'`, `<<"EOF"` and `<<E"O"F` all end their
|
|
260
|
+
// body at the line `EOF`. The word ends at whitespace or a shell metacharacter.
|
|
261
|
+
//
|
|
262
|
+
// A narrower character class (`[A-Za-z0-9_.]`) cuts the word short, and a
|
|
263
|
+
// truncated delimiter never matches the real closing line: stripHeredocBodies
|
|
264
|
+
// then swallows the rest of the command, so `cat > f <<EOF-1 … EOF-1` followed
|
|
265
|
+
// by `cat .env` hides the read entirely.
|
|
266
|
+
function readHeredocDelimiter(
|
|
267
|
+
line: string,
|
|
268
|
+
from: number,
|
|
269
|
+
): { delim: string; next: number } {
|
|
270
|
+
let delim = "";
|
|
271
|
+
let i = from;
|
|
272
|
+
|
|
273
|
+
while (i < line.length) {
|
|
274
|
+
const ch = line[i] as string;
|
|
275
|
+
if (ch === "'" || ch === '"') {
|
|
276
|
+
i++;
|
|
277
|
+
while (i < line.length && line[i] !== ch) {
|
|
278
|
+
if (ch === '"' && line[i] === "\\" && line[i + 1] !== undefined) {
|
|
279
|
+
delim += line[i + 1];
|
|
280
|
+
i += 2;
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
delim += line[i];
|
|
284
|
+
i++;
|
|
285
|
+
}
|
|
286
|
+
i++; // closing quote, or end of line for an unbalanced one
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
if (ch === "\\" && line[i + 1] !== undefined) {
|
|
290
|
+
delim += line[i + 1];
|
|
291
|
+
i += 2;
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
if (/[\s|&;()<>`]/.test(ch)) break;
|
|
295
|
+
delim += ch;
|
|
296
|
+
i++;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
return { delim, next: i };
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Heredoc delimiters introduced by one command line, in order. `<<-` allows a
|
|
303
|
+
// tab-indented closing delimiter; `<<<` is a herestring and is not a heredoc.
|
|
304
|
+
// Matches outside quotes only, so `echo "a <<EOF b"` is not a heredoc start.
|
|
305
|
+
function findHeredocDelimiters(line: string): HeredocDelimiter[] {
|
|
306
|
+
const found: HeredocDelimiter[] = [];
|
|
307
|
+
let quote: string | null = null;
|
|
308
|
+
let i = 0;
|
|
309
|
+
|
|
310
|
+
while (i < line.length) {
|
|
311
|
+
const step = stepQuote(line, i, quote);
|
|
312
|
+
quote = step.quote;
|
|
313
|
+
if (step.consumed || quote !== null) {
|
|
314
|
+
i = step.next;
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
const ch = line[i] as string;
|
|
318
|
+
if (ch === "<" && line[i + 1] === "<") {
|
|
319
|
+
let j = i + 2;
|
|
320
|
+
let allowTabs = false;
|
|
321
|
+
if (line[j] === "-") {
|
|
322
|
+
allowTabs = true;
|
|
323
|
+
j++;
|
|
324
|
+
}
|
|
325
|
+
if (line[j] === "<") {
|
|
326
|
+
i = j; // herestring
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
while (line[j] === " " || line[j] === "\t") j++;
|
|
330
|
+
const { delim, next } = readHeredocDelimiter(line, j);
|
|
331
|
+
if (delim) found.push({ delim, allowTabs });
|
|
332
|
+
i = next;
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
i++;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
return found;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// Remove heredoc bodies from a command line. A body is text, not commands —
|
|
342
|
+
// `cat > deploy.sh <<EOF` followed by a script that mentions `.env` reads
|
|
343
|
+
// nothing, and scanning the body as shell blocks exactly that everyday case.
|
|
344
|
+
// The trade-off: a heredoc that *feeds* commands to a remote shell
|
|
345
|
+
// (`ssh host <<EOF\ncat /secret\nEOF`) is not caught. Written up as a
|
|
346
|
+
// known limitation under "② PreToolUse hook" in the README.
|
|
347
|
+
export function stripHeredocBodies(command: string): string {
|
|
348
|
+
const lines = command.split("\n");
|
|
349
|
+
const kept: string[] = [];
|
|
350
|
+
const pending: HeredocDelimiter[] = [];
|
|
351
|
+
|
|
352
|
+
for (const line of lines) {
|
|
353
|
+
if (pending.length > 0) {
|
|
354
|
+
const first = pending[0] as HeredocDelimiter;
|
|
355
|
+
const cmp = first.allowTabs ? line.replace(/^\t+/, "") : line;
|
|
356
|
+
if (cmp === first.delim) pending.shift();
|
|
357
|
+
continue;
|
|
358
|
+
}
|
|
359
|
+
pending.push(...findHeredocDelimiters(line));
|
|
360
|
+
kept.push(line);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
return kept.join("\n");
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// Substitution syntaxes whose inner text is a command line in its own right.
|
|
367
|
+
// Command substitution and backticks expand inside double quotes; the process
|
|
368
|
+
// substitutions do not, so `echo "<(cat f)"` is a literal string.
|
|
369
|
+
const SUBSTITUTIONS = [
|
|
370
|
+
{ open: "$(", close: ")", expandsInDoubleQuotes: true },
|
|
371
|
+
{ open: "<(", close: ")", expandsInDoubleQuotes: false },
|
|
372
|
+
{ open: ">(", close: ")", expandsInDoubleQuotes: false },
|
|
373
|
+
{ open: "`", close: "`", expandsInDoubleQuotes: true },
|
|
374
|
+
];
|
|
375
|
+
|
|
376
|
+
// Index of the character closing a substitution whose body starts at `from`.
|
|
377
|
+
// Parentheses are counted rather than matched with a regex, because a body
|
|
378
|
+
// carries parentheses of its own: `$(python3 -c "print(open('.env').read())")`
|
|
379
|
+
// was cut short at the first `)` by the old `[^()]*` pattern, and the read it
|
|
380
|
+
// contained was never scanned. Quotes and backslashes inside the body are
|
|
381
|
+
// respected. An unbalanced substitution runs to the end of the string.
|
|
382
|
+
function findSubstitutionEnd(
|
|
383
|
+
command: string,
|
|
384
|
+
from: number,
|
|
385
|
+
close: string,
|
|
386
|
+
): number {
|
|
387
|
+
let depth = 0;
|
|
388
|
+
let quote: string | null = null;
|
|
389
|
+
|
|
390
|
+
let i = from;
|
|
391
|
+
while (i < command.length) {
|
|
392
|
+
const step = stepQuote(command, i, quote);
|
|
393
|
+
quote = step.quote;
|
|
394
|
+
if (step.consumed || quote !== null) {
|
|
395
|
+
i = step.next;
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
398
|
+
const ch = command[i] as string;
|
|
399
|
+
const at = i;
|
|
400
|
+
i = step.next;
|
|
401
|
+
if (close === "`") {
|
|
402
|
+
if (ch === "`") return at;
|
|
403
|
+
continue;
|
|
404
|
+
}
|
|
405
|
+
if (ch === "(") depth++;
|
|
406
|
+
else if (ch === ")") {
|
|
407
|
+
if (depth === 0) return at;
|
|
408
|
+
depth--;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
return command.length;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// Inner text of every outermost command substitution, process substitution and
|
|
416
|
+
// backtick expression. Only the outermost ones: each is a command line in its
|
|
417
|
+
// own right, so a nested substitution is reached by the caller feeding what this
|
|
418
|
+
// returns back through it.
|
|
419
|
+
export function extractSubstitutions(command: string): string[] {
|
|
420
|
+
const found: string[] = [];
|
|
421
|
+
let quote: string | null = null;
|
|
422
|
+
let i = 0;
|
|
423
|
+
|
|
424
|
+
// Unlike the scanners above, this one has to look inside double quotes: a
|
|
425
|
+
// command substitution expands there. So it skips only what `stepQuote` calls
|
|
426
|
+
// consumed, and asks the quote state whether an opener counts where it stands.
|
|
427
|
+
while (i < command.length) {
|
|
428
|
+
const step = stepQuote(command, i, quote);
|
|
429
|
+
quote = step.quote;
|
|
430
|
+
if (step.consumed) {
|
|
431
|
+
i = step.next;
|
|
432
|
+
continue;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const opener = SUBSTITUTIONS.find(
|
|
436
|
+
(s) =>
|
|
437
|
+
command.startsWith(s.open, i) &&
|
|
438
|
+
(quote === null || (quote === '"' && s.expandsInDoubleQuotes)),
|
|
439
|
+
);
|
|
440
|
+
if (opener === undefined) {
|
|
441
|
+
i = step.next;
|
|
442
|
+
continue;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
const bodyStart = i + opener.open.length;
|
|
446
|
+
const end = findSubstitutionEnd(command, bodyStart, opener.close);
|
|
447
|
+
found.push(command.slice(bodyStart, end));
|
|
448
|
+
i = end + 1;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
return found;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// Shell keywords and the brace-group delimiters. They stand where a command
|
|
455
|
+
// name would, so without this list a segment led by one is classified as a
|
|
456
|
+
// command called `{` or `then` and its operands are never looked at:
|
|
457
|
+
// `{ cat secrets; }`, `if …; then cat secrets; fi` and
|
|
458
|
+
// `while cat secrets; do :; done` each read a file nothing notices. The keywords that open a condition (`if`, `while`,
|
|
459
|
+
// `until`) matter as much as the ones that open a body: the command being tested
|
|
460
|
+
// runs too.
|
|
461
|
+
export const SHELL_KEYWORD_TOKENS = new Set([
|
|
462
|
+
"{",
|
|
463
|
+
"}",
|
|
464
|
+
"!",
|
|
465
|
+
"if",
|
|
466
|
+
"then",
|
|
467
|
+
"else",
|
|
468
|
+
"elif",
|
|
469
|
+
"fi",
|
|
470
|
+
"while",
|
|
471
|
+
"until",
|
|
472
|
+
"for",
|
|
473
|
+
"do",
|
|
474
|
+
"done",
|
|
475
|
+
"case",
|
|
476
|
+
"esac",
|
|
477
|
+
"in",
|
|
478
|
+
"select",
|
|
479
|
+
]);
|
|
480
|
+
|
|
481
|
+
// True for a token that cannot name a command: a redirection operator, a flag, a
|
|
482
|
+
// `VAR=value` assignment placed before one, or a shell keyword.
|
|
483
|
+
export function isNonCommandToken(token: ShellToken): boolean {
|
|
484
|
+
if (token.redirect) return true;
|
|
485
|
+
const { value } = token;
|
|
486
|
+
if (value.startsWith("-")) return true;
|
|
487
|
+
if (SHELL_KEYWORD_TOKENS.has(value)) return true;
|
|
488
|
+
return /^[A-Za-z_][A-Za-z0-9_]*=/.test(value);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// Longest quoted literal inside inline code still treated as a path candidate.
|
|
492
|
+
// Exported so its test reads the cap instead of copying the number.
|
|
493
|
+
export const MAX_QUOTED_LITERAL_LENGTH = 4096;
|
|
494
|
+
|
|
495
|
+
// Quoted literals inside inline program text — the ".env" in
|
|
496
|
+
// `python3 -c "print(open('.env').read())"`. Literals containing line breaks or
|
|
497
|
+
// tabs are skipped: those are messages and patterns, not paths. Spaces are kept,
|
|
498
|
+
// so a path like `open('my secret.txt')` is still found.
|
|
499
|
+
export function extractQuotedLiterals(code: string): string[] {
|
|
500
|
+
const literals: string[] = [];
|
|
501
|
+
for (const match of code.matchAll(/'([^']*)'|"([^"]*)"/g)) {
|
|
502
|
+
const value = match[1] ?? match[2];
|
|
503
|
+
if (
|
|
504
|
+
value &&
|
|
505
|
+
value.length <= MAX_QUOTED_LITERAL_LENGTH &&
|
|
506
|
+
!/[\t\r\n]/.test(value)
|
|
507
|
+
) {
|
|
508
|
+
literals.push(value);
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
return literals;
|
|
512
|
+
}
|