opencode-harness-kit 0.5.4 → 0.5.6
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/CHANGELOG.md +207 -1
- package/bin/oc-harness.mjs +278 -45
- package/dist/config.d.ts +5 -0
- package/dist/denylist.d.ts +5 -1
- package/dist/guard.d.ts +17 -1
- package/dist/health.d.ts +69 -7
- package/dist/index.js +514 -73
- package/dist/profile.d.ts +32 -2
- package/dist/telemetry-store.d.ts +22 -0
- package/docs/EXIT-CODES.md +7 -0
- package/docs/KNOWN-LIMITS.md +53 -11
- package/eval/measure-prune.mjs +228 -54
- package/package.json +1 -1
- package/src/config.js +126 -3
- package/src/denylist.js +45 -2
- package/src/guard.ts +78 -7
- package/src/health.js +130 -5
- package/src/lane-usage.js +13 -6
- package/src/profile.js +187 -25
- package/src/prune.ts +58 -13
- package/src/router.ts +217 -17
- package/src/rules.js +440 -19
- package/src/sqlite.js +80 -0
- package/src/tasks.js +193 -40
- package/src/telemetry-store.ts +27 -4
- package/src/telemetry.ts +69 -9
- package/src/trust.js +182 -54
package/src/rules.js
CHANGED
|
@@ -93,12 +93,18 @@ function heredocEnd(command, at) {
|
|
|
93
93
|
}
|
|
94
94
|
|
|
95
95
|
/**
|
|
96
|
-
* Split a shell command
|
|
97
|
-
*
|
|
96
|
+
* Split a shell command at the separators of one kind.
|
|
97
|
+
*
|
|
98
|
+
* The quoting, the escaping and the heredoc body are handled once, so the
|
|
99
|
+
* command splitter and the pipe splitter can never disagree about where a
|
|
100
|
+
* command starts and ends.
|
|
101
|
+
*
|
|
98
102
|
* @param {string} command
|
|
103
|
+
* @param {"command" | "pipe"} kind `command` splits at every separator,
|
|
104
|
+
* `pipe` splits at the pipe only
|
|
99
105
|
* @returns {string[]}
|
|
100
106
|
*/
|
|
101
|
-
|
|
107
|
+
function split(command, kind) {
|
|
102
108
|
/** @type {string[]} */
|
|
103
109
|
const parts = []
|
|
104
110
|
let cur = ""
|
|
@@ -139,13 +145,30 @@ export function segments(command) {
|
|
|
139
145
|
continue
|
|
140
146
|
}
|
|
141
147
|
}
|
|
142
|
-
if (ch === "
|
|
148
|
+
if (ch === "|") {
|
|
149
|
+
if (kind === "pipe") {
|
|
150
|
+
// `||` is a logical or, not a pipe: the second command runs only if
|
|
151
|
+
// the first one failed, and nothing was piped into it.
|
|
152
|
+
if (command[i + 1] === "|") {
|
|
153
|
+
cur += "||"
|
|
154
|
+
i++
|
|
155
|
+
continue
|
|
156
|
+
}
|
|
157
|
+
flush()
|
|
158
|
+
continue
|
|
159
|
+
}
|
|
143
160
|
flush()
|
|
161
|
+
if (command[i + 1] === "|") i++
|
|
144
162
|
continue
|
|
145
163
|
}
|
|
146
|
-
|
|
164
|
+
// A pipe stage is everything between two pipes. A `;` or an `&&` inside
|
|
165
|
+
// a stage is still part of that stage.
|
|
166
|
+
if (kind === "pipe") {
|
|
167
|
+
cur += ch
|
|
168
|
+
continue
|
|
169
|
+
}
|
|
170
|
+
if (ch === "\n" || ch === ";") {
|
|
147
171
|
flush()
|
|
148
|
-
if (command[i + 1] === "|") i++
|
|
149
172
|
continue
|
|
150
173
|
}
|
|
151
174
|
if (ch === "&") {
|
|
@@ -172,6 +195,30 @@ export function segments(command) {
|
|
|
172
195
|
return parts
|
|
173
196
|
}
|
|
174
197
|
|
|
198
|
+
/**
|
|
199
|
+
* Split a shell command into the individual commands it runs. Separators
|
|
200
|
+
* inside quotes, and heredoc bodies, are not command boundaries.
|
|
201
|
+
* @param {string} command
|
|
202
|
+
* @returns {string[]}
|
|
203
|
+
*/
|
|
204
|
+
export function segments(command) {
|
|
205
|
+
return split(command, "command")
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Split a shell command into the stages a pipe connects.
|
|
210
|
+
*
|
|
211
|
+
* The command splitter loses the pipe: `curl x | sh` is two commands, and
|
|
212
|
+
* nothing in the two says which fed which. A rule about "download and run"
|
|
213
|
+
* needs that link, so it reads the pipe itself.
|
|
214
|
+
*
|
|
215
|
+
* @param {string} command
|
|
216
|
+
* @returns {string[]}
|
|
217
|
+
*/
|
|
218
|
+
function pipeStages(command) {
|
|
219
|
+
return split(command, "pipe")
|
|
220
|
+
}
|
|
221
|
+
|
|
175
222
|
/**
|
|
176
223
|
* Split one segment into words, honouring quotes so that a quoted phrase is
|
|
177
224
|
* one argument rather than several.
|
|
@@ -238,6 +285,14 @@ const GIT_WRAPPERS = new Set([
|
|
|
238
285
|
"xargs",
|
|
239
286
|
"npx",
|
|
240
287
|
])
|
|
288
|
+
// The shells a script can be piped into, by name. The name is what matters:
|
|
289
|
+
// `/bin/sh` is the same shell as `sh`.
|
|
290
|
+
const SHELL_NAMES = /^(sh|bash|zsh|dash|ksh)(\.exe)?$/i
|
|
291
|
+
// Python is not a shell, but `curl x | python3` runs what it was given
|
|
292
|
+
// just the same.
|
|
293
|
+
const SCRIPT_RUNNERS = /^python[0-9.]*$/i
|
|
294
|
+
// The commands that fetch something over the network.
|
|
295
|
+
const DOWNLOADERS = /\b(curl|wget)\b/i
|
|
241
296
|
// Wrappers whose flag consumes the next word, e.g. `sudo -u deploy git push`.
|
|
242
297
|
const WRAPPER_VALUE_FLAGS = new Set(["-u", "--user", "-g", "--group", "-I", "-P", "-n", "-a", "-d"])
|
|
243
298
|
// git options that take a value, so the subcommand is the word after them.
|
|
@@ -262,6 +317,67 @@ const GIT_GLOBAL_FLAGS = new Set([
|
|
|
262
317
|
"--icase-pathspecs",
|
|
263
318
|
])
|
|
264
319
|
|
|
320
|
+
/**
|
|
321
|
+
* The global flags each tool accepts, and that take a value.
|
|
322
|
+
*
|
|
323
|
+
* A denylist pattern names a COMMAND, so a command with a global flag in
|
|
324
|
+
* front of it is not the command the pattern describes: `npm --prefix /tmp
|
|
325
|
+
* publish` is `npm publish`, and `gh -R owner/repo pr create` is `gh pr
|
|
326
|
+
* create`. A flag that changes nothing about what the command does must not
|
|
327
|
+
* be a way around the list.
|
|
328
|
+
*
|
|
329
|
+
* The lists come from the tools' own `--help` output: `gh --help` lists
|
|
330
|
+
* `--repo` and `--hostname` among the global options, `npm help 7 config`
|
|
331
|
+
* lists `--prefix`, `--workspace`, `--registry` and `--cache` among the
|
|
332
|
+
* global config flags, `yarn --help` lists `--cwd`, and `pnpm --help` lists
|
|
333
|
+
* `--dir` and `--filter`.
|
|
334
|
+
*
|
|
335
|
+
* git is not in the table: its own options are resolved by `gitSubcommand`,
|
|
336
|
+
* which reads the subcommand rather than the whole command line.
|
|
337
|
+
*
|
|
338
|
+
* @type {Record<string, Set<string>>}
|
|
339
|
+
*/
|
|
340
|
+
const TOOL_VALUE_FLAGS = {
|
|
341
|
+
gh: new Set(["-R", "--repo", "-H", "--hostname"]),
|
|
342
|
+
npm: new Set([
|
|
343
|
+
"--prefix",
|
|
344
|
+
"--cache",
|
|
345
|
+
"--registry",
|
|
346
|
+
"--userconfig",
|
|
347
|
+
"--globalconfig",
|
|
348
|
+
"--otp",
|
|
349
|
+
"--tag",
|
|
350
|
+
"--access",
|
|
351
|
+
"--loglevel",
|
|
352
|
+
"--workspace",
|
|
353
|
+
"--script-shell",
|
|
354
|
+
"--init-author-name",
|
|
355
|
+
"--init-author-email",
|
|
356
|
+
"--init-author-url",
|
|
357
|
+
"--init-license",
|
|
358
|
+
"--init-module",
|
|
359
|
+
"--init-version",
|
|
360
|
+
]),
|
|
361
|
+
yarn: new Set(["--cwd", "--registry", "--cache-folder", "--modules-folder"]),
|
|
362
|
+
pnpm: new Set(["--dir", "-C", "--filter", "--store-dir", "--registry"]),
|
|
363
|
+
bun: new Set(["--cwd", "--config", "--registry"]),
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* The last path segment of a command word: `/usr/bin/env` is `env`.
|
|
368
|
+
*
|
|
369
|
+
* A tool named by its full path is the same tool, so every list of command
|
|
370
|
+
* names - the wrappers, the shells - has to be matched against the name and
|
|
371
|
+
* not against the path.
|
|
372
|
+
*
|
|
373
|
+
* @param {string} word
|
|
374
|
+
* @returns {string}
|
|
375
|
+
*/
|
|
376
|
+
function baseName(word) {
|
|
377
|
+
const slash = Math.max(word.lastIndexOf("/"), word.lastIndexOf("\\"))
|
|
378
|
+
return slash === -1 ? word : word.slice(slash + 1)
|
|
379
|
+
}
|
|
380
|
+
|
|
265
381
|
/**
|
|
266
382
|
* Skip the wrappers and environment assignments in front of a real command.
|
|
267
383
|
* `env FOO=1 sudo -u deploy git -C /repo push` resolves at index 4.
|
|
@@ -277,7 +393,11 @@ function skipPrefixes(tokens) {
|
|
|
277
393
|
i++
|
|
278
394
|
continue
|
|
279
395
|
}
|
|
280
|
-
|
|
396
|
+
// The wrapper is matched by NAME, so `/usr/bin/env` is `env`. Matching
|
|
397
|
+
// the raw token left the whole wrapper list one absolute path away from
|
|
398
|
+
// being decorative: `/usr/bin/env rm -rf /` was not a command the rules
|
|
399
|
+
// had ever seen.
|
|
400
|
+
if (GIT_WRAPPERS.has(baseName(word))) {
|
|
281
401
|
i++
|
|
282
402
|
while (i < tokens.length && tokens[i].startsWith("-") && tokens[i] !== "-") {
|
|
283
403
|
const flag = tokens[i]
|
|
@@ -303,11 +423,39 @@ export function stripPrefixes(segment) {
|
|
|
303
423
|
const i = skipPrefixes(tokens)
|
|
304
424
|
if (i >= tokens.length) return ""
|
|
305
425
|
const rest = tokens.slice(i)
|
|
306
|
-
|
|
307
|
-
if (slash !== -1) rest[0] = rest[0].slice(slash + 1)
|
|
426
|
+
rest[0] = baseName(rest[0])
|
|
308
427
|
return rest.join(" ")
|
|
309
428
|
}
|
|
310
429
|
|
|
430
|
+
/**
|
|
431
|
+
* A command with the tool's own global flags taken out.
|
|
432
|
+
*
|
|
433
|
+
* The flags are taken out wherever they appear, because that is where the
|
|
434
|
+
* tool itself accepts them: `gh pr --repo owner/repo create` is the same
|
|
435
|
+
* command as `gh pr create`, and gh's own flag parser reads it that way.
|
|
436
|
+
*
|
|
437
|
+
* @param {string} command
|
|
438
|
+
* @returns {string} the command as the tool will read it
|
|
439
|
+
*/
|
|
440
|
+
function stripToolFlags(command) {
|
|
441
|
+
const tokens = tokenize(command)
|
|
442
|
+
if (!tokens.length) return command
|
|
443
|
+
const flags = TOOL_VALUE_FLAGS[baseName(tokens[0]).toLowerCase()]
|
|
444
|
+
if (!flags) return command
|
|
445
|
+
/** @type {string[]} */
|
|
446
|
+
const out = []
|
|
447
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
448
|
+
const token = tokens[i]
|
|
449
|
+
if (!flags.has(token.split("=")[0])) {
|
|
450
|
+
out.push(token)
|
|
451
|
+
continue
|
|
452
|
+
}
|
|
453
|
+
// `--flag value` takes the next word; `--flag=value` carries its own.
|
|
454
|
+
if (!token.includes("=") && i + 1 < tokens.length) i++
|
|
455
|
+
}
|
|
456
|
+
return out.join(" ")
|
|
457
|
+
}
|
|
458
|
+
|
|
311
459
|
/**
|
|
312
460
|
* Resolve the git subcommand a segment actually runs, or null if the segment
|
|
313
461
|
* does not run git.
|
|
@@ -338,9 +486,48 @@ export function gitSubcommand(segment) {
|
|
|
338
486
|
return tokens[i] ?? null
|
|
339
487
|
}
|
|
340
488
|
|
|
341
|
-
const SHELL_BIN = /(^|[\\/])(sh|bash|zsh|dash|ksh)(\.exe)?$/i
|
|
342
489
|
const MAX_SHELL_DEPTH = 2
|
|
343
490
|
|
|
491
|
+
/**
|
|
492
|
+
* The command string a shell's `-c` flag carries.
|
|
493
|
+
*
|
|
494
|
+
* The shell reads the operand of `-c` from the NEXT argv element, whatever
|
|
495
|
+
* else is in the flag cluster: `bash -cv "rm -rf /"` runs `rm -rf /`, and
|
|
496
|
+
* the `v` is a flag, not a payload. Reading the rest of the word instead
|
|
497
|
+
* made the guard look at the letter `v` and at nothing else, so the
|
|
498
|
+
* command line it checked was a letter.
|
|
499
|
+
*
|
|
500
|
+
* A payload written INTO the word (`bash -c"rm -rf /"`) is a different
|
|
501
|
+
* spelling. bash and dash do not read it as the command line at all: they
|
|
502
|
+
* read the whole word as a cluster of option characters and refuse it
|
|
503
|
+
* (`bash: invalid option`). The guard reads the remainder as the command
|
|
504
|
+
* string anyway, because that is how the payload is spelled, and because
|
|
505
|
+
* refusing more than the shell would run is the safe direction - the guard
|
|
506
|
+
* is not the place to decide that a word is harmless.
|
|
507
|
+
*
|
|
508
|
+
* @param {string[]} tokens
|
|
509
|
+
* @param {number} from the index of the shell word
|
|
510
|
+
* @returns {string | null} the command line, or null when there is none
|
|
511
|
+
*/
|
|
512
|
+
function shellCommandString(tokens, from) {
|
|
513
|
+
for (let n = from + 1; n < tokens.length; n++) {
|
|
514
|
+
const token = tokens[n]
|
|
515
|
+
if (!token.startsWith("-") || token === "-") continue
|
|
516
|
+
// The flag cluster is the run of letters after the dash, and `c` is the
|
|
517
|
+
// flag that takes the command line.
|
|
518
|
+
const m = /^-([a-zA-Z]*)c/.exec(token)
|
|
519
|
+
if (!m) continue
|
|
520
|
+
// What follows the `c` in the same word is either the rest of the
|
|
521
|
+
// cluster (`-cv`, where the `v` is a flag) or the command line written
|
|
522
|
+
// into the word (`-c"rm -rf /"`, where the remainder has something in it
|
|
523
|
+
// that cannot be a flag). A run of letters is a cluster.
|
|
524
|
+
const after = token.slice(m[0].length)
|
|
525
|
+
if (after && !/^[a-zA-Z]+$/.test(after)) return after.trim()
|
|
526
|
+
return tokens.slice(n + 1).join(" ") || null
|
|
527
|
+
}
|
|
528
|
+
return null
|
|
529
|
+
}
|
|
530
|
+
|
|
344
531
|
/**
|
|
345
532
|
* The command line hidden inside `sh -c "..."` or `eval "..."`.
|
|
346
533
|
* @param {string} segment
|
|
@@ -356,10 +543,225 @@ export function shellPayload(segment) {
|
|
|
356
543
|
const i = skipPrefixes(tokens)
|
|
357
544
|
if (i >= tokens.length) return null
|
|
358
545
|
if (tokens[i] === "eval") return tokens.slice(i + 1).join(" ") || null
|
|
359
|
-
if (!
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
546
|
+
if (!SHELL_NAMES.test(baseName(tokens[i]))) return null
|
|
547
|
+
return shellCommandString(tokens, i)
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* The punctuation that opens a group of commands and the punctuation that
|
|
552
|
+
* ends a word in a command list.
|
|
553
|
+
*
|
|
554
|
+
* `sh;` is the shell `sh` is - the `;` ends the word, it does not make the
|
|
555
|
+
* word something else. Reading the word whole made every pipe with a `;`
|
|
556
|
+
* after the shell look like a pipe into something that is not a shell.
|
|
557
|
+
*
|
|
558
|
+
* The opening braces are here for the same reason: the contents of a group
|
|
559
|
+
* read the pipe the same way a command outside it does, so `{ sh; }` is a
|
|
560
|
+
* pipe into a shell even though the first word of the group is `{`.
|
|
561
|
+
*/
|
|
562
|
+
const GROUP_OPEN = /^[({[]+/
|
|
563
|
+
const GROUP_END = /[;)&,}\]]+$/
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* The name of the command a word names, with the path and the punctuation
|
|
567
|
+
* that frames it taken off.
|
|
568
|
+
* @param {string} word
|
|
569
|
+
* @returns {string}
|
|
570
|
+
*/
|
|
571
|
+
function commandName(word) {
|
|
572
|
+
return baseName(word).replace(GROUP_OPEN, "").replace(GROUP_END, "")
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/**
|
|
576
|
+
* Whether a command hands what it was given to a shell or an interpreter.
|
|
577
|
+
*
|
|
578
|
+
* A pipe stage is not always one command. `sh; echo done` is a command
|
|
579
|
+
* list, and `cd /tmp && sh` is a list whose shell is not first at all, so
|
|
580
|
+
* the stage is split into its commands and each one is looked at. The
|
|
581
|
+
* wrappers in front of a command are skipped, so `sudo /bin/bash` is the
|
|
582
|
+
* shell `bash` is, and the path is dropped, so `/bin/sh` is `sh`.
|
|
583
|
+
*
|
|
584
|
+
* @param {string} stage one stage of a pipe
|
|
585
|
+
* @returns {boolean}
|
|
586
|
+
*/
|
|
587
|
+
function runsScript(stage) {
|
|
588
|
+
for (const command of segments(stage)) {
|
|
589
|
+
const tokens = tokenize(command)
|
|
590
|
+
let i = skipPrefixes(tokens)
|
|
591
|
+
if (i >= tokens.length) continue
|
|
592
|
+
let name = commandName(tokens[i])
|
|
593
|
+
// A word that is nothing but an opener is a group, and the group's
|
|
594
|
+
// contents are the command: `{ sh; }` reads the pipe through the `sh`.
|
|
595
|
+
if (!name) {
|
|
596
|
+
i++
|
|
597
|
+
if (i >= tokens.length) continue
|
|
598
|
+
name = commandName(tokens[i])
|
|
599
|
+
}
|
|
600
|
+
if (SHELL_NAMES.test(name) || SCRIPT_RUNNERS.test(name)) return true
|
|
601
|
+
}
|
|
602
|
+
return false
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/**
|
|
606
|
+
* Whether a command downloads something and pipes it into a shell.
|
|
607
|
+
*
|
|
608
|
+
* Both halves are read the way the shell reads them. The pipe target is
|
|
609
|
+
* named by its base name, so `/bin/sh` is the shell `sh` is, and the
|
|
610
|
+
* wrappers in front of it are skipped, so `sudo /bin/bash` is the shell
|
|
611
|
+
* `bash` is. A rule that only knew the bare spelling of the shell was one
|
|
612
|
+
* absolute path away from being decorative.
|
|
613
|
+
*
|
|
614
|
+
* The target stage is read as the list of commands it is: `curl x | sh;` is
|
|
615
|
+
* still a pipe into a shell, because the `;` ends the word and not the
|
|
616
|
+
* pipe.
|
|
617
|
+
*
|
|
618
|
+
* The shell does not have to be the stage right after the download. A filter
|
|
619
|
+
* in between (`curl x | grep y | sh`) does not change what the shell RUNS -
|
|
620
|
+
* it changes only what the agent sees on the way there - so the chain is
|
|
621
|
+
* the same download-and-execute. The rule used to look at the stage after
|
|
622
|
+
* the download only, so a filter in the middle hid the shell from it.
|
|
623
|
+
*
|
|
624
|
+
* The order still matters: the shell has to come AFTER the download.
|
|
625
|
+
* `sh | grep y` runs a shell over a pipe it did not fetch, and there is
|
|
626
|
+
* nothing downloaded for it to execute.
|
|
627
|
+
*
|
|
628
|
+
* @param {string} command
|
|
629
|
+
* @returns {boolean}
|
|
630
|
+
*/
|
|
631
|
+
function pipesScriptIntoShell(command) {
|
|
632
|
+
const stages = pipeStages(command)
|
|
633
|
+
for (let i = 0; i < stages.length; i++) {
|
|
634
|
+
if (!DOWNLOADERS.test(stages[i])) continue
|
|
635
|
+
for (let j = i + 1; j < stages.length; j++) {
|
|
636
|
+
if (!runsScript(stages[j])) continue
|
|
637
|
+
return true
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
return false
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/**
|
|
644
|
+
* Whether a token is a flag that makes an rm destructive.
|
|
645
|
+
*
|
|
646
|
+
* The short form is a CLUSTER of letters, so `-rf`, `-fr` and `-R` all name
|
|
647
|
+
* a recursive delete and `-f` names a forced one. The long form is GNU's
|
|
648
|
+
* own name for the same two flags.
|
|
649
|
+
*
|
|
650
|
+
* @param {string} token
|
|
651
|
+
* @returns {boolean}
|
|
652
|
+
*/
|
|
653
|
+
function isDestructiveRmFlag(token) {
|
|
654
|
+
if (/^--(recursive|force)$/i.test(token)) return true
|
|
655
|
+
return /^-[a-zA-Z]*[rf]/i.test(token)
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/**
|
|
659
|
+
* Whether a command is an rm with a flag that makes it destructive.
|
|
660
|
+
*
|
|
661
|
+
* The flags and the paths are read SEPARATELY, because GNU rm PERMUTES its
|
|
662
|
+
* options: the flags can come after the path, and `rm /tmp/x -rf` is the
|
|
663
|
+
* same delete as `rm -rf /tmp/x`. The rule used to return at the FIRST flag
|
|
664
|
+
* token and then look for a path AFTER it, so a flag that came after the
|
|
665
|
+
* path was invisible and the delete the rule exists to stop went through.
|
|
666
|
+
*
|
|
667
|
+
* So the question is not "is the first flag recursive" but "is ANY of the
|
|
668
|
+
* flags recursive or force, AND is there a path" - a delete of one named
|
|
669
|
+
* file (`rm file.txt`, `rm -i notes.md`) is not a recursive delete, and a
|
|
670
|
+
* command with no path at all (`rm`, `rm -rf`) is not a delete.
|
|
671
|
+
*
|
|
672
|
+
* `--` ends the options, so every word after it is a path whatever it looks
|
|
673
|
+
* like. That makes `rm -- -rf` a delete of a file NAMED `-rf`, and not a
|
|
674
|
+
* recursive delete.
|
|
675
|
+
*
|
|
676
|
+
* @param {string} text the segment, with its prefixes stripped
|
|
677
|
+
* @returns {boolean}
|
|
678
|
+
*/
|
|
679
|
+
function isRecursiveDelete(text) {
|
|
680
|
+
const tokens = tokenize(text)
|
|
681
|
+
if (tokens.length < 2) return false
|
|
682
|
+
if (baseName(tokens[0]).toLowerCase() !== "rm") return false
|
|
683
|
+
let destructive = false
|
|
684
|
+
let hasPath = false
|
|
685
|
+
for (let i = 1; i < tokens.length; i++) {
|
|
686
|
+
const token = tokens[i]
|
|
687
|
+
if (token === "--") {
|
|
688
|
+
// Everything after `--` is a path, so the words that follow it are
|
|
689
|
+
// the path this rm destroys.
|
|
690
|
+
hasPath = tokens.length > i + 1
|
|
691
|
+
break
|
|
692
|
+
}
|
|
693
|
+
if (!token.startsWith("-")) {
|
|
694
|
+
hasPath = true
|
|
695
|
+
continue
|
|
696
|
+
}
|
|
697
|
+
if (isDestructiveRmFlag(token)) destructive = true
|
|
698
|
+
}
|
|
699
|
+
return destructive && hasPath
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/**
|
|
703
|
+
* The chmod options that carry their value in the NEXT word.
|
|
704
|
+
*
|
|
705
|
+
* `chmod --reference FILE` takes the mode from another file, so the word
|
|
706
|
+
* after it is not the mode. A scanner that read that word as the mode would
|
|
707
|
+
* see `chmod --reference 777 file` as "chmod 777", and that command is a
|
|
708
|
+
* file named 777 rather than a mode.
|
|
709
|
+
*/
|
|
710
|
+
const CHMOD_VALUE_OPTIONS = new Set(["--reference"])
|
|
711
|
+
|
|
712
|
+
/**
|
|
713
|
+
* The mode a chmod command sets, or null when it sets none.
|
|
714
|
+
*
|
|
715
|
+
* The mode is the first numeric OPERAND, and every option in front of it is
|
|
716
|
+
* skipped - INCLUDING the long forms. The rule used to accept only a short
|
|
717
|
+
* letter cluster in the flag position, so GNU's own `--recursive` put the
|
|
718
|
+
* mode out of the rule's reach: `chmod --recursive 777 /` was allowed while
|
|
719
|
+
* `chmod -R 777 /` was blocked, and the mode is the same mode.
|
|
720
|
+
*
|
|
721
|
+
* A symbolic mode (`a=rwx`) is not a numeric one, so it is not a mode this
|
|
722
|
+
* function returns. That is a policy gap and not a spelling one, and it is
|
|
723
|
+
* left alone on purpose.
|
|
724
|
+
*
|
|
725
|
+
* @param {string} text the segment, with its prefixes stripped
|
|
726
|
+
* @returns {string | null} the mode, as it was written
|
|
727
|
+
*/
|
|
728
|
+
function chmodMode(text) {
|
|
729
|
+
const tokens = tokenize(text)
|
|
730
|
+
if (tokens.length < 2) return null
|
|
731
|
+
if (baseName(tokens[0]).toLowerCase() !== "chmod") return null
|
|
732
|
+
for (let i = 1; i < tokens.length; i++) {
|
|
733
|
+
const token = tokens[i]
|
|
734
|
+
if (token === "--") continue
|
|
735
|
+
if (token.startsWith("--")) {
|
|
736
|
+
// `--flag=value` carries its own value, and a boolean long option
|
|
737
|
+
// carries none. Only the option that takes the next word swallows
|
|
738
|
+
// it, and that word is then not the mode.
|
|
739
|
+
if (!token.includes("=") && CHMOD_VALUE_OPTIONS.has(token)) i++
|
|
740
|
+
continue
|
|
741
|
+
}
|
|
742
|
+
if (token.startsWith("-")) continue
|
|
743
|
+
// The first numeric operand is the mode; the words after it are the
|
|
744
|
+
// files the mode is applied to.
|
|
745
|
+
if (/^\d+$/.test(token)) return token
|
|
746
|
+
return null
|
|
747
|
+
}
|
|
748
|
+
return null
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
/**
|
|
752
|
+
* Whether a chmod command sets the mode that opens permissions to everyone.
|
|
753
|
+
*
|
|
754
|
+
* The mode may be written with leading zeros: `0777` and `777` are the same
|
|
755
|
+
* mode, and a rule that required the bare digits let one spelling of it
|
|
756
|
+
* through.
|
|
757
|
+
*
|
|
758
|
+
* @param {string} text the segment, with its prefixes stripped
|
|
759
|
+
* @returns {boolean}
|
|
760
|
+
*/
|
|
761
|
+
function opensPermissions(text) {
|
|
762
|
+
const mode = chmodMode(text)
|
|
763
|
+
if (mode === null) return false
|
|
764
|
+
return /^0*777$/.test(mode)
|
|
363
765
|
}
|
|
364
766
|
|
|
365
767
|
/**
|
|
@@ -380,7 +782,19 @@ export const BASH_RULES = [
|
|
|
380
782
|
{
|
|
381
783
|
name: "secret exfiltration via shell",
|
|
382
784
|
scope: "command",
|
|
383
|
-
|
|
785
|
+
// The credential word has to be the whole name or the tail of it: the
|
|
786
|
+
// name of the variable is `GITHUB_TOKEN`, not `GITHUB`. A word boundary
|
|
787
|
+
// never fires after an underscore, so the boundary alone left every
|
|
788
|
+
// prefixed credential name unmatched.
|
|
789
|
+
//
|
|
790
|
+
// The name is matched in whatever case it is written: the rule used to
|
|
791
|
+
// match the upper case spelling only, so `token=$(curl ...)` was
|
|
792
|
+
// allowed, and a lower case name is the same name.
|
|
793
|
+
//
|
|
794
|
+
// What the rule does NOT cover is an assignment whose value is not
|
|
795
|
+
// fetched: `GITHUB_TOKEN=ghp_example` sets a credential, it does not
|
|
796
|
+
// send one.
|
|
797
|
+
test: /(^|[^A-Za-z0-9])(?:[A-Za-z0-9]+_)*(TOKEN|SECRET|KEY|PASSWORD|API_KEY)\s*=\s*\$\(\s*(curl|wget)\b/i,
|
|
384
798
|
why: "assigns a fetched value into a credential-named variable",
|
|
385
799
|
},
|
|
386
800
|
{
|
|
@@ -392,7 +806,7 @@ export const BASH_RULES = [
|
|
|
392
806
|
{
|
|
393
807
|
name: "pipe remote script into a shell",
|
|
394
808
|
scope: "command",
|
|
395
|
-
|
|
809
|
+
check: (text) => pipesScriptIntoShell(text),
|
|
396
810
|
why: "downloads and executes a script in one step",
|
|
397
811
|
},
|
|
398
812
|
// `git push` is ask-gated via permissions (`*` ask in the generated
|
|
@@ -401,13 +815,17 @@ export const BASH_RULES = [
|
|
|
401
815
|
{
|
|
402
816
|
name: "recursive delete",
|
|
403
817
|
scope: "segment",
|
|
404
|
-
|
|
818
|
+
check: (text) => isRecursiveDelete(text),
|
|
405
819
|
why: "rm with a recursive or force flag",
|
|
406
820
|
},
|
|
407
821
|
{
|
|
408
822
|
name: "chmod 777",
|
|
409
823
|
scope: "segment",
|
|
410
|
-
|
|
824
|
+
// The mode is read as an OPERAND rather than as a literal, because the
|
|
825
|
+
// flags in front of it can be long. `chmod --recursive 777 /` is the
|
|
826
|
+
// same chmod as `chmod -R 777 /`, and a rule that only knew the short
|
|
827
|
+
// flag cluster let the long spelling through.
|
|
828
|
+
check: (text) => opensPermissions(text),
|
|
411
829
|
why: "opens permissions on everything",
|
|
412
830
|
},
|
|
413
831
|
{
|
|
@@ -485,7 +903,10 @@ export function checkDenied(command, depth = 0) {
|
|
|
485
903
|
const parts = segments(command)
|
|
486
904
|
const normalized = parts.map((seg) => stripPrefixes(seg))
|
|
487
905
|
for (let i = 0; i < parts.length; i++) {
|
|
488
|
-
|
|
906
|
+
// The tool's own global flags come off before the list is read: the
|
|
907
|
+
// list names a command, and `npm --prefix /tmp publish` is the command
|
|
908
|
+
// `npm publish`.
|
|
909
|
+
const pattern = deniedBy(stripToolFlags(normalized[i]))
|
|
489
910
|
if (pattern) return { pattern, segment: parts[i] }
|
|
490
911
|
}
|
|
491
912
|
if (depth < MAX_SHELL_DEPTH) {
|
package/src/sqlite.js
CHANGED
|
@@ -92,6 +92,71 @@ function encodePath(db) {
|
|
|
92
92
|
return db.split("/").map(encodeURIComponent).join("/")
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
+
/**
|
|
96
|
+
* Whether a SQL string holds one statement.
|
|
97
|
+
*
|
|
98
|
+
* A semicolon inside a string literal or a comment does not end a
|
|
99
|
+
* statement, so the text is scanned rather than split on ";".
|
|
100
|
+
*
|
|
101
|
+
* Exported so a test can pin the rule without a database: the refusal is
|
|
102
|
+
* a promise about what the reader accepts, and a promise that can only be
|
|
103
|
+
* checked against a live database is a promise nobody checks.
|
|
104
|
+
*
|
|
105
|
+
* @param {string} sql
|
|
106
|
+
* @returns {boolean}
|
|
107
|
+
*/
|
|
108
|
+
export function isOneStatement(sql) {
|
|
109
|
+
let quote = ""
|
|
110
|
+
// Whether a statement has been terminated. Everything after the `;` that
|
|
111
|
+
// is not whitespace or a comment is a SECOND statement, and a comment
|
|
112
|
+
// after the `;` is not a statement at all.
|
|
113
|
+
let ended = false
|
|
114
|
+
for (let i = 0; i < sql.length; i++) {
|
|
115
|
+
const ch = sql[i]
|
|
116
|
+
if (quote) {
|
|
117
|
+
if (ch === quote) {
|
|
118
|
+
// A doubled quote inside a literal is an escaped quote, not the end
|
|
119
|
+
// of the literal.
|
|
120
|
+
if (sql[i + 1] === quote) i++
|
|
121
|
+
else quote = ""
|
|
122
|
+
}
|
|
123
|
+
continue
|
|
124
|
+
}
|
|
125
|
+
if (ch === "'" || ch === '"' || ch === "`") {
|
|
126
|
+
quote = ch
|
|
127
|
+
continue
|
|
128
|
+
}
|
|
129
|
+
// A line comment runs to the end of the line.
|
|
130
|
+
if (ch === "-" && sql[i + 1] === "-") {
|
|
131
|
+
const end = sql.indexOf("\n", i)
|
|
132
|
+
// The comment runs to the end of the text, so nothing follows it.
|
|
133
|
+
if (end < 0) break
|
|
134
|
+
i = end
|
|
135
|
+
continue
|
|
136
|
+
}
|
|
137
|
+
// A block comment runs to its own end.
|
|
138
|
+
if (ch === "/" && sql[i + 1] === "*") {
|
|
139
|
+
const end = sql.indexOf("*/", i + 2)
|
|
140
|
+
if (end < 0) break
|
|
141
|
+
i = end + 1
|
|
142
|
+
continue
|
|
143
|
+
}
|
|
144
|
+
if (ch === ";") {
|
|
145
|
+
// A second `;` is a second statement - an empty one, which is still a
|
|
146
|
+
// statement the caller did not ask for.
|
|
147
|
+
if (ended) return false
|
|
148
|
+
ended = true
|
|
149
|
+
continue
|
|
150
|
+
}
|
|
151
|
+
// The scan does not stop at the `;`. A comment after the last
|
|
152
|
+
// statement is a comment, and the old check - "is there anything after
|
|
153
|
+
// the semicolon?" - read it as a second statement and refused
|
|
154
|
+
// `"select 1; -- done"`.
|
|
155
|
+
if (ended && !/\s/.test(ch)) return false
|
|
156
|
+
}
|
|
157
|
+
return true
|
|
158
|
+
}
|
|
159
|
+
|
|
95
160
|
/**
|
|
96
161
|
* Run a query and return the rows.
|
|
97
162
|
*
|
|
@@ -105,6 +170,21 @@ function encodePath(db) {
|
|
|
105
170
|
* @returns {any[]}
|
|
106
171
|
*/
|
|
107
172
|
export function query(db, sql) {
|
|
173
|
+
// ONE statement, on both backends.
|
|
174
|
+
//
|
|
175
|
+
// The sqlite3 CLI runs the whole string, so a call with two statements ran
|
|
176
|
+
// them both; python3's execute() takes one statement and raised. The same
|
|
177
|
+
// call therefore behaved differently depending on which backend the
|
|
178
|
+
// machine happened to have - and on one side the failure was a database
|
|
179
|
+
// error about a statement the caller never wrote on its own.
|
|
180
|
+
//
|
|
181
|
+
// REFUSING is the behaviour chosen. The function returns ROWS, and the
|
|
182
|
+
// rows of two statements have no agreed meaning: the caller asked for one
|
|
183
|
+
// answer and would be handed two. A caller that wants two answers asks
|
|
184
|
+
// twice, and it says which one it wants each time.
|
|
185
|
+
if (!isOneStatement(sql)) {
|
|
186
|
+
throw new Error("query() runs one statement at a time - split the query into separate calls")
|
|
187
|
+
}
|
|
108
188
|
if (hasSqlite()) {
|
|
109
189
|
// The path is percent-encoded, because it goes into a URI. A `?` in a
|
|
110
190
|
// path is a query separator, so a database called "we?ird.db" was read
|