DEV Community

Ian
Ian

Posted on • Originally published at github.com

I built a security scanner. Its first finding was wrong. Here's what I changed.

I almost filed a public GitHub issue last night that would have been quietly humiliating.

I had just shipped the first usable version of a small static analyzer I'd been writing for a few weeks --- it scans CLAUDE.md files and .claude/hooks/* scripts for the kinds of patterns that get developers in trouble: hardcoded API keys, --dangerously-skip-permissions, rm -rf $HOME, curl | sh, the usual suspects. On its first real production run against a popular repository it returned a HIGH-severity finding pointing at rm -rf /. My fingers were already on the keyboard, drafting an issue titled something like "Security: hook script references rm -rf /".

Then I did the one thing I think every security tool author should be forced to do before opening an issue: I cloned the repo and read the line myself.

The line was a comment. Inside an empty array. In a file whose entire purpose is to block that exact pattern.

This article is about what I changed in the scanner so it doesn't embarrass me (or anyone else) like that again. Two heuristics, maybe forty lines of JavaScript total, but they capture something I think a lot of regex-based linters get wrong: lexical context matters, and a scanner that ignores it slowly trains its users to ignore the scanner.

What the scanner does, and why it exists

The tool is called claudemd-security-auditor. It pulls a repository's CLAUDE.md, .claude/settings.json, and every .sh / .py / .js file under .claude/hooks/, and runs a small set of regex rules against them. Findings get severity-graded (critical, high, medium, low), bucketed by category (secret, prompt-injection, destructive-cmd, permission, exfiltration), and written to a Markdown report.

I started building it after losing a four-figure cloud bill to a misbehaving AI agent earlier this year. A hook script I had copied from a tutorial repo silently widened the agent's permissions in a way I didn't read closely enough. By the time I noticed, the bill had a comma in it I did not want to be there. I wrote the scanner because the next time I copy-paste somebody's .claude/ directory, I want a one-command sanity check before I let an LLM loose with shell access.

The audience for the tool is people like me: solo devs and small teams who are wiring Claude Code, Cursor, Cline, and similar agents into their workflows and have neither the time nor a security team to read every hook line-by-line.

The finding

The first repository I pointed the scanner at, more or less at random, was disler/claude-code-hooks-mastery --- a well-known, well-starred reference repo full of example hooks for Claude Code. The scanner returned a single HIGH:

[HIGH] rm -rf against $HOME / root referenced in hook or CLAUDE.md
- File: .claude/hooks/user_prompt_submit.py
- Line: 128
- Category: destructive-cmd
- Matched: rm -rf /
Enter fullscreen mode Exit fullscreen mode

My first reaction was the wrong one. It was something like: oh. Oh no. This repo has thousands of stars. People are copying these hooks into their own projects. I should file this immediately.

I want to be honest about that reaction, because I think it's the failure mode the rest of this article is really about. The scanner had given me a finding. The finding was specific, severity-graded, file-and-line-numbered. It felt like evidence. I had built the tool, I trusted the tool, and I was about to act on its output without ever looking at the underlying code.

That is exactly the posture security tooling is supposed to prevent, and I almost slid into it as the author of the tool.

What I did instead

What I did, before opening any issue, was three small things --- and I want to write them down because they are the boring habits I keep forgetting are not optional.

First, I cloned the repository locally. Not "viewed on GitHub web UI," not "read the snippet the scanner gave me" --- a real gh repo clone disler/claude-code-hooks-mastery. The scanner had given me a file and a line. I owed it to the maintainer to look at that line with my own eyes.

Second, I opened .claude/hooks/user_prompt_submit.py and went to line 128. Here is what was actually there:

# Example dangerous patterns to block (customize as needed):
blocked_patterns = [
    # Add patterns here to block specific prompts
    # Example: ('rm -rf /', 'Dangerous command detected'),
    # Example: ('format c:', 'Dangerous command detected'),
]
Enter fullscreen mode Exit fullscreen mode

It is a commented-out documentation example. Inside an empty blocked_patterns list. In a script whose entire job is to scan user prompts for dangerous patterns and refuse them.

Third, I went and read pre_tool_use.py, the file actually wired up to Claude's PreToolUse hook. Around line 102 it has a live regex that blocks rm -rf against system paths for real. The repo is, in fact, a defensive hook collection. It's the opposite of what my scanner thought it was.

If I had filed that issue --- "Security: your hook script references rm -rf /" --- I would have been the person yelling at a fire extinguisher for containing combustion instructions.

The fix

The fix is two new heuristics in the destructive-cmd rule path. Both live in src/main.js next to the rule table. I deliberately kept them small and named them clearly so future-me can find them.

The first heuristic is a comment-line detector. If a line begins with #, //, *, or -- after whitespace is stripped, it is almost certainly documentation, a header comment, or a commented-out example. Not an execution.

function isCommentLine(line) {
    const trimmed = line.trimStart();
    return (
        trimmed.startsWith('#') ||
        trimmed.startsWith('//') ||
        trimmed.startsWith('*') ||
        trimmed.startsWith('--')
    );
}
Enter fullscreen mode Exit fullscreen mode

The second heuristic is a defensive-context detector. It walks up to eight lines back from the current line and looks for variable names that strongly suggest "this is a list of things we block, not things we do."

const DEFENSIVE_CONTEXT_RE = /\b(blocked_patterns|blocklist|denylist|blacklist|dangerous_commands|forbidden_commands|banned_commands|pattern_blacklist|deny_list)\b/i;

function isInDefensiveContext(lineIndex) {
    // Look 8 lines up for a defensive-array declaration.
    const start = Math.max(0, lineIndex - 8);
    for (let j = start; j <= lineIndex; j++) {
        if (DEFENSIVE_CONTEXT_RE.test(lines[j])) return true;
    }
    return false;
}
Enter fullscreen mode Exit fullscreen mode

When a destructive-cmd rule matches, the loop now consults both heuristics and downgrades the finding instead of dropping it:

if (rule.category === 'destructive-cmd') {
    if (isCommentLine(line) || isInDefensiveContext(i)) {
        severity = 'low';
        suppressed = true;
    }
}
findings.push({
    path,
    line: i + 1,
    severity,
    category: rule.category,
    message: suppressed
        ? `${rule.msg} (in comment or defensive blocklist --- likely documentation, not execution)`
        : rule.msg,
    matched: m[0],
    context: line.trim(),
});
Enter fullscreen mode Exit fullscreen mode

Two design decisions worth calling out. I chose downgrade-to-low rather than suppress entirely because I still want users to see what the scanner saw --- silent suppression is its own trust problem. And I picked eight lines as the lookback window after staring at half a dozen real blocked_patterns arrays in the wild; it's long enough to catch realistic multi-line declarations and short enough that an rm -rf thirty lines below a variable named blacklist doesn't accidentally pass.

Re-running the scanner against disler/claude-code-hooks-mastery now produces one LOW finding with the message "...likely documentation, not execution" --- which is the right answer. The scanner still tells you it saw the string. It just stops shouting about it.

An unrelated bonus finding

While I was reading pre_tool_use.py to understand the defensive context, I noticed something else worth writing down. The repo's own live blocking regex is roughly r'rm\s+.*-[rf]'. That catches rm -rf / and rm -fr /, but it does not catch the GNU long-form flags. A motivated adversary --- or, more realistically, a confused LLM --- could ship rm --recursive --force / straight past it. I have not filed an issue yet (I want to write a proper repro first, having just learned my lesson), but it's a real gap and probably worth a follow-up PR rather than a drive-by.

Mention it here mostly so the next person scanning that repo doesn't assume the blocklist is exhaustive.

What I'm taking away

The boring lesson: regex without lexical context erodes user trust faster than missed findings do. A scanner that flags commented-out examples once is annoying. A scanner that does it twice is the security tool you mute. A muted security tool is worse than no security tool, because it makes you feel covered.

The slightly less boring lesson: a security tool's job is to err on the side of not crying wolf. Severity inflation is the failure mode I almost shipped. Downgrading is almost always the right move when context is ambiguous; deletion is almost never the right move. Users need to see what the scanner saw; they just need it framed honestly.

And the genuinely uncomfortable lesson, which is mostly about me: I had to physically clone a repo and read one file before I trusted my own tool's output. That is the bar. If I'm not willing to do that, I shouldn't be filing issues against other people's repos --- and I definitely shouldn't be telling other developers to act on the scanner's reports.

I'm still learning what this tool should be. If you run it against your own CLAUDE.md and it cries wolf at you, please tell me --- those are the bug reports that will make it actually useful.

The scanner: apify.com/ianymu/claudemd-security-auditor. Source for the related Stop-hook work: github.com/ianymu/claude-verify-before-stop. Both are open and unfinished, in that order.

Top comments (0)