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.
This commit is contained in:
1 parent
ee44fa0f7a
commit
c17192c9fe
130 files changed
+10533
-33
No files matched your search
@@ -0,0 +1,652 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Hermaguard Pre-Scan — Deterministic static analysis layer.
|
||||
Runs language-appropriate linters on changed files, normalises output,
|
||||
feeds structured findings to the LLM review agents.
|
||||
|
||||
Usage:
|
||||
hermaguard-prescan --files "src/a.py,src/b.ts" --output prescan.json
|
||||
hermaguard-prescan --diff "$(git diff --name-only)" --output prescan.json
|
||||
hermaguard-prescan --files "src/*.go" --tools bandit,ruff --output /tmp/prescan.json
|
||||
|
||||
Output: JSON written to --output path, stdout prints summary stats.
|
||||
Exit code 0 even when findings found (findings are informational).
|
||||
Exit code 1 on tool failure (unavailable linter, no files, etc).
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
VERSION = "1.0.0"
|
||||
|
||||
# Language detection: extension → list of (tool, command builder)
|
||||
LANGUAGE_TOOLS = {
|
||||
".py": [
|
||||
{
|
||||
"name": "bandit",
|
||||
"install_hint": "pip install bandit",
|
||||
"build_cmd": lambda files, tmpdir: [
|
||||
"bandit", "-f", "json", "-q", *files
|
||||
],
|
||||
"parse": "_parse_bandit",
|
||||
},
|
||||
{
|
||||
"name": "ruff",
|
||||
"install_hint": "pip install ruff",
|
||||
"build_cmd": lambda files, tmpdir: [
|
||||
"ruff", "check", "--output-format", "json", "--select",
|
||||
"B,PLC,PLW,S,A,SIM,C4,PT", *files
|
||||
],
|
||||
"parse": "_parse_ruff",
|
||||
},
|
||||
],
|
||||
".ts": [
|
||||
{
|
||||
"name": "eslint",
|
||||
"install_hint": "npm install -g eslint @typescript-eslint/parser @typescript-eslint/eslint-plugin",
|
||||
"build_cmd": lambda files, tmpdir: _make_eslint_cmd(files, tmpdir, ".ts"),
|
||||
"parse": "_parse_eslint",
|
||||
},
|
||||
{
|
||||
"name": "semgrep",
|
||||
"install_hint": "pip install semgrep",
|
||||
"build_cmd": lambda files, tmpdir: [
|
||||
"semgrep", "--config", "p/typescript", "--config", "p/secrets",
|
||||
"--json", "--quiet", *files
|
||||
],
|
||||
"parse": "_parse_semgrep",
|
||||
},
|
||||
],
|
||||
".tsx": [
|
||||
{
|
||||
"name": "eslint",
|
||||
"install_hint": "npm install -g eslint @typescript-eslint/parser @typescript-eslint/eslint-plugin",
|
||||
"build_cmd": lambda files, tmpdir: _make_eslint_cmd(files, tmpdir, ".tsx"),
|
||||
"parse": "_parse_eslint",
|
||||
},
|
||||
],
|
||||
".js": [
|
||||
{
|
||||
"name": "eslint",
|
||||
"install_hint": "npm install -g eslint",
|
||||
"build_cmd": lambda files, tmpdir: _make_eslint_cmd(files, tmpdir, ".js"),
|
||||
"parse": "_parse_eslint",
|
||||
},
|
||||
],
|
||||
".go": [
|
||||
{
|
||||
"name": "gosec",
|
||||
"install_hint": "go install github.com/securego/gosec/v2/cmd/gosec@latest",
|
||||
"build_cmd": lambda files, tmpdir: [
|
||||
"gosec", "-fmt", "json", "-quiet", *files
|
||||
],
|
||||
"parse": "_parse_gosec",
|
||||
},
|
||||
],
|
||||
".rs": [
|
||||
{
|
||||
"name": "clippy",
|
||||
"install_hint": "rustup component add clippy",
|
||||
"build_cmd": lambda files, tmpdir: [
|
||||
"cargo", "clippy", "--message-format", "json", "--", "-W", "clippy::all"
|
||||
],
|
||||
"parse": "_parse_cargo",
|
||||
},
|
||||
],
|
||||
".java": [
|
||||
{
|
||||
"name": "semgrep",
|
||||
"install_hint": "pip install semgrep",
|
||||
"build_cmd": lambda files, tmpdir: [
|
||||
"semgrep", "--config", "p/java", "--config", "p/secrets",
|
||||
"--json", "--quiet", *files
|
||||
],
|
||||
"parse": "_parse_semgrep",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
# Generic: semgrep runs on all languages as fallback
|
||||
GENERIC_TOOLS = [
|
||||
{
|
||||
"name": "semgrep",
|
||||
"install_hint": "pip install semgrep",
|
||||
"build_cmd": lambda files, tmpdir: [
|
||||
"semgrep", "--config", "p/secrets", "--config", "p/owasp-top-ten",
|
||||
"--json", "--quiet", *files
|
||||
],
|
||||
"parse": "_parse_semgrep",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def _make_eslint_cmd(files, tmpdir, ext):
|
||||
"""Create a minimal ESLint config file for hermaguard-prescan and return the command."""
|
||||
cfg_path = os.path.join(tmpdir, f"eslintrc-{ext.lstrip('.')}.json")
|
||||
config = {
|
||||
"env": {"es2021": True, "node": True},
|
||||
"parser": "@typescript-eslint/parser" if ext in (".ts", ".tsx") else "espree",
|
||||
"parserOptions": {"ecmaVersion": "latest"},
|
||||
"plugins": ["@typescript-eslint"] if ext in (".ts", ".tsx") else [],
|
||||
"rules": {
|
||||
"no-unused-vars": "warn",
|
||||
"no-undef": "error",
|
||||
"no-empty": "error",
|
||||
"no-unsafe-optional-chaining": "error",
|
||||
"no-unsafe-negation": "error",
|
||||
"no-unsafe-finally": "error",
|
||||
"no-constant-condition": "error",
|
||||
"no-dupe-keys": "error",
|
||||
"no-prototype-builtins": "warn",
|
||||
"require-atomic-updates": "error",
|
||||
},
|
||||
}
|
||||
with open(cfg_path, "w") as f:
|
||||
json.dump(config, f)
|
||||
return [
|
||||
"eslint", "-c", cfg_path, "-f", "json", "--no-error-on-unmatched-pattern", *files
|
||||
]
|
||||
|
||||
|
||||
# ── Parsers (tool → normalised {tool, rule_id, severity, file, line, message, category}) ──
|
||||
|
||||
def _parse_bandit(output: str) -> list:
|
||||
try:
|
||||
data = json.loads(output)
|
||||
except json.JSONDecodeError:
|
||||
return []
|
||||
findings = []
|
||||
for r in data.get("results", []):
|
||||
sev = {"MEDIUM": "MEDIUM", "HIGH": "HIGH"}.get(r.get("issue_severity", "").upper(), "LOW")
|
||||
findings.append({
|
||||
"tool": "bandit",
|
||||
"rule_id": r.get("test_id", ""),
|
||||
"severity": sev,
|
||||
"file": r.get("filename", ""),
|
||||
"line": r.get("line_number", 0),
|
||||
"message": r.get("issue_text", ""),
|
||||
"category": "security",
|
||||
})
|
||||
return findings
|
||||
|
||||
|
||||
def _parse_ruff(output: str) -> list:
|
||||
findings = []
|
||||
for line in output.strip().split("\n"):
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
r = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
rule_id = r.get("code", "")
|
||||
if rule_id:
|
||||
findings.append({
|
||||
"tool": "ruff",
|
||||
"rule_id": rule_id,
|
||||
"severity": "LOW",
|
||||
"file": r.get("filename", ""),
|
||||
"line": r.get("location", {}).get("row", 0),
|
||||
"message": r.get("message", ""),
|
||||
"category": "quality",
|
||||
})
|
||||
return findings
|
||||
|
||||
|
||||
def _parse_eslint(output: str) -> list:
|
||||
try:
|
||||
data = json.loads(output)
|
||||
except json.JSONDecodeError:
|
||||
return []
|
||||
findings = []
|
||||
for file_entry in data:
|
||||
fname = file_entry.get("filePath", "")
|
||||
for msg in file_entry.get("messages", []):
|
||||
sev_map = {2: "HIGH", 1: "MEDIUM", 0: "LOW"}
|
||||
sev = sev_map.get(msg.get("severity", 0), "LOW")
|
||||
findings.append({
|
||||
"tool": "eslint",
|
||||
"rule_id": msg.get("ruleId", "") or "N/A",
|
||||
"severity": sev,
|
||||
"file": fname,
|
||||
"line": msg.get("line", 0),
|
||||
"message": msg.get("message", ""),
|
||||
"category": "quality",
|
||||
})
|
||||
return findings
|
||||
|
||||
|
||||
def _parse_semgrep(output: str) -> list:
|
||||
try:
|
||||
data = json.loads(output)
|
||||
except json.JSONDecodeError:
|
||||
return []
|
||||
findings = []
|
||||
for r in data.get("results", []):
|
||||
sev = r.get("extra", {}).get("severity", "WARNING").upper()
|
||||
sev_norm = {"ERROR": "HIGH", "WARNING": "MEDIUM", "INFO": "LOW"}.get(sev, "LOW")
|
||||
findings.append({
|
||||
"tool": "semgrep",
|
||||
"rule_id": r.get("check_id", ""),
|
||||
"severity": sev_norm,
|
||||
"file": r.get("path", ""),
|
||||
"line": r.get("start", {}).get("line", 0),
|
||||
"message": r.get("extra", {}).get("message", ""),
|
||||
"category": "security" if "owasp" in r.get("check_id", "").lower() else "quality",
|
||||
})
|
||||
return findings
|
||||
|
||||
|
||||
def _parse_gosec(output: str) -> list:
|
||||
try:
|
||||
data = json.loads(output)
|
||||
except json.JSONDecodeError:
|
||||
return []
|
||||
findings = []
|
||||
for issue in data.get("Issues", []) if isinstance(data, dict) else (data if isinstance(data, list) else []):
|
||||
findings.append({
|
||||
"tool": "gosec",
|
||||
"rule_id": issue.get("rule_id", ""),
|
||||
"severity": issue.get("severity", "MEDIUM").upper(),
|
||||
"file": issue.get("file", ""),
|
||||
"line": int(issue.get("line", 0)),
|
||||
"message": issue.get("details", ""),
|
||||
"category": "security",
|
||||
})
|
||||
return findings
|
||||
|
||||
|
||||
def _parse_cargo(output: str) -> list:
|
||||
findings = []
|
||||
for line in output.strip().split("\n"):
|
||||
try:
|
||||
msg = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if msg.get("reason") != "compiler-message":
|
||||
continue
|
||||
m = msg.get("message", {})
|
||||
if m.get("code") is None:
|
||||
continue
|
||||
spans = msg.get("spans", [])
|
||||
if not spans:
|
||||
continue
|
||||
findings.append({
|
||||
"tool": "clippy",
|
||||
"rule_id": m.get("code", {}).get("code", ""),
|
||||
"severity": m.get("level", "warning").upper(),
|
||||
"file": spans[0].get("file_name", ""),
|
||||
"line": spans[0].get("line_start", 0),
|
||||
"message": m.get("message", ""),
|
||||
"category": "quality",
|
||||
})
|
||||
return findings
|
||||
|
||||
|
||||
PARSER_MAP = {
|
||||
"bandit": _parse_bandit,
|
||||
"ruff": _parse_ruff,
|
||||
"eslint": _parse_eslint,
|
||||
"semgrep": _parse_semgrep,
|
||||
"gosec": _parse_gosec,
|
||||
"clippy": _parse_cargo,
|
||||
}
|
||||
|
||||
|
||||
def find_tool(name: str) -> str | None:
|
||||
"""Return path to tool if it's on PATH, else None.
|
||||
|
||||
Uses shutil.which so the lookup works on Windows as well as POSIX
|
||||
(the old `which` subprocess was POSIX-only despite the cross-platform claim).
|
||||
"""
|
||||
found = shutil.which(name)
|
||||
if found:
|
||||
return found
|
||||
# Fall back to common install locations not always on a minimal PATH.
|
||||
for path in [f"/usr/local/bin/{name}", f"/usr/bin/{name}", os.path.expanduser(f"~/.local/bin/{name}")]:
|
||||
if os.path.isfile(path) and os.access(path, os.X_OK):
|
||||
return path
|
||||
return None
|
||||
|
||||
|
||||
def split_files(files_arg: str) -> list:
|
||||
"""Split comma/space-separated file list into list of paths."""
|
||||
# Try comma-separated first, then newline-separated (from git diff --name-only)
|
||||
if "," in files_arg:
|
||||
return [f.strip() for f in files_arg.split(",") if f.strip()]
|
||||
return [f for f in files_arg.strip().split("\n") if f.strip()]
|
||||
|
||||
|
||||
def group_by_extension(files: list, restrict_tools: list | None = None) -> dict:
|
||||
"""Group files by extension, mapping to available tools.
|
||||
Returns {ext: {"files": [...], "tools": [...]}}
|
||||
"""
|
||||
groups = {}
|
||||
for f in files:
|
||||
ext = Path(f).suffix.lower()
|
||||
if not ext:
|
||||
continue
|
||||
if ext not in groups:
|
||||
groups[ext] = {"files": [], "tools": LANGUAGE_TOOLS.get(ext, [])}
|
||||
groups[ext]["files"].append(f)
|
||||
|
||||
# Filter tools if --tools flag was passed
|
||||
if restrict_tools:
|
||||
for ext_data in groups.values():
|
||||
ext_data["tools"] = [t for t in ext_data["tools"] if t["name"] in restrict_tools]
|
||||
|
||||
return groups
|
||||
|
||||
|
||||
def run_tool(tool_def: dict, files: list, tmpdir: str) -> tuple[str | None, list]:
|
||||
"""Run a single tool. Returns (None, [findings]) on success or (error_msg, []) on failure."""
|
||||
if not files:
|
||||
return f"no files for {tool_def['name']}", []
|
||||
|
||||
tool_path = find_tool(tool_def["name"])
|
||||
if not tool_path:
|
||||
return f"{tool_def['name']} not installed ({tool_def.get('install_hint', '')})", []
|
||||
|
||||
cmd = tool_def["build_cmd"](files, tmpdir)
|
||||
try:
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
|
||||
except subprocess.TimeoutExpired:
|
||||
return f"{tool_def['name']} timed out", []
|
||||
except FileNotFoundError:
|
||||
return f"{tool_def['name']} command not found", []
|
||||
|
||||
if result.returncode != 0 and not result.stdout.strip():
|
||||
return f"{tool_def['name']} failed: {result.stderr[:200]}", []
|
||||
|
||||
parser_name = tool_def["parse"]
|
||||
parser = PARSER_MAP.get(parser_name, lambda x: [])
|
||||
try:
|
||||
findings = parser(result.stdout.strip())
|
||||
except Exception as e:
|
||||
return f"{tool_def['name']} parse error: {e}", []
|
||||
|
||||
return None, findings
|
||||
|
||||
|
||||
# ── Suppression rules (compiled from dismissed findings via hermaguard-compile-rules) ──
|
||||
|
||||
DEFAULT_RULES_PATHS = [
|
||||
".kensei/hermaguard-rules.yaml",
|
||||
"hermaguard-rules.yaml",
|
||||
]
|
||||
|
||||
|
||||
def load_suppression_rules(path: str | None = None) -> tuple[list, str | None]:
|
||||
"""Load compiled suppression rules. Returns (rules, source_path_or_None).
|
||||
|
||||
Accepts the exact format hermaguard-compile-rules writes: a JSON-superset
|
||||
YAML list of dicts with keys rule_id/pattern/agent/dismissals. Parsing is
|
||||
stdlib-only via json per-document; a fallback regex extractor handles
|
||||
hand-edited files. Missing file = no rules, never an error.
|
||||
"""
|
||||
candidates = [path] if path else DEFAULT_RULES_PATHS
|
||||
for cand in candidates:
|
||||
if not cand:
|
||||
continue
|
||||
try:
|
||||
if not os.path.isfile(cand):
|
||||
continue
|
||||
with open(cand) as f:
|
||||
text = f.read()
|
||||
except OSError:
|
||||
continue
|
||||
rules = _parse_rules_text(text)
|
||||
if rules:
|
||||
return rules, cand
|
||||
return [], cand # file exists but empty of rules
|
||||
return [], None
|
||||
|
||||
|
||||
def _parse_rules_text(text: str) -> list:
|
||||
"""Parse the compile-rules YAML subset with stdlib only."""
|
||||
rules = []
|
||||
current = {}
|
||||
# Try whole-document JSON first (valid when the file is a pure JSON array)
|
||||
try:
|
||||
data = json.loads(text)
|
||||
if isinstance(data, list):
|
||||
return [r for r in data if isinstance(r, dict) and r.get("pattern")]
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
# Line-oriented parse of the known key set
|
||||
for line in text.splitlines():
|
||||
s = line.strip()
|
||||
if s.startswith("#") or not s.startswith("- ") and not s[:1].isalpha():
|
||||
# allow "- rule_id:" item starts and " key: value" continuations
|
||||
if not s.startswith("- ") and not (current and s and ":" in s and not s.startswith("-")):
|
||||
continue
|
||||
m = re.match(r"^-\s*(rule_id|pattern|agent|dismissals|file_scope):\s*(.*)$", s)
|
||||
if m:
|
||||
if current:
|
||||
rules.append(current)
|
||||
current = {m.group(1): _coerce_scalar(m.group(2))}
|
||||
continue
|
||||
m = re.match(r"^(rule_id|pattern|agent|dismissals|file_scope):\s*(.*)$", s)
|
||||
if m and current is not None:
|
||||
key = m.group(1)
|
||||
val = _coerce_scalar(m.group(2))
|
||||
if key == "rule_id" and current:
|
||||
rules.append(current)
|
||||
current = {}
|
||||
current[key] = val
|
||||
continue
|
||||
if current:
|
||||
rules.append(current)
|
||||
return [r for r in rules if r.get("pattern")]
|
||||
|
||||
|
||||
def _coerce_scalar(v: str):
|
||||
v = v.strip()
|
||||
if v.startswith('"') and v.endswith('"') and len(v) >= 2:
|
||||
try:
|
||||
return json.loads(v)
|
||||
except json.JSONDecodeError:
|
||||
return v[1:-1]
|
||||
if v.startswith("'") and v.endswith("'") and len(v) >= 2:
|
||||
return v[1:-1]
|
||||
if v.isdigit():
|
||||
return int(v)
|
||||
return v
|
||||
|
||||
|
||||
def apply_suppression_rules(findings: list, rules: list) -> tuple[list, list]:
|
||||
"""Filter findings against compiled suppression rules.
|
||||
|
||||
A finding is suppressed when ALL of:
|
||||
- rule.pattern (case-insensitive substring) appears in the finding's
|
||||
message, AND
|
||||
- rule.file_scope is empty or equals the finding's file basename/dir.
|
||||
Suppressed findings are dropped from the output and listed in stats.
|
||||
Returns (kept_findings, suppressed_findings).
|
||||
"""
|
||||
if not rules:
|
||||
return findings, []
|
||||
kept, suppressed = [], []
|
||||
for f in findings:
|
||||
hay = f"{f.get('message', '')} {f.get('rule_id', '')}".lower()
|
||||
fname = f.get("file", "")
|
||||
dropped = False
|
||||
for r in rules:
|
||||
pat = str(r.get("pattern", "")).lower()
|
||||
if not pat:
|
||||
continue
|
||||
scope = str(r.get("file_scope", "") or "")
|
||||
scope_ok = (not scope) or (scope and (scope in fname or os.path.basename(fname) == os.path.basename(scope)))
|
||||
if pat in hay and scope_ok:
|
||||
dropped = True
|
||||
break
|
||||
if dropped:
|
||||
suppressed.append(f)
|
||||
else:
|
||||
kept.append(f)
|
||||
return kept, suppressed
|
||||
|
||||
|
||||
def run_prescan(files: list, restrict_tools: list | None = None,
|
||||
rules_path: str | None = None) -> dict:
|
||||
"""Main entry point. Run all applicable tools on given files.
|
||||
Returns the output dict.
|
||||
"""
|
||||
groups = group_by_extension(files, restrict_tools)
|
||||
|
||||
# Collect unique tools from matching extensions
|
||||
all_exts = []
|
||||
for ext, data in groups.items():
|
||||
all_exts.extend(data["files"])
|
||||
unique_exts = list(groups.keys())
|
||||
|
||||
# Also add generic tools (semgrep) to any non-empty group
|
||||
all_tools_run = set()
|
||||
all_findings = []
|
||||
tools_run = []
|
||||
tools_skipped = []
|
||||
|
||||
# Run extension-specific tools
|
||||
for ext, g in groups.items():
|
||||
for tool in g["tools"]:
|
||||
if tool["name"] in all_tools_run:
|
||||
continue
|
||||
all_tools_run.add(tool["name"])
|
||||
path = find_tool(tool["name"])
|
||||
if not path:
|
||||
tools_skipped.append(tool["name"])
|
||||
continue
|
||||
tools_run.append(tool["name"])
|
||||
err, findings = run_tool(tool, g["files"], tempfile.gettempdir())
|
||||
if err:
|
||||
# Move from run to skipped with the reason, once (no double-append).
|
||||
if tool["name"] in tools_run:
|
||||
tools_run.remove(tool["name"])
|
||||
tools_skipped.append(f"{tool['name']} ({err})")
|
||||
continue
|
||||
all_findings.extend(findings)
|
||||
|
||||
# Run generic tools (semgrep with security rules) on all files by extension, if available
|
||||
semgrep_path = find_tool("semgrep")
|
||||
if semgrep_path and all_exts:
|
||||
# Only run generic semgrep if it hasn't already been run
|
||||
if "semgrep" not in all_tools_run:
|
||||
all_tools_run.add("semgrep")
|
||||
tools_run.append("semgrep")
|
||||
err, findings = run_tool(GENERIC_TOOLS[0], all_exts, tempfile.gettempdir())
|
||||
if not err:
|
||||
all_findings.extend(findings)
|
||||
else:
|
||||
tools_skipped.append(f"semgrep-generic ({err})")
|
||||
|
||||
# Deduplicate: same (file, line, tool, rule_id) = same finding
|
||||
seen = set()
|
||||
deduped = []
|
||||
for f in all_findings:
|
||||
key = (f["file"], f["line"], f["tool"], f["rule_id"])
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
deduped.append(f)
|
||||
|
||||
# Apply compiled suppression rules (hermaguard-compile-rules output)
|
||||
rules, rules_source = load_suppression_rules(rules_path)
|
||||
kept, suppressed = apply_suppression_rules(deduped, rules)
|
||||
|
||||
by_sev = {"CRITICAL": 0, "HIGH": 0, "MEDIUM": 0, "LOW": 0}
|
||||
for f in kept:
|
||||
by_sev[f["severity"]] = by_sev.get(f["severity"], 0) + 1
|
||||
|
||||
return {
|
||||
"findings": kept,
|
||||
"stats": {
|
||||
"tools_run": tools_run,
|
||||
"tools_skipped": tools_skipped,
|
||||
"total_findings": len(kept),
|
||||
"by_severity": by_sev,
|
||||
"files_scanned": len(files),
|
||||
"suppression_rules_applied": len(rules),
|
||||
"suppression_rules_source": rules_source,
|
||||
"suppressed_findings": len(suppressed),
|
||||
"hermaguard_prescan_version": VERSION,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Hermaguard Pre-Scan — deterministic static analysis before LLM review"
|
||||
)
|
||||
src = parser.add_mutually_exclusive_group(required=True)
|
||||
src.add_argument(
|
||||
"--files",
|
||||
help="Comma-separated or newline-separated list of file paths to scan"
|
||||
)
|
||||
src.add_argument(
|
||||
"--diff",
|
||||
help="Newline-separated file list, e.g. \"$(git diff --name-only)\". Alias for --files."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output", required=True,
|
||||
help="Path to write JSON output (e.g., /tmp/hermaguard/prescan-abc123.json)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--tools",
|
||||
help="Comma-separated list of tools to restrict to (e.g., 'bandit,ruff'). Default: auto-detect from file extensions."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--rules",
|
||||
help="Path to compiled suppression rules (hermaguard-compile-rules output). Default: .kensei/hermaguard-rules.yaml or hermaguard-rules.yaml in cwd."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--version", action="version", version=f"%(prog)s {VERSION}"
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
files = split_files(args.files or args.diff)
|
||||
if not files:
|
||||
output = {
|
||||
"findings": [],
|
||||
"stats": {
|
||||
"tools_run": [],
|
||||
"tools_skipped": [],
|
||||
"total_findings": 0,
|
||||
"by_severity": {"CRITICAL": 0, "HIGH": 0, "MEDIUM": 0, "LOW": 0},
|
||||
"files_scanned": 0,
|
||||
"hermaguard_prescan_version": VERSION,
|
||||
},
|
||||
}
|
||||
else:
|
||||
restrict = [t.strip() for t in args.tools.split(",")] if args.tools else None
|
||||
output = run_prescan(files, restrict, rules_path=args.rules)
|
||||
|
||||
# Write output
|
||||
os.makedirs(os.path.dirname(args.output) or ".", exist_ok=True)
|
||||
with open(args.output, "w") as f:
|
||||
json.dump(output, f, indent=2)
|
||||
|
||||
# Summary to stdout
|
||||
stats = output["stats"]
|
||||
print(f"Pre-scan complete: {stats['total_findings']} findings from {stats['files_scanned']} files")
|
||||
print(f" Tools run: {', '.join(stats['tools_run']) if stats['tools_run'] else 'none'}")
|
||||
if stats["tools_skipped"]:
|
||||
print(f" Tools skipped: {', '.join(stats['tools_skipped'])}")
|
||||
sevs = []
|
||||
for s, c in stats["by_severity"].items():
|
||||
if c > 0:
|
||||
sevs.append(f"{s}: {c}")
|
||||
if sevs:
|
||||
print(f" By severity: {', '.join(sevs)}")
|
||||
print(f" Output: {args.output}")
|
||||
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in new issue
Block a user