@intentsolutions/audit-harness 1.1.7 → 1.1.8
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 +40 -0
- package/bin/audit-harness.js +6 -5
- package/package.json +8 -1
- package/schemas/currency/pins.v1.json +164 -22
- package/scripts/arch-check.sh +20 -2
- package/scripts/bias-count.sh +18 -1
- package/scripts/caa-check.sh +143 -0
- package/scripts/crap-score.py +57 -6
- package/scripts/currency.py +70 -25
- package/scripts/dnssec-check.sh +158 -0
- package/scripts/emit-evidence.sh +79 -9
- package/scripts/escape-scan.sh +28 -3
- package/scripts/gherkin-lint.sh +5 -0
- package/scripts/harness-hash.sh +5 -0
- package/scripts/kernel-shadow-check.sh +132 -0
package/scripts/crap-score.py
CHANGED
|
@@ -50,6 +50,22 @@ EXCLUDED_DIRS = {
|
|
|
50
50
|
}
|
|
51
51
|
|
|
52
52
|
|
|
53
|
+
def is_excluded_dir(name: str) -> bool:
|
|
54
|
+
"""Single exclusion predicate shared by the candidate-discovery walk and
|
|
55
|
+
the --json input-hash walk.
|
|
56
|
+
|
|
57
|
+
Both walks MUST agree on which directories they descend into; otherwise the
|
|
58
|
+
set of files that feed the CRAP score can diverge from the set that feeds
|
|
59
|
+
the input_hash, and the score/hash desync (a hash that claims to cover
|
|
60
|
+
files the score never saw, or vice versa). The rule is: skip any dot-dir
|
|
61
|
+
(e.g. `.idea`, `.svn`, `.git`) OR any explicitly-named build/vendor dir in
|
|
62
|
+
EXCLUDED_DIRS. Previously discovery dropped all dot-dirs while the hash walk
|
|
63
|
+
dropped only the named subset, so a dot-dir not in EXCLUDED_DIRS was hashed
|
|
64
|
+
but never scored.
|
|
65
|
+
"""
|
|
66
|
+
return name.startswith(".") or name in EXCLUDED_DIRS
|
|
67
|
+
|
|
68
|
+
|
|
53
69
|
def crap(complexity: int, coverage_pct: float) -> float:
|
|
54
70
|
cov = max(0.0, min(100.0, coverage_pct)) / 100.0
|
|
55
71
|
return (complexity ** 2) * ((1.0 - cov) ** 3) + complexity
|
|
@@ -98,8 +114,7 @@ def score_python(root: Path, kind: str) -> list[MethodScore]:
|
|
|
98
114
|
scanned = [
|
|
99
115
|
p.name for p in root.iterdir()
|
|
100
116
|
if p.is_dir()
|
|
101
|
-
and not p.name
|
|
102
|
-
and p.name not in EXCLUDED_DIRS
|
|
117
|
+
and not is_excluded_dir(p.name)
|
|
103
118
|
and p.name not in test_dirs
|
|
104
119
|
and any(p.rglob("*.py"))
|
|
105
120
|
]
|
|
@@ -165,7 +180,15 @@ def score_go(root: Path, kind: str) -> list[MethodScore]:
|
|
|
165
180
|
print("[crap-score] gocyclo not installed", file=sys.stderr)
|
|
166
181
|
return []
|
|
167
182
|
|
|
168
|
-
|
|
183
|
+
# For kind="src", ignore *_test.go at the gocyclo level. For kind="test",
|
|
184
|
+
# do NOT pass -ignore: a pattern like `.*\.go$` matches every analyzable
|
|
185
|
+
# file (gocyclo only reads .go files), which silenced all test-kind output.
|
|
186
|
+
# The include-filter below keeps only *_test.go rows for kind="test".
|
|
187
|
+
gocyclo_cmd = ["gocyclo"]
|
|
188
|
+
if kind == "src":
|
|
189
|
+
gocyclo_cmd += ["-ignore", "_test.go"]
|
|
190
|
+
gocyclo_cmd.append(".")
|
|
191
|
+
rc, out, _ = run(gocyclo_cmd, root)
|
|
169
192
|
complexity: list[tuple[str, str, int]] = []
|
|
170
193
|
for line in out.splitlines():
|
|
171
194
|
parts = line.strip().split()
|
|
@@ -187,11 +210,28 @@ def score_go(root: Path, kind: str) -> list[MethodScore]:
|
|
|
187
210
|
if not cov_out.is_file() and which_or_none("go"):
|
|
188
211
|
run(["go", "test", "-coverprofile=coverage.out", "-covermode=atomic", "./..."], root)
|
|
189
212
|
if cov_out.is_file() and which_or_none("go"):
|
|
213
|
+
# `go tool cover -func` reports module-qualified paths
|
|
214
|
+
# (github.com/user/repo/pkg/file.go) while gocyclo reports repo-relative
|
|
215
|
+
# paths (pkg/file.go). Strip the module prefix read from go.mod so the
|
|
216
|
+
# coverage keys join the complexity keys.
|
|
217
|
+
module_prefix = ""
|
|
218
|
+
go_mod = root / "go.mod"
|
|
219
|
+
if go_mod.is_file():
|
|
220
|
+
try:
|
|
221
|
+
for mod_line in go_mod.read_text().splitlines():
|
|
222
|
+
mod_line = mod_line.strip()
|
|
223
|
+
if mod_line.startswith("module ") or mod_line.startswith("module\t"):
|
|
224
|
+
module_prefix = mod_line.split(None, 1)[1].strip() + "/"
|
|
225
|
+
break
|
|
226
|
+
except OSError:
|
|
227
|
+
pass
|
|
190
228
|
rc, out, _ = run(["go", "tool", "cover", "-func=coverage.out"], root)
|
|
191
229
|
for line in out.splitlines():
|
|
192
230
|
parts = line.split()
|
|
193
231
|
if len(parts) >= 3 and parts[-1].endswith("%"):
|
|
194
232
|
fpath = parts[0].split(":", 1)[0]
|
|
233
|
+
if module_prefix and fpath.startswith(module_prefix):
|
|
234
|
+
fpath = fpath[len(module_prefix):]
|
|
195
235
|
try:
|
|
196
236
|
pct = float(parts[-1].rstrip("%"))
|
|
197
237
|
except ValueError:
|
|
@@ -228,6 +268,17 @@ def score_js(root: Path, kind: str) -> list[MethodScore]:
|
|
|
228
268
|
except json.JSONDecodeError:
|
|
229
269
|
return []
|
|
230
270
|
|
|
271
|
+
# c8/istanbul's json-summary reporter keys files by ABSOLUTE path while
|
|
272
|
+
# complexity-report (run with a repo-relative target) reports repo-relative
|
|
273
|
+
# paths. Normalize both sides to repo-relative so the coverage join works.
|
|
274
|
+
def _rel_to_root(p: str) -> str:
|
|
275
|
+
if os.path.isabs(p):
|
|
276
|
+
try:
|
|
277
|
+
return os.path.relpath(p, str(root))
|
|
278
|
+
except ValueError:
|
|
279
|
+
return p # e.g. different drive on Windows — keep as-is
|
|
280
|
+
return p
|
|
281
|
+
|
|
231
282
|
cov_path = root / "coverage" / "coverage-summary.json"
|
|
232
283
|
coverage: dict[str, float] = {}
|
|
233
284
|
if cov_path.is_file():
|
|
@@ -237,14 +288,14 @@ def score_js(root: Path, kind: str) -> list[MethodScore]:
|
|
|
237
288
|
if fpath == "total":
|
|
238
289
|
continue
|
|
239
290
|
lines_pct = summary.get("lines", {}).get("pct", 0.0)
|
|
240
|
-
coverage[fpath] = float(lines_pct)
|
|
291
|
+
coverage[_rel_to_root(fpath)] = float(lines_pct)
|
|
241
292
|
except (OSError, json.JSONDecodeError):
|
|
242
293
|
pass
|
|
243
294
|
|
|
244
295
|
scores: list[MethodScore] = []
|
|
245
296
|
for report in data.get("reports", []):
|
|
246
297
|
fpath = report.get("path", "")
|
|
247
|
-
cov = coverage.get(fpath, 0.0)
|
|
298
|
+
cov = coverage.get(_rel_to_root(fpath), 0.0)
|
|
248
299
|
for func in report.get("functions", []):
|
|
249
300
|
c = int(func.get("cyclomatic", 1))
|
|
250
301
|
scores.append(
|
|
@@ -403,7 +454,7 @@ def main() -> int:
|
|
|
403
454
|
exts = (".py", ".ts", ".tsx", ".js", ".jsx", ".go", ".rs", ".java", ".kt", ".cs", ".php", ".rb")
|
|
404
455
|
collected: list[Path] = []
|
|
405
456
|
for dirpath, dirs, files in os.walk(root):
|
|
406
|
-
dirs[:] = [d for d in dirs if
|
|
457
|
+
dirs[:] = [d for d in dirs if not is_excluded_dir(d)]
|
|
407
458
|
for fn in files:
|
|
408
459
|
if fn.endswith(exts):
|
|
409
460
|
collected.append(Path(dirpath) / fn)
|
package/scripts/currency.py
CHANGED
|
@@ -1,18 +1,26 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
"""
|
|
3
|
-
audit-harness currency — advisory
|
|
3
|
+
audit-harness currency — advisory poll-freshness report (PP-PLAN-040 Phase 5 / E7).
|
|
4
4
|
|
|
5
5
|
Currency depends on upstream state, which is non-deterministic and network-bound, so
|
|
6
6
|
it is deliberately the WEAKEST kind of check: an advisory REPORT with **no exit-code
|
|
7
7
|
authority, no auto-fix, and no live-fetch**. It reads the per-upstream-identity pin
|
|
8
8
|
relation (schemas/currency/pins.v1.json) — where each upstream carries its own
|
|
9
|
-
pinned_version + the date it was last verified (checked_at) +
|
|
10
|
-
and reports which pins are themselves
|
|
11
|
-
which pins a human should re-verify
|
|
9
|
+
pinned_version + the date it was last verified (checked_at) + an advisory
|
|
10
|
+
poll-freshness SLA — and reports which pins are themselves PAST their SLA
|
|
11
|
+
(checked_at older than the SLA window), i.e. which pins a human should re-verify
|
|
12
|
+
against upstream. The SLA gates NOTHING except human attention.
|
|
12
13
|
|
|
13
14
|
This models the pin's OWN staleness as detectable, rather than one opaque
|
|
14
|
-
".schema-version" scalar.
|
|
15
|
-
|
|
15
|
+
".schema-version" scalar. Pins are grouped by class (spec-page / schema-file /
|
|
16
|
+
release-feed / internal-contract); SLA resolution order is: explicit per-pin
|
|
17
|
+
staleness_window_days > the pin's class SLA > default_staleness_window_days.
|
|
18
|
+
The /sync-testing-harness skill consumes this report to open advisory bump PRs;
|
|
19
|
+
the report never reddens a build (always exit 0).
|
|
20
|
+
|
|
21
|
+
Follow-up (deliberately NOT wired here, [9k5h.10]): the intent-eval-lab
|
|
22
|
+
detector-health surface will consume the --json output; that cross-repo
|
|
23
|
+
integration is tracked separately.
|
|
16
24
|
|
|
17
25
|
Stdlib only. No network. No filesystem mutation.
|
|
18
26
|
"""
|
|
@@ -25,6 +33,8 @@ from datetime import datetime, timezone
|
|
|
25
33
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
26
34
|
DEFAULT_PINS = os.path.join(HERE, "..", "schemas", "currency", "pins.v1.json")
|
|
27
35
|
|
|
36
|
+
UNCLASSED = "(unclassed)"
|
|
37
|
+
|
|
28
38
|
|
|
29
39
|
def parse_date(s):
|
|
30
40
|
try:
|
|
@@ -33,12 +43,23 @@ def parse_date(s):
|
|
|
33
43
|
return None
|
|
34
44
|
|
|
35
45
|
|
|
46
|
+
def resolve_window(pin, classes, default_window):
|
|
47
|
+
"""SLA resolution: explicit per-pin window > class SLA > file default."""
|
|
48
|
+
if pin.get("staleness_window_days") is not None:
|
|
49
|
+
return pin["staleness_window_days"]
|
|
50
|
+
cls = classes.get(pin.get("class") or "", {})
|
|
51
|
+
if cls.get("staleness_window_days") is not None:
|
|
52
|
+
return cls["staleness_window_days"]
|
|
53
|
+
return default_window
|
|
54
|
+
|
|
55
|
+
|
|
36
56
|
def build_report(pins_doc, today):
|
|
37
57
|
default_window = pins_doc.get("default_staleness_window_days", 90)
|
|
58
|
+
classes = pins_doc.get("staleness_classes", {})
|
|
38
59
|
out = []
|
|
39
60
|
for pin in pins_doc.get("pins", []):
|
|
40
61
|
checked = parse_date(pin.get("checked_at", ""))
|
|
41
|
-
window = pin
|
|
62
|
+
window = resolve_window(pin, classes, default_window)
|
|
42
63
|
if checked is None:
|
|
43
64
|
age, status = None, "unknown-checked_at"
|
|
44
65
|
else:
|
|
@@ -46,6 +67,7 @@ def build_report(pins_doc, today):
|
|
|
46
67
|
status = "stale" if age > window else "current"
|
|
47
68
|
out.append({
|
|
48
69
|
"identity": pin.get("identity"),
|
|
70
|
+
"class": pin.get("class") or UNCLASSED,
|
|
49
71
|
"pinned_version": pin.get("pinned_version"),
|
|
50
72
|
"checked_at": pin.get("checked_at"),
|
|
51
73
|
"age_days": age,
|
|
@@ -57,8 +79,18 @@ def build_report(pins_doc, today):
|
|
|
57
79
|
return out
|
|
58
80
|
|
|
59
81
|
|
|
82
|
+
def group_by_class(report):
|
|
83
|
+
"""Ordered {class: [rows]} grouping, classes sorted, (unclassed) last."""
|
|
84
|
+
grouped = {}
|
|
85
|
+
for r in report:
|
|
86
|
+
grouped.setdefault(r["class"], []).append(r)
|
|
87
|
+
ordered = sorted(grouped, key=lambda c: (c == UNCLASSED, c))
|
|
88
|
+
return {c: grouped[c] for c in ordered}
|
|
89
|
+
|
|
90
|
+
|
|
60
91
|
def main():
|
|
61
|
-
ap = argparse.ArgumentParser(
|
|
92
|
+
ap = argparse.ArgumentParser(
|
|
93
|
+
description="Advisory poll-freshness report (no exit authority — the SLA gates nothing but human attention)")
|
|
62
94
|
ap.add_argument("--pins", default=DEFAULT_PINS, help="path to the pin relation datum")
|
|
63
95
|
ap.add_argument("--json", action="store_true", help="emit JSON report")
|
|
64
96
|
ap.add_argument("--today", default=None, help="override 'today' (YYYY-MM-DD) for reproducible reports/tests")
|
|
@@ -74,39 +106,52 @@ def main():
|
|
|
74
106
|
|
|
75
107
|
today = parse_date(args.today) if args.today else datetime.now(timezone.utc).date()
|
|
76
108
|
report = build_report(pins_doc, today)
|
|
109
|
+
grouped = group_by_class(report)
|
|
77
110
|
stale = [r for r in report if r["status"] == "stale"]
|
|
78
111
|
unknown = [r for r in report if r["status"] == "unknown-checked_at"]
|
|
79
112
|
|
|
80
113
|
if args.json:
|
|
114
|
+
by_class = {}
|
|
115
|
+
for cls, rows in grouped.items():
|
|
116
|
+
by_class[cls] = {
|
|
117
|
+
"total": len(rows),
|
|
118
|
+
"stale": sum(1 for r in rows if r["status"] == "stale"),
|
|
119
|
+
"current": sum(1 for r in rows if r["status"] == "current"),
|
|
120
|
+
"unknown": sum(1 for r in rows if r["status"] == "unknown-checked_at"),
|
|
121
|
+
}
|
|
81
122
|
print(json.dumps({
|
|
82
123
|
"report": "currency/v1",
|
|
83
124
|
"generated_for": today.strftime("%Y-%m-%d"),
|
|
84
125
|
"pins": report,
|
|
126
|
+
"by_class": by_class,
|
|
85
127
|
"stale_count": len(stale),
|
|
86
128
|
"advisory": True,
|
|
87
129
|
}, indent=2))
|
|
88
130
|
else:
|
|
89
|
-
print(f"Upstream currency
|
|
90
|
-
print(f"{'identity':<
|
|
91
|
-
for
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
131
|
+
print(f"Upstream currency — advisory poll-freshness SLA report — as of {today.strftime('%Y-%m-%d')}")
|
|
132
|
+
print(f"{'identity':<26} {'pinned':<18} {'checked_at':<12} {'age':>5} {'sla':>4} status")
|
|
133
|
+
for cls, rows in grouped.items():
|
|
134
|
+
print(f"[{cls}] — {len(rows)} pin(s)")
|
|
135
|
+
for r in rows:
|
|
136
|
+
age = "—" if r["age_days"] is None else str(r["age_days"]) + "d"
|
|
137
|
+
if r["status"] == "stale":
|
|
138
|
+
mark = "⚠ PAST SLA"
|
|
139
|
+
elif r["status"] == "current":
|
|
140
|
+
mark = "current"
|
|
141
|
+
else:
|
|
142
|
+
mark = "? " + r["status"]
|
|
143
|
+
print(f" {(r['identity'] or ''):<24} {(r['pinned_version'] or ''):<18} "
|
|
144
|
+
f"{(r['checked_at'] or ''):<12} {age:>5} {r['window_days']:>4} {mark}")
|
|
101
145
|
print()
|
|
102
146
|
if stale:
|
|
103
|
-
print(f"{len(stale)} pin(s) past their
|
|
104
|
-
f"then bump pinned_version + checked_at in
|
|
147
|
+
print(f"{len(stale)} pin(s) past their poll-freshness SLA — the SLA gates nothing but human "
|
|
148
|
+
f"attention: re-verify against upstream, then bump pinned_version + checked_at in "
|
|
149
|
+
f"schemas/currency/pins.v1.json:")
|
|
105
150
|
for r in stale:
|
|
106
|
-
print(f" - {r['identity']}: last checked {r['checked_at']} "
|
|
107
|
-
f"({r['age_days']}d ago > {r['window_days']}d)")
|
|
151
|
+
print(f" - {r['identity']} [{r['class']}]: last checked {r['checked_at']} "
|
|
152
|
+
f"({r['age_days']}d ago > {r['window_days']}d SLA)")
|
|
108
153
|
else:
|
|
109
|
-
print("All pins within their
|
|
154
|
+
print("All pins within their poll-freshness SLA.")
|
|
110
155
|
if unknown:
|
|
111
156
|
print(f"{len(unknown)} pin(s) have an unparseable checked_at — fix the date format (YYYY-MM-DD).")
|
|
112
157
|
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# dnssec-check.sh — verify a namespace is DNSSEC-signed before a production
|
|
3
|
+
# signed attestation is anchored against it.
|
|
4
|
+
#
|
|
5
|
+
# WHY THIS EXISTS (CISO binding, DR-010 Q5 / ISEDC v1 Q1 2026-05-10):
|
|
6
|
+
# Predicate URIs for the Evidence Bundle live ONLY at evals.intentsolutions.io.
|
|
7
|
+
# Pushing a signed in-toto Statement to a PUBLIC transparency log (Rekor)
|
|
8
|
+
# against an unsigned namespace is irreversible and lets an attacker who can
|
|
9
|
+
# spoof the zone mint look-alike attestations. DNSSEC must be verified on the
|
|
10
|
+
# namespace BEFORE the first production attestation. This script is that gate.
|
|
11
|
+
# It anchors NOTHING — it is a read-only verification that can only make
|
|
12
|
+
# signing MORE conservative (fail-closed).
|
|
13
|
+
#
|
|
14
|
+
# WHY IT QUERIES AN EXPLICIT RESOLVER (the bug this version fixes):
|
|
15
|
+
# Querying the LOCAL STUB RESOLVER (plain `dig`, no `@server`) FALSE-NEGATIVES
|
|
16
|
+
# on hosts whose stub resolver strips DNSSEC records or never sets the AD bit
|
|
17
|
+
# (systemd-resolved, most CI runners, dev boxes behind a caching forwarder).
|
|
18
|
+
# On such a host a correctly DNSSEC-signed zone looks unsigned. For a
|
|
19
|
+
# fail-closed gate that is the WRONG failure mode for usability AND it can
|
|
20
|
+
# block a legitimate production signing while a genuinely-unsigned zone would
|
|
21
|
+
# also block — i.e. it loses all discriminating power. The fix is to query a
|
|
22
|
+
# TRUSTED VALIDATING resolver and require the resolver to assert validation
|
|
23
|
+
# (`delv` full-chain "fully validated", or `dig`'s AD bit + an RRSIG). The
|
|
24
|
+
# gate stays fail-closed: PASS only on positive confirmation from a trusted
|
|
25
|
+
# resolver; UNKNOWN / unreachable / no-tool => non-zero.
|
|
26
|
+
#
|
|
27
|
+
# Usage:
|
|
28
|
+
# bash scripts/dnssec-check.sh [DOMAIN]
|
|
29
|
+
# DNSSEC_CHECK_DOMAIN=evals.intentsolutions.io bash scripts/dnssec-check.sh
|
|
30
|
+
#
|
|
31
|
+
# Resolution order for the domain:
|
|
32
|
+
# 1. $1 (positional)
|
|
33
|
+
# 2. $DNSSEC_CHECK_DOMAIN
|
|
34
|
+
# 3. default: evals.intentsolutions.io
|
|
35
|
+
#
|
|
36
|
+
# Behavior:
|
|
37
|
+
# - Queries each resolver in $DNSSEC_CHECK_RESOLVERS (default 1.1.1.1 8.8.8.8),
|
|
38
|
+
# in order, and PASSES on the FIRST that confirms DNSSEC validation.
|
|
39
|
+
# - For each resolver: prefers `delv @<resolver>` (full DNSSEC chain validation
|
|
40
|
+
# against the IANA trust anchor; "fully validated" => PASS). Falls back to
|
|
41
|
+
# `dig @<resolver> +dnssec` and requires BOTH the AD (Authenticated Data)
|
|
42
|
+
# header flag AND the presence of an RRSIG record (a non-validating answer,
|
|
43
|
+
# i.e. RRSIG but no AD, does NOT pass — a malicious/forwarding resolver that
|
|
44
|
+
# returns records without validating the chain cannot trivially pass).
|
|
45
|
+
# - If NO resolver confirms validation (every resolver says unsigned, or is
|
|
46
|
+
# unreachable), exits 1 (fail-closed).
|
|
47
|
+
# - If NEITHER delv NOR dig is installed, emits a typed UNKNOWN/UNREACHABLE
|
|
48
|
+
# result and exits 2 (fail-closed for production).
|
|
49
|
+
#
|
|
50
|
+
# Exit codes:
|
|
51
|
+
# 0 — DNSSEC verified (a trusted resolver fully validated, or set AD + RRSIG)
|
|
52
|
+
# 1 — DNSSEC NOT verified (no trusted resolver confirmed; zone unsigned /
|
|
53
|
+
# validation failed / all resolvers unreachable)
|
|
54
|
+
# 2 — UNKNOWN/UNREACHABLE (no resolver tool installed at all)
|
|
55
|
+
#
|
|
56
|
+
# Override knobs:
|
|
57
|
+
# DNSSEC_CHECK_RESOLVERS — space-separated list of validating/public resolvers
|
|
58
|
+
# to query in order (default: "1.1.1.1 8.8.8.8").
|
|
59
|
+
# DNSSEC_CHECK_DELV_CMD — command used in place of `delv` (default: delv)
|
|
60
|
+
# DNSSEC_CHECK_DIG_CMD — command used in place of `dig` (default: dig)
|
|
61
|
+
|
|
62
|
+
set -euo pipefail
|
|
63
|
+
|
|
64
|
+
DOMAIN="${1:-${DNSSEC_CHECK_DOMAIN:-evals.intentsolutions.io}}"
|
|
65
|
+
DELV_CMD="${DNSSEC_CHECK_DELV_CMD:-delv}"
|
|
66
|
+
DIG_CMD="${DNSSEC_CHECK_DIG_CMD:-dig}"
|
|
67
|
+
# Trusted validating/public resolvers, queried in order. Cloudflare (1.1.1.1)
|
|
68
|
+
# and Google (8.8.8.8) both perform DNSSEC validation and set the AD bit.
|
|
69
|
+
RESOLVERS="${DNSSEC_CHECK_RESOLVERS:-1.1.1.1 8.8.8.8}"
|
|
70
|
+
|
|
71
|
+
log() { printf 'dnssec-check: %s\n' "$1" >&2; }
|
|
72
|
+
|
|
73
|
+
if [[ "$DOMAIN" == "-h" || "$DOMAIN" == "--help" ]]; then
|
|
74
|
+
sed -n '2,60p' "$0"
|
|
75
|
+
exit 0
|
|
76
|
+
fi
|
|
77
|
+
|
|
78
|
+
have() { command -v "$1" >/dev/null 2>&1; }
|
|
79
|
+
|
|
80
|
+
have_delv=0
|
|
81
|
+
have_dig=0
|
|
82
|
+
have "$DELV_CMD" && have_delv=1
|
|
83
|
+
have "$DIG_CMD" && have_dig=1
|
|
84
|
+
|
|
85
|
+
# --- No resolver tool at all -> typed UNKNOWN, fail-closed (exit 2) ---
|
|
86
|
+
if [[ "$have_delv" -eq 0 && "$have_dig" -eq 0 ]]; then
|
|
87
|
+
log "UNKNOWN/UNREACHABLE — neither '$DELV_CMD' nor '$DIG_CMD' is installed"
|
|
88
|
+
log " cannot verify DNSSEC for '$DOMAIN'; failing closed (production must not sign on UNKNOWN)"
|
|
89
|
+
log " remediation: install bind9-dnsutils (provides dig + delv) on the signing host"
|
|
90
|
+
exit 2
|
|
91
|
+
fi
|
|
92
|
+
|
|
93
|
+
# delv_validates RESOLVER -> 0 if delv reports the chain fully validated.
|
|
94
|
+
delv_validates() {
|
|
95
|
+
local resolver="$1" out
|
|
96
|
+
# delv prints "; fully validated" on each validated RRset when the chain of
|
|
97
|
+
# trust holds; "; unsigned answer" / "resolution failed" otherwise. delv
|
|
98
|
+
# validates LOCALLY against the IANA trust anchor regardless of which resolver
|
|
99
|
+
# serves the records, so a non-validating @resolver cannot fake a pass.
|
|
100
|
+
out="$("$DELV_CMD" "$DOMAIN" "@$resolver" 2>&1 || true)"
|
|
101
|
+
printf '%s\n' "$out" | grep -q "fully validated"
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
# dig_validates RESOLVER -> 0 if the resolver set the AD bit AND an RRSIG is
|
|
105
|
+
# present. BOTH are required: AD alone could be spoofed by a lying resolver
|
|
106
|
+
# without signatures, RRSIG alone proves the zone publishes signatures but not
|
|
107
|
+
# that the chain validated. Requiring AD means a non-validating resolver's
|
|
108
|
+
# answer (RRSIG copied through, AD never set) does NOT pass.
|
|
109
|
+
dig_validates() {
|
|
110
|
+
local resolver="$1" out ad_flag=0 rrsig=0
|
|
111
|
+
out="$("$DIG_CMD" "@$resolver" +dnssec +multiline "$DOMAIN" 2>&1 || true)"
|
|
112
|
+
if printf '%s\n' "$out" | grep -qE '^;; flags:[^;]*\bad\b'; then
|
|
113
|
+
ad_flag=1
|
|
114
|
+
fi
|
|
115
|
+
if printf '%s\n' "$out" | grep -qE '[[:space:]]RRSIG[[:space:]]'; then
|
|
116
|
+
rrsig=1
|
|
117
|
+
fi
|
|
118
|
+
[[ "$ad_flag" -eq 1 && "$rrsig" -eq 1 ]]
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
saw_unsigned=0 # at least one resolver answered, and said NOT validated
|
|
122
|
+
|
|
123
|
+
for resolver in $RESOLVERS; do
|
|
124
|
+
# --- Path 1: delv @resolver (authoritative DNSSEC chain validation) ---
|
|
125
|
+
if [[ "$have_delv" -eq 1 ]]; then
|
|
126
|
+
log "validating DNSSEC for '$DOMAIN' via $DELV_CMD @$resolver"
|
|
127
|
+
if delv_validates "$resolver"; then
|
|
128
|
+
log "VERIFIED — '$DOMAIN' is DNSSEC-signed (delv @$resolver: fully validated)"
|
|
129
|
+
exit 0
|
|
130
|
+
fi
|
|
131
|
+
saw_unsigned=1
|
|
132
|
+
log "delv @$resolver did not confirm validation; trying dig @$resolver"
|
|
133
|
+
fi
|
|
134
|
+
|
|
135
|
+
# --- Path 2: dig @resolver +dnssec (AD bit + RRSIG presence) ---
|
|
136
|
+
if [[ "$have_dig" -eq 1 ]]; then
|
|
137
|
+
log "checking DNSSEC for '$DOMAIN' via $DIG_CMD @$resolver +dnssec"
|
|
138
|
+
if dig_validates "$resolver"; then
|
|
139
|
+
log "VERIFIED — '$DOMAIN' is DNSSEC-signed (dig @$resolver: AD bit set + RRSIG present)"
|
|
140
|
+
exit 0
|
|
141
|
+
fi
|
|
142
|
+
saw_unsigned=1
|
|
143
|
+
log "dig @$resolver did not confirm validation (no AD+RRSIG) for '$DOMAIN'"
|
|
144
|
+
fi
|
|
145
|
+
done
|
|
146
|
+
|
|
147
|
+
# No resolver confirmed validation. Distinguish "answered but unsigned" from
|
|
148
|
+
# "nothing reachable" only for the operator message — both fail-closed (exit 1).
|
|
149
|
+
if [[ "$saw_unsigned" -eq 1 ]]; then
|
|
150
|
+
log "NOT VERIFIED — no trusted resolver confirmed DNSSEC for '$DOMAIN' (zone appears unsigned / chain not validated)"
|
|
151
|
+
log " resolvers tried: $RESOLVERS"
|
|
152
|
+
log " remediation: sign the zone (DNSSEC) at the registrar/DNS host, then re-run"
|
|
153
|
+
else
|
|
154
|
+
log "NOT VERIFIED — could not reach any resolver to validate DNSSEC for '$DOMAIN'"
|
|
155
|
+
log " resolvers tried: $RESOLVERS"
|
|
156
|
+
log " failing closed (production must not sign without positive confirmation)"
|
|
157
|
+
fi
|
|
158
|
+
exit 1
|
package/scripts/emit-evidence.sh
CHANGED
|
@@ -35,15 +35,28 @@
|
|
|
35
35
|
# 1 — input JSON malformed or missing required fields
|
|
36
36
|
# 2 — signing requested but cosign not available
|
|
37
37
|
# 3 — Rekor push requested but failed
|
|
38
|
+
# 4 — production DNSSEC/CAA pre-flight FAILED (fail-closed; nothing was signed)
|
|
38
39
|
#
|
|
39
|
-
# CISO gate (per ISEDC v1 Q1, 2026-05-10): pushing to a
|
|
40
|
-
# (Rekor) against the predicate URI
|
|
41
|
-
# is BLOCKED until DNSSEC + CAA
|
|
42
|
-
#
|
|
43
|
-
#
|
|
40
|
+
# CISO gate (per DR-010 Q5 / ISEDC v1 Q1, 2026-05-10): pushing to a PUBLIC
|
|
41
|
+
# transparency log (Rekor) against the predicate URI
|
|
42
|
+
# https://evals.intentsolutions.io/gate-result/v1 is BLOCKED until DNSSEC + CAA
|
|
43
|
+
# records are verified on the namespace. This script ENFORCES that: when a
|
|
44
|
+
# production Rekor push is requested (--rekor-url / non-empty REKOR_URL), it runs
|
|
45
|
+
# scripts/dnssec-check.sh then scripts/caa-check.sh against the predicate
|
|
46
|
+
# namespace and REFUSES to sign (exit 4) if either fails. The gate is read-only —
|
|
47
|
+
# it anchors nothing and can only make signing MORE conservative.
|
|
48
|
+
#
|
|
49
|
+
# Opt-out (NON-PRODUCTION / staging ONLY): EVIDENCE_SKIP_DNS_PREFLIGHT=1 skips the
|
|
50
|
+
# pre-flight. It is honored ONLY when no production Rekor push is requested; a
|
|
51
|
+
# real Rekor push can NEVER be silently skipped.
|
|
44
52
|
|
|
45
53
|
set -euo pipefail
|
|
46
54
|
|
|
55
|
+
# Bash version floor: these gates rely on bash 4+ features. Refuse early with a
|
|
56
|
+
# clear message on bash 3.x (e.g. macOS system bash) instead of failing later
|
|
57
|
+
# with a cryptic syntax error (jcgw).
|
|
58
|
+
[ "${BASH_VERSINFO:-0}" -ge 4 ] || { echo 'audit-harness requires bash >= 4' >&2; exit 3; }
|
|
59
|
+
|
|
47
60
|
INPUT="-"
|
|
48
61
|
OUTPUT=""
|
|
49
62
|
SIGN=0
|
|
@@ -54,6 +67,9 @@ RUNNER_VERSION_OVERRIDE=""
|
|
|
54
67
|
COMMIT_SHA_OVERRIDE=""
|
|
55
68
|
PREDICATE_URI="https://evals.intentsolutions.io/gate-result/v1"
|
|
56
69
|
STATEMENT_TYPE="https://in-toto.io/Statement/v1"
|
|
70
|
+
# The namespace whose DNSSEC + CAA posture gates production attestations. Derived
|
|
71
|
+
# from the predicate URI host; overridable for testing via EVIDENCE_PREDICATE_DOMAIN.
|
|
72
|
+
PREDICATE_DOMAIN="${EVIDENCE_PREDICATE_DOMAIN:-evals.intentsolutions.io}"
|
|
57
73
|
|
|
58
74
|
while [[ $# -gt 0 ]]; do
|
|
59
75
|
case "$1" in
|
|
@@ -181,10 +197,30 @@ fi
|
|
|
181
197
|
# OR an OTEL_EXPORTER_OTLP_ENDPOINT is set. Real exporter wiring is consumer-side;
|
|
182
198
|
# we emit a structured signal that any collector can scrape via stderr capture.
|
|
183
199
|
if [[ "${AUDIT_HARNESS_OTEL:-0}" == "1" ]] || [[ -n "${OTEL_EXPORTER_OTLP_ENDPOINT:-}" ]]; then
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
200
|
+
# Compose the JSON via python so every attribute value is JSON-escaped.
|
|
201
|
+
# printf-interpolating gate_id/result/runner into a JSON format string
|
|
202
|
+
# emitted structurally invalid JSON whenever a value carried a double quote
|
|
203
|
+
# (e.g. AUDIT_HARNESS_SIDE='ci"injection' flowing into gate_id).
|
|
204
|
+
OTEL_LINE=$(GATE_JSON="$GATE_JSON" RUNNER="$RUNNER" COMMIT_SHA="$COMMIT_SHA" TIMESTAMP="$TIMESTAMP" \
|
|
205
|
+
python3 - <<'PY' 2>/dev/null || echo ""
|
|
206
|
+
import json, os
|
|
207
|
+
try:
|
|
208
|
+
gate = json.loads(os.environ["GATE_JSON"])
|
|
209
|
+
except (json.JSONDecodeError, ValueError):
|
|
210
|
+
gate = {}
|
|
211
|
+
print(json.dumps({
|
|
212
|
+
"name": "agent.rollout.gate.evaluated",
|
|
213
|
+
"attributes": {
|
|
214
|
+
"gate.id": str(gate.get("gate_id", "")),
|
|
215
|
+
"gate.result": str(gate.get("result", "")),
|
|
216
|
+
"gate.runner": os.environ["RUNNER"],
|
|
217
|
+
"gate.commit_sha": os.environ["COMMIT_SHA"],
|
|
218
|
+
},
|
|
219
|
+
"timestamp": os.environ["TIMESTAMP"],
|
|
220
|
+
}, separators=(",", ":")))
|
|
221
|
+
PY
|
|
222
|
+
)
|
|
223
|
+
[[ -n "$OTEL_LINE" ]] && printf '[OTEL] %s\n' "$OTEL_LINE" >&2
|
|
188
224
|
fi
|
|
189
225
|
|
|
190
226
|
# --- Sign + emit ---
|
|
@@ -212,6 +248,40 @@ if ! command -v cosign >/dev/null 2>&1; then
|
|
|
212
248
|
exit 2
|
|
213
249
|
fi
|
|
214
250
|
|
|
251
|
+
# --- Production DNSSEC + CAA pre-flight gate (CISO binding DR-010 Q5) ----------
|
|
252
|
+
# A "production" signing event is one that pushes a signed Statement to a PUBLIC
|
|
253
|
+
# transparency log (Rekor) — i.e. REKOR_URL is non-empty. Before that irreversible
|
|
254
|
+
# anchor, the predicate namespace MUST be DNSSEC-signed AND CAA-pinned. We run the
|
|
255
|
+
# two read-only checks; if EITHER fails we REFUSE to sign and exit 4.
|
|
256
|
+
#
|
|
257
|
+
# The opt-out EVIDENCE_SKIP_DNS_PREFLIGHT=1 is honored ONLY for non-production
|
|
258
|
+
# (no Rekor push). A real Rekor push can never be silently skipped.
|
|
259
|
+
if [[ -n "$REKOR_URL" ]]; then
|
|
260
|
+
PREFLIGHT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
261
|
+
if [[ "${EVIDENCE_SKIP_DNS_PREFLIGHT:-0}" == "1" ]]; then
|
|
262
|
+
echo "emit-evidence: IGNORING EVIDENCE_SKIP_DNS_PREFLIGHT=1 — a Rekor push (REKOR_URL=$REKOR_URL) is a production attestation and CANNOT skip the DNSSEC/CAA pre-flight." >&2
|
|
263
|
+
fi
|
|
264
|
+
echo "emit-evidence: production Rekor push requested — running DNSSEC + CAA pre-flight on '$PREDICATE_DOMAIN'" >&2
|
|
265
|
+
|
|
266
|
+
if ! bash "$PREFLIGHT_DIR/dnssec-check.sh" "$PREDICATE_DOMAIN" >&2; then
|
|
267
|
+
echo "emit-evidence: REFUSING TO SIGN — DNSSEC pre-flight FAILED for '$PREDICATE_DOMAIN'." >&2
|
|
268
|
+
echo "emit-evidence: remediation: pin DNSSEC + CAA on $PREDICATE_DOMAIN before any production attestation." >&2
|
|
269
|
+
echo "emit-evidence: see intent-eval-platform/intent-eval-lab/000-docs (DR-010 Q5 CISO binding) + the iah-E06 runbook." >&2
|
|
270
|
+
exit 4
|
|
271
|
+
fi
|
|
272
|
+
if ! bash "$PREFLIGHT_DIR/caa-check.sh" "$PREDICATE_DOMAIN" >&2; then
|
|
273
|
+
echo "emit-evidence: REFUSING TO SIGN — CAA pre-flight FAILED for '$PREDICATE_DOMAIN'." >&2
|
|
274
|
+
echo "emit-evidence: remediation: pin DNSSEC + CAA on $PREDICATE_DOMAIN before any production attestation." >&2
|
|
275
|
+
echo "emit-evidence: set EXPECTED_CAA_ISSUER to the published CA, then publish a CAA record pinning it." >&2
|
|
276
|
+
exit 4
|
|
277
|
+
fi
|
|
278
|
+
echo "emit-evidence: DNSSEC + CAA pre-flight PASSED for '$PREDICATE_DOMAIN' — proceeding to sign." >&2
|
|
279
|
+
elif [[ "${EVIDENCE_SKIP_DNS_PREFLIGHT:-0}" == "1" ]]; then
|
|
280
|
+
# Non-production sign (no Rekor push) with the explicit opt-out set: keep
|
|
281
|
+
# existing staging flows green without running the network-bound checks.
|
|
282
|
+
echo "emit-evidence: non-production sign (no Rekor push); DNSSEC/CAA pre-flight skipped per EVIDENCE_SKIP_DNS_PREFLIGHT=1." >&2
|
|
283
|
+
fi
|
|
284
|
+
|
|
215
285
|
# Stage the Statement to a temp file for cosign to consume
|
|
216
286
|
TMP=$(mktemp -d)
|
|
217
287
|
trap 'rm -rf "$TMP"' EXIT
|
package/scripts/escape-scan.sh
CHANGED
|
@@ -28,6 +28,23 @@
|
|
|
28
28
|
|
|
29
29
|
set -euo pipefail
|
|
30
30
|
|
|
31
|
+
# Bash version floor: these gates rely on bash 4+ features. Refuse early with a
|
|
32
|
+
# clear message on bash 3.x (e.g. macOS system bash) instead of failing later
|
|
33
|
+
# with a cryptic syntax error (jcgw).
|
|
34
|
+
[ "${BASH_VERSINFO:-0}" -ge 4 ] || { echo 'audit-harness requires bash >= 4' >&2; exit 3; }
|
|
35
|
+
|
|
36
|
+
# Cross-platform SHA-256: `sha256sum` ships with GNU coreutils (Linux);
|
|
37
|
+
# macOS only has `shasum -a 256`. Both produce identical `<hash> <file>`
|
|
38
|
+
# output, so downstream awk parsing is unchanged. Mirrors harness-hash.sh.
|
|
39
|
+
if command -v sha256sum >/dev/null 2>&1; then
|
|
40
|
+
SHA256_CMD=(sha256sum)
|
|
41
|
+
elif command -v shasum >/dev/null 2>&1; then
|
|
42
|
+
SHA256_CMD=(shasum -a 256)
|
|
43
|
+
else
|
|
44
|
+
echo "escape-scan: neither sha256sum nor shasum found in PATH" >&2
|
|
45
|
+
exit 2
|
|
46
|
+
fi
|
|
47
|
+
|
|
31
48
|
DIFF_SRC=""
|
|
32
49
|
VERIFY_HASH=1
|
|
33
50
|
JSON_OUT=0
|
|
@@ -51,7 +68,15 @@ if [[ "$#" -eq 0 ]]; then
|
|
|
51
68
|
fi
|
|
52
69
|
|
|
53
70
|
case "$1" in
|
|
54
|
-
-)
|
|
71
|
+
-)
|
|
72
|
+
# Buffer stdin into a temp file so the diff can be read multiple times.
|
|
73
|
+
# /dev/stdin is drained by the first grep, which would leave later reads
|
|
74
|
+
# (notably the input_hash sha256) seeing an empty stream — emitting the
|
|
75
|
+
# SHA-256 of "" instead of the real diff hash.
|
|
76
|
+
DIFF_SRC=$(mktemp)
|
|
77
|
+
trap 'rm -f "$DIFF_SRC"' EXIT
|
|
78
|
+
cat > "$DIFF_SRC"
|
|
79
|
+
;;
|
|
55
80
|
--staged)
|
|
56
81
|
DIFF_SRC=$(mktemp)
|
|
57
82
|
trap 'rm -f "$DIFF_SRC"' EXIT
|
|
@@ -198,10 +223,10 @@ if [[ "$JSON_OUT" -eq 1 ]]; then
|
|
|
198
223
|
elif [[ "$FLAG" -gt 0 ]]; then
|
|
199
224
|
result="ADVISORY"
|
|
200
225
|
fi
|
|
201
|
-
input_hash=$(
|
|
226
|
+
input_hash=$("${SHA256_CMD[@]}" "$DIFF_SRC" | awk '{print "sha256:"$1}')
|
|
202
227
|
policy_hash="sha256:0000000000000000000000000000000000000000000000000000000000000000"
|
|
203
228
|
if [[ -f "$TESTING_MD" ]]; then
|
|
204
|
-
policy_hash=$(
|
|
229
|
+
policy_hash=$("${SHA256_CMD[@]}" "$TESTING_MD" | awk '{print "sha256:"$1}')
|
|
205
230
|
fi
|
|
206
231
|
printf '{"gate_id":"audit-harness:%s:escape-scan","result":"%s","input_hash":"%s","policy_hash":"%s","metadata":{"refuse":%d,"challenge":%d,"flag":%d,"coverage_line_floor":%d,"coverage_branch_floor":%d,"mutation_floor":%d}' \
|
|
207
232
|
"${AUDIT_HARNESS_SIDE:-ci}" "$result" "$input_hash" "$policy_hash" "$REFUSE" "$CHALLENGE" "$FLAG" \
|
package/scripts/gherkin-lint.sh
CHANGED
|
@@ -13,6 +13,11 @@
|
|
|
13
13
|
|
|
14
14
|
set -euo pipefail
|
|
15
15
|
|
|
16
|
+
# Bash version floor: these gates rely on bash 4+ features. Refuse early with a
|
|
17
|
+
# clear message on bash 3.x (e.g. macOS system bash) instead of failing later
|
|
18
|
+
# with a cryptic syntax error (jcgw).
|
|
19
|
+
[ "${BASH_VERSINFO:-0}" -ge 4 ] || { echo 'audit-harness requires bash >= 4' >&2; exit 3; }
|
|
20
|
+
|
|
16
21
|
PATH_ARG="features/"
|
|
17
22
|
STRICT=0
|
|
18
23
|
JSON_OUT=0
|
package/scripts/harness-hash.sh
CHANGED
|
@@ -23,6 +23,11 @@
|
|
|
23
23
|
|
|
24
24
|
set -euo pipefail
|
|
25
25
|
|
|
26
|
+
# Bash version floor: these gates rely on bash 4+ features. Refuse early with a
|
|
27
|
+
# clear message on bash 3.x (e.g. macOS system bash) instead of failing later
|
|
28
|
+
# with a cryptic syntax error (jcgw).
|
|
29
|
+
[ "${BASH_VERSINFO:-0}" -ge 4 ] || { echo 'audit-harness requires bash >= 4' >&2; exit 3; }
|
|
30
|
+
|
|
26
31
|
# Cross-platform SHA-256: `sha256sum` ships with GNU coreutils (Linux);
|
|
27
32
|
# macOS only has `shasum -a 256`. Both produce identical `<hash> <file>`
|
|
28
33
|
# output, so downstream awk parsing is unchanged.
|