opencode-harness-kit 0.5.3 → 0.5.5
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 -17
- 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 +452 -68
- 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 +18 -7
- package/eval/measure-prune.mjs +13 -4
- package/package.json +1 -1
- package/src/config.js +126 -3
- package/src/denylist.js +46 -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 +329 -19
- package/src/sqlite.js +80 -0
- package/src/tasks.js +193 -40
- package/src/telemetry-store.ts +27 -4
- 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,115 @@ 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
|
+
* @param {string} command
|
|
619
|
+
* @returns {boolean}
|
|
620
|
+
*/
|
|
621
|
+
function pipesScriptIntoShell(command) {
|
|
622
|
+
const stages = pipeStages(command)
|
|
623
|
+
for (let i = 1; i < stages.length; i++) {
|
|
624
|
+
if (!DOWNLOADERS.test(stages[i - 1])) continue
|
|
625
|
+
if (!runsScript(stages[i])) continue
|
|
626
|
+
return true
|
|
627
|
+
}
|
|
628
|
+
return false
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/**
|
|
632
|
+
* Whether a command is an rm with a flag that makes it destructive.
|
|
633
|
+
*
|
|
634
|
+
* The flag can be a short cluster (`-rf`) or the long GNU name of the same
|
|
635
|
+
* two flags (`--recursive --force`), and it can be anywhere among the
|
|
636
|
+
* options. A rule that only knew the short form allowed the long one, which
|
|
637
|
+
* is the same command.
|
|
638
|
+
*
|
|
639
|
+
* @param {string} text the segment, with its prefixes stripped
|
|
640
|
+
* @returns {boolean}
|
|
641
|
+
*/
|
|
642
|
+
function isRecursiveDelete(text) {
|
|
643
|
+
const tokens = tokenize(text)
|
|
644
|
+
if (tokens.length < 2) return false
|
|
645
|
+
if (baseName(tokens[0]).toLowerCase() !== "rm") return false
|
|
646
|
+
for (let i = 1; i < tokens.length; i++) {
|
|
647
|
+
const token = tokens[i]
|
|
648
|
+
if (!token.startsWith("-")) continue
|
|
649
|
+
if (!/^-[a-zA-Z]*[rf]/i.test(token) && !/^--(recursive|force)$/i.test(token)) continue
|
|
650
|
+
// The flag needs something to destroy: `rm --recursive` on its own does
|
|
651
|
+
// nothing, and a rule that blocks it blocks a typo.
|
|
652
|
+
return tokens.slice(i + 1).some((t) => !t.startsWith("-"))
|
|
653
|
+
}
|
|
654
|
+
return false
|
|
363
655
|
}
|
|
364
656
|
|
|
365
657
|
/**
|
|
@@ -380,7 +672,19 @@ export const BASH_RULES = [
|
|
|
380
672
|
{
|
|
381
673
|
name: "secret exfiltration via shell",
|
|
382
674
|
scope: "command",
|
|
383
|
-
|
|
675
|
+
// The credential word has to be the whole name or the tail of it: the
|
|
676
|
+
// name of the variable is `GITHUB_TOKEN`, not `GITHUB`. A word boundary
|
|
677
|
+
// never fires after an underscore, so the boundary alone left every
|
|
678
|
+
// prefixed credential name unmatched.
|
|
679
|
+
//
|
|
680
|
+
// The name is matched in whatever case it is written: the rule used to
|
|
681
|
+
// match the upper case spelling only, so `token=$(curl ...)` was
|
|
682
|
+
// allowed, and a lower case name is the same name.
|
|
683
|
+
//
|
|
684
|
+
// What the rule does NOT cover is an assignment whose value is not
|
|
685
|
+
// fetched: `GITHUB_TOKEN=ghp_example` sets a credential, it does not
|
|
686
|
+
// send one.
|
|
687
|
+
test: /(^|[^A-Za-z0-9])(?:[A-Za-z0-9]+_)*(TOKEN|SECRET|KEY|PASSWORD|API_KEY)\s*=\s*\$\(\s*(curl|wget)\b/i,
|
|
384
688
|
why: "assigns a fetched value into a credential-named variable",
|
|
385
689
|
},
|
|
386
690
|
{
|
|
@@ -392,7 +696,7 @@ export const BASH_RULES = [
|
|
|
392
696
|
{
|
|
393
697
|
name: "pipe remote script into a shell",
|
|
394
698
|
scope: "command",
|
|
395
|
-
|
|
699
|
+
check: (text) => pipesScriptIntoShell(text),
|
|
396
700
|
why: "downloads and executes a script in one step",
|
|
397
701
|
},
|
|
398
702
|
// `git push` is ask-gated via permissions (`*` ask in the generated
|
|
@@ -401,13 +705,16 @@ export const BASH_RULES = [
|
|
|
401
705
|
{
|
|
402
706
|
name: "recursive delete",
|
|
403
707
|
scope: "segment",
|
|
404
|
-
|
|
708
|
+
check: (text) => isRecursiveDelete(text),
|
|
405
709
|
why: "rm with a recursive or force flag",
|
|
406
710
|
},
|
|
407
711
|
{
|
|
408
712
|
name: "chmod 777",
|
|
409
713
|
scope: "segment",
|
|
410
|
-
|
|
714
|
+
// The mode may be written with leading zeros: `0777` and `777` are the
|
|
715
|
+
// same mode, and a rule that required the bare digits let one spelling
|
|
716
|
+
// of it through.
|
|
717
|
+
test: /^chmod\s+(-[a-zA-Z]+\s+)*0*777\b/,
|
|
411
718
|
why: "opens permissions on everything",
|
|
412
719
|
},
|
|
413
720
|
{
|
|
@@ -485,7 +792,10 @@ export function checkDenied(command, depth = 0) {
|
|
|
485
792
|
const parts = segments(command)
|
|
486
793
|
const normalized = parts.map((seg) => stripPrefixes(seg))
|
|
487
794
|
for (let i = 0; i < parts.length; i++) {
|
|
488
|
-
|
|
795
|
+
// The tool's own global flags come off before the list is read: the
|
|
796
|
+
// list names a command, and `npm --prefix /tmp publish` is the command
|
|
797
|
+
// `npm publish`.
|
|
798
|
+
const pattern = deniedBy(stripToolFlags(normalized[i]))
|
|
489
799
|
if (pattern) return { pattern, segment: parts[i] }
|
|
490
800
|
}
|
|
491
801
|
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
|