mirror of
https://github.com/affaan-m/ECC.git
synced 2026-09-10 19:57:57 +02:00
Merge reviewed PowerShell enforcement fixes for 2.2.1
# Conflicts: # tests/hooks/gateguard-fact-force.test.js
This commit is contained in:
@@ -0,0 +1,256 @@
|
||||
# ECC-039 PowerShell GateGuard and Audit Alignment Plan
|
||||
|
||||
## Status
|
||||
|
||||
- Ticket: ECC-039
|
||||
- Size: large
|
||||
- Priority: critical
|
||||
- Baseline: `origin/main` at `e04ea0b9`
|
||||
- Source to salvage: PR #2721 at `4a2e59ba`
|
||||
- Implementation state: implemented in PR #2961 and under hosted verification
|
||||
|
||||
The fix spans the security enforcement path, governance evidence, configured
|
||||
hook routing, post-tool dispatch, and cross-platform regression coverage. It is
|
||||
large because the stale PR changes eight files, conflicts with current `main`,
|
||||
and must establish one consistent policy/evidence contract.
|
||||
|
||||
## Objective
|
||||
|
||||
Make PowerShell a governed arbitrary-command shell with one destructive-command
|
||||
classification result shared by pre-execution denial and governance evidence.
|
||||
Every PowerShell command denied as destructive must produce an
|
||||
`approval_requested` event when governance capture is enabled.
|
||||
|
||||
## Verified Current State
|
||||
|
||||
Current `main` has no dedicated PowerShell GateGuard route and excludes
|
||||
PowerShell from governance capture. PR #2721 adds the route and most of the
|
||||
detector, but its exact head still has these reproduced mismatches:
|
||||
|
||||
| Command class | PR #2721 GateGuard | PR #2721 governance |
|
||||
|---|---|---|
|
||||
| Direct recursive `Remove-Item` | deny | approval event |
|
||||
| Destructive command inside `$()` | allow | approval event |
|
||||
| Force-only `Remove-Item` | deny | no event |
|
||||
| Wildcard `Remove-Item` | deny | no event |
|
||||
| `.NET Directory::Delete` | deny | no event |
|
||||
| `Clear-Content` | allow | approval event |
|
||||
| `Format-Volume` | allow | approval event |
|
||||
| Benign `Get-ChildItem` | allow | no event |
|
||||
|
||||
The focused PR-head suites pass with 166 GateGuard tests and 35 governance
|
||||
tests. Those green suites do not cover the mismatches above. A direct
|
||||
`merge-tree` check against current `main` reports conflicts in
|
||||
`scripts/hooks/gateguard-fact-force.js` and `tests/hooks/hooks.test.js`.
|
||||
|
||||
Applying the stale PR files wholesale would also discard current-main heredoc
|
||||
filtering, narrow recovery guidance, valid `.*` hook matchers, post-dispatcher
|
||||
skill tracking, and newer hook tests.
|
||||
|
||||
## Prior Art Review
|
||||
|
||||
The implementation was informed by existing and merged alternatives before any
|
||||
production code was changed:
|
||||
|
||||
- PR #2721 supplied the original PowerShell route and detection inventory, but
|
||||
its conflicted head had GateGuard/governance drift and removed backticks
|
||||
before parsing, which changes PowerShell escape meaning.
|
||||
- PRs #1912 and #2495 established the useful bounded executable-body traversal
|
||||
and parser-focused test patterns. Their Bash parser was not reused because
|
||||
Bash backslashes and backticks have different semantics from PowerShell.
|
||||
- PR #2902 showed the safe forward-port pattern used here: retain current-main
|
||||
heredoc filtering, narrow recovery hints, and valid `.*` matchers while
|
||||
applying only the feature-specific changes.
|
||||
- PR #2897 reinforced that quoted delimiters must not terminate executable
|
||||
ranges and that executable expressions inside double quotes still run.
|
||||
- PR #2865 and related open work cover separate Bash and hook hardening. Those
|
||||
changes remain outside ECC-039 and were not absorbed into this patch.
|
||||
|
||||
## Design Decision
|
||||
|
||||
Add a pure shared module at
|
||||
`scripts/lib/powershell-destructive-command.js`. It returns stable,
|
||||
non-sensitive rule IDs for all matches. GateGuard denies when the result is
|
||||
non-empty, and governance uses the same result to emit approval evidence.
|
||||
|
||||
The module owns PowerShell-specific parsing and policy:
|
||||
|
||||
- `Remove-Item`, `Remove-ItemProperty`, and built-in aliases
|
||||
- `-Recurse` and valid unambiguous abbreviations
|
||||
- `-Force` without recursion
|
||||
- wildcard targets and opaque splatted parameters
|
||||
- pipeline-wide recursion evidence
|
||||
- `.NET` `Directory::Delete` and `File::Delete`
|
||||
- `cmd /c` recursive deletion
|
||||
- nested `powershell` and `pwsh -Command`
|
||||
- `Start-Process` and static nested-shell argument forms
|
||||
- UTF-16LE `-EncodedCommand`
|
||||
- `Clear-Content`, `Clear-Disk`, and `Format-Volume`
|
||||
- static aliases, functions, script blocks, class construction, and common
|
||||
execution primitives
|
||||
- fail-closed `powershell.dynamic-execution` evidence when an execution
|
||||
primitive cannot be resolved safely
|
||||
- bounded recursion that fails closed after executable nesting exceeds budget
|
||||
|
||||
The parser extracts balanced PowerShell `$()` bodies recursively. It treats
|
||||
subexpressions outside quotes and inside double quotes as executable, ignores
|
||||
single-quoted literals, respects backtick-escaped dollar signs, and handles
|
||||
nested parentheses without deleting escape characters before parsing.
|
||||
|
||||
GateGuard retains its current Bash classifier. The PowerShell path combines the
|
||||
existing shell-agnostic destructive classifications with the new shared
|
||||
PowerShell findings. Governance preserves its current Bash approval behavior
|
||||
and consumes the shared PowerShell findings for the PowerShell tool.
|
||||
|
||||
## Task List
|
||||
|
||||
1. Add red classifier and consumer tests.
|
||||
- Create `tests/lib/powershell-destructive-command.test.js`.
|
||||
- Add identical destructive and benign command tables to the GateGuard and
|
||||
governance consumer tests.
|
||||
- Prove the direct configured PowerShell route denies a recursive delete,
|
||||
while `$()` and evidence-parity cases fail before implementation.
|
||||
|
||||
2. Implement the shared PowerShell classifier.
|
||||
- Port only the valuable detection behavior from PR #2721.
|
||||
- Return stable rule IDs instead of raw command text or a bare boolean.
|
||||
- Add quote-aware, nesting-aware `$()` extraction and recursive scanning.
|
||||
- Preserve bounded work and conservative failure on opaque executable input.
|
||||
|
||||
3. Integrate GateGuard from current `main`.
|
||||
- Normalize the `PowerShell` tool name.
|
||||
- Add the PowerShell classifier to the existing shell branch.
|
||||
- Preserve first-denial and retry state semantics.
|
||||
- Emit the PowerShell hook ID in routine denial recovery guidance.
|
||||
- Preserve current heredoc stripping, denial dampening, and narrow recovery
|
||||
hints.
|
||||
|
||||
4. Integrate governance evidence.
|
||||
- Add PowerShell to the security-relevant tool set.
|
||||
- Emit one `approval_requested` event from the shared findings.
|
||||
- Store stable rule IDs and the existing command fingerprint only.
|
||||
- Preserve secret redaction and avoid raw command text in events.
|
||||
|
||||
5. Wire the configured entry points.
|
||||
- Add one dedicated PowerShell PreToolUse GateGuard route to
|
||||
`hooks/hooks.json`.
|
||||
- Add PowerShell to the pre-governance matcher.
|
||||
- Add PowerShell to post-governance dispatch only, keeping Bash-only post
|
||||
hooks restricted to Bash.
|
||||
- Preserve current `.*` matcher syntax and all current-main routes.
|
||||
|
||||
6. Exercise the real hook commands.
|
||||
- Run the exact command read from `hooks/hooks.json` for denial and
|
||||
governance capture with isolated state and unique sessions.
|
||||
- Clear ambient GateGuard opt-out variables in fixtures.
|
||||
- Verify the post-tool dispatcher selects governance for PowerShell.
|
||||
|
||||
7. Complete review and verification.
|
||||
- Run focused unit and hook suites, then the full repository suite and
|
||||
coverage.
|
||||
- Run a security review for parser bypasses, quote false positives, command
|
||||
leakage, recursion-budget behavior, and Bash regressions.
|
||||
- Resolve every critical or high finding before commit review.
|
||||
|
||||
## Acceptance Matrix
|
||||
|
||||
| Command class | GateGuard | Governance evidence |
|
||||
|---|---|---|
|
||||
| Recursive `Remove-Item` and aliases | deny first attempt | approval event |
|
||||
| Force-only `Remove-Item` | deny | approval event |
|
||||
| Wildcard or splatted delete | deny | approval event |
|
||||
| `.NET Directory::Delete` or `File::Delete` | deny | approval event |
|
||||
| `Clear-Content`, `Clear-Disk`, `Format-Volume` | deny | approval event |
|
||||
| Nested `pwsh -Command` or encoded command | deny | approval event |
|
||||
| Destructive command in unquoted `$()` | deny | approval event |
|
||||
| Destructive command in double-quoted `$()` | deny | approval event |
|
||||
| Recursively nested executable `$()` | deny | approval event |
|
||||
| Same text in a single-quoted literal | no destructive denial | no event |
|
||||
| Backtick-escaped literal `$()` | no destructive denial | no event |
|
||||
| Plain `Remove-Item file.txt` | allow under current policy | no event |
|
||||
| `Get-ChildItem` or `Get-Date` | allow | no event |
|
||||
| Existing Bash destructive and heredoc cases | unchanged | unchanged |
|
||||
| Configured PreToolUse route | command denies | event when enabled |
|
||||
| Configured PostToolUse route | not applicable | reaches governance |
|
||||
|
||||
## Verification
|
||||
|
||||
Run in this order:
|
||||
|
||||
```sh
|
||||
node tests/lib/powershell-destructive-command.test.js
|
||||
node tests/hooks/gateguard-fact-force.test.js
|
||||
node tests/hooks/governance-capture.test.js
|
||||
node tests/hooks/hooks.test.js
|
||||
node tests/hooks/posttooluse-dispatcher.test.js
|
||||
npm test
|
||||
npm run coverage
|
||||
git diff --check
|
||||
```
|
||||
|
||||
Hosted acceptance requires the repository security scan, lint, coverage, and
|
||||
the supported Node and package-manager CI matrix at the exact proposed head.
|
||||
|
||||
## Implementation and Verification Results
|
||||
|
||||
The implementation is committed in PR #2961. It adds the shared classifier,
|
||||
dedicated PowerShell hook routes, exact
|
||||
GateGuard/governance rule parity, redacted evidence, case-insensitive tool
|
||||
matching, post-tool governance dispatch, and the review-driven hardening needed
|
||||
for static variables embedded in nested double-quoted command payloads.
|
||||
|
||||
- Focused classifier and hook suites: 531 passed, 0 failed.
|
||||
- Full repository suite: 4,217 passed, 0 failed.
|
||||
- Coverage gate: passed at 89.23% statements, 81.28% branches, 94.56%
|
||||
functions, and 89.23% lines.
|
||||
- Supply-chain IOC scan: passed for all 224 inspected files.
|
||||
- ESLint, Markdown lint, hook validation, personal-path validation, and
|
||||
`git diff --check`: passed.
|
||||
- Independent final security replay: no critical or high findings across 109
|
||||
destructive cases, 19 benign controls, 9 elevation cases, and 13
|
||||
GateGuard/governance parity cases.
|
||||
- The 40,000-container, approximately 840 KB stress input completed well below
|
||||
the configured five-second hook timeout and preserved the destructive tail
|
||||
finding.
|
||||
|
||||
PowerShell itself is not installed in the local PATH, so the repository's
|
||||
native `install.ps1` delegation checks were skipped by their existing runtime
|
||||
guard. Classifier, configured-hook, governance, and dispatcher behavior were
|
||||
still exercised through the Node hook boundary.
|
||||
|
||||
## Risks and Controls
|
||||
|
||||
- PowerShell quoting and backtick semantics can cause bypasses or false
|
||||
positives. Use explicit executable and literal pairs for each parser case.
|
||||
- Short parameter prefixes can become ambiguous. Test only valid prefixes for
|
||||
the intended cmdlets and keep rule IDs visible in unit failures.
|
||||
- Encoded and deeply nested commands can consume unbounded work. Enforce a
|
||||
shared recursion budget and fail closed only after executable nesting is
|
||||
observed.
|
||||
- Dynamic execution can hide a command from static inspection. Resolve common
|
||||
static forms and return `powershell.dynamic-execution` for unresolved
|
||||
execution primitives or shell-launch splats.
|
||||
- Governance records can leak command content. Reuse the existing fingerprint
|
||||
and summary path and assert that emitted events contain no raw command.
|
||||
- A stale-PR merge can regress current hardening. Port PowerShell hunks manually
|
||||
onto `origin/main` and keep current-main regression tests green.
|
||||
|
||||
## Roadmap and Scope
|
||||
|
||||
This is post-2.2 hardening of the ECC 2 trustworthy substrate. It makes the
|
||||
policy/evidence seam truthful at configured hook boundaries and prepares for
|
||||
future evidence contracts while keeping ECC authoritative over policy,
|
||||
enforcement, canonical evidence, and workflow outcomes.
|
||||
|
||||
Out of scope are a general PowerShell parser, exact interpretation of arbitrary
|
||||
runtime-generated payloads or reflection, broader Bash classifier refactoring,
|
||||
public API changes, issue #2921 glob semantics, issue #2886 heredoc redesign,
|
||||
ExecutionCapsule, sandbox tiers, Feature Fleet, Itô, and Nasiko. Unresolved
|
||||
execution primitives fail closed instead of being interpreted. Current-main
|
||||
behavior for #2886 remains covered and unchanged.
|
||||
|
||||
Known non-bypass residuals are conservative classification of unresolved safe
|
||||
dynamic execution and `Start-Process` splats, plus whole-class scanning when a
|
||||
class is activated. Whole-class scanning can flag an uncalled destructive
|
||||
method when a safe sibling member is invoked. Separating constructor and method
|
||||
resolution is a precision improvement, not a release-blocking enforcement gap.
|
||||
+13
-1
@@ -13,6 +13,18 @@
|
||||
"description": "Consolidated Bash preflight dispatcher for quality, tmux, push, and GateGuard checks",
|
||||
"id": "pre:bash:dispatcher"
|
||||
},
|
||||
{
|
||||
"matcher": "PowerShell",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node -e \"const p=require('path');const r=(function(){var p=require('path'),f=require('fs'),o=require('os');var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var d=p.join(o.homedir(),'.claude');function L(x){try{return require(p.join(x,'scripts','lib','resolve-ecc-root')).resolveEccRoot()}catch(_){return null}}var r=L(d);if(r)return r;var s=['ecc','ecc@ecc','marketplaces/ecc','everything-claude-code','everything-claude-code@everything-claude-code','marketplaces/everything-claude-code'];for(var i=0;i<s.length;i++){r=L(p.join(d,'plugins',s[i]));if(r)return r}try{var g=['ecc','everything-claude-code'];for(var j=0;j<g.length;j++){var c=p.join(d,'plugins','cache',g[j]);var O=f.readdirSync(c);for(var k=0;k<O.length;k++){var q=p.join(c,O[k]);var V=f.readdirSync(q);for(var m=0;m<V.length;m++){r=L(p.join(q,V[m]));if(r)return r}}}}catch(_){}return d})();const s=p.join(r,'scripts/hooks/plugin-hook-bootstrap.js');process.env.CLAUDE_PLUGIN_ROOT=r;process.argv.splice(1,0,s);require(s)\" node scripts/hooks/run-with-flags.js pre:powershell:gateguard-fact-force scripts/hooks/gateguard-fact-force.js standard,strict",
|
||||
"timeout": 5
|
||||
}
|
||||
],
|
||||
"description": "PowerShell fact-forcing gate: inspect destructive commands without running unrelated Bash-only preflight hooks",
|
||||
"id": "pre:powershell:gateguard-fact-force"
|
||||
},
|
||||
{
|
||||
"matcher": "Write",
|
||||
"hooks": [
|
||||
@@ -49,7 +61,7 @@
|
||||
"id": "pre:observe:continuous-learning"
|
||||
},
|
||||
{
|
||||
"matcher": "Bash|Write|Edit|MultiEdit",
|
||||
"matcher": "Bash|PowerShell|Write|Edit|MultiEdit",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
|
||||
@@ -10,8 +10,8 @@
|
||||
*
|
||||
* Gates:
|
||||
* - Edit/Write: list importers, affected API, verify data schemas, quote instruction
|
||||
* - Bash (destructive): list targets, rollback plan, quote instruction
|
||||
* - Bash (routine): quote current instruction (once per session)
|
||||
* - Bash/PowerShell (destructive): list targets, rollback plan, quote instruction
|
||||
* - Bash/PowerShell (routine): quote current instruction (once per session)
|
||||
*
|
||||
* Compatible with run-with-flags.js via module.exports.run().
|
||||
* Cross-platform (Windows, macOS, Linux).
|
||||
@@ -26,6 +26,7 @@ const crypto = require('crypto');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { extractCommandSubstitutions, extractSubshellGroups, extractBraceGroups } = require('../lib/shell-substitution');
|
||||
const { classifyPowerShellDestructiveCommand } = require('../lib/powershell-destructive-command');
|
||||
const { stripHeredocBodies } = require('./gateguard-heredoc');
|
||||
|
||||
// Session state — scoped per session to avoid cross-session races.
|
||||
@@ -42,10 +43,13 @@ const MAX_SESSION_KEYS = 50;
|
||||
const ROUTINE_BASH_SESSION_KEY = '__bash_session__';
|
||||
const EDIT_WRITE_HOOK_ID = 'pre:edit-write:gateguard-fact-force';
|
||||
const BASH_HOOK_ID = 'pre:bash:gateguard-fact-force';
|
||||
const POWERSHELL_HOOK_ID = 'pre:powershell:gateguard-fact-force';
|
||||
const EDIT_WRITE_NARROW_RECOVERY_HINT =
|
||||
'Narrow recovery: add a matching path glob to `GATEGUARD_EXEMPT_GLOBS` to skip first-touch Edit/Write checks without disabling destructive Bash checks.';
|
||||
const ROUTINE_BASH_NARROW_RECOVERY_HINT =
|
||||
'Narrow recovery: set `GATEGUARD_BASH_ROUTINE_DISABLED=1`; destructive Bash checks remain active.';
|
||||
const ROUTINE_POWERSHELL_NARROW_RECOVERY_HINT =
|
||||
'Narrow recovery: set `GATEGUARD_BASH_ROUTINE_DISABLED=1`; destructive Bash and PowerShell checks remain active.';
|
||||
const ECC_DISABLE_VALUES = new Set(['0', 'false', 'off', 'disabled', 'disable']);
|
||||
const ECC_ENABLE_VALUES = new Set(['1', 'true', 'on', 'enabled', 'enable', 'yes']);
|
||||
|
||||
@@ -737,6 +741,27 @@ function isDestructiveBash(command) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the stable, non-sensitive rule IDs that drive the destructive gate.
|
||||
* PowerShell also passes through the existing Bash-compatible classifier so
|
||||
* shell-agnostic git, SQL, and operator-configured rules retain coverage.
|
||||
* Governance consumes this exact decision for PowerShell approval evidence.
|
||||
*
|
||||
* @param {string} toolName
|
||||
* @param {string} command
|
||||
* @returns {string[]}
|
||||
*/
|
||||
function classifyDestructiveCommand(toolName, command) {
|
||||
const normalizedTool = String(toolName || '').toLowerCase();
|
||||
if (normalizedTool !== 'bash' && normalizedTool !== 'powershell') return [];
|
||||
|
||||
const findings = [
|
||||
...(isDestructiveBash(command) ? ['gateguard.bash-compatible-destructive'] : []),
|
||||
...(normalizedTool === 'powershell' ? classifyPowerShellDestructiveCommand(command) : []),
|
||||
];
|
||||
return [...new Set(findings)];
|
||||
}
|
||||
|
||||
// --- State management (per-session, atomic writes, bounded) ---
|
||||
|
||||
function normalizeEnvValue(value) {
|
||||
@@ -915,8 +940,8 @@ function markChecked(key) {
|
||||
// 3); afterwards emit a condensed single-line denial that carries the
|
||||
// denial ordinal, so consecutive denials are structurally different and
|
||||
// never textually identical. True retries of an already-gated target are
|
||||
// unaffected (they were always allowed). Destructive-Bash and routine-Bash
|
||||
// gates are unchanged.
|
||||
// unaffected (they were always allowed). Destructive shell and routine shell
|
||||
// gates are not denial-dampened.
|
||||
|
||||
const DEFAULT_FULL_DENIALS = 3;
|
||||
|
||||
@@ -1136,11 +1161,12 @@ function destructiveBashMsg() {
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function routineBashMsg() {
|
||||
function routineShellMsg(toolName) {
|
||||
const shellName = toolName === 'PowerShell' ? 'PowerShell' : 'Bash';
|
||||
return [
|
||||
'[Fact-Forcing Gate]',
|
||||
'',
|
||||
'Before the first Bash command this session, present these facts:',
|
||||
`Before the first ${shellName} command this session, present these facts:`,
|
||||
'',
|
||||
'1. The current user request in one sentence',
|
||||
'2. What this specific command verifies or produces',
|
||||
@@ -1217,7 +1243,7 @@ function run(rawInput) {
|
||||
const rawToolName = data.tool_name || '';
|
||||
const toolInput = data.tool_input || {};
|
||||
// Normalize: case-insensitive matching via lookup map
|
||||
const TOOL_MAP = { edit: 'Edit', write: 'Write', multiedit: 'MultiEdit', bash: 'Bash' };
|
||||
const TOOL_MAP = { edit: 'Edit', write: 'Write', multiedit: 'MultiEdit', bash: 'Bash', powershell: 'PowerShell' };
|
||||
const toolName = TOOL_MAP[rawToolName.toLowerCase()] || rawToolName;
|
||||
const inSubagent = isSubagentInvocation(data);
|
||||
|
||||
@@ -1272,13 +1298,13 @@ function run(rawInput) {
|
||||
return rawInput; // allow
|
||||
}
|
||||
|
||||
if (toolName === 'Bash') {
|
||||
if (toolName === 'Bash' || toolName === 'PowerShell') {
|
||||
const command = toolInput.command || '';
|
||||
if (isReadOnlyGitIntrospection(command)) {
|
||||
return rawInput;
|
||||
}
|
||||
|
||||
if (isDestructiveBash(command)) {
|
||||
if (classifyDestructiveCommand(toolName, command).length > 0) {
|
||||
// Gate destructive commands on first attempt; allow retry after facts presented
|
||||
const key = '__destructive__' + crypto.createHash('sha256').update(command).digest('hex').slice(0, 16);
|
||||
if (!isChecked(key)) {
|
||||
@@ -1290,7 +1316,7 @@ function run(rawInput) {
|
||||
return rawInput; // allow retry after facts presented
|
||||
}
|
||||
|
||||
// Operator opt-out: skip the routine-bash gate entirely. The destructive
|
||||
// Operator opt-out: skip the routine shell gate entirely. The destructive
|
||||
// gate above still fires. This is the documented escape hatch for hosts
|
||||
// (Cursor, OpenCode, etc.) where the once-per-session routine gate is
|
||||
// friction without signal.
|
||||
@@ -1302,9 +1328,13 @@ function run(rawInput) {
|
||||
if (!markChecked(ROUTINE_BASH_SESSION_KEY)) {
|
||||
return allowWithStateWarning();
|
||||
}
|
||||
return denyResult(routineBashMsg(), {
|
||||
hookIds: [BASH_HOOK_ID],
|
||||
narrowRecoveryHint: ROUTINE_BASH_NARROW_RECOVERY_HINT
|
||||
const hookId = toolName === 'PowerShell' ? POWERSHELL_HOOK_ID : BASH_HOOK_ID;
|
||||
const narrowRecoveryHint = toolName === 'PowerShell'
|
||||
? ROUTINE_POWERSHELL_NARROW_RECOVERY_HINT
|
||||
: ROUTINE_BASH_NARROW_RECOVERY_HINT;
|
||||
return denyResult(routineShellMsg(toolName), {
|
||||
hookIds: [hookId],
|
||||
narrowRecoveryHint
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1314,4 +1344,4 @@ function run(rawInput) {
|
||||
return rawInput; // allow
|
||||
}
|
||||
|
||||
module.exports = { run };
|
||||
module.exports = { classifyDestructiveCommand, run };
|
||||
|
||||
@@ -19,8 +19,17 @@
|
||||
'use strict';
|
||||
|
||||
const crypto = require('crypto');
|
||||
const { isElevatedPowerShellCommand } = require('../lib/powershell-destructive-command');
|
||||
|
||||
const MAX_STDIN = 1024 * 1024;
|
||||
let destructiveCommandClassifier = null;
|
||||
|
||||
function classifyDestructiveCommand(toolName, command) {
|
||||
if (!destructiveCommandClassifier) {
|
||||
destructiveCommandClassifier = require('./gateguard-fact-force').classifyDestructiveCommand;
|
||||
}
|
||||
return destructiveCommandClassifier(toolName, command);
|
||||
}
|
||||
|
||||
// Patterns that indicate potential hardcoded secrets
|
||||
const SECRET_PATTERNS = [
|
||||
@@ -34,6 +43,7 @@ const SECRET_PATTERNS = [
|
||||
// Tool names that represent security-relevant operations
|
||||
const SECURITY_RELEVANT_TOOLS = new Set([
|
||||
'Bash', // Could execute arbitrary commands
|
||||
'PowerShell',
|
||||
]);
|
||||
|
||||
// Commands that require governance approval
|
||||
@@ -123,8 +133,27 @@ function summarizeCommand(command) {
|
||||
};
|
||||
}
|
||||
|
||||
if (trimmed.startsWith("'") || trimmed.startsWith('"')) {
|
||||
return {
|
||||
commandName: null,
|
||||
commandFingerprint: fingerprintCommand(trimmed),
|
||||
};
|
||||
}
|
||||
|
||||
const firstToken = trimmed.split(/\s+/)[0] || '';
|
||||
// Static method invocations can attach their arguments to the first token,
|
||||
// for example `[IO.File]::Delete('private-path')`. Keep the operation name
|
||||
// while excluding attached argument content from governance evidence.
|
||||
const operation = firstToken.split('(', 1)[0].replace(/^['"]|['"]$/g, '');
|
||||
let commandName = null;
|
||||
if (/^\[(?:[A-Za-z_][\w]*\.)*[A-Za-z_][\w]*\]::[A-Za-z_][\w-]*$/.test(operation)) {
|
||||
commandName = operation;
|
||||
} else if (/^[A-Za-z_][A-Za-z0-9_.:\\/-]*$/.test(operation)) {
|
||||
commandName = operation.split(/[\\/]/).pop() || null;
|
||||
}
|
||||
|
||||
return {
|
||||
commandName: trimmed.split(/\s+/)[0] || null,
|
||||
commandName,
|
||||
commandFingerprint: fingerprintCommand(trimmed),
|
||||
};
|
||||
}
|
||||
@@ -142,7 +171,11 @@ function emitGovernanceEvent(event) {
|
||||
*/
|
||||
function analyzeForGovernanceEvents(input, context = {}) {
|
||||
const events = [];
|
||||
const toolName = input.tool_name || '';
|
||||
const rawToolName = input.tool_name || '';
|
||||
const normalizedToolName = String(rawToolName).toLowerCase();
|
||||
const toolName = normalizedToolName === 'powershell'
|
||||
? 'PowerShell'
|
||||
: normalizedToolName === 'bash' ? 'Bash' : rawToolName;
|
||||
const toolInput = input.tool_input || {};
|
||||
const toolOutput = typeof input.tool_output === 'string' ? input.tool_output : '';
|
||||
const sessionId = context.sessionId || null;
|
||||
@@ -174,13 +207,17 @@ function analyzeForGovernanceEvents(input, context = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Approval-required commands (Bash only)
|
||||
if (toolName === 'Bash') {
|
||||
// 2. Approval-required commands. Bash retains its existing approval
|
||||
// patterns. PowerShell consumes the exact classifier result used by
|
||||
// GateGuard so denial and governance evidence cannot drift apart.
|
||||
if (toolName === 'Bash' || toolName === 'PowerShell') {
|
||||
const command = toolInput.command || '';
|
||||
const approvalFindings = detectApprovalRequired(command);
|
||||
const matchedPatterns = toolName === 'PowerShell'
|
||||
? classifyDestructiveCommand(toolName, command)
|
||||
: detectApprovalRequired(command).map(finding => finding.pattern);
|
||||
const commandSummary = summarizeCommand(command);
|
||||
|
||||
if (approvalFindings.length > 0) {
|
||||
if (matchedPatterns.length > 0) {
|
||||
events.push({
|
||||
id: generateEventId(),
|
||||
sessionId,
|
||||
@@ -189,7 +226,7 @@ function analyzeForGovernanceEvents(input, context = {}) {
|
||||
toolName,
|
||||
hookPhase,
|
||||
...commandSummary,
|
||||
matchedPatterns: approvalFindings.map(f => f.pattern),
|
||||
matchedPatterns,
|
||||
severity: 'high',
|
||||
},
|
||||
resolvedAt: null,
|
||||
@@ -220,7 +257,9 @@ function analyzeForGovernanceEvents(input, context = {}) {
|
||||
// 4. Security-relevant tool usage tracking
|
||||
if (SECURITY_RELEVANT_TOOLS.has(toolName) && hookPhase === 'post') {
|
||||
const command = toolInput.command || '';
|
||||
const hasElevated = /sudo\s/.test(command) || /chmod\s/.test(command) || /chown\s/.test(command);
|
||||
const hasElevated = toolName === 'PowerShell'
|
||||
? isElevatedPowerShellCommand(command)
|
||||
: /sudo\s/.test(command) || /chmod\s/.test(command) || /chown\s/.test(command);
|
||||
const commandSummary = summarizeCommand(command);
|
||||
|
||||
if (hasElevated) {
|
||||
|
||||
@@ -27,7 +27,7 @@ const SYNC_HOOKS = [
|
||||
{ id: 'post:edit:design-quality-check', matcher: 'Edit|Write|MultiEdit', profiles: 'standard,strict', script: 'scripts/hooks/design-quality-check.js', run: runDesignQualityCheck },
|
||||
{ id: 'post:edit:accumulator', matcher: 'Edit|Write|MultiEdit', profiles: 'standard,strict', script: 'scripts/hooks/post-edit-accumulator.js', run: runPostEditAccumulator },
|
||||
{ id: 'post:edit:console-warn', matcher: 'Edit', profiles: 'standard,strict', script: 'scripts/hooks/post-edit-console-warn.js', run: runConsoleWarn },
|
||||
{ id: 'post:governance-capture', matcher: 'Bash|Write|Edit|MultiEdit', profiles: 'standard,strict', script: 'scripts/hooks/governance-capture.js', run: runGovernanceCapture },
|
||||
{ id: 'post:governance-capture', matcher: 'Bash|PowerShell|Write|Edit|MultiEdit', profiles: 'standard,strict', script: 'scripts/hooks/governance-capture.js', run: runGovernanceCapture },
|
||||
{ id: 'post:session-activity-tracker', matcher: '*', profiles: 'standard,strict', script: 'scripts/hooks/session-activity-tracker.js', run: runSessionActivityTracker },
|
||||
{ id: 'post:ecc-metrics-bridge', matcher: '*', profiles: 'minimal,standard,strict', script: 'scripts/hooks/ecc-metrics-bridge.js', run: runMetricsBridge },
|
||||
{ id: 'post:ecc-context-monitor', matcher: '*', profiles: 'standard,strict', script: 'scripts/hooks/ecc-context-monitor.js', run: runContextMonitor }
|
||||
@@ -55,13 +55,14 @@ function getPluginRoot(env = process.env) {
|
||||
}
|
||||
|
||||
function matchesTool(matcher, toolName) {
|
||||
const normalizedToolName = String(toolName || '').toLowerCase();
|
||||
return (
|
||||
matcher === '*' ||
|
||||
String(matcher || '')
|
||||
.split('|')
|
||||
.map(value => value.trim())
|
||||
.filter(Boolean)
|
||||
.includes(String(toolName || ''))
|
||||
.some(value => value.toLowerCase() === normalizedToolName)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -105,6 +105,38 @@ function runBashHook(input, env = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
function runPowerShellHook(input, env = {}) {
|
||||
const rawInput = typeof input === 'string' ? input : JSON.stringify(input);
|
||||
const result = spawnSync(
|
||||
'node',
|
||||
[
|
||||
runner,
|
||||
'pre:powershell:gateguard-fact-force',
|
||||
'scripts/hooks/gateguard-fact-force.js',
|
||||
'standard,strict'
|
||||
],
|
||||
{
|
||||
input: rawInput,
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
ECC_HOOK_PROFILE: 'standard',
|
||||
GATEGUARD_STATE_DIR: stateDir,
|
||||
CLAUDE_SESSION_ID: TEST_SESSION_ID,
|
||||
...env
|
||||
},
|
||||
timeout: 15000,
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
code: Number.isInteger(result.status) ? result.status : 1,
|
||||
stdout: result.stdout || '',
|
||||
stderr: result.stderr || ''
|
||||
};
|
||||
}
|
||||
|
||||
function parseOutput(stdout) {
|
||||
try {
|
||||
return JSON.parse(stdout);
|
||||
@@ -2903,6 +2935,161 @@ function runTests() {
|
||||
})) passed++;
|
||||
else failed++;
|
||||
|
||||
// --- PowerShell tool consumer contract ---
|
||||
if (
|
||||
test('normalizes PowerShell tool-name casing before destructive classification', () => {
|
||||
for (const toolName of ['PowerShell', 'powershell', 'POWERSHELL']) {
|
||||
clearState();
|
||||
const result = runPowerShellHook({
|
||||
tool_name: toolName,
|
||||
tool_input: { command: 'Remove-Item -Force C:/tmp/demo' }
|
||||
});
|
||||
assert.strictEqual(result.code, 0, `${toolName} hook should exit 0`);
|
||||
const output = parseOutput(result.stdout);
|
||||
assert.ok(output, `${toolName} should produce JSON output`);
|
||||
assert.strictEqual(
|
||||
output.hookSpecificOutput?.permissionDecision,
|
||||
'deny',
|
||||
`${toolName} should be denied`
|
||||
);
|
||||
assert.match(
|
||||
output.hookSpecificOutput.permissionDecisionReason,
|
||||
/Destructive command detected/
|
||||
);
|
||||
}
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('denies the first routine PowerShell command and allows its retry', () => {
|
||||
clearState();
|
||||
const input = {
|
||||
tool_name: 'PowerShell',
|
||||
tool_input: { command: 'Get-Date' }
|
||||
};
|
||||
|
||||
const first = runPowerShellHook(input);
|
||||
assert.strictEqual(first.code, 0, 'first PowerShell hook should exit 0');
|
||||
const firstOutput = parseOutput(first.stdout);
|
||||
assert.ok(firstOutput, 'first PowerShell attempt should produce JSON output');
|
||||
assert.strictEqual(
|
||||
firstOutput.hookSpecificOutput?.permissionDecision,
|
||||
'deny',
|
||||
'first routine PowerShell command should be denied'
|
||||
);
|
||||
assert.match(
|
||||
firstOutput.hookSpecificOutput.permissionDecisionReason,
|
||||
/pre:powershell:gateguard-fact-force/,
|
||||
'recovery guidance should name the independently configurable PowerShell hook ID'
|
||||
);
|
||||
|
||||
const retry = runPowerShellHook(input);
|
||||
assert.strictEqual(retry.code, 0, 'PowerShell retry should exit 0');
|
||||
const retryOutput = parseOutput(retry.stdout);
|
||||
assert.ok(retryOutput, 'PowerShell retry should produce JSON output');
|
||||
if (retryOutput.hookSpecificOutput) {
|
||||
assert.notStrictEqual(
|
||||
retryOutput.hookSpecificOutput.permissionDecision,
|
||||
'deny',
|
||||
'routine PowerShell retry should be allowed'
|
||||
);
|
||||
} else {
|
||||
assert.strictEqual(retryOutput.tool_name, 'PowerShell');
|
||||
}
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('denies direct and nested destructive PowerShell commands', () => {
|
||||
const encodedPayload = Buffer.from(
|
||||
'Remove-Item -Force C:/tmp/demo',
|
||||
'utf16le'
|
||||
).toString('base64');
|
||||
const commands = [
|
||||
'Remove-Item -Recurse C:/tmp/demo',
|
||||
'rp -Force HKCU:/Software/Demo -Name setting',
|
||||
'Clear-Disk -Number 2 -RemoveData -Confirm:$false',
|
||||
'pwsh -Command "Remove-Item -Force C:/tmp/demo"',
|
||||
'pwsh -Command:"Remove-Item -Force C:/tmp/demo"',
|
||||
`pwsh -EncodedCommand:${encodedPayload}`,
|
||||
"$payload='Remove-Item -Force C:/tmp/demo'; pwsh -Command $payload",
|
||||
"$payload='Remove-Item -Force C:/tmp/demo'; pwsh -Command \"$payload\"",
|
||||
"$payload='Remove-Item -Force C:/tmp/demo'; pwsh -Command \"Write-Output ready; $payload\"",
|
||||
"$payload='Remove-Item'; pwsh -Command $payload -Force C:/tmp/demo",
|
||||
'pwsh -Command "Write-Output ready; $runtimePayload"',
|
||||
'pwsh -Command $runtimePayload -Force C:/tmp/demo',
|
||||
'Write-Output "$(Remove-Item -Force C:/tmp/demo)"',
|
||||
'& { Remove-Item -Force C:/tmp/demo }',
|
||||
'if ($true) { Remove-Item -Force C:/tmp/demo }',
|
||||
'@(Remove-Item -Force C:/tmp/demo)',
|
||||
'cmd /c "rd /s /q C:/tmp/demo"',
|
||||
'Remove-Item `\n-Force C:/tmp/demo',
|
||||
'# (\nRemove-Item -Force C:/tmp/demo',
|
||||
'<# ignored <# #> Remove-Item -Force C:/tmp/demo',
|
||||
'function cleanup { Remove-Item -Force C:/tmp/demo }; if ($true) { cleanup }',
|
||||
'cmd /c pwsh -Command "Remove-Item -Force C:/tmp/demo"',
|
||||
'@"\n" # $(Remove-Item -Force C:/tmp/demo)\n"@',
|
||||
'& ‘Remove-Item’ -Force C:/tmp/demo',
|
||||
'Invoke-Expression $runtimeValue',
|
||||
'pwsh -Command "$payload"; $payload = "Write-Output ok"'
|
||||
];
|
||||
|
||||
for (const command of commands) {
|
||||
clearState();
|
||||
const result = runPowerShellHook({
|
||||
tool_name: 'PowerShell',
|
||||
tool_input: { command }
|
||||
});
|
||||
assert.strictEqual(result.code, 0, `${command} hook should exit 0`);
|
||||
const output = parseOutput(result.stdout);
|
||||
assert.ok(output, `${command} should produce JSON output`);
|
||||
assert.strictEqual(
|
||||
output.hookSpecificOutput?.permissionDecision,
|
||||
'deny',
|
||||
`${command} should be denied`
|
||||
);
|
||||
assert.match(
|
||||
output.hookSpecificOutput.permissionDecisionReason,
|
||||
/Destructive command detected/
|
||||
);
|
||||
}
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('allows benign PowerShell after the shared routine shell gate is satisfied', () => {
|
||||
clearState();
|
||||
writeState({ checked: ['__bash_session__'], last_active: Date.now() });
|
||||
|
||||
for (const command of ['Get-ChildItem C:/tmp', 'Remove-Item C:/tmp/notes.txt']) {
|
||||
const result = runPowerShellHook({
|
||||
tool_name: 'PowerShell',
|
||||
tool_input: { command }
|
||||
});
|
||||
assert.strictEqual(result.code, 0, `${command} hook should exit 0`);
|
||||
const output = parseOutput(result.stdout);
|
||||
assert.ok(output, `${command} should produce JSON output`);
|
||||
if (output.hookSpecificOutput) {
|
||||
assert.notStrictEqual(
|
||||
output.hookSpecificOutput.permissionDecision,
|
||||
'deny',
|
||||
`${command} should not receive a destructive denial`
|
||||
);
|
||||
} else {
|
||||
assert.strictEqual(output.tool_name, 'PowerShell');
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
// Cleanup only the temp directory created by this test file.
|
||||
try {
|
||||
if (fs.existsSync(stateDir)) {
|
||||
|
||||
@@ -185,6 +185,257 @@ async function runTests() {
|
||||
assert.ok(/^[a-f0-9]{12}$/.test(securityEvent.payload.commandFingerprint), 'Expected short command fingerprint');
|
||||
assert.ok(!Object.prototype.hasOwnProperty.call(securityEvent.payload, 'command'), 'Should not store raw command text');
|
||||
})) passed += 1; else failed += 1;
|
||||
|
||||
if (await test('PowerShell approval events contain exact destructive rule IDs without raw commands', async () => {
|
||||
const encodedPayload = Buffer.from(
|
||||
'Remove-Item C:/private/encoded-command-sentinel/*',
|
||||
'utf16le'
|
||||
).toString('base64');
|
||||
const cases = [
|
||||
{
|
||||
command: 'Remove-Item -Recurse -Force C:/private/remove-command-sentinel',
|
||||
expectedRules: [
|
||||
'powershell.remove-item.recurse',
|
||||
'powershell.remove-item.force',
|
||||
],
|
||||
},
|
||||
{
|
||||
command: 'Remove-Item C:/private/wildcard-command-sentinel/*',
|
||||
expectedRules: ['powershell.remove-item.wildcard'],
|
||||
},
|
||||
{
|
||||
command: 'Remove-Item @deleteParams',
|
||||
expectedRules: ['powershell.remove-item.splat'],
|
||||
},
|
||||
{
|
||||
command: 'Get-ChildItem C:/private/pipeline-command-sentinel -Recurse | Remove-Item',
|
||||
expectedRules: ['powershell.remove-item.pipeline-recurse'],
|
||||
},
|
||||
{
|
||||
command: 'Clear-Content C:/private/clear-command-sentinel.txt',
|
||||
expectedRules: ['powershell.clear-content'],
|
||||
},
|
||||
{
|
||||
command: 'Clear-Disk -Number 2 -RemoveData -Confirm:$false',
|
||||
expectedRules: ['powershell.clear-disk'],
|
||||
},
|
||||
{
|
||||
command: 'Format-Volume -DriveLetter D -Force',
|
||||
expectedRules: ['powershell.format-volume'],
|
||||
},
|
||||
{
|
||||
command: "[System.IO.Directory]::Delete('C:/private/dotnet-command-sentinel', $true)",
|
||||
expectedRules: ['powershell.dotnet.directory-delete'],
|
||||
},
|
||||
{
|
||||
command: "[IO.File]::Delete('C:/private/file-command-sentinel.txt')",
|
||||
expectedRules: ['powershell.dotnet.file-delete'],
|
||||
},
|
||||
{
|
||||
command: 'cmd /c rd /s /q C:/private/cmd-command-sentinel',
|
||||
expectedRules: ['powershell.cmd.recursive-delete'],
|
||||
},
|
||||
{
|
||||
command: 'pwsh -Command "Remove-Item -Force C:/private/nested-command-sentinel"',
|
||||
expectedRules: ['powershell.remove-item.force'],
|
||||
},
|
||||
{
|
||||
command: 'pwsh -Command:"Remove-Item -Force C:/private/inline-command-sentinel"',
|
||||
expectedRules: ['powershell.remove-item.force'],
|
||||
},
|
||||
{
|
||||
command: "$payload='Remove-Item -Force C:/private/expanded-command-sentinel'; pwsh -Command \"Write-Output ready; $payload\"",
|
||||
expectedRules: ['powershell.remove-item.force'],
|
||||
},
|
||||
{
|
||||
command: 'pwsh -Command "Write-Output ready; $runtimePayload"',
|
||||
expectedRules: ['powershell.dynamic-execution'],
|
||||
},
|
||||
{
|
||||
command: 'pwsh -Command "$payload"; $payload = "Write-Output ok"',
|
||||
expectedRules: ['powershell.dynamic-execution'],
|
||||
},
|
||||
{
|
||||
command: 'pwsh -Command $runtimePayload -Force C:/private/runtime-command-sentinel',
|
||||
expectedRules: ['powershell.dynamic-execution'],
|
||||
},
|
||||
{
|
||||
command: `pwsh -EncodedCommand ${encodedPayload}`,
|
||||
expectedRules: ['powershell.remove-item.wildcard'],
|
||||
},
|
||||
{
|
||||
command: `pwsh -EncodedCommand:${encodedPayload}`,
|
||||
expectedRules: ['powershell.remove-item.wildcard'],
|
||||
},
|
||||
{
|
||||
command: 'Write-Output "$(Remove-Item -Force C:/private/subexpression-command-sentinel)"',
|
||||
expectedRules: ['powershell.remove-item.force'],
|
||||
},
|
||||
{
|
||||
command: '<# ignored <# #> Remove-Item -Force C:/private/comment-command-sentinel',
|
||||
expectedRules: ['powershell.remove-item.force'],
|
||||
},
|
||||
{
|
||||
command: 'function cleanup { Remove-Item -Force C:/private/function-command-sentinel }; $(cleanup)',
|
||||
expectedRules: ['powershell.remove-item.force'],
|
||||
},
|
||||
{
|
||||
command: 'cmd /c pwsh -Command "Remove-Item -Force C:/private/cmd-pwsh-sentinel"',
|
||||
expectedRules: ['powershell.remove-item.force'],
|
||||
},
|
||||
{
|
||||
command: 'Invoke-Expression $runtimeValue',
|
||||
expectedRules: ['powershell.dynamic-execution'],
|
||||
},
|
||||
{
|
||||
command: 'git switch --discard-changes',
|
||||
expectedRules: ['gateguard.bash-compatible-destructive'],
|
||||
},
|
||||
];
|
||||
|
||||
for (const { command, expectedRules } of cases) {
|
||||
const events = analyzeForGovernanceEvents({
|
||||
tool_name: 'PowerShell',
|
||||
tool_input: { command },
|
||||
}, {
|
||||
hookPhase: 'pre',
|
||||
});
|
||||
const approvalEvent = events.find(event => event.eventType === 'approval_requested');
|
||||
|
||||
assert.ok(approvalEvent, `${command} should raise approval_requested`);
|
||||
assert.strictEqual(approvalEvent.payload.toolName, 'PowerShell');
|
||||
assert.deepStrictEqual(
|
||||
[...approvalEvent.payload.matchedPatterns].sort(),
|
||||
[...expectedRules].sort(),
|
||||
`${command} should preserve exact classifier rule IDs`
|
||||
);
|
||||
assert.ok(
|
||||
/^[a-f0-9]{12}$/.test(approvalEvent.payload.commandFingerprint),
|
||||
'Expected short command fingerprint'
|
||||
);
|
||||
assert.ok(
|
||||
!Object.prototype.hasOwnProperty.call(approvalEvent.payload, 'command'),
|
||||
'Should not store raw command text'
|
||||
);
|
||||
assert.ok(
|
||||
!JSON.stringify(approvalEvent).includes(JSON.stringify(command).slice(1, -1)),
|
||||
'Serialized governance evidence should not leak the raw command'
|
||||
);
|
||||
}
|
||||
})) passed += 1; else failed += 1;
|
||||
|
||||
if (await test('PowerShell governance ignores literal and benign delete text', async () => {
|
||||
const commands = [
|
||||
'Get-ChildItem C:/tmp',
|
||||
'Get-Date',
|
||||
'Remove-Item C:/tmp/notes.txt',
|
||||
"Write-Output '$(Remove-Item -Force C:/tmp/demo)'",
|
||||
'Write-Output "`$(Remove-Item -Force C:/tmp/demo)"',
|
||||
];
|
||||
|
||||
for (const command of commands) {
|
||||
const events = analyzeForGovernanceEvents({
|
||||
tool_name: 'PowerShell',
|
||||
tool_input: { command },
|
||||
}, {
|
||||
hookPhase: 'pre',
|
||||
});
|
||||
|
||||
assert.ok(
|
||||
!events.some(event => event.eventType === 'approval_requested'),
|
||||
`${command} should not raise approval_requested`
|
||||
);
|
||||
}
|
||||
})) passed += 1; else failed += 1;
|
||||
|
||||
if (await test('PowerShell governance normalizes tool casing and redacts assignment prefixes', async () => {
|
||||
const command = "$label='governance-private-marker'; Remove-Item -Force C:/tmp/demo";
|
||||
for (const toolName of ['PowerShell', 'powershell', 'POWERSHELL']) {
|
||||
const events = analyzeForGovernanceEvents({
|
||||
tool_name: toolName,
|
||||
tool_input: { command },
|
||||
}, {
|
||||
hookPhase: 'pre',
|
||||
});
|
||||
const approvalEvent = events.find(event => event.eventType === 'approval_requested');
|
||||
assert.ok(approvalEvent, `${toolName} should raise approval_requested`);
|
||||
assert.strictEqual(approvalEvent.payload.toolName, 'PowerShell');
|
||||
assert.strictEqual(approvalEvent.payload.commandName, null);
|
||||
assert.ok(!JSON.stringify(events).includes('governance-private-marker'));
|
||||
}
|
||||
})) passed += 1; else failed += 1;
|
||||
|
||||
if (await test('PowerShell governance redacts quoted expression prefixes', async () => {
|
||||
const command = "'quoted-private-marker' ; Remove-Item -Force C:/tmp/demo";
|
||||
const events = analyzeForGovernanceEvents({
|
||||
tool_name: 'PowerShell',
|
||||
tool_input: { command },
|
||||
}, {
|
||||
hookPhase: 'pre',
|
||||
});
|
||||
const approvalEvent = events.find(event => event.eventType === 'approval_requested');
|
||||
assert.ok(approvalEvent, 'quoted prefix should still raise approval_requested');
|
||||
assert.strictEqual(approvalEvent.payload.commandName, null);
|
||||
assert.ok(!JSON.stringify(events).includes('quoted-private-marker'));
|
||||
})) passed += 1; else failed += 1;
|
||||
|
||||
if (await test('PowerShell elevation events are captured without raw command leakage', async () => {
|
||||
const commands = [
|
||||
'Start-Process -Verb RunAs cmd -ArgumentList elevation-command-sentinel',
|
||||
'Start-Process –Verb RunAs cmd',
|
||||
'Start-Process -Verb $("RunAs") cmd',
|
||||
'Start-Process -Verb ("RunAs") cmd',
|
||||
'saps pwsh -Verb RunAs',
|
||||
'start pwsh -Verb RunAs',
|
||||
'runas.exe /user:Administrator cmd',
|
||||
'sudo chmod 600 C:/private/native-elevation-sentinel',
|
||||
'$script:aclResult = Set-Acl -Path C:/private/scoped-assignment-sentinel -AclObject $acl',
|
||||
'Set-Acl -Path C:/private/acl-command-sentinel -AclObject $acl',
|
||||
'takeown /f C:/private/ownership-command-sentinel',
|
||||
"& 'Set-Acl' -Path C:/private/call-operator-sentinel -AclObject $acl",
|
||||
'Microsoft.PowerShell.Security\\Set-Acl -Path C:/private/module-sentinel -AclObject $acl',
|
||||
'Set`-Acl -Path C:/private/backtick-sentinel -AclObject $acl',
|
||||
'Write-Output $(Set-Acl -Path C:/private/subexpression-sentinel -AclObject $acl)',
|
||||
];
|
||||
|
||||
for (const command of commands) {
|
||||
const events = analyzeForGovernanceEvents({
|
||||
tool_name: 'PowerShell',
|
||||
tool_input: { command },
|
||||
}, {
|
||||
hookPhase: 'post',
|
||||
});
|
||||
const securityEvent = events.find(event => event.eventType === 'security_finding');
|
||||
|
||||
assert.ok(securityEvent, `${command} should raise a security_finding`);
|
||||
assert.strictEqual(securityEvent.payload.toolName, 'PowerShell');
|
||||
assert.strictEqual(securityEvent.payload.reason, 'elevated_privilege_command');
|
||||
assert.ok(
|
||||
/^[a-f0-9]{12}$/.test(securityEvent.payload.commandFingerprint),
|
||||
'Expected short command fingerprint'
|
||||
);
|
||||
assert.ok(
|
||||
!Object.prototype.hasOwnProperty.call(securityEvent.payload, 'command'),
|
||||
'Should not store raw command text'
|
||||
);
|
||||
assert.ok(
|
||||
!JSON.stringify(securityEvent).includes(JSON.stringify(command).slice(1, -1)),
|
||||
'Serialized governance evidence should not leak the raw command'
|
||||
);
|
||||
}
|
||||
|
||||
const literalEvents = analyzeForGovernanceEvents({
|
||||
tool_name: 'PowerShell',
|
||||
tool_input: { command: "Write-Output 'Start-Process -Verb RunAs cmd'" },
|
||||
}, {
|
||||
hookPhase: 'post',
|
||||
});
|
||||
assert.ok(
|
||||
!literalEvents.some(event => event.eventType === 'security_finding'),
|
||||
'quoted elevation prose should not raise a security finding'
|
||||
);
|
||||
})) passed += 1; else failed += 1;
|
||||
|
||||
if (await test('analyzeForGovernanceEvents detects sensitive file access', async () => {
|
||||
const events = analyzeForGovernanceEvents({
|
||||
tool_name: 'Edit',
|
||||
|
||||
@@ -2599,6 +2599,107 @@ async function runTests() {
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('hooks.json gives PowerShell dedicated GateGuard and governance routes', () => {
|
||||
const hooksPath = path.join(__dirname, '..', '..', 'hooks', 'hooks.json');
|
||||
const hooks = JSON.parse(fs.readFileSync(hooksPath, 'utf8'));
|
||||
const powerShellRoutes = hooks.hooks.PreToolUse.filter(entry => entry.matcher === 'PowerShell');
|
||||
const governanceRoute = hooks.hooks.PreToolUse.find(entry => entry.id === 'pre:governance-capture');
|
||||
|
||||
assert.strictEqual(
|
||||
powerShellRoutes.length,
|
||||
1,
|
||||
'Should have exactly one dedicated PreToolUse PowerShell route'
|
||||
);
|
||||
assert.strictEqual(
|
||||
powerShellRoutes[0].id,
|
||||
'pre:powershell:gateguard-fact-force',
|
||||
'PowerShell should use its independently configurable GateGuard hook ID'
|
||||
);
|
||||
assert.ok(
|
||||
powerShellRoutes[0].hooks[0].command.includes('pre:powershell:gateguard-fact-force'),
|
||||
'Configured command should preserve the PowerShell GateGuard hook ID'
|
||||
);
|
||||
assert.ok(
|
||||
powerShellRoutes[0].hooks[0].command.includes('scripts/hooks/gateguard-fact-force.js'),
|
||||
'PowerShell route should invoke GateGuard without Bash-only preflight hooks'
|
||||
);
|
||||
assert.ok(governanceRoute, 'PreToolUse governance route should exist');
|
||||
assert.ok(
|
||||
governanceRoute.matcher.split('|').includes('PowerShell'),
|
||||
'PreToolUse governance matcher should include PowerShell'
|
||||
);
|
||||
assert.ok(
|
||||
hooks.hooks.PostToolUse.every(entry => entry.matcher === '.*'),
|
||||
'Top-level PostToolUse dispatchers should preserve current-main wildcard matchers'
|
||||
);
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('configured PowerShell routes enforce denial and emit redacted governance evidence', () => {
|
||||
const root = path.join(__dirname, '..', '..');
|
||||
const hooks = JSON.parse(fs.readFileSync(path.join(root, 'hooks', 'hooks.json'), 'utf8'));
|
||||
const gateRoute = hooks.hooks.PreToolUse.find(entry => entry.id === 'pre:powershell:gateguard-fact-force');
|
||||
const governanceRoute = hooks.hooks.PreToolUse.find(entry => entry.id === 'pre:governance-capture');
|
||||
const stateDir = createTestDir();
|
||||
const command = 'Remove-Item -Force C:/private/configured-route-sentinel';
|
||||
const payload = JSON.stringify({
|
||||
tool_name: 'PowerShell',
|
||||
tool_input: { command }
|
||||
});
|
||||
const env = {
|
||||
...process.env,
|
||||
CLAUDE_PLUGIN_ROOT: root,
|
||||
ECC_HOOK_PROFILE: 'standard',
|
||||
GATEGUARD_STATE_DIR: stateDir,
|
||||
CLAUDE_SESSION_ID: 'ecc039-configured-route-test'
|
||||
};
|
||||
for (const key of ['ECC_GATEGUARD', 'GATEGUARD_DISABLED', 'GATEGUARD_BASH_ROUTINE_DISABLED', 'ECC_DISABLED_HOOKS']) {
|
||||
delete env[key];
|
||||
}
|
||||
|
||||
try {
|
||||
const gated = spawnSync(gateRoute.hooks[0].command, {
|
||||
cwd: root,
|
||||
env,
|
||||
input: payload,
|
||||
encoding: 'utf8',
|
||||
shell: true,
|
||||
timeout: 15000
|
||||
});
|
||||
assert.strictEqual(gated.status, 0, gated.stderr);
|
||||
assert.strictEqual(
|
||||
JSON.parse(gated.stdout).hookSpecificOutput?.permissionDecision,
|
||||
'deny',
|
||||
'exact configured GateGuard command should deny destructive PowerShell'
|
||||
);
|
||||
|
||||
const governed = spawnSync(governanceRoute.hooks[0].command, {
|
||||
cwd: root,
|
||||
env: {
|
||||
...env,
|
||||
ECC_GOVERNANCE_CAPTURE: '1',
|
||||
CLAUDE_HOOK_EVENT_NAME: 'PreToolUse'
|
||||
},
|
||||
input: payload,
|
||||
encoding: 'utf8',
|
||||
shell: true,
|
||||
timeout: 15000
|
||||
});
|
||||
assert.strictEqual(governed.status, 0, governed.stderr);
|
||||
assert.ok(governed.stderr.includes('powershell.remove-item.force'));
|
||||
assert.ok(!governed.stderr.includes(command), 'governance evidence should omit raw command text');
|
||||
} finally {
|
||||
cleanupTestDir(stateDir);
|
||||
}
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('all string hook matchers are valid regular expressions', () => {
|
||||
const hooksPath = path.join(__dirname, '..', '..', 'hooks', 'hooks.json');
|
||||
|
||||
@@ -30,7 +30,9 @@ function runDispatcher(mode, toolName, env = {}) {
|
||||
const raw = JSON.stringify({
|
||||
hook_event_name: 'PostToolUse',
|
||||
tool_name: toolName,
|
||||
tool_input: toolName === 'Bash' ? { command: 'true' } : { file_path: path.join(os.tmpdir(), 'ecc-posttooluse-test.txt') },
|
||||
tool_input: ['Bash', 'PowerShell'].includes(toolName)
|
||||
? { command: 'true' }
|
||||
: { file_path: path.join(os.tmpdir(), 'ecc-posttooluse-test.txt') },
|
||||
tool_response: {}
|
||||
});
|
||||
|
||||
@@ -126,6 +128,16 @@ function runTests() {
|
||||
sync: ['post:governance-capture', 'post:session-activity-tracker', 'post:ecc-metrics-bridge', 'post:ecc-context-monitor'],
|
||||
async: ['post:bash:dispatcher', 'post:observe:continuous-learning']
|
||||
},
|
||||
{
|
||||
tool: 'PowerShell',
|
||||
sync: ['post:governance-capture', 'post:session-activity-tracker', 'post:ecc-metrics-bridge', 'post:ecc-context-monitor'],
|
||||
async: ['post:observe:continuous-learning']
|
||||
},
|
||||
{
|
||||
tool: 'powershell',
|
||||
sync: ['post:governance-capture', 'post:session-activity-tracker', 'post:ecc-metrics-bridge', 'post:ecc-context-monitor'],
|
||||
async: ['post:observe:continuous-learning']
|
||||
},
|
||||
{
|
||||
tool: 'Read',
|
||||
sync: ['post:session-activity-tracker', 'post:ecc-metrics-bridge', 'post:ecc-context-monitor'],
|
||||
|
||||
@@ -0,0 +1,760 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('assert');
|
||||
const {
|
||||
classifyPowerShellDestructiveCommand,
|
||||
} = require('../../scripts/lib/powershell-destructive-command');
|
||||
|
||||
const RULES = Object.freeze({
|
||||
REMOVE_RECURSE: 'powershell.remove-item.recurse',
|
||||
REMOVE_FORCE: 'powershell.remove-item.force',
|
||||
REMOVE_WILDCARD: 'powershell.remove-item.wildcard',
|
||||
REMOVE_SPLAT: 'powershell.remove-item.splat',
|
||||
PIPELINE_RECURSE: 'powershell.remove-item.pipeline-recurse',
|
||||
CLEAR_CONTENT: 'powershell.clear-content',
|
||||
CLEAR_DISK: 'powershell.clear-disk',
|
||||
FORMAT_VOLUME: 'powershell.format-volume',
|
||||
DOTNET_DIRECTORY_DELETE: 'powershell.dotnet.directory-delete',
|
||||
DOTNET_FILE_DELETE: 'powershell.dotnet.file-delete',
|
||||
CMD_RECURSIVE_DELETE: 'powershell.cmd.recursive-delete',
|
||||
DYNAMIC_EXECUTION: 'powershell.dynamic-execution',
|
||||
SCAN_DEPTH_EXCEEDED: 'powershell.scan-depth-exceeded',
|
||||
});
|
||||
|
||||
console.log('=== Testing powershell-destructive-command.js ===\n');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(` PASS ${name}`);
|
||||
passed += 1;
|
||||
} catch (error) {
|
||||
console.log(` FAIL ${name}`);
|
||||
console.log(` ${error.message}`);
|
||||
failed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
function classify(command) {
|
||||
const findings = classifyPowerShellDestructiveCommand(command);
|
||||
assert.ok(Array.isArray(findings), 'classifier must return an array');
|
||||
assert.ok(
|
||||
findings.every(ruleId => typeof ruleId === 'string' && ruleId.length > 0),
|
||||
'every finding must be a non-empty rule-id string'
|
||||
);
|
||||
assert.strictEqual(
|
||||
new Set(findings).size,
|
||||
findings.length,
|
||||
`findings must be unique: ${JSON.stringify(findings)}`
|
||||
);
|
||||
return findings;
|
||||
}
|
||||
|
||||
function expectRules(command, expected) {
|
||||
const actual = classify(command);
|
||||
assert.deepStrictEqual(
|
||||
[...actual].sort(),
|
||||
[...expected].sort(),
|
||||
`unexpected findings for ${JSON.stringify(command)}`
|
||||
);
|
||||
}
|
||||
|
||||
function expectSafe(command) {
|
||||
expectRules(command, []);
|
||||
}
|
||||
|
||||
console.log('Remove-Item forms:');
|
||||
|
||||
test('classifies recursive and force parameters independently', () => {
|
||||
expectRules('Remove-Item -Recurse -Force C:/tmp/demo', [
|
||||
RULES.REMOVE_RECURSE,
|
||||
RULES.REMOVE_FORCE,
|
||||
]);
|
||||
expectRules('Remove-Item -Recurse C:/tmp/demo', [RULES.REMOVE_RECURSE]);
|
||||
expectRules('Remove-Item -Force C:/tmp/demo', [RULES.REMOVE_FORCE]);
|
||||
});
|
||||
|
||||
test('classifies PowerShell parameter abbreviations case-insensitively', () => {
|
||||
expectRules('REMOVE-ITEM -Rec -Fo C:/tmp/demo', [
|
||||
RULES.REMOVE_RECURSE,
|
||||
RULES.REMOVE_FORCE,
|
||||
]);
|
||||
});
|
||||
|
||||
test('normalizes every PowerShell command-parameter dash character', () => {
|
||||
expectRules('Remove-Item –Force C:/tmp/demo', [RULES.REMOVE_FORCE]);
|
||||
expectRules('Remove-Item —Recurse C:/tmp/demo', [RULES.REMOVE_RECURSE]);
|
||||
expectRules('Remove-Item ―Force C:/tmp/demo', [RULES.REMOVE_FORCE]);
|
||||
});
|
||||
|
||||
test('normalizes PowerShell backtick obfuscation after finding executable ranges', () => {
|
||||
expectRules('Rem`ove-Item -Rec`urse C:/tmp/demo', [RULES.REMOVE_RECURSE]);
|
||||
});
|
||||
|
||||
test('classifies recursive Remove-Item aliases', () => {
|
||||
for (const alias of ['ri', 'rm', 'rmdir', 'rd', 'del', 'erase']) {
|
||||
expectRules(`${alias} -Recurse C:/tmp/demo`, [RULES.REMOVE_RECURSE]);
|
||||
}
|
||||
expectRules('rp -Force HKCU:/Software/Demo -Name setting', [RULES.REMOVE_FORCE]);
|
||||
expectRules('Remove-ItemProperty -Force HKCU:/Software/Demo -Name setting', [
|
||||
RULES.REMOVE_FORCE,
|
||||
]);
|
||||
});
|
||||
|
||||
test('classifies wildcard targets, including quoted provider paths', () => {
|
||||
expectRules('Remove-Item C:/build/*', [RULES.REMOVE_WILDCARD]);
|
||||
expectRules('Remove-Item "C:/build/file?.tmp"', [RULES.REMOVE_WILDCARD]);
|
||||
});
|
||||
|
||||
test('classifies splatted Remove-Item parameters', () => {
|
||||
expectRules('Remove-Item @deleteParams', [RULES.REMOVE_SPLAT]);
|
||||
});
|
||||
|
||||
test('returns deterministic, unique rule IDs when a rule matches repeatedly', () => {
|
||||
const command = 'Remove-Item -Force C:/one; Remove-Item -Force C:/two';
|
||||
const first = classify(command);
|
||||
const second = classify(command);
|
||||
|
||||
assert.deepStrictEqual(first, second);
|
||||
assert.deepStrictEqual(first, [RULES.REMOVE_FORCE]);
|
||||
});
|
||||
|
||||
console.log('\nAdditional destructive APIs:');
|
||||
|
||||
test('classifies Clear-Content, Clear-Disk, and Format-Volume', () => {
|
||||
expectRules('Clear-Content C:/tmp/log.txt', [RULES.CLEAR_CONTENT]);
|
||||
expectRules('Clear-Disk -Number 2 -RemoveData -Confirm:$false', [RULES.CLEAR_DISK]);
|
||||
expectRules('Format-Volume -DriveLetter D -Force', [RULES.FORMAT_VOLUME]);
|
||||
});
|
||||
|
||||
test('classifies .NET directory and file deletion', () => {
|
||||
expectRules("[System.IO.Directory]::Delete('C:/tmp/demo', $true)", [
|
||||
RULES.DOTNET_DIRECTORY_DELETE,
|
||||
]);
|
||||
expectRules("[IO.File]::Delete('C:/tmp/demo.txt')", [
|
||||
RULES.DOTNET_FILE_DELETE,
|
||||
]);
|
||||
expectRules("[IO.Fi`le]::Delete('C:/tmp/demo.txt')", [
|
||||
RULES.DOTNET_FILE_DELETE,
|
||||
]);
|
||||
});
|
||||
|
||||
test('classifies recursive cmd.exe deletion reached through PowerShell', () => {
|
||||
expectRules('cmd /c rd /s /q C:/tmp/demo', [RULES.CMD_RECURSIVE_DELETE]);
|
||||
expectRules('cmd.exe /c del /s /q C:/tmp/demo/*', [
|
||||
RULES.CMD_RECURSIVE_DELETE,
|
||||
]);
|
||||
expectRules('cmd /c "rd /s /q C:/tmp/demo"', [RULES.CMD_RECURSIVE_DELETE]);
|
||||
expectRules('cmd /c @rd /s /q C:/tmp/demo', [RULES.CMD_RECURSIVE_DELETE]);
|
||||
expectRules('cmd /c --% rd /s /q C:/tmp/demo', [RULES.CMD_RECURSIVE_DELETE]);
|
||||
expectRules('cmd /c if exist C:/tmp/demo rd /s /q C:/tmp/demo', [
|
||||
RULES.CMD_RECURSIVE_DELETE,
|
||||
]);
|
||||
expectRules('cmd /c "(rd /s /q C:/tmp/demo)"', [RULES.CMD_RECURSIVE_DELETE]);
|
||||
expectRules('cmd /c (rd /s /q C:/tmp/demo)', [RULES.CMD_RECURSIVE_DELETE]);
|
||||
expectRules('cmd /c if /i "x"=="x" rd /s /q C:/tmp/demo', [
|
||||
RULES.CMD_RECURSIVE_DELETE,
|
||||
]);
|
||||
expectRules('cmd /c for %i in (1) do rd /s /q C:/tmp/demo', [
|
||||
RULES.CMD_RECURSIVE_DELETE,
|
||||
]);
|
||||
expectRules('cmd /c call rd /s /q C:/tmp/demo', [RULES.CMD_RECURSIVE_DELETE]);
|
||||
expectRules('cmd /c start /wait rd /s /q C:/tmp/demo', [
|
||||
RULES.CMD_RECURSIVE_DELETE,
|
||||
]);
|
||||
expectRules('cmd /c if exist C:/never echo safe else rd /s /q C:/tmp/demo', [
|
||||
RULES.CMD_RECURSIVE_DELETE,
|
||||
]);
|
||||
for (const command of [
|
||||
'cmd /c if exist C:/never echo safe else if exist C:/never echo safe else rd /s /q C:/tmp/demo',
|
||||
'cmd /c for %i in (1) do if exist C:/never echo safe else rd /s /q C:/tmp/demo',
|
||||
'cmd /c call call rd /s /q C:/tmp/demo',
|
||||
'cmd /c start "job" /wait cmd /c rd /s /q C:/tmp/demo',
|
||||
'cmd /c >nul rd /s /q C:/tmp/demo',
|
||||
'cmd /c if /i "x" EQU "x" rd /s /q C:/tmp/demo',
|
||||
'cmd /c if 1 NEQ 2 rd /s /q C:/tmp/demo',
|
||||
'cmd /c if /i "x" EQU "x" if 1 NEQ 2 rd /s /q C:/tmp/demo',
|
||||
]) {
|
||||
expectRules(command, [RULES.CMD_RECURSIVE_DELETE]);
|
||||
}
|
||||
});
|
||||
|
||||
test('classifies pipeline recursion evidence upstream of Remove-Item', () => {
|
||||
expectRules('Get-ChildItem C:/tmp -Recurse | Remove-Item', [
|
||||
RULES.PIPELINE_RECURSE,
|
||||
]);
|
||||
});
|
||||
|
||||
console.log('\nNested shell payloads:');
|
||||
|
||||
test('does not resolve earlier invocations from later scalar assignments', () => {
|
||||
for (const invocation of [
|
||||
'pwsh -Command "$payload"',
|
||||
'pwsh -Command:$payload',
|
||||
'pwsh -EncodedCommand:$payload',
|
||||
'Invoke-Expression $payload',
|
||||
'& $payload',
|
||||
]) {
|
||||
expectRules(`${invocation}; $payload = 'Write-Output ok'`, [RULES.DYNAMIC_EXECUTION]);
|
||||
}
|
||||
expectRules('pwsh -Command "$payload"; $payload = "Remove-Item -Force C:/tmp/demo"', [
|
||||
RULES.DYNAMIC_EXECUTION,
|
||||
]);
|
||||
expectSafe('$payload = "Write-Output ok"; pwsh -Command "$payload"');
|
||||
});
|
||||
|
||||
test('classifies powershell and pwsh command payloads recursively', () => {
|
||||
expectRules(
|
||||
'powershell -Command "Remove-Item -Recurse C:/tmp/demo"',
|
||||
[RULES.REMOVE_RECURSE]
|
||||
);
|
||||
expectRules(
|
||||
"pwsh -c 'Remove-Item -Force C:/tmp/demo'",
|
||||
[RULES.REMOVE_FORCE]
|
||||
);
|
||||
expectRules(
|
||||
'cmd /c pwsh -Command "Remove-Item -Force C:/tmp/demo"',
|
||||
[RULES.REMOVE_FORCE]
|
||||
);
|
||||
expectRules(
|
||||
"'Remove-Item -Force C:/tmp/demo' | pwsh -Command -",
|
||||
[RULES.REMOVE_FORCE]
|
||||
);
|
||||
expectRules(
|
||||
"Write-Output 'Remove-Item -Force C:/tmp/demo' | pwsh -Command -",
|
||||
[RULES.REMOVE_FORCE]
|
||||
);
|
||||
expectRules("@('Remove-Item -Force C:/tmp/demo') | pwsh -Command -", [
|
||||
RULES.REMOVE_FORCE,
|
||||
]);
|
||||
expectRules("@'\nRemove-Item -Force C:/tmp/demo\n'@ | pwsh -Command -", [
|
||||
RULES.REMOVE_FORCE,
|
||||
]);
|
||||
expectRules("@'\nRemove-Item -Force C:/tmp/demo\n'@ | pwsh -NoProfile -Command -", [
|
||||
RULES.REMOVE_FORCE,
|
||||
]);
|
||||
expectRules(
|
||||
"Write-Output \"[IO.File]::Delete('C:/tmp/demo')\" | pwsh -Command -",
|
||||
[RULES.DOTNET_FILE_DELETE]
|
||||
);
|
||||
expectRules('pwsh -CommandWithArgs "Remove-Item -Force C:/tmp/demo"', [
|
||||
RULES.REMOVE_FORCE,
|
||||
]);
|
||||
expectRules('pwsh -cwa "Remove-Item -Force C:/tmp/demo"', [RULES.REMOVE_FORCE]);
|
||||
expectRules('pwsh -Command:"Remove-Item -Force C:/tmp/demo"', [RULES.REMOVE_FORCE]);
|
||||
expectRules('pwsh -Command:Remove-Item -Force C:/tmp/demo', [RULES.REMOVE_FORCE]);
|
||||
expectRules(
|
||||
"Start-Process pwsh -ArgumentList '-NoProfile -Command \"Remove-Item -Force C:/tmp/demo\"'",
|
||||
[RULES.REMOVE_FORCE]
|
||||
);
|
||||
for (const command of [
|
||||
"Start-Process pwsh -ArgumentList '-NoProfile','-Command','Remove-Item -Force C:/tmp/demo'",
|
||||
"Start-Process -FilePath pwsh -ArgumentList '-NoProfile', '-Command', 'Remove-Item -Force C:/tmp/demo'",
|
||||
"saps pwsh -ArgumentList '-NoProfile','-c','Remove-Item -Force C:/tmp/demo'",
|
||||
"Start-Process pwsh -ArgumentList @('-NoProfile','-Command','Remove-Item -Force C:/tmp/demo')",
|
||||
"Start-Process pwsh '-Command \"Remove-Item -Force C:/tmp/demo\"'",
|
||||
"Start-Process pwsh -Args '-Command \"Remove-Item -Force C:/tmp/demo\"'",
|
||||
"Start-Process -FilePath:pwsh -ArgumentList '-Command \"Remove-Item -Force C:/tmp/demo\"'",
|
||||
"Start-Process pwsh -ArgumentList:'-Command \"Remove-Item -Force C:/tmp/demo\"'",
|
||||
"Start-Process -Fi:pwsh -Arg:'-Command \"Remove-Item -Force C:/tmp/demo\"'",
|
||||
"Start-Process -ArgumentList '-Command \"Remove-Item -Force C:/tmp/demo\"' -FilePath pwsh",
|
||||
"Start-Process -WindowStyle Hidden pwsh -ArgumentList '-Command \"Remove-Item -Force C:/tmp/demo\"'",
|
||||
"Start-Process -WorkingDirectory C:/tmp pwsh -ArgumentList '-Command \"Remove-Item -Force C:/tmp/demo\"'",
|
||||
"Start-Process pwsh '-NoProfile','-Command','Remove-Item -Force C:/tmp/demo'",
|
||||
"Start-Process pwsh -ArgumentList @('-NoProfile',('-Command'),('Remove-Item -Force C:/tmp/demo'))",
|
||||
]) {
|
||||
expectRules(command, [RULES.REMOVE_FORCE]);
|
||||
}
|
||||
expectRules("Start-Process cmd -ArgumentList '/c rd /s /q C:/tmp/demo'", [
|
||||
RULES.CMD_RECURSIVE_DELETE,
|
||||
]);
|
||||
expectRules(
|
||||
"$params=@{FilePath='pwsh';ArgumentList='-Command \"Remove-Item -Force C:/tmp/demo\"'}; Start-Process @params",
|
||||
[RULES.DYNAMIC_EXECUTION]
|
||||
);
|
||||
expectRules(
|
||||
"$global:params=@{FilePath='pwsh';ArgumentList='-Command \"Remove-Item -Force C:/tmp/demo\"'}; Start-Process @global:params",
|
||||
[RULES.DYNAMIC_EXECUTION]
|
||||
);
|
||||
expectRules(
|
||||
"$shell='pwsh'; 'Remove-Item -Force C:/tmp/demo' | & $shell -Command -",
|
||||
[RULES.REMOVE_FORCE]
|
||||
);
|
||||
expectRules("@'\nRemove-Item -Force C:/tmp/demo\n'@ | & pwsh -Command -", [
|
||||
RULES.REMOVE_FORCE,
|
||||
]);
|
||||
});
|
||||
|
||||
test('classifies UTF-16LE EncodedCommand payloads', () => {
|
||||
const payload = Buffer.from(
|
||||
'Remove-Item C:/tmp/demo/*',
|
||||
'utf16le'
|
||||
).toString('base64');
|
||||
|
||||
expectRules(`pwsh -EncodedCommand ${payload}`, [RULES.REMOVE_WILDCARD]);
|
||||
expectRules(`pwsh -EncodedCommand:${payload}`, [RULES.REMOVE_WILDCARD]);
|
||||
expectRules(`$payload='${payload}'; pwsh -EncodedCommand:$payload`, [
|
||||
RULES.REMOVE_WILDCARD,
|
||||
]);
|
||||
expectRules('pwsh -EncodedCommand $runtimePayload', [RULES.DYNAMIC_EXECUTION]);
|
||||
expectSafe(`$payload='${payload}'; pwsh -EncodedCommand:\`$payload`);
|
||||
expectSafe(`$payload='${payload}'; pwsh -EncodedCommand:'$payload'`);
|
||||
});
|
||||
|
||||
test('ignores an invalid EncodedCommand payload without throwing', () => {
|
||||
assert.doesNotThrow(() => classify('pwsh -EncodedCommand %%%not-base64%%%'));
|
||||
expectSafe('pwsh -EncodedCommand %%%not-base64%%%');
|
||||
});
|
||||
|
||||
test('bounds deeply nested encoded commands and reports conservative evidence', () => {
|
||||
let command = 'Remove-Item -Recurse C:/tmp/demo';
|
||||
for (let depth = 0; depth < 8; depth += 1) {
|
||||
const payload = Buffer.from(command, 'utf16le').toString('base64');
|
||||
command = `pwsh -EncodedCommand ${payload}`;
|
||||
}
|
||||
|
||||
expectRules(command, [RULES.SCAN_DEPTH_EXCEEDED]);
|
||||
});
|
||||
|
||||
test('classifies destructive commands in executable PowerShell containers', () => {
|
||||
const commands = [
|
||||
'& { Remove-Item -Force C:/tmp/demo }',
|
||||
'if ($true) { Remove-Item -Force C:/tmp/demo }',
|
||||
'ForEach-Object { Remove-Item -Force C:/tmp/demo }',
|
||||
'@(Remove-Item -Force C:/tmp/demo)',
|
||||
'(Remove-Item -Force C:/tmp/demo)',
|
||||
'pwsh -Command "& { Remove-Item -Force C:/tmp/demo }"',
|
||||
'pwsh -Command { Remove-Item -Force C:/tmp/demo }',
|
||||
'switch ($x) { default { Remove-Item -Force C:/tmp/demo } }',
|
||||
"switch ($x) { 'match' { Remove-Item -Force C:/tmp/demo } }",
|
||||
'& ({ Remove-Item -Force C:/tmp/demo })',
|
||||
'& $( { Remove-Item -Force C:/tmp/demo } )',
|
||||
'Invoke-Command -ScriptBlock ({ Remove-Item -Force C:/tmp/demo })',
|
||||
'ForEach-Object -Process ({ Remove-Item -Force C:/tmp/demo })',
|
||||
'function cleanup { Remove-Item -Force C:/tmp/demo }; cleanup',
|
||||
];
|
||||
for (const command of commands) expectRules(command, [RULES.REMOVE_FORCE]);
|
||||
});
|
||||
|
||||
test('preserves executable context through spacing and nested grouping', () => {
|
||||
const commands = [
|
||||
`&${' '.repeat(300)}{ Remove-Item -Force C:/tmp/demo }`,
|
||||
'& (({ Remove-Item -Force C:/tmp/demo }))',
|
||||
'pwsh -Command (({ Remove-Item -Force C:/tmp/demo }))',
|
||||
'{ Remove-Item -Force C:/tmp/demo }.Invoke()',
|
||||
'{ Remove-Item -Force C:/tmp/demo }.InvokeReturnAsIs()',
|
||||
'{ Remove-Item -Force C:/tmp/demo }.Inv`oke()',
|
||||
"{ Remove-Item -Force C:/tmp/demo }.'Invoke'()",
|
||||
'{ Remove-Item -Force C:/tmp/demo }.InvokeWithContext($null, $null, @())',
|
||||
'{ Remove-Item -Force C:/tmp/demo } `\n.Invoke()',
|
||||
'{ Remove-Item -Force C:/tmp/demo }.GetNewClosure().Invoke()',
|
||||
'{ Remove-Item -Force C:/tmp/demo }.GetNewClosure().GetNewClosure().Invoke()',
|
||||
"{ Remove-Item -Force C:/tmp/demo }.'GetNewClosure'().Invoke()",
|
||||
];
|
||||
for (const command of commands) expectRules(command, [RULES.REMOVE_FORCE]);
|
||||
});
|
||||
|
||||
test('classifies invoked functions and filters across executable containers', () => {
|
||||
const commands = [
|
||||
'function cleanup { Remove-Item -Force C:/tmp/demo }; if ($true) { cleanup }',
|
||||
'function cleanup { Remove-Item -Force C:/tmp/demo }; $(cleanup)',
|
||||
'filter cleanup { Remove-Item -Force C:/tmp/demo }; 1 | cleanup',
|
||||
'1 | foreach { Remove-Item -Force C:/tmp/demo }',
|
||||
'1 | where { Remove-Item -Force C:/tmp/demo; $true }',
|
||||
'1 | Microsoft.PowerShell.Core\\ForEach-Object { Remove-Item -Force C:/tmp/demo }',
|
||||
];
|
||||
for (const command of commands) expectRules(command, [RULES.REMOVE_FORCE]);
|
||||
});
|
||||
|
||||
test('classifies invoked static script-block variables but leaves assignments inert', () => {
|
||||
expectSafe('$cleanup = { Remove-Item -Force C:/tmp/demo }');
|
||||
expectRules('$cleanup = { Remove-Item -Force C:/tmp/demo }; & $cleanup', [
|
||||
RULES.REMOVE_FORCE,
|
||||
]);
|
||||
expectRules('$cleanup = { Remove-Item -Force C:/tmp/demo }; $cleanup.Invoke()', [
|
||||
RULES.REMOVE_FORCE,
|
||||
]);
|
||||
expectRules('${cleanup} = { Remove-Item -Force C:/tmp/demo }; & ${cleanup}', [
|
||||
RULES.REMOVE_FORCE,
|
||||
]);
|
||||
for (const command of [
|
||||
'$cleanup = { Remove-Item -Force C:/tmp/demo }; Invoke-Command -ScriptBlock $cleanup',
|
||||
'$cleanup = { Remove-Item -Force C:/tmp/demo }; 1 | ForEach-Object -Process $cleanup',
|
||||
'$cleanup = { Remove-Item -Force C:/tmp/demo }; Start-Job -ScriptBlock $cleanup',
|
||||
'$cleanup = { Remove-Item -Force C:/tmp/demo }; Measure-Command -Expression $cleanup',
|
||||
'$cleanup = { Remove-Item -Force C:/tmp/demo }; Register-EngineEvent x -Action $cleanup',
|
||||
'$cleanup = { Remove-Item -Force C:/tmp/demo }; Invoke-Command $cleanup',
|
||||
'$cleanup = { Remove-Item -Force C:/tmp/demo }; 1 | ForEach-Object $cleanup',
|
||||
'$cleanup = { Remove-Item -Force C:/tmp/demo }; Start-Job $cleanup',
|
||||
'$cleanup = { Remove-Item -Force C:/tmp/demo }; Measure-Command $cleanup',
|
||||
'$cleanup = { Remove-Item -Force C:/tmp/demo }; $cleanup.GetNewClosure().Invoke()',
|
||||
'${cleanup} = { Remove-Item -Force C:/tmp/demo }; ${cleanup}.GetNewClosure().Invoke()',
|
||||
'$cleanup = { Remove-Item -Force C:/tmp/demo }; icm -ScriptBlock $cleanup',
|
||||
'$cleanup = { Remove-Item -Force C:/tmp/demo }; sajb -ScriptBlock $cleanup',
|
||||
'$cleanup = { Remove-Item -Force C:/tmp/demo }; Trace-Command demo -Expression $cleanup',
|
||||
'$cleanup = { Remove-Item -Force C:/tmp/demo }; Invoke-Command -NoNewScope $cleanup',
|
||||
'$cleanup = { Remove-Item -Force C:/tmp/demo }; 1 | ForEach-Object -Begin {} $cleanup',
|
||||
]) {
|
||||
expectRules(command, [RULES.REMOVE_FORCE]);
|
||||
}
|
||||
});
|
||||
|
||||
test('classifies static command results reached through the call operator', () => {
|
||||
expectRules("& ('Remove-Item') -Force C:/tmp/demo", [RULES.REMOVE_FORCE]);
|
||||
expectRules("& $('Remove-Item') -Force C:/tmp/demo", [RULES.REMOVE_FORCE]);
|
||||
expectRules("& (('Remove-Item')) -Force C:/tmp/demo", [RULES.REMOVE_FORCE]);
|
||||
expectRules("& $(( 'Remove-Item')) -Force C:/tmp/demo", [RULES.REMOVE_FORCE]);
|
||||
});
|
||||
|
||||
test('classifies assignments, hashtables, and multiline executable blocks', () => {
|
||||
const commands = [
|
||||
'$x = Remove-Item -Force C:/tmp/demo',
|
||||
'$h = @{ x = $(Remove-Item -Force C:/tmp/demo) }',
|
||||
'if ($true)\n{ Remove-Item -Force C:/tmp/demo }',
|
||||
'switch ($x)\n{ default { Remove-Item -Force C:/tmp/demo } }',
|
||||
'function cleanup\n{ Remove-Item -Force C:/tmp/demo }; cleanup',
|
||||
'if ($true) `\n{ Remove-Item -Force C:/tmp/demo }',
|
||||
];
|
||||
for (const command of commands) expectRules(command, [RULES.REMOVE_FORCE]);
|
||||
expectRules('$null = Clear-Disk -Number 2 -RemoveData -Confirm:$false', [
|
||||
RULES.CLEAR_DISK,
|
||||
]);
|
||||
});
|
||||
|
||||
test('classifies compact, scoped, indexed, property, and return execution', () => {
|
||||
const commands = [
|
||||
'$result=Remove-Item -Force C:/tmp/demo',
|
||||
'[object]$result=Remove-Item -Force C:/tmp/demo',
|
||||
'$script:x = Remove-Item -Force C:/tmp/demo',
|
||||
'${x} = Remove-Item -Force C:/tmp/demo',
|
||||
'$x[0] = Remove-Item -Force C:/tmp/demo',
|
||||
'$x.Value = Remove-Item -Force C:/tmp/demo',
|
||||
'$x,$y = Remove-Item -Force C:/tmp/demo',
|
||||
'return Remove-Item -Force C:/tmp/demo',
|
||||
'$script:cleanup = { Remove-Item -Force C:/tmp/demo }; & $script:cleanup',
|
||||
];
|
||||
for (const command of commands) expectRules(command, [RULES.REMOVE_FORCE]);
|
||||
});
|
||||
|
||||
test('classifies named function blocks and sibling consumer blocks', () => {
|
||||
const commands = [
|
||||
'function cleanup { begin { Remove-Item -Force C:/tmp/demo } }; cleanup',
|
||||
'function cleanup { process { Remove-Item -Force C:/tmp/demo } }; 1 | cleanup',
|
||||
'workflow cleanup { Remove-Item -Force C:/tmp/demo }; cleanup',
|
||||
'1 | ForEach-Object { Write-Output safe } { Remove-Item -Force C:/tmp/demo }',
|
||||
'Trace-Command demo -Expression { Remove-Item -Force C:/tmp/demo }',
|
||||
'Register-EngineEvent demo -Action { Remove-Item -Force C:/tmp/demo }',
|
||||
'Register-EngineEvent demo -Action:{ Remove-Item -Force C:/tmp/demo }',
|
||||
'class Cleanup { static [void] Run() { Remove-Item -Force C:/tmp/demo } }; [Cleanup]::Run()',
|
||||
'class Cleanup { Cleanup() { Remove-Item -Force C:/tmp/demo } }; [Cleanup]::new()',
|
||||
'class Cleanup { Cleanup() { Remove-Item -Force C:/tmp/demo } }; New-Object -TypeName Cleanup',
|
||||
'class Cleanup { Cleanup() { Remove-Item -Force C:/tmp/demo } }; New-Object Cleanup',
|
||||
'class Cleanup { Cleanup() { Remove-Item -Force C:/tmp/demo } }; New-Object ([Cleanup])',
|
||||
"class Cleanup { Cleanup() { Remove-Item -Force C:/tmp/demo } }; New-Object ('Cleanup')",
|
||||
'class Cleanup { Cleanup() { Remove-Item -Force C:/tmp/demo } }; [Activator]::CreateInstance([Cleanup])',
|
||||
"$type = 'Cleanup'; class Cleanup { Cleanup() { Remove-Item -Force C:/tmp/demo } }; New-Object $type",
|
||||
];
|
||||
for (const command of commands) expectRules(command, [RULES.REMOVE_FORCE]);
|
||||
});
|
||||
|
||||
test('classifies static execution primitives', () => {
|
||||
expectRules("iex 'Remove-Item -Force C:/tmp/demo'", [RULES.REMOVE_FORCE]);
|
||||
expectRules("Invoke-Expression 'Remove-Item -Force C:/tmp/demo'", [
|
||||
RULES.REMOVE_FORCE,
|
||||
]);
|
||||
expectRules("& ([scriptblock]::Create('Remove-Item -Force C:/tmp/demo'))", [
|
||||
RULES.REMOVE_FORCE,
|
||||
]);
|
||||
expectRules("Invoke-Expression @'\nRemove-Item -Force C:/tmp/demo\n'@", [
|
||||
RULES.REMOVE_FORCE,
|
||||
]);
|
||||
expectRules("[scriptblock]::Create(@'\nRemove-Item -Force C:/tmp/demo\n'@).Invoke()", [
|
||||
RULES.REMOVE_FORCE,
|
||||
]);
|
||||
expectRules("& (@'\nRemove-Item\n'@) -Force C:/tmp/demo", [RULES.REMOVE_FORCE]);
|
||||
for (const command of [
|
||||
"$cmd = 'Remove-Item -Force C:/tmp/demo'; Invoke-Expression $cmd",
|
||||
"$cmd='Remove-Item -Force C:/tmp/demo'; iex $cmd",
|
||||
"$cmd = 'Remove-Item -Force C:/tmp/demo'; & ([scriptblock]::Create($cmd))",
|
||||
"$name = 'Remove-Item'; & $name -Force C:/tmp/demo",
|
||||
"$args = '-Command \"Remove-Item -Force C:/tmp/demo\"'; Start-Process pwsh -ArgumentList $args",
|
||||
"$payload = 'Remove-Item -Force C:/tmp/demo'; pwsh -Command $payload",
|
||||
"$payload = 'Remove-Item -Force C:/tmp/demo'; pwsh -Command \"$payload\"",
|
||||
"$payload = 'Remove-Item -Force C:/tmp/demo'; pwsh -Command \"Write-Output ready; $payload\"",
|
||||
"$payload = 'Remove-Item -Force C:/tmp/demo'; pwsh -Command \"Write-Output ready; $($payload)\"",
|
||||
"$payload = 'Remove-Item -Force C:/tmp/demo'; pwsh -Command:$payload",
|
||||
"$payload = 'Remove-Item'; pwsh -Command $payload -Force C:/tmp/demo",
|
||||
"$payload = \"Remove-Item `\n-Force C:/tmp/demo\"; pwsh -Command $payload",
|
||||
]) {
|
||||
expectRules(command, [RULES.REMOVE_FORCE]);
|
||||
}
|
||||
expectRules('Invoke-Expression $runtimeValue', [RULES.DYNAMIC_EXECUTION]);
|
||||
expectRules('pwsh -Command $runtimeValue', [RULES.DYNAMIC_EXECUTION]);
|
||||
expectRules('pwsh -Command "Write-Output ready; $runtimeValue"', [
|
||||
RULES.DYNAMIC_EXECUTION,
|
||||
]);
|
||||
expectRules('pwsh -Command "Write-Output ready; $($runtimeValue)"', [
|
||||
RULES.DYNAMIC_EXECUTION,
|
||||
]);
|
||||
expectRules('pwsh -Command $runtimeValue -Force C:/tmp/demo', [
|
||||
RULES.DYNAMIC_EXECUTION,
|
||||
]);
|
||||
expectSafe("$payload = 'Remove-Item -Force C:/tmp/demo'; pwsh -Command '$payload'");
|
||||
expectSafe("$payload = 'Remove-Item -Force C:/tmp/demo'; pwsh -Command \"Write-Output `$payload\"");
|
||||
expectSafe("$payload = 'Remove-Item -Force C:/tmp/demo'; pwsh -Command:`$payload");
|
||||
expectSafe("$payload = 'Remove-Item -Force C:/tmp/demo'; pwsh -Command:'$payload'");
|
||||
expectRules('Start-Process pwsh -ArgumentList $runtimeArgs', [RULES.DYNAMIC_EXECUTION]);
|
||||
expectRules("$cmd='Remove-'; $cmd+='Item'; & $cmd -Force C:/tmp/demo", [
|
||||
RULES.DYNAMIC_EXECUTION,
|
||||
]);
|
||||
expectRules(
|
||||
'$verb=\'Remove\'; $cmd="${verb}-Item"; & $cmd -Force C:/tmp/demo',
|
||||
[RULES.DYNAMIC_EXECUTION]
|
||||
);
|
||||
expectRules('& (Get-Command Remove-Item) -Force C:/tmp/demo', [
|
||||
RULES.DYNAMIC_EXECUTION,
|
||||
]);
|
||||
expectRules("iex ('Remove-'+'Item -Force C:/tmp/demo')", [
|
||||
RULES.DYNAMIC_EXECUTION,
|
||||
]);
|
||||
expectRules("iex ('{0}-Item -Force C:/tmp/demo' -f 'Remove')", [
|
||||
RULES.DYNAMIC_EXECUTION,
|
||||
]);
|
||||
expectRules(
|
||||
'$cleanup={ Remove-Item -Force C:/tmp/demo }; Invoke-Command -ScriptBlock (Get-Variable cleanup -ValueOnly)',
|
||||
[RULES.DYNAMIC_EXECUTION]
|
||||
);
|
||||
expectRules('Set-Alias zap Remove-Item; zap -Force C:/tmp/demo', [
|
||||
RULES.REMOVE_FORCE,
|
||||
]);
|
||||
expectRules('New-Alias -Name zap -Value Remove-Item; zap -Force C:/tmp/demo', [
|
||||
RULES.REMOVE_FORCE,
|
||||
]);
|
||||
expectRules(
|
||||
"$ExecutionContext.InvokeCommand.InvokeScript('Remove-Item -Force C:/tmp/demo')",
|
||||
[RULES.REMOVE_FORCE]
|
||||
);
|
||||
expectRules(
|
||||
"${ExecutionContext}.InvokeCommand.InvokeScript('Remove-Item -Force C:/tmp/demo')",
|
||||
[RULES.REMOVE_FORCE]
|
||||
);
|
||||
expectRules(
|
||||
"$ExecutionContext.InvokeCommand.InvokeScript(\"Write-Output safe; `\nRemove-Item -Force C:/tmp/demo\")",
|
||||
[RULES.REMOVE_FORCE]
|
||||
);
|
||||
expectRules(
|
||||
"$ExecutionContext.InvokeCommand.InvokeScript(\"Write-Output safe; `\rRemove-Item -Force C:/tmp/demo\")",
|
||||
[RULES.REMOVE_FORCE]
|
||||
);
|
||||
});
|
||||
|
||||
test('scans malformed InvokeScript string arguments in bounded time', () => {
|
||||
const command = `$ExecutionContext.InvokeCommand.InvokeScript("${'`!'.repeat(10000)}`;
|
||||
const assignment = '$payload = "' + '`!'.repeat(10000);
|
||||
const startedAt = Date.now();
|
||||
expectRules(command, [RULES.DYNAMIC_EXECUTION]);
|
||||
expectSafe(assignment);
|
||||
assert.ok(Date.now() - startedAt < 4000, 'malformed string scan should remain below hook timeout');
|
||||
});
|
||||
|
||||
test('classifies command names composed from static subexpression output', () => {
|
||||
expectRules('Remove-$(Write-Output Item) -Force C:/tmp/demo', [RULES.REMOVE_FORCE]);
|
||||
expectRules('Clear-$(echo Disk) -Number 2', [RULES.CLEAR_DISK]);
|
||||
expectRules('Format-$(echo Volume) -DriveLetter D', [RULES.FORMAT_VOLUME]);
|
||||
expectRules('r$(echo m) -Force C:/tmp/demo', [RULES.REMOVE_FORCE]);
|
||||
});
|
||||
|
||||
test('classifies executable containers inside EncodedCommand payloads', () => {
|
||||
const payload = Buffer.from(
|
||||
'& { Remove-Item -Force C:/tmp/demo }',
|
||||
'utf16le'
|
||||
).toString('base64');
|
||||
expectRules(`pwsh -EncodedCommand ${payload}`, [RULES.REMOVE_FORCE]);
|
||||
});
|
||||
|
||||
console.log('\nPowerShell subexpressions:');
|
||||
|
||||
test('classifies destructive commands in unquoted subexpressions', () => {
|
||||
expectRules('Write-Output $(Remove-Item -Force C:/tmp/demo)', [
|
||||
RULES.REMOVE_FORCE,
|
||||
]);
|
||||
});
|
||||
|
||||
test('classifies destructive commands in double-quoted subexpressions', () => {
|
||||
expectRules('Write-Output "$(Remove-Item -Recurse C:/tmp/demo)"', [
|
||||
RULES.REMOVE_RECURSE,
|
||||
]);
|
||||
});
|
||||
|
||||
test('classifies recursively nested subexpressions', () => {
|
||||
expectRules(
|
||||
'Write-Output "$(Write-Output $(Remove-Item -Force C:/tmp/demo))"',
|
||||
[RULES.REMOVE_FORCE]
|
||||
);
|
||||
});
|
||||
|
||||
test('classifies sibling subexpressions without duplicating rule IDs', () => {
|
||||
expectRules(
|
||||
'Write-Output $(Remove-Item -Force C:/one) $(Remove-Item -Force C:/two)',
|
||||
[RULES.REMOVE_FORCE]
|
||||
);
|
||||
});
|
||||
|
||||
test('keeps quoted delimiters inside subexpressions from splitting commands', () => {
|
||||
expectRules(
|
||||
'Write-Output $(Write-Output "safe;|&"; Remove-Item -Force "C:/tmp/a;b/*")',
|
||||
[RULES.REMOVE_FORCE, RULES.REMOVE_WILDCARD]
|
||||
);
|
||||
});
|
||||
|
||||
test('keeps a quoted closing parenthesis inside a subexpression body', () => {
|
||||
expectRules(
|
||||
'Write-Output $(Write-Output ")"; Remove-Item -Force C:/tmp/demo)',
|
||||
[RULES.REMOVE_FORCE]
|
||||
);
|
||||
});
|
||||
|
||||
test('keeps double-quoted apostrophes from suppressing executable subexpressions', () => {
|
||||
expectRules(
|
||||
'Write-Output "it\'s $(Remove-Item -Force C:/tmp/demo)"',
|
||||
[RULES.REMOVE_FORCE]
|
||||
);
|
||||
});
|
||||
|
||||
test('treats subexpression text inside single quotes as literal', () => {
|
||||
expectSafe("Write-Output '$(Remove-Item -Force C:/tmp/demo)'");
|
||||
});
|
||||
|
||||
test('treats a backtick-escaped subexpression inside double quotes as literal', () => {
|
||||
expectSafe('Write-Output "`$(Remove-Item -Force C:/tmp/demo)"');
|
||||
});
|
||||
|
||||
test('respects literal and expandable PowerShell here-strings', () => {
|
||||
expectSafe("@'\nliteral's Remove-Item -Force C:/tmp/demo\n'@");
|
||||
expectSafe('Write-Output "@\'\nRemove-Item -Force C:/tmp/demo\n\'@"');
|
||||
expectRules('@"\n$(Remove-Item -Force C:/tmp/demo)\n"@', [
|
||||
RULES.REMOVE_FORCE,
|
||||
]);
|
||||
expectRules('@"\n" # $(Remove-Item -Force C:/tmp/demo)\n"@', [
|
||||
RULES.REMOVE_FORCE,
|
||||
]);
|
||||
expectSafe('@"\n" # literal Remove-Item -Force C:/tmp/demo\n"@');
|
||||
});
|
||||
|
||||
test('normalizes PowerShell smart quotes before lexical analysis', () => {
|
||||
expectRules('& ‘Remove-Item’ -Force C:/tmp/demo', [RULES.REMOVE_FORCE]);
|
||||
expectRules('& ‚Remove-Item‚ -Force C:/tmp/demo', [RULES.REMOVE_FORCE]);
|
||||
expectRules('& ‛Remove-Item‛ -Force C:/tmp/demo', [RULES.REMOVE_FORCE]);
|
||||
expectRules('& „Remove-Item„ -Force C:/tmp/demo', [RULES.REMOVE_FORCE]);
|
||||
expectSafe('Write-Output ‘$(Remove-Item -Force C:/tmp/demo)’');
|
||||
expectSafe('Write-Output ‚$(Remove-Item -Force C:/tmp/demo)‚');
|
||||
});
|
||||
|
||||
test('does not treat a backslash as an escape for an executable subexpression', () => {
|
||||
expectRules('Write-Output \\$(Remove-Item -Force C:/tmp/demo)', [
|
||||
RULES.REMOVE_FORCE,
|
||||
]);
|
||||
});
|
||||
|
||||
test('handles backtick line continuations before destructive parameters', () => {
|
||||
expectRules('Remove-Item `\n-Force C:/tmp/demo', [RULES.REMOVE_FORCE]);
|
||||
expectRules('Remove-Item `\r\n-Force C:/tmp/demo', [RULES.REMOVE_FORCE]);
|
||||
});
|
||||
|
||||
test('ignores comment syntax without letting it poison following parser state', () => {
|
||||
expectRules('# (\nRemove-Item -Force C:/tmp/demo', [RULES.REMOVE_FORCE]);
|
||||
expectRules('<# ( #>\nRemove-Item -Force C:/tmp/demo', [RULES.REMOVE_FORCE]);
|
||||
expectRules('<# ignored <# #> Remove-Item -Force C:/tmp/demo', [RULES.REMOVE_FORCE]);
|
||||
expectSafe('Write-Output safe # ; Remove-Item -Force C:/tmp/demo');
|
||||
expectSafe('Write-Output safe# | Remove-Item -Force C:/tmp/demo');
|
||||
expectSafe("# [IO.File]::Delete('C:/tmp/demo')");
|
||||
expectRules('# @"\nRemove-Item -Force C:/tmp/demo\n"@', [RULES.REMOVE_FORCE]);
|
||||
expectRules('${a#b}=1; Remove-Item -Force C:/tmp/demo', [RULES.REMOVE_FORCE]);
|
||||
expectRules('${a<#b}=1; Remove-Item -Force C:/tmp/demo', [RULES.REMOVE_FORCE]);
|
||||
});
|
||||
|
||||
test('scans large comments and unmatched openers in bounded time', () => {
|
||||
const started = Date.now();
|
||||
expectRules(`<# ${'$('.repeat(40000)} #>\nRemove-Item -Force C:/tmp/demo`, [
|
||||
RULES.REMOVE_FORCE,
|
||||
]);
|
||||
expectSafe('$('.repeat(40000));
|
||||
expectSafe('()'.repeat(10000));
|
||||
assert.ok(Date.now() - started < 2000, 'large malformed input should remain bounded');
|
||||
});
|
||||
|
||||
test('resolves long invoked-function chains within the hook time budget', () => {
|
||||
const definitions = [];
|
||||
for (let index = 0; index < 20001; index += 1) {
|
||||
const body = index === 20000
|
||||
? 'Remove-Item -Force C:/tmp/demo'
|
||||
: `f${index + 1}`;
|
||||
definitions.push(`function f${index} { ${body} }`);
|
||||
}
|
||||
const started = Date.now();
|
||||
expectRules(`${definitions.join('; ')}; f0`, [RULES.REMOVE_FORCE]);
|
||||
assert.ok(Date.now() - started < 4000, 'function resolution should remain below hook timeout');
|
||||
});
|
||||
|
||||
test('scans many sibling executable containers within the hook time budget', () => {
|
||||
const command = Array.from(
|
||||
{ length: 40000 },
|
||||
(_, index) => index === 39999
|
||||
? '$(Remove-Item -Force C:/tmp/demo)'
|
||||
: '$(Write-Output safe)'
|
||||
).join(' ');
|
||||
const started = Date.now();
|
||||
expectRules(command, [RULES.REMOVE_FORCE]);
|
||||
assert.ok(Date.now() - started < 4000, 'sibling containers should remain bounded');
|
||||
});
|
||||
|
||||
console.log('\nBenign controls:');
|
||||
|
||||
test('allows plain non-recursive, non-forced, non-wildcard Remove-Item', () => {
|
||||
expectSafe('Remove-Item C:/tmp/notes.txt');
|
||||
});
|
||||
|
||||
test('allows benign PowerShell and non-recursive cmd commands', () => {
|
||||
expectSafe('Get-ChildItem C:/tmp');
|
||||
expectSafe('Get-Date');
|
||||
expectSafe('cmd /c del C:/tmp/notes.txt');
|
||||
expectSafe('cmd /c echo rd /s /q C:/tmp/demo');
|
||||
expectSafe('cmd /c "echo safe ^& rd /s /q C:/tmp/demo"');
|
||||
expectSafe("function cleanup { Remove-Item -Force C:/tmp/demo }; 'cleanup'");
|
||||
expectSafe('Write-Output safe`nRemove-Item -Force C:/tmp/demo');
|
||||
});
|
||||
|
||||
test('allows explicitly false destructive switches and inert script blocks', () => {
|
||||
expectSafe('Remove-Item -Force:$false C:/tmp/demo');
|
||||
expectSafe('Remove-Item -Force:$null C:/tmp/demo');
|
||||
expectSafe('Remove-Item -Recurse:$false C:/tmp/demo');
|
||||
expectSafe("Remove-Item '-Force'");
|
||||
expectSafe("Remove-Item -LiteralPath 'C:/tmp/file*.txt'");
|
||||
expectSafe('{ Remove-Item -Force C:/tmp/demo }');
|
||||
expectSafe('function cleanup { Remove-Item -Force C:/tmp/demo }');
|
||||
});
|
||||
|
||||
test('treats backticks literally inside single-quoted strings', () => {
|
||||
expectRules("Write-Output 'safe`'; Remove-Item -Force C:/tmp/demo", [
|
||||
RULES.REMOVE_FORCE,
|
||||
]);
|
||||
});
|
||||
|
||||
test('handles empty and non-string commands', () => {
|
||||
expectSafe('');
|
||||
expectSafe(null);
|
||||
expectSafe(undefined);
|
||||
});
|
||||
|
||||
test('handles a trailing backtick without throwing or inventing a finding', () => {
|
||||
assert.doesNotThrow(() => classify('Write-Output safe`'));
|
||||
expectSafe('Write-Output safe`');
|
||||
});
|
||||
|
||||
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||
if (failed > 0) {
|
||||
process.exit(1);
|
||||
}
|
||||
Reference in New Issue
Block a user