@getmarrow/mcp 3.9.106 → 3.9.107
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 +19 -3
- package/dist/hook-contract.d.ts +2 -2
- package/dist/hook-contract.d.ts.map +1 -1
- package/dist/hook-contract.js +8 -4
- package/dist/hook-contract.js.map +1 -1
- package/dist/hook-pre-action.d.ts +6 -1
- package/dist/hook-pre-action.d.ts.map +1 -1
- package/dist/hook-pre-action.js +276 -12
- package/dist/hook-pre-action.js.map +1 -1
- package/dist/hook-tool-policy.d.ts +13 -0
- package/dist/hook-tool-policy.d.ts.map +1 -1
- package/dist/hook-tool-policy.js +68 -4
- package/dist/hook-tool-policy.js.map +1 -1
- package/dist/host-approval-state.d.ts +17 -0
- package/dist/host-approval-state.d.ts.map +1 -1
- package/dist/host-approval-state.js +53 -0
- package/dist/host-approval-state.js.map +1 -1
- package/dist/normalized-action.d.ts +9 -3
- package/dist/normalized-action.d.ts.map +1 -1
- package/dist/normalized-action.js +241 -40
- package/dist/normalized-action.js.map +1 -1
- package/dist/own-server-identity.d.ts.map +1 -1
- package/dist/own-server-identity.js +10 -4
- package/dist/own-server-identity.js.map +1 -1
- package/dist/seatbelt/env-dump.d.ts +22 -1
- package/dist/seatbelt/env-dump.d.ts.map +1 -1
- package/dist/seatbelt/env-dump.js +27 -2
- package/dist/seatbelt/env-dump.js.map +1 -1
- package/dist/seatbelt/env-names.d.ts +22 -0
- package/dist/seatbelt/env-names.d.ts.map +1 -0
- package/dist/seatbelt/env-names.js +298 -0
- package/dist/seatbelt/env-names.js.map +1 -0
- package/dist/seatbelt/mapper.d.ts.map +1 -1
- package/dist/seatbelt/mapper.js +1247 -73
- package/dist/seatbelt/mapper.js.map +1 -1
- package/dist/seatbelt/tables.d.ts.map +1 -1
- package/dist/seatbelt/tables.js +4 -1
- package/dist/seatbelt/tables.js.map +1 -1
- package/dist/secret-paths.d.ts +23 -0
- package/dist/secret-paths.d.ts.map +1 -1
- package/dist/secret-paths.js +29 -2
- package/dist/secret-paths.js.map +1 -1
- package/dist/session-loop-guard.d.ts.map +1 -1
- package/dist/session-loop-guard.js +10 -1
- package/dist/session-loop-guard.js.map +1 -1
- package/package.json +1 -1
- package/server.json +2 -2
package/dist/seatbelt/mapper.js
CHANGED
|
@@ -46,8 +46,10 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
46
46
|
exports.evaluateSeatbeltShell = evaluateSeatbeltShell;
|
|
47
47
|
exports.evaluateSeatbelt = evaluateSeatbelt;
|
|
48
48
|
const path = __importStar(require("node:path"));
|
|
49
|
+
const secret_paths_1 = require("../secret-paths");
|
|
49
50
|
const tables_1 = require("./tables");
|
|
50
51
|
const classifier_pin_1 = require("./classifier-pin");
|
|
52
|
+
const env_names_1 = require("./env-names");
|
|
51
53
|
const longopts_1 = require("./longopts");
|
|
52
54
|
const guards_1 = require("./guards");
|
|
53
55
|
const env_dump_1 = require("./env-dump");
|
|
@@ -262,6 +264,13 @@ function evaluateEvent(event, env) {
|
|
|
262
264
|
if (clock(run))
|
|
263
265
|
break;
|
|
264
266
|
checkSecretOperand(litArg(p), ctx, run, 'tool_grep');
|
|
267
|
+
// The tool searches every file under a folder: a process folder reaches the `environ` of each.
|
|
268
|
+
if (namesProcEnviron(absCandidates(p, ctx, run) || [], env_dump_1.PROC_PROCESS_TREE))
|
|
269
|
+
hit(run, 'S4', 'proc_environ');
|
|
270
|
+
// Likewise the host-approvals folder, and ~/.marrow that holds it.
|
|
271
|
+
if (namesApprovalStateFolder(absCandidates(p, ctx, run) || [], run)) {
|
|
272
|
+
checkSecretOperand(litArg(`${path.posix.join(run.home, '.marrow', 'host-approvals')}/state.json`), ctx, run, 'tool_grep');
|
|
273
|
+
}
|
|
265
274
|
}
|
|
266
275
|
for (const key of ['glob', 'include', 'file_pattern']) {
|
|
267
276
|
const g = input[key];
|
|
@@ -2933,6 +2942,9 @@ function dispatchProgram(prog, args, rest, pathLike, ctx, run, x) {
|
|
|
2933
2942
|
run.headMayChange = true;
|
|
2934
2943
|
// Generic operand checks apply to every program (S4 reads, S0 writes by write-programs).
|
|
2935
2944
|
genericOperandChecks(prog, args, ctx, run, x.info.subs);
|
|
2945
|
+
// The v1 compose binaries have no handler and are not in KNOWN_COMMANDS (their program label stays as it is): their bind mounts are read here, then their argv is scanned as an unknown program's is.
|
|
2946
|
+
if (prog === 'docker-compose' || prog === 'podman-compose')
|
|
2947
|
+
dockerMountChecks(rest, ctx, run);
|
|
2936
2948
|
// Every command that prints the environment (a word-level table shared with the keyed hook).
|
|
2937
2949
|
envDumpChecks(prog, rest, ctx, run, x);
|
|
2938
2950
|
// NEW-2: a protected path as an operand of a program that is not a known reader.
|
|
@@ -3075,6 +3087,9 @@ function dispatchProgram(prog, args, rest, pathLike, ctx, run, x) {
|
|
|
3075
3087
|
}
|
|
3076
3088
|
default: break;
|
|
3077
3089
|
}
|
|
3090
|
+
// An awk by a name the table does not have (`original-awk`, `goawk`, `my-awk`): read as awk, and then as the unknown program it also is (as `nawk` is).
|
|
3091
|
+
if (prog !== 'nawk' && /awk/i.test(prog))
|
|
3092
|
+
awkProgram(rest, ctx, run, x);
|
|
3078
3093
|
if (/^mkfs(?:\.|$)/.test(prog)) {
|
|
3079
3094
|
hit(run, 'S5', 'mkfs');
|
|
3080
3095
|
return;
|
|
@@ -4404,6 +4419,24 @@ function compileGlob(pattern, opts) {
|
|
|
4404
4419
|
function patternToRegex(pattern, lookalike) {
|
|
4405
4420
|
return compileGlob(pattern, { dyn: true, lookalike, ci: true });
|
|
4406
4421
|
}
|
|
4422
|
+
// `..` read lexically (`/tmp/../p*/self/e*` is `/p*/self/e*`): a segment before it, a glob or a dynamic part too, is dropped with it. A dynamic or
|
|
4423
|
+
// relative first segment that is dropped leaves the root, so the absolute reading stays.
|
|
4424
|
+
function collapseParents(p) {
|
|
4425
|
+
if (!p.includes('..'))
|
|
4426
|
+
return p;
|
|
4427
|
+
const out = [];
|
|
4428
|
+
for (const part of p.split('/')) {
|
|
4429
|
+
if (part !== '..') {
|
|
4430
|
+
out.push(part);
|
|
4431
|
+
continue;
|
|
4432
|
+
}
|
|
4433
|
+
if (out.length > 1)
|
|
4434
|
+
out.pop();
|
|
4435
|
+
else if (out.length === 1 && out[0] !== '')
|
|
4436
|
+
out[0] = out[0].includes(DYN) ? '' : '.';
|
|
4437
|
+
}
|
|
4438
|
+
return out.join('/') || '/';
|
|
4439
|
+
}
|
|
4407
4440
|
function sampleMatch(a, ctx, run, samples) {
|
|
4408
4441
|
const raw = a.pattern;
|
|
4409
4442
|
const lookalike = a.nonAscii;
|
|
@@ -4415,21 +4448,60 @@ function sampleMatch(a, ctx, run, samples) {
|
|
|
4415
4448
|
for (const c of ctx.cwds)
|
|
4416
4449
|
cands.push(`${(c || '/__unknown_cwd__').replace(/\/$/, '')}/${expanded}`);
|
|
4417
4450
|
for (const cand of cands) {
|
|
4418
|
-
const re = patternToRegex(cand.replace(/\/\.\//g, '/'), lookalike);
|
|
4451
|
+
const re = patternToRegex(collapseParents(cand.replace(/\/\.\//g, '/')), lookalike);
|
|
4419
4452
|
if (samples.some((s) => re.test(s) && isSecretPath(s, run)))
|
|
4420
4453
|
return true;
|
|
4421
4454
|
}
|
|
4422
4455
|
return false;
|
|
4423
4456
|
}
|
|
4424
4457
|
function cwdsList(ctx) { return ctx.cwds.map((c) => c || '/__unknown_cwd__'); }
|
|
4458
|
+
// `${P:-/proc/self/task/1/environ}`, `${P:=…}`, `${P:+…}`: whenever the name is unset (or set, for `+`) the word after the operator is the path, so the
|
|
4459
|
+
// word is also read with that default in place of the expansion (any other expansion in it stays dynamic). Null when no part has such a default.
|
|
4460
|
+
const PARAM_DEFAULT = /^[A-Za-z_][A-Za-z0-9_]*:?[-=+]([\s\S]*)$/;
|
|
4461
|
+
const NESTED_DEFAULT = /^\$\{[A-Za-z_][A-Za-z0-9_]*:?[-=+]([\s\S]*)$/;
|
|
4462
|
+
function parameterDefaultReading(a) {
|
|
4463
|
+
if (!a.word || a.word.slots.length === 0)
|
|
4464
|
+
return null;
|
|
4465
|
+
const parts = a.word.text.split('\u0000');
|
|
4466
|
+
let text = parts[0];
|
|
4467
|
+
let any = false;
|
|
4468
|
+
a.word.slots.forEach((slot, k) => {
|
|
4469
|
+
const m = slot.type === 'var' && slot.name ? PARAM_DEFAULT.exec(slot.name) : null;
|
|
4470
|
+
if (m)
|
|
4471
|
+
any = true;
|
|
4472
|
+
// The parser ends an expansion at its first `}`: a default that opens another (`${A:-${B:-/x}/y}`) is read as the innermost default, followed
|
|
4473
|
+
// by the text up to each closing brace it left after the slot.
|
|
4474
|
+
let inner = m ? m[1] : '';
|
|
4475
|
+
let depth = 0;
|
|
4476
|
+
for (let n = NESTED_DEFAULT.exec(inner); n && depth < 16; n = NESTED_DEFAULT.exec(inner)) {
|
|
4477
|
+
inner = n[1];
|
|
4478
|
+
depth += 1;
|
|
4479
|
+
}
|
|
4480
|
+
const word = m ? inner.replace(/^(["'])([\s\S]*)\1$/, '$2').replace(/\$\{[^}]*\}|\$\([^)]*\)|\$[A-Za-z_][A-Za-z0-9_]*|`[^`]*`/g, DYN) : DYN;
|
|
4481
|
+
let rest = parts[k + 1] || '';
|
|
4482
|
+
let tail = '';
|
|
4483
|
+
for (let level = 0; level < depth; level += 1) {
|
|
4484
|
+
const close = rest.indexOf('}');
|
|
4485
|
+
if (close < 0)
|
|
4486
|
+
break;
|
|
4487
|
+
tail += rest.slice(0, close);
|
|
4488
|
+
rest = rest.slice(close + 1);
|
|
4489
|
+
}
|
|
4490
|
+
text += `${word}${tail}${rest}`;
|
|
4491
|
+
});
|
|
4492
|
+
if (!any)
|
|
4493
|
+
return null;
|
|
4494
|
+
// No word: the reading is final (it is not read for defaults again).
|
|
4495
|
+
return text.includes(DYN) ? { text: null, alts: null, pattern: text, glob: a.glob, word: null, nonAscii: a.nonAscii } : { ...litArg(text), glob: a.glob };
|
|
4496
|
+
}
|
|
4425
4497
|
// S4: a secret path as an operand. Returns true on a stop.
|
|
4426
4498
|
// `history`: git reads the path from its object store (a pathspec after `--`, a `REV:path` object), so it
|
|
4427
4499
|
// need not exist in the work tree.
|
|
4428
4500
|
// `pattern`: the pattern of a search (the word in `grep -rn <word> src`), which names no file to read.
|
|
4429
|
-
function checkSecretOperand(a, ctx, run, reason, history = false, pattern = false) {
|
|
4501
|
+
function checkSecretOperand(a, ctx, run, reason, history = false, pattern = false, envOnly = false) {
|
|
4430
4502
|
if (a.text === null && a.alts && a.alts.length) {
|
|
4431
4503
|
for (const v of a.alts)
|
|
4432
|
-
if (checkSecretOperand({ ...litArg(v), glob: a.glob, word: a.word }, ctx, run, reason, history, pattern))
|
|
4504
|
+
if (checkSecretOperand({ ...litArg(v), glob: a.glob, word: a.word }, ctx, run, reason, history, pattern, envOnly))
|
|
4433
4505
|
return true;
|
|
4434
4506
|
return false;
|
|
4435
4507
|
}
|
|
@@ -4448,16 +4520,22 @@ function checkSecretOperand(a, ctx, run, reason, history = false, pattern = fals
|
|
|
4448
4520
|
const matches = expandGlob(text, ctx, run);
|
|
4449
4521
|
if (matches !== null) {
|
|
4450
4522
|
for (const mm of matches.slice(0, tables_1.LIMITS.maxGlobMatches))
|
|
4451
|
-
if (checkSecretOperand({ ...litArg(mm), glob: false }, ctx, run, reason, history, pattern))
|
|
4523
|
+
if (checkSecretOperand({ ...litArg(mm), glob: false }, ctx, run, reason, history, pattern, envOnly))
|
|
4452
4524
|
return true;
|
|
4453
4525
|
for (const k of linkNamesMatching(text, ctx, run))
|
|
4454
|
-
if (checkSecretOperand({ ...litArg(k), glob: false }, ctx, run, reason, history, pattern))
|
|
4526
|
+
if (checkSecretOperand({ ...litArg(k), glob: false }, ctx, run, reason, history, pattern, envOnly))
|
|
4455
4527
|
return true;
|
|
4456
4528
|
}
|
|
4457
4529
|
}
|
|
4458
4530
|
if (a.text === null || a.glob || a.nonAscii) {
|
|
4459
4531
|
const probe = a.text === null ? a : { ...a, pattern: text };
|
|
4460
|
-
|
|
4532
|
+
const samples = /[A-Za-z0-9._~/-]/.test(probe.pattern.replace(new RegExp(DYN, 'g'), '')) ? (envOnly ? envSamples(run, ctx) : (0, tables_1.secretSamples)(run.home, cwdsList(ctx))) : [];
|
|
4533
|
+
if (samples.length > 0 && sampleMatch(probe, ctx, run, samples)) {
|
|
4534
|
+
// A glob or dynamic path that can spell a process environment file also carries the env-dump reason (the keyed hook keeps only those).
|
|
4535
|
+
if (sampleMatch(probe, ctx, run, samples.filter((s) => env_dump_1.PROC_ENVIRON.test(s))))
|
|
4536
|
+
hit(run, 'S4', 'proc_environ');
|
|
4537
|
+
if (sampleMatch(probe, ctx, run, samples.filter((s) => secret_paths_1.APPROVAL_STATE_PATH.test(s))))
|
|
4538
|
+
hit(run, 'S4', secret_paths_1.APPROVAL_STATE_REASON);
|
|
4461
4539
|
return hit(run, 'S4', `${reason}_secret_pattern`);
|
|
4462
4540
|
}
|
|
4463
4541
|
if (underUnknown && a.text !== null && a.glob && /[*?[]/.test(text) && globMayEndWith(text, '.env', run.fold))
|
|
@@ -4473,12 +4551,20 @@ function checkSecretOperand(a, ctx, run, reason, history = false, pattern = fals
|
|
|
4473
4551
|
if (run.links.size > 0 && applyLinks(p, run) === '/__unknown_link__')
|
|
4474
4552
|
return hit(run, 'S4', `${reason}_unknown_link`);
|
|
4475
4553
|
const mustExist = !history && !(underUnknown || (!pattern && p.startsWith('/__unknown_cwd__/')));
|
|
4476
|
-
for (const r of realCandidates(p, run, true))
|
|
4477
|
-
if (isSecretPath(r, run, mustExist))
|
|
4478
|
-
|
|
4554
|
+
for (const r of realCandidates(p, run, true)) {
|
|
4555
|
+
if (!isSecretPath(r, run, mustExist) || (envOnly && (0, env_names_1.secretEnvBase)((0, env_names_1.lastSegment)(r)) === null))
|
|
4556
|
+
continue;
|
|
4557
|
+
if (env_dump_1.PROC_ENVIRON.test(r))
|
|
4558
|
+
hit(run, 'S4', 'proc_environ');
|
|
4559
|
+
if (secret_paths_1.APPROVAL_STATE_PATH.test(r))
|
|
4560
|
+
hit(run, 'S4', secret_paths_1.APPROVAL_STATE_REASON);
|
|
4561
|
+
return hit(run, 'S4', `${reason}_secret_path`);
|
|
4562
|
+
}
|
|
4479
4563
|
}
|
|
4480
4564
|
}
|
|
4481
|
-
|
|
4565
|
+
// Read last, so a stop the operand already had keeps its reason.
|
|
4566
|
+
const defaulted = a.text === null ? parameterDefaultReading(a) : null;
|
|
4567
|
+
return defaulted !== null && checkSecretOperand(defaulted, ctx, run, reason, history, pattern, envOnly);
|
|
4482
4568
|
}
|
|
4483
4569
|
// Whether some name the glob matches (shell rules: `*` and `?` stay inside one path segment) ends with `suffix`:
|
|
4484
4570
|
// a walk over (glob position, how much of the suffix the name ends with so far), trying the suffix characters
|
|
@@ -4842,7 +4928,11 @@ function isSecretDirOrAncestor(text, ctx, run) {
|
|
|
4842
4928
|
if (!abs)
|
|
4843
4929
|
return false;
|
|
4844
4930
|
const dirs = SECRET_DIRS.map((d) => path.posix.join(run.home, d));
|
|
4845
|
-
|
|
4931
|
+
const secretDir = abs.some((p) => realCandidates(p, run, true).some((r) => dirs.some((d) => isAncestorOrSelf(r, d) || isAncestorOrSelf(d, r))));
|
|
4932
|
+
// Every caller stops (S4) on true. A walk that reaches the host-approvals state file also carries the reason the keyed hook keeps.
|
|
4933
|
+
if (secretDir && reachesApprovalState(text, ctx, run))
|
|
4934
|
+
hit(run, 'S4', secret_paths_1.APPROVAL_STATE_REASON);
|
|
4935
|
+
return secretDir;
|
|
4846
4936
|
}
|
|
4847
4937
|
// `cat $(echo ~/.ssh/id_rsa)`, `cat $(ls ~/.ssh/id_*)`, `cat $(find ~/.ssh -name 'id_*')`: the path
|
|
4848
4938
|
// is produced by a substitution that the reader then opens.
|
|
@@ -4871,6 +4961,14 @@ function substitutionOperandCheck(prog, rest, ctx, run, subs) {
|
|
|
4871
4961
|
}
|
|
4872
4962
|
// grep -r, rg, ag and ack over a credential directory, or over a directory that holds one, print
|
|
4873
4963
|
// matches from the credential files inside it.
|
|
4964
|
+
// True when `text` names the host-approvals folder, or a folder that holds it (~/.marrow, the home, /): a search walking it reads the state file.
|
|
4965
|
+
function reachesApprovalState(text, ctx, run) {
|
|
4966
|
+
const abs = absCandidates(text, ctx, run);
|
|
4967
|
+
if (!abs)
|
|
4968
|
+
return false;
|
|
4969
|
+
const dir = path.posix.join(run.home, '.marrow', 'host-approvals');
|
|
4970
|
+
return abs.some((p) => realCandidates(p, run, true).some((r) => isAncestorOrSelf(run.fold ? r.toLowerCase() : r, run.fold ? dir.toLowerCase() : dir)));
|
|
4971
|
+
}
|
|
4874
4972
|
function recursiveSecretDirRead(prog, rest, ctx, run) {
|
|
4875
4973
|
const searchers = new Set(['grep', 'egrep', 'fgrep', 'zgrep', 'rg', 'ag', 'ack']);
|
|
4876
4974
|
if (!searchers.has(prog))
|
|
@@ -4924,6 +5022,11 @@ function genericOperandChecks(prog, args, ctx, run, subs = []) {
|
|
|
4924
5022
|
const abs = absCandidates(a.text, ctx, run);
|
|
4925
5023
|
if (!abs)
|
|
4926
5024
|
continue;
|
|
5025
|
+
if (reachesApprovalState(a.text, ctx, run)) {
|
|
5026
|
+
hit(run, 'S4', secret_paths_1.APPROVAL_STATE_REASON);
|
|
5027
|
+
hit(run, 'S4', 'copy_secret_directory');
|
|
5028
|
+
break;
|
|
5029
|
+
}
|
|
4927
5030
|
if (abs.some((p) => realCandidates(p, run, true).some((r) => secretDirs.some((d) => isAncestorOrSelf(r, d))))) {
|
|
4928
5031
|
hit(run, 'S4', 'copy_secret_directory');
|
|
4929
5032
|
break;
|
|
@@ -6083,6 +6186,17 @@ function targetDirFlag(rest) {
|
|
|
6083
6186
|
}
|
|
6084
6187
|
// ---------------------------------------------------------------------------------------------
|
|
6085
6188
|
// builtins
|
|
6189
|
+
// `cd /proc/$$`, `cd /proc/$(pgrep node)/task/*`, `cd $PID` from `/proc`: a target that is a process or thread id (a dynamic part or a glob) under
|
|
6190
|
+
// `/proc` is also read as `self`, so a later `cat environ` names the process file. The unknown folder stays possible, as before.
|
|
6191
|
+
function procFolderGuesses(target, ctx) {
|
|
6192
|
+
const spelled = target.text !== null ? target.text : target.pattern;
|
|
6193
|
+
if (spelled === '-')
|
|
6194
|
+
return [];
|
|
6195
|
+
const guess = (t) => t.split(DYN).join('self').replace(/\*+/g, 'self').replace(/\?/g, '1').replace(/\[[^\]]*\]/g, '1');
|
|
6196
|
+
if (spelled.startsWith('/'))
|
|
6197
|
+
return /^\/proc(?:\/|$)/.test(spelled) ? [path.posix.normalize(guess(spelled))] : [];
|
|
6198
|
+
return ctx.cwds.filter((c) => c !== null && /^\/proc(?:\/|$)/.test(c)).map((c) => path.posix.resolve(c, guess(spelled)));
|
|
6199
|
+
}
|
|
6086
6200
|
function changeDir(rest, ctx, run, subshell) {
|
|
6087
6201
|
const ops = nonOptions(rest);
|
|
6088
6202
|
const target = ops[0];
|
|
@@ -6091,7 +6205,7 @@ function changeDir(rest, ctx, run, subshell) {
|
|
|
6091
6205
|
return;
|
|
6092
6206
|
}
|
|
6093
6207
|
if (target.text === null || target.text === '-' || target.glob) {
|
|
6094
|
-
ctx.cwds = [...new Set([...ctx.cwds, null])];
|
|
6208
|
+
ctx.cwds = [...new Set([...ctx.cwds, ...procFolderGuesses(target, ctx), null])];
|
|
6095
6209
|
return;
|
|
6096
6210
|
}
|
|
6097
6211
|
const next = [];
|
|
@@ -6667,8 +6781,16 @@ function simpleWrapper(prog, rest, ctx, run, x) {
|
|
|
6667
6781
|
caffeinate: ['-t', '-w'], taskset: [], chrt: [], retry: ['-t', '--times', '-d', '--delay', '--until'], flock: ['-w', '--timeout', '-E', '--conflict-exit-code'],
|
|
6668
6782
|
};
|
|
6669
6783
|
const vf = new Set(valueFlags[prog] || []);
|
|
6670
|
-
|
|
6671
|
-
|
|
6784
|
+
// `command -v NAME` and `-V` only look a name up. They are options of `command`, so they end at the first word that is not one:
|
|
6785
|
+
// `command awk -v c=x '…'` runs awk with its own -v.
|
|
6786
|
+
if (prog === 'command') {
|
|
6787
|
+
for (const a of rest) {
|
|
6788
|
+
if (a.text === null || a.text === '--' || !a.text.startsWith('-'))
|
|
6789
|
+
break;
|
|
6790
|
+
if (a.text === '-v' || a.text === '-V')
|
|
6791
|
+
return;
|
|
6792
|
+
}
|
|
6793
|
+
}
|
|
6672
6794
|
while (i < rest.length) {
|
|
6673
6795
|
const t = rest[i].text;
|
|
6674
6796
|
if (t === null)
|
|
@@ -6863,6 +6985,10 @@ function xargsWrapper(prog, rest, ctx, run, x) {
|
|
|
6863
6985
|
xargsRunsBareEnv(inner, run);
|
|
6864
6986
|
if (inner.length === 0)
|
|
6865
6987
|
inner = [litArg('echo')];
|
|
6988
|
+
// A command that is not one of the exempt programs reads the names it is given (a dynamic command word too).
|
|
6989
|
+
const innerProg = progName(inner[0], run);
|
|
6990
|
+
if (innerProg === null || !tables_1.S4_EXEMPT_PROGRAMS.has(innerProg))
|
|
6991
|
+
xargsUpstreamEnv(x, ctx, run);
|
|
6866
6992
|
// Input lines become extra operands: unknown targets (spec A6). The -I replacement string stands
|
|
6867
6993
|
// for one input line wherever it appears.
|
|
6868
6994
|
let replaced = false;
|
|
@@ -6984,6 +7110,36 @@ function sshRemote(rest, ctx, run, x) {
|
|
|
6984
7110
|
else
|
|
6985
7111
|
dynamicCode({ text: null, alts: null, pattern: remoteCmd.map((a) => (a.text !== null ? a.text : a.pattern)).join(' '), glob: false, word: null, nonAscii: false }, remoteCtx, run, 'dynamic_remote_command');
|
|
6986
7112
|
}
|
|
7113
|
+
// `sh -c '"$@"' _ env`, `bash -c 'exec "$@"' x printenv`: the words after the script text are its positional parameters ($0 first), and a script whose
|
|
7114
|
+
// command word is an expansion runs them. `"$@"` / `$*` run the words after $0 as one command line, `$N` runs word N (alone or with the rest after it),
|
|
7115
|
+
// and a name (`for c; do "$c"; done`) may hold any of them. The mapper reads each such line; a dynamic word is left to the dynamic-command stop.
|
|
7116
|
+
function positionalCommands(code, positional, ctx, run) {
|
|
7117
|
+
if (code.text === null || positional.some((p) => p.text === null))
|
|
7118
|
+
return;
|
|
7119
|
+
const tokens = [...code.text.matchAll(new RegExp(env_dump_1.SHELL_RUNS_EXPANSION.source, 'g'))].map((m) => m[1]);
|
|
7120
|
+
if (tokens.length === 0)
|
|
7121
|
+
return;
|
|
7122
|
+
const words = positional.map((p) => p.text);
|
|
7123
|
+
const quoted = (list) => list.map((w) => `'${w.replace(/'/g, "'\\''")}'`).join(' ');
|
|
7124
|
+
const lines = new Set();
|
|
7125
|
+
for (const t of tokens) {
|
|
7126
|
+
if (t === '@' || t === '*')
|
|
7127
|
+
lines.add(quoted(words.slice(1)));
|
|
7128
|
+
else if (/[0-9]/.test(t)) {
|
|
7129
|
+
const at = Number(t);
|
|
7130
|
+
if (at < words.length) {
|
|
7131
|
+
lines.add(words[at]);
|
|
7132
|
+
lines.add(quoted(words.slice(at)));
|
|
7133
|
+
}
|
|
7134
|
+
}
|
|
7135
|
+
else
|
|
7136
|
+
for (const w of words.slice(1))
|
|
7137
|
+
lines.add(w);
|
|
7138
|
+
}
|
|
7139
|
+
for (const line of lines)
|
|
7140
|
+
if (line.trim() !== '')
|
|
7141
|
+
evalShell(line, childCtx(ctx), run);
|
|
7142
|
+
}
|
|
6987
7143
|
function handleShell(prog, rest, ctx, run, x) {
|
|
6988
7144
|
// `env BASH_ENV=f bash -c :`: a startup file named by a wrapper's assignment (audit N1).
|
|
6989
7145
|
for (const name of ['BASH_ENV', 'ENV']) {
|
|
@@ -7026,16 +7182,20 @@ function handleShell(prog, rest, ctx, run, x) {
|
|
|
7026
7182
|
if (/^[-+][a-zA-Z]+$/.test(t) && /[coO]/.test(t)) {
|
|
7027
7183
|
let used = 0;
|
|
7028
7184
|
let codeArg = null;
|
|
7185
|
+
let codeAt = -1;
|
|
7029
7186
|
for (const ch of t.slice(1)) {
|
|
7030
7187
|
if (ch === 'c' && t[0] === '-') {
|
|
7031
|
-
|
|
7188
|
+
codeAt = i + 1 + used;
|
|
7189
|
+
codeArg = rest[codeAt] || null;
|
|
7032
7190
|
used += 1;
|
|
7033
7191
|
}
|
|
7034
7192
|
else if (ch === 'o' || ch === 'O')
|
|
7035
7193
|
used += 1;
|
|
7036
7194
|
}
|
|
7037
|
-
if (codeArg)
|
|
7195
|
+
if (codeArg) {
|
|
7196
|
+
positionalCommands(codeArg, rest.slice(codeAt + 1), ctx, run);
|
|
7038
7197
|
return shellString(codeArg, withFeed(ctx, x, run), run, x.info.subs);
|
|
7198
|
+
}
|
|
7039
7199
|
i += used;
|
|
7040
7200
|
continue;
|
|
7041
7201
|
}
|
|
@@ -7043,6 +7203,7 @@ function handleShell(prog, rest, ctx, run, x) {
|
|
|
7043
7203
|
const code = rest[i + 1];
|
|
7044
7204
|
if (!code)
|
|
7045
7205
|
return;
|
|
7206
|
+
positionalCommands(code, rest.slice(i + 2), ctx, run);
|
|
7046
7207
|
// What this command's stdin carries (here-string, heredoc, pipe, enclosing group) reaches any
|
|
7047
7208
|
// reader inside the `-c` text.
|
|
7048
7209
|
return shellString(code, withFeed(ctx, x, run), run, x.info.subs);
|
|
@@ -7104,8 +7265,26 @@ function handleInterpreter(prog, rest, ctx, run, x) {
|
|
|
7104
7265
|
return;
|
|
7105
7266
|
}
|
|
7106
7267
|
}
|
|
7107
|
-
const
|
|
7108
|
-
scanInterpreterCode(
|
|
7268
|
+
const firstCode = code.text !== null ? code.text : argText(code);
|
|
7269
|
+
scanInterpreterCode(firstCode, run);
|
|
7270
|
+
// A repeated code flag (`perl -e 'print 1' -e 'system(shift)' env`) runs every piece: each is read like the first, and a dynamic one is code
|
|
7271
|
+
// nobody can read.
|
|
7272
|
+
let codeText = firstCode;
|
|
7273
|
+
for (let j = i + 2; j < rest.length; j += 1) {
|
|
7274
|
+
const flag = rest[j].text;
|
|
7275
|
+
if (flag === null || !codeRe.test(flag) || !rest[j + 1])
|
|
7276
|
+
continue;
|
|
7277
|
+
const more = rest[j + 1];
|
|
7278
|
+
if (more.text === null && !more.alts) {
|
|
7279
|
+
worseParse(run, 'dynamic');
|
|
7280
|
+
tripwire(run.topSource, run);
|
|
7281
|
+
hit(run, 'Sx', 'dynamic_interpreter_code');
|
|
7282
|
+
continue;
|
|
7283
|
+
}
|
|
7284
|
+
const moreText = more.text !== null ? more.text : argText(more);
|
|
7285
|
+
scanInterpreterCode(moreText, run);
|
|
7286
|
+
codeText = `${codeText}\n${moreText}`;
|
|
7287
|
+
}
|
|
7109
7288
|
// Code that reads its own stdin and runs what it reads: the stdin text is a command too.
|
|
7110
7289
|
// Code that can start a process passes its stdin on to it, whether or not it reads it itself.
|
|
7111
7290
|
if (tables_1.INTERPRETER_EXEC.test(codeText))
|
|
@@ -7119,6 +7298,16 @@ function handleInterpreter(prog, rest, ctx, run, x) {
|
|
|
7119
7298
|
if (extra.text !== null && !extra.text.startsWith('-'))
|
|
7120
7299
|
evalShell(extra.text, childCtx(ctx), run);
|
|
7121
7300
|
}
|
|
7301
|
+
else if (env_dump_1.CODE_STARTS_COMMAND.test(codeText)) {
|
|
7302
|
+
// Code that starts a command and takes its name some other way (`system(shift)`, `os.execvp(sys.argv[1], …)`, `passthru($argv[1])`,
|
|
7303
|
+
// `os.execute(arg[1])`): an environment printer among the arguments is that command.
|
|
7304
|
+
for (const extra of rest.slice(i + 2))
|
|
7305
|
+
if (extra.text !== null && (0, env_dump_1.mayRunEnvPrinter)(extra.text))
|
|
7306
|
+
evalShell(extra.text, childCtx(ctx), run);
|
|
7307
|
+
}
|
|
7308
|
+
// …or takes it from its environment: a value set on this command line (`C=env perl -e 'system $ENV{C}'`).
|
|
7309
|
+
if (env_dump_1.CODE_STARTS_COMMAND.test(codeText) && env_dump_1.CODE_READS_ENVIRONMENT.test(codeText))
|
|
7310
|
+
assignedEnvPrinters(x, ctx, run);
|
|
7122
7311
|
return;
|
|
7123
7312
|
}
|
|
7124
7313
|
if ((prog === 'deno' || prog === 'bun') && (t === 'run' || t === 'repl'))
|
|
@@ -7242,13 +7431,17 @@ function envDumpChecks(prog, rest, ctx, run, x) {
|
|
|
7242
7431
|
const text = a.text !== null ? a.text : a.pattern.split(DYN).join('x');
|
|
7243
7432
|
if (!text.startsWith('/') && !text.includes('proc'))
|
|
7244
7433
|
continue;
|
|
7245
|
-
|
|
7246
|
-
if (candidates.some((p) => env_dump_1.PROC_ENVIRON.test(p) || (0, env_dump_1.procGlobMatchesEnviron)(p) || (env_dump_1.RECURSIVE_READERS.has(prog) && env_dump_1.PROC_PROCESS_DIRECTORY.test(p)))) {
|
|
7434
|
+
if (namesProcEnviron(a.text !== null ? absCandidates(a.text, ctx, run) || [] : [text], env_dump_1.RECURSIVE_READERS.has(prog) ? env_dump_1.PROC_PROCESS_DIRECTORY : null)) {
|
|
7247
7435
|
hit(run, 'S4', 'proc_environ');
|
|
7248
7436
|
return;
|
|
7249
7437
|
}
|
|
7250
7438
|
}
|
|
7251
7439
|
}
|
|
7440
|
+
// A path that is, or can spell, a process environment file; for a program that reads every file under a folder, a process folder too.
|
|
7441
|
+
function namesProcEnviron(candidates, folder) {
|
|
7442
|
+
return candidates.map((p) => collapseParents(p.length > 1 ? p.replace(/\/+$/, '') : p))
|
|
7443
|
+
.some((p) => env_dump_1.PROC_ENVIRON.test(p) || (0, env_dump_1.procGlobMatchesEnviron)(p) || (folder !== null && folder.test(p)));
|
|
7444
|
+
}
|
|
7252
7445
|
// `xargs env`, `parallel env ::: x`: the command is `env` or `printenv` with only options and assignments, so it prints the environment.
|
|
7253
7446
|
function xargsRunsBareEnv(words, run) {
|
|
7254
7447
|
const b = words[0] && words[0].text !== null ? progName(words[0], run) : null;
|
|
@@ -7296,60 +7489,793 @@ const AWK_RUNS_ARGV = /\bsystem\s*\(|\|\s*&?\s*["'$a-zA-Z(]|\bgetline\b/;
|
|
|
7296
7489
|
function awkArgvCommands(text, rest, ctx, run, x) {
|
|
7297
7490
|
if (AWK_RUNS_ARGV.test(text) && /\b(?:ARGV|ARGC)\b/.test(text) && !ctx.backstop)
|
|
7298
7491
|
scanArgvForCommands(rest, ctx, run, x);
|
|
7299
|
-
|
|
7300
|
-
|
|
7301
|
-
|
|
7302
|
-
|
|
7303
|
-
|
|
7304
|
-
|
|
7305
|
-
|
|
7306
|
-
|
|
7307
|
-
|
|
7308
|
-
|
|
7309
|
-
|
|
7310
|
-
|
|
7311
|
-
|
|
7312
|
-
|
|
7313
|
-
|
|
7492
|
+
if (env_dump_1.AWK_STARTS_COMMAND.test(text)) {
|
|
7493
|
+
awkEnvPrinterWords(text, rest, ctx, run);
|
|
7494
|
+
if (env_dump_1.CODE_READS_ENVIRONMENT.test(text))
|
|
7495
|
+
assignedEnvPrinters(x, ctx, run);
|
|
7496
|
+
}
|
|
7497
|
+
}
|
|
7498
|
+
// A value assigned on the command line itself (`X=env awk 'BEGIN{system(ENVIRON["X"])}'`) reaches code that reads its environment: a value that is
|
|
7499
|
+
// the environment printer, or a command line that holds it, is a command that code may run.
|
|
7500
|
+
function assignedEnvPrinters(x, ctx, run) {
|
|
7501
|
+
for (const values of x.env.values())
|
|
7502
|
+
if (values !== null)
|
|
7503
|
+
for (const v of values)
|
|
7504
|
+
if ((0, env_dump_1.mayRunEnvPrinter)(v))
|
|
7505
|
+
evalShell(v, childCtx(ctx), run);
|
|
7506
|
+
}
|
|
7507
|
+
// A program that can start a command may run any word of its command line: an ARGV word, a `-v NAME=VALUE` or `NAME=VALUE` value. A word that
|
|
7508
|
+
// is the environment printer (`env`, `printenv`, alone or with options) is that command, even by itself (`awk 'BEGIN{system(ARGV[1])}' env`).
|
|
7509
|
+
function awkEnvPrinterWords(program, rest, ctx, run) {
|
|
7510
|
+
for (const a of rest) {
|
|
7511
|
+
// The program text is code (it may say `env` in a string), not a word it runs.
|
|
7512
|
+
if (a.text === null || a.text === program)
|
|
7513
|
+
continue;
|
|
7514
|
+
const m = /^(?:-v|--assign=)?(?:[A-Za-z_][A-Za-z0-9_]*=)?([\s\S]*)$/.exec(a.text);
|
|
7515
|
+
const value = a.text.startsWith('-') && !/^-v[\s\S]|^--assign=/.test(a.text) ? '' : (m ? m[1] : a.text);
|
|
7516
|
+
if ((0, env_dump_1.mayRunEnvPrinter)(value))
|
|
7517
|
+
evalShell(value, childCtx(ctx), run);
|
|
7518
|
+
}
|
|
7519
|
+
}
|
|
7520
|
+
// awk's own options, in exact spellings only (longopts.ts): gawk 5.2, mawk 1.3.4, BusyBox awk and the one-true-awk. A long option that is
|
|
7521
|
+
// shortened, unknown or misspelled is not on the list and stops the run; so does a short letter that is not one of these.
|
|
7522
|
+
const AWK_LONG = (0, longopts_1.longList)('file= field-separator= assign= include= load= source= exec= debug[=] dump-variables[=] lint[=] pretty-print[=] profile[=] persist[=] ' +
|
|
7523
|
+
'traditional characters-as-bytes copyright gen-pot help trace bignum use-lc-numeric non-decimal-data optimize no-optimize posix ' +
|
|
7524
|
+
're-interval sandbox lint-old version csv usage');
|
|
7525
|
+
const AWK_SHORT_PLAIN = 'bcCghIMnNOPrsStV';
|
|
7526
|
+
const AWK_SHORT_OPTIONAL = 'dDLop';
|
|
7527
|
+
// `-W NAME` (mawk, and gawk's spelling of its long options): only these are known, the rest (`-W source=…`, `-W exec=…`) are unknown options.
|
|
7528
|
+
const AWK_W_KNOWN = /^(?:v(?:ersion)?|u(?:sage)?|h(?:elp)?|d(?:ump)?|i(?:nteractive)?|p(?:osix_space)?|r(?:andom)?=\d+|s(?:printf)?=\d+|posix|traditional|lint|lint-old|copyright|re-interval|non-decimal-data|optimize|no-optimize|sandbox|bignum|characters-as-bytes|use-lc-numeric|gen-pot|csv)$/;
|
|
7529
|
+
const AWK_REGEX_AFTER = new Set(['print', 'printf', 'return', 'else', 'do', 'in', 'delete', 'getline', 'case']);
|
|
7530
|
+
const AWK_OPS = ['**=', '|&', '&&', '||', '==', '<=', '>=', '!=', '!~', '++', '--', '+=', '-=', '*=', '/=', '%=', '^=', '**', '>>'];
|
|
7531
|
+
const AWK_ESCAPES = { n: '\n', t: '\t', r: '\r', a: '\x07', b: '\b', f: '\f', v: '\v', '\\': '\\', '"': '"', '/': '/' };
|
|
7532
|
+
// The tokens of an awk program, or null when it cannot be cut into tokens (an unterminated string or regular expression). A `/` starts a regular
|
|
7533
|
+
// expression where an operand is expected and divides after one. awks disagree about a `/` inside a bracket expression (`/[/]/`), so the program is
|
|
7534
|
+
// cut twice, once either way, and the commands of both cuttings are read.
|
|
7535
|
+
function awkTokens(src, bracketAware) {
|
|
7536
|
+
const out = [];
|
|
7537
|
+
const n = src.length;
|
|
7538
|
+
let i = 0;
|
|
7539
|
+
while (i < n) {
|
|
7540
|
+
const c = src[i];
|
|
7541
|
+
if (c === ' ' || c === '\t' || c === '\r' || c === '\f' || c === '\v') {
|
|
7542
|
+
i += 1;
|
|
7543
|
+
continue;
|
|
7544
|
+
}
|
|
7545
|
+
if (c === '\\' && (src[i + 1] === '\n' || (src[i + 1] === '\r' && src[i + 2] === '\n'))) {
|
|
7546
|
+
i += src[i + 1] === '\n' ? 2 : 3;
|
|
7547
|
+
continue;
|
|
7548
|
+
}
|
|
7549
|
+
if (c === '\n') {
|
|
7550
|
+
out.push({ k: 'nl', v: c });
|
|
7551
|
+
i += 1;
|
|
7552
|
+
continue;
|
|
7553
|
+
}
|
|
7554
|
+
if (c === '#') {
|
|
7555
|
+
while (i < n && src[i] !== '\n')
|
|
7556
|
+
i += 1;
|
|
7557
|
+
continue;
|
|
7558
|
+
}
|
|
7559
|
+
if (c === '"') {
|
|
7560
|
+
let j = i + 1;
|
|
7561
|
+
let v = '';
|
|
7562
|
+
for (;;) {
|
|
7563
|
+
const ch = src[j];
|
|
7564
|
+
if (ch === undefined || ch === '\n')
|
|
7565
|
+
return null;
|
|
7566
|
+
if (ch === '"')
|
|
7567
|
+
break;
|
|
7568
|
+
if (ch !== '\\') {
|
|
7569
|
+
v += ch;
|
|
7570
|
+
j += 1;
|
|
7571
|
+
continue;
|
|
7572
|
+
}
|
|
7573
|
+
const e = src[j + 1];
|
|
7574
|
+
if (e === undefined)
|
|
7575
|
+
return null;
|
|
7576
|
+
j += 2;
|
|
7577
|
+
if (e === '\n')
|
|
7578
|
+
continue;
|
|
7579
|
+
if (Object.prototype.hasOwnProperty.call(AWK_ESCAPES, e))
|
|
7580
|
+
v += AWK_ESCAPES[e];
|
|
7581
|
+
else if (e >= '0' && e <= '7') {
|
|
7582
|
+
let d = e;
|
|
7583
|
+
while (d.length < 3 && src[j] !== undefined && src[j] >= '0' && src[j] <= '7') {
|
|
7584
|
+
d += src[j];
|
|
7585
|
+
j += 1;
|
|
7314
7586
|
}
|
|
7315
|
-
|
|
7316
|
-
worseParse(run, 'dynamic');
|
|
7317
|
-
hit(run, 'Sx', 'dynamic_stdin_code');
|
|
7587
|
+
v += String.fromCharCode(parseInt(d, 8) & 0xff);
|
|
7318
7588
|
}
|
|
7319
|
-
|
|
7589
|
+
else if (e === 'x' && /^[0-9a-fA-F]/.test(src.slice(j, j + 1))) {
|
|
7590
|
+
const hex = /^[0-9a-fA-F]{1,2}/.exec(src.slice(j, j + 2));
|
|
7591
|
+
v += String.fromCharCode(parseInt(hex[0], 16));
|
|
7592
|
+
j += hex[0].length;
|
|
7593
|
+
}
|
|
7594
|
+
else if (e === 'u' && /^[0-9a-fA-F]/.test(src.slice(j, j + 1))) {
|
|
7595
|
+
// gawk 5.3 reads `\u` and up to eight hex digits as that code point. A run of up to four is the same under every reading; a longer one is
|
|
7596
|
+
// read as four or as all of them, so it is a text nobody can read here (a mark no literal reading accepts).
|
|
7597
|
+
const digits = /^[0-9a-fA-F]{1,8}/.exec(src.slice(j, j + 8))[0];
|
|
7598
|
+
v += digits.length <= 4 ? String.fromCharCode(parseInt(digits, 16)) : DYN;
|
|
7599
|
+
j += digits.length;
|
|
7600
|
+
}
|
|
7601
|
+
else
|
|
7602
|
+
v += `\\${e}`;
|
|
7603
|
+
}
|
|
7604
|
+
out.push({ k: 'str', v });
|
|
7605
|
+
i = j + 1;
|
|
7606
|
+
continue;
|
|
7607
|
+
}
|
|
7608
|
+
const prev = out.length ? out[out.length - 1] : null;
|
|
7609
|
+
const operandBefore = prev !== null && (prev.k === 'str' || prev.k === 'num' || prev.k === 'rx' || (prev.k === 'id' && !AWK_REGEX_AFTER.has(prev.v))
|
|
7610
|
+
|| (prev.k === 'op' && (prev.v === ')' || prev.v === ']' || prev.v === '++' || prev.v === '--')));
|
|
7611
|
+
if (c === '/' && !operandBefore) {
|
|
7612
|
+
let j = i + 1;
|
|
7613
|
+
let inBracket = false;
|
|
7614
|
+
for (;;) {
|
|
7615
|
+
const ch = src[j];
|
|
7616
|
+
if (ch === undefined || ch === '\n')
|
|
7617
|
+
return null;
|
|
7618
|
+
if (ch === '\\') {
|
|
7619
|
+
j += 2;
|
|
7620
|
+
continue;
|
|
7621
|
+
}
|
|
7622
|
+
if (bracketAware && !inBracket && ch === '[') {
|
|
7623
|
+
inBracket = true;
|
|
7624
|
+
j += 1;
|
|
7625
|
+
if (src[j] === '^')
|
|
7626
|
+
j += 1;
|
|
7627
|
+
if (src[j] === ']')
|
|
7628
|
+
j += 1;
|
|
7629
|
+
continue;
|
|
7630
|
+
}
|
|
7631
|
+
if (bracketAware && inBracket && ch === ']') {
|
|
7632
|
+
inBracket = false;
|
|
7633
|
+
j += 1;
|
|
7634
|
+
continue;
|
|
7635
|
+
}
|
|
7636
|
+
if (ch === '/' && !inBracket)
|
|
7637
|
+
break;
|
|
7638
|
+
j += 1;
|
|
7639
|
+
}
|
|
7640
|
+
out.push({ k: 'rx', v: src.slice(i + 1, j) });
|
|
7641
|
+
i = j + 1;
|
|
7642
|
+
continue;
|
|
7643
|
+
}
|
|
7644
|
+
const num = /^(?:0[xX][0-9a-fA-F]+|(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?)/.exec(src.slice(i, i + 64));
|
|
7645
|
+
if (num && (c >= '0' && c <= '9' || (c === '.' && src[i + 1] >= '0' && src[i + 1] <= '9'))) {
|
|
7646
|
+
out.push({ k: 'num', v: num[0] });
|
|
7647
|
+
i += num[0].length;
|
|
7648
|
+
continue;
|
|
7649
|
+
}
|
|
7650
|
+
const id = /^[A-Za-z_][A-Za-z0-9_]*(?:::[A-Za-z_][A-Za-z0-9_]*)?/.exec(src.slice(i, i + 256));
|
|
7651
|
+
if (id) {
|
|
7652
|
+
out.push({ k: 'id', v: id[0] });
|
|
7653
|
+
i += id[0].length;
|
|
7654
|
+
continue;
|
|
7655
|
+
}
|
|
7656
|
+
const op = AWK_OPS.find((o) => src.startsWith(o, i));
|
|
7657
|
+
if (op) {
|
|
7658
|
+
out.push({ k: 'op', v: op });
|
|
7659
|
+
i += op.length;
|
|
7660
|
+
continue;
|
|
7661
|
+
}
|
|
7662
|
+
out.push({ k: 'op', v: c });
|
|
7663
|
+
i += 1;
|
|
7664
|
+
}
|
|
7665
|
+
return out;
|
|
7666
|
+
}
|
|
7667
|
+
const AWK_SPECIAL_VARS = new Set(['ARGC', 'ARGIND', 'ARGV', 'BINMODE', 'CONVFMT', 'ENVIRON', 'ERRNO', 'FIELDWIDTHS', 'FILENAME', 'FNR', 'FPAT', 'FS', 'FUNCTAB', 'IGNORECASE', 'LINT',
|
|
7668
|
+
'NF', 'NR', 'OFMT', 'OFS', 'ORS', 'PREC', 'PROCINFO', 'RLENGTH', 'ROUNDMODE', 'RS', 'RSTART', 'RT', 'SUBSEP', 'SYMTAB', 'TEXTDOMAIN']);
|
|
7669
|
+
// Calls that store into an argument: every name inside them is a variable that may hold anything.
|
|
7670
|
+
const AWK_WRITERS = new Set(['split', 'sub', 'gsub', 'patsplit', 'asort', 'asorti', 'match']);
|
|
7671
|
+
const AWK_BOUND = 400;
|
|
7672
|
+
const AWK_ASSIGN_OP = /^(?:[-+*/%^]|\*\*)?=$/;
|
|
7673
|
+
// Where a command is named: the statement or group of tokens that follows `from` (an output pipe's command, a variable's value) or precedes it (the
|
|
7674
|
+
// command of `CMD | getline`). A token cap ends a statement that is never closed; it leaves a mark that no literal reading accepts.
|
|
7675
|
+
function awkAfter(t, from, comma) {
|
|
7676
|
+
const out = [];
|
|
7677
|
+
let depth = 0;
|
|
7678
|
+
for (let j = from; j < t.length; j += 1) {
|
|
7679
|
+
const k = t[j];
|
|
7680
|
+
if (k.k === 'nl') {
|
|
7681
|
+
if (depth === 0)
|
|
7682
|
+
break;
|
|
7683
|
+
continue;
|
|
7684
|
+
}
|
|
7685
|
+
if (k.k === 'op') {
|
|
7686
|
+
if (k.v === '(')
|
|
7687
|
+
depth += 1;
|
|
7688
|
+
else if (k.v === ')') {
|
|
7689
|
+
if (depth === 0)
|
|
7690
|
+
break;
|
|
7691
|
+
depth -= 1;
|
|
7692
|
+
}
|
|
7693
|
+
else if (depth === 0 && (k.v === ';' || k.v === '}' || (comma && k.v === ',')))
|
|
7694
|
+
break;
|
|
7695
|
+
}
|
|
7696
|
+
out.push(k);
|
|
7697
|
+
if (out.length >= AWK_BOUND) {
|
|
7698
|
+
out.push({ k: 'op', v: '?' });
|
|
7699
|
+
break;
|
|
7700
|
+
}
|
|
7701
|
+
}
|
|
7702
|
+
return out;
|
|
7703
|
+
}
|
|
7704
|
+
function awkBefore(t, from, comma) {
|
|
7705
|
+
const out = [];
|
|
7706
|
+
let depth = 0;
|
|
7707
|
+
for (let j = from - 1; j >= 0; j -= 1) {
|
|
7708
|
+
const k = t[j];
|
|
7709
|
+
if (k.k === 'nl') {
|
|
7710
|
+
if (depth === 0)
|
|
7711
|
+
break;
|
|
7712
|
+
continue;
|
|
7713
|
+
}
|
|
7714
|
+
if (k.k === 'op') {
|
|
7715
|
+
if (k.v === ')')
|
|
7716
|
+
depth += 1;
|
|
7717
|
+
else if (k.v === '(') {
|
|
7718
|
+
if (depth === 0)
|
|
7719
|
+
break;
|
|
7720
|
+
depth -= 1;
|
|
7721
|
+
}
|
|
7722
|
+
else if (depth === 0 && (k.v === ';' || k.v === '{' || k.v === '}' || (comma && k.v === ',') || k.v === '&&' || k.v === '||' || k.v === '?' || k.v === ':' || AWK_ASSIGN_OP.test(k.v)))
|
|
7723
|
+
break;
|
|
7724
|
+
}
|
|
7725
|
+
out.push(k);
|
|
7726
|
+
if (out.length >= AWK_BOUND) {
|
|
7727
|
+
out.push({ k: 'op', v: '?' });
|
|
7728
|
+
break;
|
|
7729
|
+
}
|
|
7730
|
+
}
|
|
7731
|
+
return out.reverse();
|
|
7732
|
+
}
|
|
7733
|
+
// The tokens between the parenthesis at `open` and its match.
|
|
7734
|
+
function awkInside(t, open) {
|
|
7735
|
+
const out = [];
|
|
7736
|
+
let depth = 0;
|
|
7737
|
+
for (let j = open; j < t.length; j += 1) {
|
|
7738
|
+
const k = t[j];
|
|
7739
|
+
if (k.k === 'op' && k.v === '(') {
|
|
7740
|
+
depth += 1;
|
|
7741
|
+
if (depth === 1)
|
|
7320
7742
|
continue;
|
|
7743
|
+
}
|
|
7744
|
+
if (k.k === 'op' && k.v === ')') {
|
|
7745
|
+
depth -= 1;
|
|
7746
|
+
if (depth === 0)
|
|
7747
|
+
return out;
|
|
7748
|
+
}
|
|
7749
|
+
if (k.k === 'nl')
|
|
7750
|
+
continue;
|
|
7751
|
+
out.push(k);
|
|
7752
|
+
if (out.length >= AWK_BOUND)
|
|
7753
|
+
break;
|
|
7754
|
+
}
|
|
7755
|
+
out.push({ k: 'op', v: '?' });
|
|
7756
|
+
return out;
|
|
7757
|
+
}
|
|
7758
|
+
function awkFacts(t, taintedNames, all) {
|
|
7759
|
+
const facts = { sinks: [], assigns: new Map(), tainted: new Set(taintedNames), all, indirect: false };
|
|
7760
|
+
const nextOp = (i, re) => t[i + 1] !== undefined && t[i + 1].k === 'op' && re.test(t[i + 1].v);
|
|
7761
|
+
for (let i = 0; i < t.length; i += 1) {
|
|
7762
|
+
const k = t[i];
|
|
7763
|
+
const nx = t[i + 1];
|
|
7764
|
+
const pv = i > 0 ? t[i - 1] : null;
|
|
7765
|
+
if (k.k === 'op') {
|
|
7766
|
+
if (k.v === '@' && nx && nx.k === 'id' && !/^(?:include|load|namespace)$/.test(nx.v))
|
|
7767
|
+
facts.indirect = true;
|
|
7768
|
+
if (k.v === '|' || k.v === '|&') {
|
|
7769
|
+
if (nx && nx.k === 'id' && nx.v === 'getline')
|
|
7770
|
+
facts.sinks.push({ command: awkBefore(t, i, true) });
|
|
7771
|
+
else {
|
|
7772
|
+
// `print LIST | CMD`: LIST is back to the `print` or `printf` of this statement.
|
|
7773
|
+
const before = awkBefore(t, i, false);
|
|
7774
|
+
const at = before.findIndex((b) => b.k === 'id' && (b.v === 'print' || b.v === 'printf'));
|
|
7775
|
+
facts.sinks.push({ command: awkAfter(t, i + 1, false), printed: at === -1 ? null : before.slice(at + 1), printf: at !== -1 && before[at].v === 'printf' });
|
|
7776
|
+
}
|
|
7321
7777
|
}
|
|
7322
|
-
|
|
7323
|
-
|
|
7324
|
-
|
|
7325
|
-
|
|
7326
|
-
|
|
7327
|
-
|
|
7328
|
-
|
|
7778
|
+
continue;
|
|
7779
|
+
}
|
|
7780
|
+
if (k.k !== 'id')
|
|
7781
|
+
continue;
|
|
7782
|
+
if (k.v === 'system') {
|
|
7783
|
+
facts.sinks.push({ command: nx && nx.k === 'op' && nx.v === '(' ? awkInside(t, i + 1) : [{ k: 'op', v: '?' }] });
|
|
7784
|
+
continue;
|
|
7785
|
+
}
|
|
7786
|
+
if ((k.v === 'function' || k.v === 'func') && t[i + 2] && t[i + 2].k === 'op' && t[i + 2].v === '(') {
|
|
7787
|
+
for (const p of awkInside(t, i + 2))
|
|
7788
|
+
if (p.k === 'id')
|
|
7789
|
+
facts.tainted.add(p.v);
|
|
7790
|
+
continue;
|
|
7791
|
+
}
|
|
7792
|
+
if (AWK_WRITERS.has(k.v) && nx && nx.k === 'op' && nx.v === '(') {
|
|
7793
|
+
for (const p of awkInside(t, i + 1))
|
|
7794
|
+
if (p.k === 'id')
|
|
7795
|
+
facts.tainted.add(p.v);
|
|
7796
|
+
continue;
|
|
7797
|
+
}
|
|
7798
|
+
if (k.v === 'getline') {
|
|
7799
|
+
// `getline NAME`, `getline NAME < FILE`, `CMD | getline NAME`: NAME holds what was read.
|
|
7800
|
+
if (nx && nx.k === 'id')
|
|
7801
|
+
facts.tainted.add(nx.v);
|
|
7802
|
+
continue;
|
|
7803
|
+
}
|
|
7804
|
+
if ((pv && pv.k === 'op' && (pv.v === '++' || pv.v === '--')) || nextOp(i, /^(?:[-+*/%^]|\*\*)=$|^\+\+$|^--$|^\[$/) || (nx && nx.k === 'id' && nx.v === 'in')) {
|
|
7805
|
+
facts.tainted.add(k.v);
|
|
7806
|
+
continue;
|
|
7807
|
+
}
|
|
7808
|
+
if (nextOp(i, /^=$/) && !(pv && pv.k === 'op' && pv.v === '$')) {
|
|
7809
|
+
const list = facts.assigns.get(k.v) || [];
|
|
7810
|
+
list.push(awkAfter(t, i + 2, true));
|
|
7811
|
+
facts.assigns.set(k.v, list);
|
|
7812
|
+
}
|
|
7813
|
+
}
|
|
7814
|
+
return facts;
|
|
7815
|
+
}
|
|
7816
|
+
// The values a run of tokens can have when it holds only string literals, numbers and variables that are set only from such values (a variable may
|
|
7817
|
+
// also still be unset): one list of values per word of the run, or null when any part is not a literal. An unset or tainted variable, a field, ARGV,
|
|
7818
|
+
// ENVIRON, an array element and the result of any call (sprintf, substr, tolower, a function of the program) are not literals.
|
|
7819
|
+
function awkWords(tokens, facts, stack) {
|
|
7820
|
+
const words = [];
|
|
7821
|
+
for (const k of tokens) {
|
|
7822
|
+
if (k.k === 'op' && (k.v === '(' || k.v === ')'))
|
|
7823
|
+
continue;
|
|
7824
|
+
if (k.k === 'str' || k.k === 'num') {
|
|
7825
|
+
words.push([k.v]);
|
|
7826
|
+
continue;
|
|
7827
|
+
}
|
|
7828
|
+
if (k.k !== 'id')
|
|
7829
|
+
return null;
|
|
7830
|
+
const values = awkVariable(k.v, facts, stack);
|
|
7831
|
+
if (values === null)
|
|
7832
|
+
return null;
|
|
7833
|
+
words.push(values);
|
|
7834
|
+
}
|
|
7835
|
+
return words;
|
|
7836
|
+
}
|
|
7837
|
+
function awkVariable(name, facts, stack) {
|
|
7838
|
+
const written = facts.assigns.get(name);
|
|
7839
|
+
if (facts.all || AWK_SPECIAL_VARS.has(name) || facts.tainted.has(name) || !written || stack.has(name))
|
|
7840
|
+
return null;
|
|
7841
|
+
stack.add(name);
|
|
7842
|
+
const values = new Set(['']);
|
|
7843
|
+
try {
|
|
7844
|
+
for (const value of written) {
|
|
7845
|
+
const words = awkWords(value, facts, stack);
|
|
7846
|
+
const joined = words === null ? null : awkJoin(words, 0, words.length);
|
|
7847
|
+
if (joined === null)
|
|
7848
|
+
return null;
|
|
7849
|
+
for (const v of joined)
|
|
7850
|
+
values.add(v);
|
|
7851
|
+
if (values.size > 32)
|
|
7852
|
+
return null;
|
|
7853
|
+
}
|
|
7854
|
+
}
|
|
7855
|
+
finally {
|
|
7856
|
+
stack.delete(name);
|
|
7857
|
+
}
|
|
7858
|
+
return [...values];
|
|
7859
|
+
}
|
|
7860
|
+
// Every text that words[from..to) can spell, or null past 64 of them.
|
|
7861
|
+
function awkJoin(words, from, to) {
|
|
7862
|
+
let out = [''];
|
|
7863
|
+
for (let i = from; i < to; i += 1) {
|
|
7864
|
+
if (out.length * words[i].length > 64)
|
|
7865
|
+
return null;
|
|
7866
|
+
out = out.flatMap((head) => words[i].map((w) => head + w));
|
|
7867
|
+
}
|
|
7868
|
+
return out;
|
|
7869
|
+
}
|
|
7870
|
+
// What a program may print into without the words it prints being run: a command that only reads its input and writes it out. A fail-closed list: any other
|
|
7871
|
+
// command (a shell by any name or wrapper, an interpreter, `env`, `xargs`, a program nobody knows) may run what it is given, so the words printed into it are
|
|
7872
|
+
// judged as the commands they would be. A bare name or one in a system bin folder, plain words and plain redirects only; `sort --compress-program=PROG` and
|
|
7873
|
+
// `less +!CMD` run a program of their own.
|
|
7874
|
+
const AWK_READERS = new Set(['cat', 'tee', 'sort', 'uniq', 'wc', 'grep', 'egrep', 'fgrep', 'head', 'tail', 'less', 'more', 'cut', 'tr', 'column', 'nl', 'rev', 'fold', 'paste', 'tac']);
|
|
7875
|
+
function awkReaderSink(text) {
|
|
7876
|
+
if (text.trim() === '')
|
|
7877
|
+
return true;
|
|
7878
|
+
const parsed = safeParse(text, { depth: 1 });
|
|
7879
|
+
if (!parsed.ok || parsed.dynamic || parsed.reasons.length > 0 || parsed.substitutions.length > 0 || parsed.commands.length === 0 || parsed.commands.length > 8)
|
|
7880
|
+
return false;
|
|
7881
|
+
const plain = (w) => !w.dynamic && !w.substitution && !w.glob;
|
|
7882
|
+
return parsed.commands.every((c) => {
|
|
7883
|
+
if (c.words.length === 0 || c.assignments.length > 0 || c.heredoc !== null || c.forLoop !== undefined || !c.words.every(plain))
|
|
7884
|
+
return false;
|
|
7885
|
+
// A command word that starts with `-` names no program (a command variable that may still be unset reads ` -u`).
|
|
7886
|
+
if (c.words[0].text.startsWith('-'))
|
|
7887
|
+
return true;
|
|
7888
|
+
const m = /^(?:\/usr\/bin\/|\/bin\/)?([a-z]+)$/.exec(c.words[0].text);
|
|
7889
|
+
if (!m || !AWK_READERS.has(m[1]))
|
|
7890
|
+
return false;
|
|
7891
|
+
// getopt takes any prefix of a long option that only one option starts with: `sort --compr=PROG` and `sort --co PROG` are `--compress-program`.
|
|
7892
|
+
if (c.words.slice(1).some((w) => /compress-program/.test(w.text) || (m[1] === 'sort' && w.text.startsWith('--co')) || ((m[1] === 'less' || m[1] === 'more') && w.text.startsWith('+'))))
|
|
7893
|
+
return false;
|
|
7894
|
+
return c.redirects.every((r) => /^(?:>|>>|<|>&)$/.test(r.op) && r.target !== null && plain(r.target));
|
|
7895
|
+
});
|
|
7896
|
+
}
|
|
7897
|
+
// The items of a print list (the tokens between its commas), each as the words it can be, or null when any item is not made of literals.
|
|
7898
|
+
function awkItems(printed, facts) {
|
|
7899
|
+
const items = [[]];
|
|
7900
|
+
for (const t of printed) {
|
|
7901
|
+
if (t.k === 'op' && t.v === ',')
|
|
7902
|
+
items.push([]);
|
|
7903
|
+
else
|
|
7904
|
+
items[items.length - 1].push(t);
|
|
7905
|
+
}
|
|
7906
|
+
const out = [];
|
|
7907
|
+
for (const item of items) {
|
|
7908
|
+
const words = awkWords(item, facts, new Set());
|
|
7909
|
+
if (words === null)
|
|
7910
|
+
return null;
|
|
7911
|
+
out.push(words);
|
|
7912
|
+
}
|
|
7913
|
+
return out;
|
|
7914
|
+
}
|
|
7915
|
+
// Every place a program starts a command is `system(CMD)`, `print … | CMD`, `printf … | CMD` or `CMD | getline` (and gawk's `|&`). A CMD made only of
|
|
7916
|
+
// literals is a command line, judged like a nested shell string: the same stop the direct command gets (every part of a concatenation is judged
|
|
7917
|
+
// too, since awks differ on what `"a" "b" | getline` runs). Any other CMD is a command nobody can read here (ARGV, ENVIRON, a -v or NAME=VALUE
|
|
7918
|
+
// variable, a field, a value read by getline, a call such as sprintf): a stop. Words printed into a shell are the commands it runs: they must be
|
|
7919
|
+
// literals too.
|
|
7920
|
+
function awkCommandSinks(program, words, taintedNames, taintAll, ctx, run, x) {
|
|
7921
|
+
if (!/\||\bsystem\b|@/.test(program))
|
|
7922
|
+
return;
|
|
7923
|
+
let dynamic = false;
|
|
7924
|
+
const judged = new Set();
|
|
7925
|
+
const judge = (texts) => {
|
|
7926
|
+
if (texts === null) {
|
|
7927
|
+
dynamic = true;
|
|
7928
|
+
return;
|
|
7929
|
+
}
|
|
7930
|
+
for (const text of texts) {
|
|
7931
|
+
if (text.includes(DYN)) {
|
|
7932
|
+
dynamic = true;
|
|
7933
|
+
continue;
|
|
7934
|
+
}
|
|
7935
|
+
if (text.trim() === '' || judged.has(text))
|
|
7936
|
+
continue;
|
|
7937
|
+
judged.add(text);
|
|
7938
|
+
if (judged.size > 96) {
|
|
7939
|
+
dynamic = true;
|
|
7940
|
+
return;
|
|
7941
|
+
}
|
|
7942
|
+
evalShell(text, childCtx(ctx), run);
|
|
7943
|
+
}
|
|
7944
|
+
};
|
|
7945
|
+
const judgeWords = (w, slices) => {
|
|
7946
|
+
judge(awkJoin(w, 0, w.length));
|
|
7947
|
+
if (!slices || w.length < 2 || w.length > 6)
|
|
7948
|
+
return;
|
|
7949
|
+
for (let a = 0; a < w.length; a += 1)
|
|
7950
|
+
for (let b = a + 1; b <= w.length; b += 1)
|
|
7951
|
+
if (b - a < w.length)
|
|
7952
|
+
judge(awkJoin(w, a, b));
|
|
7953
|
+
};
|
|
7954
|
+
// The line a `print` or `printf` writes into a command that is not a known reader: the command may run it, so it must be made of literals and is judged as
|
|
7955
|
+
// the shell string it is. A print list is written with OFS (a space) between its words, and is also read with nothing between them. A `%` in a printf format
|
|
7956
|
+
// is a conversion that puts something else in the line (`%c`, `%s`): a stop. OFS set by the program or the command line is not a space nobody can rely on: a stop.
|
|
7957
|
+
const judgePrinted = (sink, facts) => {
|
|
7958
|
+
const lists = sink.printed ? awkItems(sink.printed, facts) : null;
|
|
7959
|
+
// A bare `print` prints the input line: not a literal.
|
|
7960
|
+
if (lists === null || lists.every((l) => l.length === 0)) {
|
|
7961
|
+
dynamic = true;
|
|
7962
|
+
return;
|
|
7963
|
+
}
|
|
7964
|
+
if (sink.printf) {
|
|
7965
|
+
const format = awkJoin(lists[0], 0, lists[0].length);
|
|
7966
|
+
if (format === null || format.some((f) => f.includes('%'))) {
|
|
7967
|
+
dynamic = true;
|
|
7968
|
+
return;
|
|
7969
|
+
}
|
|
7970
|
+
}
|
|
7971
|
+
judgeWords(lists.flat(), true);
|
|
7972
|
+
if (lists.length < 2)
|
|
7973
|
+
return;
|
|
7974
|
+
if (facts.all || facts.tainted.has('OFS') || facts.assigns.has('OFS')) {
|
|
7975
|
+
dynamic = true;
|
|
7976
|
+
return;
|
|
7977
|
+
}
|
|
7978
|
+
const spaced = [];
|
|
7979
|
+
lists.forEach((l, k) => { if (k > 0)
|
|
7980
|
+
spaced.push([' ']); spaced.push(...l); });
|
|
7981
|
+
judge(awkJoin(spaced, 0, spaced.length));
|
|
7982
|
+
for (const l of lists)
|
|
7983
|
+
judge(awkJoin(l, 0, l.length));
|
|
7984
|
+
};
|
|
7985
|
+
if (program.length > 65536)
|
|
7986
|
+
dynamic = true;
|
|
7987
|
+
else {
|
|
7988
|
+
for (const bracketAware of [true, false]) {
|
|
7989
|
+
const tokens = awkTokens(program, bracketAware);
|
|
7990
|
+
if (tokens === null) {
|
|
7991
|
+
dynamic = true;
|
|
7992
|
+
continue;
|
|
7993
|
+
}
|
|
7994
|
+
const facts = awkFacts(tokens, taintedNames, taintAll);
|
|
7995
|
+
if (facts.indirect)
|
|
7996
|
+
dynamic = true;
|
|
7997
|
+
for (const sink of facts.sinks) {
|
|
7998
|
+
const w = awkWords(sink.command, facts, new Set());
|
|
7999
|
+
if (w === null || w.length === 0) {
|
|
8000
|
+
dynamic = true;
|
|
8001
|
+
continue;
|
|
8002
|
+
}
|
|
8003
|
+
judgeWords(w, true);
|
|
8004
|
+
if (sink.printed !== undefined) {
|
|
8005
|
+
const command = awkJoin(w, 0, w.length);
|
|
8006
|
+
if (command === null || !command.every(awkReaderSink))
|
|
8007
|
+
judgePrinted(sink, facts);
|
|
7329
8008
|
}
|
|
7330
8009
|
}
|
|
8010
|
+
}
|
|
8011
|
+
}
|
|
8012
|
+
if (!dynamic)
|
|
8013
|
+
return;
|
|
8014
|
+
// The reason first: a command-line word, a -v or NAME=VALUE value, or an environment value that is itself a stopped command.
|
|
8015
|
+
if (!ctx.backstop)
|
|
8016
|
+
scanArgvForCommands(words, ctx, run, x);
|
|
8017
|
+
for (const values of x.env.values())
|
|
8018
|
+
if (values !== null)
|
|
8019
|
+
for (const v of values)
|
|
8020
|
+
if (/\S/.test(v))
|
|
8021
|
+
evalShell(v, childCtx(ctx), run);
|
|
8022
|
+
worseParse(run, 'dynamic');
|
|
8023
|
+
hit(run, 'Sx', 'dynamic_awk_command');
|
|
8024
|
+
}
|
|
8025
|
+
// A word with no readable text: it is wholly a variable, a substitution or a parameter default. The word parser ends `${P:-BEGIN{…}}` at its first `}`, which
|
|
8026
|
+
// leaves a closing brace that nothing opened; no awk program text has that, so such a word is read as unreadable too.
|
|
8027
|
+
function awkUnreadable(a) {
|
|
8028
|
+
if (a.text !== null)
|
|
8029
|
+
return false;
|
|
8030
|
+
const literal = a.pattern.split(DYN).join('');
|
|
8031
|
+
if (literal.trim() === '')
|
|
8032
|
+
return true;
|
|
8033
|
+
let depth = 0;
|
|
8034
|
+
for (const ch of literal) {
|
|
8035
|
+
if (ch === '{')
|
|
8036
|
+
depth += 1;
|
|
8037
|
+
else if (ch === '}') {
|
|
8038
|
+
if (depth === 0)
|
|
8039
|
+
return true;
|
|
8040
|
+
depth -= 1;
|
|
8041
|
+
}
|
|
8042
|
+
}
|
|
8043
|
+
return false;
|
|
8044
|
+
}
|
|
8045
|
+
// The word that awk programs were read from before the options were parsed: the first word that is not an option or the value of one.
|
|
8046
|
+
function awkOlderProgram(rest) {
|
|
8047
|
+
const valued = new Set(['-F', '-v', '-W', '--field-separator', '--assign', '-i', '-l', '--include', '--load']);
|
|
8048
|
+
for (let i = 0; i < rest.length; i += 1) {
|
|
8049
|
+
const t = rest[i].text;
|
|
8050
|
+
if (t === '-f' || t === '--file' || (t !== null && valued.has(t))) {
|
|
7331
8051
|
i += 1;
|
|
7332
8052
|
continue;
|
|
7333
8053
|
}
|
|
7334
|
-
if (t !== null &&
|
|
8054
|
+
if (t !== null && t.startsWith('-') && t.length > 1)
|
|
8055
|
+
continue;
|
|
8056
|
+
return rest[i];
|
|
8057
|
+
}
|
|
8058
|
+
return null;
|
|
8059
|
+
}
|
|
8060
|
+
function awkProgram(rest, ctx, run, x) {
|
|
8061
|
+
const texts = [];
|
|
8062
|
+
const assigned = [];
|
|
8063
|
+
let anyAssign = false;
|
|
8064
|
+
let unread = false;
|
|
8065
|
+
// A word that may be the program itself and cannot be read (`awk "${P:-…}"`): judged like the code of `python3 -c "$P"`, by the whole command text.
|
|
8066
|
+
let unreadCode = false;
|
|
8067
|
+
// ... and when it is code for certain (`-e "$P"`, the word after `--`) or one word that is not a list of options (a quoted `"$P"`), as that code is a stop.
|
|
8068
|
+
// An unquoted `$OPTS` before a program may be options, which word splitting turns into several words: it is judged by name only.
|
|
8069
|
+
let unreadHit = false;
|
|
8070
|
+
let programFile = false;
|
|
8071
|
+
// The text of a program word: its literal text, or its pattern (an unreadable part is DYN, never nothing). Never its `alts`: the word
|
|
8072
|
+
// parser brace-expands a quoted `{a,b}`, so a program with a comma inside braces has no single text but its pattern is the program.
|
|
8073
|
+
const programTexts = (a) => [a.text !== null ? a.text : a.pattern];
|
|
8074
|
+
// A program or program file that is a substitution (`awk -f <(…)`, `awk "$(…)"`) is read like one given to an interpreter.
|
|
8075
|
+
const substituted = (a) => { if (a.word && a.word.substitution)
|
|
8076
|
+
checkSubstitutionExec(x.info.subs, ctx, run, true); };
|
|
8077
|
+
const source = (a) => {
|
|
8078
|
+
if (!a)
|
|
8079
|
+
return;
|
|
8080
|
+
substituted(a);
|
|
8081
|
+
programFile = true;
|
|
8082
|
+
// `awk -e "$P"`: a program that is wholly a variable or a substitution is code nobody can read.
|
|
8083
|
+
if (awkUnreadable(a)) {
|
|
8084
|
+
unread = true;
|
|
8085
|
+
unreadCode = true;
|
|
8086
|
+
unreadHit = true;
|
|
8087
|
+
}
|
|
8088
|
+
for (const text of programTexts(a))
|
|
8089
|
+
texts.push([text, true]);
|
|
8090
|
+
};
|
|
8091
|
+
const file = (a) => {
|
|
8092
|
+
programFile = true;
|
|
8093
|
+
if (!a)
|
|
8094
|
+
return;
|
|
8095
|
+
substituted(a);
|
|
8096
|
+
// `awk -f -`: the program arrives on stdin.
|
|
8097
|
+
if (a.text !== null && (a.text === '-' || STDIN_PATHS.has(a.text))) {
|
|
8098
|
+
const src = stdinSource(x, ctx, run);
|
|
8099
|
+
for (const text of src.texts)
|
|
8100
|
+
texts.push([text, false]);
|
|
8101
|
+
if (src.unknown || (src.texts.length === 0 && src.files.length === 0)) {
|
|
8102
|
+
worseParse(run, 'dynamic');
|
|
8103
|
+
hit(run, 'Sx', 'dynamic_stdin_code');
|
|
8104
|
+
}
|
|
8105
|
+
return;
|
|
8106
|
+
}
|
|
8107
|
+
// A program file is read like any script: one that cannot be read is an unread script, as for `python3 prog.py`. A path that is not literal is
|
|
8108
|
+
// `python3 "$S"`: the words of the command line are all that can be judged.
|
|
8109
|
+
if (a.text === null) {
|
|
8110
|
+
unread = true;
|
|
8111
|
+
return;
|
|
8112
|
+
}
|
|
8113
|
+
for (const abs of absCandidates(a.text, ctx, run) || []) {
|
|
8114
|
+
const content = safeRead(run.env, abs, tables_1.LIMITS.maxScriptBytes);
|
|
8115
|
+
if (content !== null)
|
|
8116
|
+
texts.push([content, false]);
|
|
8117
|
+
}
|
|
8118
|
+
};
|
|
8119
|
+
const assign = (a) => {
|
|
8120
|
+
if (!a || a.text === null) {
|
|
8121
|
+
anyAssign = true;
|
|
8122
|
+
return;
|
|
8123
|
+
}
|
|
8124
|
+
const m = /^([A-Za-z_][A-Za-z0-9_]*)=/.exec(a.text);
|
|
8125
|
+
if (m)
|
|
8126
|
+
assigned.push(m[1]);
|
|
8127
|
+
};
|
|
8128
|
+
const wordList = [];
|
|
8129
|
+
const unknown = () => { hit(run, 'Sx', 'awk_unknown_option'); };
|
|
8130
|
+
let i = 0;
|
|
8131
|
+
for (; i < rest.length; i += 1) {
|
|
8132
|
+
const a = rest[i];
|
|
8133
|
+
let t = a.text;
|
|
8134
|
+
if (t === null) {
|
|
8135
|
+
// A word nobody can read may be an option, its value or the program: it is judged by name (unread), and the options go on after it.
|
|
8136
|
+
if (awkUnreadable(a)) {
|
|
8137
|
+
unread = true;
|
|
8138
|
+
unreadCode = true;
|
|
8139
|
+
if (a.word && a.word.quoted)
|
|
8140
|
+
unreadHit = true;
|
|
8141
|
+
substituted(a);
|
|
8142
|
+
continue;
|
|
8143
|
+
}
|
|
8144
|
+
// A program with a dynamic part (`awk "NR==$N"`) ends the options; an option with one (`-F$SEP`) is read from its pattern.
|
|
8145
|
+
if (!a.pattern.startsWith('-'))
|
|
8146
|
+
break;
|
|
8147
|
+
t = a.pattern;
|
|
8148
|
+
}
|
|
8149
|
+
if (t === '--') {
|
|
7335
8150
|
i += 1;
|
|
8151
|
+
break;
|
|
8152
|
+
}
|
|
8153
|
+
if (t === '-' || !t.startsWith('-'))
|
|
8154
|
+
break;
|
|
8155
|
+
if (t.startsWith('--')) {
|
|
8156
|
+
const eq = t.indexOf('=');
|
|
8157
|
+
const name = eq === -1 ? t.slice(2) : t.slice(2, eq);
|
|
8158
|
+
const mode = AWK_LONG.get(name);
|
|
8159
|
+
if (mode === undefined || (eq !== -1 && mode === 'n')) {
|
|
8160
|
+
unknown();
|
|
8161
|
+
continue;
|
|
8162
|
+
}
|
|
8163
|
+
let value;
|
|
8164
|
+
if (mode === 'v') {
|
|
8165
|
+
if (eq === -1) {
|
|
8166
|
+
value = rest[i + 1];
|
|
8167
|
+
i += 1;
|
|
8168
|
+
}
|
|
8169
|
+
else
|
|
8170
|
+
value = litArg(t.slice(eq + 1));
|
|
8171
|
+
}
|
|
8172
|
+
else if (eq !== -1)
|
|
8173
|
+
value = litArg(t.slice(eq + 1));
|
|
8174
|
+
if (name === 'file' || name === 'exec')
|
|
8175
|
+
file(value);
|
|
8176
|
+
else if (name === 'source')
|
|
8177
|
+
source(value);
|
|
8178
|
+
else if (name === 'assign') {
|
|
8179
|
+
assign(value);
|
|
8180
|
+
if (value)
|
|
8181
|
+
wordList.push(value);
|
|
8182
|
+
}
|
|
7336
8183
|
continue;
|
|
7337
8184
|
}
|
|
7338
|
-
|
|
8185
|
+
// `-version` and `-safe` are the one-true-awk's single-dash long options.
|
|
8186
|
+
if (t === '-version' || t === '-safe')
|
|
7339
8187
|
continue;
|
|
7340
|
-
|
|
7341
|
-
|
|
8188
|
+
for (let c = 1; c < t.length; c += 1) {
|
|
8189
|
+
const ch = t[c];
|
|
8190
|
+
if (AWK_SHORT_PLAIN.includes(ch))
|
|
8191
|
+
continue;
|
|
8192
|
+
if (AWK_SHORT_OPTIONAL.includes(ch))
|
|
8193
|
+
break;
|
|
8194
|
+
if (!'fFeEvilW'.includes(ch)) {
|
|
8195
|
+
unknown();
|
|
8196
|
+
break;
|
|
8197
|
+
}
|
|
8198
|
+
const attached = t.slice(c + 1);
|
|
8199
|
+
const value = attached !== '' ? litArg(attached) : rest[i + 1];
|
|
8200
|
+
if (attached === '')
|
|
8201
|
+
i += 1;
|
|
8202
|
+
if (ch === 'f' || ch === 'E')
|
|
8203
|
+
file(value);
|
|
8204
|
+
else if (ch === 'e')
|
|
8205
|
+
source(value);
|
|
8206
|
+
else if (ch === 'v') {
|
|
8207
|
+
assign(value);
|
|
8208
|
+
if (value)
|
|
8209
|
+
wordList.push(value);
|
|
8210
|
+
}
|
|
8211
|
+
else if (ch === 'W') {
|
|
8212
|
+
const w = value && value.text !== null ? value.text : null;
|
|
8213
|
+
if (w !== null && /^e(?:xec)?$/.test(w)) {
|
|
8214
|
+
file(rest[i + 1]);
|
|
8215
|
+
i += 1;
|
|
8216
|
+
}
|
|
8217
|
+
else if (w === null || !AWK_W_KNOWN.test(w))
|
|
8218
|
+
unknown();
|
|
8219
|
+
}
|
|
8220
|
+
break;
|
|
8221
|
+
}
|
|
7342
8222
|
}
|
|
7343
|
-
|
|
7344
|
-
|
|
7345
|
-
|
|
7346
|
-
|
|
7347
|
-
|
|
7348
|
-
|
|
8223
|
+
// The program is the first operand unless -f or -e gave it; the others are files and NAME=VALUE assignments.
|
|
8224
|
+
let operands = rest.slice(i);
|
|
8225
|
+
if (!programFile && operands.length > 0) {
|
|
8226
|
+
const program = operands[0];
|
|
8227
|
+
operands = operands.slice(1);
|
|
8228
|
+
substituted(program);
|
|
8229
|
+
if (awkUnreadable(program)) {
|
|
8230
|
+
unread = true;
|
|
8231
|
+
unreadCode = true;
|
|
8232
|
+
unreadHit = true;
|
|
8233
|
+
}
|
|
8234
|
+
for (const text of programTexts(program))
|
|
8235
|
+
texts.push([text, true]);
|
|
8236
|
+
}
|
|
8237
|
+
// The word the older reading took as the program stays one more program text, so a command line the two read differently (`awk -- -e 'PROGRAM'`,
|
|
8238
|
+
// `awk -f prog.awk 'PROGRAM'`: awk reads those words as file names) is never judged less than it was.
|
|
8239
|
+
const older = awkOlderProgram(rest);
|
|
8240
|
+
if (older)
|
|
8241
|
+
for (const text of programTexts(older))
|
|
8242
|
+
if (!texts.some(([t]) => t === text))
|
|
8243
|
+
texts.push([text, true]);
|
|
8244
|
+
for (const o of operands) {
|
|
8245
|
+
if (o.text === null) {
|
|
8246
|
+
anyAssign = true;
|
|
8247
|
+
wordList.push(o);
|
|
8248
|
+
continue;
|
|
8249
|
+
}
|
|
8250
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(o.text)) {
|
|
8251
|
+
assign(o);
|
|
8252
|
+
wordList.push(o);
|
|
8253
|
+
}
|
|
8254
|
+
else
|
|
8255
|
+
wordList.push(o);
|
|
8256
|
+
}
|
|
8257
|
+
// A program that cannot be read: the words of its command line are judged by name, as for an interpreter given a script it cannot read.
|
|
8258
|
+
if (unread) {
|
|
8259
|
+
worseParse(run, 'dynamic');
|
|
8260
|
+
tripwire(rest.map(argText).join(' '), run);
|
|
8261
|
+
}
|
|
8262
|
+
if (unreadCode) {
|
|
8263
|
+
tripwire(run.topSource.slice(0, tables_1.LIMITS.maxCommandBytes), run);
|
|
8264
|
+
if (unreadHit)
|
|
8265
|
+
hit(run, 'Sx', 'dynamic_interpreter_code');
|
|
8266
|
+
}
|
|
8267
|
+
// What base read of each program text it was given stays as it was; the commands it names are then read from all of them as one program.
|
|
8268
|
+
for (const [raw, inline] of texts) {
|
|
8269
|
+
const text = raw.split(DYN).join('');
|
|
8270
|
+
if (!AWK_EXEC.test(text))
|
|
8271
|
+
continue;
|
|
8272
|
+
scanInterpreterCode(text, run);
|
|
8273
|
+
awkArgvCommands(text, rest, ctx, run, x);
|
|
7349
8274
|
// `awk '{system($0)}'` runs the lines it reads: the input is a command text (audit N1).
|
|
7350
|
-
if (/\bsystem\s*\(|\|\s*&?\s*["'$a-zA-Z(]|\bgetline\b/.test(
|
|
8275
|
+
if (inline && /\bsystem\s*\(|\|\s*&?\s*["'$a-zA-Z(]|\bgetline\b/.test(text))
|
|
7351
8276
|
stdinExec('sh', [], x, ctx, run);
|
|
7352
8277
|
}
|
|
8278
|
+
awkCommandSinks(texts.map(([text]) => text).join('\n'), wordList, assigned, anyAssign, ctx, run, x);
|
|
7353
8279
|
}
|
|
7354
8280
|
// GNU sed runs a command with the `e` command and the `s///e` flag.
|
|
7355
8281
|
function sedProgram(rest, ctx, run) {
|
|
@@ -10204,7 +11130,249 @@ function handleHelm(rest, ctx, run) {
|
|
|
10204
11130
|
function emptyExec() {
|
|
10205
11131
|
return { cmd: { words: [], assignments: [], redirects: [], heredoc: null, heredocDynamic: false, pipedInput: false, pipesOutput: false, background: false, subshell: false }, info: { upstream: [], ansiC: false, subs: [] }, env: new Map() };
|
|
10206
11132
|
}
|
|
11133
|
+
// ---------------------------------------------------------------------------------------------
|
|
11134
|
+
// S4: a secret env file named in a container bind mount, a find primary or the stage that feeds xargs (the stop a plain `cat .env.local` already has).
|
|
11135
|
+
// Only an env file counts here (envOnly): `.env`, `.env.<word>` (never a template), `.dev.vars`, and `<name>.env` where it is there. A mount or a listing
|
|
11136
|
+
// of a FOLDER that holds one is not a stop (a folder mount such as `-v $PWD:/app` is common), and no program is added to the readers: the command that gets
|
|
11137
|
+
// the name is a reader when it is not one of S4_EXEMPT_PROGRAMS, as for `find -exec`.
|
|
11138
|
+
/** The samples a glob or a partly dynamic path is judged against when only env files count: the env-family samples and the common names, per working folder. */
|
|
11139
|
+
function envSamples(run, ctx) {
|
|
11140
|
+
const out = (0, tables_1.secretSamples)(run.home, cwdsList(ctx)).filter((s) => (0, env_names_1.secretEnvBase)((0, env_names_1.lastSegment)(s)) !== null);
|
|
11141
|
+
for (const c of cwdsList(ctx))
|
|
11142
|
+
for (const n of env_names_1.ENV_NAME_CORPUS)
|
|
11143
|
+
out.push(`${c.replace(/\/$/, '')}/${n}`);
|
|
11144
|
+
return out;
|
|
11145
|
+
}
|
|
11146
|
+
/** The readings of an argument: its known values (a loop variable, a brace list) and its dynamic form. */
|
|
11147
|
+
function argVariants(a) {
|
|
11148
|
+
if (a.text !== null)
|
|
11149
|
+
return [a];
|
|
11150
|
+
const out = a.alts ? a.alts.map((v) => ({ ...litArg(v), glob: a.glob, word: a.word })) : [];
|
|
11151
|
+
out.push(a);
|
|
11152
|
+
return out;
|
|
11153
|
+
}
|
|
11154
|
+
/** The part of an argument from `from` to `to`: a literal stays a literal, a dynamic word keeps its dynamic slots. */
|
|
11155
|
+
function subArg(a, from, to) {
|
|
11156
|
+
if (a.text !== null)
|
|
11157
|
+
return { ...litArg(a.text.slice(from, to)), glob: a.glob, word: a.word };
|
|
11158
|
+
const pattern = a.pattern.slice(from, to);
|
|
11159
|
+
return pattern.includes(DYN) ? { text: null, alts: null, pattern, glob: a.glob, word: null, nonAscii: a.nonAscii } : { ...litArg(pattern), glob: a.glob };
|
|
11160
|
+
}
|
|
11161
|
+
/** Whether a word names a secret env file anywhere in it: a piece between separators is one by name (a dynamic slot ends the piece, so `.env.$X` is). */
|
|
11162
|
+
function namesSecretEnv(text) {
|
|
11163
|
+
return text.split(/[\s,:=/;|&()<>"'`]+/).some((piece) => piece !== '' && (0, env_names_1.secretEnvBase)(piece) === 'secret');
|
|
11164
|
+
}
|
|
11165
|
+
/** A path (the host side of a mount, a find start, a word a stage lists) that is a secret env file, a glob that can name one, or a dynamic path that ends in one. */
|
|
11166
|
+
function checkEnvHost(h, ctx, run, reason) {
|
|
11167
|
+
for (const v of argVariants(h)) {
|
|
11168
|
+
if (checkSecretOperand(v, ctx, run, reason, false, false, true))
|
|
11169
|
+
return true;
|
|
11170
|
+
const seg = (0, env_names_1.lastSegment)(v.text !== null ? v.text : v.pattern);
|
|
11171
|
+
if (seg === '')
|
|
11172
|
+
continue;
|
|
11173
|
+
const hasDyn = seg.includes(DYN);
|
|
11174
|
+
const hasGlob = v.glob && /[*?[]/.test(seg);
|
|
11175
|
+
if (!hasDyn && !hasGlob) {
|
|
11176
|
+
// A literal name, whatever the case and whatever is in front of it; `<name>.env` is judged by the file being there unless the folder is not known.
|
|
11177
|
+
const kind = (0, env_names_1.secretEnvBase)(seg);
|
|
11178
|
+
if (kind === 'secret' || (kind === 'present' && v.text === null))
|
|
11179
|
+
return hit(run, 'S4', `${reason}_secret_name`);
|
|
11180
|
+
continue;
|
|
11181
|
+
}
|
|
11182
|
+
if (!/[^*?[\]\u0001]/.test(seg))
|
|
11183
|
+
continue;
|
|
11184
|
+
const re = patternToRegex(seg, false);
|
|
11185
|
+
if (env_names_1.ENV_NAME_CORPUS.some((n) => re.test(n)))
|
|
11186
|
+
return hit(run, 'S4', `${reason}_secret_pattern`);
|
|
11187
|
+
}
|
|
11188
|
+
return false;
|
|
11189
|
+
}
|
|
11190
|
+
/**
|
|
11191
|
+
* The bind-mount flags of a docker or podman command line, wherever they stand (an unknown flag or a word it takes never hides one; a flag that is part of the
|
|
11192
|
+
* container's own command line can only add a stop): `-v`, `--volume` and `--mount` in every spelling (`-vX`, `-v=X`, `--volume=X`, `-itv X`), every repeat.
|
|
11193
|
+
* The host side of each is the path the container reads. A value that cannot be read (a dynamic word with no colon, no `source=`) on a line that names a secret
|
|
11194
|
+
* env file anywhere is a stop.
|
|
11195
|
+
*/
|
|
11196
|
+
function dockerMountChecks(rest, ctx, run) {
|
|
11197
|
+
let unresolved = false;
|
|
11198
|
+
const sourceArg = (v, text) => (text.includes(DYN) ? { text: null, alts: null, pattern: text, glob: v.glob, word: null, nonAscii: v.nonAscii } : { ...litArg(text), glob: v.glob });
|
|
11199
|
+
const hostsOf = (kind, value) => {
|
|
11200
|
+
const hosts = [];
|
|
11201
|
+
for (const v of argVariants(value)) {
|
|
11202
|
+
const raw = v.text !== null ? v.text : v.pattern;
|
|
11203
|
+
if (kind === 'volume') {
|
|
11204
|
+
const host = (0, env_names_1.volumeHost)(raw);
|
|
11205
|
+
if (host !== null)
|
|
11206
|
+
hosts.push(subArg(v, 0, host.length));
|
|
11207
|
+
else if (v.text === null) {
|
|
11208
|
+
hosts.push(v);
|
|
11209
|
+
unresolved = true;
|
|
11210
|
+
}
|
|
11211
|
+
}
|
|
11212
|
+
else {
|
|
11213
|
+
const sources = (0, env_names_1.mountSources)(raw);
|
|
11214
|
+
for (const s of sources)
|
|
11215
|
+
hosts.push(sourceArg(v, s));
|
|
11216
|
+
if (sources.length === 0 && v.text === null)
|
|
11217
|
+
unresolved = true;
|
|
11218
|
+
}
|
|
11219
|
+
}
|
|
11220
|
+
return hosts;
|
|
11221
|
+
};
|
|
11222
|
+
for (let k = 0; k < rest.length; k += 1) {
|
|
11223
|
+
if (clock(run))
|
|
11224
|
+
return;
|
|
11225
|
+
const a = rest[k];
|
|
11226
|
+
const t = a.text !== null ? a.text : a.pattern;
|
|
11227
|
+
let kind = null;
|
|
11228
|
+
let value = null;
|
|
11229
|
+
if (t === '-v' || t === '--volume') {
|
|
11230
|
+
kind = 'volume';
|
|
11231
|
+
value = rest[k + 1] || null;
|
|
11232
|
+
}
|
|
11233
|
+
else if (t === '--mount') {
|
|
11234
|
+
kind = 'mount';
|
|
11235
|
+
value = rest[k + 1] || null;
|
|
11236
|
+
}
|
|
11237
|
+
else if (t.startsWith('--volume=') || t.startsWith('--mount=')) {
|
|
11238
|
+
kind = t.startsWith('--mount') ? 'mount' : 'volume';
|
|
11239
|
+
value = subArg(a, t.indexOf('=') + 1);
|
|
11240
|
+
}
|
|
11241
|
+
else if (!t.startsWith('--') && /^-[A-Za-z]*v/.test(t)) {
|
|
11242
|
+
const at = t.indexOf('v');
|
|
11243
|
+
kind = 'volume';
|
|
11244
|
+
value = at + 1 >= t.length ? rest[k + 1] || null : subArg(a, at + 1 + (t[at + 1] === '=' ? 1 : 0));
|
|
11245
|
+
}
|
|
11246
|
+
if (kind === null || value === null)
|
|
11247
|
+
continue;
|
|
11248
|
+
for (const h of hostsOf(kind, value))
|
|
11249
|
+
if (checkEnvHost(h, ctx, run, 'docker_mount'))
|
|
11250
|
+
return;
|
|
11251
|
+
}
|
|
11252
|
+
if (unresolved && rest.some((a) => namesSecretEnv(argText(a))))
|
|
11253
|
+
hit(run, 'S4', 'docker_mount_unresolved_secret_name');
|
|
11254
|
+
}
|
|
11255
|
+
// `find` options that take a pattern for the name of a file, and the names a -regex pattern is tried against (the common secret env names).
|
|
11256
|
+
const FIND_NAME_PRIMARIES = new Set(['-name', '-iname', '-path', '-ipath', '-wholename', '-iwholename']);
|
|
11257
|
+
const ENV_REGEX_NAMES = [...new Set([...env_names_1.ENV_NAME_CORPUS, '.env', '.env.local', '.env.production', '.dev.vars', 'app.env'])];
|
|
11258
|
+
/** The start points and the expression of a find command line. */
|
|
11259
|
+
function splitFind(rest) {
|
|
11260
|
+
const starts = [];
|
|
11261
|
+
let i = 0;
|
|
11262
|
+
while (i < rest.length && rest[i].text !== null && /^-[HLP]$|^-O\d$|^-D$/.test(rest[i].text))
|
|
11263
|
+
i += rest[i].text === '-D' ? 2 : 1;
|
|
11264
|
+
while (i < rest.length) {
|
|
11265
|
+
const t = rest[i].text;
|
|
11266
|
+
if (t !== null && (t.startsWith('-') || t === '(' || t === '!' || t === ')'))
|
|
11267
|
+
break;
|
|
11268
|
+
starts.push(rest[i]);
|
|
11269
|
+
i += 1;
|
|
11270
|
+
}
|
|
11271
|
+
if (starts.length === 0)
|
|
11272
|
+
starts.push(litArg('.'));
|
|
11273
|
+
return { starts, expr: rest.slice(i) };
|
|
11274
|
+
}
|
|
11275
|
+
// Whether a regex pattern's own text mentions env (a backslash, bracket or group mark inside the word does not hide it).
|
|
11276
|
+
const regexMentionsEnv = (text) => /env/i.test(text.replace(/[\\[\]()|?\u0001]/g, ''));
|
|
11277
|
+
/** A `-regex` pattern that matches the path of a secret env file under a start point (read as emacs and as extended syntax by a bounded matcher). */
|
|
11278
|
+
function findRegexNamesEnv(pat, starts, run) {
|
|
11279
|
+
for (const v of argVariants(pat)) {
|
|
11280
|
+
if (v.text === null) {
|
|
11281
|
+
if (regexMentionsEnv(v.pattern))
|
|
11282
|
+
return true;
|
|
11283
|
+
continue;
|
|
11284
|
+
}
|
|
11285
|
+
const bases = new Set();
|
|
11286
|
+
for (const st of starts)
|
|
11287
|
+
if (st.text !== null) {
|
|
11288
|
+
bases.add(st.text.replace(/\/+$/, '') || '/');
|
|
11289
|
+
const e = expandTilde(st.text, run);
|
|
11290
|
+
if (e !== null)
|
|
11291
|
+
bases.add(e.replace(/\/+$/, '') || '/');
|
|
11292
|
+
}
|
|
11293
|
+
const candidates = [];
|
|
11294
|
+
for (const b of bases)
|
|
11295
|
+
for (const n of ENV_REGEX_NAMES)
|
|
11296
|
+
candidates.push(`${b}/${n}`, `${b}/d/${n}`, `${b}/d/e/${n}`);
|
|
11297
|
+
const answer = (0, env_names_1.regexMatchesAny)(v.text, candidates);
|
|
11298
|
+
if (answer === 'yes' || (answer === 'unsure' && regexMentionsEnv(v.text)))
|
|
11299
|
+
return true;
|
|
11300
|
+
}
|
|
11301
|
+
return false;
|
|
11302
|
+
}
|
|
11303
|
+
/**
|
|
11304
|
+
* A find expression that selects a secret env file: a start point that is one, a name, path or regex primary that matches one. The caller says whether what
|
|
11305
|
+
* find hands the files to reads them (`-exec`, or a command after `xargs`).
|
|
11306
|
+
*/
|
|
11307
|
+
function findEnvNames(starts, expr, ctx, run) {
|
|
11308
|
+
for (const st of starts)
|
|
11309
|
+
if (checkEnvHost(st, ctx, run, 'find_start'))
|
|
11310
|
+
return true;
|
|
11311
|
+
for (let k = 0; k + 1 < expr.length; k += 1) {
|
|
11312
|
+
const t = expr[k].text;
|
|
11313
|
+
if (t === null)
|
|
11314
|
+
continue;
|
|
11315
|
+
if (t === '-regex' || t === '-iregex') {
|
|
11316
|
+
if (findRegexNamesEnv(expr[k + 1], starts, run))
|
|
11317
|
+
return hit(run, 'S4', 'find_exec_secret_name');
|
|
11318
|
+
continue;
|
|
11319
|
+
}
|
|
11320
|
+
// `-samefile <file>` selects the file that is named (and every link to it): the name is judged as a start point is.
|
|
11321
|
+
if (t === '-samefile') {
|
|
11322
|
+
if (checkEnvHost(expr[k + 1], ctx, run, 'find_start'))
|
|
11323
|
+
return true;
|
|
11324
|
+
continue;
|
|
11325
|
+
}
|
|
11326
|
+
if (!FIND_NAME_PRIMARIES.has(t))
|
|
11327
|
+
continue;
|
|
11328
|
+
const pathLike = t.includes('path') || t.includes('wholename');
|
|
11329
|
+
for (const v of argVariants(expr[k + 1])) {
|
|
11330
|
+
const seg = pathLike ? (0, env_names_1.lastSegment)(v.text !== null ? v.text : v.pattern) : (v.text !== null ? v.text : v.pattern);
|
|
11331
|
+
if (seg === '')
|
|
11332
|
+
continue;
|
|
11333
|
+
if (!seg.includes(DYN) && !/[*?[]/.test(seg)) {
|
|
11334
|
+
if ((0, env_names_1.secretEnvBase)(seg) !== null)
|
|
11335
|
+
return hit(run, 'S4', 'find_exec_secret_name');
|
|
11336
|
+
continue;
|
|
11337
|
+
}
|
|
11338
|
+
// Only wildcards (`*`) are judged by the samples the find rule already has.
|
|
11339
|
+
if (!/[^*?[\]\u0001]/.test(seg))
|
|
11340
|
+
continue;
|
|
11341
|
+
const re = patternToRegex(seg, false);
|
|
11342
|
+
if (env_names_1.ENV_NAME_CORPUS.some((n) => re.test(n)))
|
|
11343
|
+
return hit(run, 'S4', 'find_exec_secret_name');
|
|
11344
|
+
}
|
|
11345
|
+
}
|
|
11346
|
+
return false;
|
|
11347
|
+
}
|
|
11348
|
+
/**
|
|
11349
|
+
* `ls .env* | xargs cat`, `printf '%s\n' .env.local | xargs head`, `find . -name .env.staging | xargs cat`: a stage in front of xargs that lists a secret
|
|
11350
|
+
* env file (by name or by a glob that matches one), whatever the stages between, feeds a command that reads what it is given. Called only for such a command.
|
|
11351
|
+
*/
|
|
11352
|
+
function xargsUpstreamEnv(x, ctx, run) {
|
|
11353
|
+
for (const stage of x.info.upstream) {
|
|
11354
|
+
if (clock(run))
|
|
11355
|
+
return;
|
|
11356
|
+
const words = stage.words.map((w) => resolveWord(w, ctx, run));
|
|
11357
|
+
const prog = words[0] ? progName(words[0], run) : null;
|
|
11358
|
+
if (prog === 'find') {
|
|
11359
|
+
const { starts, expr } = splitFind(words.slice(1));
|
|
11360
|
+
if (findEnvNames(starts, expr, ctx, run))
|
|
11361
|
+
return;
|
|
11362
|
+
continue;
|
|
11363
|
+
}
|
|
11364
|
+
if (prog !== 'ls' && prog !== 'printf' && prog !== 'echo')
|
|
11365
|
+
continue;
|
|
11366
|
+
for (const a of words.slice(1)) {
|
|
11367
|
+
if (a.text !== null && a.text.length > 1 && a.text.startsWith('-'))
|
|
11368
|
+
continue;
|
|
11369
|
+
if (checkEnvHost(a, ctx, run, 'xargs_input'))
|
|
11370
|
+
return;
|
|
11371
|
+
}
|
|
11372
|
+
}
|
|
11373
|
+
}
|
|
10207
11374
|
function handleDocker(rest, ctx, run, x) {
|
|
11375
|
+
dockerMountChecks(rest, ctx, run);
|
|
10208
11376
|
// The command inside the container reads this command's stdin (`… | docker exec -i c sh`).
|
|
10209
11377
|
const inner = () => emptyExec();
|
|
10210
11378
|
ctx = withFeed(ctx, x, run);
|
|
@@ -10334,20 +11502,7 @@ function handleRmdir(rest, ctx, run) {
|
|
|
10334
11502
|
}
|
|
10335
11503
|
}
|
|
10336
11504
|
function handleFind(rest, ctx, run, x) {
|
|
10337
|
-
const starts =
|
|
10338
|
-
let i = 0;
|
|
10339
|
-
while (i < rest.length && rest[i].text !== null && /^-[HLP]$|^-O\d$|^-D$/.test(rest[i].text))
|
|
10340
|
-
i += rest[i].text === '-D' ? 2 : 1;
|
|
10341
|
-
while (i < rest.length) {
|
|
10342
|
-
const t = rest[i].text;
|
|
10343
|
-
if (t !== null && (t.startsWith('-') || t === '(' || t === '!' || t === ')'))
|
|
10344
|
-
break;
|
|
10345
|
-
starts.push(rest[i]);
|
|
10346
|
-
i += 1;
|
|
10347
|
-
}
|
|
10348
|
-
if (starts.length === 0)
|
|
10349
|
-
starts.push(litArg('.'));
|
|
10350
|
-
const expr = rest.slice(i);
|
|
11505
|
+
const { starts, expr } = splitFind(rest);
|
|
10351
11506
|
let deletes = false;
|
|
10352
11507
|
for (let k = 0; k < expr.length; k += 1) {
|
|
10353
11508
|
const t = expr[k].text;
|
|
@@ -10393,12 +11548,19 @@ function handleFind(rest, ctx, run, x) {
|
|
|
10393
11548
|
const samples = (0, tables_1.secretSamples)(run.home, cwdsList(ctx));
|
|
10394
11549
|
for (const n of names) {
|
|
10395
11550
|
const re = patternToRegex(n.replace(/^\*\*\//, ''), false);
|
|
10396
|
-
|
|
11551
|
+
const named = samples.filter((smp) => (re.test(path.posix.basename(smp)) || re.test(smp)) && isSecretPath(smp, run));
|
|
11552
|
+
if (named.length > 0) {
|
|
11553
|
+
// `find /proc/self/task -name environ -exec cat {} +`: the process files are what the name reaches (the keyed hook keeps this reason).
|
|
11554
|
+
if (named.some((smp) => env_dump_1.PROC_ENVIRON.test(smp)) && starts.some((st) => st.text === null || /^\/proc(?:\/|$)/.test(collapseParents(st.text))))
|
|
11555
|
+
hit(run, 'S4', 'proc_environ');
|
|
10397
11556
|
hit(run, 'S4', 'find_exec_secret_name');
|
|
10398
11557
|
break;
|
|
10399
11558
|
}
|
|
10400
11559
|
}
|
|
10401
11560
|
}
|
|
11561
|
+
// A secret env file named as a start point, by name, path or regex, whatever the name is (the samples above only know a few): the same reader rule.
|
|
11562
|
+
if (readerExec)
|
|
11563
|
+
findEnvNames(starts, expr, ctx, run);
|
|
10402
11564
|
if (deletes)
|
|
10403
11565
|
for (const s of starts) {
|
|
10404
11566
|
checkProtectedWrite(s, ctx, run, 'find_delete', false);
|
|
@@ -10445,12 +11607,24 @@ function handleDd(rest, run) {
|
|
|
10445
11607
|
}
|
|
10446
11608
|
// ---------------------------------------------------------------------------------------------
|
|
10447
11609
|
// Grep tool glob, Edit/Write tool on protected files
|
|
11610
|
+
function namesApprovalStateFolder(abs, run) {
|
|
11611
|
+
const folder = path.posix.join(run.home, '.marrow', 'host-approvals');
|
|
11612
|
+
const same = (a, b) => (run.fold ? a.toLowerCase() === b.toLowerCase() : a === b);
|
|
11613
|
+
return abs.some((p) => { const q = path.posix.normalize(p).replace(/\/+$/, ''); return same(q, folder) || same(q, path.posix.dirname(folder)); });
|
|
11614
|
+
}
|
|
10448
11615
|
function checkGrepGlob(glob, ctx, run) {
|
|
10449
11616
|
const g = glob.replace(/^\*\*\//, '');
|
|
10450
11617
|
const re = patternToRegex(g, false);
|
|
10451
11618
|
const samples = (0, tables_1.secretSamples)(run.home, cwdsList(ctx));
|
|
10452
|
-
|
|
11619
|
+
// The state file's name is generic (`state.json`): a glob on the name alone is not read as that file (the folder rule and the suffix rule below are).
|
|
11620
|
+
if (samples.some((s) => !secret_paths_1.APPROVAL_STATE_PATH.test(s) && re.test(path.posix.basename(s)) && isSecretPath(s, run)))
|
|
11621
|
+
hit(run, 'S4', 'tool_grep_glob_secret');
|
|
11622
|
+
// A glob with folders (`.marrow/host-approvals/state.json`, an absolute one) is read against the same number of trailing names of the state file samples.
|
|
11623
|
+
const depth = g.split('/').filter(Boolean).length;
|
|
11624
|
+
if (depth > 1 && samples.some((s) => secret_paths_1.APPROVAL_STATE_PATH.test(s) && patternToRegex(g.replace(/^\/+/, ''), false).test(s.split('/').slice(-depth).join('/')))) {
|
|
10453
11625
|
hit(run, 'S4', 'tool_grep_glob_secret');
|
|
11626
|
+
hit(run, 'S4', secret_paths_1.APPROVAL_STATE_REASON);
|
|
11627
|
+
}
|
|
10454
11628
|
}
|
|
10455
11629
|
// Patch round 3 (delta audit F-CODEX): `.codex/hooks.json` is not compared like the others: any write under .codex stops (S0)
|
|
10456
11630
|
// on every host, including a gemini write_file that leaves the Marrow part as it was.
|