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 регрессий).
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user