Adds BLOCK_RULE for the malware-loader pattern: echo|cat|printf <base64-blob> | base64 -d | <shell> This is a common RCE delivery shape that bypasses static name-matching gates by encoding the destructive command as a base64 blob. The new rule fires only when the final pipe target is a shell interpreter (bash, sh, zsh, dash, ksh) — base64 decoded into jq or any non-shell consumer remains allowed. 5 new tests in pre-bash-destructive.test.mjs: - 3 BLOCK cases (echo|base64|bash, printf|base64|sh, cat|base64|zsh) - 2 FP probes (base64 -d -> jq passes; base64 -d alone passes) Closes E9 in critical-review-2026-04-20.md.
224 lines
8.8 KiB
JavaScript
224 lines
8.8 KiB
JavaScript
#!/usr/bin/env node
|
|
// Hook: pre-bash-destructive.mjs
|
|
// Event: PreToolUse (Bash)
|
|
// Purpose: Block or warn about destructive shell commands.
|
|
//
|
|
// Protocol:
|
|
// - Read JSON from stdin: { tool_name, tool_input }
|
|
// - tool_input.command — the shell command string
|
|
// - BLOCK (exit 2): catastrophic/irreversible operations
|
|
// - WARN (exit 0): risky but recoverable operations — advisory message to stderr
|
|
// - Allow (exit 0): everything else
|
|
|
|
import { readFileSync } from 'node:fs';
|
|
import { normalizeBashExpansion } from '../../scanners/lib/bash-normalize.mjs';
|
|
import { getPolicyValue } from '../../scanners/lib/policy-loader.mjs';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// BLOCK rules — exit 2, command is not executed.
|
|
// Each rule: { name, pattern, description }
|
|
// ---------------------------------------------------------------------------
|
|
const BLOCK_RULES = [
|
|
{
|
|
name: 'Filesystem root destruction (rm -rf /)',
|
|
pattern: /\brm\s+(?:-[a-zA-Z]*f[a-zA-Z]*\s+|--force\s+)*-[a-zA-Z]*r[a-zA-Z]*\s+(?:\/|~|\$HOME)\b/,
|
|
description:
|
|
'`rm -rf /`, `rm -rf ~`, and `rm -rf $HOME` would destroy the entire filesystem ' +
|
|
'or home directory. This command is unconditionally blocked.',
|
|
},
|
|
{
|
|
name: 'World-writable chmod (chmod 777)',
|
|
pattern: /\bchmod\s+(?:-[a-zA-Z]+\s+)*777\b/,
|
|
description:
|
|
'`chmod 777` grants full read/write/execute to all users, creating a severe ' +
|
|
'security vulnerability. Use the minimal permission set required (e.g. 644, 755).',
|
|
},
|
|
{
|
|
name: 'Pipe-to-shell (curl|sh, wget|sh, curl|bash)',
|
|
// Matches: curl ... | sh, curl ... | bash, wget ... | sh, etc.
|
|
// Also catches variations with xargs sh, xargs bash
|
|
pattern: /(?:curl|wget)\b[^|]*\|\s*(?:bash|sh|zsh|ksh|dash)\b/,
|
|
description:
|
|
'Piping remote content directly into a shell interpreter allows ' +
|
|
'arbitrary remote code execution without inspection. Download the script first, ' +
|
|
'review it, then execute explicitly.',
|
|
},
|
|
{
|
|
name: 'Fork bomb',
|
|
pattern: /:\(\)\s*\{\s*:\s*\|\s*:&\s*\}\s*;?\s*:/,
|
|
description:
|
|
'This is a fork bomb that will exhaust system process resources and require a hard reboot. Blocked.',
|
|
},
|
|
{
|
|
name: 'Filesystem format (mkfs)',
|
|
pattern: /\bmkfs(?:\.[a-z0-9]+)?\s/,
|
|
description:
|
|
'`mkfs` formats a filesystem, destroying all data on the target device. ' +
|
|
'This is an irreversible operation and is blocked.',
|
|
},
|
|
{
|
|
name: 'Raw disk overwrite via dd',
|
|
// dd if=... of=/dev/sd* or of=/dev/nvme* or similar block devices
|
|
pattern: /\bdd\b[^&|;]*\bof=\/dev\/(?:sd|nvme|hd|vd|xvd|mmcblk)[a-z0-9]*/,
|
|
description:
|
|
'`dd` writing to a raw block device (/dev/sd*, /dev/nvme*) will destroy partition ' +
|
|
'tables and data on that disk. Blocked to prevent accidental disk wipe.',
|
|
},
|
|
{
|
|
name: 'Direct device write (> /dev/sd* etc.)',
|
|
pattern: />\s*\/dev\/(?:sd|nvme|hd|vd|xvd|mmcblk)[a-z0-9]*/,
|
|
description:
|
|
'Writing directly to a block device via shell redirection destroys disk data. Blocked.',
|
|
},
|
|
{
|
|
name: 'eval with variable/command expansion (potential injection)',
|
|
// eval $VAR, eval $(cmd), eval `cmd`, eval "$VAR"
|
|
pattern: /\beval\s+(?:`|\$[\({]|"[^"]*\$)/,
|
|
description:
|
|
'`eval` with variable or command substitution executes dynamically constructed ' +
|
|
'strings, which is a common code injection vector. Blocked. ' +
|
|
'Refactor to use explicit commands instead.',
|
|
},
|
|
{
|
|
name: 'T8 — base64-pipe-shell idiom (echo BLOB | base64 -d | sh)',
|
|
// Matches: echo|cat|printf <base64-blob> | base64 -d | <shell>
|
|
// Common malware loader pattern that bypasses static name-matching by
|
|
// delivering the destructive command as encoded text.
|
|
pattern: /\b(?:echo|cat|printf)\s+[A-Za-z0-9+/=]+\s*\|\s*base64\s+-d\s*\|\s*(?:bash|sh|zsh|dash|ksh)\b/i,
|
|
description:
|
|
'Decoding a base64 blob and piping it directly into a shell interpreter ' +
|
|
'is a remote-code-execution loader pattern. Decode the blob first, ' +
|
|
'inspect it, then execute explicitly if safe.',
|
|
},
|
|
// Policy-defined additional blocked patterns
|
|
...getPolicyValue('destructive', 'additional_blocked', []).map(entry => ({
|
|
name: entry.name || 'Custom blocked pattern',
|
|
pattern: new RegExp(entry.pattern),
|
|
description: entry.description || 'Blocked by policy.',
|
|
})),
|
|
];
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// WARN rules — exit 0 with advisory message on stderr.
|
|
// Command is allowed to proceed but the user/agent is informed.
|
|
// ---------------------------------------------------------------------------
|
|
const WARN_RULES = [
|
|
{
|
|
name: 'Force push (git push --force)',
|
|
pattern: /\bgit\s+push\b[^|&;]*(?:--force|-f)\b/,
|
|
description:
|
|
'WARNING: `git push --force` rewrites remote history. This can destroy commits ' +
|
|
'for all collaborators on shared branches. Prefer `--force-with-lease`.',
|
|
},
|
|
{
|
|
name: 'Hard reset (git reset --hard)',
|
|
pattern: /\bgit\s+reset\s+--hard\b/,
|
|
description:
|
|
'WARNING: `git reset --hard` permanently discards uncommitted changes and ' +
|
|
'moves the branch pointer. Ensure you have no unsaved work.',
|
|
},
|
|
{
|
|
name: 'Recursive remove (rm -rf, non-root non-home target)',
|
|
// Warn for rm -rf that doesn't hit /, ~, or $HOME (those are BLOCKED above)
|
|
pattern: /\brm\s+(?:-[a-zA-Z]*f[a-zA-Z]*\s+|--force\s+)*-[a-zA-Z]*r[a-zA-Z]*\s+/,
|
|
description:
|
|
'WARNING: `rm -rf` permanently deletes files and directories without recovery. ' +
|
|
'Verify the target path before proceeding.',
|
|
},
|
|
{
|
|
name: 'Docker system prune',
|
|
pattern: /\bdocker\s+system\s+prune\b/,
|
|
description:
|
|
'WARNING: `docker system prune` removes all stopped containers, unused images, ' +
|
|
'networks, and build cache. This may delete data needed for local development.',
|
|
},
|
|
{
|
|
name: 'npm publish',
|
|
pattern: /\bnpm\s+publish\b/,
|
|
description:
|
|
'WARNING: `npm publish` releases a package to the public npm registry. ' +
|
|
'Confirm the version, changelog, and that no secrets are bundled.',
|
|
},
|
|
{
|
|
name: 'DROP TABLE or DROP DATABASE (SQL)',
|
|
pattern: /\bDROP\s+(?:TABLE|DATABASE|SCHEMA)\b/i,
|
|
description:
|
|
'WARNING: SQL DROP statements permanently delete database objects and all their data. ' +
|
|
'Ensure you have a recent backup and are targeting the correct environment.',
|
|
},
|
|
{
|
|
name: 'DELETE without WHERE (SQL)',
|
|
pattern: /\bDELETE\s+FROM\s+\w+(?:\s*;|\s*$)/i,
|
|
description:
|
|
'WARNING: DELETE FROM without a WHERE clause deletes all rows in the table. ' +
|
|
'Ensure this is intentional and backed up.',
|
|
},
|
|
];
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Normalize command: strip ANSI, collapse whitespace, for pattern matching.
|
|
// We do NOT strip quotes entirely — patterns are designed to work with raw input.
|
|
// ---------------------------------------------------------------------------
|
|
function normalizeCommand(cmd) {
|
|
return cmd
|
|
// Remove ANSI escape codes
|
|
.replace(/\x1B\[[0-9;]*m/g, '')
|
|
// Collapse runs of whitespace (including newlines from heredocs) to single space
|
|
.replace(/\s+/g, ' ')
|
|
.trim();
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Main
|
|
// ---------------------------------------------------------------------------
|
|
let input;
|
|
try {
|
|
const raw = readFileSync(0, 'utf-8');
|
|
input = JSON.parse(raw);
|
|
} catch {
|
|
// Cannot parse stdin — fail open.
|
|
process.exit(0);
|
|
}
|
|
|
|
const command = input?.tool_input?.command;
|
|
|
|
if (!command || typeof command !== 'string') {
|
|
process.exit(0);
|
|
}
|
|
|
|
// First strip bash evasion techniques (empty quotes, ${} expansion, backslash splitting),
|
|
// then apply standard normalization (ANSI strip, whitespace collapse).
|
|
const deobfuscated = normalizeBashExpansion(command);
|
|
const normalized = normalizeCommand(deobfuscated);
|
|
|
|
// Check BLOCK rules first
|
|
for (const rule of BLOCK_RULES) {
|
|
if (rule.pattern.test(normalized)) {
|
|
process.stderr.write(
|
|
`BLOCKED: Destructive command detected — ${rule.name}\n` +
|
|
` Command: ${normalized.slice(0, 200)}${normalized.length > 200 ? '...' : ''}\n` +
|
|
` ${rule.description}\n`
|
|
);
|
|
process.exit(2);
|
|
}
|
|
}
|
|
|
|
// Check WARN rules (advisory — still exit 0)
|
|
const warnings = [];
|
|
for (const rule of WARN_RULES) {
|
|
if (rule.pattern.test(normalized)) {
|
|
warnings.push(` [WARN] ${rule.name}: ${rule.description}`);
|
|
}
|
|
}
|
|
|
|
if (warnings.length > 0) {
|
|
process.stderr.write(
|
|
`SECURITY ADVISORY: Potentially risky command detected.\n` +
|
|
` Command: ${normalized.slice(0, 200)}${normalized.length > 200 ? '...' : ''}\n` +
|
|
warnings.join('\n') + '\n' +
|
|
` Proceeding — verify intent before confirming.\n`
|
|
);
|
|
}
|
|
|
|
// Allow (with or without warnings)
|
|
process.exit(0);
|