170 lines
5.8 KiB
JavaScript
170 lines
5.8 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* PreToolUse Hook: Security guard layer (cross-platform version)
|
|
*
|
|
* Event: PreToolUse
|
|
* Two-tier security: Block (exit 2) | Confirm (exit 0 + ask user)
|
|
*/
|
|
|
|
const path = require('path');
|
|
const os = require('os');
|
|
const common = require('./hook-common');
|
|
|
|
// Read stdin input
|
|
let input = {};
|
|
try {
|
|
const stdinData = require('fs').readFileSync(0, 'utf8');
|
|
if (stdinData.trim()) {
|
|
input = JSON.parse(stdinData);
|
|
}
|
|
} catch {
|
|
// Use default empty object
|
|
}
|
|
|
|
const toolName = input.tool_name || '';
|
|
const cwd = input.cwd || process.cwd();
|
|
|
|
let decision = 'allow';
|
|
let reason = '';
|
|
let confirmLabel = '';
|
|
|
|
function isPathInside(basePath, targetPath) {
|
|
const relative = path.relative(basePath, targetPath);
|
|
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
|
|
}
|
|
|
|
// === Bash command security check ===
|
|
if (toolName === 'Bash') {
|
|
const command = input.tool_input?.command || '';
|
|
|
|
// Tier 1: Block — catastrophic, no recovery
|
|
const blockPatterns = [
|
|
/rm\s+-rf\s+\/(\s|$)/, // rm -rf /
|
|
/rm\s+--no-preserve-root/, // rm --no-preserve-root
|
|
/dd\s+if=\/dev\/(zero|random)/, // dd from /dev/zero or /dev/random
|
|
/>\s*\/dev\/(sd|nvme|vda)/, // Write to block devices
|
|
/mkfs\./, // Format filesystem
|
|
/rm\s+-rf?\s+\/(etc|usr|bin|sbin)(\/|\s|$)/, // Remove system dirs
|
|
/rm\s+-rf\s+\/home\/[^\/\s]*\/?(\s|$)/, // Remove user home dir
|
|
/rm\s+-rf\s+\/Users\/[^\/\s]*\/?(\s|$)/, // Remove macOS user dir
|
|
];
|
|
|
|
for (const pattern of blockPatterns) {
|
|
if (pattern.test(command)) {
|
|
decision = 'deny';
|
|
reason = 'Catastrophic command detected';
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Tier 2: Confirm — dangerous but sometimes legitimate
|
|
if (decision === 'allow') {
|
|
const confirmPatterns = [
|
|
{ pattern: /git\s+push\s+.*(-f|--force)/, label: 'git push --force (overwrites remote history)' },
|
|
{ pattern: /git\s+reset\s+--hard/, label: 'git reset --hard (discards all uncommitted changes)' },
|
|
{ pattern: /git\s+clean\s+-[a-z]*f/, label: 'git clean -f (permanently deletes untracked files)' },
|
|
{ pattern: /git\s+(checkout|restore)\s+\./, label: 'git checkout/restore . (discards all working tree changes)' },
|
|
{ pattern: /rm\s+-[rf]/, label: 'rm -rf (recursive/force delete)' },
|
|
{ pattern: /chmod\s+(-R\s+)?777/, label: 'chmod 777 (world-writable permissions)' },
|
|
{ pattern: /npm\s+publish/, label: 'npm publish (publishes package to registry)' },
|
|
{ pattern: /pip\s+upload|twine\s+upload/, label: 'pip/twine upload (publishes package to PyPI)' },
|
|
{ pattern: /docker\s+system\s+prune/, label: 'docker system prune (removes all unused resources)' },
|
|
{ pattern: /DROP\s+(DATABASE|TABLE)/i, label: 'SQL DROP (destroys database/table)' },
|
|
{ pattern: /DELETE\s+FROM\s+(?!.*WHERE)/i, label: 'DELETE without WHERE (deletes all rows)' },
|
|
{ pattern: /UPDATE\s+\S+\s+SET\s+(?!.*WHERE)/i, label: 'UPDATE without WHERE (updates all rows)' },
|
|
{ pattern: /TRUNCATE\s+TABLE/i, label: 'SQL TRUNCATE (empties entire table)' },
|
|
];
|
|
|
|
for (const { pattern, label } of confirmPatterns) {
|
|
if (pattern.test(command)) {
|
|
confirmLabel = label;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// === File write security check ===
|
|
} else if (toolName === 'Write' || toolName === 'Edit') {
|
|
const filePath = input.tool_input?.file_path || '';
|
|
const homeDir = os.homedir();
|
|
const repoRoot = common.getRepoRoot(cwd);
|
|
const resolved = path.resolve(cwd, filePath);
|
|
|
|
// Tier 1: Block — system paths
|
|
const sensitivePaths = [
|
|
'/etc/', '/usr/bin/', '/usr/sbin/',
|
|
'/bin/', '/sbin/', '/System/',
|
|
'/dev/', '/proc/', '/sys/'
|
|
];
|
|
|
|
for (const sp of sensitivePaths) {
|
|
if (filePath.startsWith(sp)) {
|
|
decision = 'deny';
|
|
reason = `Writing to system path denied: ${sp}`;
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Block — outside repo and home
|
|
if (decision === 'allow') {
|
|
const insideRepo = isPathInside(repoRoot, resolved);
|
|
const insideHome = isPathInside(homeDir, resolved);
|
|
|
|
if (!insideRepo && !insideHome) {
|
|
decision = 'deny';
|
|
reason = 'Path traversal attack detected: resolved path is outside the repository and home directory';
|
|
} else if (!insideRepo && insideHome) {
|
|
const relativeHomePath = path.relative(homeDir, resolved) || path.basename(resolved);
|
|
confirmLabel = `home directory path outside repo (~/${relativeHomePath})`;
|
|
}
|
|
}
|
|
|
|
// Tier 2: Confirm — sensitive files
|
|
if (decision === 'allow') {
|
|
const fileName = path.basename(filePath);
|
|
|
|
if (fileName.startsWith('.env')) {
|
|
confirmLabel = `.env file (${fileName})`;
|
|
}
|
|
|
|
if (!confirmLabel) {
|
|
const sensitiveFiles = ['credentials.json', 'key.pem', 'key.json', 'id_rsa'];
|
|
for (const sf of sensitiveFiles) {
|
|
if (fileName === sf) {
|
|
confirmLabel = `sensitive file (${sf})`;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!confirmLabel) {
|
|
const sensitivePathPatterns = ['.aws/credentials', '.npmrc'];
|
|
for (const sp of sensitivePathPatterns) {
|
|
if (filePath.includes(sp)) {
|
|
confirmLabel = `sensitive path (${sp})`;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// === Build output ===
|
|
if (decision === 'deny') {
|
|
const errorOutput = {
|
|
hookSpecificOutput: { permissionDecision: 'deny' },
|
|
systemMessage: `🛑 Blocked: ${reason}\n\nTo perform this operation, run it manually in the terminal.`
|
|
};
|
|
console.error(JSON.stringify(errorOutput));
|
|
process.exit(2);
|
|
} else {
|
|
const result = { continue: true };
|
|
|
|
if (confirmLabel) {
|
|
result.systemMessage = `⚠️ CONFIRM REQUIRED: ${confirmLabel}\n\nYou MUST ask the user for explicit confirmation before executing this operation. Do NOT proceed without user approval.`;
|
|
}
|
|
|
|
console.log(JSON.stringify(result));
|
|
process.exit(0);
|
|
}
|