@getmarrow/mcp 3.9.103 → 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 +43 -6
- package/dist/approval-reply.d.ts +2 -0
- package/dist/approval-reply.d.ts.map +1 -0
- package/dist/approval-reply.js +18 -0
- package/dist/approval-reply.js.map +1 -0
- package/dist/cli.js +1677 -1636
- package/dist/cli.js.map +1 -1
- package/dist/codex-plugin-pins.d.ts.map +1 -1
- package/dist/codex-plugin-pins.js +18 -0
- package/dist/codex-plugin-pins.js.map +1 -1
- package/dist/env.d.ts +8 -0
- package/dist/env.d.ts.map +1 -1
- package/dist/env.js +83 -4
- package/dist/env.js.map +1 -1
- package/dist/hook-context.d.ts.map +1 -1
- package/dist/hook-context.js +25 -0
- package/dist/hook-context.js.map +1 -1
- package/dist/hook-contract.d.ts +4 -3
- package/dist/hook-contract.d.ts.map +1 -1
- package/dist/hook-contract.js +43 -6
- package/dist/hook-contract.js.map +1 -1
- package/dist/hook-pre-action.d.ts +43 -7
- package/dist/hook-pre-action.d.ts.map +1 -1
- package/dist/hook-pre-action.js +518 -21
- package/dist/hook-pre-action.js.map +1 -1
- package/dist/hook-tool-policy.d.ts +86 -4
- package/dist/hook-tool-policy.d.ts.map +1 -1
- package/dist/hook-tool-policy.js +354 -21
- package/dist/hook-tool-policy.js.map +1 -1
- package/dist/hook.d.ts.map +1 -1
- package/dist/hook.js +5 -3
- package/dist/hook.js.map +1 -1
- package/dist/host-approval-state.d.ts +24 -0
- package/dist/host-approval-state.d.ts.map +1 -1
- package/dist/host-approval-state.js +68 -0
- package/dist/host-approval-state.js.map +1 -1
- package/dist/host-approval.d.ts +22 -1
- package/dist/host-approval.d.ts.map +1 -1
- package/dist/host-approval.js +65 -4
- package/dist/host-approval.js.map +1 -1
- package/dist/index.d.ts +5 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -1
- package/dist/index.js.map +1 -1
- package/dist/keyless-server.d.ts +53 -0
- package/dist/keyless-server.d.ts.map +1 -0
- package/dist/keyless-server.js +230 -0
- package/dist/keyless-server.js.map +1 -0
- package/dist/normalized-action.d.ts +11 -3
- package/dist/normalized-action.d.ts.map +1 -1
- package/dist/normalized-action.js +305 -49
- package/dist/normalized-action.js.map +1 -1
- package/dist/own-server-identity.d.ts +22 -3
- package/dist/own-server-identity.d.ts.map +1 -1
- package/dist/own-server-identity.js +108 -31
- package/dist/own-server-identity.js.map +1 -1
- package/dist/own-tool.d.ts +14 -6
- package/dist/own-tool.d.ts.map +1 -1
- package/dist/own-tool.js +18 -7
- package/dist/own-tool.js.map +1 -1
- package/dist/seatbelt/env-dump.d.ts +66 -0
- package/dist/seatbelt/env-dump.d.ts.map +1 -0
- package/dist/seatbelt/env-dump.js +333 -0
- package/dist/seatbelt/env-dump.js.map +1 -0
- 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/fallback-copy.d.ts +16 -0
- package/dist/seatbelt/fallback-copy.d.ts.map +1 -0
- package/dist/seatbelt/fallback-copy.js +68 -0
- package/dist/seatbelt/fallback-copy.js.map +1 -0
- package/dist/seatbelt/hook.d.ts +21 -2
- package/dist/seatbelt/hook.d.ts.map +1 -1
- package/dist/seatbelt/hook.js +45 -11
- package/dist/seatbelt/hook.js.map +1 -1
- package/dist/seatbelt/mapper.d.ts.map +1 -1
- package/dist/seatbelt/mapper.js +1592 -85
- package/dist/seatbelt/mapper.js.map +1 -1
- package/dist/seatbelt/tables.d.ts +1 -1
- package/dist/seatbelt/tables.d.ts.map +1 -1
- package/dist/seatbelt/tables.js +13 -17
- package/dist/seatbelt/tables.js.map +1 -1
- package/dist/secret-paths.d.ts +40 -0
- package/dist/secret-paths.d.ts.map +1 -0
- package/dist/secret-paths.js +59 -0
- package/dist/secret-paths.js.map +1 -0
- package/dist/session-loop-guard.d.ts.map +1 -1
- package/dist/session-loop-guard.js +25 -6
- package/dist/session-loop-guard.js.map +1 -1
- package/package.json +2 -2
- package/server.json +2 -2
package/dist/seatbelt/mapper.js
CHANGED
|
@@ -46,10 +46,13 @@ 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");
|
|
55
|
+
const env_dump_1 = require("./env-dump");
|
|
53
56
|
/* eslint-disable @typescript-eslint/no-require-imports */
|
|
54
57
|
const shellParse = require('../../vendor/classifier/shell-parse.js');
|
|
55
58
|
const baseline = require('../../vendor/classifier/policy-baseline.js');
|
|
@@ -182,6 +185,11 @@ function evaluateEvent(event, env) {
|
|
|
182
185
|
if (Array.isArray(rawInput))
|
|
183
186
|
rawInput = { commands: rawInput.filter((v) => typeof v === 'string'), items: rawInput.filter((v) => v && typeof v === 'object') };
|
|
184
187
|
const input = (rawInput && typeof rawInput === 'object' ? rawInput : {});
|
|
188
|
+
// A Claude Code built-in that runs a fixed local git command (exact label) is read as that command,
|
|
189
|
+
// as the keyed hook reads it (hook-tool-policy NATIVE_SHELL_EQUIVALENTS).
|
|
190
|
+
const nativeShell = NATIVE_SHELL_EQUIVALENTS.get(tool);
|
|
191
|
+
if (nativeShell !== undefined)
|
|
192
|
+
rawText = rawText === null ? nativeShell : `${rawText}\n${nativeShell}`;
|
|
185
193
|
// One wall-clock ceiling for the whole event, counted from here (audit N6): the input walks below
|
|
186
194
|
// run before any classification and share it.
|
|
187
195
|
const deadline = deadlineFor(env);
|
|
@@ -197,9 +205,14 @@ function evaluateEvent(event, env) {
|
|
|
197
205
|
}
|
|
198
206
|
const evName = mcp ? mcp.name : name;
|
|
199
207
|
const evInput = mcp ? mcp.args : input;
|
|
208
|
+
// Claude Code's Workflow (exact host label) runs its `script` as JavaScript that only starts
|
|
209
|
+
// sub-agents, whose own tools reach the hook: that one field is not shell text (as the keyed hook
|
|
210
|
+
// reads it, hook-tool-policy NON_SHELL_COMMAND_TOOLS). Every other field is read as for any tool.
|
|
211
|
+
const shellInput = !mcp && tool === 'Workflow' && typeof evInput.script === 'string'
|
|
212
|
+
? Object.fromEntries(Object.entries(evInput).filter(([key]) => key !== 'script')) : evInput;
|
|
200
213
|
// A tool that runs a shell command (audit F11): a known shell tool, or any tool whose input names a
|
|
201
214
|
// command, script or argv field, at any depth.
|
|
202
|
-
const invocation = shellInvocations(evName,
|
|
215
|
+
const invocation = shellInvocations(evName, shellInput, deadline, rawText);
|
|
203
216
|
if (invocation) {
|
|
204
217
|
const run = newRun(env, invocation.shell.join('\n'), deadline);
|
|
205
218
|
run.shell = true;
|
|
@@ -251,6 +264,13 @@ function evaluateEvent(event, env) {
|
|
|
251
264
|
if (clock(run))
|
|
252
265
|
break;
|
|
253
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
|
+
}
|
|
254
274
|
}
|
|
255
275
|
for (const key of ['glob', 'include', 'file_pattern']) {
|
|
256
276
|
const g = input[key];
|
|
@@ -377,6 +397,11 @@ function mcpPathChecks(label, input, ctx, run, allStrings = true) {
|
|
|
377
397
|
checkProtectedWrite(litArg(p), ctx, run, 'mcp_write', false);
|
|
378
398
|
}
|
|
379
399
|
}
|
|
400
|
+
// Claude Code built-ins that run a fixed local git command (exact host label).
|
|
401
|
+
const NATIVE_SHELL_EQUIVALENTS = new Map([
|
|
402
|
+
['EnterWorktree', 'git worktree add'],
|
|
403
|
+
['ExitWorktree', 'git worktree remove'],
|
|
404
|
+
]);
|
|
380
405
|
// Tools that never run what their `command`/`script` field says (it names a skill, a slash command,
|
|
381
406
|
// a sub-agent task), plus Marrow's own tools.
|
|
382
407
|
const NON_SHELL_TOOLS = new Set(['slashcommand', 'skill', 'task', 'agent', 'todowrite', 'todoread', 'exitplanmode', 'enterplanmode',
|
|
@@ -978,6 +1003,7 @@ function newRun(rawEnv, source, deadline) {
|
|
|
978
1003
|
protectedAnchors: anchors.map((a) => (fold ? a.toLowerCase() : a)),
|
|
979
1004
|
operandAnchors: operandAnchors.map((a) => (fold ? a.toLowerCase() : a)),
|
|
980
1005
|
secretRegexes: tables_1.SECRET_PATTERNS.map((p) => classifierModule.globToRegex(p, home)),
|
|
1006
|
+
presentSecretRegexes: tables_1.PRESENT_SECRET_PATTERNS.map((p) => classifierModule.globToRegex(p, home)),
|
|
981
1007
|
branchRegexes: baseline.BASELINE_POLICY.protected_branches.map((p) => classifierModule.globToRegex(p, '/nonexistent-home')),
|
|
982
1008
|
topSource: source,
|
|
983
1009
|
work: 0,
|
|
@@ -2916,6 +2942,11 @@ function dispatchProgram(prog, args, rest, pathLike, ctx, run, x) {
|
|
|
2916
2942
|
run.headMayChange = true;
|
|
2917
2943
|
// Generic operand checks apply to every program (S4 reads, S0 writes by write-programs).
|
|
2918
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);
|
|
2948
|
+
// Every command that prints the environment (a word-level table shared with the keyed hook).
|
|
2949
|
+
envDumpChecks(prog, rest, ctx, run, x);
|
|
2919
2950
|
// NEW-2: a protected path as an operand of a program that is not a known reader.
|
|
2920
2951
|
protectedOperandCheck(prog, rest, ctx, run);
|
|
2921
2952
|
// Patch round 4: the command zip runs to test its archive (-TT, --unzip-command) is command text.
|
|
@@ -3014,6 +3045,10 @@ function dispatchProgram(prog, args, rest, pathLike, ctx, run, x) {
|
|
|
3014
3045
|
case 'awk':
|
|
3015
3046
|
case 'gawk':
|
|
3016
3047
|
case 'mawk': return awkProgram(rest, ctx, run, x);
|
|
3048
|
+
// nawk was read as an unknown program before: it keeps that argv scan (the break falls through to unknownProgram) besides the awk reading.
|
|
3049
|
+
case 'nawk':
|
|
3050
|
+
awkProgram(rest, ctx, run, x);
|
|
3051
|
+
break;
|
|
3017
3052
|
case 'sed':
|
|
3018
3053
|
case 'gsed': return sedProgram(rest, ctx, run);
|
|
3019
3054
|
case 'ps': return psProgram(rest, run);
|
|
@@ -3052,6 +3087,9 @@ function dispatchProgram(prog, args, rest, pathLike, ctx, run, x) {
|
|
|
3052
3087
|
}
|
|
3053
3088
|
default: break;
|
|
3054
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);
|
|
3055
3093
|
if (/^mkfs(?:\.|$)/.test(prog)) {
|
|
3056
3094
|
hit(run, 'S5', 'mkfs');
|
|
3057
3095
|
return;
|
|
@@ -3292,7 +3330,7 @@ const NESTED_NOT_PROGRAMS = new Set(['set', 'export', 'declare', 'typeset', 'rea
|
|
|
3292
3330
|
// Every command hidden in the argv of a program the mapper has no handler for (`strace -f npm
|
|
3293
3331
|
// publish`, `cross-env NODE_ENV=production vercel --prod`, `tmux new -d 'npm publish'`). Used for
|
|
3294
3332
|
// unknown programs and for known wrappers with open grammars (docker run IMAGE CMD…).
|
|
3295
|
-
function scanArgvForCommands(rest, ctx, run, x, remote = false) {
|
|
3333
|
+
function scanArgvForCommands(rest, ctx, run, x, remote = false, execWrapper = false) {
|
|
3296
3334
|
const fed = withFeed(ctx, x, run).stdinFeed;
|
|
3297
3335
|
const base = remote ? childCtx(ctx, { cwds: [null], remote: true, vars: new Map(), aliases: new Map(), backstop: true, stdinFeed: fed }) : { ...ctx, backstop: true };
|
|
3298
3336
|
const env = new Map(x.env);
|
|
@@ -3323,7 +3361,7 @@ function scanArgvForCommands(rest, ctx, run, x, remote = false) {
|
|
|
3323
3361
|
continue;
|
|
3324
3362
|
const tail = rest.slice(k);
|
|
3325
3363
|
const b = progName(w, run);
|
|
3326
|
-
if ((b === 'env' || b === 'printenv') && tail.length === 1)
|
|
3364
|
+
if ((b === 'env' || b === 'printenv') && tail.length === 1 && !execWrapper)
|
|
3327
3365
|
continue;
|
|
3328
3366
|
trials += 1;
|
|
3329
3367
|
if (trials > tables_1.LIMITS.maxTailTrials) {
|
|
@@ -3545,7 +3583,7 @@ function unknownProgram(prog, args, ctx, run, x) {
|
|
|
3545
3583
|
if (tables_1.INERT_PROGRAMS.has(prog) || ctx.backstop)
|
|
3546
3584
|
return;
|
|
3547
3585
|
const rest = args.slice(1);
|
|
3548
|
-
scanArgvForCommands(rest, ctx, run, x);
|
|
3586
|
+
scanArgvForCommands(rest, ctx, run, x, false, env_dump_1.EXEC_WRAPPERS.has(prog));
|
|
3549
3587
|
// stdin text: `echo 'npm publish' | at now`, `at now <<< 'npm publish'`, `batch <<EOF …`.
|
|
3550
3588
|
const src = stdinSource(x, ctx, run);
|
|
3551
3589
|
// Decoded text piped into a program that runs what it reads (at, batch, crontab, a function) or
|
|
@@ -3930,7 +3968,11 @@ function interpreterEnvDump(code) {
|
|
|
3930
3968
|
if (rest.includes('\u0004SECRETENV\u0004'))
|
|
3931
3969
|
return true;
|
|
3932
3970
|
// What is left: any other mention of the environment object is a dump or a computed lookup.
|
|
3933
|
-
|
|
3971
|
+
if (/\bos\.environb?\b|\bprocess\.env\b|%ENV\b|\bENV\b(?!\s*(?:=|==)\s*['"])|\bENVIRON\b|\benvironb?\b\s*(?:[.)\],;:|]|$)|\$_ENV|\$_SERVER|\bSystem\.getenv\s*\(\s*\)|\bSystem\.get_env\s*\(\s*\)|\bgetenv\s*\(\s*(?:null)?\s*\)|\bphpinfo\s*\(|\benvironment\.get_environment_variables|\bGet-ChildItem\s+env:/i.test(rest))
|
|
3972
|
+
return true;
|
|
3973
|
+
// JavaScript reaches the environment object without spelling it out: `const { env } = process`, `process['en' + 'v']`, `with (process) env`, `Reflect.get(process, 'env')`.
|
|
3974
|
+
return (/\bprocess\b/.test(rest) && (/\benv\b/.test(rest) || /\bprocess\s*(?:\?\.)?\s*\[(?!\s*(['"])[A-Za-z_]\w*\1\s*\])/.test(rest)))
|
|
3975
|
+
|| /\b(?:globalThis|global)\s*(?:\?\.)?\s*\[(?!\s*(['"])[A-Za-z_]\w*\1\s*\])/.test(rest);
|
|
3934
3976
|
}
|
|
3935
3977
|
function scanInterpreterCode(code, run) {
|
|
3936
3978
|
if ((0, tables_1.interpreterTreeDelete)(code))
|
|
@@ -4243,7 +4285,9 @@ function longestRealpath(abs, run) {
|
|
|
4243
4285
|
}
|
|
4244
4286
|
return null;
|
|
4245
4287
|
}
|
|
4246
|
-
|
|
4288
|
+
// `mustExist`: a `<name>.env` path (PRESENT_SECRET_PATTERNS) counts only when it is there to read; samples, git
|
|
4289
|
+
// pathspecs and git objects (history) leave it false.
|
|
4290
|
+
function isSecretPath(abs, run, mustExist = false) {
|
|
4247
4291
|
const p = run.fold ? abs.toLowerCase() : abs;
|
|
4248
4292
|
const base = path.posix.basename(p);
|
|
4249
4293
|
if (/^\.env(?:\.|$)/.test(base) && tables_1.ENV_TEMPLATE_SUFFIX.test(base))
|
|
@@ -4254,8 +4298,11 @@ function isSecretPath(abs, run) {
|
|
|
4254
4298
|
return true;
|
|
4255
4299
|
}
|
|
4256
4300
|
const hits = run.secretRegexes.some((re) => re.test(p) || (run.fold && re.test(abs)));
|
|
4257
|
-
if (!hits)
|
|
4258
|
-
|
|
4301
|
+
if (!hits) {
|
|
4302
|
+
if (!run.presentSecretRegexes.some((re) => re.test(p) || (run.fold && re.test(abs))))
|
|
4303
|
+
return false;
|
|
4304
|
+
return !mustExist || safeRealpath(run.env, abs) !== null;
|
|
4305
|
+
}
|
|
4259
4306
|
if (/\/\.ssh\//.test(p) && tables_1.SSH_PUBLIC.test(base))
|
|
4260
4307
|
return false;
|
|
4261
4308
|
return true;
|
|
@@ -4372,6 +4419,24 @@ function compileGlob(pattern, opts) {
|
|
|
4372
4419
|
function patternToRegex(pattern, lookalike) {
|
|
4373
4420
|
return compileGlob(pattern, { dyn: true, lookalike, ci: true });
|
|
4374
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
|
+
}
|
|
4375
4440
|
function sampleMatch(a, ctx, run, samples) {
|
|
4376
4441
|
const raw = a.pattern;
|
|
4377
4442
|
const lookalike = a.nonAscii;
|
|
@@ -4383,41 +4448,98 @@ function sampleMatch(a, ctx, run, samples) {
|
|
|
4383
4448
|
for (const c of ctx.cwds)
|
|
4384
4449
|
cands.push(`${(c || '/__unknown_cwd__').replace(/\/$/, '')}/${expanded}`);
|
|
4385
4450
|
for (const cand of cands) {
|
|
4386
|
-
const re = patternToRegex(cand.replace(/\/\.\//g, '/'), lookalike);
|
|
4451
|
+
const re = patternToRegex(collapseParents(cand.replace(/\/\.\//g, '/')), lookalike);
|
|
4387
4452
|
if (samples.some((s) => re.test(s) && isSecretPath(s, run)))
|
|
4388
4453
|
return true;
|
|
4389
4454
|
}
|
|
4390
4455
|
return false;
|
|
4391
4456
|
}
|
|
4392
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
|
+
}
|
|
4393
4497
|
// S4: a secret path as an operand. Returns true on a stop.
|
|
4394
|
-
|
|
4498
|
+
// `history`: git reads the path from its object store (a pathspec after `--`, a `REV:path` object), so it
|
|
4499
|
+
// need not exist in the work tree.
|
|
4500
|
+
// `pattern`: the pattern of a search (the word in `grep -rn <word> src`), which names no file to read.
|
|
4501
|
+
function checkSecretOperand(a, ctx, run, reason, history = false, pattern = false, envOnly = false) {
|
|
4395
4502
|
if (a.text === null && a.alts && a.alts.length) {
|
|
4396
4503
|
for (const v of a.alts)
|
|
4397
|
-
if (checkSecretOperand({ ...litArg(v), glob: a.glob, word: a.word }, ctx, run, reason))
|
|
4504
|
+
if (checkSecretOperand({ ...litArg(v), glob: a.glob, word: a.word }, ctx, run, reason, history, pattern, envOnly))
|
|
4398
4505
|
return true;
|
|
4399
4506
|
return false;
|
|
4400
4507
|
}
|
|
4401
4508
|
for (const text of operandPaths(a)) {
|
|
4402
4509
|
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(text))
|
|
4403
4510
|
continue;
|
|
4511
|
+
// A name under an unknown working folder (the event gave no absolute cwd): a relative operand, `..` included,
|
|
4512
|
+
// or one under /proc/<pid>/cwd. It may be there to read, so a `<name>.env` operand counts as present and a
|
|
4513
|
+
// glob that can expand to a `*.env` name stops (fail closed). The pattern of a search names no file.
|
|
4514
|
+
const underUnknown = !pattern && ctx.cwds.some((c) => c === null) && (() => {
|
|
4515
|
+
const t = expandTilde(text, run);
|
|
4516
|
+
return t !== null && (!path.posix.isAbsolute(t) || /^\/proc\/[^/]+\/cwd(?:\/|$)/.test(t));
|
|
4517
|
+
})();
|
|
4404
4518
|
// A glob is also listed (through links and names made in this command) and each match judged.
|
|
4405
4519
|
if (a.text !== null && a.glob && /[*?[]/.test(text) && !a.nonAscii && run.env.fs.readdir) {
|
|
4406
4520
|
const matches = expandGlob(text, ctx, run);
|
|
4407
4521
|
if (matches !== null) {
|
|
4408
4522
|
for (const mm of matches.slice(0, tables_1.LIMITS.maxGlobMatches))
|
|
4409
|
-
if (checkSecretOperand({ ...litArg(mm), glob: false }, ctx, run, reason))
|
|
4523
|
+
if (checkSecretOperand({ ...litArg(mm), glob: false }, ctx, run, reason, history, pattern, envOnly))
|
|
4410
4524
|
return true;
|
|
4411
4525
|
for (const k of linkNamesMatching(text, ctx, run))
|
|
4412
|
-
if (checkSecretOperand({ ...litArg(k), glob: false }, ctx, run, reason))
|
|
4526
|
+
if (checkSecretOperand({ ...litArg(k), glob: false }, ctx, run, reason, history, pattern, envOnly))
|
|
4413
4527
|
return true;
|
|
4414
4528
|
}
|
|
4415
4529
|
}
|
|
4416
4530
|
if (a.text === null || a.glob || a.nonAscii) {
|
|
4417
4531
|
const probe = a.text === null ? a : { ...a, pattern: text };
|
|
4418
|
-
|
|
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);
|
|
4419
4539
|
return hit(run, 'S4', `${reason}_secret_pattern`);
|
|
4420
4540
|
}
|
|
4541
|
+
if (underUnknown && a.text !== null && a.glob && /[*?[]/.test(text) && globMayEndWith(text, '.env', run.fold))
|
|
4542
|
+
return hit(run, 'S4', `${reason}_secret_pattern`);
|
|
4421
4543
|
if (a.text === null)
|
|
4422
4544
|
continue;
|
|
4423
4545
|
}
|
|
@@ -4428,13 +4550,143 @@ function checkSecretOperand(a, ctx, run, reason) {
|
|
|
4428
4550
|
// A path through a link whose target the mapper cannot follow (audit N3).
|
|
4429
4551
|
if (run.links.size > 0 && applyLinks(p, run) === '/__unknown_link__')
|
|
4430
4552
|
return hit(run, 'S4', `${reason}_unknown_link`);
|
|
4431
|
-
|
|
4432
|
-
|
|
4433
|
-
|
|
4553
|
+
const mustExist = !history && !(underUnknown || (!pattern && p.startsWith('/__unknown_cwd__/')));
|
|
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
|
+
}
|
|
4563
|
+
}
|
|
4564
|
+
}
|
|
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);
|
|
4568
|
+
}
|
|
4569
|
+
// Whether some name the glob matches (shell rules: `*` and `?` stay inside one path segment) ends with `suffix`:
|
|
4570
|
+
// a walk over (glob position, how much of the suffix the name ends with so far), trying the suffix characters
|
|
4571
|
+
// and a few others for every wildcard.
|
|
4572
|
+
function globMayEndWith(glob, suffix, ci) {
|
|
4573
|
+
const fold = (c) => (ci ? c.toLowerCase() : c);
|
|
4574
|
+
const toks = [];
|
|
4575
|
+
for (let i = 0; i < glob.length; i += 1) {
|
|
4576
|
+
const ch = glob[i];
|
|
4577
|
+
if (ch === '*') {
|
|
4578
|
+
if (toks[toks.length - 1]?.k !== 'star')
|
|
4579
|
+
toks.push({ k: 'star' });
|
|
4580
|
+
continue;
|
|
4434
4581
|
}
|
|
4582
|
+
if (ch === '?') {
|
|
4583
|
+
toks.push({ k: 'any1' });
|
|
4584
|
+
continue;
|
|
4585
|
+
}
|
|
4586
|
+
if (ch === '[') {
|
|
4587
|
+
const end = glob.indexOf(']', i + 2);
|
|
4588
|
+
if (end > 0) {
|
|
4589
|
+
let body = glob.slice(i + 1, end);
|
|
4590
|
+
const neg = body.startsWith('!') || body.startsWith('^');
|
|
4591
|
+
if (neg)
|
|
4592
|
+
body = body.slice(1);
|
|
4593
|
+
const tok = { k: 'cls', neg, chars: [], ranges: [] };
|
|
4594
|
+
for (let q = 0; q < body.length; q += 1) {
|
|
4595
|
+
if (body[q + 1] === '-' && q + 2 < body.length) {
|
|
4596
|
+
tok.ranges.push([fold(body[q]), fold(body[q + 2])]);
|
|
4597
|
+
q += 2;
|
|
4598
|
+
}
|
|
4599
|
+
else
|
|
4600
|
+
tok.chars.push(fold(body[q]));
|
|
4601
|
+
}
|
|
4602
|
+
toks.push(tok);
|
|
4603
|
+
i = end;
|
|
4604
|
+
continue;
|
|
4605
|
+
}
|
|
4606
|
+
}
|
|
4607
|
+
toks.push({ k: 'lit', c: fold(ch) });
|
|
4608
|
+
}
|
|
4609
|
+
const want = fold(suffix);
|
|
4610
|
+
const tries = [...new Set([...want, 'x', 'A', '0', '_', '-', ...toks.flatMap((t) => (t.k === 'cls' ? [...t.chars, ...t.ranges.flat()] : []))])].filter((c) => c !== '/');
|
|
4611
|
+
const step = (k, c) => {
|
|
4612
|
+
const s = want.slice(0, k) + c;
|
|
4613
|
+
for (let n = Math.min(s.length, want.length); n > 0; n -= 1)
|
|
4614
|
+
if (s.endsWith(want.slice(0, n)))
|
|
4615
|
+
return n;
|
|
4616
|
+
return 0;
|
|
4617
|
+
};
|
|
4618
|
+
const accepts = (t, c) => {
|
|
4619
|
+
if (t.k === 'lit')
|
|
4620
|
+
return t.c === c;
|
|
4621
|
+
if (t.k === 'any1' || t.k === 'star')
|
|
4622
|
+
return c !== '/';
|
|
4623
|
+
const inSet = t.chars.includes(c) || t.ranges.some(([lo, hi]) => c >= lo && c <= hi);
|
|
4624
|
+
return t.neg ? !inSet && c !== '/' : inSet;
|
|
4625
|
+
};
|
|
4626
|
+
const seen = new Set();
|
|
4627
|
+
const queue = [[0, 0]];
|
|
4628
|
+
while (queue.length) {
|
|
4629
|
+
const [i, k] = queue.pop();
|
|
4630
|
+
const key = `${i}:${k}`;
|
|
4631
|
+
if (seen.has(key))
|
|
4632
|
+
continue;
|
|
4633
|
+
seen.add(key);
|
|
4634
|
+
if (i === toks.length) {
|
|
4635
|
+
if (k === want.length)
|
|
4636
|
+
return true;
|
|
4637
|
+
continue;
|
|
4638
|
+
}
|
|
4639
|
+
const t = toks[i];
|
|
4640
|
+
const chars = t.k === 'lit' ? [t.c] : tries;
|
|
4641
|
+
if (t.k === 'star')
|
|
4642
|
+
queue.push([i + 1, k]);
|
|
4643
|
+
for (const c of chars)
|
|
4644
|
+
if (accepts(t, c))
|
|
4645
|
+
queue.push([t.k === 'star' ? i : i + 1, step(k, c)]);
|
|
4435
4646
|
}
|
|
4436
4647
|
return false;
|
|
4437
4648
|
}
|
|
4649
|
+
// The words a search reads as its pattern, not as a file: the value of -e/--regexp (also attached), and the
|
|
4650
|
+
// first operand when no -e/-f gave one. A form not listed here is read as a file (the strict reading).
|
|
4651
|
+
function searchPatternArgs(rest, end = rest.length) {
|
|
4652
|
+
const out = new Set();
|
|
4653
|
+
let given = false;
|
|
4654
|
+
let first = null;
|
|
4655
|
+
for (let i = 0; i < end; i += 1) {
|
|
4656
|
+
const t = rest[i].text;
|
|
4657
|
+
if (t === null) {
|
|
4658
|
+
if (first === null)
|
|
4659
|
+
first = rest[i];
|
|
4660
|
+
continue;
|
|
4661
|
+
}
|
|
4662
|
+
if (t === '--')
|
|
4663
|
+
break;
|
|
4664
|
+
if (t === '-e' || t === '--regexp') {
|
|
4665
|
+
given = true;
|
|
4666
|
+
if (rest[i + 1])
|
|
4667
|
+
out.add(rest[i + 1]);
|
|
4668
|
+
i += 1;
|
|
4669
|
+
continue;
|
|
4670
|
+
}
|
|
4671
|
+
if (/^(?:--regexp=|-e.)/.test(t)) {
|
|
4672
|
+
given = true;
|
|
4673
|
+
out.add(rest[i]);
|
|
4674
|
+
continue;
|
|
4675
|
+
}
|
|
4676
|
+
if (t === '-f' || t === '--file' || /^(?:--file=|-f.)/.test(t)) {
|
|
4677
|
+
given = true;
|
|
4678
|
+
if (t === '-f' || t === '--file')
|
|
4679
|
+
i += 1;
|
|
4680
|
+
continue;
|
|
4681
|
+
}
|
|
4682
|
+
if (first === null && !t.startsWith('-'))
|
|
4683
|
+
first = rest[i];
|
|
4684
|
+
}
|
|
4685
|
+
if (!given && first !== null)
|
|
4686
|
+
out.add(first);
|
|
4687
|
+
return out;
|
|
4688
|
+
}
|
|
4689
|
+
const SEARCH_PROGRAMS = new Set(['grep', 'egrep', 'fgrep', 'zgrep', 'rg', 'ag', 'ack']);
|
|
4438
4690
|
// Path-ish parts of an operand: the value after `--opt=`, after `@` (curl -d @file) and after `:`
|
|
4439
4691
|
// for git object paths (HEAD:.env).
|
|
4440
4692
|
function operandPaths(a) {
|
|
@@ -4676,7 +4928,11 @@ function isSecretDirOrAncestor(text, ctx, run) {
|
|
|
4676
4928
|
if (!abs)
|
|
4677
4929
|
return false;
|
|
4678
4930
|
const dirs = SECRET_DIRS.map((d) => path.posix.join(run.home, d));
|
|
4679
|
-
|
|
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;
|
|
4680
4936
|
}
|
|
4681
4937
|
// `cat $(echo ~/.ssh/id_rsa)`, `cat $(ls ~/.ssh/id_*)`, `cat $(find ~/.ssh -name 'id_*')`: the path
|
|
4682
4938
|
// is produced by a substitution that the reader then opens.
|
|
@@ -4705,6 +4961,14 @@ function substitutionOperandCheck(prog, rest, ctx, run, subs) {
|
|
|
4705
4961
|
}
|
|
4706
4962
|
// grep -r, rg, ag and ack over a credential directory, or over a directory that holds one, print
|
|
4707
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
|
+
}
|
|
4708
4972
|
function recursiveSecretDirRead(prog, rest, ctx, run) {
|
|
4709
4973
|
const searchers = new Set(['grep', 'egrep', 'fgrep', 'zgrep', 'rg', 'ag', 'ack']);
|
|
4710
4974
|
if (!searchers.has(prog))
|
|
@@ -4732,6 +4996,7 @@ function genericOperandChecks(prog, args, ctx, run, subs = []) {
|
|
|
4732
4996
|
recursiveSecretDirRead(prog, rest, ctx, run);
|
|
4733
4997
|
if (!tables_1.S4_EXEMPT_PROGRAMS.has(prog) && prog !== 'git') {
|
|
4734
4998
|
let skipNext = false;
|
|
4999
|
+
const patterns = SEARCH_PROGRAMS.has(prog) ? searchPatternArgs(rest) : null;
|
|
4735
5000
|
for (let i = 0; i < rest.length; i += 1) {
|
|
4736
5001
|
const a = rest[i];
|
|
4737
5002
|
if (skipNext) {
|
|
@@ -4745,7 +5010,7 @@ function genericOperandChecks(prog, args, ctx, run, subs = []) {
|
|
|
4745
5010
|
}
|
|
4746
5011
|
if (clock(run))
|
|
4747
5012
|
return;
|
|
4748
|
-
checkSecretOperand(a, ctx, run, 'operand');
|
|
5013
|
+
checkSecretOperand(a, ctx, run, 'operand', false, patterns !== null && patterns.has(a));
|
|
4749
5014
|
}
|
|
4750
5015
|
}
|
|
4751
5016
|
// Copying or archiving a directory that holds credential files (cp -r ~/.ssh, tar ~) is a read too.
|
|
@@ -4757,6 +5022,11 @@ function genericOperandChecks(prog, args, ctx, run, subs = []) {
|
|
|
4757
5022
|
const abs = absCandidates(a.text, ctx, run);
|
|
4758
5023
|
if (!abs)
|
|
4759
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
|
+
}
|
|
4760
5030
|
if (abs.some((p) => realCandidates(p, run, true).some((r) => secretDirs.some((d) => isAncestorOrSelf(r, d))))) {
|
|
4761
5031
|
hit(run, 'S4', 'copy_secret_directory');
|
|
4762
5032
|
break;
|
|
@@ -5916,6 +6186,17 @@ function targetDirFlag(rest) {
|
|
|
5916
6186
|
}
|
|
5917
6187
|
// ---------------------------------------------------------------------------------------------
|
|
5918
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
|
+
}
|
|
5919
6200
|
function changeDir(rest, ctx, run, subshell) {
|
|
5920
6201
|
const ops = nonOptions(rest);
|
|
5921
6202
|
const target = ops[0];
|
|
@@ -5924,7 +6205,7 @@ function changeDir(rest, ctx, run, subshell) {
|
|
|
5924
6205
|
return;
|
|
5925
6206
|
}
|
|
5926
6207
|
if (target.text === null || target.text === '-' || target.glob) {
|
|
5927
|
-
ctx.cwds = [...new Set([...ctx.cwds, null])];
|
|
6208
|
+
ctx.cwds = [...new Set([...ctx.cwds, ...procFolderGuesses(target, ctx), null])];
|
|
5928
6209
|
return;
|
|
5929
6210
|
}
|
|
5930
6211
|
const next = [];
|
|
@@ -6500,8 +6781,16 @@ function simpleWrapper(prog, rest, ctx, run, x) {
|
|
|
6500
6781
|
caffeinate: ['-t', '-w'], taskset: [], chrt: [], retry: ['-t', '--times', '-d', '--delay', '--until'], flock: ['-w', '--timeout', '-E', '--conflict-exit-code'],
|
|
6501
6782
|
};
|
|
6502
6783
|
const vf = new Set(valueFlags[prog] || []);
|
|
6503
|
-
|
|
6504
|
-
|
|
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
|
+
}
|
|
6505
6794
|
while (i < rest.length) {
|
|
6506
6795
|
const t = rest[i].text;
|
|
6507
6796
|
if (t === null)
|
|
@@ -6670,6 +6959,7 @@ function xargsWrapper(prog, rest, ctx, run, x) {
|
|
|
6670
6959
|
const sep = inner.findIndex((a) => a.text !== null && /^::::?\+?$/.test(a.text));
|
|
6671
6960
|
const inputs = sep >= 0 ? inner.slice(sep + 1) : [];
|
|
6672
6961
|
inner = sep >= 0 ? inner.slice(0, sep) : inner;
|
|
6962
|
+
xargsRunsBareEnv(inner, run);
|
|
6673
6963
|
if (inner.length === 0) {
|
|
6674
6964
|
// `parallel ::: 'cmd1' 'cmd2'` and `… | parallel`: every input line is a command line.
|
|
6675
6965
|
if (sep >= 0) {
|
|
@@ -6692,8 +6982,13 @@ function xargsWrapper(prog, rest, ctx, run, x) {
|
|
|
6692
6982
|
evalShell(/\{(?:[.\/#%]|\/\.|\d+)?\}/.test(inner.map((a) => a.text || '').join(' ')) ? text : `${text} ${marker}`, childCtx(ctx), run);
|
|
6693
6983
|
return;
|
|
6694
6984
|
}
|
|
6985
|
+
xargsRunsBareEnv(inner, run);
|
|
6695
6986
|
if (inner.length === 0)
|
|
6696
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);
|
|
6697
6992
|
// Input lines become extra operands: unknown targets (spec A6). The -I replacement string stands
|
|
6698
6993
|
// for one input line wherever it appears.
|
|
6699
6994
|
let replaced = false;
|
|
@@ -6815,6 +7110,36 @@ function sshRemote(rest, ctx, run, x) {
|
|
|
6815
7110
|
else
|
|
6816
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');
|
|
6817
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
|
+
}
|
|
6818
7143
|
function handleShell(prog, rest, ctx, run, x) {
|
|
6819
7144
|
// `env BASH_ENV=f bash -c :`: a startup file named by a wrapper's assignment (audit N1).
|
|
6820
7145
|
for (const name of ['BASH_ENV', 'ENV']) {
|
|
@@ -6857,16 +7182,20 @@ function handleShell(prog, rest, ctx, run, x) {
|
|
|
6857
7182
|
if (/^[-+][a-zA-Z]+$/.test(t) && /[coO]/.test(t)) {
|
|
6858
7183
|
let used = 0;
|
|
6859
7184
|
let codeArg = null;
|
|
7185
|
+
let codeAt = -1;
|
|
6860
7186
|
for (const ch of t.slice(1)) {
|
|
6861
7187
|
if (ch === 'c' && t[0] === '-') {
|
|
6862
|
-
|
|
7188
|
+
codeAt = i + 1 + used;
|
|
7189
|
+
codeArg = rest[codeAt] || null;
|
|
6863
7190
|
used += 1;
|
|
6864
7191
|
}
|
|
6865
7192
|
else if (ch === 'o' || ch === 'O')
|
|
6866
7193
|
used += 1;
|
|
6867
7194
|
}
|
|
6868
|
-
if (codeArg)
|
|
7195
|
+
if (codeArg) {
|
|
7196
|
+
positionalCommands(codeArg, rest.slice(codeAt + 1), ctx, run);
|
|
6869
7197
|
return shellString(codeArg, withFeed(ctx, x, run), run, x.info.subs);
|
|
7198
|
+
}
|
|
6870
7199
|
i += used;
|
|
6871
7200
|
continue;
|
|
6872
7201
|
}
|
|
@@ -6874,6 +7203,7 @@ function handleShell(prog, rest, ctx, run, x) {
|
|
|
6874
7203
|
const code = rest[i + 1];
|
|
6875
7204
|
if (!code)
|
|
6876
7205
|
return;
|
|
7206
|
+
positionalCommands(code, rest.slice(i + 2), ctx, run);
|
|
6877
7207
|
// What this command's stdin carries (here-string, heredoc, pipe, enclosing group) reaches any
|
|
6878
7208
|
// reader inside the `-c` text.
|
|
6879
7209
|
return shellString(code, withFeed(ctx, x, run), run, x.info.subs);
|
|
@@ -6894,12 +7224,13 @@ function handleShell(prog, rest, ctx, run, x) {
|
|
|
6894
7224
|
}
|
|
6895
7225
|
function handleInterpreter(prog, rest, ctx, run, x) {
|
|
6896
7226
|
const codeFlags = {
|
|
6897
|
-
node: /^(?:-e|--eval|-p|--print|-pe)$/, nodejs: /^(?:-e|--eval|-p|--print)$/, bun: /^(?:-e|--eval|-p|--print)$/,
|
|
7227
|
+
node: /^(?:-e|--eval|-p|--print|-pe|-ep)$/, 'ts-node': /^(?:-e|--eval|-p|--print)$/, tsx: /^(?:-e|--eval|-p|--print)$/, nodejs: /^(?:-e|--eval|-p|--print)$/, bun: /^(?:-e|--eval|-p|--print)$/,
|
|
6898
7228
|
deno: /^(?:eval)$/, php: /^-r$/, ruby: /^-[a-zA-Z]*e$/, perl: /^-[a-zA-Z]*[eE]$/, lua: /^-e$/, irb: /^-e$/,
|
|
6899
7229
|
pwsh: /^-(?:c|command|e|ec|enc|encodedcommand)$/i, powershell: /^-(?:c|command|e|ec|enc|encodedcommand)$/i,
|
|
6900
7230
|
};
|
|
6901
|
-
const isPython = /^(?:python[23]?(?:\.\d+)?|ipython)$/.test(prog);
|
|
6902
|
-
|
|
7231
|
+
const isPython = /^(?:python[23]?(?:\.\d+)?|ipython|pypy3?(?:\.\d+)?)$/.test(prog);
|
|
7232
|
+
// pypy keeps the `-e` it was read with before it was read as Python (a stop, never a pass), besides Python's `-c`.
|
|
7233
|
+
const codeRe = isPython ? (prog.startsWith('pypy') ? /^(?:-[a-zA-Z]*c|-e)$/ : /^-[a-zA-Z]*c$/) : (codeFlags[prog] || /^-e$/);
|
|
6903
7234
|
for (let i = 0; i < rest.length; i += 1) {
|
|
6904
7235
|
const a = rest[i];
|
|
6905
7236
|
const t = a.text;
|
|
@@ -6914,6 +7245,12 @@ function handleInterpreter(prog, rest, ctx, run, x) {
|
|
|
6914
7245
|
hit(run, 'Sx', 'dynamic_interpreter_code');
|
|
6915
7246
|
return;
|
|
6916
7247
|
}
|
|
7248
|
+
// The code attached to its flag: `node --eval=CODE`, `perl -e'CODE'`, `ruby -e'CODE'`, `php -r'CODE'`, `python3 -c'CODE'`.
|
|
7249
|
+
const attachedCode = /^(?:node|nodejs|bun)$/.test(prog) ? /^--(?:eval|print)=([\s\S]+)$/.exec(t)
|
|
7250
|
+
: isPython ? /^-[a-zA-Z]*c([\s\S]+)$/.exec(t) : prog === 'perl' ? /^-[a-zA-Z]*[eE]([\s\S]+)$/.exec(t) : prog === 'ruby' ? /^-[a-zA-Z]*e([\s\S]+)$/.exec(t)
|
|
7251
|
+
: prog === 'php' ? /^-r([\s\S]+)$/.exec(t) : null;
|
|
7252
|
+
if (attachedCode)
|
|
7253
|
+
scanInterpreterCode(attachedCode[1], run);
|
|
6917
7254
|
if (codeRe.test(t) || (prog === 'deno' && t === 'eval')) {
|
|
6918
7255
|
const code = rest[i + 1];
|
|
6919
7256
|
if (!code)
|
|
@@ -6928,8 +7265,26 @@ function handleInterpreter(prog, rest, ctx, run, x) {
|
|
|
6928
7265
|
return;
|
|
6929
7266
|
}
|
|
6930
7267
|
}
|
|
6931
|
-
const
|
|
6932
|
-
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
|
+
}
|
|
6933
7288
|
// Code that reads its own stdin and runs what it reads: the stdin text is a command too.
|
|
6934
7289
|
// Code that can start a process passes its stdin on to it, whether or not it reads it itself.
|
|
6935
7290
|
if (tables_1.INTERPRETER_EXEC.test(codeText))
|
|
@@ -6943,6 +7298,16 @@ function handleInterpreter(prog, rest, ctx, run, x) {
|
|
|
6943
7298
|
if (extra.text !== null && !extra.text.startsWith('-'))
|
|
6944
7299
|
evalShell(extra.text, childCtx(ctx), run);
|
|
6945
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);
|
|
6946
7311
|
return;
|
|
6947
7312
|
}
|
|
6948
7313
|
if ((prog === 'deno' || prog === 'bun') && (t === 'run' || t === 'repl'))
|
|
@@ -7027,15 +7392,65 @@ function stdinInterpreter(prog, x, ctx, run) {
|
|
|
7027
7392
|
}
|
|
7028
7393
|
// BSD `ps e`, `ps auxe`, `ps eww` print every process's environment.
|
|
7029
7394
|
function psProgram(rest, run) {
|
|
7395
|
+
const words = rest.map((a) => a.text);
|
|
7396
|
+
if ((0, env_dump_1.psPrintsEnvironment)(words))
|
|
7397
|
+
hit(run, 'S4', 'ps_environment');
|
|
7398
|
+
// What 3.9.103 stopped and no later version loosens: the keyed hook leaves this reading out.
|
|
7399
|
+
else if ((0, env_dump_1.psLooselyPrintsEnvironment)(words))
|
|
7400
|
+
hit(run, 'S4', 'ps_environment_loose');
|
|
7401
|
+
}
|
|
7402
|
+
// Commands that print many or all environment variables, found by their words alone (never by file contents):
|
|
7403
|
+
// `compgen -e`, `tmux show-environment`, `systemctl show-environment`, `php -i`, a jq filter that reads `env`,
|
|
7404
|
+
// `npm run env`, and any program given /proc/<pid>/environ.
|
|
7405
|
+
function envDumpChecks(prog, rest, ctx, run, x) {
|
|
7406
|
+
const words = rest.map((a) => a.text);
|
|
7407
|
+
if (prog === 'compgen' && (0, env_dump_1.compgenListsVariables)(words))
|
|
7408
|
+
hit(run, 'S4', 'env_dump_compgen');
|
|
7409
|
+
else if (prog === 'tmux' && ((0, env_dump_1.tmuxShowsEnvironment)(words) || (0, env_dump_1.tmuxRunsEnvironmentPrinter)(words)))
|
|
7410
|
+
hit(run, 'S4', 'env_dump_tmux');
|
|
7411
|
+
else if (prog === 'strace' && (0, env_dump_1.straceShowsEnvironment)(words))
|
|
7412
|
+
hit(run, 'S4', 'env_dump_strace');
|
|
7413
|
+
else if (prog === 'man' && (0, env_dump_1.manPagerPrintsEnvironment)(words))
|
|
7414
|
+
hit(run, 'S4', 'env_dump_env');
|
|
7415
|
+
else if (prog === 'systemctl' && (0, env_dump_1.systemctlShowsEnvironment)(words))
|
|
7416
|
+
hit(run, 'S4', 'env_dump_systemctl');
|
|
7417
|
+
else if (prog === 'php' && words.some((w) => w === '-i' || w === '--info'))
|
|
7418
|
+
hit(run, 'S4', 'env_dump_php_info');
|
|
7419
|
+
else if (prog === 'jq' && ((0, env_dump_1.jqFilters)(words).some(env_dump_1.jqFilterReadsEnvironment) || (x.cmd.heredoc !== null && words.some((w) => w !== null && STDIN_PATHS.has(w)) && (0, env_dump_1.jqFilterReadsEnvironment)(x.cmd.heredoc))))
|
|
7420
|
+
hit(run, 'S4', 'env_dump_jq');
|
|
7421
|
+
else if ((prog === 'npm' || prog === 'pnpm' || prog === 'yarn' || prog === 'bun') && (0, env_dump_1.packageRunsEnvScript)(words))
|
|
7422
|
+
hit(run, 'S4', 'env_dump_npm_run_env');
|
|
7423
|
+
for (const [name, values] of x.env) {
|
|
7424
|
+
if (env_dump_1.HELPER_ENV_VARS.has(name) && values !== null && values.some((v) => env_dump_1.ENV_PRINTER_COMMAND.test(v))) {
|
|
7425
|
+
hit(run, 'S4', 'env_dump_env');
|
|
7426
|
+
break;
|
|
7427
|
+
}
|
|
7428
|
+
}
|
|
7030
7429
|
for (const a of rest) {
|
|
7031
|
-
|
|
7430
|
+
// A dynamic part of the path (`/proc/$(pgrep node)/environ`) stands for any pid.
|
|
7431
|
+
const text = a.text !== null ? a.text : a.pattern.split(DYN).join('x');
|
|
7432
|
+
if (!text.startsWith('/') && !text.includes('proc'))
|
|
7032
7433
|
continue;
|
|
7033
|
-
if (
|
|
7034
|
-
hit(run, 'S4', '
|
|
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)) {
|
|
7435
|
+
hit(run, 'S4', 'proc_environ');
|
|
7035
7436
|
return;
|
|
7036
7437
|
}
|
|
7037
7438
|
}
|
|
7038
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
|
+
}
|
|
7445
|
+
// `xargs env`, `parallel env ::: x`: the command is `env` or `printenv` with only options and assignments, so it prints the environment.
|
|
7446
|
+
function xargsRunsBareEnv(words, run) {
|
|
7447
|
+
const b = words[0] && words[0].text !== null ? progName(words[0], run) : null;
|
|
7448
|
+
if (b !== 'env' && b !== 'printenv')
|
|
7449
|
+
return;
|
|
7450
|
+
const bare = words.slice(1).every((a) => a.text !== null && (a.text.startsWith('-') || (b === 'env' && (/^[A-Za-z_]\w*=/.test(a.text) || /^\{[^}]*\}$/.test(a.text)))));
|
|
7451
|
+
if (bare)
|
|
7452
|
+
hit(run, 'S4', b === 'env' ? 'env_dump_env' : 'env_dump_printenv');
|
|
7453
|
+
}
|
|
7039
7454
|
// trap 'cmd' SIGNAL…: the command runs when the signal arrives.
|
|
7040
7455
|
// util-linux `script [-q] [-c COMMAND] [file]`: COMMAND runs in a pty with this command's stdin.
|
|
7041
7456
|
function scriptSession(rest, ctx, run, x) {
|
|
@@ -7069,54 +7484,798 @@ function trapBuiltin(rest, ctx, run, x) {
|
|
|
7069
7484
|
}
|
|
7070
7485
|
// awk programs run commands through system() and pipes.
|
|
7071
7486
|
const AWK_EXEC = /\bsystem\s*\(|\|\s*&?\s*["'$a-zA-Z(]|\bgetline\b|\bENVIRON\b|\bPROCINFO\b/;
|
|
7072
|
-
|
|
7073
|
-
|
|
7074
|
-
|
|
7075
|
-
|
|
7076
|
-
|
|
7077
|
-
|
|
7078
|
-
|
|
7079
|
-
|
|
7080
|
-
|
|
7081
|
-
|
|
7082
|
-
|
|
7083
|
-
|
|
7084
|
-
|
|
7085
|
-
|
|
7086
|
-
|
|
7087
|
-
|
|
7088
|
-
|
|
7487
|
+
// A program that can start a command and names its own arguments (`system(ARGV[1])`, `for (i = 1; i < ARGC; i++) s = s ARGV[i]`) may run them: they are command text.
|
|
7488
|
+
const AWK_RUNS_ARGV = /\bsystem\s*\(|\|\s*&?\s*["'$a-zA-Z(]|\bgetline\b/;
|
|
7489
|
+
function awkArgvCommands(text, rest, ctx, run, x) {
|
|
7490
|
+
if (AWK_RUNS_ARGV.test(text) && /\b(?:ARGV|ARGC)\b/.test(text) && !ctx.backstop)
|
|
7491
|
+
scanArgvForCommands(rest, ctx, run, x);
|
|
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')
|
|
7089
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;
|
|
7586
|
+
}
|
|
7587
|
+
v += String.fromCharCode(parseInt(d, 8) & 0xff);
|
|
7588
|
+
}
|
|
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)
|
|
7090
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
|
+
}
|
|
7091
7777
|
}
|
|
7092
|
-
|
|
7093
|
-
|
|
7094
|
-
|
|
7095
|
-
|
|
7096
|
-
|
|
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);
|
|
7097
8008
|
}
|
|
7098
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))) {
|
|
7099
8051
|
i += 1;
|
|
7100
8052
|
continue;
|
|
7101
8053
|
}
|
|
7102
|
-
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 === '--') {
|
|
7103
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
|
+
}
|
|
7104
8183
|
continue;
|
|
7105
8184
|
}
|
|
7106
|
-
|
|
8185
|
+
// `-version` and `-safe` are the one-true-awk's single-dash long options.
|
|
8186
|
+
if (t === '-version' || t === '-safe')
|
|
7107
8187
|
continue;
|
|
7108
|
-
|
|
7109
|
-
|
|
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
|
+
}
|
|
7110
8222
|
}
|
|
7111
|
-
|
|
7112
|
-
|
|
7113
|
-
|
|
7114
|
-
|
|
7115
|
-
|
|
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);
|
|
7116
8274
|
// `awk '{system($0)}'` runs the lines it reads: the input is a command text (audit N1).
|
|
7117
|
-
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))
|
|
7118
8276
|
stdinExec('sh', [], x, ctx, run);
|
|
7119
8277
|
}
|
|
8278
|
+
awkCommandSinks(texts.map(([text]) => text).join('\n'), wordList, assigned, anyAssign, ctx, run, x);
|
|
7120
8279
|
}
|
|
7121
8280
|
// GNU sed runs a command with the `e` command and the `s///e` flag.
|
|
7122
8281
|
function sedProgram(rest, ctx, run) {
|
|
@@ -8290,19 +9449,119 @@ function handleGit(rest, ctx, run, x, cOverrides, aliasDepth) {
|
|
|
8290
9449
|
hit(run, 'S4', 'git_credential_get');
|
|
8291
9450
|
return;
|
|
8292
9451
|
}
|
|
8293
|
-
|
|
8294
|
-
|
|
9452
|
+
// `git log` prints names and messages, but with patch output it prints the content of the files it
|
|
9453
|
+
// names, as `git show` does: then its operands are read like those of `git show`.
|
|
9454
|
+
if (!tables_1.GIT_S4_EXEMPT_SUBS.has(sub) || (sub === 'log' && gitLogPrintsPatch(args))) {
|
|
9455
|
+
// Commit messages are prose, never paths. A pathspec after `--` or a `REV:path` object is read from history.
|
|
9456
|
+
// log, whatchanged and format-patch take no message: their -m and -F are flags, and the next word is read.
|
|
9457
|
+
const prose = !GIT_PATCH_LOG_SUBS.has(sub);
|
|
9458
|
+
const dashDash = args.findIndex((a) => a.text === '--');
|
|
9459
|
+
const patterns = sub === 'grep' ? searchPatternArgs(args, dashDash >= 0 ? dashDash : args.length) : null;
|
|
8295
9460
|
for (let k = 0; k < args.length; k += 1) {
|
|
8296
9461
|
const t = args[k].text;
|
|
8297
|
-
if (t === '-m' || t === '--message' || t === '-F' || t === '--file' || (t !== null && /^-[a-zA-Z]*m$/.test(t))) {
|
|
9462
|
+
if (prose && (t === '-m' || t === '--message' || t === '-F' || t === '--file' || (t !== null && /^-[a-zA-Z]*m$/.test(t)))) {
|
|
8298
9463
|
k += 1;
|
|
8299
9464
|
continue;
|
|
8300
9465
|
}
|
|
8301
|
-
if (t !== null && /^(?:--message=|-m.)/.test(t))
|
|
9466
|
+
if (prose && t !== null && /^(?:--message=|-m.)/.test(t))
|
|
8302
9467
|
continue;
|
|
8303
|
-
|
|
9468
|
+
const history = (dashDash >= 0 && k > dashDash) || t === null || /^[^/:]+:/.test(t);
|
|
9469
|
+
checkSecretOperand(args[k], gctx, run, 'git_operand', history, patterns !== null && patterns.has(args[k]));
|
|
9470
|
+
// `-L<range>:<file>` (or `-L <range>:<file>`) prints the history of <file>: every part after a colon is read.
|
|
9471
|
+
if (!prose && t !== null && /^-L/.test(t) && (dashDash < 0 || k < dashDash)) {
|
|
9472
|
+
const value = t === '-L' ? (args[k + 1] ? args[k + 1].text : null) : t.slice(2);
|
|
9473
|
+
if (value !== null) {
|
|
9474
|
+
for (let c = value.indexOf(':'); c >= 0; c = value.indexOf(':', c + 1)) {
|
|
9475
|
+
if (c + 1 < value.length)
|
|
9476
|
+
checkSecretOperand(litArg(value.slice(c + 1)), gctx, run, 'git_operand', true);
|
|
9477
|
+
}
|
|
9478
|
+
}
|
|
9479
|
+
}
|
|
9480
|
+
}
|
|
9481
|
+
}
|
|
9482
|
+
}
|
|
9483
|
+
// Subcommands whose output can be the patches of the files they name; none takes a commit message.
|
|
9484
|
+
const GIT_PATCH_LOG_SUBS = new Set(['log', 'whatchanged', 'format-patch']);
|
|
9485
|
+
// Long options of `git log` that print patches (each implies --patch, or prints changed lines: --check).
|
|
9486
|
+
const GIT_LOG_PATCH_LONG = new Set(['--patch', '--patch-with-stat', '--patch-with-raw', '--unified', '--binary', '--check', '--cc', '--dd',
|
|
9487
|
+
'--remerge-diff', '--combined']);
|
|
9488
|
+
// Words that never take the next word as their value (so a `-s` after one of them is an option).
|
|
9489
|
+
const GIT_LOG_FLAG_WORDS = new Set([...GIT_LOG_PATCH_LONG, '-p', '-u', '-s', '-c', '-m', '--no-patch', '--oneline', '--stat', '--all', '--follow',
|
|
9490
|
+
'--graph', '--reverse', '--first-parent', '--no-merges', '--merges', '--name-only', '--name-status', '--raw', '--numstat', '--shortstat',
|
|
9491
|
+
'--summary', '--decorate', '--full-diff', '--color', '--no-color', '--abbrev-commit', '--no-abbrev-commit', '--topo-order', '--date-order',
|
|
9492
|
+
'--no-renames', '--no-ext-diff', '--no-textconv', '--no-diff-merges']);
|
|
9493
|
+
// Whether `git log <args>` prints patches. On: -p, -u, --patch, -U<n>, --unified, -L, --binary, --check, -c,
|
|
9494
|
+
// --cc, --dd, --remerge-diff, --diff-merges=<format> other than off/none, --patch-with-stat/raw, a short cluster
|
|
9495
|
+
// with p, u, U or L (`-sp`), a word the mapper cannot read, and any long option not on the read list (git takes
|
|
9496
|
+
// no abbreviation here, so it cannot be a known one). Off: -s or --no-patch after the last of them, only where the
|
|
9497
|
+
// word cannot be the value of the word before it (`--author -s`) and only before --end-of-options. A word after
|
|
9498
|
+
// `--` is a pathspec (`git log -- -p`).
|
|
9499
|
+
function gitLogPrintsPatch(args) {
|
|
9500
|
+
let on = false;
|
|
9501
|
+
let offs = true;
|
|
9502
|
+
// A word that is not an option itself or holds its value (`--author=x`, `-n5`, `-Sx`) takes no next word.
|
|
9503
|
+
const mayTakeNext = (k) => {
|
|
9504
|
+
if (k < 0)
|
|
9505
|
+
return false;
|
|
9506
|
+
const p = args[k].text;
|
|
9507
|
+
if (p === null)
|
|
9508
|
+
return true;
|
|
9509
|
+
if (!p.startsWith('-') || p === '-' || p.includes('=') || GIT_LOG_FLAG_WORDS.has(p))
|
|
9510
|
+
return false;
|
|
9511
|
+
if (p.startsWith('--'))
|
|
9512
|
+
return true;
|
|
9513
|
+
const m = p.slice(1).match(/[SGOIlLnU]/);
|
|
9514
|
+
return m !== null && m.index === p.length - 2;
|
|
9515
|
+
};
|
|
9516
|
+
for (let k = 0; k < args.length; k += 1) {
|
|
9517
|
+
const t = args[k].text;
|
|
9518
|
+
if (t === '--')
|
|
9519
|
+
break;
|
|
9520
|
+
if (t === null) {
|
|
9521
|
+
on = true;
|
|
9522
|
+
continue;
|
|
9523
|
+
}
|
|
9524
|
+
if (t === '--end-of-options') {
|
|
9525
|
+
offs = false;
|
|
9526
|
+
continue;
|
|
9527
|
+
}
|
|
9528
|
+
const off = offs && !mayTakeNext(k - 1);
|
|
9529
|
+
if (t === '-c' || t === '--no-patch') {
|
|
9530
|
+
if (t === '-c')
|
|
9531
|
+
on = true;
|
|
9532
|
+
else if (off)
|
|
9533
|
+
on = false;
|
|
9534
|
+
continue;
|
|
9535
|
+
}
|
|
9536
|
+
if (t.startsWith('--')) {
|
|
9537
|
+
const eq = t.indexOf('=');
|
|
9538
|
+
const name = eq === -1 ? t : t.slice(0, eq);
|
|
9539
|
+
if (GIT_LOG_PATCH_LONG.has(name))
|
|
9540
|
+
on = true;
|
|
9541
|
+
else if (name === '--diff-merges') {
|
|
9542
|
+
if (!/^--diff-merges=(?:off|none)$/.test(t))
|
|
9543
|
+
on = true;
|
|
9544
|
+
}
|
|
9545
|
+
else if (t.length > 2 && (0, longopts_1.unknownLong)([args[k]], longopts_1.GIT_READ_LONG, 2, false) !== -1)
|
|
9546
|
+
on = true;
|
|
9547
|
+
continue;
|
|
9548
|
+
}
|
|
9549
|
+
// A short word or cluster, left to right: p, u, U<n> and L<range> print patches, s stops them; a letter
|
|
9550
|
+
// that takes a value ends the word (`-Sfoo`, `-n5`).
|
|
9551
|
+
if (/^-[A-Za-z]/.test(t)) {
|
|
9552
|
+
for (const ch of t.slice(1)) {
|
|
9553
|
+
if (ch === 'p' || ch === 'u' || ch === 'U' || ch === 'L')
|
|
9554
|
+
on = true;
|
|
9555
|
+
else if (ch === 's') {
|
|
9556
|
+
if (off)
|
|
9557
|
+
on = false;
|
|
9558
|
+
}
|
|
9559
|
+
if (/[^A-Za-z]|[SGOIlLnUMCBX]/.test(ch))
|
|
9560
|
+
break;
|
|
9561
|
+
}
|
|
8304
9562
|
}
|
|
8305
9563
|
}
|
|
9564
|
+
return on;
|
|
8306
9565
|
}
|
|
8307
9566
|
// Options that hand git a command to run: rebase --exec, filter-branch filters, bisect run, submodule
|
|
8308
9567
|
// foreach, and the pack programs of fetch and push.
|
|
@@ -9871,7 +11130,249 @@ function handleHelm(rest, ctx, run) {
|
|
|
9871
11130
|
function emptyExec() {
|
|
9872
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() };
|
|
9873
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
|
+
}
|
|
9874
11374
|
function handleDocker(rest, ctx, run, x) {
|
|
11375
|
+
dockerMountChecks(rest, ctx, run);
|
|
9875
11376
|
// The command inside the container reads this command's stdin (`… | docker exec -i c sh`).
|
|
9876
11377
|
const inner = () => emptyExec();
|
|
9877
11378
|
ctx = withFeed(ctx, x, run);
|
|
@@ -10001,20 +11502,7 @@ function handleRmdir(rest, ctx, run) {
|
|
|
10001
11502
|
}
|
|
10002
11503
|
}
|
|
10003
11504
|
function handleFind(rest, ctx, run, x) {
|
|
10004
|
-
const starts =
|
|
10005
|
-
let i = 0;
|
|
10006
|
-
while (i < rest.length && rest[i].text !== null && /^-[HLP]$|^-O\d$|^-D$/.test(rest[i].text))
|
|
10007
|
-
i += rest[i].text === '-D' ? 2 : 1;
|
|
10008
|
-
while (i < rest.length) {
|
|
10009
|
-
const t = rest[i].text;
|
|
10010
|
-
if (t !== null && (t.startsWith('-') || t === '(' || t === '!' || t === ')'))
|
|
10011
|
-
break;
|
|
10012
|
-
starts.push(rest[i]);
|
|
10013
|
-
i += 1;
|
|
10014
|
-
}
|
|
10015
|
-
if (starts.length === 0)
|
|
10016
|
-
starts.push(litArg('.'));
|
|
10017
|
-
const expr = rest.slice(i);
|
|
11505
|
+
const { starts, expr } = splitFind(rest);
|
|
10018
11506
|
let deletes = false;
|
|
10019
11507
|
for (let k = 0; k < expr.length; k += 1) {
|
|
10020
11508
|
const t = expr[k].text;
|
|
@@ -10060,12 +11548,19 @@ function handleFind(rest, ctx, run, x) {
|
|
|
10060
11548
|
const samples = (0, tables_1.secretSamples)(run.home, cwdsList(ctx));
|
|
10061
11549
|
for (const n of names) {
|
|
10062
11550
|
const re = patternToRegex(n.replace(/^\*\*\//, ''), false);
|
|
10063
|
-
|
|
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');
|
|
10064
11556
|
hit(run, 'S4', 'find_exec_secret_name');
|
|
10065
11557
|
break;
|
|
10066
11558
|
}
|
|
10067
11559
|
}
|
|
10068
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);
|
|
10069
11564
|
if (deletes)
|
|
10070
11565
|
for (const s of starts) {
|
|
10071
11566
|
checkProtectedWrite(s, ctx, run, 'find_delete', false);
|
|
@@ -10112,12 +11607,24 @@ function handleDd(rest, run) {
|
|
|
10112
11607
|
}
|
|
10113
11608
|
// ---------------------------------------------------------------------------------------------
|
|
10114
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
|
+
}
|
|
10115
11615
|
function checkGrepGlob(glob, ctx, run) {
|
|
10116
11616
|
const g = glob.replace(/^\*\*\//, '');
|
|
10117
11617
|
const re = patternToRegex(g, false);
|
|
10118
11618
|
const samples = (0, tables_1.secretSamples)(run.home, cwdsList(ctx));
|
|
10119
|
-
|
|
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('/')))) {
|
|
10120
11625
|
hit(run, 'S4', 'tool_grep_glob_secret');
|
|
11626
|
+
hit(run, 'S4', secret_paths_1.APPROVAL_STATE_REASON);
|
|
11627
|
+
}
|
|
10121
11628
|
}
|
|
10122
11629
|
// Patch round 3 (delta audit F-CODEX): `.codex/hooks.json` is not compared like the others: any write under .codex stops (S0)
|
|
10123
11630
|
// on every host, including a gemini write_file that leaves the Marrow part as it was.
|