Files
dev-workflows/skills/hermaguard/tools/hermaguard-role-patterns/hermaguard-role-patterns.py
evandrodevbr c17192c9fe feat: vendorizar as 10 skills do pilar de seguranca em skills/
Copia local (nao fork, nao submodule) de safedeps, sca-audit,
secrets-scan, code-review-security, api-security-review,
web-security-review, cve-triage, patch-prioritization,
dependency-scanning e hermaguard -- commit exato de origem e licenca
de cada uma em skills/NOTICE.md. Elimina o passo de clone externo
pro pilar de seguranca do wf-security-review: git clone deste repo
ja traz tudo funcional.

Corrige tambem o comando de instalacao pro Claude Code nos dois
READMEs e no docs/SKILLS.md: Claude Code exige um diretorio por
skill (skills/<nome>/SKILL.md), nao aceita os .md soltos que o
comando antigo copiava -- confirmado testando a instalacao real.
2026-08-24 00:32:20 -03:00

155 lines
8.1 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
Hermaguard Role-Pattern Scanner — agent-specific SAST rules (built-in).
CLAWAUDIT's finding: generic SAST (Semgrep Pro, CodeQL) catches only
13.8–21.7% of agent-specific vulnerabilities, while agent-specific rule
bundles reach 66.8–75.1% recall. Hermaguard's prescan shells out to
generic tools (bandit, ruff, eslint); this scanner adds the agent-specific
layer that generic tools miss — pattern bundles keyed to the exact bug
classes each hermaguard reviewer role hunts.
Design (deterministic, stdlib-only, zero external deps):
- Rule bundles: adversarial (injection/auth/deserialization),
edge-case (races/null/async/resource), blast-radius (I/O, config,
migration touchpoints).
- Each rule: {id, agent, class_id, regex, message, severity}.
- Emits findings in the SAME schema the prescan normalizes to, so the
rest of the pipeline (dedup, suppression, report) treats them as
first-class findings: tool = "hermaguard-role-patterns".
Supports both Python and JS/TS targets; rules carry a `langs` list.
Usage:
hermaguard-role-patterns.py --files a.py,b.ts [--agents adversarial,edge-case]
"""
import argparse
import json
import os
import re
import sys
from pathlib import Path
VERSION = "1.0.0"
TOOL_NAME = "hermaguard-role-patterns"
RULES = [
# ---- adversarial-reviewer: security-focused ----
{"id": "ADV-001", "agent": "adversarial", "class_id": "CWE-89", "langs": ["py", "js", "ts"],
"regex": r"(?:execute|query|raw)\s*\(\s*f[\"']|f[\"'](?:SELECT|INSERT|UPDATE|DELETE)\b[^\"']*\{",
"severity": "HIGH", "message": "f-string/format interpolation reaches SQL — parameterize"},
{"id": "ADV-002", "agent": "adversarial", "class_id": "CWE-78", "langs": ["py"],
"regex": r"subprocess\.(?:run|Popen|call)\([^)]*shell\s*=\s*True",
"severity": "HIGH", "message": "shell=True with subprocess — command injection surface"},
{"id": "ADV-003", "agent": "adversarial", "class_id": "CWE-502", "langs": ["py"],
"regex": r"pickle\.loads?\(|yaml\.load\s*\([^)]*\)(?!.*Loader)",
"severity": "CRITICAL", "message": "unsafe deserialization — pickle/yaml.load on untrusted data"},
{"id": "ADV-004", "agent": "adversarial", "class_id": "CWE-798", "langs": ["py", "js", "ts"],
"regex": r"(?:password|passwd|secret|api[_-]?key|token)\s*[:=]\s*[\"'][^\"']{8,}[\"']",
"severity": "HIGH", "message": "hardcoded credential — move to secrets manager"},
{"id": "ADV-005", "agent": "adversarial", "class_id": "CWE-918", "langs": ["py", "js", "ts"],
"regex": r"requests\.(?:get|post|put|delete)\([^)]*(?:url|user)|fetch\(\s*(?:url|req)",
"severity": "MEDIUM", "message": "outbound request from user-influenced URL — SSRF check"},
{"id": "ADV-006", "agent": "adversarial", "class_id": "CWE-22", "langs": ["py"],
"regex": r"os\.path\.join\(\s*(?:user|filename|name)|Path\([^)]*(?:user|filename)",
"severity": "MEDIUM", "message": "path built from user input — traversal check"},
# ---- edge-case-hunter: correctness-focused ----
{"id": "EDG-001", "agent": "edge-case", "class_id": "CWE-476", "langs": ["py"],
"regex": r"\.fetchone\(\)\s*\[\s*0\s*\]|\.get\([^)]*\)\s*\[\s*0\s*\]",
"severity": "MEDIUM", "message": "index [0] on possibly-None result — null deref"},
{"id": "EDG-002", "agent": "edge-case", "class_id": "CWE-362", "langs": ["py"],
"regex": r"(?:os\.path\.(?:exists|isfile|isdir)|os\.access)\s*\([^)]*\)[\s\S]{0,400}?(?:remove|unlink|rename|write)",
"severity": "HIGH", "message": "check-then-act pattern — TOCTOU race window"},
{"id": "EDG-003", "agent": "edge-case", "class_id": "HG-ASYNC", "langs": ["py", "js", "ts"],
"regex": r"asyncio\.gather\([^)]*\)|Promise\.all\([^)]*\)",
"severity": "MEDIUM", "message": "concurrent async work — check rejection/exception handling"},
{"id": "EDG-004", "agent": "edge-case", "class_id": "HG-PARTIAL-WRITE", "langs": ["py"],
"regex": r"\.write\s*\([^)]*\)\s*\n(?![^\n]*(?:flush|close|commit|with\s+open))",
"severity": "MEDIUM", "message": "write without visible flush/commit — partial-write risk"},
{"id": "EDG-005", "agent": "edge-case", "class_id": "CWE-400", "langs": ["py", "js", "ts"],
"regex": r"while\s+True\s*:|\.read\(\)\s*$",
"severity": "MEDIUM", "message": "unbounded loop / full-file read — resource exhaustion"},
{"id": "EDG-006", "agent": "edge-case", "class_id": "CWE-190", "langs": ["py"],
"regex": r"int\([^)]*\)\s*[+*]|len\([^)]*\)\s*\+",
"severity": "LOW", "message": "arithmetic on int()/len() — overflow/limit check"},
# ---- blast-radius-integration: cross-cutting touchpoints ----
{"id": "BLT-001", "agent": "blast-radius", "class_id": "CWE-200", "langs": ["py", "js", "ts"],
"regex": r"traceback\.format_exc\(\)|print\(\s*(?:f[\"'])?[^\"']*(?:token|password|secret|key)",
"severity": "MEDIUM", "message": "sensitive data may be emitted — exposure check"},
{"id": "BLT-002", "agent": "blast-radius", "class_id": "CWE-400", "langs": ["py", "js", "ts"],
"regex": r"getenv\([^)]*\)|process\.env\.\w+|environ\.get\([^)]*\)",
"severity": "LOW", "message": "env/config read — blast radius of config change"},
{"id": "BLT-003", "agent": "blast-radius", "class_id": "HG-PARTIAL-WRITE", "langs": ["py"],
"regex": r"ALTER TABLE|DROP COLUMN|ADD COLUMN|CREATE TABLE",
"severity": "HIGH", "message": "schema migration — check rollback + data integrity"},
{"id": "BLT-004", "agent": "blast-radius", "class_id": "CWE-306", "langs": ["py", "js", "ts"],
"regex": r"@app\.(?:route|get|post|put|delete)|@router\.(?:get|post|put|delete)",
"severity": "LOW", "message": "route endpoint — verify authz/ownership check exists"},
]
RULE_MAP = {r["id"]: r for r in RULES}
def _lang_of(path: str) -> str:
s = Path(path).suffix.lower().lstrip(".")
return s if s in ("py", "js", "ts", "jsx", "tsx") else s.split(".")[-1] if s else ""
def scan_files(files: list, agents: list | None = None) -> list:
"""Scan files with role-pattern rules. Returns normalized findings list."""
active = set(agents) if agents else {"adversarial", "edge-case", "blast-radius"}
findings = []
for f in files:
p = Path(f)
try:
text = p.read_text(errors="replace")
except OSError:
continue
lang = _lang_of(f)
for rule in RULES:
if rule["agent"] not in active:
continue
if lang not in rule["langs"]:
continue
rx = re.compile(rule["regex"], re.IGNORECASE)
for m in rx.finditer(text):
line_no = text[:m.start()].count("\n") + 1
findings.append({
"tool": TOOL_NAME,
"rule_id": rule["id"],
"severity": rule["severity"],
"file": f,
"line": line_no,
"message": rule["message"],
"category": "security" if rule["agent"] == "adversarial" else "correctness",
"agent": rule["agent"],
"class_id": rule["class_id"],
})
return findings
def main():
p = argparse.ArgumentParser(description="Hermaguard agent-specific SAST rules")
p.add_argument("--files", required=True, help="Comma-separated file paths")
p.add_argument("--agents", help="Comma-separated agents (default: all)")
p.add_argument("--json", dest="json_out", help="Write findings JSON to path")
args = p.parse_args()
files = [f.strip() for f in args.files.split(",") if f.strip()]
agents = [a.strip() for a in args.agents.split(",")] if args.agents else None
findings = scan_files(files, agents)
out = {"tool": TOOL_NAME, "version": VERSION, "findings": findings,
"stats": {"files_scanned": len(files), "findings": len(findings)}}
if args.json_out:
Path(args.json_out).write_text(json.dumps(out, indent=2))
print(f"role-patterns: {len(findings)} findings from {len(files)} files")
for f in findings:
print(f" [{f['rule_id']}] {f['severity']} {f['file']}:{f['line']} {f['message']}")
return 0
if __name__ == "__main__":
sys.exit(main())