@gotgenes/pi-permission-system 33.0.0 → 33.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +21 -0
- package/docs/configuration.md +10 -1
- package/docs/subagent-integration.md +5 -1
- package/package.json +1 -1
- package/src/access-intent/bash/command-enumeration.ts +46 -5
- package/src/access-intent/bash/wrapper-analysis.ts +60 -6
- package/src/logging/command-redaction.ts +170 -10
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,27 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [33.0.2](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v33.0.1...pi-permission-system-v33.0.2) (2026-09-19)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
* **pi-permission-system:** stop writing a named secret inside an inline-shell payload ([eaeb297](https://github.com/gotgenes/pi-packages/commit/eaeb297e0f20b5ceeb935f0d1a062ebf9daa17ec)), closes [#923](https://github.com/gotgenes/pi-packages/issues/923)
|
|
14
|
+
* **pi-permission-system:** mask a secret in a quote-stitched inline-shell payload ([4b79c10](https://github.com/gotgenes/pi-packages/commit/4b79c105c21d3400ff25050eb19490f6c71ede61)), closes [#923](https://github.com/gotgenes/pi-packages/issues/923)
|
|
15
|
+
* **pi-permission-system:** mask a secret in a payload behind an indirection wrapper ([c17959a](https://github.com/gotgenes/pi-packages/commit/c17959a87b2a43816796c9deb9f7b28ae9ceb436)), closes [#923](https://github.com/gotgenes/pi-packages/issues/923)
|
|
16
|
+
|
|
17
|
+
### Documentation
|
|
18
|
+
|
|
19
|
+
* **pi-permission-system:** record the payload widening and accept the heredoc residual ([8544508](https://github.com/gotgenes/pi-packages/commit/85445088fad6ce7d10df95cd661e98c0c3fd587f)), closes [#923](https://github.com/gotgenes/pi-packages/issues/923)
|
|
20
|
+
* **pi-permission-system:** record the coarse and wrapper-peeled payload cases ([c3644d6](https://github.com/gotgenes/pi-packages/commit/c3644d60e8dbcd9f332d2509711df05356844e03)), closes [#923](https://github.com/gotgenes/pi-packages/issues/923)
|
|
21
|
+
|
|
22
|
+
## [33.0.1](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v33.0.0...pi-permission-system-v33.0.1) (2026-09-19)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
### Documentation
|
|
26
|
+
|
|
27
|
+
* **pi-permission-system:** document the serving-liveness and bound-channel mechanism in the integration spec ([#942](https://github.com/gotgenes/pi-packages/issues/942)) ([2786acc](https://github.com/gotgenes/pi-packages/commit/2786accaf40136f192e6f8d187058ae60c7555b5)), closes [#942](https://github.com/gotgenes/pi-packages/issues/942)
|
|
28
|
+
|
|
8
29
|
## [33.0.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v32.1.0...pi-permission-system-v33.0.0) (2026-09-18)
|
|
9
30
|
|
|
10
31
|
|
package/docs/configuration.md
CHANGED
|
@@ -1292,6 +1292,7 @@ The command is parsed, and a value is masked when it is bound to a sensitive nam
|
|
|
1292
1292
|
KEY="sk-or-v1-…" curl https://x → KEY=[redacted] curl https://x
|
|
1293
1293
|
env MY_KEY=… deploy → env MY_KEY=[redacted] deploy
|
|
1294
1294
|
curl -H "Authorization: Bearer sk-…" → curl -H "Authorization:[redacted]"
|
|
1295
|
+
bash -c 'TOKEN=sk-… deploy' → bash -c 'TOKEN=[redacted] deploy'
|
|
1295
1296
|
```
|
|
1296
1297
|
|
|
1297
1298
|
The boundary is worth stating exactly, because it is easy to over-read:
|
|
@@ -1301,7 +1302,15 @@ The boundary is worth stating exactly, because it is easy to over-read:
|
|
|
1301
1302
|
|
|
1302
1303
|
So `grep -r "sk-ant-…" .` and `deploy --token abc123` are both logged unredacted: the first binds the secret to nothing, and the second binds it to a flag rather than a name.
|
|
1303
1304
|
The extension deliberately does not try to guess which parts of a command look secret-shaped — see [ADR 0010] for the measured reasoning.
|
|
1304
|
-
A command the parser could not fully resolve
|
|
1305
|
+
A command the parser could not fully resolve is masked only as far as the parse reached.
|
|
1306
|
+
A secret inside a **heredoc body** (`cat > .env <<'EOF'` / `API_KEY=…` / `EOF`) is not masked at all: a heredoc body is literal data rather than shell, and re-parsing one as shell is how the log's own Python and TypeScript heredocs come to read as assignments — measured at six false positives and no true ones, so [ADR 0010] declines it.
|
|
1307
|
+
An **inline-shell payload** (`bash -c '…'`, `sh -c "…"`, `eval '…'`) *is* masked, because the package already knows that argument is shell — including one reached through a wrapper (`sudo bash -c '…'`, `xargs -I{} sh -c '…'`).
|
|
1308
|
+
An interpreter's payload (`python3 -c '…'`) is not, for the same reason a heredoc body is not.
|
|
1309
|
+
Where a payload is stitched together across quote boundaries (`bash -c 'TOKEN='"$SECRET"`), the whole argument is replaced rather than just the value, because no single offset maps the value back onto the command:
|
|
1310
|
+
|
|
1311
|
+
```text
|
|
1312
|
+
bash -c 'TOKEN='"$SECRET" → bash -c [redacted]
|
|
1313
|
+
```
|
|
1305
1314
|
|
|
1306
1315
|
Every value the **review** log writes is narrowed to `reviewLogFieldMaxWidth` (1000 characters by default) and marked with an ellipsis, so a single pathological command cannot put tens of kilobytes in one entry.
|
|
1307
1316
|
This is a length bound, not redaction: it never inspects a value to decide what to hide, and it applies to every field alike.
|
|
@@ -160,7 +160,9 @@ The polling session publishes the session id it polls, and a child checks that i
|
|
|
160
160
|
|
|
161
161
|
The announcement goes out on two channels, because a child cannot always reach the same one.
|
|
162
162
|
A child running inside its parent's process reads a process-global registry.
|
|
163
|
+
A stale mark left in that registry by a session that died without `session_shutdown` suppresses the fast-fail and falls back to the timeout — the safe direction for that channel.
|
|
163
164
|
A child running as a separate `pi` process (the `PI_SUBAGENT_PARENT_SESSION` path) shares no memory with its parent, so it reads a heartbeat record the serving session refreshes under `<agent dir>/sessions/permission-forwarding/serving/`, holding the served session id, the serving process id, and the time it was last refreshed.
|
|
165
|
+
That `serving/` directory is created on demand and never removed.
|
|
164
166
|
|
|
165
167
|
For an out-of-process target, four things count as "not draining":
|
|
166
168
|
|
|
@@ -171,7 +173,7 @@ For an out-of-process target, four things count as "not draining":
|
|
|
171
173
|
| A record nobody has refreshed for several seconds | The parent's process survives but has stopped polling |
|
|
172
174
|
| A record for a different session id | The child is forwarding somewhere nobody is listening |
|
|
173
175
|
|
|
174
|
-
If the target is not draining its inbox, the child gives up after a two-second grace window rather than waiting out `forwardingTimeoutMs`, and the tool is blocked with:
|
|
176
|
+
If the target is not draining its inbox, the child gives up after a two-second grace window (`PERMISSION_FORWARDING_SERVING_GRACE_MS`) rather than waiting out `forwardingTimeoutMs`, and the tool is blocked with:
|
|
175
177
|
|
|
176
178
|
```text
|
|
177
179
|
[pi-permission-system] Running bash command 'pwd' requires approval, but no
|
|
@@ -180,6 +182,7 @@ permission requests.
|
|
|
180
182
|
```
|
|
181
183
|
|
|
182
184
|
The grace window exists so a request that arrives while the parent is switching sessions is not abandoned in the gap.
|
|
185
|
+
Absence of a record is deliberately **not** read as unknown: a cleanly exited parent leaves nothing behind, so treating absence as "maybe serving" would restore the full-timeout stall that the heartbeat exists to end.
|
|
183
186
|
A target that *is* draining its inbox is waited on for the full `forwardingTimeoutMs`, however long the human takes to decide.
|
|
184
187
|
That includes a parent whose human is still deliberating at an earlier forwarded prompt: it keeps refreshing its heartbeat throughout, so a second child does not read it as gone.
|
|
185
188
|
|
|
@@ -188,6 +191,7 @@ None of them is reported as a user denial, because no user was ever asked.
|
|
|
188
191
|
|
|
189
192
|
The two sides of the exchange are correlatable in the review log: the serving session writes `forwarded_permission.serving_started` with the id it polls, and the child writes `forwarded_permission.request_created` with the `targetSessionId` it forwarded to.
|
|
190
193
|
When a forwarded request goes unanswered, comparing those two entries distinguishes a parent that was not polling from one polling a different session.
|
|
194
|
+
The child's `forwarded_permission.no_serving_session` entry also records `servingChannel` and `servingState` beside the ids observed, since "exited", "killed", and "polling a different session id" are different diagnoses the shared denial string does not distinguish.
|
|
191
195
|
|
|
192
196
|
When a forwarded request *is* answered, the child's own terminal entry names both which session answered and what within it decided.
|
|
193
197
|
The serving node records its decider on the response — a rule of its own (with the surface, pattern, and origin that matched), the link that ruled, or the human who answered its dialog — and the child records it nested under a `forwarded` frame:
|
package/package.json
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
type CommandWord,
|
|
7
7
|
classifyWrapperWords,
|
|
8
8
|
executedUnitOf,
|
|
9
|
+
inlineShellPayloadIndex,
|
|
9
10
|
isTransparentWrapper,
|
|
10
11
|
type WrapperKind,
|
|
11
12
|
} from "./wrapper-analysis";
|
|
@@ -263,6 +264,30 @@ export function collectSalvagedCommands(node: TSNode): BashCommand[] {
|
|
|
263
264
|
return out;
|
|
264
265
|
}
|
|
265
266
|
|
|
267
|
+
/**
|
|
268
|
+
* The node holding a `command` node's inline-shell payload — the inner program
|
|
269
|
+
* of `bash -c '…'`, `sh -c "…"`, or `eval '…'` — or `null` for any other
|
|
270
|
+
* command.
|
|
271
|
+
*
|
|
272
|
+
* The node rather than its text, because the log's command masker re-parses the
|
|
273
|
+
* payload and offsets the spans it recovers by the node's `startIndex`
|
|
274
|
+
* (`logging/command-redaction.ts`, #923). {@link executedUnitOf} answers the
|
|
275
|
+
* text question for display and cannot serve that one: it unquotes, unwraps
|
|
276
|
+
* nested indirection, and drops a result that adds nothing — all of which lose
|
|
277
|
+
* the correspondence to the command as written.
|
|
278
|
+
*
|
|
279
|
+
* The payload set is the *shell* set, which is what keeps an interpreter
|
|
280
|
+
* (`python3 -c`, `node -e`) out: its payload is another language, so re-parsing
|
|
281
|
+
* it as bash would read a secret out of embedded Python.
|
|
282
|
+
*/
|
|
283
|
+
export function inlineShellPayloadNode(command: TSNode): TSNode | null {
|
|
284
|
+
const nodes = commandWordNodes(command);
|
|
285
|
+
const index = inlineShellPayloadIndex(
|
|
286
|
+
nodes.map((node) => ({ text: node.text, offset: node.startIndex })),
|
|
287
|
+
);
|
|
288
|
+
return index === -1 ? null : (nodes.at(index) ?? null);
|
|
289
|
+
}
|
|
290
|
+
|
|
266
291
|
function collectCommandsInto(
|
|
267
292
|
node: TSNode,
|
|
268
293
|
inherited: UnitScope,
|
|
@@ -433,16 +458,32 @@ function redirectedScope(node: TSNode, scope: UnitScope): UnitScope {
|
|
|
433
458
|
* list means a pure assignment with no `command_name`.
|
|
434
459
|
*/
|
|
435
460
|
function readCommandWords(node: TSNode): CommandWord[] {
|
|
436
|
-
const
|
|
437
|
-
|
|
461
|
+
const nodes = commandWordNodes(node);
|
|
462
|
+
const unitStart = nodes.at(0)?.startIndex ?? 0;
|
|
463
|
+
return nodes.map((child) => ({
|
|
464
|
+
text: child.text,
|
|
465
|
+
offset: child.startIndex - unitStart,
|
|
466
|
+
}));
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* The nodes {@link readCommandWords} reports words for, in the same order.
|
|
471
|
+
*
|
|
472
|
+
* Split out so a consumer that needs a *node* rather than a word — the log's
|
|
473
|
+
* command masker, which offsets a re-parse by the payload node's `startIndex` —
|
|
474
|
+
* walks the identical filtered list. Two walks over the same children with the
|
|
475
|
+
* same filter, written twice, is how the two come to disagree about which word
|
|
476
|
+
* is at which index.
|
|
477
|
+
*/
|
|
478
|
+
function commandWordNodes(node: TSNode): TSNode[] {
|
|
479
|
+
const nodes: TSNode[] = [];
|
|
438
480
|
for (let i = 0; i < node.childCount; i++) {
|
|
439
481
|
const child = node.child(i);
|
|
440
482
|
if (!child?.isNamed) continue;
|
|
441
483
|
if (child.type === "variable_assignment") continue;
|
|
442
|
-
|
|
443
|
-
words.push({ text: child.text, offset: child.startIndex - unitStart });
|
|
484
|
+
nodes.push(child);
|
|
444
485
|
}
|
|
445
|
-
return
|
|
486
|
+
return nodes;
|
|
446
487
|
}
|
|
447
488
|
|
|
448
489
|
/**
|
|
@@ -59,6 +59,57 @@ export function classifyWrapperWords(
|
|
|
59
59
|
return undefined;
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
/**
|
|
63
|
+
* Index within `words` of the inline-shell payload — the inner program a shell or
|
|
64
|
+
* `eval` runs — or `-1` when the unit carries none.
|
|
65
|
+
*
|
|
66
|
+
* Indirection layers are peeled first, so `sudo bash -c '…'` answers the
|
|
67
|
+
* payload's index rather than `-1`. That is not a convenience:
|
|
68
|
+
* {@link executedUnitOf} peels them too, so a consumer that did not would mask a
|
|
69
|
+
* secret under `executedUnit` and write it verbatim under `command` — the
|
|
70
|
+
* inconsistency #923 reports, reintroduced one wrapper layer up.
|
|
71
|
+
*
|
|
72
|
+
* The index names the payload's *position*, which a vacant one still has
|
|
73
|
+
* (`bash -c`), so each caller decides for itself what reading past the end of
|
|
74
|
+
* `words` is worth.
|
|
75
|
+
*/
|
|
76
|
+
export function inlineShellPayloadIndex(words: readonly CommandWord[]): number {
|
|
77
|
+
let base = 0;
|
|
78
|
+
let current = words;
|
|
79
|
+
|
|
80
|
+
for (let depth = 0; depth < MAX_UNWRAP_DEPTH; depth++) {
|
|
81
|
+
const direct = directPayloadIndex(current);
|
|
82
|
+
if (direct !== -1) return base + direct;
|
|
83
|
+
if (classifyWrapperWords(current) !== "indirection") return -1;
|
|
84
|
+
|
|
85
|
+
const start = innerCommandIndex(current);
|
|
86
|
+
if (start === -1 || start >= current.length) return -1;
|
|
87
|
+
const end = execTerminatorIndex(current, start);
|
|
88
|
+
base += start;
|
|
89
|
+
current = current.slice(start, end);
|
|
90
|
+
}
|
|
91
|
+
return -1;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The payload index of a unit that is *already* the shell or `eval` running it.
|
|
96
|
+
*
|
|
97
|
+
* `eval` takes its program as the first argument (no `-c`, so the flag scan
|
|
98
|
+
* answers -1 and the index falls out as 1); a shell takes it after the `-c`
|
|
99
|
+
* cluster. Any other command name carries no inline program at all, which is
|
|
100
|
+
* what keeps an interpreter (`python3 -c`, `node -e`) out: its payload is
|
|
101
|
+
* another language, not shell.
|
|
102
|
+
*/
|
|
103
|
+
function directPayloadIndex(words: readonly CommandWord[]): number {
|
|
104
|
+
const commandName = wrapperName(words);
|
|
105
|
+
if (commandName === undefined) return -1;
|
|
106
|
+
const isShell = SHELL_WRAPPER_NAMES.has(commandName);
|
|
107
|
+
if (commandName !== "eval" && !isShell) return -1;
|
|
108
|
+
const flagIndex = shortFlagCIndex(words.slice(1).map((word) => word.text));
|
|
109
|
+
if (isShell && flagIndex === -1) return -1;
|
|
110
|
+
return flagIndex + 2;
|
|
111
|
+
}
|
|
112
|
+
|
|
62
113
|
// ── Wrapper vocabulary ───────────────────────────────────────────────────────
|
|
63
114
|
|
|
64
115
|
/**
|
|
@@ -201,13 +252,16 @@ function nothingNew(text: string | null, unitText: string): string | null {
|
|
|
201
252
|
return text.startsWith("-") ? null : text;
|
|
202
253
|
}
|
|
203
254
|
|
|
204
|
-
/**
|
|
255
|
+
/**
|
|
256
|
+
* The inline-shell payload argument, unquoted; `null` when absent.
|
|
257
|
+
*
|
|
258
|
+
* Reads the **direct** index: {@link unwrapIndirection} has already peeled every
|
|
259
|
+
* wrapper layer by the time it reaches its opaque branch, so peeling again would
|
|
260
|
+
* look past a shell that is itself an outer wrapper's payload.
|
|
261
|
+
*/
|
|
205
262
|
function opaquePayload(words: readonly CommandWord[]): string | null {
|
|
206
|
-
const
|
|
207
|
-
|
|
208
|
-
// -1); a shell takes it after the `-c` cluster.
|
|
209
|
-
const flagIndex = shortFlagCIndex(args.map((word) => word.text));
|
|
210
|
-
const payload = args[flagIndex + 1] as CommandWord | undefined;
|
|
263
|
+
const index = directPayloadIndex(words);
|
|
264
|
+
const payload = index === -1 ? undefined : words.at(index);
|
|
211
265
|
return payload === undefined ? null : unquote(payload.text);
|
|
212
266
|
}
|
|
213
267
|
|
|
@@ -1,8 +1,13 @@
|
|
|
1
|
+
import { inlineShellPayloadNode } from "#src/access-intent/bash/command-enumeration";
|
|
1
2
|
import {
|
|
2
3
|
ARG_NODE_TYPES,
|
|
3
4
|
resolveNodeText,
|
|
4
5
|
} from "#src/access-intent/bash/node-text";
|
|
5
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
type BashReparser,
|
|
8
|
+
getWarmBashParser,
|
|
9
|
+
type TSNode,
|
|
10
|
+
} from "#src/access-intent/bash/parser";
|
|
6
11
|
import { isPlainRecord } from "#src/value-guards";
|
|
7
12
|
import { isSensitiveName, REDACTED_PLACEHOLDER } from "./log-redaction";
|
|
8
13
|
|
|
@@ -25,6 +30,14 @@ import { isSensitiveName, REDACTED_PLACEHOLDER } from "./log-redaction";
|
|
|
25
30
|
*
|
|
26
31
|
* A value with no name bound to it — a secret typed as a `grep` pattern — is
|
|
27
32
|
* out of reach of a structural rule and stays unmasked.
|
|
33
|
+
*
|
|
34
|
+
* An inline-shell payload (`bash -c '…'`, `eval "…"`) is one opaque token to
|
|
35
|
+
* the outer parse, so it is re-parsed on its own and the recovered spans are
|
|
36
|
+
* shifted onto the command as written. The widening is restricted to the
|
|
37
|
+
* payloads the wrapper analyzer already identifies as shell: a heredoc body is
|
|
38
|
+
* not one, and applying these rules to the 915 `<<'EOF'` bodies in the same
|
|
39
|
+
* corpus matched six commands, every one embedded Python or TypeScript written
|
|
40
|
+
* to a file (#923).
|
|
28
41
|
*/
|
|
29
42
|
|
|
30
43
|
/** The log keys whose value is a bash command string. */
|
|
@@ -55,20 +68,167 @@ export function redactCommandSecrets(command: string): string {
|
|
|
55
68
|
try {
|
|
56
69
|
const parser = getWarmBashParser();
|
|
57
70
|
if (!parser) return command;
|
|
58
|
-
|
|
59
|
-
if (!tree) return command;
|
|
60
|
-
try {
|
|
61
|
-
const spans: MaskSpan[] = [];
|
|
62
|
-
collectMaskSpans(tree.rootNode, spans);
|
|
63
|
-
return applyMaskSpans(command, spans);
|
|
64
|
-
} finally {
|
|
65
|
-
tree.delete();
|
|
66
|
-
}
|
|
71
|
+
return applyMaskSpans(command, collectSpansIn(parser, command, 0, 0));
|
|
67
72
|
} catch {
|
|
68
73
|
return command;
|
|
69
74
|
}
|
|
70
75
|
}
|
|
71
76
|
|
|
77
|
+
/**
|
|
78
|
+
* Every mask span in `source`, shifted by `offset` to its place in the command
|
|
79
|
+
* being masked, including the spans of any inline-shell payload `source`
|
|
80
|
+
* carries.
|
|
81
|
+
*
|
|
82
|
+
* A payload is re-parsed from its **verbatim inner slice** rather than
|
|
83
|
+
* `resolveNodeText`'s shell value: the resolved text concatenates children and
|
|
84
|
+
* expands `$HOME`, which destroys the offset correspondence this shift relies
|
|
85
|
+
* on. Because the slice excludes the payload's quotes, no span recovered from it
|
|
86
|
+
* can reach one, so the masked payload stays quoted as it was written.
|
|
87
|
+
*
|
|
88
|
+
* `parser` is the narrow {@link BashReparser} rather than the warmed parser's own
|
|
89
|
+
* type, so the recursion structurally cannot `delete()` the process-wide parser
|
|
90
|
+
* out from under every later command — the same reason `unresolved-salvage.ts`
|
|
91
|
+
* takes that interface.
|
|
92
|
+
*/
|
|
93
|
+
function collectSpansIn(
|
|
94
|
+
parser: BashReparser,
|
|
95
|
+
source: string,
|
|
96
|
+
offset: number,
|
|
97
|
+
depth: number,
|
|
98
|
+
): MaskSpan[] {
|
|
99
|
+
const tree = parser.parse(source);
|
|
100
|
+
if (!tree) return [];
|
|
101
|
+
try {
|
|
102
|
+
const own: MaskSpan[] = [];
|
|
103
|
+
collectMaskSpans(tree.rootNode, own);
|
|
104
|
+
const spans = own.map((span) => ({
|
|
105
|
+
...span,
|
|
106
|
+
start: span.start + offset,
|
|
107
|
+
end: span.end + offset,
|
|
108
|
+
}));
|
|
109
|
+
if (depth >= MAX_PAYLOAD_DEPTH) return spans;
|
|
110
|
+
for (const payload of inlineShellPayloads(tree.rootNode)) {
|
|
111
|
+
const sliced = payload.kind === "slice";
|
|
112
|
+
const inner = collectSpansIn(
|
|
113
|
+
parser,
|
|
114
|
+
payload.text,
|
|
115
|
+
sliced ? offset + payload.start : 0,
|
|
116
|
+
depth + 1,
|
|
117
|
+
);
|
|
118
|
+
if (sliced) spans.push(...inner);
|
|
119
|
+
else if (inner.length > 0) spans.push(wholeOf(payload, offset));
|
|
120
|
+
}
|
|
121
|
+
return spans;
|
|
122
|
+
} finally {
|
|
123
|
+
tree.delete();
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* How many payload layers to descend.
|
|
129
|
+
*
|
|
130
|
+
* A payload is a strict sub-span of its own command, so the recursion terminates
|
|
131
|
+
* regardless; the bound is what makes its cost statable, and it matches the
|
|
132
|
+
* unwrap depth `wrapper-analysis.ts` already applies to nested wrappers.
|
|
133
|
+
*/
|
|
134
|
+
const MAX_PAYLOAD_DEPTH = 4;
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* An inline-shell payload's program, and how a span found in it maps back onto
|
|
138
|
+
* the command being masked.
|
|
139
|
+
*
|
|
140
|
+
* `"slice"` — the program is a verbatim span of the command: a bare word, or
|
|
141
|
+
* literal text inside one pair of quotes. A span found in it shifts onto the
|
|
142
|
+
* command by the constant {@link PayloadSource.start}.
|
|
143
|
+
*
|
|
144
|
+
* `"stitched"` — the program is assembled across quote boundaries
|
|
145
|
+
* (`bash -c 'TOKEN='"$SECRET"`, one `concatenation` node), so no constant shift
|
|
146
|
+
* exists and an offset into the program names nothing in the command. The
|
|
147
|
+
* program still decides *whether* a secret is bound inside, so the whole
|
|
148
|
+
* argument is masked when one is — coarser than a slice, and the alternative is
|
|
149
|
+
* writing the secret.
|
|
150
|
+
*/
|
|
151
|
+
interface PayloadSource {
|
|
152
|
+
readonly kind: "slice" | "stitched";
|
|
153
|
+
readonly text: string;
|
|
154
|
+
readonly start: number;
|
|
155
|
+
readonly end: number;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** The span covering a stitched payload argument whole, quotes included. */
|
|
159
|
+
function wholeOf(payload: PayloadSource, offset: number): MaskSpan {
|
|
160
|
+
return {
|
|
161
|
+
start: payload.start + offset,
|
|
162
|
+
end: payload.end + offset,
|
|
163
|
+
replacement: REDACTED_PLACEHOLDER,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function inlineShellPayloads(root: TSNode): PayloadSource[] {
|
|
168
|
+
const payloads: PayloadSource[] = [];
|
|
169
|
+
collectInlineShellPayloads(root, payloads);
|
|
170
|
+
return payloads;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function collectInlineShellPayloads(
|
|
174
|
+
node: TSNode,
|
|
175
|
+
payloads: PayloadSource[],
|
|
176
|
+
): void {
|
|
177
|
+
if (node.type === "command") {
|
|
178
|
+
const payload = inlineShellPayloadNode(node);
|
|
179
|
+
if (payload) payloads.push(payloadSourceOf(payload));
|
|
180
|
+
}
|
|
181
|
+
for (let i = 0; i < node.childCount; i++) {
|
|
182
|
+
const child = node.child(i);
|
|
183
|
+
if (child) collectInlineShellPayloads(child, payloads);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* The program a payload node carries, sliced where the grammar spells it as one
|
|
189
|
+
* contiguous run of the command and stitched where it does not.
|
|
190
|
+
*
|
|
191
|
+
* A `word` payload is the program already. A `string`/`raw_string` wraps it in
|
|
192
|
+
* one quote pair and an `ansi_c_string` in a `$` plus one quote pair, so each is
|
|
193
|
+
* a slice at a known offset. Anything else — a `concatenation`, an expansion —
|
|
194
|
+
* is stitched: `resolveNodeText` knows how to read its shell value, and that
|
|
195
|
+
* value's own offsets describe no span of the command.
|
|
196
|
+
*/
|
|
197
|
+
function payloadSourceOf(node: TSNode): PayloadSource {
|
|
198
|
+
const bounds = { start: node.startIndex, end: node.endIndex };
|
|
199
|
+
if (node.type === "word") {
|
|
200
|
+
return { kind: "slice", text: node.text, ...bounds };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const text = node.text;
|
|
204
|
+
const quoteAt = text.startsWith("$") ? 1 : 0;
|
|
205
|
+
const quote = text.at(quoteAt);
|
|
206
|
+
const singlyQuoted =
|
|
207
|
+
SINGLY_QUOTED_PAYLOAD_TYPES.has(node.type) &&
|
|
208
|
+
(quote === "'" || quote === '"') &&
|
|
209
|
+
text.length >= quoteAt + 2 &&
|
|
210
|
+
text.endsWith(quote);
|
|
211
|
+
|
|
212
|
+
return singlyQuoted
|
|
213
|
+
? {
|
|
214
|
+
kind: "slice",
|
|
215
|
+
text: text.slice(quoteAt + 1, -1),
|
|
216
|
+
start: node.startIndex + quoteAt + 1,
|
|
217
|
+
end: node.endIndex - 1,
|
|
218
|
+
}
|
|
219
|
+
: { kind: "stitched", text: resolveNodeText(node), ...bounds };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Payload node types whose program sits inside exactly one pair of quotes, so
|
|
224
|
+
* removing them (and a leading `$`) leaves a verbatim span of the command.
|
|
225
|
+
*/
|
|
226
|
+
const SINGLY_QUOTED_PAYLOAD_TYPES: ReadonlySet<string> = new Set([
|
|
227
|
+
"string",
|
|
228
|
+
"raw_string",
|
|
229
|
+
"ansi_c_string",
|
|
230
|
+
]);
|
|
231
|
+
|
|
72
232
|
/**
|
|
73
233
|
* Apply {@link redactCommandSecrets} to every command-bearing key in a log
|
|
74
234
|
* record.
|