Files
portal/tools/enforce-powershell-gate.test.mjs
T
Дмитрий 8e56df3842 refactor(m7-floor): PS-content единый источник — matchPsHardBlacklist в shell-content-rules (variant-analysis)
Закрывающий variant-analysis-гейт Фазы 1 вскрыл класс P-1 для PowerShell: у
powershell-gate был СВОЙ PS_HARD_BLACKLIST (29 паттернов), а пол использовал
отдельный узкий psContentBlock (7) — подмножество, которое дрейфовало бы (та же
проблема, что P-1 для Bash). После Фазы 8 (увольнение powershell-gate) пол оказался
бы слабее гейта, который он заменяет. Решение владельца: исправить сейчас.

Зеркало P-1:
- PS_HARD_BLACKLIST + matchPsHardBlacklist перенесены в единый дом shell-content-rules;
  powershell-gate ре-экспортирует (тест single-source-identity: ссылка gate === SCR).
- +bare-egress (Invoke-WebRequest/iwr/irm/curl/wget bare — floor НЕ default-deny, нужен
  в blacklist, не только в whitelist гейта) +rmdir +rm (алиасы Remove-Item, которые гейт
  ловил whitelist'ом default-deny — полу нужны явно).
- psContentBlock стал ТОНКИМ делегатом над matchPsHardBlacklist (симметрия с
  bashIsContentBlock); пол через него видит ТОТ ЖЕ набор, что гейт. Дрейф невозможен.
- Следствие (осознанно): floor теперь блокирует все Set-Content/sc/$env/Az/… как гейт
  (симметрия с Bash-полом, блокирующим все cp/mv/redirect). Escapable. FP-толерантность
  унаследована от гейта (например `sc query`/`del.txt` — gate-aligned, fail-safe).

powershell-destructive.mjs физически не удалён (живые gate'ы блокируют rm/git rm) —
оставлен тонким делегатом (НЕ второй источник). Удаление — follow-up по git-approval.

Регрессия tools-only: 3044 passed + 2 skip (baseline 2843+2, 0 регрессий).
2026-06-08 09:34:23 +03:00

99 lines
3.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, it, expect } from 'vitest';
import { tokenizePowerShell, matchPsHardBlacklist } from './enforce-powershell-gate.mjs';
describe('tokenizePowerShell', () => {
it('splits on ; and | into segments', () => {
const segs = tokenizePowerShell('Get-Content a | Select-String x ; Get-Item b');
expect(segs.map((s) => s.cmd)).toEqual(['get-content', 'select-string', 'get-item']);
});
});
describe('matchPsHardBlacklist — keep', () => {
it.each([
'Remove-Item x',
'ri x',
'del x',
'Move-Item a b',
'Copy-Item a b',
'Set-Content x "y"',
'Add-Content x "y"',
'Out-File -FilePath x',
'cmd > out.txt',
'Invoke-Expression $x',
'iex $x',
'Start-Process notepad',
'[System.IO.File]::Delete("x")',
'Stop-Process -Name node',
'Set-ExecutionPolicy Bypass',
'icacls x /grant y',
])('blocks %s', (cmd) => {
expect(matchPsHardBlacklist(cmd)).toBeTruthy();
});
});
describe('matchPsHardBlacklist — v4.1 G10', () => {
it.each([
'$env:PATH = "x"',
'$env:ROUTER_LLM_KEY="leak"',
'[System.Environment]::SetEnvironmentVariable("X","Y")',
'Set-Item -Path Env:FOO -Value bar',
'New-PSDrive -Name X -PSProvider FileSystem -Root C:\\',
'Get-AzVM',
'New-AzResourceGroup x',
'Get-AWSCredential',
'gcloud auth login',
])('blocks %s', (cmd) => {
expect(matchPsHardBlacklist(cmd)).toBeTruthy();
});
});
describe('matchPsHardBlacklist — allows benign', () => {
it.each(['Get-ChildItem', 'Get-Content app/x.php', 'Select-String x file', 'git status'])('allows %s', (cmd) => {
expect(matchPsHardBlacklist(cmd)).toBe(null);
});
});
import { classifyPowerShellCommand } from './enforce-powershell-gate.mjs';
describe('classifyPowerShellCommand', () => {
const now = 4_000_000;
it('allows whitelisted reading cmdlet', () => {
expect(classifyPowerShellCommand('Get-ChildItem -Path app', {}).result).toBe('allow');
});
it('allows alias gci', () => {
expect(classifyPowerShellCommand('gci', {}).result).toBe('allow');
});
it('blocks hard-blacklisted Remove-Item', () => {
expect(classifyPowerShellCommand('Remove-Item x', {}).result).toBe('block');
});
it('blocks G10 $env set', () => {
expect(classifyPowerShellCommand('$env:PATH="x"', {}).result).toBe('block');
});
it('blocks reading a protected path', () => {
expect(classifyPowerShellCommand('Get-Content ~/.claude/settings.json', {}).result).toBe('block');
});
it('routes git through shared classifier (block unapproved commit)', () => {
expect(classifyPowerShellCommand('git commit -m "x"', { approvedGitOps: [], now }).result).toBe('block');
});
it('allows readonly git through PowerShell', () => {
expect(classifyPowerShellCommand('git status', {}).result).toBe('allow');
});
it('default-denies unknown cmdlet', () => {
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);
});
});