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¶
- Deterministic -- Same input produces the same output every time. No probabilistic models, no temperature settings, no API calls.
- Zero dependencies -- The scanner uses only
re,string,dataclasses,enum, andurllib.parsefrom Python's stdlib. - 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.
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)¶
-
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.
-
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.). -
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 []