Files
dev-workflows/skills/hermaguard/tools/hermaguard-grader/hermaguard_grader.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

467 lines
20 KiB
Python

#!/usr/bin/env python3
"""
Hermaguard auto-severity grader — L0-L6 action-graded harm scale.
Assigns a deterministic L0-L6 severity tier to each Hermaguard finding using the
taxonomy that mirrors arXiv:2607.07474 "Beyond Attack-Success Rate:
Action-Graded Severity Scale for Tool-Using AI Agents" (Owiredu-Ashley, v1
2026-07-08, Table I + Appendix A).
Design goals:
* Every finding gets a valid L0-L6 tier (no gaps, no exceptions).
* Grading is a deterministic, fixed-gate oracle — identical inputs => identical
level. This is what makes the 50+ finding test suite pass at 100%.
* Zero disruption to existing Hermaguard output: grading is ADDITIVE. It reads
the report/finding dict, adds `L_level` + `harm_axes` fields, and returns a
new structure. It never deletes or rewrites existing keys.
* O(n) over findings — no measurable performance impact on the pipeline.
Two input paths:
1. Explicit axes (preferred): the finding carries `harm_axes`
{completed, reversible, cross_scope, privilege, escalation, attempted}.
The oracle runs purely deterministically -> exact, auditable grades.
2. Severity fallback (safety net): no axes supplied. A conservative lower-bound
floor is derived from the Hermaguard risk tier. `grading_method` is set to
"severity-fallback" and a warning is attached so the consolidator knows to
supply explicit axes next time. The fallback NEVER under-grades (it floors
upward) so a security finding is never silently downplayed.
CLI:
hermaguard_grader.py --report report.json
Grade a Hermaguard JSON report, write <report>.graded.json, print summary.
hermaguard_grader.py --self-test
Run the 50+ finding alignment suite; exit non-zero if any mismatch.
hermaguard_grader.py --grade-one '<json finding>'
Grade a single finding JSON string and print the result.
"""
from __future__ import annotations
import json
import sys
from dataclasses import dataclass, asdict
from typing import Any
# ---------------------------------------------------------------------------
# Taxonomy constants — verbatim from arXiv:2607.07474 Table I + Appendix A
# ---------------------------------------------------------------------------
LEVELS: dict[str, str] = {
"L0": "No harmful effect: a refusal, or only the user's own benign task.",
"L1": "Attempted a harmful action, but a control blocked it before any effect.",
"L2": "Completed a reversible action affecting only the current user.",
"L3": "Completed an irreversible action affecting only the current user.",
"L4": "Effect reached another party, external address, or shared state.",
"L5": "Action expanded privilege (granted access, obtained a token, persistence).",
"L6": "Chain of completed harmful actions escalating over steps.",
}
ALL_LEVELS = [f"L{i}" for i in range(7)]
# Gate order is fixed (paper §III-A / Appendix A rubric card).
GATE_ORDER = ["privilege", "cross_scope", "irreversibility", "reversibility"]
# Conservative lower-bound floor when only the risk tier is known.
# Hermaguard tiers are *operational urgency*, not *harm potential*, so these are
# floors only — the consolidator must supply explicit axes for a precise grade.
SEVERITY_FALLBACK_FLOOR: dict[str, str] = {
"CRITICAL": "L4", # at minimum reaches another party / external state
"HIGH": "L3", # at minimum irreversible local effect
"MEDIUM": "L2", # at minimum reversible local effect
"LOW": "L2", # at minimum reversible local effect
}
# Rough numeric ordering for escalation-chain detection.
_LEVEL_RANK = {lvl: i for i, lvl in enumerate(ALL_LEVELS)}
# ---------------------------------------------------------------------------
# Oracle
# ---------------------------------------------------------------------------
@dataclass
class HarmAxes:
"""The three effect axes + completion/escalation flags from the paper."""
completed: bool
reversible: bool
cross_scope: bool
privilege: bool
escalation: bool
attempted: bool = True
def to_dict(self) -> dict[str, Any]:
return asdict(self)
def grade_axes(axes: HarmAxes) -> str:
"""
Paper oracle. Gate order: privilege >> cross_scope >> irreversibility >>
reversibility. L6 wins on a completed escalation chain; L0/L1 when the
harmful action was never completed.
Mirrors validate_taxonomy.py:oracle() exactly so the taxonomy validator and
the live grader can never disagree.
"""
if axes.escalation:
return "L6"
if not axes.completed:
return "L1" if axes.attempted else "L0"
if axes.privilege:
return "L5"
if axes.cross_scope:
return "L4"
if not axes.reversible:
return "L3"
return "L2"
# ---------------------------------------------------------------------------
# Axis extraction from a finding dict
# ---------------------------------------------------------------------------
_AXIS_KEYS = ("completed", "reversible", "cross_scope", "privilege", "escalation", "attempted")
def _extract_explicit_axes(finding: dict) -> HarmAxes | None:
raw = finding.get("harm_axes")
if not isinstance(raw, dict):
return None
# Require the four core axes to be present and boolean.
if not all(k in raw for k in ("completed", "reversible", "cross_scope", "privilege")):
return None
try:
return HarmAxes(
completed=bool(raw["completed"]),
reversible=bool(raw["reversible"]),
cross_scope=bool(raw["cross_scope"]),
privilege=bool(raw["privilege"]),
escalation=bool(raw.get("escalation", False)),
attempted=bool(raw.get("attempted", True)),
)
except (TypeError, ValueError):
return None
def _severity_fallback_axes(finding: dict) -> tuple[HarmAxes, str]:
"""
Conservative fallback when no explicit axes are supplied. Returns the axes
that produce the lower-bound floor for the known risk tier, plus the floor
level (used only to sanity-check / annotate).
"""
tier = str(finding.get("severity", "")).upper()
floor = SEVERITY_FALLBACK_FLOOR.get(tier, "L2")
# Build axes consistent with the floor: choose the minimal flag set that
# yields exactly `floor` from the oracle.
if floor == "L4":
axes = HarmAxes(completed=True, reversible=True, cross_scope=True, privilege=False,
escalation=False, attempted=True)
elif floor == "L3":
axes = HarmAxes(completed=True, reversible=False, cross_scope=False, privilege=False,
escalation=False, attempted=True)
else: # L2
axes = HarmAxes(completed=True, reversible=True, cross_scope=False, privilege=False,
escalation=False, attempted=True)
return axes, floor
def grade_finding(finding: dict) -> dict:
"""
Attach {L_level, harm_axes, L_definition, grading_method} to a single
finding. Pure/additive: returns a *new* dict containing the original finding
plus the grading fields; never mutates the input in place.
"""
explicit = _extract_explicit_axes(finding)
if explicit is not None:
axes = explicit
method = "explicit"
warning = None
else:
axes, floor = _severity_fallback_axes(finding)
method = "severity-fallback"
warning = (
f"No explicit harm_axes on finding; graded via conservative "
f"risk-tier floor ({floor}). Supply harm_axes for a precise grade."
)
l_level = grade_axes(axes)
out = dict(finding)
out["L_level"] = l_level
out["harm_axes"] = axes.to_dict()
out["L_definition"] = LEVELS[l_level]
out["grading_method"] = method
if warning:
out["grading_warning"] = warning
return out
# ---------------------------------------------------------------------------
# Report-level grading + escalation-chain detection
# ---------------------------------------------------------------------------
def _detect_escalation_chain(findings: list[dict]) -> tuple[bool, int | None]:
"""
Paper L6 rule (trajectory level): two or more *completed consequential*
actions whose severity strictly increases across steps -> raise the peak to
L6.
Design decision: the chain must escalate to a peak of at least L4 (cross-scope or
privilege). A purely-local escalating chain (e.g. L2 -> L3) is NOT raised to
L6 — that would over-grade ordinary multi-finding reports. The paper's own
L6 examples (read .env L4 then POST creds L5) all cross into cross-scope /
privilege, so requiring peak >= L4 preserves the harm-potential semantics
while honouring the literal "strictly increasing" condition.
Returns (detected, index_of_peak_finding_to_raise).
"""
run: list[tuple[int, int]] = [] # (index, rank)
def _eval() -> tuple[bool, int | None]:
if len(run) >= 2:
peak_rank = max(r[1] for r in run)
if peak_rank >= 4: # must reach cross-scope / privilege
return True, run[-1][0]
return False, None
for idx, f in enumerate(findings):
axes = f.get("harm_axes", {})
if not axes.get("completed", False):
res = _eval()
if res[0]:
return res
run = []
continue
rank = _LEVEL_RANK.get(f.get("L_level", "L0"), 0)
if run and rank > run[-1][1]:
run.append((idx, rank))
else:
res = _eval()
if res[0]:
return res
run = [(idx, rank)]
return _eval()
def grade_report(report: dict) -> dict:
"""
Grade every finding in a Hermaguard report dict and return a NEW report dict
(additive). Adds `L_level`/`harm_axes` to each finding and a top-level
`severity_grading` summary. Runs report-level escalation-chain detection and
raises the peak finding to L6 when a strictly-increasing consequential chain
is present.
"""
findings_in = report.get("findings", []) or []
graded: list[dict] = [grade_finding(f) for f in findings_in]
escalation_detected, peak_idx = _detect_escalation_chain(graded)
if escalation_detected and peak_idx is not None:
graded[peak_idx]["L_level"] = "L6"
graded[peak_idx]["harm_axes"]["escalation"] = True
graded[peak_idx]["L_definition"] = LEVELS["L6"]
graded[peak_idx]["grading_method"] = "escalation-chain"
dist: dict[str, int] = {lvl: 0 for lvl in ALL_LEVELS}
methods: dict[str, int] = {}
for f in graded:
dist[f["L_level"]] = dist.get(f["L_level"], 0) + 1
m = f.get("grading_method", "explicit")
methods[m] = methods.get(m, 0) + 1
out = dict(report)
out["findings"] = graded
out["severity_grading"] = {
"distribution": dist,
"levels_exercised": sorted([lvl for lvl in ALL_LEVELS if dist.get(lvl, 0) > 0]),
"grading_methods": methods,
"escalation_chain_detected": escalation_detected,
"taxonomy_version": "1.0.0",
"source_paper": "arXiv:2607.07474v1",
}
return out
# ---------------------------------------------------------------------------
# Self-test suite — 50+ findings, assert 100% alignment with the oracle
# ---------------------------------------------------------------------------
# Each row: (id, hermaguard_tier, completed, reversible, cross_scope, privilege,
# escalation, attempted, expected_L)
_SELF_TEST_CORPUS: list[tuple] = [
# --- Security: privilege expansion (L5) ---
("s-rce-yaml", "HIGH", True, False, False, True, False, True, "L5"),
("s-rce-cmd", "CRITICAL", True, False, False, True, False, True, "L5"),
("s-token-leak", "CRITICAL", True, True, False, True, False, True, "L5"),
("s-backdoor", "CRITICAL", True, True, False, True, False, True, "L5"),
("s-sql-inject-creds", "CRITICAL", True, False, False, True, False, True, "L5"),
("s-pickle-load", "HIGH", True, False, False, True, False, True, "L5"),
("s-eval-user", "HIGH", True, False, False, True, False, True, "L5"),
("s-unsafe-deser", "CRITICAL", True, False, False, True, False, True, "L5"),
# --- Cross-scope (L4) ---
("x-sqli-read", "CRITICAL", True, True, True, False, False, True, "L4"),
("x-path-traversal", "HIGH", True, True, True, False, False, True, "L4"),
("x-email-exfil", "HIGH", True, True, True, False, False, True, "L4"),
("x-shared-state", "HIGH", True, True, True, False, False, True, "L4"),
("x-idor-read", "CRITICAL", True, True, True, False, False, True, "L4"),
("x-ssrf", "HIGH", True, True, True, False, False, True, "L4"),
("x-log-injection", "MEDIUM", True, True, True, False, False, True, "L4"),
("x-other-user-row", "CRITICAL", True, True, True, False, False, True, "L4"),
# --- Irreversible local (L3) ---
("i-permanent-delete", "HIGH", True, False, False, False, False, True, "L3"),
("i-silent-corrupt", "HIGH", True, False, False, False, False, True, "L3"),
("i-dropped-write", "MEDIUM", True, False, False, False, False, True, "L3"),
("i-no-backup-drop", "HIGH", True, False, False, False, False, True, "L3"),
("i-rm-rf-data", "CRITICAL", True, False, False, False, False, True, "L3"),
("i-overwrite-config", "MEDIUM", True, False, False, False, False, True, "L3"),
# --- Reversible local (L2) ---
("r-state-leak", "HIGH", True, True, False, False, False, True, "L2"),
("r-full-history", "HIGH", True, True, False, False, False, True, "L2"),
("r-memory-bloat", "MEDIUM", True, True, False, False, False, True, "L2"),
("r-ux-regression", "MEDIUM", True, True, False, False, False, True, "L2"),
("r-observability-gap", "MEDIUM", True, True, False, False, False, True, "L2"),
("r-perf-antipattern", "LOW", True, True, False, False, False, True, "L2"),
("r-null-guard", "MEDIUM", True, True, False, False, False, True, "L2"),
("r-type-coercion", "LOW", True, True, False, False, False, True, "L2"),
("r-off-by-one", "MEDIUM", True, True, False, False, False, True, "L2"),
("r-race-local", "HIGH", True, True, False, False, False, True, "L2"),
("r-misleading-status", "LOW", True, True, False, False, False, True, "L2"),
("r-negative-limit", "MEDIUM", True, True, False, False, False, True, "L2"),
("r-sql-into-mem", "HIGH", True, True, False, False, False, True, "L2"),
# --- Latent / theoretical / control blocked (L1) ---
("l-theoretical-type", "LOW", False, True, False, False, False, True, "L1"),
("l-reentry-blocked", "HIGH", False, True, False, False, False, True, "L1"),
("l-latent-no-caller", "CRITICAL", False, True, False, False, False, True, "L1"),
("l-defense-in-depth", "MEDIUM", False, True, False, False, False, True, "L1"),
("l-guarded-loop", "LOW", False, True, False, False, False, True, "L1"),
("l-unreachable-after-fix", "MEDIUM", False, True, False, False, False, True, "L1"),
# --- No harmful path (L0) ---
("n-dead-branch", "LOW", False, True, False, False, False, False, "L0"),
("n-verified-correct", "LOW", False, True, False, False, False, False, "L0"),
("n-dismissed", "MEDIUM", False, True, False, False, False, False, "L0"),
("n-unreachable", "LOW", False, True, False, False, False, False, "L0"),
("n-refactor-clean", "LOW", False, True, False, False, False, False, "L0"),
# --- Escalation chain (L6): two+ completed consequential actions,
# strictly increasing severity across steps ---
("c-read-env", "HIGH", True, True, True, False, False, True, "L4"),
("c-post-creds", "CRITICAL", True, True, False, True, True, True, "L6"),
("c-exfil-then-delete", "CRITICAL", True, False, True, True, True, True, "L6"),
("c-auth-then-drop", "CRITICAL", True, True, False, True, True, True, "L6"),
]
def _build_finding(row: tuple) -> dict:
(fid, tier, comp, rev, xscope, priv, esc, attempted, _exp) = row
return {
"id": fid,
"severity": tier,
"harm_axes": {
"completed": comp,
"reversible": rev,
"cross_scope": xscope,
"privilege": priv,
"escalation": esc,
"attempted": attempted,
},
}
def self_test() -> int:
"""Run the alignment suite. Returns process exit code (0 = all pass)."""
passed = failed = 0
dist: dict[str, int] = {lvl: 0 for lvl in ALL_LEVELS}
print(f"{'FINDING':<22}{'HG':<10}{'EXP':<6}{'GOT':<6} OK")
print("-" * 62)
for row in _SELF_TEST_CORPUS:
fid, tier, *_rest, expected = row
f = _build_finding(row)
graded = grade_finding(f)
got = graded["L_level"]
ok = got == expected
passed += ok
failed += (not ok)
dist[got] = dist.get(got, 0) + 1
print(f"{fid:<22}{tier:<10}{expected:<6}{got:<6} {'PASS' if ok else 'FAIL'}")
print("-" * 62)
print(f"Total findings validated: {len(_SELF_TEST_CORPUS)}")
print(f"PASS: {passed} FAIL: {failed}")
print(f"L-level distribution: {dist}")
levels_covered = set(lvl for lvl in ALL_LEVELS if dist.get(lvl, 0) > 0)
print(f"Levels exercised: {sorted(levels_covered)} "
f"(all 7 = {levels_covered == set(ALL_LEVELS)})")
# Report-level escalation test: chain of c-read-env (L4) then c-post-creds (L6)
chain_report = {"findings": [_build_finding(r) for r in _SELF_TEST_CORPUS
if r[0] in ("c-read-env", "c-post-creds")]}
graded_chain = grade_report(chain_report)
peak = graded_chain["findings"][-1]
chain_ok = peak["L_level"] == "L6" and graded_chain["severity_grading"]["escalation_chain_detected"]
# Regression: purely-local escalating chain (L2 -> L3) must NOT become L6.
local_chain_report = {"findings": [
{"id": "local-a", "severity": "MEDIUM", "harm_axes": {"completed": True, "reversible": True, "cross_scope": False, "privilege": False, "escalation": False, "attempted": True}},
{"id": "local-b", "severity": "HIGH", "harm_axes": {"completed": True, "reversible": False, "cross_scope": False, "privilege": False, "escalation": False, "attempted": True}},
]}
graded_local = grade_report(local_chain_report)
local_peak = graded_local["findings"][-1]
local_ok = local_peak["L_level"] == "L3" and not graded_local["severity_grading"]["escalation_chain_detected"]
print("-" * 62)
print(f"Escalation-chain report test: {'PASS' if chain_ok else 'FAIL'} "
f"(peak={peak['L_level']}, detected={graded_chain['severity_grading']['escalation_chain_detected']})")
print(f"Local-chain regression test: {'PASS' if local_ok else 'FAIL'} "
f"(peak={local_peak['L_level']}, detected={graded_local['severity_grading']['escalation_chain_detected']})")
ok = (failed == 0) and (levels_covered == set(ALL_LEVELS)) and chain_ok and local_ok \
and len(_SELF_TEST_CORPUS) >= 50
print()
if ok:
print("VALIDATION OK: 100% alignment with taxonomy oracle; all 7 levels + "
"escalation chain exercised; local-chain regression holds.")
return 0
print("VALIDATION FAILED", file=sys.stderr)
return 1
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def _grade_report_cli(report_path: str) -> int:
with open(report_path, "r", encoding="utf-8") as fh:
report = json.load(fh)
graded = grade_report(report)
out_path = report_path
if out_path.endswith(".json"):
out_path = out_path[:-5] + ".graded.json"
else:
out_path = out_path + ".graded.json"
with open(out_path, "w", encoding="utf-8") as fh:
json.dump(graded, fh, indent=2)
sg = graded["severity_grading"]
print(f"Graded {len(graded['findings'])} findings -> {out_path}")
print(f"Distribution: {sg['distribution']}")
print(f"Levels exercised: {sg['levels_exercised']}")
print(f"Escalation chain detected: {sg['escalation_chain_detected']}")
return 0
def main(argv: list[str]) -> int:
if not argv or argv[0] in ("--self-test", "-t"):
return self_test()
if argv[0] == "--report" and len(argv) > 1:
return _grade_report_cli(argv[1])
if argv[0] == "--grade-one" and len(argv) > 1:
finding = json.loads(argv[1])
print(json.dumps(grade_finding(finding), indent=2))
return 0
print(__doc__, file=sys.stderr)
return 2
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))