Scanner Architecture

brunnr's security scanner is deterministic, regex-based, and uses zero external dependencies. It runs entirely on Python's standard library.


Design principles

  1. Deterministic -- Same input produces the same output every time. No probabilistic models, no temperature settings, no API calls.
  2. Zero dependencies -- The scanner uses only re, string, dataclasses, enum, and urllib.parse from Python's stdlib.
  3. Fast -- Scanning a SKILL.md file takes milliseconds. The scanner can process hundreds of files in under a second.

Pipeline flow

The scanner processes each SKILL.md file through a pipeline of independent rules. The Pipeline parses content once into an immutable ScanContext, then passes it to each rule in sequence. Findings accumulate into a single ScanResult.

INPUT SKILL.md raw content frontmatter + body 00 · PARSE Extract frontmatter + body description, body 01 · COMMAND INJECTION curl|sh, reverse shells, base64 BLOCK 02 · DATA EXFILTRATION SSH keys, secrets in responses BLOCK 03 · CREDENTIAL THEFT .env, env vars, keychain access BLOCK 04 · PROMPT OVERRIDE Jailbreaks, instruction bypass BLOCK 05 · SUPPLY CHAIN Custom registries, install hooks FLAG 06 · PRIVILEGE ESCALATION sudo abuse, chmod 777, setuid FLAG 07 · STEGANOGRAPHIC Zero-width chars, HTML comments FLAG 08 · SEMANTIC MISMATCH URL allowlist, keyword divergence FLAG/INFO VERDICT ScanResult (CLEAN / FLAG / BLOCK) findings accumulate

Package structure

The scanner is a Python package (brunnr/scanner/) built around a Rule protocol and a sequential Pipeline. Each threat class is an independent rule module.

brunnr/scanner/
    __init__.py            # Public API: scan_skill_md, ScanResult, Finding, ...
    types.py               # Severity, ThreatClass, Finding, ScanResult
    context.py             # ScanContext (immutable pre-parsed content)
    protocol.py            # Rule protocol definition
    pattern_rule.py        # PatternRule base for regex-based rules
    pipeline.py            # Pipeline class (sequential rule executor)
    rules/
        __init__.py        # DEFAULT_RULES registry
        cmd_injection.py   # CommandInjectionRule        (BLOCK)
        data_exfil.py      # DataExfiltrationRule        (BLOCK)
        cred_theft.py      # CredentialTheftRule         (BLOCK)
        prompt_override.py # PromptOverrideRule          (BLOCK)
        supply_chain.py    # SupplyChainRule             (FLAG)
        priv_esc.py        # PrivilegeEscalationRule     (FLAG)
        steg_patterns.py   # StegPatternRule             (FLAG)
        zero_width.py      # ZeroWidthRule               (FLAG)
        url_domains.py     # UrlDomainRule               (INFO)
        sensitive_paths.py # SensitivePathRule           (FLAG)
        keyword_divergence.py # KeywordDivergenceRule    (FLAG)

Core abstractions

Rule protocol

Every scanner rule implements the Rule protocol (structural typing, no inheritance required):

@runtime_checkable
class Rule(Protocol):
    name: str
    def scan(self, ctx: ScanContext) -> list[Finding]: ...

Rules are pure: they receive an immutable ScanContext and return a list of findings. They do not mutate shared state. This maps directly to a Rust trait Rule.

ScanContext

The Pipeline parses raw content once into a frozen ScanContext, then passes it to every rule:

@dataclass(frozen=True)
class ScanContext:
    raw: str          # Full content (frontmatter + body)
    body: str         # Body text after frontmatter
    description: str  # Description from frontmatter (may be "")

PatternRule

Seven of the eleven rules are regex pattern lists. PatternRule provides a shared scan() implementation. Subclasses only set class attributes:

class CommandInjectionRule(PatternRule):
    name = "command_injection"
    threat_class = ThreatClass.COMMAND_INJECTION
    severity = Severity.BLOCK
    patterns = [
        (r"curl\s+[^\s]+\s*\|\s*(?:ba)?sh", "curl pipe to shell"),
        # ...
    ]

The four custom rules (ZeroWidthRule, UrlDomainRule, SensitivePathRule, KeywordDivergenceRule) implement scan() directly.

Pipeline

The Pipeline runs rules sequentially and collects findings:

class Pipeline:
    def __init__(self, rules: list[Rule] | None = None) -> None: ...
    def scan(self, content: str) -> ScanResult: ...

Pipeline() with no arguments loads the default 11 rules. Custom pipelines are created by passing a rule list: Pipeline(rules=[CommandInjectionRule(), PromptOverrideRule()]). Pipeline(rules=[]) is a null scanner.


Internal types

ScanResult

The ScanResult dataclass accumulates all findings from a scan:

@dataclass
class ScanResult:
    findings: list[Finding]

    @property
    def blocked(self) -> bool: ...   # Any BLOCK findings?
    @property
    def flagged(self) -> bool: ...   # Any FLAG findings?
    @property
    def clean(self) -> bool: ...     # No findings at all?
    @property
    def severity(self) -> Severity: ...  # Highest severity

Finding

Each finding records the threat class, severity, a human-readable description, the matched evidence, and the line number:

@dataclass
class Finding:
    threat_class: ThreatClass
    severity: Severity
    description: str
    evidence: str
    line: int | None = None

Severity and ThreatClass enums

class Severity(StrEnum):
    BLOCK = "block"
    FLAG = "flag"
    INFO = "info"
    CLEAN = "clean"

class ThreatClass(StrEnum):
    COMMAND_INJECTION = "command_injection"
    DATA_EXFILTRATION = "data_exfiltration"
    CREDENTIAL_THEFT = "credential_theft"
    PROMPT_OVERRIDE = "prompt_override"
    SUPPLY_CHAIN = "supply_chain"
    PRIVILEGE_ESCALATION = "privilege_escalation"
    STEGANOGRAPHIC = "steganographic"
    SEMANTIC_MISMATCH = "semantic_mismatch"

How rules execute

Parse

The Pipeline constructs a ScanContext from raw content, separating YAML frontmatter from the body. The description field is extracted for semantic mismatch analysis. The body (everything after ---) is used for pattern matching.

BLOCK-severity rules (4 rules)

Command injection, data exfiltration, credential theft, and prompt override rules check regex patterns against the body text. Each match produces a BLOCK-severity finding with evidence and line number.

FLAG-severity pattern rules (3 rules)

Supply chain, privilege escalation, and steganographic pattern rules check with FLAG severity. These patterns have legitimate uses, so they warrant review rather than automatic rejection.

Steganographic: zero-width scan

The ZeroWidthRule iterates over every character in the full content (including frontmatter) and flags any of the 12 monitored zero-width Unicode codepoints (U+200B, U+200C, U+200D, U+2060, U+FEFF, U+200E, U+200F, U+202A--U+202E).

Semantic mismatch rules (3 rules)

  1. UrlDomainRule -- Extracts all URLs from the body and checks each domain against a curated allowlist. Non-allowlisted domains produce INFO findings. Unparseable URLs are flagged as possible evasion.

  2. SensitivePathRule -- Checks for references to sensitive paths (~/.ssh/, ~/.aws/, .env, etc.). Findings are suppressed if the skill has security context (detected by keywords like "audit", "security", "encrypt", etc.).

  3. KeywordDivergenceRule -- Computes Jaccard similarity between tokenized description and tokenized body. Similarity below 0.02 with a sufficiently large body suggests the description is a decoy.


Security context detection

The SensitivePathRule includes a check for skills that legitimately discuss security topics. If the content contains keywords like "audit", "security", "pentest", "protect", "encrypt", "ssh-keygen", or "git config", sensitive path references are suppressed. This prevents false positives on skills about SSH setup, security hardening, or infrastructure automation.


Using the scanner as a library

Quick scan

from brunnr.scanner import scan_skill_md, ScanResult

content = open("skills/my-skill/SKILL.md").read()
result = scan_skill_md(content)

if result.blocked:
    print(f"BLOCKED: {result.block_reasons}")
elif result.flagged:
    print(f"FLAGGED: {result.flag_reasons}")
else:
    print("CLEAN")

# Get structured output
print(result.to_dict())

Custom pipeline

from brunnr.scanner.pipeline import Pipeline
from brunnr.scanner.rules.cmd_injection import CommandInjectionRule
from brunnr.scanner.rules.prompt_override import PromptOverrideRule

# Scan only for command injection and prompt override
pipeline = Pipeline(rules=[CommandInjectionRule(), PromptOverrideRule()])
result = pipeline.scan(content)

Writing a custom rule

Any class with a name attribute and a scan(ctx) -> list[Finding] method satisfies the Rule protocol:

from brunnr.scanner.context import ScanContext
from brunnr.scanner.types import Finding, Severity, ThreatClass

class MyCustomRule:
    name = "my_custom_check"

    def scan(self, ctx: ScanContext) -> list[Finding]:
        if "suspicious_pattern" in ctx.body:
            return [Finding(
                threat_class=ThreatClass.COMMAND_INJECTION,
                severity=Severity.FLAG,
                description="found suspicious pattern",
                evidence="suspicious_pattern",
            )]
        return []