@hasna/hooks 0.11.6 → 0.12.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/README.md +129 -4
- package/bin/hooks-mcp.js +37 -5
- package/bin/index.js +153 -15
- package/bin/native-safety-entry.js +15 -1718
- package/bin/serve.js +27 -0
- package/dist/index.js +38 -5
- package/dist/lib/native-safety-registration.d.ts +49 -6
- package/dist/lib/registry.d.ts +15 -0
- package/dist/native-safety.d.ts +4 -0
- package/dist/native-safety.js +8 -4
- package/hooks/hook-signed-link-guard/README.md +228 -0
- package/hooks/hook-signed-link-guard/package.json +12 -0
- package/hooks/hook-signed-link-guard/src/classify.ts +1280 -0
- package/hooks/hook-signed-link-guard/src/hook.ts +72 -0
- package/hooks/hook-signed-link-guard/src/jq.ts +545 -0
- package/hooks/hook-signed-link-guard/src/links.ts +136 -0
- package/hooks/hook-signed-link-output/README.md +47 -0
- package/hooks/hook-signed-link-output/package.json +12 -0
- package/hooks/hook-signed-link-output/src/hook.ts +111 -0
- package/hooks/hook-trash-guard/src/hook.ts +204 -80
- package/hooks/native-safety-entry.ts +28 -7
- package/package.json +2 -2
- package/scripts/validate-package.ts +12 -2
|
@@ -0,0 +1,1280 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Command classification for the signed-link guard.
|
|
3
|
+
*
|
|
4
|
+
* THE CLASS. Composite GitHub content — pull request, issue, release and
|
|
5
|
+
* discussion bodies, comments, reviews, commit messages and check-run details —
|
|
6
|
+
* carries third-party signed action links: URLs with a signature parameter, a
|
|
7
|
+
* multi-week expiry and no revoke path. A tool result is persisted in the agent
|
|
8
|
+
* transcript, so a printed link is an exposed capability. This module decides,
|
|
9
|
+
* before a shell command runs, whether any `gh` invocation in it would print
|
|
10
|
+
* such content, so the harness can refuse it with SIGNED_LINK_REFUSAL.
|
|
11
|
+
*
|
|
12
|
+
* Command position is honoured through the existing parser (`lexCommand`,
|
|
13
|
+
* `commandWordIndex` from hook-trash-guard): only a `gh` word in command
|
|
14
|
+
* position — after assignments, keywords and wrappers such as `env`, `sudo`,
|
|
15
|
+
* `timeout`, `command`, `exec` — is a `gh` invocation. `echo gh pr view` is
|
|
16
|
+
* text, and so is `node x.js gh pr view`. The classifier also follows the
|
|
17
|
+
* places a command can hide:
|
|
18
|
+
* - pipelines, `&&`, `||`, `;`, newlines, subshells and `{ …; }` groups;
|
|
19
|
+
* - `$( … )` and backtick substitutions, including inside double quotes and
|
|
20
|
+
* unquoted here-documents (here-document bodies inside `$( )` are data);
|
|
21
|
+
* - `sh|bash|zsh|… -c <string>` (also when the string is built from a
|
|
22
|
+
* `$(cat <<EOF …)` or `$(echo …)` substitution), `eval`, `env -S`,
|
|
23
|
+
* `su|runuser|script|flock -c`, `ssh <host> <command>` and `watch`;
|
|
24
|
+
* - text a shell reads as its script: here-documents and here-strings fed to
|
|
25
|
+
* a shell, `echo … | bash`, `cat <<EOF | sh`, `source <(…)`, `. /dev/stdin`;
|
|
26
|
+
* - `xargs … gh …` (a read whose arguments come from stdin fails closed) and
|
|
27
|
+
* `find … -exec gh … ;`;
|
|
28
|
+
* - known runners that pass a bare `gh` word on (`secrets exec … -- gh`,
|
|
29
|
+
* `op run -- gh`, `unbuffer gh`, …) and a command word built by expansion
|
|
30
|
+
* whose name cannot be read (`$GH pr view …`).
|
|
31
|
+
*
|
|
32
|
+
* `GH_DEBUG` (and gh's legacy `DEBUG`) makes gh log HTTP traffic, response
|
|
33
|
+
* bodies included, whatever `--jq` or `--silent` print. Under it only reads
|
|
34
|
+
* that FETCH nothing composite pass.
|
|
35
|
+
*
|
|
36
|
+
* Anything the classifier cannot read with confidence inside a `gh` read of
|
|
37
|
+
* those objects (an expanded endpoint, field list or query, an unknown `gh`
|
|
38
|
+
* command that may be an alias, an unterminated command) fails closed.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import { commandWordIndex, envSplitCommandText, heredocMarkerAt, lexCommand, scanParenAt, skipHeredocBodies, type Word } from "../../hook-trash-guard/src/hook";
|
|
42
|
+
import { jqProjectionIsScalar } from "./jq";
|
|
43
|
+
|
|
44
|
+
/** The exact refusal reason, fixed by the engineering decision for this class. */
|
|
45
|
+
export const SIGNED_LINK_REFUSAL =
|
|
46
|
+
"composite output withheld: signed-link shape; use bounded scalar projections per class disposition 799310";
|
|
47
|
+
|
|
48
|
+
export type SignedLinkRule =
|
|
49
|
+
| "view-human"
|
|
50
|
+
| "view-comments"
|
|
51
|
+
| "json-composite"
|
|
52
|
+
| "checks-human"
|
|
53
|
+
| "checks-json"
|
|
54
|
+
| "api-rest"
|
|
55
|
+
| "api-graphql"
|
|
56
|
+
| "composite-other"
|
|
57
|
+
| "debug-traffic"
|
|
58
|
+
| "unclassifiable";
|
|
59
|
+
|
|
60
|
+
export type SignedLinkVerdict = { refused: false } | { refused: true; rule: SignedLinkRule };
|
|
61
|
+
|
|
62
|
+
const ALLOW: SignedLinkVerdict = { refused: false };
|
|
63
|
+
const refused = (rule: SignedLinkRule): SignedLinkVerdict => ({ refused: true, rule });
|
|
64
|
+
|
|
65
|
+
/* ------------------------------------------------------------------ */
|
|
66
|
+
/* JSON field classes */
|
|
67
|
+
/* ------------------------------------------------------------------ */
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* `--json` fields that are safe to print, per command family. Everything else
|
|
71
|
+
* — every current composite field and any field a future gh adds — is refused
|
|
72
|
+
* unless a `--jq` projection provably prints only bounded scalars.
|
|
73
|
+
*
|
|
74
|
+
* Admitted: numbers, booleans, timestamps, enums, refs and object ids; identity
|
|
75
|
+
* objects (login, name, id); the object's own title; the object's own canonical
|
|
76
|
+
* GitHub URL (generated by GitHub from owner, repository and number, with no
|
|
77
|
+
* query string, so it cannot carry a signature parameter); file paths (the same
|
|
78
|
+
* information `gh pr diff` prints); reaction counts; and labels (a label
|
|
79
|
+
* description is a repository setting capped at 100 characters, too short for
|
|
80
|
+
* the signed-link shape).
|
|
81
|
+
*
|
|
82
|
+
* Refused, with the reason:
|
|
83
|
+
* body free text authored by users and apps
|
|
84
|
+
* comments, reviews, comment and review bodies
|
|
85
|
+
* latestReviews
|
|
86
|
+
* statusCheckRollup check details URLs, target URLs and descriptions
|
|
87
|
+
* commits commit messages (autofix commits are app-authored)
|
|
88
|
+
* autoMergeRequest commit headline and body text
|
|
89
|
+
* closingIssuesReferences, other objects' titles and URLs
|
|
90
|
+
* closedByPullRequestsReferences, blockedBy, blocking, parent, subIssues
|
|
91
|
+
* milestone, projectCards, free-text descriptions and titles with no length cap
|
|
92
|
+
* projectItems, issueType, category
|
|
93
|
+
* assets uploader-authored labels and names
|
|
94
|
+
*/
|
|
95
|
+
const PR_FIELDS = new Set([
|
|
96
|
+
"additions", "assignees", "author", "baseRefName", "baseRefOid", "changedFiles", "closed", "closedAt",
|
|
97
|
+
"createdAt", "deletions", "files", "fullDatabaseId", "headRefName", "headRefOid", "headRepository",
|
|
98
|
+
"headRepositoryOwner", "id", "isCrossRepository", "isDraft", "labels", "maintainerCanModify", "mergeCommit",
|
|
99
|
+
"mergeStateStatus", "mergeable", "mergedAt", "mergedBy", "number", "potentialMergeCommit", "reactionGroups",
|
|
100
|
+
"reviewDecision", "reviewRequests", "state", "title", "updatedAt", "url",
|
|
101
|
+
]);
|
|
102
|
+
const ISSUE_FIELDS = new Set([
|
|
103
|
+
"assignees", "author", "closed", "closedAt", "createdAt", "id", "isPinned", "labels", "number",
|
|
104
|
+
"reactionGroups", "state", "stateReason", "subIssuesSummary", "title", "updatedAt", "url",
|
|
105
|
+
]);
|
|
106
|
+
const RELEASE_FIELDS = new Set([
|
|
107
|
+
"apiUrl", "author", "createdAt", "databaseId", "id", "isDraft", "isImmutable", "isLatest", "isPrerelease", "name",
|
|
108
|
+
"publishedAt", "tagName", "tarballUrl", "targetCommitish", "uploadUrl", "url", "zipballUrl",
|
|
109
|
+
]);
|
|
110
|
+
const DISCUSSION_FIELDS = new Set([
|
|
111
|
+
"answerChosenAt", "answerChosenBy", "answered", "author", "closed", "closedAt", "createdAt", "id", "labels",
|
|
112
|
+
"locked", "number", "reactionGroups", "state", "stateReason", "title", "updatedAt", "url",
|
|
113
|
+
]);
|
|
114
|
+
const SEARCH_ISSUE_FIELDS = new Set([
|
|
115
|
+
"assignees", "author", "authorAssociation", "closedAt", "commentsCount", "createdAt", "id", "isDraft", "isLocked",
|
|
116
|
+
"isPullRequest", "labels", "number", "repository", "state", "title", "updatedAt", "url",
|
|
117
|
+
]);
|
|
118
|
+
const SEARCH_COMMIT_FIELDS = new Set(["author", "committer", "id", "parents", "repository", "sha", "url"]);
|
|
119
|
+
/** `link` and `description` are app-authored and carry the signed link itself. */
|
|
120
|
+
const CHECK_FIELDS = new Set(["bucket", "completedAt", "event", "name", "startedAt", "state", "workflow"]);
|
|
121
|
+
|
|
122
|
+
/* ------------------------------------------------------------------ */
|
|
123
|
+
/* Shell words */
|
|
124
|
+
/* ------------------------------------------------------------------ */
|
|
125
|
+
|
|
126
|
+
export interface ShellWord {
|
|
127
|
+
/** The word after quote removal. Expansions are kept as written. */
|
|
128
|
+
value: string;
|
|
129
|
+
/** True when the value depends on an expansion (`$x`, `$( )`, backticks, globs, brace lists). */
|
|
130
|
+
opaque: boolean;
|
|
131
|
+
/** Raw source text. */
|
|
132
|
+
raw: string;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* An unquoted brace list (`{a,b}`) or sequence (`{1..3}`), which the shell
|
|
137
|
+
* expands into several words. One linear pass over the raw word.
|
|
138
|
+
*/
|
|
139
|
+
function hasBraceExpansion(raw: string): boolean {
|
|
140
|
+
const open: Array<{ list: boolean }> = [];
|
|
141
|
+
for (let i = 0; i < raw.length; i++) {
|
|
142
|
+
const c = raw[i];
|
|
143
|
+
if (c === "\\") { i++; continue; }
|
|
144
|
+
if (c === "'") { const end = raw.indexOf("'", i + 1); if (end < 0) return false; i = end; continue; }
|
|
145
|
+
if (c === '"') {
|
|
146
|
+
i++;
|
|
147
|
+
while (i < raw.length && raw[i] !== '"') i += raw[i] === "\\" ? 2 : 1;
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
if (c === "{") { open.push({ list: false }); continue; }
|
|
151
|
+
if (open.length === 0) continue;
|
|
152
|
+
if (c === "," || (c === "." && raw[i + 1] === ".")) open[open.length - 1].list = true;
|
|
153
|
+
else if (c === "}" && open.pop()!.list) return true;
|
|
154
|
+
}
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Decode one shell word the way the shell would for its literal parts.
|
|
160
|
+
* Single quotes are literal; double quotes keep `$`, backticks and `\`-escapes
|
|
161
|
+
* of `$ \` " \\` and newline only; an unquoted backslash escapes any character.
|
|
162
|
+
*/
|
|
163
|
+
export function shellWord(raw: string): ShellWord {
|
|
164
|
+
let value = "";
|
|
165
|
+
let opaque = false;
|
|
166
|
+
let i = 0;
|
|
167
|
+
const expansionAt = (at: number) => raw[at] === "`" || (raw[at] === "$" && /[A-Za-z_{(@*#?$!0-9-]/.test(raw[at + 1] ?? ""));
|
|
168
|
+
while (i < raw.length) {
|
|
169
|
+
const ch = raw[i];
|
|
170
|
+
if (ch === "\\") { value += raw[i + 1] ?? ""; i += 2; continue; }
|
|
171
|
+
if (ch === "$" && raw[i + 1] === "'") { opaque = true; value += ch; i++; continue; }
|
|
172
|
+
if (ch === "'") {
|
|
173
|
+
const end = raw.indexOf("'", i + 1);
|
|
174
|
+
if (end < 0) { opaque = true; value += raw.slice(i + 1); break; }
|
|
175
|
+
value += raw.slice(i + 1, end);
|
|
176
|
+
i = end + 1;
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
if (ch === '"') {
|
|
180
|
+
i++;
|
|
181
|
+
while (i < raw.length && raw[i] !== '"') {
|
|
182
|
+
if (raw[i] === "\\" && /[$`"\\\n]/.test(raw[i + 1] ?? "")) { value += raw[i + 1]; i += 2; continue; }
|
|
183
|
+
if (expansionAt(i)) opaque = true;
|
|
184
|
+
value += raw[i];
|
|
185
|
+
i++;
|
|
186
|
+
}
|
|
187
|
+
if (i >= raw.length) opaque = true;
|
|
188
|
+
i++;
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
if (expansionAt(i) || ch === "*" || ch === "?" || ch === "[") opaque = true;
|
|
192
|
+
value += ch;
|
|
193
|
+
i++;
|
|
194
|
+
}
|
|
195
|
+
if (!opaque && raw.includes("{") && hasBraceExpansion(raw)) opaque = true;
|
|
196
|
+
return { value, opaque, raw };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const base = (value: string) => value.slice(value.lastIndexOf("/") + 1);
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Bodies of every command substitution in `text` — `$( … )` and backticks.
|
|
203
|
+
* In `word` mode (shell source) single quotes hide substitutions and double
|
|
204
|
+
* quotes do not; in `heredoc` mode (an unquoted here-document body) quotes are
|
|
205
|
+
* plain characters. `complete` is false when a substitution does not end.
|
|
206
|
+
*/
|
|
207
|
+
export function substitutions(text: string, mode: "word" | "heredoc" = "word"): { bodies: string[]; complete: boolean } {
|
|
208
|
+
const bodies: string[] = [];
|
|
209
|
+
let inDouble = false;
|
|
210
|
+
for (let i = 0; i < text.length; i++) {
|
|
211
|
+
const ch = text[i];
|
|
212
|
+
if (ch === "\\") { i++; continue; }
|
|
213
|
+
if (mode === "word" && ch === "'" && !inDouble) {
|
|
214
|
+
const end = text.indexOf("'", i + 1);
|
|
215
|
+
if (end < 0) return { bodies, complete: false };
|
|
216
|
+
i = end;
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
if (mode === "word" && ch === '"') { inDouble = !inDouble; continue; }
|
|
220
|
+
if (ch === "`") {
|
|
221
|
+
let j = i + 1;
|
|
222
|
+
while (j < text.length && text[j] !== "`") j += text[j] === "\\" ? 2 : 1;
|
|
223
|
+
if (j >= text.length) return { bodies, complete: false };
|
|
224
|
+
bodies.push(text.slice(i + 1, j));
|
|
225
|
+
i = j;
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
if (ch === "$" && text[i + 1] === "(") {
|
|
229
|
+
const end = scanParenAt(text, i + 1);
|
|
230
|
+
if (end < 0) return { bodies, complete: false };
|
|
231
|
+
bodies.push(text.slice(i + 2, end - 1));
|
|
232
|
+
i = end - 1;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return { bodies, complete: true };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/* ------------------------------------------------------------------ */
|
|
239
|
+
/* Flag parsing (pflag-compatible, per command) */
|
|
240
|
+
/* ------------------------------------------------------------------ */
|
|
241
|
+
|
|
242
|
+
interface FlagSpec {
|
|
243
|
+
/** Flags that take a value, long (`--json`) and short (`-q`) spellings. */
|
|
244
|
+
values: string[];
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
interface Flag { name: string; value?: ShellWord; missing?: boolean; inline?: boolean }
|
|
248
|
+
|
|
249
|
+
interface ParsedFlags {
|
|
250
|
+
/** Every flag occurrence in order, with its value when it takes one. */
|
|
251
|
+
flags: Flag[];
|
|
252
|
+
positionals: ShellWord[];
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Parse arguments the way gh's flag library does: `--name=value`, `--name
|
|
257
|
+
* value`, `-x value`, `-xvalue`, `-x=value`, boolean clusters (`-cw`) and
|
|
258
|
+
* `--` ending options. Unknown flags are booleans; gh refuses them before
|
|
259
|
+
* printing data. A boolean given inline (`--web=false`) keeps its value.
|
|
260
|
+
*/
|
|
261
|
+
function parseFlags(args: ShellWord[], spec: FlagSpec): ParsedFlags {
|
|
262
|
+
const takesValue = new Set(spec.values);
|
|
263
|
+
const out: ParsedFlags = { flags: [], positionals: [] };
|
|
264
|
+
const inlineValue = (text: string, word: ShellWord): ShellWord => ({ value: text, opaque: word.opaque, raw: word.raw });
|
|
265
|
+
for (let i = 0; i < args.length; i++) {
|
|
266
|
+
const word = args[i];
|
|
267
|
+
const text = word.value;
|
|
268
|
+
if (text === "--") { out.positionals.push(...args.slice(i + 1)); break; }
|
|
269
|
+
if (text.startsWith("--") && text.length > 2) {
|
|
270
|
+
const eq = text.indexOf("=");
|
|
271
|
+
const name = eq >= 0 ? text.slice(0, eq) : text;
|
|
272
|
+
if (eq >= 0) out.flags.push({ name, value: inlineValue(text.slice(eq + 1), word), inline: true });
|
|
273
|
+
else if (takesValue.has(name)) {
|
|
274
|
+
const value = args[i + 1];
|
|
275
|
+
out.flags.push(value ? { name, value } : { name, missing: true });
|
|
276
|
+
i++;
|
|
277
|
+
} else out.flags.push({ name });
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
if (text.startsWith("-") && text.length > 1) {
|
|
281
|
+
for (let at = 1; at < text.length; at++) {
|
|
282
|
+
const name = "-" + text[at];
|
|
283
|
+
const rest = text.slice(at + 1);
|
|
284
|
+
if (rest.startsWith("=")) {
|
|
285
|
+
out.flags.push({ name, value: inlineValue(rest.slice(1), word), inline: true });
|
|
286
|
+
break;
|
|
287
|
+
}
|
|
288
|
+
if (!takesValue.has(name)) { out.flags.push({ name }); continue; }
|
|
289
|
+
if (rest) out.flags.push({ name, value: inlineValue(rest, word), inline: true });
|
|
290
|
+
else {
|
|
291
|
+
const value = args[i + 1];
|
|
292
|
+
out.flags.push(value ? { name, value } : { name, missing: true });
|
|
293
|
+
i++;
|
|
294
|
+
}
|
|
295
|
+
break;
|
|
296
|
+
}
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
out.positionals.push(word);
|
|
300
|
+
}
|
|
301
|
+
return out;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Whether a boolean flag ends up set: its last occurrence wins, `--web` and
|
|
306
|
+
* `--web=true` set it, `--web=false` (and pflag's other false spellings)
|
|
307
|
+
* clear it. A value that cannot be read (an expansion) counts as not set, so
|
|
308
|
+
* it never unlocks an allow shortcut.
|
|
309
|
+
*/
|
|
310
|
+
function flagOn(parsed: ParsedFlags, ...names: string[]): boolean {
|
|
311
|
+
let on = false;
|
|
312
|
+
for (const flag of parsed.flags) {
|
|
313
|
+
if (!names.includes(flag.name)) continue;
|
|
314
|
+
if (!flag.inline) { on = true; continue; }
|
|
315
|
+
const value = flag.value!;
|
|
316
|
+
on = !value.opaque && ["1", "t", "T", "TRUE", "true", "True"].includes(value.value);
|
|
317
|
+
}
|
|
318
|
+
return on;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const valuesOf = (parsed: ParsedFlags, ...names: string[]) => parsed.flags.filter((flag) => names.includes(flag.name));
|
|
322
|
+
|
|
323
|
+
const COMMON_VALUES = ["-R", "--repo", "-q", "--jq", "-t", "--template", "--json"];
|
|
324
|
+
const SPECS: Record<string, FlagSpec> = {
|
|
325
|
+
"pr view": { values: COMMON_VALUES },
|
|
326
|
+
"issue view": { values: COMMON_VALUES },
|
|
327
|
+
"release view": { values: COMMON_VALUES },
|
|
328
|
+
"discussion view": { values: [...COMMON_VALUES, "--after", "-L", "--limit", "--order"] },
|
|
329
|
+
"pr list": { values: [...COMMON_VALUES, "--app", "-a", "--assignee", "-A", "--author", "-B", "--base", "-H", "--head", "-l", "--label", "-L", "--limit", "-S", "--search", "-s", "--state"] },
|
|
330
|
+
"issue list": { values: [...COMMON_VALUES, "--app", "-a", "--assignee", "-A", "--author", "-l", "--label", "-L", "--limit", "--mention", "-m", "--milestone", "-S", "--search", "-s", "--state"] },
|
|
331
|
+
"discussion list": { values: [...COMMON_VALUES, "--after", "-A", "--author", "-c", "--category", "-l", "--label", "-L", "--limit", "--order", "-S", "--search", "--sort", "-s", "--state"] },
|
|
332
|
+
"release list": { values: [...COMMON_VALUES, "-L", "--limit", "-O", "--order"] },
|
|
333
|
+
"pr status": { values: COMMON_VALUES },
|
|
334
|
+
"issue status": { values: COMMON_VALUES },
|
|
335
|
+
"pr checks": { values: [...COMMON_VALUES, "-i", "--interval"] },
|
|
336
|
+
"pr diff": { values: ["-R", "--repo"] },
|
|
337
|
+
search: { values: [...COMMON_VALUES, "--app", "--assignee", "--author", "--author-date", "--author-email", "--author-name", "--base", "--checks", "--closed", "--commenter", "--committer", "--committer-date", "--committer-email", "--committer-name", "--created", "--hash", "--head", "--interactions", "--involves", "--label", "--language", "--limit", "-L", "--match", "--mentions", "--merged-at", "--milestone", "--no-assignee", "--no-label", "--no-milestone", "--no-project", "--order", "--owner", "--parent", "--project", "--reactions", "--review", "--review-requested", "--reviewed-by", "--sort", "--state", "--team-mentions", "--tree", "--updated", "--visibility", "--extension", "--filename", "--size", "--forks", "--good-first-issue", "--help-wanted-issues", "--number-topics", "--stars", "--topic", "--followers", "--include-forks", "--license"] },
|
|
338
|
+
"project item-list": { values: ["--format", "-L", "--limit", "--owner", "-q", "--jq", "-t", "--template", "--query", "--field", "--field-id"] },
|
|
339
|
+
api: { values: ["-X", "--method", "-H", "--header", "-f", "--raw-field", "-F", "--field", "-q", "--jq", "-t", "--template", "-p", "--preview", "--input", "--hostname", "--cache"] },
|
|
340
|
+
group: { values: ["-R", "--repo"] },
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
/* ------------------------------------------------------------------ */
|
|
344
|
+
/* gh classification */
|
|
345
|
+
/* ------------------------------------------------------------------ */
|
|
346
|
+
|
|
347
|
+
interface Context {
|
|
348
|
+
depth: number;
|
|
349
|
+
/** `xargs` without a replacement string appends stdin words to the command. */
|
|
350
|
+
appendsArgs: boolean;
|
|
351
|
+
/** `GH_DEBUG` (or gh's legacy `DEBUG`) is set: gh logs HTTP traffic, bodies included. */
|
|
352
|
+
debug: boolean;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** Top-level gh commands (2.98) that never print the composite objects of this class. */
|
|
356
|
+
const SAFE_TOP = new Set([
|
|
357
|
+
"auth", "browse", "codespace", "cs", "gist", "org", "repo", "skill", "cache", "run", "workflow", "co",
|
|
358
|
+
"agent-task", "alias", "attestation", "completion", "config", "copilot", "extension", "ext", "extensions",
|
|
359
|
+
"gpg-key", "label", "licenses", "preview", "ruleset", "secret", "ssh-key", "variable", "help", "version",
|
|
360
|
+
"accessibility", "actions", "environment", "exit-codes", "formatting", "mintty", "reference", "telemetry",
|
|
361
|
+
]);
|
|
362
|
+
/** gh commands that read the objects of this class; an unreadable read of one fails closed. */
|
|
363
|
+
const READ_TOP = new Set(["pr", "issue", "release", "discussion", "search", "project", "api", "status"]);
|
|
364
|
+
const PR_WRITES = new Set(["create", "new", "checkout", "co", "close", "comment", "edit", "lock", "merge", "ready", "reopen", "revert", "review", "unlock", "update-branch"]);
|
|
365
|
+
const ISSUE_WRITES = new Set(["create", "new", "close", "comment", "delete", "develop", "edit", "lock", "pin", "reopen", "transfer", "unlock", "unpin"]);
|
|
366
|
+
const RELEASE_WRITES = new Set(["create", "new", "delete", "delete-asset", "download", "edit", "upload", "verify", "verify-asset"]);
|
|
367
|
+
const DISCUSSION_WRITES = new Set(["create", "new", "close", "comment", "delete", "edit", "lock", "reopen", "unlock", "answer", "unanswer"]);
|
|
368
|
+
|
|
369
|
+
function jsonFields(parsed: ParsedFlags, ctx: Context): { present: boolean; opaque: boolean; fields: string[] } {
|
|
370
|
+
const occurrences = valuesOf(parsed, "--json");
|
|
371
|
+
const fields: string[] = [];
|
|
372
|
+
let opaque = false;
|
|
373
|
+
for (const occurrence of occurrences) {
|
|
374
|
+
if (occurrence.missing) { if (ctx.appendsArgs) opaque = true; continue; }
|
|
375
|
+
if (!occurrence.value) continue;
|
|
376
|
+
if (occurrence.value.opaque) opaque = true;
|
|
377
|
+
fields.push(...occurrence.value.value.split(",").map((field) => field.trim()).filter(Boolean));
|
|
378
|
+
}
|
|
379
|
+
return { present: occurrences.length > 0, opaque, fields };
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** A `--jq` projection that provably prints only bounded scalars, with nothing else printed beside it. */
|
|
383
|
+
function jqRescues(parsed: ParsedFlags, rootScalars?: ReadonlySet<string>): boolean {
|
|
384
|
+
if (flagOn(parsed, "--verbose") || valuesOf(parsed, "-t", "--template").length > 0) return false;
|
|
385
|
+
const jq = valuesOf(parsed, "-q", "--jq");
|
|
386
|
+
return jq.length > 0 && jq.every((flag) => flag.value !== undefined && !flag.value.opaque && jqProjectionIsScalar(flag.value.value, { rootScalars }));
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* The `--json` field set decides; a composite field is admitted only when a
|
|
391
|
+
* `--jq` projection provably prints bounded scalars. Under GH_DEBUG the
|
|
392
|
+
* projection does not help (gh logs the whole response), so only an
|
|
393
|
+
* allowlisted field set passes.
|
|
394
|
+
*/
|
|
395
|
+
function checkFields(parsed: ParsedFlags, allowed: ReadonlySet<string>, ctx: Context, rule: SignedLinkRule): SignedLinkVerdict {
|
|
396
|
+
const json = jsonFields(parsed, ctx);
|
|
397
|
+
if (json.opaque) return refused("unclassifiable");
|
|
398
|
+
if (json.fields.every((field) => allowed.has(field))) return ALLOW;
|
|
399
|
+
if (ctx.debug) return refused("debug-traffic");
|
|
400
|
+
return jqRescues(parsed) ? ALLOW : refused(rule);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** `view` commands: the human view prints the body; `--comments` prints comments. */
|
|
404
|
+
function classifyView(args: ShellWord[], spec: string, fields: ReadonlySet<string>, ctx: Context, commentsFlag: boolean): SignedLinkVerdict {
|
|
405
|
+
const parsed = parseFlags(args, SPECS[spec]);
|
|
406
|
+
if (flagOn(parsed, "-h", "--help")) return ALLOW;
|
|
407
|
+
if (flagOn(parsed, "-w", "--web")) return ctx.debug ? refused("debug-traffic") : ALLOW;
|
|
408
|
+
if (commentsFlag && flagOn(parsed, "-c", "--comments")) return refused("view-comments");
|
|
409
|
+
if (!jsonFields(parsed, ctx).present) return refused(ctx.debug ? "debug-traffic" : "view-human");
|
|
410
|
+
return checkFields(parsed, fields, ctx, "json-composite");
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/** `list`/`status`/`search` commands: the table prints no bodies; `--json` is checked. */
|
|
414
|
+
function classifyList(args: ShellWord[], spec: string, fields: ReadonlySet<string>, ctx: Context): SignedLinkVerdict {
|
|
415
|
+
const parsed = parseFlags(args, SPECS[spec]);
|
|
416
|
+
if (flagOn(parsed, "-h", "--help")) return ALLOW;
|
|
417
|
+
if (ctx.debug && (flagOn(parsed, "-w", "--web") || !jsonFields(parsed, ctx).present)) return refused("debug-traffic");
|
|
418
|
+
if (flagOn(parsed, "-w", "--web")) return ALLOW;
|
|
419
|
+
return checkFields(parsed, fields, ctx, "json-composite");
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* `gh pr checks`: the table prints check links. With `--json` only scalar
|
|
424
|
+
* fields pass. gh's checks query always fetches `detailsUrl`, so under
|
|
425
|
+
* GH_DEBUG every form is refused.
|
|
426
|
+
*/
|
|
427
|
+
function classifyChecks(args: ShellWord[], ctx: Context): SignedLinkVerdict {
|
|
428
|
+
const parsed = parseFlags(args, SPECS["pr checks"]);
|
|
429
|
+
if (flagOn(parsed, "-h", "--help")) return ALLOW;
|
|
430
|
+
if (ctx.debug) return refused("debug-traffic");
|
|
431
|
+
if (flagOn(parsed, "-w", "--web")) return ALLOW;
|
|
432
|
+
if (!jsonFields(parsed, ctx).present) return refused("checks-human");
|
|
433
|
+
return checkFields(parsed, CHECK_FIELDS, ctx, "checks-json");
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/** Split a gh group invocation into its subcommand and the remaining words. */
|
|
437
|
+
function subcommand(args: ShellWord[]): { sub?: ShellWord; rest: ShellWord[] } {
|
|
438
|
+
const takesValue = new Set(SPECS.group.values);
|
|
439
|
+
for (let i = 0; i < args.length; i++) {
|
|
440
|
+
const text = args[i].value;
|
|
441
|
+
if (text === "--") return { sub: args[i + 1], rest: args.slice(i + 2) };
|
|
442
|
+
if (!text.startsWith("-") || text === "-") return { sub: args[i], rest: [...args.slice(0, i), ...args.slice(i + 1)] };
|
|
443
|
+
if (takesValue.has(text)) i++;
|
|
444
|
+
}
|
|
445
|
+
return { rest: args };
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function classifyGroup(group: string, args: ShellWord[], ctx: Context): SignedLinkVerdict {
|
|
449
|
+
const { sub, rest } = subcommand(args);
|
|
450
|
+
// `xargs gh pr` supplies the subcommand (and its flags) from stdin.
|
|
451
|
+
if (!sub) return ctx.appendsArgs ? refused("unclassifiable") : ALLOW;
|
|
452
|
+
if (sub.opaque) return refused("unclassifiable");
|
|
453
|
+
const name = sub.value;
|
|
454
|
+
const writes = group === "pr" ? PR_WRITES : group === "issue" ? ISSUE_WRITES : group === "release" ? RELEASE_WRITES : group === "discussion" ? DISCUSSION_WRITES : new Set<string>();
|
|
455
|
+
if (writes.has(name)) {
|
|
456
|
+
// A write prints no composite content, but gh fetches the object first;
|
|
457
|
+
// under GH_DEBUG that fetch is logged.
|
|
458
|
+
return ctx.debug ? refused("debug-traffic") : ALLOW;
|
|
459
|
+
}
|
|
460
|
+
// A read whose arguments are appended from stdin can gain `--json body`,
|
|
461
|
+
// `--comments` or `--patch`: it cannot be classified.
|
|
462
|
+
if (ctx.appendsArgs && !(group === "project" && name !== "item-list")) return refused("unclassifiable");
|
|
463
|
+
switch (group) {
|
|
464
|
+
case "pr":
|
|
465
|
+
if (name === "view") return classifyView(rest, "pr view", PR_FIELDS, ctx, true);
|
|
466
|
+
if (name === "list" || name === "ls") return classifyList(rest, "pr list", PR_FIELDS, ctx);
|
|
467
|
+
if (name === "status") return classifyList(rest, "pr status", PR_FIELDS, ctx);
|
|
468
|
+
if (name === "checks") return classifyChecks(rest, ctx);
|
|
469
|
+
if (name === "diff") {
|
|
470
|
+
// `--patch` prints each commit's full message, the same content as the refused `commits` field.
|
|
471
|
+
const parsed = parseFlags(rest, SPECS["pr diff"]);
|
|
472
|
+
if (flagOn(parsed, "-h", "--help")) return ALLOW;
|
|
473
|
+
if (ctx.debug) return refused("debug-traffic");
|
|
474
|
+
return flagOn(parsed, "--patch") ? refused("composite-other") : ALLOW;
|
|
475
|
+
}
|
|
476
|
+
return refused("unclassifiable");
|
|
477
|
+
case "issue":
|
|
478
|
+
if (name === "view") return classifyView(rest, "issue view", ISSUE_FIELDS, ctx, true);
|
|
479
|
+
if (name === "list" || name === "ls") return classifyList(rest, "issue list", ISSUE_FIELDS, ctx);
|
|
480
|
+
if (name === "status") return classifyList(rest, "issue status", ISSUE_FIELDS, ctx);
|
|
481
|
+
return refused("unclassifiable");
|
|
482
|
+
case "release":
|
|
483
|
+
if (name === "view") return classifyView(rest, "release view", RELEASE_FIELDS, ctx, false);
|
|
484
|
+
if (name === "list" || name === "ls") return classifyList(rest, "release list", RELEASE_FIELDS, ctx);
|
|
485
|
+
return refused("unclassifiable");
|
|
486
|
+
case "discussion":
|
|
487
|
+
if (name === "view") return classifyView(rest, "discussion view", DISCUSSION_FIELDS, ctx, true);
|
|
488
|
+
if (name === "list" || name === "ls") return classifyList(rest, "discussion list", DISCUSSION_FIELDS, ctx);
|
|
489
|
+
return refused("unclassifiable");
|
|
490
|
+
case "search":
|
|
491
|
+
if (name === "prs" || name === "issues") return classifyList(rest, "search", SEARCH_ISSUE_FIELDS, ctx);
|
|
492
|
+
if (name === "commits") return classifyList(rest, "search", SEARCH_COMMIT_FIELDS, ctx);
|
|
493
|
+
if (name === "code" || name === "repos") return ctx.debug ? refused("debug-traffic") : ALLOW;
|
|
494
|
+
return refused("unclassifiable");
|
|
495
|
+
case "project": {
|
|
496
|
+
if (ctx.debug) return refused("debug-traffic");
|
|
497
|
+
if (name !== "item-list") return ALLOW;
|
|
498
|
+
// JSON items embed the underlying issue and pull request content.
|
|
499
|
+
const parsed = parseFlags(rest, SPECS["project item-list"]);
|
|
500
|
+
if (flagOn(parsed, "-h", "--help")) return ALLOW;
|
|
501
|
+
const format = valuesOf(parsed, "--format");
|
|
502
|
+
if (format.some((flag) => !flag.value || flag.value.opaque)) return refused("unclassifiable");
|
|
503
|
+
if (format.every((flag) => flag.value!.value !== "json")) return ALLOW;
|
|
504
|
+
return jqRescues(parsed) ? ALLOW : refused("composite-other");
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
return refused("unclassifiable");
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/* ------------------------------------------------------------------ */
|
|
511
|
+
/* gh api */
|
|
512
|
+
/* ------------------------------------------------------------------ */
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* REST families whose responses contain bodies, comments, reviews, commit
|
|
516
|
+
* messages, check runs and suites, statuses (target URLs and descriptions),
|
|
517
|
+
* events, deployment payloads or issue and pull request objects. Matched on
|
|
518
|
+
* the path after `repos/<owner>/<repo>` (or `repositories/<id>`).
|
|
519
|
+
*/
|
|
520
|
+
const REPO_FAMILIES = new Set([
|
|
521
|
+
"pulls", "issues", "commits", "check-runs", "check-suites", "statuses", "comments", "releases",
|
|
522
|
+
"compare", "events", "discussions", "deployments", "milestones",
|
|
523
|
+
]);
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* `download`: the response is repository data (workflow logs, artifact and
|
|
527
|
+
* source archives) served through a redirect to a short-lived signed storage
|
|
528
|
+
* URL. gh follows the redirect without printing it, so the download passes;
|
|
529
|
+
* `--verbose` and GH_DEBUG print the redirect, so under them it is refused.
|
|
530
|
+
*/
|
|
531
|
+
export type EndpointClass = "sensitive" | "graphql" | "download" | "other" | "unclassifiable";
|
|
532
|
+
|
|
533
|
+
export interface Endpoint {
|
|
534
|
+
kind: EndpointClass;
|
|
535
|
+
/** Normalized, lower-cased path segments (empty when unreadable). */
|
|
536
|
+
segments: string[];
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* Normalize a gh api endpoint the way the request reaches GitHub: strip the
|
|
541
|
+
* API origin (`https://api.github.com/`, GHES `/api/v3/`), the query and
|
|
542
|
+
* fragment, percent-decode each segment, drop empty and `.` segments and
|
|
543
|
+
* resolve `..`. Any other absolute URL (a github.com web page, a raw
|
|
544
|
+
* `.patch`) is not an API path and cannot be classified.
|
|
545
|
+
*/
|
|
546
|
+
function normalizeEndpoint(word: ShellWord): { segments: string[]; opaque: boolean[] } | null {
|
|
547
|
+
let path = word.value.trim();
|
|
548
|
+
const absolute = /^https?:\/\/([^/]+)(\/.*)?$/i.exec(path);
|
|
549
|
+
if (absolute) {
|
|
550
|
+
const host = absolute[1].toLowerCase();
|
|
551
|
+
const rest = absolute[2] ?? "/";
|
|
552
|
+
if (host === "api.github.com" || /^api\./.test(host)) path = rest;
|
|
553
|
+
else if (/^\/api\/v3(?:\/|$)/i.test(rest)) path = rest.replace(/^\/api\/v3/i, "");
|
|
554
|
+
else if (/^\/api\/graphql(?:[/?#]|$)/i.test(rest)) path = "graphql";
|
|
555
|
+
else return null;
|
|
556
|
+
}
|
|
557
|
+
path = path.replace(/[?#].*$/, "");
|
|
558
|
+
const out: string[] = [];
|
|
559
|
+
const opaque: boolean[] = [];
|
|
560
|
+
for (const piece of path.split("/")) {
|
|
561
|
+
let decoded: string;
|
|
562
|
+
try { decoded = decodeURIComponent(piece); } catch { return null; }
|
|
563
|
+
for (const segment of decoded.split("/")) {
|
|
564
|
+
if (segment === "" || segment === ".") continue;
|
|
565
|
+
if (segment === "..") { out.pop(); opaque.pop(); continue; }
|
|
566
|
+
out.push(segment.toLowerCase());
|
|
567
|
+
opaque.push(word.opaque && /[$`]/.test(segment) || /[*?[]/.test(segment) || hasBraceExpansion(segment));
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
return { segments: out, opaque };
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/** Classify a gh api endpoint path. */
|
|
574
|
+
export function classifyEndpoint(word: ShellWord): Endpoint {
|
|
575
|
+
const normalized = normalizeEndpoint(word);
|
|
576
|
+
if (!normalized) return { kind: "unclassifiable", segments: [] };
|
|
577
|
+
const { segments: s, opaque } = normalized;
|
|
578
|
+
const endpoint = (kind: EndpointClass): Endpoint => ({ kind, segments: s });
|
|
579
|
+
if (s.length === 1 && s[0] === "graphql") return endpoint("graphql");
|
|
580
|
+
if (s.length === 0) return endpoint(word.opaque ? "unclassifiable" : "other");
|
|
581
|
+
if (opaque[0]) return endpoint("unclassifiable");
|
|
582
|
+
let rest: string[] | undefined;
|
|
583
|
+
let restOpaque: boolean[] = [];
|
|
584
|
+
if (s[0] === "repos") { rest = s.slice(3); restOpaque = opaque.slice(3); }
|
|
585
|
+
else if (s[0] === "repositories") { rest = s.slice(2); restOpaque = opaque.slice(2); }
|
|
586
|
+
if (rest) {
|
|
587
|
+
const headOpaque = s[0] === "repos" ? opaque.slice(1, 3) : opaque.slice(1, 2);
|
|
588
|
+
if (headOpaque.some(Boolean)) {
|
|
589
|
+
// An expanded owner/repo may itself contain slashes: classify from the
|
|
590
|
+
// first literal family keyword after the expansion, or fail closed.
|
|
591
|
+
const first = opaque.findIndex(Boolean);
|
|
592
|
+
const at = s.findIndex((segment, index) => index > first && !opaque[index] && REPO_KEYWORDS.has(segment));
|
|
593
|
+
return endpoint(at < 0 ? "unclassifiable" : classifyRepoPath(s.slice(at), opaque.slice(at)));
|
|
594
|
+
}
|
|
595
|
+
return endpoint(classifyRepoPath(rest, restOpaque));
|
|
596
|
+
}
|
|
597
|
+
const top = s[0];
|
|
598
|
+
if (top === "issues") return endpoint("sensitive");
|
|
599
|
+
if (top === "user" && s[1] === "issues") return endpoint("sensitive");
|
|
600
|
+
if (top === "orgs" && s[2] === "issues") return endpoint("sensitive");
|
|
601
|
+
if (top === "search") return endpoint(s[1] === "issues" || s[1] === "commits" ? "sensitive" : s[1] !== undefined && opaque[1] ? "unclassifiable" : "other");
|
|
602
|
+
if (top === "events" || top === "networks") return endpoint("sensitive");
|
|
603
|
+
if ((top === "users" || top === "orgs") && s.slice(2).some((segment) => segment === "events" || segment === "received_events" || segment === "discussions")) return endpoint("sensitive");
|
|
604
|
+
if ((top === "users" || top === "orgs") && s[2] === "projectsv2" && s[4] === "items") return endpoint("sensitive");
|
|
605
|
+
if ((top === "orgs" && s[2] === "hooks" && s[4] === "deliveries" && s[5] !== undefined) || (top === "app" && s[1] === "hook" && s[2] === "deliveries" && s[3] !== undefined)) return endpoint("sensitive");
|
|
606
|
+
if (top === "teams" && s.includes("discussions")) return endpoint("sensitive");
|
|
607
|
+
if (top === "projects" && s.includes("cards")) return endpoint("sensitive");
|
|
608
|
+
if (opaque.some(Boolean)) return endpoint(s.some((segment) => segment === "events" || segment === "discussions" || segment === "issues") ? "sensitive" : "unclassifiable");
|
|
609
|
+
return endpoint("other");
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/** Path words that start a repository sub-resource the guard classifies. */
|
|
613
|
+
const REPO_KEYWORDS = new Set([...REPO_FAMILIES, "branches", "git", "actions", "hooks", "zipball", "tarball"]);
|
|
614
|
+
|
|
615
|
+
/** Classify the path after `repos/<owner>/<repo>`. */
|
|
616
|
+
function classifyRepoPath(rest: string[], restOpaque: boolean[]): EndpointClass {
|
|
617
|
+
const [family, second] = rest;
|
|
618
|
+
if (family === undefined) return "other";
|
|
619
|
+
if (restOpaque[0]) return "unclassifiable";
|
|
620
|
+
if (family === "issues" && /^\d+$/.test(second ?? "") && rest[2] === "labels") return "other";
|
|
621
|
+
if (REPO_FAMILIES.has(family)) return "sensitive";
|
|
622
|
+
if (family === "branches") {
|
|
623
|
+
// A single branch carries its head commit, message included; the list and
|
|
624
|
+
// protection and rename endpoints do not. Branch names may contain `/`.
|
|
625
|
+
if (rest.length === 1 || rest.slice(1).some((segment) => segment === "protection") || rest[rest.length - 1] === "rename") return "other";
|
|
626
|
+
return restOpaque.slice(1).some(Boolean) ? "unclassifiable" : "sensitive";
|
|
627
|
+
}
|
|
628
|
+
if (family === "git") return second === undefined || second === "commits" || second === "tags" ? "sensitive" : restOpaque[1] ? "unclassifiable" : "other";
|
|
629
|
+
if (family === "actions") {
|
|
630
|
+
if (second === undefined) return "sensitive";
|
|
631
|
+
if (restOpaque[1]) return "unclassifiable";
|
|
632
|
+
if (second === "runs" || second === "jobs") {
|
|
633
|
+
// Workflow logs are the repository's own step output, the same text
|
|
634
|
+
// `gh run view --log` prints; they are not the composite class.
|
|
635
|
+
if (rest[rest.length - 1] === "logs" && rest.length >= 4 && !restOpaque.some(Boolean)) return "download";
|
|
636
|
+
return "sensitive";
|
|
637
|
+
}
|
|
638
|
+
if (second === "workflows" && rest[3] === "runs") return "sensitive";
|
|
639
|
+
if (second === "artifacts" && rest.length === 4) return "download";
|
|
640
|
+
return "other";
|
|
641
|
+
}
|
|
642
|
+
if (family === "hooks" && rest[2] === "deliveries" && rest[3] !== undefined) return "sensitive";
|
|
643
|
+
if (family === "zipball" || family === "tarball") return "download";
|
|
644
|
+
return "other";
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
/**
|
|
648
|
+
* Writes whose documented response is empty or scalar-only (GitHub REST
|
|
649
|
+
* OpenAPI description 1.1.4): deleting a comment, reaction, release, run,
|
|
650
|
+
* deployment or milestone (204); re-running, cancelling or approving a run or
|
|
651
|
+
* job (201/202, empty body); merging a pull request (`sha`, `merged` and a
|
|
652
|
+
* GitHub status `message` such as "Pull Request successfully merged" — the
|
|
653
|
+
* merge commit message is request input, never echoed); updating a branch
|
|
654
|
+
* (`message`, `url`); locking an issue (204); and adding, setting or removing
|
|
655
|
+
* issue labels (the label list; label descriptions are capped at 100
|
|
656
|
+
* characters). Writes that echo the whole issue or pull request (PATCH of an
|
|
657
|
+
* issue, review or comment) stay refused unless `--silent` or a scalar `--jq`.
|
|
658
|
+
*/
|
|
659
|
+
function writeIsScalar(method: string, s: string[]): boolean {
|
|
660
|
+
if (s[0] !== "repos" || s.length < 5) return false;
|
|
661
|
+
const r = s.slice(3);
|
|
662
|
+
const num = (at: number) => /^\d+$/.test(r[at] ?? "");
|
|
663
|
+
const is = (...parts: Array<string | number>) => r.length === parts.length && parts.every((part, at) => typeof part === "number" ? num(at) : r[at] === part);
|
|
664
|
+
if (method === "DELETE") {
|
|
665
|
+
return is("issues", "comments", 0) || is("pulls", "comments", 0) || is("comments", 0)
|
|
666
|
+
|| is("issues", "comments", 0, "reactions", 0) || is("pulls", "comments", 0, "reactions", 0) || is("issues", 0, "reactions", 0)
|
|
667
|
+
|| is("issues", 0, "labels") || (r.length === 4 && r[0] === "issues" && num(1) && r[2] === "labels")
|
|
668
|
+
|| is("releases", 0) || is("releases", "assets", 0) || is("actions", "runs", 0) || is("actions", "runs", 0, "logs")
|
|
669
|
+
|| is("deployments", 0) || is("milestones", 0) || is("issues", 0, "lock");
|
|
670
|
+
}
|
|
671
|
+
if (method === "POST") {
|
|
672
|
+
return (r.length === 4 && r[0] === "actions" && r[1] === "runs" && num(2) && ["rerun", "rerun-failed-jobs", "cancel", "force-cancel", "approve"].includes(r[3]))
|
|
673
|
+
|| is("actions", "jobs", 0, "rerun") || is("issues", 0, "labels");
|
|
674
|
+
}
|
|
675
|
+
if (method === "PUT") return is("pulls", 0, "merge") || is("pulls", 0, "update-branch") || is("issues", 0, "labels") || is("issues", 0, "lock");
|
|
676
|
+
if (method === "GET") return is("pulls", 0, "merge");
|
|
677
|
+
return false;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
/** Top-level fields that are plain counts on a single pull request or issue object. */
|
|
681
|
+
function rootScalarsFor(s: string[]): ReadonlySet<string> | undefined {
|
|
682
|
+
if (s[0] !== "repos" || s.length !== 5 || !/^\d+$/.test(s[4])) return undefined;
|
|
683
|
+
if (s[3] === "pulls") return new Set(["commits", "comments", "review_comments"]);
|
|
684
|
+
if (s[3] === "issues") return new Set(["comments"]);
|
|
685
|
+
return undefined;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/**
|
|
689
|
+
* GraphQL selections that carry free text or URLs: bodies, comments, reviews,
|
|
690
|
+
* commit messages and the auto-merge commit text, check and annotation text,
|
|
691
|
+
* deployment payloads, descriptions, notes and diffs, and every URL-valued
|
|
692
|
+
* field (`url`, `*Url`, `*URL`) and HTML rendering. A check run's or status
|
|
693
|
+
* context's `title` is app-authored too, so `title` is refused beneath one.
|
|
694
|
+
*/
|
|
695
|
+
const GRAPHQL_SENSITIVE = new Set([
|
|
696
|
+
"body", "bodyText", "bodyHTML", "bodyUrl", "bodyResourcePath", "comments", "reviews", "latestReviews",
|
|
697
|
+
"latestOpinionatedReviews", "reviewThreads", "url", "permalink", "summary", "text", "description", "message",
|
|
698
|
+
"messageBody", "messageHeadline", "commitBody", "commitHeadline", "annotations", "rawDetails", "note", "diff",
|
|
699
|
+
"readme", "payload",
|
|
700
|
+
]);
|
|
701
|
+
const CHECK_SCOPES = new Set([
|
|
702
|
+
"checkRuns", "checkRun", "checkSuites", "checkSuite", "contexts", "statusCheckRollup", "annotations",
|
|
703
|
+
"CheckRun", "CheckSuite", "StatusContext", "CheckAnnotation", "latestStatus", "status", "statuses",
|
|
704
|
+
]);
|
|
705
|
+
|
|
706
|
+
type GqlToken = { kind: "name" | "punct"; text: string };
|
|
707
|
+
|
|
708
|
+
/** Tokenize a GraphQL document: strings, block strings and `#` comments are skipped in one left-to-right pass. */
|
|
709
|
+
function graphqlTokens(query: string): GqlToken[] | null {
|
|
710
|
+
const out: GqlToken[] = [];
|
|
711
|
+
let i = 0;
|
|
712
|
+
while (i < query.length) {
|
|
713
|
+
const c = query[i];
|
|
714
|
+
if (/[\s,]/.test(c)) { i++; continue; }
|
|
715
|
+
if (c === "#") { const end = query.indexOf("\n", i); i = end < 0 ? query.length : end + 1; continue; }
|
|
716
|
+
if (query.startsWith('"""', i)) {
|
|
717
|
+
let j = i + 3;
|
|
718
|
+
for (;;) {
|
|
719
|
+
const end = query.indexOf('"""', j);
|
|
720
|
+
if (end < 0) return null;
|
|
721
|
+
if (query[end - 1] === "\\") { j = end + 3; continue; }
|
|
722
|
+
i = end + 3;
|
|
723
|
+
break;
|
|
724
|
+
}
|
|
725
|
+
continue;
|
|
726
|
+
}
|
|
727
|
+
if (c === '"') {
|
|
728
|
+
let j = i + 1;
|
|
729
|
+
while (j < query.length && query[j] !== '"' && query[j] !== "\n") j += query[j] === "\\" ? 2 : 1;
|
|
730
|
+
if (query[j] !== '"') return null;
|
|
731
|
+
i = j + 1;
|
|
732
|
+
continue;
|
|
733
|
+
}
|
|
734
|
+
const name = /^[_A-Za-z][_0-9A-Za-z]*/.exec(query.slice(i, i + 256));
|
|
735
|
+
if (name) { out.push({ kind: "name", text: name[0] }); i += name[0].length; continue; }
|
|
736
|
+
const number = /^-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/.exec(query.slice(i, i + 64));
|
|
737
|
+
if (number) { i += number[0].length; continue; }
|
|
738
|
+
if (query.startsWith("...", i)) { out.push({ kind: "punct", text: "..." }); i += 3; continue; }
|
|
739
|
+
if ("{}()[]:!$@=|&".includes(c)) { out.push({ kind: "punct", text: c }); i++; continue; }
|
|
740
|
+
return null;
|
|
741
|
+
}
|
|
742
|
+
return out;
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
/**
|
|
746
|
+
* Whether a GraphQL document selects a sensitive field. Argument lists,
|
|
747
|
+
* variable definitions, strings and comments are not selections. The
|
|
748
|
+
* enclosing field names and type conditions are tracked so a check run's
|
|
749
|
+
* `title` is refused while a pull request's is not. An unreadable document
|
|
750
|
+
* counts as sensitive.
|
|
751
|
+
*/
|
|
752
|
+
export function graphqlIsSensitive(query: string): boolean {
|
|
753
|
+
const tokens = graphqlTokens(query);
|
|
754
|
+
if (!tokens) return true;
|
|
755
|
+
const scopes: string[] = [];
|
|
756
|
+
let lastOwner = "";
|
|
757
|
+
let parenDepth = 0;
|
|
758
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
759
|
+
const t = tokens[i];
|
|
760
|
+
if (t.kind === "punct") {
|
|
761
|
+
if (t.text === "(") parenDepth++;
|
|
762
|
+
else if (t.text === ")") { parenDepth--; if (parenDepth < 0) return true; }
|
|
763
|
+
else if (parenDepth === 0 && t.text === "{") { scopes.push(lastOwner); lastOwner = ""; }
|
|
764
|
+
else if (parenDepth === 0 && t.text === "}") { if (scopes.pop() === undefined) return true; lastOwner = ""; }
|
|
765
|
+
continue;
|
|
766
|
+
}
|
|
767
|
+
if (parenDepth > 0) continue;
|
|
768
|
+
const prev = tokens[i - 1];
|
|
769
|
+
if (prev?.kind === "punct" && (prev.text === "@" || prev.text === "$")) continue;
|
|
770
|
+
if (tokens[i + 1]?.kind === "punct" && tokens[i + 1].text === ":") continue; // an alias
|
|
771
|
+
if (t.text === "on" && prev?.kind === "punct" && prev.text === "...") continue;
|
|
772
|
+
if (prev?.kind === "name" && prev.text === "on") { lastOwner = t.text; continue; } // a type condition
|
|
773
|
+
if (scopes.length === 0) { lastOwner = t.text; continue; } // operation or fragment header
|
|
774
|
+
if (GRAPHQL_SENSITIVE.has(t.text) || /(?:Url|URL|HTML|Html)$/.test(t.text)) return true;
|
|
775
|
+
if (t.text === "title" && scopes.some((scope) => CHECK_SCOPES.has(scope))) return true;
|
|
776
|
+
lastOwner = t.text;
|
|
777
|
+
}
|
|
778
|
+
return parenDepth !== 0 || scopes.length !== 0;
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
function classifyApi(args: ShellWord[], ctx: Context): SignedLinkVerdict {
|
|
782
|
+
const parsed = parseFlags(args, SPECS.api);
|
|
783
|
+
if (flagOn(parsed, "-h", "--help")) return ALLOW;
|
|
784
|
+
const endpointWord = parsed.positionals[0];
|
|
785
|
+
if (!endpointWord) return ctx.appendsArgs ? refused("unclassifiable") : ALLOW;
|
|
786
|
+
const endpoint = classifyEndpoint(endpointWord);
|
|
787
|
+
const methodFlags = valuesOf(parsed, "-X", "--method");
|
|
788
|
+
const lastMethod = methodFlags[methodFlags.length - 1];
|
|
789
|
+
const bodyFlags = valuesOf(parsed, "-f", "--raw-field", "-F", "--field", "--input");
|
|
790
|
+
const method = lastMethod ? (lastMethod.value && !lastMethod.value.opaque ? lastMethod.value.value.toUpperCase() : "?") : bodyFlags.length > 0 ? "POST" : "GET";
|
|
791
|
+
const scalarWrite = writeIsScalar(method, endpoint.segments);
|
|
792
|
+
|
|
793
|
+
// What the request FETCHES: GH_DEBUG and --verbose print the raw response
|
|
794
|
+
// whatever --jq or --silent select.
|
|
795
|
+
let fetchVerdict: SignedLinkVerdict = ALLOW;
|
|
796
|
+
if (endpoint.kind === "unclassifiable") fetchVerdict = refused("unclassifiable");
|
|
797
|
+
else if (endpoint.kind === "sensitive" && !scalarWrite) fetchVerdict = refused("api-rest");
|
|
798
|
+
else if (endpoint.kind === "graphql") fetchVerdict = classifyGraphqlQuery(parsed);
|
|
799
|
+
const traffic = ctx.debug || flagOn(parsed, "--verbose");
|
|
800
|
+
if (traffic) {
|
|
801
|
+
if (endpoint.kind === "download" && !fetchVerdict.refused) fetchVerdict = refused("api-rest");
|
|
802
|
+
if (fetchVerdict.refused) return ctx.debug ? refused("debug-traffic") : fetchVerdict;
|
|
803
|
+
return ALLOW;
|
|
804
|
+
}
|
|
805
|
+
if (ctx.appendsArgs && endpoint.kind !== "other" && endpoint.kind !== "download") return refused("unclassifiable");
|
|
806
|
+
if (!fetchVerdict.refused || flagOn(parsed, "--silent")) return ALLOW;
|
|
807
|
+
// The projection proof is about what is printed, so it holds for any endpoint.
|
|
808
|
+
if (jqRescues(parsed, rootScalarsFor(endpoint.segments))) return ALLOW;
|
|
809
|
+
return fetchVerdict;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
/** GraphQL: the query arrives as a `query=` field, or from a file or stdin. */
|
|
813
|
+
function classifyGraphqlQuery(parsed: ParsedFlags): SignedLinkVerdict {
|
|
814
|
+
if (valuesOf(parsed, "--input").length > 0) return refused("unclassifiable");
|
|
815
|
+
const fields = valuesOf(parsed, "-f", "--raw-field", "-F", "--field");
|
|
816
|
+
const queries = fields.filter((flag) => flag.value && /^query=/.test(flag.value.value));
|
|
817
|
+
if (queries.length === 0 || fields.some((flag) => !flag.value)) return refused("unclassifiable");
|
|
818
|
+
for (const flag of queries) {
|
|
819
|
+
const text = flag.value!.value.slice("query=".length);
|
|
820
|
+
const fromFile = (flag.name === "-F" || flag.name === "--field") && text.startsWith("@");
|
|
821
|
+
if (fromFile || flag.value!.opaque) return refused("unclassifiable");
|
|
822
|
+
if (graphqlIsSensitive(text)) return refused("api-graphql");
|
|
823
|
+
}
|
|
824
|
+
return ALLOW;
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
/** Classify the words after a `gh` command word. */
|
|
828
|
+
export function classifyGh(args: ShellWord[], ctx: Context): SignedLinkVerdict {
|
|
829
|
+
let i = 0;
|
|
830
|
+
while (i < args.length && args[i].value.startsWith("-") && !args[i].opaque) {
|
|
831
|
+
if (["--help", "-h", "--version"].includes(args[i].value)) return ALLOW;
|
|
832
|
+
i++;
|
|
833
|
+
}
|
|
834
|
+
// `xargs gh` supplies the whole command from stdin.
|
|
835
|
+
if (i >= args.length) return ctx.appendsArgs ? refused("unclassifiable") : ALLOW;
|
|
836
|
+
const top = args[i];
|
|
837
|
+
const rest = args.slice(i + 1);
|
|
838
|
+
if (top.opaque) return refused("unclassifiable");
|
|
839
|
+
switch (top.value) {
|
|
840
|
+
case "pr":
|
|
841
|
+
case "issue":
|
|
842
|
+
case "release":
|
|
843
|
+
case "discussion":
|
|
844
|
+
case "search":
|
|
845
|
+
case "project":
|
|
846
|
+
return classifyGroup(top.value, rest, ctx);
|
|
847
|
+
case "api":
|
|
848
|
+
return classifyApi(rest, ctx);
|
|
849
|
+
case "status":
|
|
850
|
+
// The dashboard prints excerpts of recent comments and mentions.
|
|
851
|
+
return rest.length > 0 && rest.every((word) => word.value === "-h" || word.value === "--help") ? ALLOW : refused("composite-other");
|
|
852
|
+
case "help":
|
|
853
|
+
case "version":
|
|
854
|
+
return ALLOW;
|
|
855
|
+
case "cs":
|
|
856
|
+
case "codespace": {
|
|
857
|
+
// `gh codespace ssh … -- <command>` runs the command remotely and prints its output here.
|
|
858
|
+
const separator = rest.findIndex((word) => word.value === "--");
|
|
859
|
+
if (rest.some((word) => word.value === "ssh") && separator >= 0 && separator + 1 < rest.length) {
|
|
860
|
+
const verdict = classifyCommand(rest.slice(separator + 1).map((word) => word.value).join(" "), ctx.depth + 1, ctx.debug);
|
|
861
|
+
if (verdict.refused) return verdict;
|
|
862
|
+
}
|
|
863
|
+
break;
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
if (!SAFE_TOP.has(top.value)) return refused("unclassifiable"); // may be a user alias for a read
|
|
867
|
+
if (ctx.debug) return refused("debug-traffic");
|
|
868
|
+
return ALLOW;
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
/* ------------------------------------------------------------------ */
|
|
872
|
+
/* Shell traversal */
|
|
873
|
+
/* ------------------------------------------------------------------ */
|
|
874
|
+
|
|
875
|
+
const SHELLS = new Set(["sh", "bash", "zsh", "dash", "ksh", "ash", "mksh", "fish"]);
|
|
876
|
+
/** Commands that treat their `-c` value as a command string. */
|
|
877
|
+
const DASH_C_RUNNERS = new Set(["su", "runuser", "script", "flock"]);
|
|
878
|
+
/**
|
|
879
|
+
* Runners that execute the command given in their arguments. Only these (and
|
|
880
|
+
* the wrappers command position already skips: env, sudo, doas, nice, ionice,
|
|
881
|
+
* stdbuf, time, timeout, nohup, setsid, command, builtin, exec) pass a bare
|
|
882
|
+
* `gh` word on as a command; any other program's `gh` argument is data.
|
|
883
|
+
*/
|
|
884
|
+
const RUNNERS = new Set([
|
|
885
|
+
"secrets", "op", "unbuffer", "caffeinate", "chronic", "strace", "ltrace", "firejail", "bwrap", "nsenter",
|
|
886
|
+
"unshare", "chroot", "sg", "retry", "parallel", "doppler", "aws-vault", "dotenv", "direnv", "faketime",
|
|
887
|
+
"taskset", "numactl", "cgexec", "systemd-run", "docker", "podman", "kubectl", "mise", "asdf", "rtx", "devbox",
|
|
888
|
+
"gosu", "su-exec", "setpriv", "pkexec", "with-contenv", "uv", "poetry", "pipenv", "npx", "bunx", "pnpm", "yarn",
|
|
889
|
+
"nix", "nix-shell", "infisical", "chamber", "envchain", "dotenvx", "teller", "vault", "chrt", "coproc", "prlimit",
|
|
890
|
+
"ionice", "cpulimit", "trickle", "proxychains", "proxychains4", "torsocks", "tsocks", "catchsegv", "valgrind",
|
|
891
|
+
]);
|
|
892
|
+
/** Package runners whose `x`/`exec`/`dlx` subcommand runs the named program. */
|
|
893
|
+
const PACKAGE_EXEC = new Map([["bun", ["x", "exec"]], ["npm", ["exec", "x"]], ["deno", ["run"]]]);
|
|
894
|
+
const XARGS_VALUES = new Set(["-I", "-L", "-l", "-n", "-P", "-s", "-d", "-E", "-e", "-a", "--arg-file", "--delimiter", "--max-args", "--max-lines", "--max-procs", "--max-chars", "--replace", "--eof", "--process-slot-var"]);
|
|
895
|
+
const SSH_VALUES = new Set(["-b", "-c", "-D", "-E", "-e", "-F", "-I", "-i", "-J", "-L", "-l", "-m", "-O", "-o", "-p", "-Q", "-R", "-S", "-W", "-w"]);
|
|
896
|
+
const GH_MENTION = /(?:^|[^A-Za-z0-9_.-])gh(?:$|[^A-Za-z0-9_.-])/;
|
|
897
|
+
const ASSIGNMENT = /^([A-Za-z_][A-Za-z0-9_]*)=([\s\S]*)$/;
|
|
898
|
+
|
|
899
|
+
const MAX_DEPTH = 6;
|
|
900
|
+
|
|
901
|
+
/** gh's own rule (`utils.IsDebugEnabled`): GH_DEBUG wins when set; legacy DEBUG counts for 1/true/yes/api. */
|
|
902
|
+
function debugFromAssignments(words: ShellWord[], inherited: boolean): boolean {
|
|
903
|
+
let ghDebug: ShellWord | undefined;
|
|
904
|
+
let legacy: ShellWord | undefined;
|
|
905
|
+
for (const word of words) {
|
|
906
|
+
const match = ASSIGNMENT.exec(word.value);
|
|
907
|
+
if (!match) continue;
|
|
908
|
+
const value = { ...word, value: match[2] };
|
|
909
|
+
if (match[1] === "GH_DEBUG") ghDebug = value;
|
|
910
|
+
else if (match[1] === "DEBUG") legacy = value;
|
|
911
|
+
}
|
|
912
|
+
if (ghDebug) return ghDebug.opaque || !["", "0", "false", "no"].includes(ghDebug.value);
|
|
913
|
+
if (legacy && (legacy.opaque || ["1", "true", "yes", "api"].includes(legacy.value))) return true;
|
|
914
|
+
return inherited;
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
interface Segment { words: Word[]; redirects: Set<number>; sepBefore: string }
|
|
918
|
+
|
|
919
|
+
/** lexCommand segments with the separator text before each (`|`, `&&`, `;`, `<(`, …). */
|
|
920
|
+
function segmentsOf(text: string): { segments: Segment[]; trustworthy: boolean } {
|
|
921
|
+
const lexed = lexCommand(text);
|
|
922
|
+
let last = 0;
|
|
923
|
+
const segments = lexed.segments.map((segment) => {
|
|
924
|
+
const first = segment.words[0];
|
|
925
|
+
const sepBefore = text.slice(last, first.start);
|
|
926
|
+
last = segment.words[segment.words.length - 1].end;
|
|
927
|
+
return { words: segment.words, redirects: segment.redirects, sepBefore };
|
|
928
|
+
});
|
|
929
|
+
return { segments, trustworthy: lexed.trustworthy };
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
const isPipe = (separator: string) => /(?:^|[^|])\|&?$/.test(separator.trim());
|
|
933
|
+
|
|
934
|
+
/** A shell whose script is its stdin: no `-c` and no script operand (`-s` and `-` allowed). */
|
|
935
|
+
function shellReadsStdin(verb: string, args: ShellWord[]): boolean {
|
|
936
|
+
if (!SHELLS.has(verb)) return false;
|
|
937
|
+
if (dashCValue(args)) return false;
|
|
938
|
+
for (let i = 0; i < args.length; i++) {
|
|
939
|
+
const text = args[i].value;
|
|
940
|
+
if (text === "--") return i + 1 >= args.length;
|
|
941
|
+
if (text === "-" || text === "-s") return true;
|
|
942
|
+
if (/^[-+][A-Za-z]*[oO]$/.test(text)) { i++; continue; }
|
|
943
|
+
if (!text.startsWith("-") && !text.startsWith("+")) return false;
|
|
944
|
+
}
|
|
945
|
+
return true;
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
/**
|
|
949
|
+
* The literal text a simple command prints, when it can be read: `echo …`,
|
|
950
|
+
* `printf <format> …` (with `%s` filled in) and `cat <<EOF … EOF`. Anything
|
|
951
|
+
* else — a file, a network download, a variable — is null.
|
|
952
|
+
*/
|
|
953
|
+
function literalOutput(text: string): string | null {
|
|
954
|
+
const { segments } = segmentsOf(text.trim());
|
|
955
|
+
if (segments.length !== 1) return null;
|
|
956
|
+
const segment = segments[0];
|
|
957
|
+
const index = commandWordIndex(segment);
|
|
958
|
+
if (index < 0) return null;
|
|
959
|
+
const verb = base(shellWord(segment.words[index].text).value);
|
|
960
|
+
const args = segment.words.slice(index + 1).filter((_, at) => !segment.redirects.has(at + index + 1)).map((word) => shellWord(word.text));
|
|
961
|
+
if (verb === "echo") return args.filter((word, at) => !(at === 0 && /^-[neE]+$/.test(word.value))).map((word) => word.value).join(" ");
|
|
962
|
+
if (verb === "printf") {
|
|
963
|
+
const [format, ...values] = args.filter((word) => word.value !== "--");
|
|
964
|
+
if (!format) return null;
|
|
965
|
+
let next = 0;
|
|
966
|
+
return format.value.replace(/\\n/g, "\n").replace(/%s/g, () => values[next++]?.value ?? "") + (next < values.length ? " " + values.slice(next).map((word) => word.value).join(" ") : "");
|
|
967
|
+
}
|
|
968
|
+
if (verb === "cat") {
|
|
969
|
+
const at = text.indexOf("<<");
|
|
970
|
+
if (at < 0) return null;
|
|
971
|
+
const marker = heredocMarkerAt(text, at);
|
|
972
|
+
const newline = text.indexOf("\n", at);
|
|
973
|
+
if (!marker || newline < 0) return null;
|
|
974
|
+
const end = skipHeredocBodies(text, newline, [marker]);
|
|
975
|
+
if (end < 0) return null;
|
|
976
|
+
const body = text.slice(newline + 1, end);
|
|
977
|
+
return body.slice(0, body.lastIndexOf(marker.delimiter)).replace(/\n$/, "");
|
|
978
|
+
}
|
|
979
|
+
return null;
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
/** Classify the literal output of each substitution in `raw`, as the script a shell or eval would run. */
|
|
983
|
+
function scriptsFromSubstitutions(raw: string, depth: number, ctx: Context): SignedLinkVerdict {
|
|
984
|
+
for (const body of substitutions(raw).bodies) {
|
|
985
|
+
const script = literalOutput(body);
|
|
986
|
+
if (script === null) continue;
|
|
987
|
+
const verdict = classifyCommand(script, depth + 1, ctx.debug);
|
|
988
|
+
if (verdict.refused) return verdict;
|
|
989
|
+
}
|
|
990
|
+
return ALLOW;
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
/** Classify a complete shell command string. */
|
|
994
|
+
export function classifyCommand(command: string, depth = 0, inheritedDebug = false): SignedLinkVerdict {
|
|
995
|
+
if (typeof command !== "string" || !command.trim()) return ALLOW;
|
|
996
|
+
if (depth > MAX_DEPTH) return GH_MENTION.test(command) ? refused("unclassifiable") : ALLOW;
|
|
997
|
+
const { segments, trustworthy } = segmentsOf(command);
|
|
998
|
+
// A command the parser cannot follow to its end cannot be proven free of a gh read.
|
|
999
|
+
if (!trustworthy) return GH_MENTION.test(command) ? refused("unclassifiable") : ALLOW;
|
|
1000
|
+
let debug = inheritedDebug;
|
|
1001
|
+
const next = (text: string) => classifyCommand(text, depth + 1, debug);
|
|
1002
|
+
|
|
1003
|
+
for (const heredoc of heredocs(command)) {
|
|
1004
|
+
// A shell (or ssh) runs the body; an unquoted delimiter runs its substitutions.
|
|
1005
|
+
if (heredoc.runsBody) {
|
|
1006
|
+
const verdict = next(heredoc.body);
|
|
1007
|
+
if (verdict.refused) return verdict;
|
|
1008
|
+
}
|
|
1009
|
+
if (!heredoc.quoted) {
|
|
1010
|
+
const found = substitutions(heredoc.body, "heredoc");
|
|
1011
|
+
if (!found.complete && GH_MENTION.test(heredoc.body)) return refused("unclassifiable");
|
|
1012
|
+
for (const body of found.bodies) {
|
|
1013
|
+
const verdict = next(body);
|
|
1014
|
+
if (verdict.refused) return verdict;
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
for (let at = 0; at < segments.length; at++) {
|
|
1020
|
+
const segment = segments[at];
|
|
1021
|
+
// Command substitutions run wherever they appear, whatever the command is.
|
|
1022
|
+
for (const word of segment.words) {
|
|
1023
|
+
const found = substitutions(word.text);
|
|
1024
|
+
if (!found.complete && GH_MENTION.test(word.text)) return refused("unclassifiable");
|
|
1025
|
+
for (const body of found.bodies) {
|
|
1026
|
+
const verdict = next(body);
|
|
1027
|
+
if (verdict.refused) return verdict;
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
const index = commandWordIndex(segment);
|
|
1031
|
+
const words = segment.words.map((word, position) => ({ word: shellWord(word.text), redirect: segment.redirects.has(position) }));
|
|
1032
|
+
const prefix = words.slice(0, index < 0 ? words.length : index).map((entry) => entry.word);
|
|
1033
|
+
// `export GH_DEBUG=api`, a bare assignment or `declare -x` sticks for the rest of the command.
|
|
1034
|
+
const statement = index >= 0 ? base(words[index].word.value) : "";
|
|
1035
|
+
if (index < 0 || ["export", "declare", "typeset", "readonly", "local"].includes(statement)) {
|
|
1036
|
+
debug = debugFromAssignments([...prefix, ...words.slice(index + 1).map((entry) => entry.word)], debug);
|
|
1037
|
+
if (index < 0) continue;
|
|
1038
|
+
}
|
|
1039
|
+
const segmentDebug = debugFromAssignments(prefix, debug);
|
|
1040
|
+
const operands = words.slice(index + 1).filter((entry) => !entry.redirect).map((entry) => entry.word);
|
|
1041
|
+
const redirects = words.slice(index + 1).filter((entry) => entry.redirect).map((entry) => entry.word);
|
|
1042
|
+
const verbWord = words[index].word;
|
|
1043
|
+
const verb = base(verbWord.value);
|
|
1044
|
+
const ctx: Context = { depth, appendsArgs: false, debug: segmentDebug };
|
|
1045
|
+
// `function name { cmd …; }`: the body's first command follows the name.
|
|
1046
|
+
if (index > 0 && words[index - 1].word.value === "function") {
|
|
1047
|
+
const body = operands.filter((word) => !["{", "()", "(", ")"].includes(word.value));
|
|
1048
|
+
const verdict = runArgv(body, ctx);
|
|
1049
|
+
if (verdict.refused) return verdict;
|
|
1050
|
+
continue;
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
// Literal text piped into a shell that reads its script from stdin.
|
|
1054
|
+
if (at > 0 && isPipe(segment.sepBefore) && shellReadsStdin(verb, operands)) {
|
|
1055
|
+
const producer = segments[at - 1];
|
|
1056
|
+
const text = segmentText(command, producer);
|
|
1057
|
+
const script = text === null ? null : literalOutput(text);
|
|
1058
|
+
if (script !== null) {
|
|
1059
|
+
const verdict = classifyCommand(script, depth + 1, segmentDebug);
|
|
1060
|
+
if (verdict.refused) return verdict;
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
// `source <(…)`, `. <(…)`, `bash <(…)`: the process substitution's output is the script.
|
|
1064
|
+
const following = segments[at + 1];
|
|
1065
|
+
if (following && /<\(\s*$/.test(following.sepBefore) && (SHELLS.has(verb) || verb === "source" || verb === ".")) {
|
|
1066
|
+
const open = following.words[0].start - following.sepBefore.length + following.sepBefore.lastIndexOf("(");
|
|
1067
|
+
const end = scanParenAt(command, open);
|
|
1068
|
+
const script = end < 0 ? null : literalOutput(command.slice(open + 1, end - 1));
|
|
1069
|
+
if (script !== null) {
|
|
1070
|
+
const verdict = classifyCommand(script, depth + 1, segmentDebug);
|
|
1071
|
+
if (verdict.refused) return verdict;
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
const verdict = classifyInvocation(verb, verbWord, operands, redirects, segment.words.slice(0, index + 1), ctx);
|
|
1076
|
+
if (verdict.refused) return verdict;
|
|
1077
|
+
}
|
|
1078
|
+
return ALLOW;
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
/** The source text of one segment, from its first word to its last. */
|
|
1082
|
+
function segmentText(command: string, segment: Segment): string | null {
|
|
1083
|
+
const first = segment.words[0];
|
|
1084
|
+
const last = segment.words[segment.words.length - 1];
|
|
1085
|
+
return first && last ? command.slice(first.start, last.end) : null;
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
/** Literal basename of a command word, or undefined when the name itself is built by an expansion. */
|
|
1089
|
+
function literalBasename(word: ShellWord): string | undefined {
|
|
1090
|
+
const name = base(word.value);
|
|
1091
|
+
return /[$`]/.test(name) || name === "" ? undefined : name;
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
function classifyInvocation(verb: string, verbWord: ShellWord, args: ShellWord[], redirects: ShellWord[], prefix: Word[], ctx: Context): SignedLinkVerdict {
|
|
1095
|
+
const depth = ctx.depth;
|
|
1096
|
+
const next = (text: string) => classifyCommand(text, depth + 1, ctx.debug);
|
|
1097
|
+
if (verbWord.opaque) {
|
|
1098
|
+
const name = literalBasename(verbWord);
|
|
1099
|
+
if (name === undefined) {
|
|
1100
|
+
// A command word whose name is an expansion (`$GH`, `"$(command -v gh)"`)
|
|
1101
|
+
// may be gh: refuse what gh would refuse, including an unreadable read.
|
|
1102
|
+
const top = args.find((word) => !word.value.startsWith("-"));
|
|
1103
|
+
const verdict = classifyGh(args, ctx);
|
|
1104
|
+
if (!verdict.refused) return ALLOW;
|
|
1105
|
+
return verdict.rule !== "unclassifiable" || (top !== undefined && !top.opaque && READ_TOP.has(top.value)) ? verdict : ALLOW;
|
|
1106
|
+
}
|
|
1107
|
+
verb = name;
|
|
1108
|
+
}
|
|
1109
|
+
if (verb === "gh") return classifyGh(args, ctx);
|
|
1110
|
+
if (SHELLS.has(verb)) {
|
|
1111
|
+
const script = dashCValue(args);
|
|
1112
|
+
if (script) {
|
|
1113
|
+
const verdict = next(script.value);
|
|
1114
|
+
if (verdict.refused || !script.opaque) return verdict;
|
|
1115
|
+
// `bash -c "$(cat <<EOF …)"`: the substitution's literal output is the script.
|
|
1116
|
+
return scriptsFromSubstitutions(script.raw, depth, ctx);
|
|
1117
|
+
}
|
|
1118
|
+
// No -c: the shell reads its script from a file or stdin; follow here-strings.
|
|
1119
|
+
for (const word of redirects) {
|
|
1120
|
+
const verdict = next(word.value);
|
|
1121
|
+
if (verdict.refused) return verdict;
|
|
1122
|
+
if (word.opaque) {
|
|
1123
|
+
const fromSubstitution = scriptsFromSubstitutions(word.raw, depth, ctx);
|
|
1124
|
+
if (fromSubstitution.refused) return fromSubstitution;
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
return ALLOW;
|
|
1128
|
+
}
|
|
1129
|
+
if (DASH_C_RUNNERS.has(verb)) {
|
|
1130
|
+
const at = args.findIndex((word) => word.value === "-c" || word.value === "--command" || /^-[A-Za-z]*c$/.test(word.value));
|
|
1131
|
+
const script = at >= 0 ? args[at + 1] : undefined;
|
|
1132
|
+
return script ? next(script.value) : bareGh(args, ctx);
|
|
1133
|
+
}
|
|
1134
|
+
if (verb === "eval" || verb === "watch") {
|
|
1135
|
+
let words = args;
|
|
1136
|
+
if (verb === "watch") {
|
|
1137
|
+
// watch's options come first; `-n`/`--interval` and `-q`/`--equexit` take a value.
|
|
1138
|
+
let i = 0;
|
|
1139
|
+
while (i < args.length && args[i].value.startsWith("-")) {
|
|
1140
|
+
if (args[i].value === "--") { i++; break; }
|
|
1141
|
+
if (["-n", "--interval", "-q", "--equexit"].includes(args[i].value)) i++;
|
|
1142
|
+
i++;
|
|
1143
|
+
}
|
|
1144
|
+
words = args.slice(i);
|
|
1145
|
+
}
|
|
1146
|
+
const verdict = next(words.map((word) => word.value).join(" "));
|
|
1147
|
+
if (verdict.refused) return verdict;
|
|
1148
|
+
for (const word of words) {
|
|
1149
|
+
const fromSubstitution = scriptsFromSubstitutions(word.raw, depth, ctx);
|
|
1150
|
+
if (fromSubstitution.refused) return fromSubstitution;
|
|
1151
|
+
}
|
|
1152
|
+
return ALLOW;
|
|
1153
|
+
}
|
|
1154
|
+
if (verb === "env") {
|
|
1155
|
+
// commandWordIndex stops on env only for -S/--split-string.
|
|
1156
|
+
return next(envSplitCommandText([...prefix.map((word) => shellWord(word.text).value), ...args.map((word) => word.value)]));
|
|
1157
|
+
}
|
|
1158
|
+
if (verb === "ssh") {
|
|
1159
|
+
let i = 0;
|
|
1160
|
+
for (; i < args.length; i++) {
|
|
1161
|
+
const text = args[i].value;
|
|
1162
|
+
if (!text.startsWith("-")) break;
|
|
1163
|
+
if (SSH_VALUES.has(text)) i++;
|
|
1164
|
+
}
|
|
1165
|
+
const remoteWords = args.slice(i + 1);
|
|
1166
|
+
if (remoteWords[0]?.value === "--") remoteWords.shift();
|
|
1167
|
+
const remote = remoteWords.map((word) => word.value).join(" ");
|
|
1168
|
+
return remote ? next(remote) : ALLOW;
|
|
1169
|
+
}
|
|
1170
|
+
if (verb === "xargs") {
|
|
1171
|
+
let i = 0;
|
|
1172
|
+
let replace = false;
|
|
1173
|
+
for (; i < args.length; i++) {
|
|
1174
|
+
const text = args[i].value;
|
|
1175
|
+
if (text === "--") { i++; break; }
|
|
1176
|
+
if (!text.startsWith("-")) break;
|
|
1177
|
+
if (/^-I|^-i|^--replace/.test(text)) replace = true;
|
|
1178
|
+
if (XARGS_VALUES.has(text)) i++;
|
|
1179
|
+
}
|
|
1180
|
+
return runArgv(args.slice(i), { depth, appendsArgs: !replace, debug: ctx.debug });
|
|
1181
|
+
}
|
|
1182
|
+
if (verb === "find") {
|
|
1183
|
+
for (let i = 0; i < args.length; i++) {
|
|
1184
|
+
if (!["-exec", "-execdir", "-ok", "-okdir"].includes(args[i].value)) continue;
|
|
1185
|
+
let end = i + 1;
|
|
1186
|
+
while (end < args.length && args[end].value !== ";" && args[end].value !== "+") end++;
|
|
1187
|
+
const verdict = runArgv(args.slice(i + 1, end), ctx);
|
|
1188
|
+
if (verdict.refused) return verdict;
|
|
1189
|
+
i = end;
|
|
1190
|
+
}
|
|
1191
|
+
return ALLOW;
|
|
1192
|
+
}
|
|
1193
|
+
if (RUNNERS.has(verb)) return bareGh(args, ctx);
|
|
1194
|
+
if (PACKAGE_EXEC.get(verb)?.includes(args[0]?.value ?? "")) return bareGh(args.slice(1), ctx);
|
|
1195
|
+
return ALLOW;
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
/** An argv vector run as a command (xargs, find -exec). */
|
|
1199
|
+
function runArgv(argv: ShellWord[], ctx: Context): SignedLinkVerdict {
|
|
1200
|
+
if (argv.length === 0) return ALLOW;
|
|
1201
|
+
const verb = base(argv[0].value);
|
|
1202
|
+
if (verb === "gh") return classifyGh(argv.slice(1), ctx);
|
|
1203
|
+
if (SHELLS.has(verb)) {
|
|
1204
|
+
const script = dashCValue(argv.slice(1));
|
|
1205
|
+
return script ? classifyCommand(script.value, ctx.depth + 1, ctx.debug) : ALLOW;
|
|
1206
|
+
}
|
|
1207
|
+
return classifyInvocation(verb, argv[0], argv.slice(1), [], [], ctx);
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
/** A known runner that passes a bare `gh` word on as a command. */
|
|
1211
|
+
function bareGh(args: ShellWord[], ctx: Context): SignedLinkVerdict {
|
|
1212
|
+
const at = args.findIndex((word) => !word.opaque && base(word.value) === "gh" && (word.value === "gh" || word.value.includes("/")));
|
|
1213
|
+
return at >= 0 ? classifyGh(args.slice(at + 1), ctx) : ALLOW;
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
/** The command string given to `-c` (also inside a short-option cluster such as `-lc`). */
|
|
1217
|
+
function dashCValue(args: ShellWord[]): ShellWord | undefined {
|
|
1218
|
+
for (let i = 0; i < args.length; i++) {
|
|
1219
|
+
const text = args[i].value;
|
|
1220
|
+
if (text === "--") return undefined;
|
|
1221
|
+
if (!text.startsWith("-") && !text.startsWith("+")) return undefined;
|
|
1222
|
+
if (text === "-c" || (/^-[A-Za-z]+$/.test(text) && text.includes("c"))) {
|
|
1223
|
+
return args.slice(i + 1).find((word) => !word.value.startsWith("-") || word.value === "-");
|
|
1224
|
+
}
|
|
1225
|
+
// `-o option` / `-O shopt`, also at the end of a cluster such as `-euo pipefail`.
|
|
1226
|
+
if (/^[-+][A-Za-z]*[oO]$/.test(text)) i++;
|
|
1227
|
+
}
|
|
1228
|
+
return undefined;
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
/**
|
|
1232
|
+
* Top-level here-documents, with whether the body runs as a script and
|
|
1233
|
+
* whether its delimiter is quoted (a quoted body is literal). The body runs
|
|
1234
|
+
* when the command that owns the operator is a shell reading stdin, `ssh`,
|
|
1235
|
+
* or `source`/`.` of `/dev/stdin`, or when that command is `cat` piped
|
|
1236
|
+
* straight into a shell reading stdin. A here-document feeding any other
|
|
1237
|
+
* command is data.
|
|
1238
|
+
*/
|
|
1239
|
+
function heredocs(command: string): Array<{ body: string; runsBody: boolean; quoted: boolean }> {
|
|
1240
|
+
const found: Array<{ body: string; runsBody: boolean; quoted: boolean }> = [];
|
|
1241
|
+
let from = 0;
|
|
1242
|
+
for (;;) {
|
|
1243
|
+
const at = command.indexOf("<<", from);
|
|
1244
|
+
if (at < 0) break;
|
|
1245
|
+
from = at + 2;
|
|
1246
|
+
if (command[at + 2] === "<" || (at > 0 && command[at - 1] === "<")) { from = at + 3; continue; }
|
|
1247
|
+
const marker = heredocMarkerAt(command, at);
|
|
1248
|
+
if (!marker) continue;
|
|
1249
|
+
const lineStart = command.lastIndexOf("\n", at) + 1;
|
|
1250
|
+
const lineEnd = command.indexOf("\n", at);
|
|
1251
|
+
if (lineEnd < 0) continue;
|
|
1252
|
+
const end = skipHeredocBodies(command, lineEnd, [marker]);
|
|
1253
|
+
const bodyText = end < 0 ? command.slice(lineEnd + 1) : command.slice(lineEnd + 1, end);
|
|
1254
|
+
const body = end < 0 ? bodyText : bodyText.slice(0, Math.max(0, bodyText.lastIndexOf(marker.delimiter))).replace(/\n$/, "");
|
|
1255
|
+
const line = command.slice(lineStart, at) + " HEREDOC_OPERATOR " + command.slice(marker.end, lineEnd);
|
|
1256
|
+
found.push({ body, runsBody: heredocRunsBody(line), quoted: marker.quoted });
|
|
1257
|
+
}
|
|
1258
|
+
return found;
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
function heredocRunsBody(line: string): boolean {
|
|
1262
|
+
const { segments } = segmentsOf(line);
|
|
1263
|
+
const owner = segments.findIndex((segment) => segment.words.some((word) => word.text === "HEREDOC_OPERATOR"));
|
|
1264
|
+
if (owner < 0) return false;
|
|
1265
|
+
const describe = (segment: Segment) => {
|
|
1266
|
+
const index = commandWordIndex(segment);
|
|
1267
|
+
if (index < 0) return { verb: "", args: [] as ShellWord[] };
|
|
1268
|
+
const args = segment.words.slice(index + 1).filter((word) => word.text !== "HEREDOC_OPERATOR").map((word) => shellWord(word.text));
|
|
1269
|
+
return { verb: base(shellWord(segment.words[index].text).value), args };
|
|
1270
|
+
};
|
|
1271
|
+
const { verb, args } = describe(segments[owner]);
|
|
1272
|
+
if (verb === "ssh" || shellReadsStdin(verb, args)) return true;
|
|
1273
|
+
if ((verb === "source" || verb === ".") && ["/dev/stdin", "/dev/fd/0", "/proc/self/fd/0"].includes(args[0]?.value ?? "")) return true;
|
|
1274
|
+
const consumer = segments[owner + 1];
|
|
1275
|
+
if (verb === "cat" && consumer && isPipe(consumer.sepBefore)) {
|
|
1276
|
+
const next = describe(consumer);
|
|
1277
|
+
return next.verb === "ssh" || shellReadsStdin(next.verb, next.args);
|
|
1278
|
+
}
|
|
1279
|
+
return false;
|
|
1280
|
+
}
|