@intentsolutions/audit-harness 1.1.5 → 1.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +71 -0
- package/bin/audit-harness.js +90 -0
- package/docs/gate-promotion.md +45 -0
- package/package.json +3 -1
- package/schemas/audit-profile/layer-applicability.md +146 -0
- package/schemas/audit-profile/registry.v1.json +87 -0
- package/schemas/audit-profile/v1.schema.json +294 -0
- package/schemas/conform/v1/agent-frontmatter.schema.json +24 -0
- package/schemas/conform/v1/mcp-config.schema.json +31 -0
- package/schemas/conform/v1/plugin-manifest.schema.json +26 -0
- package/schemas/conform/v1/skillmd-frontmatter.schema.json +40 -0
- package/schemas/currency/pins.v1.json +55 -0
- package/scripts/audit.py +386 -0
- package/scripts/classify.py +403 -0
- package/scripts/conform.py +481 -0
- package/scripts/currency.py +118 -0
- package/scripts/fp-rate.py +145 -0
- package/scripts/gen-layer-applicability.py +157 -0
- package/scripts/scan.py +228 -0
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
audit-harness fp-rate — measure a gate's false-positive / false-negative rate over
|
|
4
|
+
a labeled corpus.
|
|
5
|
+
|
|
6
|
+
A new gate ships `enforcement: advisory`. It earns promotion to `blocking` only
|
|
7
|
+
once its measured false-positive rate (clean inputs it wrongly flags) sits below a
|
|
8
|
+
stated bar on a labeled corpus. This harness produces that measurement — the
|
|
9
|
+
evidence an engineer cites when they pin `enforcement: blocking` in a repo's
|
|
10
|
+
`tests/TESTING.md` (PP-PLAN-040 Phase 0, bead c2e; rule: docs/gate-promotion.md).
|
|
11
|
+
|
|
12
|
+
Labeled corpus layout (default `tests/fixtures/conform`):
|
|
13
|
+
<corpus>/valid/<fixture>/... → every gate that fires here SHOULD be clean
|
|
14
|
+
<corpus>/malformed/<fixture>/... → every gate that fires here SHOULD flag
|
|
15
|
+
|
|
16
|
+
Per row the gate emits on a fixture, the verdict is bucketed:
|
|
17
|
+
clean = PASS | NOT_APPLICABLE
|
|
18
|
+
flag = FAIL | ADVISORY(advisory_severity=error)
|
|
19
|
+
skip = ADVISORY indeterminate (tool/schema absent) — unmeasurable, excluded
|
|
20
|
+
|
|
21
|
+
false positive (FP) = a `valid` fixture the gate flagged
|
|
22
|
+
false negative (FN) = a `malformed` fixture the gate left clean
|
|
23
|
+
|
|
24
|
+
Stdlib only. Read-only. Default exit 0 (report); `--max-fp-rate X` exits 1 if any
|
|
25
|
+
gate exceeds the bar (use in CI when promoting a gate).
|
|
26
|
+
"""
|
|
27
|
+
import argparse
|
|
28
|
+
import json
|
|
29
|
+
import os
|
|
30
|
+
import subprocess
|
|
31
|
+
import sys
|
|
32
|
+
|
|
33
|
+
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
34
|
+
CONFORM = os.path.join(HERE, "conform.py")
|
|
35
|
+
DEFAULT_CORPUS = os.path.join(HERE, "..", "tests", "fixtures", "conform")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def verdict_bucket(row):
|
|
39
|
+
r = row.get("result")
|
|
40
|
+
if r in ("PASS", "NOT_APPLICABLE"):
|
|
41
|
+
return "clean"
|
|
42
|
+
if r == "FAIL":
|
|
43
|
+
return "flag"
|
|
44
|
+
if r == "ADVISORY":
|
|
45
|
+
if row.get("metadata", {}).get("indeterminate"):
|
|
46
|
+
return "skip"
|
|
47
|
+
if row.get("advisory_severity") == "error":
|
|
48
|
+
return "flag"
|
|
49
|
+
return "skip"
|
|
50
|
+
return "skip"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def run_conform(fixture):
|
|
54
|
+
out = subprocess.run([sys.executable, CONFORM, fixture], capture_output=True, text=True)
|
|
55
|
+
try:
|
|
56
|
+
return json.loads(out.stdout)
|
|
57
|
+
except Exception:
|
|
58
|
+
return []
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def measure(corpus):
|
|
62
|
+
# per gate_id: {valid_total, fp, malformed_total, fn, skipped}
|
|
63
|
+
stats = {}
|
|
64
|
+
|
|
65
|
+
def bump(gid, key):
|
|
66
|
+
s = stats.setdefault(gid, {"valid": 0, "fp": 0, "malformed": 0, "fn": 0, "skipped": 0})
|
|
67
|
+
s[key] += 1
|
|
68
|
+
|
|
69
|
+
for label in ("valid", "malformed"):
|
|
70
|
+
base = os.path.join(corpus, label)
|
|
71
|
+
if not os.path.isdir(base):
|
|
72
|
+
continue
|
|
73
|
+
for name in sorted(os.listdir(base)):
|
|
74
|
+
fixture = os.path.join(base, name)
|
|
75
|
+
if not os.path.isdir(fixture):
|
|
76
|
+
continue
|
|
77
|
+
for row in run_conform(fixture):
|
|
78
|
+
gid = row["gate_id"]
|
|
79
|
+
bucket = verdict_bucket(row)
|
|
80
|
+
if bucket == "skip":
|
|
81
|
+
bump(gid, "skipped")
|
|
82
|
+
continue
|
|
83
|
+
if label == "valid":
|
|
84
|
+
bump(gid, "valid")
|
|
85
|
+
if bucket == "flag":
|
|
86
|
+
bump(gid, "fp")
|
|
87
|
+
else:
|
|
88
|
+
bump(gid, "malformed")
|
|
89
|
+
if bucket == "clean":
|
|
90
|
+
bump(gid, "fn")
|
|
91
|
+
return stats
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def rate(n, d):
|
|
95
|
+
return (n / d) if d else 0.0
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def main():
|
|
99
|
+
ap = argparse.ArgumentParser(description="Measure gate FP/FN rate over a labeled corpus")
|
|
100
|
+
ap.add_argument("--corpus", default=DEFAULT_CORPUS, help="labeled corpus root (valid/ + malformed/)")
|
|
101
|
+
ap.add_argument("--json", action="store_true", help="emit JSON report to stdout")
|
|
102
|
+
ap.add_argument("--max-fp-rate", type=float, default=None,
|
|
103
|
+
help="exit 1 if any measured gate's FP-rate exceeds this (promotion gate)")
|
|
104
|
+
args = ap.parse_args()
|
|
105
|
+
|
|
106
|
+
corpus = os.path.abspath(args.corpus)
|
|
107
|
+
stats = measure(corpus)
|
|
108
|
+
|
|
109
|
+
report = {}
|
|
110
|
+
for gid, s in sorted(stats.items()):
|
|
111
|
+
report[gid] = {
|
|
112
|
+
"valid_samples": s["valid"],
|
|
113
|
+
"false_positives": s["fp"],
|
|
114
|
+
"fp_rate": round(rate(s["fp"], s["valid"]), 4),
|
|
115
|
+
"malformed_samples": s["malformed"],
|
|
116
|
+
"false_negatives": s["fn"],
|
|
117
|
+
"fn_rate": round(rate(s["fn"], s["malformed"]), 4),
|
|
118
|
+
"skipped_indeterminate": s["skipped"],
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if args.json:
|
|
122
|
+
print(json.dumps({"corpus": os.path.relpath(corpus, os.path.join(HERE, "..")),
|
|
123
|
+
"gates": report}, indent=2))
|
|
124
|
+
else:
|
|
125
|
+
print(f"FP/FN rate over corpus: {os.path.relpath(corpus, os.path.join(HERE, '..'))}")
|
|
126
|
+
print(f"{'gate_id':<42} {'valid':>5} {'FP':>3} {'FP%':>6} {'malf':>4} {'FN':>3} {'FN%':>6}")
|
|
127
|
+
for gid, r in report.items():
|
|
128
|
+
print(f"{gid:<42} {r['valid_samples']:>5} {r['false_positives']:>3} "
|
|
129
|
+
f"{r['fp_rate']*100:>5.1f}% {r['malformed_samples']:>4} "
|
|
130
|
+
f"{r['false_negatives']:>3} {r['fn_rate']*100:>5.1f}%")
|
|
131
|
+
if not report:
|
|
132
|
+
print(" (no measurable gate verdicts in corpus)")
|
|
133
|
+
|
|
134
|
+
if args.max_fp_rate is not None:
|
|
135
|
+
over = {g: r["fp_rate"] for g, r in report.items() if r["fp_rate"] > args.max_fp_rate}
|
|
136
|
+
if over:
|
|
137
|
+
sys.stderr.write(f"\nfp-rate: {len(over)} gate(s) exceed --max-fp-rate={args.max_fp_rate}:\n")
|
|
138
|
+
for g, fr in over.items():
|
|
139
|
+
sys.stderr.write(f" {g}: {fr:.4f}\n")
|
|
140
|
+
sys.exit(1)
|
|
141
|
+
sys.exit(0)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
if __name__ == "__main__":
|
|
145
|
+
main()
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
audit-harness gen-layer-applicability — project the canonical registry datum into
|
|
4
|
+
the human-readable layer-applicability matrix.
|
|
5
|
+
|
|
6
|
+
`schemas/audit-profile/registry.v1.json` is THE single source of truth for "which
|
|
7
|
+
gates apply to repo-type X, in which dimension, at what applicability". This
|
|
8
|
+
generator renders `schemas/audit-profile/layer-applicability.md` as a PROJECTION
|
|
9
|
+
of that datum so the doc can never silently drift from the registry the classifier
|
|
10
|
+
actually resolves against (PP-PLAN-040 Phase 0, bead c2b).
|
|
11
|
+
|
|
12
|
+
Modes:
|
|
13
|
+
(default) print the rendered markdown to stdout
|
|
14
|
+
--write write it to schemas/audit-profile/layer-applicability.md
|
|
15
|
+
--check regenerate in-memory and diff against the committed file;
|
|
16
|
+
exit 1 on drift (the CI `layer-applicability-drift` gate)
|
|
17
|
+
|
|
18
|
+
Stdlib only. Read-only except in --write mode (which only writes the one doc).
|
|
19
|
+
"""
|
|
20
|
+
import argparse
|
|
21
|
+
import difflib
|
|
22
|
+
import hashlib
|
|
23
|
+
import json
|
|
24
|
+
import os
|
|
25
|
+
import sys
|
|
26
|
+
|
|
27
|
+
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
28
|
+
REGISTRY = os.path.join(HERE, "..", "schemas", "audit-profile", "registry.v1.json")
|
|
29
|
+
DOC = os.path.join(HERE, "..", "schemas", "audit-profile", "layer-applicability.md")
|
|
30
|
+
|
|
31
|
+
GLYPH = {"required": "✅", "recommended": "⭕", "conditional": "⚠", "waived": "❌"}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def sha256_file(path):
|
|
35
|
+
h = hashlib.sha256()
|
|
36
|
+
with open(path, "rb") as f:
|
|
37
|
+
for chunk in iter(lambda: f.read(65536), b""):
|
|
38
|
+
h.update(chunk)
|
|
39
|
+
return "sha256:" + h.hexdigest()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def row(gate):
|
|
43
|
+
app = gate.get("applicability", "")
|
|
44
|
+
return "| `{gid}` | {dim} | {glyph} {app} | {enf} | {tool} |".format(
|
|
45
|
+
gid=gate["gate_id"],
|
|
46
|
+
dim=gate.get("dimension", ""),
|
|
47
|
+
glyph=GLYPH.get(app, ""),
|
|
48
|
+
app=app,
|
|
49
|
+
enf=gate.get("enforcement", "advisory"),
|
|
50
|
+
tool=("`" + gate["tool"] + "`") if gate.get("tool") else "—",
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def table(gates):
|
|
55
|
+
out = ["| Gate | Dimension | Applicability | Enforcement | Tool |",
|
|
56
|
+
"|---|---|---|---|---|"]
|
|
57
|
+
out += [row(g) for g in sorted(gates, key=lambda g: (g.get("dimension", ""), g["gate_id"]))]
|
|
58
|
+
return "\n".join(out)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def render(registry, registry_hash):
|
|
62
|
+
lines = []
|
|
63
|
+
a = lines.append
|
|
64
|
+
a("# Layer Applicability — GENERATED from `registry.v1.json`")
|
|
65
|
+
a("")
|
|
66
|
+
a("> ⚠️ **GENERATED FILE — do not edit by hand.**")
|
|
67
|
+
a("> Source of truth: [`registry.v1.json`](registry.v1.json) "
|
|
68
|
+
"(the canonical dimension→gate datum; `classify` resolves against it).")
|
|
69
|
+
a("> Regenerate: `audit-harness gen-layer-applicability --write` "
|
|
70
|
+
"(or `python3 scripts/gen-layer-applicability.py --write`).")
|
|
71
|
+
a("> CI gate `layer-applicability-drift` fails the build if this file drifts from the registry.")
|
|
72
|
+
a(">")
|
|
73
|
+
a(f"> registry `{registry_hash}`")
|
|
74
|
+
a("")
|
|
75
|
+
a(registry.get("description", "").strip())
|
|
76
|
+
a("")
|
|
77
|
+
a("**Legend (applicability):** "
|
|
78
|
+
+ " · ".join(f"{GLYPH[k]} {k}" for k in ("required", "recommended", "conditional", "waived")))
|
|
79
|
+
a("")
|
|
80
|
+
a("Every gate defaults to `enforcement: advisory`. Blocking is **earned** — "
|
|
81
|
+
"engineer-pinned in the target repo's `tests/TESTING.md`, FP-rate-gated "
|
|
82
|
+
"(see [`gate-promotion.md`](../../docs/gate-promotion.md)).")
|
|
83
|
+
a("")
|
|
84
|
+
a("## Base gates (apply to every repo)")
|
|
85
|
+
a("")
|
|
86
|
+
a(table(registry.get("base", [])))
|
|
87
|
+
a("")
|
|
88
|
+
a("## By classification")
|
|
89
|
+
a("")
|
|
90
|
+
a("A repo carries the **UNION** of every classification it matches "
|
|
91
|
+
"(`classify` never picks a single winner). Gates dedup by `gate_id`, "
|
|
92
|
+
"keeping the highest applicability.")
|
|
93
|
+
a("")
|
|
94
|
+
for kind in sorted(registry.get("classifications", {})):
|
|
95
|
+
a(f"### `{kind}`")
|
|
96
|
+
a("")
|
|
97
|
+
a(table(registry["classifications"][kind]))
|
|
98
|
+
a("")
|
|
99
|
+
overlays = registry.get("overlays", {})
|
|
100
|
+
if overlays:
|
|
101
|
+
a("## Overlays")
|
|
102
|
+
a("")
|
|
103
|
+
for name in sorted(overlays):
|
|
104
|
+
ov = overlays[name]
|
|
105
|
+
a(f"### `{name}`")
|
|
106
|
+
a("")
|
|
107
|
+
a(ov.get("description", "").strip())
|
|
108
|
+
promote = ov.get("promote_to_required", [])
|
|
109
|
+
if promote:
|
|
110
|
+
a("")
|
|
111
|
+
a("Promotes to **required**: " + ", ".join(f"`{d}`" for d in promote) + ".")
|
|
112
|
+
a("")
|
|
113
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def main():
|
|
117
|
+
ap = argparse.ArgumentParser(description="Project registry.v1.json -> layer-applicability.md")
|
|
118
|
+
ap.add_argument("--write", action="store_true", help="write the doc to its canonical path")
|
|
119
|
+
ap.add_argument("--check", action="store_true", help="fail (exit 1) if the committed doc drifts")
|
|
120
|
+
ap.add_argument("--registry", default=REGISTRY)
|
|
121
|
+
ap.add_argument("--out", default=DOC)
|
|
122
|
+
args = ap.parse_args()
|
|
123
|
+
|
|
124
|
+
registry_path = os.path.abspath(args.registry)
|
|
125
|
+
with open(registry_path, "r", encoding="utf-8") as f:
|
|
126
|
+
registry = json.load(f)
|
|
127
|
+
rendered = render(registry, sha256_file(registry_path))
|
|
128
|
+
|
|
129
|
+
if args.check:
|
|
130
|
+
try:
|
|
131
|
+
with open(args.out, "r", encoding="utf-8") as f:
|
|
132
|
+
current = f.read()
|
|
133
|
+
except FileNotFoundError:
|
|
134
|
+
print(f"gen-layer-applicability: {args.out} missing — run --write", file=sys.stderr)
|
|
135
|
+
sys.exit(1)
|
|
136
|
+
if current != rendered:
|
|
137
|
+
diff = difflib.unified_diff(
|
|
138
|
+
current.splitlines(True), rendered.splitlines(True),
|
|
139
|
+
fromfile="committed", tofile="generated",
|
|
140
|
+
)
|
|
141
|
+
sys.stderr.write("".join(diff))
|
|
142
|
+
sys.stderr.write("\ngen-layer-applicability: DRIFT — regenerate with --write\n")
|
|
143
|
+
sys.exit(1)
|
|
144
|
+
print("gen-layer-applicability: layer-applicability.md matches the registry datum")
|
|
145
|
+
sys.exit(0)
|
|
146
|
+
|
|
147
|
+
if args.write:
|
|
148
|
+
with open(args.out, "w", encoding="utf-8") as f:
|
|
149
|
+
f.write(rendered)
|
|
150
|
+
print(f"gen-layer-applicability: wrote {os.path.relpath(args.out, os.path.join(HERE, '..'))}")
|
|
151
|
+
sys.exit(0)
|
|
152
|
+
|
|
153
|
+
sys.stdout.write(rendered)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
if __name__ == "__main__":
|
|
157
|
+
main()
|
package/scripts/scan.py
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
audit-harness scan — read-only security / hygiene / skill-quality gate-runner
|
|
4
|
+
(PP-PLAN-040 Phase 4 / E6).
|
|
5
|
+
|
|
6
|
+
For every `dimension: security | hygiene | skill-quality` gate in a repo's
|
|
7
|
+
audit-profile/v1, scan runs the right external tool with the repo present and wraps
|
|
8
|
+
its exit code into a `gate-result/v1` row (JSON array, stdout). Advisory-first; a
|
|
9
|
+
missing tool degrades to ADVISORY indeterminate (never a false FAIL). It NEVER
|
|
10
|
+
fixes anything and NEVER reimplements a scanner.
|
|
11
|
+
|
|
12
|
+
Strategies:
|
|
13
|
+
- local hygiene-readme: deterministic README presence check (no tool).
|
|
14
|
+
- shell-out every gate carrying a `tool` (gitleaks, osv-scanner, semgrep, syft,
|
|
15
|
+
markdownlint, lychee, ...): run it if on PATH; clean exit -> PASS;
|
|
16
|
+
findings -> ADVISORY(error) (or FAIL under --strict / blocking);
|
|
17
|
+
tool absent -> ADVISORY indeterminate.
|
|
18
|
+
- consume skill-quality skill-behavioral (tool j-rig): CONSUME a j-rig
|
|
19
|
+
Evidence Bundle verdict row (--jrig-verdict PATH or a default
|
|
20
|
+
location). The harness does NOT run behavioral judgment itself —
|
|
21
|
+
it ingests j-rig's verdict. No verdict -> ADVISORY indeterminate.
|
|
22
|
+
|
|
23
|
+
Stdlib only. No network beyond whatever the shelled-out tool does (and the only
|
|
24
|
+
network-touching gates fail open to indeterminate). No filesystem mutation.
|
|
25
|
+
"""
|
|
26
|
+
import argparse
|
|
27
|
+
import hashlib
|
|
28
|
+
import json
|
|
29
|
+
import os
|
|
30
|
+
import shutil
|
|
31
|
+
import subprocess
|
|
32
|
+
import sys
|
|
33
|
+
from datetime import datetime, timezone
|
|
34
|
+
|
|
35
|
+
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
36
|
+
if HERE not in sys.path:
|
|
37
|
+
sys.path.insert(0, HERE)
|
|
38
|
+
import classify as C # noqa: E402
|
|
39
|
+
|
|
40
|
+
EMPTY_SHA = "sha256:" + hashlib.sha256(b"").hexdigest()
|
|
41
|
+
SCAN_DIMENSIONS = {"security", "hygiene", "skill-quality"}
|
|
42
|
+
|
|
43
|
+
# tool -> argv (run with cwd=repo). "generation" tools (syft) are PASS on exit 0,
|
|
44
|
+
# INDETERMINATE on failure (they produce an artifact, they don't pass/fail policy).
|
|
45
|
+
TOOL_CMD = {
|
|
46
|
+
"gitleaks": (["gitleaks", "detect", "--no-banner"], "scan"),
|
|
47
|
+
"osv-scanner": (["osv-scanner", "-r", "."], "scan"),
|
|
48
|
+
"semgrep": (["semgrep", "scan", "--error", "--quiet"], "scan"),
|
|
49
|
+
"syft": (["syft", "."], "generation"),
|
|
50
|
+
"markdownlint": (["markdownlint", "."], "scan"),
|
|
51
|
+
"lychee": (["lychee", "--offline", "--no-progress", "."], "scan"),
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def sha256_str(s):
|
|
56
|
+
return "sha256:" + hashlib.sha256(s.encode("utf-8")).hexdigest()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def make_row(gate_id, result, *, policy_hash, input_hash, commit_sha, runner,
|
|
60
|
+
metadata=None, failure_mode=None, advisory_severity=None):
|
|
61
|
+
row = {
|
|
62
|
+
"gate_id": gate_id, "result": result, "policy_hash": policy_hash,
|
|
63
|
+
"input_hash": input_hash,
|
|
64
|
+
"timestamp": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
65
|
+
"runner": runner, "commit_sha": commit_sha,
|
|
66
|
+
}
|
|
67
|
+
if metadata:
|
|
68
|
+
row["metadata"] = metadata
|
|
69
|
+
if failure_mode is not None:
|
|
70
|
+
row["failure_mode"] = failure_mode
|
|
71
|
+
if advisory_severity is not None:
|
|
72
|
+
row["advisory_severity"] = advisory_severity
|
|
73
|
+
return row
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def gate_suffix(gate_id):
|
|
77
|
+
return gate_id.rsplit(":", 1)[-1]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def indeterminate(gate, commit_sha, runner, reason, policy):
|
|
81
|
+
return make_row(gate["gate_id"], "ADVISORY", policy_hash=sha256_str(policy),
|
|
82
|
+
input_hash=EMPTY_SHA, commit_sha=commit_sha, runner=runner,
|
|
83
|
+
advisory_severity="warn",
|
|
84
|
+
metadata={"indeterminate": True, "reason": reason})
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def run_readme(repo, gate, commit_sha, runner, strict):
|
|
88
|
+
enforcement = gate.get("enforcement", "advisory")
|
|
89
|
+
present = any(os.path.isfile(os.path.join(repo, n))
|
|
90
|
+
for n in ("README.md", "README.rst", "README.txt", "README"))
|
|
91
|
+
if present:
|
|
92
|
+
return make_row(gate["gate_id"], "PASS", policy_hash=sha256_str("hygiene:readme"),
|
|
93
|
+
input_hash=EMPTY_SHA, commit_sha=commit_sha, runner=runner,
|
|
94
|
+
metadata={"method": "local-presence", "signal": "README present"})
|
|
95
|
+
result, fm, sev = ("FAIL", "hygiene:readme-missing", None) if (strict or enforcement == "blocking") \
|
|
96
|
+
else ("ADVISORY", None, "warn")
|
|
97
|
+
return make_row(gate["gate_id"], result, policy_hash=sha256_str("hygiene:readme"),
|
|
98
|
+
input_hash=EMPTY_SHA, commit_sha=commit_sha, runner=runner,
|
|
99
|
+
failure_mode=fm, advisory_severity=sev,
|
|
100
|
+
metadata={"method": "local-presence", "reason": "no README found"})
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def run_tool(tool, repo, gate, commit_sha, runner, strict):
|
|
104
|
+
enforcement = gate.get("enforcement", "advisory")
|
|
105
|
+
policy = f"tool:{tool}"
|
|
106
|
+
if tool not in TOOL_CMD:
|
|
107
|
+
return indeterminate(gate, commit_sha, runner,
|
|
108
|
+
f"no invocation wired for tool '{tool}'", policy)
|
|
109
|
+
if shutil.which(tool) is None:
|
|
110
|
+
return indeterminate(gate, commit_sha, runner,
|
|
111
|
+
f"{tool} not on PATH — {gate.get('dimension')} unmeasured", policy)
|
|
112
|
+
argv, kind = TOOL_CMD[tool]
|
|
113
|
+
try:
|
|
114
|
+
proc = subprocess.run(argv, cwd=repo, capture_output=True, text=True, timeout=300)
|
|
115
|
+
except Exception as e:
|
|
116
|
+
return indeterminate(gate, commit_sha, runner, f"{tool} failed to run: {e}", policy)
|
|
117
|
+
if proc.returncode == 0:
|
|
118
|
+
return make_row(gate["gate_id"], "PASS", policy_hash=sha256_str(policy),
|
|
119
|
+
input_hash=EMPTY_SHA, commit_sha=commit_sha, runner=runner,
|
|
120
|
+
metadata={"method": "shell-out", "tool": tool})
|
|
121
|
+
if kind == "generation":
|
|
122
|
+
# syft etc. failing to generate is infra, not a policy violation
|
|
123
|
+
return indeterminate(gate, commit_sha, runner,
|
|
124
|
+
f"{tool} could not generate artifact (exit {proc.returncode})", policy)
|
|
125
|
+
detail = (proc.stdout or proc.stderr).strip()[:2000]
|
|
126
|
+
result, fm, sev = ("FAIL", f"scan:{tool}-findings", None) if (strict or enforcement == "blocking") \
|
|
127
|
+
else ("ADVISORY", None, "error")
|
|
128
|
+
return make_row(gate["gate_id"], result, policy_hash=sha256_str(policy),
|
|
129
|
+
input_hash=EMPTY_SHA, commit_sha=commit_sha, runner=runner,
|
|
130
|
+
failure_mode=fm, advisory_severity=sev,
|
|
131
|
+
metadata={"method": "shell-out", "tool": tool, "detail": detail})
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def consume_jrig(repo, gate, commit_sha, runner, strict, verdict_path):
|
|
135
|
+
"""Ingest a j-rig Evidence Bundle verdict row — never run judgment here."""
|
|
136
|
+
policy = "consume:j-rig"
|
|
137
|
+
candidates = [verdict_path] if verdict_path else []
|
|
138
|
+
candidates += [os.path.join(repo, p) for p in
|
|
139
|
+
(".j-rig/verdict.json", ".jrig/verdict.json", "j-rig-verdict.json")]
|
|
140
|
+
path = next((p for p in candidates if p and os.path.isfile(p)), None)
|
|
141
|
+
if path is None:
|
|
142
|
+
return indeterminate(gate, commit_sha, runner,
|
|
143
|
+
"no j-rig verdict available — run j-rig eval and pass --jrig-verdict",
|
|
144
|
+
policy)
|
|
145
|
+
verdict = C.read_json(path)
|
|
146
|
+
if not isinstance(verdict, dict):
|
|
147
|
+
return indeterminate(gate, commit_sha, runner, f"unreadable j-rig verdict at {path}", policy)
|
|
148
|
+
# Pass through j-rig's own result if present; otherwise interpret a boolean pass.
|
|
149
|
+
enforcement = gate.get("enforcement", "advisory")
|
|
150
|
+
jres = verdict.get("result") or ("PASS" if verdict.get("passed") else "FAIL")
|
|
151
|
+
meta = {"method": "consume-j-rig", "source": os.path.relpath(path, repo),
|
|
152
|
+
"jrig": {k: verdict.get(k) for k in ("result", "passed", "layers_passed", "baseline_delta")
|
|
153
|
+
if k in verdict}}
|
|
154
|
+
if jres == "PASS":
|
|
155
|
+
return make_row(gate["gate_id"], "PASS", policy_hash=sha256_str(policy),
|
|
156
|
+
input_hash=EMPTY_SHA, commit_sha=commit_sha, runner=runner, metadata=meta)
|
|
157
|
+
result, fm, sev = ("FAIL", "skill-quality:jrig-fail", None) if (strict or enforcement == "blocking") \
|
|
158
|
+
else ("ADVISORY", None, "error")
|
|
159
|
+
return make_row(gate["gate_id"], result, policy_hash=sha256_str(policy),
|
|
160
|
+
input_hash=EMPTY_SHA, commit_sha=commit_sha, runner=runner,
|
|
161
|
+
failure_mode=fm, advisory_severity=sev, metadata=meta)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def compute_profile(repo, registry_path, profile_arg):
|
|
165
|
+
if profile_arg == "-":
|
|
166
|
+
return json.load(sys.stdin)
|
|
167
|
+
if profile_arg:
|
|
168
|
+
with open(profile_arg, "r", encoding="utf-8") as f:
|
|
169
|
+
return json.load(f)
|
|
170
|
+
out = subprocess.run([sys.executable, os.path.join(HERE, "classify.py"), repo,
|
|
171
|
+
"--registry", registry_path], capture_output=True, text=True)
|
|
172
|
+
if out.returncode != 0:
|
|
173
|
+
sys.stderr.write(out.stderr)
|
|
174
|
+
raise SystemExit(2)
|
|
175
|
+
return json.loads(out.stdout)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def main():
|
|
179
|
+
ap = argparse.ArgumentParser(description="Security/hygiene/skill-quality gate-runner -> gate-result/v1")
|
|
180
|
+
ap.add_argument("repo", nargs="?", default=".")
|
|
181
|
+
ap.add_argument("--strict", action="store_true", help="treat a finding/gap as FAIL (exit 1)")
|
|
182
|
+
ap.add_argument("--registry", default=C.DEFAULT_REGISTRY)
|
|
183
|
+
ap.add_argument("--profile", default=None, help="pinned audit-profile/v1 (PATH or '-')")
|
|
184
|
+
ap.add_argument("--jrig-verdict", default=None, help="path to a j-rig Evidence Bundle verdict to consume")
|
|
185
|
+
args = ap.parse_args()
|
|
186
|
+
|
|
187
|
+
repo = os.path.abspath(args.repo)
|
|
188
|
+
runner = f"audit-harness@{C.harness_version()}"
|
|
189
|
+
|
|
190
|
+
override_path = os.path.join(repo, ".audit-harness.yml")
|
|
191
|
+
override = C.parse_override(override_path) if os.path.isfile(override_path) else {"disable": False}
|
|
192
|
+
if override.get("disable") or os.environ.get("AUDIT_HARNESS_DISABLE") == "1":
|
|
193
|
+
sys.stderr.write("audit-harness: KILL-SWITCH active — scan skipped (no rows emitted)\n")
|
|
194
|
+
print("[]")
|
|
195
|
+
sys.exit(0)
|
|
196
|
+
|
|
197
|
+
profile = compute_profile(repo, os.path.abspath(args.registry), args.profile)
|
|
198
|
+
commit_sha = profile.get("subject", {}).get("commit_sha") or C.git_short_sha(repo)
|
|
199
|
+
|
|
200
|
+
gates = [g for g in profile.get("gates", [])
|
|
201
|
+
if g.get("dimension") in SCAN_DIMENSIONS and g.get("enforcement") != "disabled"]
|
|
202
|
+
|
|
203
|
+
rows = []
|
|
204
|
+
for gate in gates:
|
|
205
|
+
suffix = gate_suffix(gate["gate_id"])
|
|
206
|
+
tool = gate.get("tool")
|
|
207
|
+
if suffix == "hygiene-readme":
|
|
208
|
+
rows.append(run_readme(repo, gate, commit_sha, runner, args.strict))
|
|
209
|
+
elif tool == "j-rig":
|
|
210
|
+
rows.append(consume_jrig(repo, gate, commit_sha, runner, args.strict, args.jrig_verdict))
|
|
211
|
+
elif tool:
|
|
212
|
+
rows.append(run_tool(tool, repo, gate, commit_sha, runner, args.strict))
|
|
213
|
+
else:
|
|
214
|
+
rows.append(indeterminate(gate, commit_sha, runner,
|
|
215
|
+
f"gate '{suffix}' has no tool wired in this harness version",
|
|
216
|
+
f"scan:{suffix}"))
|
|
217
|
+
|
|
218
|
+
print(json.dumps(rows, indent=2))
|
|
219
|
+
n_fail = sum(1 for r in rows if r["result"] == "FAIL")
|
|
220
|
+
n_adv = sum(1 for r in rows if r["result"] == "ADVISORY")
|
|
221
|
+
n_pass = sum(1 for r in rows if r["result"] == "PASS")
|
|
222
|
+
sys.stderr.write(f"audit-harness scan: {n_pass} PASS, {n_adv} ADVISORY, {n_fail} FAIL "
|
|
223
|
+
f"across {len(rows)} gate(s)\n")
|
|
224
|
+
sys.exit(1 if n_fail else 0)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
if __name__ == "__main__":
|
|
228
|
+
main()
|