* Recursively collect leaf `command` nodes from a structural wrapper node. * Returns an error result on any disallowed node type, or null on success.
( node: Node, commands: SimpleCommand[], varScope: Map<string, string>, )
| 480 | * Returns an error result on any disallowed node type, or null on success. |
| 481 | */ |
| 482 | function collectCommands( |
| 483 | node: Node, |
| 484 | commands: SimpleCommand[], |
| 485 | varScope: Map<string, string>, |
| 486 | ): ParseForSecurityResult | null { |
| 487 | if (node.type === 'command') { |
| 488 | // Pass `commands` as the innerCommands accumulator — any $() extracted |
| 489 | // during walkCommand gets appended alongside the outer command. |
| 490 | const result = walkCommand(node, [], commands, varScope) |
| 491 | if (result.kind !== 'simple') return result |
| 492 | commands.push(...result.commands) |
| 493 | return null |
| 494 | } |
| 495 | |
| 496 | if (node.type === 'redirected_statement') { |
| 497 | return walkRedirectedStatement(node, commands, varScope) |
| 498 | } |
| 499 | |
| 500 | if (node.type === 'comment') { |
| 501 | return null |
| 502 | } |
| 503 | |
| 504 | if (STRUCTURAL_TYPES.has(node.type)) { |
| 505 | // SECURITY: `||`, `|`, `|&`, `&` must NOT carry varScope linearly. In bash: |
| 506 | // `||` RHS runs conditionally → vars set there MAY not be set |
| 507 | // `|`/`|&` stages run in subshells → vars set there are NEVER visible after |
| 508 | // `&` LHS runs in a background subshell → same as above |
| 509 | // Flag-omission attack: `true || FLAG=--dry-run && cmd $FLAG` — bash skips |
| 510 | // the `||` RHS (FLAG unset → $FLAG empty), runs `cmd` WITHOUT --dry-run. |
| 511 | // With linear scope, our argv has ['cmd','--dry-run'] → looks SAFE → bypass. |
| 512 | // |
| 513 | // Fix: snapshot incoming scope at entry. After these separators, reset to |
| 514 | // the snapshot — vars set in clauses between separators don't leak. `scope` |
| 515 | // for clauses BETWEEN `&&`/`;` chains shares state (common `VAR=x && cmd |
| 516 | // $VAR`). `scope` crosses `||`/`|`/`&` as the pre-structure snapshot only. |
| 517 | // |
| 518 | // `&&` and `;` DO carry scope: `VAR=x && cmd $VAR` is sequential, VAR is set. |
| 519 | // |
| 520 | // NOTE: `scope` and `varScope` diverge after the first `||`/`|`/`&`. The |
| 521 | // caller's varScope is only mutated for the `&&`/`;` prefix — this is |
| 522 | // conservative (vars set in `A && B | C && D` leak A+B into caller, not |
| 523 | // C+D) but safe. |
| 524 | // |
| 525 | // Efficiency: snapshot is only needed if we hit `||`/`|`/`|&`/`&`. For |
| 526 | // the dominant case (`ls`, `git status` — no such separators), skip the |
| 527 | // Map alloc via a cheap pre-scan. For `pipeline`, node.type already tells |
| 528 | // us stages are subshells — copy once at entry, no snapshot needed (each |
| 529 | // reset uses the entry copy pattern via varScope, which is untouched). |
| 530 | const isPipeline = node.type === 'pipeline' |
| 531 | let needsSnapshot = false |
| 532 | if (!isPipeline) { |
| 533 | for (const c of node.children) { |
| 534 | if (c && (c.type === '||' || c.type === '&')) { |
| 535 | needsSnapshot = true |
| 536 | break |
| 537 | } |
| 538 | } |
| 539 | } |
no test coverage detected