-
Notifications
You must be signed in to change notification settings - Fork 0
fix(hooks): bound remaining dangerous-command flag wildcards (FP class) #795
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -18,47 +18,56 @@ | |
| # Normalize: collapse whitespace, lowercase for pattern matching | ||
| normalized = " ".join(cmd.split()).lower() | ||
|
|
||
| # Strip single- and double-quoted string content so that shell separators | ||
| # inside argument values (e.g. --name 'a;b') are not treated as real command | ||
| # boundaries by [^|&;<>]* patterns. This prevents the quoted-separator bypass | ||
| # identified in Codex P1 review (PR #795). | ||
| normalized = re.sub(r"'[^']*'", " ", normalized) | ||
| normalized = re.sub(r'"[^"]*"', " ", normalized) | ||
|
|
||
| # ── Dangerous patterns (regex) ────────────────────────────────── | ||
| DANGEROUS_PATTERNS = [ | ||
| # === Git destructive operations === | ||
| # Bound the wildcard to the push invocation's own args: [^|&;<>]* stops at a | ||
| # pipe/redirect/separator so a later `-f` in a chained command or a flattened | ||
| # heredoc body (newlines collapse to spaces) cannot trigger a false positive. | ||
| # NOTE: flag wildcards use [^|&;<>]* (not .*) so the match stays within a | ||
| # single command's own arguments. normalize() flattens newlines to spaces, | ||
| # so a greedy .* would otherwise reach a flag-like token in a chained command | ||
| # (`a && b`), a pipe (`| x`), a redirect (`2>&1`) or a heredoc body, causing | ||
| # false positives. [^|&;<>]* stops at the first such boundary. | ||
| (r"git\s+push\s+[^|&;<>]*--force", "git push --force (force push)"), | ||
| (r"git\s+push\s+[^|&;<>]*-f\b", "git push -f (force push)"), | ||
| (r"git\s+push\s+[^|&;<>]*--force-with-lease", "git push --force-with-lease"), | ||
| (r"git\s+push\s+\S+\s+\+", "git push origin +branch (force push)"), | ||
| (r"git\s+clean\s+.*-f", "git clean -f (delete untracked files)"), | ||
| (r"git\s+clean\s+[^|&;<>]*-f", "git clean -f (delete untracked files)"), | ||
| (r"git\s+reflog\s+expire", "git reflog expire (destroy recovery data)"), | ||
| (r"git\s+reset\s+--hard", "git reset --hard (discard all changes)"), | ||
|
|
||
| # === chmod dangerous operations === | ||
| (r"chmod\s+777\b", "chmod 777 (world-writable permissions)"), | ||
|
|
||
| # === rm destructive operations === | ||
| (r"rm\s+.*-r.*-f|rm\s+.*-f.*-r|rm\s+-rf", "rm -rf (recursive force delete)"), | ||
| (r"rm\s+[^|&;<>]*-r[^|&;<>]*-f|rm\s+[^|&;<>]*-f[^|&;<>]*-r|rm\s+-rf", "rm -rf (recursive force delete)"), | ||
|
|
||
| # === Docker destructive operations === | ||
| (r"docker\s+system\s+prune", "docker system prune"), | ||
| (r"docker\s+volume\s+prune", "docker volume prune (data loss)"), | ||
| (r"docker\s+run\s+.*--privileged", "docker run --privileged (host access)"), | ||
| (r"docker\s+run\s+[^|&;<>]*--privileged", "docker run --privileged (host access)"), | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When an argument before the dangerous flag legitimately contains a quoted or escaped separator, this pattern stops scanning too early and allows the same command the hook is meant to block; for example, I checked Useful? React with 👍 / 👎. |
||
| (r"docker\s+push\b", "docker push (registry publish)"), | ||
|
|
||
| # === Kubernetes destructive operations === | ||
| (r"kubectl\s+delete\s+(deployment|service|pvc|statefulset|ingress|daemonset|cronjob|job)\b", | ||
| "kubectl delete (workload/resource destruction)"), | ||
| (r"kubectl\s+delete\s+(namespace|ns)\b", "kubectl delete namespace"), | ||
| (r"kubectl\s+delete\s+pod\s+.*--all", "kubectl delete pod --all"), | ||
| (r"kubectl\s+scale\s+.*--replicas\s*=\s*0", "kubectl scale --replicas=0 (service stop)"), | ||
| (r"kubectl\s+delete\s+pod\s+[^|&;<>]*--all", "kubectl delete pod --all"), | ||
| (r"kubectl\s+scale\s+[^|&;<>]*--replicas\s*=\s*0", "kubectl scale --replicas=0 (service stop)"), | ||
|
|
||
| # === Terraform destructive operations === | ||
| (r"terraform\s+destroy", "terraform destroy"), | ||
| (r"terraform\s+apply\s+.*-auto-approve", "terraform apply -auto-approve"), | ||
| (r"terraform\s+apply\s+[^|&;<>]*-auto-approve", "terraform apply -auto-approve"), | ||
| (r"terraform\s+state\s+rm", "terraform state rm (orphan resources)"), | ||
|
|
||
| # === AWS destructive operations === | ||
| (r"aws\s+ec2\s+terminate-instances", "aws ec2 terminate-instances"), | ||
| (r"aws\s+s3\s+rm\s+.*--recursive", "aws s3 rm --recursive (bulk delete)"), | ||
| (r"aws\s+s3\s+rm\s+[^|&;<>]*--recursive", "aws s3 rm --recursive (bulk delete)"), | ||
| (r"aws\s+s3\s+rb\b", "aws s3 rb (bucket delete)"), | ||
| (r"aws\s+rds\s+delete-db", "aws rds delete-db (database deletion)"), | ||
| (r"aws\s+cloudformation\s+delete-stack", "aws cloudformation delete-stack"), | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,166 @@ | ||
| 'use strict'; | ||
|
|
||
| const fs = require('fs'); | ||
| const path = require('path'); | ||
|
|
||
| const hooksDir = path.join(__dirname, '../.claude/hooks'); | ||
|
|
||
| describe('Claude Code command-safety hooks', () => { | ||
| describe('block_dangerous_commands.py — defense-in-depth', () => { | ||
| let content; | ||
|
|
||
| beforeAll(() => { | ||
| content = fs.readFileSync(path.join(hooksDir, 'block_dangerous_commands.py'), 'utf8'); | ||
| }); | ||
|
|
||
| test('should have shebang line', () => { | ||
| expect(content.startsWith('#!/usr/bin/env python3')).toBe(true); | ||
| }); | ||
|
|
||
| test('should define DANGEROUS_PATTERNS list', () => { | ||
| expect(content).toContain('DANGEROUS_PATTERNS'); | ||
| }); | ||
|
|
||
| test('should block git force push (--force)', () => { | ||
| expect(content).toContain('git\\s+push\\s+[^|&;<>]*--force'); | ||
| }); | ||
|
|
||
| test('should block git force push (-f)', () => { | ||
| expect(content).toContain('git\\s+push\\s+[^|&;<>]*-f\\b'); | ||
| }); | ||
|
|
||
| test('should bound force-push wildcard to avoid chained-command false positives', () => { | ||
| // [^|&;<>]* stops at a pipe/redirect/separator so a later -f in a chained | ||
| // command or flattened heredoc body does not trigger a false positive. | ||
| expect(content).not.toContain('git\\s+push\\s+.*-f\\b'); | ||
| }); | ||
|
|
||
| test('should block git reset --hard', () => { | ||
| expect(content).toContain('git\\s+reset\\s+--hard'); | ||
| }); | ||
|
|
||
| test('should block rm -rf', () => { | ||
| // Pattern covers rm -rf, rm -fr, rm -r -f combinations | ||
| expect(content).toContain('rm\\s+-rf'); | ||
| }); | ||
|
|
||
| test('should block docker system prune', () => { | ||
| expect(content).toContain('docker\\s+system\\s+prune'); | ||
| }); | ||
|
|
||
| test('should block terraform destroy', () => { | ||
| expect(content).toContain('terraform\\s+destroy'); | ||
| }); | ||
|
|
||
| test('should block kubectl delete deployments', () => { | ||
| expect(content).toContain('kubectl\\s+delete'); | ||
| }); | ||
|
|
||
| test('should block AWS EC2 terminate', () => { | ||
| expect(content).toContain('aws\\s+ec2\\s+terminate-instances'); | ||
| }); | ||
|
|
||
| test('should block AWS S3 recursive delete', () => { | ||
| expect(content).toContain('aws\\s+s3\\s+rm\\s+[^|&;<>]*--recursive'); | ||
| }); | ||
|
|
||
| test('should block gcloud project deletion', () => { | ||
| expect(content).toContain('gcloud\\s+projects\\s+delete'); | ||
| }); | ||
|
|
||
| test('should block Azure resource group deletion', () => { | ||
| expect(content).toContain('az\\s+group\\s+delete'); | ||
| }); | ||
|
|
||
| test('should block npm publish', () => { | ||
| expect(content).toContain('npm\\s+publish\\b'); | ||
| }); | ||
|
|
||
| test('should block SQL DROP statements', () => { | ||
| expect(content).toContain('drop\\s+(database|table|schema'); | ||
| }); | ||
|
|
||
| test('should block Helm uninstall', () => { | ||
| expect(content).toContain('helm\\s+(uninstall|delete)'); | ||
| }); | ||
|
|
||
| test('should block Vercel production deploy', () => { | ||
| expect(content).toContain('vercel\\s+--prod\\b'); | ||
| }); | ||
|
|
||
| test('should bound all flag wildcards to a single command (no greedy \\s+.*)', () => { | ||
| // Flag patterns must use [^|&;<>]* instead of .* so a match cannot reach a | ||
| // flag-like token in a chained command, pipe, redirect or heredoc body | ||
| // (normalize() flattens newlines to spaces). See #794 and follow-up. | ||
| expect(content).not.toContain('\\s+.*'); | ||
| }); | ||
|
|
||
| test('should strip quoted string content to prevent quoted-separator bypass (Codex P1)', () => { | ||
| // Without this, --name 'a;b' would have ';' treated as a real boundary, | ||
| // allowing docker run --name 'a;b' --privileged ubuntu to bypass the check. | ||
| expect(content).toContain("re.sub(r\"'[^']*'\""); | ||
| expect(content).toContain('re.sub(r\'"[^"]*"\''); | ||
| }); | ||
|
Comment on lines
+98
to
+103
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This test only validates source text, not hook behavior. Line 98-103 passes even when Suggested behavioral regression test pattern+const { spawnSync } = require('child_process');
+
+function runDangerousHook(command) {
+ const hook = path.join(hooksDir, 'block_dangerous_commands.py');
+ const input = JSON.stringify({ tool_input: { command } });
+ return spawnSync('python3', [hook], { input, encoding: 'utf8' });
+}
+
+test('blocks dangerous command inside quoted -c payload', () => {
+ const res = runDangerousHook('bash -c "rm -rf /tmp/demo"');
+ expect(res.status).toBe(2);
+});
+
+test('does not treat quoted separators as command boundaries', () => {
+ const res = runDangerousHook("docker run --name 'a;b' ubuntu:latest");
+ expect(res.status).toBe(0);
+});As per coding guidelines, 🤖 Prompt for AI Agents |
||
|
|
||
| test('should exit 2 when dangerous command detected', () => { | ||
| expect(content).toContain('sys.exit(2)'); | ||
| }); | ||
|
|
||
| test('should exit 0 for safe commands', () => { | ||
| expect(content).toContain('sys.exit(0)'); | ||
| }); | ||
|
|
||
| test('should normalize command for matching (lowercase)', () => { | ||
| expect(content).toContain('.lower()'); | ||
| }); | ||
| }); | ||
|
|
||
| describe('block_inline_secrets.py — inline credential protection', () => { | ||
| let content; | ||
|
|
||
| beforeAll(() => { | ||
| content = fs.readFileSync(path.join(hooksDir, 'block_inline_secrets.py'), 'utf8'); | ||
| }); | ||
|
|
||
| test('should have shebang line', () => { | ||
| expect(content.startsWith('#!/usr/bin/env python3')).toBe(true); | ||
| }); | ||
|
|
||
| test('should define SECRET_PATTERNS list', () => { | ||
| expect(content).toContain('SECRET_PATTERNS'); | ||
| }); | ||
|
|
||
| test('should detect AWS access key ids (AKIA/ASIA)', () => { | ||
| expect(content).toContain('(AKIA|ASIA)[0-9A-Z]{16}'); | ||
| }); | ||
|
|
||
| test('should detect GitHub personal access tokens', () => { | ||
| expect(content).toContain('ghp_'); | ||
| }); | ||
|
|
||
| test('should detect Anthropic API keys', () => { | ||
| expect(content).toContain('sk-ant-'); | ||
| }); | ||
|
|
||
| test('should detect private key blocks', () => { | ||
| expect(content).toContain('PRIVATE KEY'); | ||
| }); | ||
|
|
||
| test('should whitelist the public Supabase demo JWT', () => { | ||
| expect(content).toContain('SUPABASE_DEMO_MARKER'); | ||
| }); | ||
|
|
||
| test('should exit 2 when an inline secret is detected', () => { | ||
| expect(content).toContain('sys.exit(2)'); | ||
| }); | ||
|
|
||
| test('should exit 0 for commands without inline secrets', () => { | ||
| expect(content).toContain('sys.exit(0)'); | ||
| }); | ||
|
|
||
| test('should reuse get_command from common', () => { | ||
| expect(content).toContain('from common import'); | ||
| expect(content).toContain('get_command'); | ||
| }); | ||
| }); | ||
| }); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Quoted-content stripping introduces a dangerous-command bypass.
At Line 25-26, full quoted payloads are removed before matching.
bash -c "rm -rf /"is normalized to essentiallybash -c, so destructive commands inside-care no longer detectable.Suggested fix
As per coding guidelines,
**/*.{js,ts,jsx,tsx,py,java,go,rb,php,json,lock,txt,yaml,yml}must fail on critical security vulnerabilities during Security Code Analysis.🤖 Prompt for AI Agents