diff --git a/tools/enforce-powershell-gate.mjs b/tools/enforce-powershell-gate.mjs index 77507486..524c75e1 100644 --- a/tools/enforce-powershell-gate.mjs +++ b/tools/enforce-powershell-gate.mjs @@ -9,13 +9,17 @@ import { defaultPathNormalize, DEFAULT_PROTECTED_PATTERNS, pathDenyOverlay, - matchAny, - hasInjection, classifyGitCommand, loadApprovedGitOps, + matchPsHardBlacklist, + PS_HARD_BLACKLIST, } from './shell-content-rules.mjs'; import { readStdin, parseEventJson, exitDecision } from './enforce-hook-helpers.mjs'; +// M7 PS single-source (P-1 зеркало): PS_HARD_BLACKLIST + matchPsHardBlacklist живут в едином доме +// shell-content-rules; ре-экспорт для обратной совместимости (тесты + тело гейта зовут локально). +export { matchPsHardBlacklist, PS_HARD_BLACKLIST }; + // PowerShell — лёгкий сплиттер по ; | && || (без shell-quote: иной синтаксис). export function tokenizePowerShell(command) { const parts = String(command || '').split(/\s*(?:\|\||&&|[;|])\s*/).filter((p) => p.trim() !== ''); @@ -26,44 +30,6 @@ export function tokenizePowerShell(command) { }); } -export const PS_HARD_BLACKLIST = [ - // keep v3.8 F1 - { re: /\b(?:Remove-Item|ri|del|erase|rd)\b/i, reason: 'Remove-Item/del запрещён' }, - { re: /\b(?:Move-Item|mi|move)\b/i, reason: 'Move-Item запрещён' }, - { re: /\b(?:Copy-Item|cpi|copy)\b/i, reason: 'Copy-Item запрещён' }, - { re: /\b(?:Set-Content|sc|Add-Content|ac|Out-File)\b/i, reason: 'Set/Add-Content/Out-File запрещён' }, - { re: /(?:^|[^0-9>&])>{1,2}(?![>&])/, reason: 'redirect (>/>>) запрещён' }, - { re: /\b(?:Invoke-Expression|iex)\b/i, reason: 'Invoke-Expression/iex запрещён' }, - { re: /\b(?:Invoke-WebRequest|iwr|curl|wget)\b[^\n]*\|\s*(?:iex|Invoke-Expression)/i, reason: 'IWR | iex запрещён' }, - { re: /\bStart-Process\b/i, reason: 'Start-Process запрещён' }, - { re: /\[System\.IO\.File\]::(?:Delete|WriteAllText|WriteAllBytes|AppendAllText)\b/i, reason: '[IO.File] write/delete запрещён' }, - { re: /\[System\.IO\.Directory\]::(?:Delete|CreateDirectory)\b/i, reason: '[IO.Directory] mutate запрещён' }, - { re: /\b(?:Stop-Process|kill|spps)\b/i, reason: 'Stop-Process/kill запрещён' }, - { re: /\b(?:Stop-Service|Remove-Service|Set-Service|New-Service)\b/i, reason: 'service mutate запрещён' }, - { re: /\bSet-ExecutionPolicy\b/i, reason: 'Set-ExecutionPolicy запрещён' }, - { re: /\bSet-ItemProperty\b/i, reason: 'Set-ItemProperty запрещён' }, - { re: /\b(?:Get-Credential|Export-PSSession)\b/i, reason: 'Get-Credential/Export-PSSession запрещён' }, - { re: /\b(?:Restart-Computer|Stop-Computer)\b/i, reason: 'Restart/Stop-Computer запрещён' }, - { re: /\b(?:Register-ScheduledTask|Set-ScheduledTask)\b/i, reason: 'ScheduledTask mutate запрещён' }, - { re: /\b(?:Set-Acl|icacls)\b/i, reason: 'Set-Acl/icacls запрещён' }, - { re: /\bNew-Item\b[^\n]*-ItemType\s+(?:File|Directory)\b/i, reason: 'New-Item (mutate) запрещён' }, - // v4.1 G10 - { re: /\$env:[A-Za-z_]+\s*=/i, reason: 'G10: $env:X = ... запрещён' }, - { re: /\[System\.Environment\]::SetEnvironmentVariable\b/i, reason: 'G10: SetEnvironmentVariable запрещён' }, - { re: /\bSet-Item\s+-Path\s+Env:/i, reason: 'G10: Set-Item Env: запрещён' }, - { re: /\bNew-PSDrive\b/i, reason: 'G10: New-PSDrive запрещён' }, - { re: /\bInvoke-Azure[A-Z]/, reason: 'G10: Azure cmdlet запрещён' }, - { re: /\b(?:Get|New|Set|Remove)-Az[A-Z]/, reason: 'G10: Az cmdlet запрещён' }, - { re: /\b(?:Get|New|Set|Remove)-AWS[A-Z]/, reason: 'G10: AWS cmdlet запрещён' }, - { re: /\bgcloud\s+(?:auth|compute|iam|storage)\b/, reason: 'G10: gcloud запрещён' }, -]; - -export function matchPsHardBlacklist(command) { - const s = String(command || ''); - if (hasInjection(s)) return '#34: Write-Output/echo prompt-injection запрещён'; - return matchAny(PS_HARD_BLACKLIST, s); -} - // whitelist cmdlets (lowercased) + aliases const PS_READING = new Set([ 'get-childitem', 'gci', 'ls', 'dir', 'select-string', 'sls', 'get-content', 'gc', 'cat', 'type', diff --git a/tools/enforce-powershell-gate.test.mjs b/tools/enforce-powershell-gate.test.mjs index d5a6db1b..44ca8ffe 100644 --- a/tools/enforce-powershell-gate.test.mjs +++ b/tools/enforce-powershell-gate.test.mjs @@ -82,3 +82,17 @@ describe('classifyPowerShellCommand', () => { expect(classifyPowerShellCommand('Frobnicate-Thing', {}).result).toBe('block'); }); }); + +// M7 PS single-source: powershell-gate ре-экспортирует матчер из единого дома shell-content-rules. +// Идентичность ссылки доказывает «единый источник, не копия» (зеркало Bash P-1). content-floor (М5) +// импортирует ТОТ ЖЕ матчер → дрейф подмножествами невозможен по конструкции. +import { matchPsHardBlacklist as PG_MATCH, PS_HARD_BLACKLIST as PG_LIST } from './enforce-powershell-gate.mjs'; +import { matchPsHardBlacklist as SCR_PS_MATCH, PS_HARD_BLACKLIST as SCR_PS_LIST } from './shell-content-rules.mjs'; +describe('powershell-gate re-exports single-source PS matcher (M7)', () => { + it('matchPsHardBlacklist is the SAME reference as shell-content-rules', () => { + expect(PG_MATCH).toBe(SCR_PS_MATCH); + }); + it('PS_HARD_BLACKLIST is the SAME reference as shell-content-rules', () => { + expect(PG_LIST).toBe(SCR_PS_LIST); + }); +}); diff --git a/tools/floor-decide.mjs b/tools/floor-decide.mjs index 02136156..c22aad63 100644 --- a/tools/floor-decide.mjs +++ b/tools/floor-decide.mjs @@ -143,8 +143,10 @@ export function floorDecide({ toolUse, escapeGrants = [], escapeConsumed = [], n return { block: false, reason: 'floor: Bash не необратимо' }; } - // M7 Task 1.4 (V1-PS): PowerShell — content-block (psContentBlock) + P-3 forge-страж - // (psProtectedWrite). Реоткрытие v3.8 F1: PowerShell-tool был полностью вне пола. Escapable. + // M7 Task 1.4 + PS single-source: PowerShell — psContentBlock (тонкий floor-предикат над ЕДИНЫМ + // matchPsHardBlacklist = powershell-gate; variant-analysis закрыл дрейф psContentBlock⊂gate) + + // P-3 forge-страж psProtectedWrite (для New-Item-в-протектед, что у́же blacklist'а). Симметрия с + // bashIsContentBlock. Реоткрытие v3.8 F1: PowerShell был вне пола. Escapable. if (name === 'PowerShell') { const cmd = input.command || ''; if (psContentBlock(cmd) || psProtectedWrite(cmd)) { diff --git a/tools/floor-decide.test.mjs b/tools/floor-decide.test.mjs index 75af87b9..0bdc982b 100644 --- a/tools/floor-decide.test.mjs +++ b/tools/floor-decide.test.mjs @@ -232,8 +232,11 @@ describe('floor content-block PowerShell (V1-PS)', () => { it('P-3: Set-Content в .env → block', () => { expect(floorDecide({ toolUse: ps("Set-Content -Path app/.env -Value 'X=1'"), escapeGrants: [], escapeConsumed: [], normalizeImpl: id }).block).toBe(true); }); - it('P-3: Set-Content в обычный файл → НЕ over-block', () => { - expect(floorDecide({ toolUse: ps("Set-Content -Path notes.md -Value ok"), escapeGrants: [], escapeConsumed: [], normalizeImpl: id }).block).toBe(false); + // PS single-source (variant-analysis): floor использует ЕДИНЫЙ matchPsHardBlacklist (= powershell-gate). + // Тот блокирует ВСЕ Set-Content/Out-File/Copy-Item (как Bash-floor блокирует все cp/mv/redirect) — + // не path-gated. Симметрия с Bash + полнота покрытия после увольнения powershell-gate (Фаза 8). Escapable. + it('floor блокирует любой Set-Content (single-source, симметрия с Bash cp/mv)', () => { + expect(floorDecide({ toolUse: ps("Set-Content -Path notes.md -Value ok"), escapeGrants: [], escapeConsumed: [], normalizeImpl: id }).block).toBe(true); }); // escape снимает PS content-block (детальный инвариант — Task 1.5) @@ -261,9 +264,15 @@ describe('floor content-block PowerShell (V1-PS)', () => { it('SE: ri -r -fo (Remove-Item alias) → block', () => { expect(floorDecide({ toolUse: ps('ri -r -fo C:\\x'), escapeGrants: [], escapeConsumed: [], normalizeImpl: id }).block).toBe(true); }); - // Контроль: алиас-токен без протектед-пути не over-блокируется (sc query — Service Control read). - it('SE-контроль: sc query (не запись в протектед) → НЕ block', () => { - expect(floorDecide({ toolUse: ps('sc query spooler'), escapeGrants: [], escapeConsumed: [], normalizeImpl: id }).block).toBe(false); + // single-source: `sc` = алиас Set-Content в едином blacklist → floor блокирует `sc query` + // (sc.exe Service Control тоже считается опасным sc-токеном; так же делает powershell-gate; escapable). + it('floor блокирует sc-токен (single-source, sc=Set-Content alias)', () => { + expect(floorDecide({ toolUse: ps('sc query spooler'), escapeGrants: [], escapeConsumed: [], normalizeImpl: id }).block).toBe(true); + }); + // Контроль читающих cmdlet'ов — НЕ в blacklist → не блокируются. + it('read-cmdlet Get-Content/Get-ChildItem не блокируются', () => { + expect(floorDecide({ toolUse: ps('Get-Content notes.md'), escapeGrants: [], escapeConsumed: [], normalizeImpl: id }).block).toBe(false); + expect(floorDecide({ toolUse: ps('Get-ChildItem -Path app'), escapeGrants: [], escapeConsumed: [], normalizeImpl: id }).block).toBe(false); }); }); diff --git a/tools/powershell-destructive.mjs b/tools/powershell-destructive.mjs index 280c5c81..2c28f099 100644 --- a/tools/powershell-destructive.mjs +++ b/tools/powershell-destructive.mjs @@ -1,22 +1,17 @@ #!/usr/bin/env node /** - * powershell-destructive — content-block для PowerShell-tool (V1-PS, правило 8 §4.1). - * PS-нативные глаголы НЕ матчат unix-regex classify-destructive → отдельный набор. - * Дополняет несущий пол: floor-decide зовёт это для name==='PowerShell' (Task 1.4). + * powershell-destructive — floor-предикат content-block для PowerShell-tool (V1-PS, правило 8 §4.1). * Реоткрытие v3.8 F1: PowerShell-tool был полностью вне scope content-floor. + * + * M7 PS single-source (variant-analysis закрыл дрейф): psContentBlock — ТОНКИЙ предикат над ЕДИНЫМ + * matchPsHardBlacklist (живёт в shell-content-rules, его же зовёт powershell-gate). Раньше здесь был + * собственный узкий 7-паттерн набор — подмножество gate'а, который дрейфовал бы (та же проблема, что + * P-1 для Bash). Теперь один источник: пол и gate видят один и тот же набор. Симметрия с + * bashIsContentBlock (тот тоже floor-предикат над matchBashHardBlacklist + sub-shell/per-segment). + * floor-decide зовёт это для name==='PowerShell' (Task 1.4). */ -const PS_CONTENT_BLOCK_RE = [ - // sharp-edges (после 1.4): алиасы Remove-Item — ri/rd/rmdir/del/erase. Long-флаги -Recurse/-Force. - /\b(?:Remove-Item|ri|rd|rmdir|del|erase)\b[^\n]*-(?:Recurse|Force)\b/i, - // Short-флаги -r -fo (+те же алиасы). Срабатывает на recurse-токен затем force-токен. - /\b(?:rm|ri|rd|rmdir|del|erase)\b[^\n]*-[a-z]*r[a-z]*\s[^\n]*-[a-z]*f/i, - /\b(?:Invoke-WebRequest|iwr|curl|wget|Invoke-RestMethod|irm)\b/i, // egress - /\b(?:Invoke-Expression|iex)\b/i, // произвольное исполнение - /\bStart-Process\b/i, // запуск интерпретатора/процесса - /\bOut-File\b/i, // запись в файл - /(?:^|[^0-9>&])>{1,2}(?![>&])/, // redirect >/>> -]; +import { matchPsHardBlacklist } from './shell-content-rules.mjs'; + export function psContentBlock(command) { - const cmd = String(command || ''); - return PS_CONTENT_BLOCK_RE.some((re) => re.test(cmd)); + return matchPsHardBlacklist(command) !== null; } diff --git a/tools/powershell-destructive.test.mjs b/tools/powershell-destructive.test.mjs index 52d9a8c0..a249f593 100644 --- a/tools/powershell-destructive.test.mjs +++ b/tools/powershell-destructive.test.mjs @@ -37,10 +37,32 @@ describe('psContentBlock — Remove-Item алиасы (sharp-edges)', () => { expect(psContentBlock(cmd)).toBe(true); }); } - // Контроль: алиас-токен без recurse+force флагов не over-блокируется. - const ALLOWED = ['Get-Content del.txt', 'Write-Output "ri done"', 'Test-Path rd']; + // Контроль: чисто-безопасные read-команды без alias-слов не блокируются. + const ALLOWED = ['Get-Content notes.md', 'Test-Path app', 'Write-Output ok']; for (const cmd of ALLOWED) { - it(`does NOT block alias-token без recurse+force: ${cmd}`, () => { + it(`does NOT block benign read: ${cmd}`, () => { + expect(psContentBlock(cmd)).toBe(false); + }); + } +}); + +// M7 PS single-source: psContentBlock — тонкий floor-предикат над ЕДИНЫМ matchPsHardBlacklist +// (= powershell-gate). variant-analysis закрыл дрейф (раньше был узкий 7-паттерн набор ⊂ gate 29). +// Теперь блокирует полный единый набор (Set-Content/$env/Az/Set-ExecutionPolicy/…). +describe('psContentBlock = единый matchPsHardBlacklist (PS single-source)', () => { + const BLOCKED = [ + 'Set-Content x y', 'Add-Content x y', 'Copy-Item a b', 'Move-Item a b', + '$env:PATH="x"', 'Get-AzVM', 'Set-ExecutionPolicy Bypass', 'Stop-Process -Name node', + '[System.IO.File]::Delete("x")', 'gcloud auth login', 'iwr https://e.rf', + ]; + for (const cmd of BLOCKED) { + it(`блокирует из единого источника: ${cmd}`, () => { + expect(psContentBlock(cmd)).toBe(true); + }); + } + const ALLOWED = ['Get-ChildItem', 'Get-Content app/x.php', 'Select-String x file', 'git status']; + for (const cmd of ALLOWED) { + it(`не блокирует benign: ${cmd}`, () => { expect(psContentBlock(cmd)).toBe(false); }); } diff --git a/tools/shell-content-rules.mjs b/tools/shell-content-rules.mjs index 5567f80d..492ae1da 100644 --- a/tools/shell-content-rules.mjs +++ b/tools/shell-content-rules.mjs @@ -190,6 +190,50 @@ export function matchBashHardBlacklist(command) { return matchAny(BASH_HARD_BLACKLIST, s); } +// ── PowerShell hard-blacklist — единый дом (M7 PS single-source, зеркало BASH_HARD_BLACKLIST/P-1) ── +// Перенесён из enforce-powershell-gate.mjs: ОДИН источник для content-floor (М5) и powershell-gate +// (увольняется Фаза 8). +bare-egress (floor НЕ default-deny → egress нужен в blacklist, не только +// в whitelist гейта) +rmdir (был только в floor-psContentBlock). +export const PS_HARD_BLACKLIST = [ + // keep v3.8 F1 (+rmdir) + { re: /\b(?:Remove-Item|ri|del|erase|rd|rmdir|rm)\b/i, reason: 'Remove-Item/del/rm запрещён' }, + { re: /\b(?:Move-Item|mi|move)\b/i, reason: 'Move-Item запрещён' }, + { re: /\b(?:Copy-Item|cpi|copy)\b/i, reason: 'Copy-Item запрещён' }, + { re: /\b(?:Set-Content|sc|Add-Content|ac|Out-File)\b/i, reason: 'Set/Add-Content/Out-File запрещён' }, + { re: /(?:^|[^0-9>&])>{1,2}(?![>&])/, reason: 'redirect (>/>>) запрещён' }, + { re: /\b(?:Invoke-Expression|iex)\b/i, reason: 'Invoke-Expression/iex запрещён' }, + // bare-egress (M7 PS single-source): floor не default-deny → нужен явный blacklist-паттерн. + { re: /\b(?:Invoke-WebRequest|iwr|Invoke-RestMethod|irm|curl|wget)\b/i, reason: 'egress (IWR/IRM/curl/wget) запрещён' }, + { re: /\b(?:Invoke-WebRequest|iwr|curl|wget)\b[^\n]*\|\s*(?:iex|Invoke-Expression)/i, reason: 'IWR | iex запрещён' }, + { re: /\bStart-Process\b/i, reason: 'Start-Process запрещён' }, + { re: /\[System\.IO\.File\]::(?:Delete|WriteAllText|WriteAllBytes|AppendAllText)\b/i, reason: '[IO.File] write/delete запрещён' }, + { re: /\[System\.IO\.Directory\]::(?:Delete|CreateDirectory)\b/i, reason: '[IO.Directory] mutate запрещён' }, + { re: /\b(?:Stop-Process|kill|spps)\b/i, reason: 'Stop-Process/kill запрещён' }, + { re: /\b(?:Stop-Service|Remove-Service|Set-Service|New-Service)\b/i, reason: 'service mutate запрещён' }, + { re: /\bSet-ExecutionPolicy\b/i, reason: 'Set-ExecutionPolicy запрещён' }, + { re: /\bSet-ItemProperty\b/i, reason: 'Set-ItemProperty запрещён' }, + { re: /\b(?:Get-Credential|Export-PSSession)\b/i, reason: 'Get-Credential/Export-PSSession запрещён' }, + { re: /\b(?:Restart-Computer|Stop-Computer)\b/i, reason: 'Restart/Stop-Computer запрещён' }, + { re: /\b(?:Register-ScheduledTask|Set-ScheduledTask)\b/i, reason: 'ScheduledTask mutate запрещён' }, + { re: /\b(?:Set-Acl|icacls)\b/i, reason: 'Set-Acl/icacls запрещён' }, + { re: /\bNew-Item\b[^\n]*-ItemType\s+(?:File|Directory)\b/i, reason: 'New-Item (mutate) запрещён' }, + // v4.1 G10 + { re: /\$env:[A-Za-z_]+\s*=/i, reason: 'G10: $env:X = ... запрещён' }, + { re: /\[System\.Environment\]::SetEnvironmentVariable\b/i, reason: 'G10: SetEnvironmentVariable запрещён' }, + { re: /\bSet-Item\s+-Path\s+Env:/i, reason: 'G10: Set-Item Env: запрещён' }, + { re: /\bNew-PSDrive\b/i, reason: 'G10: New-PSDrive запрещён' }, + { re: /\bInvoke-Azure[A-Z]/, reason: 'G10: Azure cmdlet запрещён' }, + { re: /\b(?:Get|New|Set|Remove)-Az[A-Z]/, reason: 'G10: Az cmdlet запрещён' }, + { re: /\b(?:Get|New|Set|Remove)-AWS[A-Z]/, reason: 'G10: AWS cmdlet запрещён' }, + { re: /\bgcloud\s+(?:auth|compute|iam|storage)\b/, reason: 'G10: gcloud запрещён' }, +]; + +export function matchPsHardBlacklist(command) { + const s = String(command || ''); + if (hasInjection(s)) return '#34: Write-Output/echo prompt-injection запрещён'; + return matchAny(PS_HARD_BLACKLIST, s); +} + // ── approve_git_operation (Stream E пишет; мы читаем) ── const APPROVE_WINDOW_MS = 5 * 60 * 1000; diff --git a/tools/shell-content-rules.test.mjs b/tools/shell-content-rules.test.mjs index aa27d15e..0c8af94a 100644 --- a/tools/shell-content-rules.test.mjs +++ b/tools/shell-content-rules.test.mjs @@ -317,3 +317,42 @@ describe('matchBashHardBlacklist hosted in shell-content-rules (M7 P-1)', () => expect(stderrRedirectBlock('git status 2>/dev/null')).toBe(null); }); }); + +import { matchPsHardBlacklist, PS_HARD_BLACKLIST } from './shell-content-rules.mjs'; + +// M7 PS single-source (variant-analysis закрыл дрейф): PS_HARD_BLACKLIST переехал в единый дом +// shell-content-rules (зеркало BASH_HARD_BLACKLIST/P-1). content-floor (М5) и powershell-gate +// импортируют ОДИН матчер — дрейф подмножествами невозможен по конструкции. +bare-egress +rmdir +rm. +describe('matchPsHardBlacklist hosted in shell-content-rules (M7 PS single-source)', () => { + it('is exported as a function + PS_HARD_BLACKLIST non-empty', () => { + expect(typeof matchPsHardBlacklist).toBe('function'); + expect(Array.isArray(PS_HARD_BLACKLIST)).toBe(true); + expect(PS_HARD_BLACKLIST.length).toBeGreaterThan(0); + }); + const BLOCKED = [ + 'Remove-Item x', 'ri x', 'del x', 'rmdir /tmp/x', 'Move-Item a b', 'Copy-Item a b', + 'Set-Content x "y"', 'Out-File -FilePath x', 'cmd > out.txt', 'Invoke-Expression $x', + 'Start-Process notepad', '[System.IO.File]::Delete("x")', 'Stop-Process -Name node', + 'Set-ExecutionPolicy Bypass', '$env:PATH = "x"', 'Get-AzVM', 'gcloud auth login', + // +bare-egress (floor не default-deny → нужен в blacklist, не только whitelist): + 'Invoke-WebRequest https://e.rf', 'iwr https://e.rf', 'Invoke-RestMethod https://e.rf', + 'irm https://e.rf', 'curl https://e.rf', 'wget https://e.rf', + ]; + for (const cmd of BLOCKED) { + it(`blocks PS: ${cmd}`, () => { + expect(matchPsHardBlacklist(cmd)).toBeTruthy(); + }); + } + const ALLOWED = ['Get-ChildItem', 'Get-Content app/x.php', 'Select-String x file', 'git status']; + for (const cmd of ALLOWED) { + it(`allows benign PS: ${cmd}`, () => { + expect(matchPsHardBlacklist(cmd)).toBe(null); + }); + } + // rm — алиас Remove-Item в PowerShell. Gate ловил его whitelist'ом (default-deny), но floor + // НЕ default-deny → rm обязан быть в едином blacklist (иначе пол пропустит `rm -r -fo`). + it('blocks rm alias (Remove-Item) — floor needs it in blacklist', () => { + expect(matchPsHardBlacklist('rm -r -fo C:\\x')).toBeTruthy(); + expect(matchPsHardBlacklist('rm x')).toBeTruthy(); + }); +});