scrypto-audit-kit 0.5.0__py3-none-any.whl
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.
- scrypto_audit_kit/__init__.py +56 -0
- scrypto_audit_kit/attest.py +128 -0
- scrypto_audit_kit/ci-gate.py +14 -0
- scrypto_audit_kit/ci_gate.py +54 -0
- scrypto_audit_kit/extract-report.py +229 -0
- scrypto_audit_kit/gen_tests.py +157 -0
- scrypto_audit_kit/mcp_server.py +284 -0
- scrypto_audit_kit/sak_lib.py +268 -0
- scrypto_audit_kit/static_analysis.py +414 -0
- scrypto_audit_kit-0.5.0.dist-info/METADATA +307 -0
- scrypto_audit_kit-0.5.0.dist-info/RECORD +15 -0
- scrypto_audit_kit-0.5.0.dist-info/WHEEL +5 -0
- scrypto_audit_kit-0.5.0.dist-info/entry_points.txt +6 -0
- scrypto_audit_kit-0.5.0.dist-info/licenses/LICENSE +201 -0
- scrypto_audit_kit-0.5.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""scrypto-audit-kit — the deterministic Python core as an importable library.
|
|
2
|
+
|
|
3
|
+
`pip install scrypto-audit-kit` makes this package importable:
|
|
4
|
+
|
|
5
|
+
from scrypto_audit_kit import static_analysis, sak_lib, attest, gen_tests
|
|
6
|
+
|
|
7
|
+
findings = static_analysis.analyze_package("path/to/your/scrypto/package")
|
|
8
|
+
verdict = sak_lib.gate_verdict({"findings": findings}, fail_on="high")
|
|
9
|
+
|
|
10
|
+
The deterministic tools (static_analysis, gen_tests, attest) and helpers (sak_lib) need
|
|
11
|
+
no API key and no toolchain. The full LLM audit (audit.sh) and the MCP server's
|
|
12
|
+
audit_package tool additionally need a kit clone + aider + ANTHROPIC_API_KEY — see
|
|
13
|
+
docs/sdk.md for what runs where.
|
|
14
|
+
|
|
15
|
+
Implementation note: the modules in this directory cross-import by bare name
|
|
16
|
+
(`import sak_lib`) — the historical layout, which keeps a bare `git clone` runnable with
|
|
17
|
+
no install. So that those bare imports resolve when the directory is imported as the
|
|
18
|
+
`scrypto_audit_kit` package, we put the package directory on sys.path here. Converting the
|
|
19
|
+
cross-imports to relative imports is tracked for a future release (see ROADMAP.md).
|
|
20
|
+
"""
|
|
21
|
+
import os as _os
|
|
22
|
+
import sys as _sys
|
|
23
|
+
|
|
24
|
+
_PKG_DIR = _os.path.dirname(_os.path.abspath(__file__))
|
|
25
|
+
if _PKG_DIR not in _sys.path:
|
|
26
|
+
_sys.path.insert(0, _PKG_DIR)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _read_version():
|
|
30
|
+
"""Kit version: the VERSION file (clone / editable install), else installed metadata."""
|
|
31
|
+
version_file = _os.path.join(_os.path.dirname(_PKG_DIR), "VERSION")
|
|
32
|
+
try:
|
|
33
|
+
with open(version_file, encoding="utf-8") as fh:
|
|
34
|
+
return fh.read().strip()
|
|
35
|
+
except OSError:
|
|
36
|
+
pass
|
|
37
|
+
try:
|
|
38
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
39
|
+
try:
|
|
40
|
+
return version("scrypto-audit-kit")
|
|
41
|
+
except PackageNotFoundError:
|
|
42
|
+
return "unknown"
|
|
43
|
+
except ImportError:
|
|
44
|
+
return "unknown"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
__version__ = _read_version()
|
|
48
|
+
|
|
49
|
+
# Re-export the deterministic, no-API-key core. These bind the same module objects the
|
|
50
|
+
# internal cross-imports use, so `scrypto_audit_kit.sak_lib` is the one the tools call.
|
|
51
|
+
import sak_lib as sak_lib # noqa: E402
|
|
52
|
+
import static_analysis as static_analysis # noqa: E402
|
|
53
|
+
import attest as attest # noqa: E402
|
|
54
|
+
import gen_tests as gen_tests # noqa: E402
|
|
55
|
+
|
|
56
|
+
__all__ = ["sak_lib", "static_analysis", "attest", "gen_tests", "__version__"]
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Turn a pre-audit report.json into an on-chain attestation payload + a Radix manifest.
|
|
3
|
+
|
|
4
|
+
The bridge from a kit run to the L3 attestation registry (see attestation/). It reads the
|
|
5
|
+
report's source_hash, hashes the report (and optionally the built wasm), counts findings by
|
|
6
|
+
severity, derives the level, and renders a transaction manifest that calls `attest(...)` on a
|
|
7
|
+
deployed registry component. Stdlib only. Importable + CLI; also the attestation_payload MCP tool.
|
|
8
|
+
"""
|
|
9
|
+
import argparse
|
|
10
|
+
import hashlib
|
|
11
|
+
import json
|
|
12
|
+
import sys
|
|
13
|
+
|
|
14
|
+
import sak_lib
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _sha256_file(path):
|
|
18
|
+
h = hashlib.sha256()
|
|
19
|
+
with open(path, "rb") as fh:
|
|
20
|
+
for chunk in iter(lambda: fh.read(65536), b""):
|
|
21
|
+
h.update(chunk)
|
|
22
|
+
return h.hexdigest()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _u16(n):
|
|
26
|
+
"""Clamp a count to the u16 the on-chain field accepts (so >65535 findings can't break it)."""
|
|
27
|
+
try:
|
|
28
|
+
return max(0, min(int(n), 65535))
|
|
29
|
+
except (TypeError, ValueError):
|
|
30
|
+
return 0
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _derive_level(report):
|
|
34
|
+
"""L1-static when only the static tier ran, else L2-hybrid. Derived from the harness-stamped
|
|
35
|
+
model (authoritative), not from whether findings happen to be tagged — so a clean full run
|
|
36
|
+
(0 findings) is still L2, not a spurious downgrade."""
|
|
37
|
+
model = str(report.get("kit", {}).get("model", "")).lower()
|
|
38
|
+
return "L1-static" if "static-only" in model else "L2-hybrid"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def build_payload(report_path, wasm_path="", level=""):
|
|
42
|
+
"""Compute the AttestationInput payload (the fields the registry's attest() expects)."""
|
|
43
|
+
report = sak_lib.load_report(report_path)
|
|
44
|
+
counts = sak_lib.severity_counts(report.get("findings", []))
|
|
45
|
+
kit = report.get("kit", {})
|
|
46
|
+
return {
|
|
47
|
+
"source_hash": report.get("target", {}).get("source_hash", ""),
|
|
48
|
+
"report_hash": _sha256_file(report_path),
|
|
49
|
+
"wasm_hash": _sha256_file(wasm_path) if wasm_path else "",
|
|
50
|
+
"kit_version": kit.get("version", "unknown"),
|
|
51
|
+
"checklist_version": kit.get("checklist_version", "unknown"),
|
|
52
|
+
"level": level or _derive_level(report),
|
|
53
|
+
"critical": _u16(counts.get("critical", 0)),
|
|
54
|
+
"high": _u16(counts.get("high", 0)),
|
|
55
|
+
"medium": _u16(counts.get("medium", 0)),
|
|
56
|
+
"low": _u16(counts.get("low", 0)),
|
|
57
|
+
"info": _u16(counts.get("info", 0)),
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
_STR_FIELDS = ["source_hash", "report_hash", "wasm_hash", "kit_version", "checklist_version", "level"]
|
|
62
|
+
_U16_FIELDS = ["critical", "high", "medium", "low", "info"]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def render_manifest(payload, component, account, fee="10"):
|
|
66
|
+
"""Render a Radix transaction manifest calling attest(AttestationInput) + depositing the NFT."""
|
|
67
|
+
def quote(v):
|
|
68
|
+
s = str(v).replace("\\", "\\\\").replace('"', '\\"')
|
|
69
|
+
s = s.replace("\n", " ").replace("\r", " ") # no control chars in a manifest string literal
|
|
70
|
+
return '"' + s + '"'
|
|
71
|
+
|
|
72
|
+
rows = [f" {quote(payload[k])}," for k in _STR_FIELDS]
|
|
73
|
+
rows += [f" {int(payload[k])}u16," for k in _U16_FIELDS]
|
|
74
|
+
rows[-1] = rows[-1].rstrip(",") # the final tuple element takes no trailing comma
|
|
75
|
+
tuple_body = "\n".join(rows)
|
|
76
|
+
return f"""# scrypto-audit-kit attestation — generated; review before submitting.
|
|
77
|
+
# The account deposit method may need adjusting to your network's current API.
|
|
78
|
+
CALL_METHOD
|
|
79
|
+
Address("{account}")
|
|
80
|
+
"lock_fee"
|
|
81
|
+
Decimal("{fee}")
|
|
82
|
+
;
|
|
83
|
+
CALL_METHOD
|
|
84
|
+
Address("{component}")
|
|
85
|
+
"attest"
|
|
86
|
+
Tuple(
|
|
87
|
+
{tuple_body}
|
|
88
|
+
)
|
|
89
|
+
;
|
|
90
|
+
CALL_METHOD
|
|
91
|
+
Address("{account}")
|
|
92
|
+
"try_deposit_batch_or_abort"
|
|
93
|
+
Expression("ENTIRE_WORKTOP")
|
|
94
|
+
Enum<0u8>()
|
|
95
|
+
;
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def main():
|
|
100
|
+
ap = argparse.ArgumentParser(description="Attestation payload + manifest from a report.json.")
|
|
101
|
+
ap.add_argument("report", help="path to a report.json")
|
|
102
|
+
ap.add_argument("--wasm", default="", help="path to the built blueprint wasm to hash")
|
|
103
|
+
ap.add_argument("--level", default="", help="override the derived level (L1-static / L2-hybrid / ...)")
|
|
104
|
+
ap.add_argument("--component", default="", help="deployed attestation registry component address")
|
|
105
|
+
ap.add_argument("--account", default="", help="your account address (pays the fee, receives the NFT)")
|
|
106
|
+
ap.add_argument("--out-manifest", default="", help="write the manifest to this file")
|
|
107
|
+
ap.add_argument("--json", action="store_true", help="print the payload JSON (even if --component is given)")
|
|
108
|
+
args = ap.parse_args()
|
|
109
|
+
|
|
110
|
+
payload = build_payload(args.report, args.wasm, args.level)
|
|
111
|
+
sys.stderr.write(f"[attest] level {payload['level']} · {payload['critical']}C/{payload['high']}H · "
|
|
112
|
+
f"source {payload['source_hash'][:12]}\n")
|
|
113
|
+
|
|
114
|
+
if args.component and args.account and not args.json:
|
|
115
|
+
manifest = render_manifest(payload, args.component, args.account)
|
|
116
|
+
if args.out_manifest:
|
|
117
|
+
with open(args.out_manifest, "w", encoding="utf-8") as fh:
|
|
118
|
+
fh.write(manifest)
|
|
119
|
+
else:
|
|
120
|
+
sys.stdout.write(manifest)
|
|
121
|
+
else:
|
|
122
|
+
json.dump(payload, sys.stdout, indent=2)
|
|
123
|
+
sys.stdout.write("\n")
|
|
124
|
+
return 0
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
if __name__ == "__main__":
|
|
128
|
+
sys.exit(main())
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Compatibility shim → ci_gate.main().
|
|
3
|
+
|
|
4
|
+
The gate logic lives in ci_gate.py (an importable, un-hyphenated module so it can back the
|
|
5
|
+
`sak-gate` console entry point). This hyphenated path is kept verbatim because the pre-audit
|
|
6
|
+
CI workflow (.github/workflows/pre-audit.yml), the docs, and tests/test_security.py invoke
|
|
7
|
+
`bin/ci-gate.py` directly — they all keep working unchanged.
|
|
8
|
+
"""
|
|
9
|
+
import sys
|
|
10
|
+
|
|
11
|
+
from ci_gate import main # bin/ is sys.path[0] when this runs as a script
|
|
12
|
+
|
|
13
|
+
if __name__ == "__main__":
|
|
14
|
+
sys.exit(main())
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""CI gate: fail the build when a pre-audit report has findings at/above a severity.
|
|
3
|
+
|
|
4
|
+
Reads a report.json (or every *.json in a directory) and exits non-zero when any
|
|
5
|
+
finding's severity is >= --fail-on. Backs an honest "pre-audit passing" badge.
|
|
6
|
+
Stdlib only; shares its logic with the MCP server via sak_lib.
|
|
7
|
+
|
|
8
|
+
This is the importable, un-hyphenated home of the gate CLI (it backs the `sak-gate`
|
|
9
|
+
console entry point). The historical `bin/ci-gate.py` is a thin shim that calls main()
|
|
10
|
+
here, so existing invocations (the pre-audit CI workflow, tests) keep working.
|
|
11
|
+
"""
|
|
12
|
+
import argparse
|
|
13
|
+
import sys
|
|
14
|
+
|
|
15
|
+
import sak_lib
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def main():
|
|
19
|
+
ap = argparse.ArgumentParser(description=__doc__)
|
|
20
|
+
ap.add_argument("--reports", required=True, help="a report.json file or a directory of them")
|
|
21
|
+
ap.add_argument("--fail-on", default="high", help="none | critical | high | medium | low (default: high)")
|
|
22
|
+
ap.add_argument("--allow-missing", action="store_true",
|
|
23
|
+
help="pass when no report is found (default: fail closed)")
|
|
24
|
+
args = ap.parse_args()
|
|
25
|
+
|
|
26
|
+
# Fail CLOSED: a missing, unreadable, or malformed report must not pass the gate.
|
|
27
|
+
# Shares sak_lib.collect_findings with the MCP gate so both surfaces fail closed identically.
|
|
28
|
+
try:
|
|
29
|
+
findings = sak_lib.collect_findings(args.reports, allow_missing=args.allow_missing)
|
|
30
|
+
except sak_lib.GateError as exc:
|
|
31
|
+
print(f"::error::{exc} — failing closed (use --allow-missing to opt out)", file=sys.stderr)
|
|
32
|
+
return 1
|
|
33
|
+
if findings is None:
|
|
34
|
+
print(f"warn: no report.json at {args.reports} — passing (--allow-missing)", file=sys.stderr)
|
|
35
|
+
return 0
|
|
36
|
+
|
|
37
|
+
try:
|
|
38
|
+
verdict = sak_lib.gate_verdict({"findings": findings}, args.fail_on)
|
|
39
|
+
except ValueError as exc:
|
|
40
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
41
|
+
return 2
|
|
42
|
+
|
|
43
|
+
print(f"pre-audit gate: {verdict['total']} findings "
|
|
44
|
+
f"({sak_lib.counts_summary(verdict['counts'])}); threshold = {verdict['fail_on']}")
|
|
45
|
+
if not verdict["passed"]:
|
|
46
|
+
print(f"::error::pre-audit gate failed — found {verdict['worst']} finding(s) "
|
|
47
|
+
f"at or above '{verdict['fail_on']}'")
|
|
48
|
+
return 1
|
|
49
|
+
print("pre-audit gate passed.")
|
|
50
|
+
return 0
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
if __name__ == "__main__":
|
|
54
|
+
sys.exit(main())
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Split a pre-audit run into a clean markdown report + a machine-readable report.json.
|
|
3
|
+
|
|
4
|
+
The model emits a human markdown report followed by a fenced ```json block conforming
|
|
5
|
+
to schema/audit-report.schema.json. This script:
|
|
6
|
+
|
|
7
|
+
1. pulls the LAST valid JSON block out of the raw output,
|
|
8
|
+
2. stamps it with authoritative provenance (kit version, model, checklist version,
|
|
9
|
+
source hash, ...) — the harness knows these, the model does not,
|
|
10
|
+
3. writes report.json,
|
|
11
|
+
4. rewrites the markdown without the JSON appendix, with a one-line provenance header.
|
|
12
|
+
|
|
13
|
+
Soft-fails: if no valid JSON block is found, the markdown is still written and the script
|
|
14
|
+
exits 0 (the human report is never lost to a structured-output hiccup). Schema validation
|
|
15
|
+
is performed only if `jsonschema` is importable — it is never a hard dependency.
|
|
16
|
+
|
|
17
|
+
Stdlib only.
|
|
18
|
+
"""
|
|
19
|
+
import argparse
|
|
20
|
+
import json
|
|
21
|
+
import re
|
|
22
|
+
import sys
|
|
23
|
+
|
|
24
|
+
import sak_lib
|
|
25
|
+
|
|
26
|
+
FENCE_RE = re.compile(r"```(?:json)?\s*\n(.*?)\n```", re.DOTALL)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def extract_json_blocks(text):
|
|
30
|
+
"""Return (span, parsed_obj) for each fenced JSON block that parses, in document order."""
|
|
31
|
+
blocks = []
|
|
32
|
+
for m in FENCE_RE.finditer(text):
|
|
33
|
+
body = m.group(1).strip()
|
|
34
|
+
if not body.startswith("{"):
|
|
35
|
+
continue
|
|
36
|
+
try:
|
|
37
|
+
obj = json.loads(body)
|
|
38
|
+
except json.JSONDecodeError:
|
|
39
|
+
continue
|
|
40
|
+
blocks.append((m.span(), obj))
|
|
41
|
+
return blocks
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def select_appendix(text, blocks, nonce):
|
|
45
|
+
"""Pick the authoritative JSON block.
|
|
46
|
+
|
|
47
|
+
With a per-run `nonce`, a block is authentic ONLY if the `sak:nonce:<nonce>` marker is the
|
|
48
|
+
LAST non-whitespace content immediately before its opening fence — ADJACENCY, not a fuzzy
|
|
49
|
+
"within 200 chars" window. The old window let a small real block lend its marker to a LATER
|
|
50
|
+
attacker block (which then won as the last authed block); adjacency + scanning only the gap
|
|
51
|
+
since the previous block closes that. If MORE THAN ONE block authenticates, refuse (a run
|
|
52
|
+
carries exactly one) and prefer the FIRST otherwise. Without a nonce (static-only / legacy)
|
|
53
|
+
the last block is returned.
|
|
54
|
+
|
|
55
|
+
Returns (selected_block_or_None, authenticated, ambiguous).
|
|
56
|
+
"""
|
|
57
|
+
if not nonce:
|
|
58
|
+
return (blocks[-1] if blocks else None), True, False
|
|
59
|
+
marker = f"sak:nonce:{nonce}"
|
|
60
|
+
authed = []
|
|
61
|
+
prev_end = 0
|
|
62
|
+
for span, obj in blocks:
|
|
63
|
+
start, end = span
|
|
64
|
+
preamble = text[prev_end:start] # only the gap since the previous block — no reach-back
|
|
65
|
+
prev_end = end
|
|
66
|
+
idx = preamble.rfind(marker)
|
|
67
|
+
if idx == -1:
|
|
68
|
+
continue
|
|
69
|
+
tail = preamble[idx + len(marker):].replace("-->", "", 1) # only the comment close may follow
|
|
70
|
+
if tail.strip() == "":
|
|
71
|
+
authed.append((span, obj))
|
|
72
|
+
if len(authed) > 1:
|
|
73
|
+
return None, False, True # ambiguous — refuse, fail safe
|
|
74
|
+
return (authed[0] if authed else None), bool(authed), False
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def strip_json_appendix(text, span):
|
|
78
|
+
"""Remove the JSON fenced block, plus the `<!-- machine-readable -->` marker and the
|
|
79
|
+
`---` rule that precede it, from the markdown."""
|
|
80
|
+
start, end = span
|
|
81
|
+
before = text[:start]
|
|
82
|
+
after = text[end:]
|
|
83
|
+
# the marker comment sits just above the block; the `---` rule sits just above that
|
|
84
|
+
before = re.sub(r"\s*<!--[^>]*?(?:machine-readable|sak:nonce:)[^>]*?-->\s*$", "\n", before, flags=re.IGNORECASE)
|
|
85
|
+
before = re.sub(r"\s*\n-{3,}[ \t]*$", "\n", before)
|
|
86
|
+
cleaned = before.rstrip() + "\n"
|
|
87
|
+
if after.strip():
|
|
88
|
+
cleaned += after.lstrip()
|
|
89
|
+
return cleaned
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def soft_validate(obj, schema_path):
|
|
93
|
+
"""Best-effort schema validation. Returns a list of human-readable problems (empty = ok/skipped)."""
|
|
94
|
+
try:
|
|
95
|
+
import jsonschema # type: ignore
|
|
96
|
+
except Exception:
|
|
97
|
+
return [] # not installed — skip silently
|
|
98
|
+
try:
|
|
99
|
+
with open(schema_path, encoding="utf-8") as fh:
|
|
100
|
+
schema = json.load(fh)
|
|
101
|
+
jsonschema.Draft202012Validator(schema).validate(obj)
|
|
102
|
+
return []
|
|
103
|
+
except FileNotFoundError:
|
|
104
|
+
return []
|
|
105
|
+
except jsonschema.ValidationError as exc: # type: ignore
|
|
106
|
+
return [exc.message]
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def main():
|
|
110
|
+
ap = argparse.ArgumentParser(description=__doc__)
|
|
111
|
+
ap.add_argument("--raw", required=True, help="path to the raw markdown report (read)")
|
|
112
|
+
ap.add_argument("--out-json", required=True, help="path to write report.json")
|
|
113
|
+
ap.add_argument("--out-md", required=True, help="path to write the cleaned markdown (may equal --raw)")
|
|
114
|
+
ap.add_argument("--kit-version", default="unknown")
|
|
115
|
+
ap.add_argument("--model", default="unknown")
|
|
116
|
+
ap.add_argument("--checklist-version", default="unknown")
|
|
117
|
+
ap.add_argument("--reference-set", default="")
|
|
118
|
+
ap.add_argument("--repo", default="")
|
|
119
|
+
ap.add_argument("--package", default="")
|
|
120
|
+
ap.add_argument("--source-hash", default="")
|
|
121
|
+
ap.add_argument("--files", type=int, default=0)
|
|
122
|
+
ap.add_argument("--generated-at", default="")
|
|
123
|
+
ap.add_argument("--schema", default="", help="optional schema path for soft validation")
|
|
124
|
+
ap.add_argument("--static-json", default="", help="optional JSON file of static findings to merge in")
|
|
125
|
+
ap.add_argument("--nonce", default="", help="per-run nonce; only a JSON block carrying its marker is trusted")
|
|
126
|
+
args = ap.parse_args()
|
|
127
|
+
|
|
128
|
+
with open(args.raw, encoding="utf-8") as fh:
|
|
129
|
+
raw = fh.read()
|
|
130
|
+
|
|
131
|
+
static_findings = []
|
|
132
|
+
static_ran = False
|
|
133
|
+
if args.static_json:
|
|
134
|
+
try:
|
|
135
|
+
with open(args.static_json, encoding="utf-8") as fh:
|
|
136
|
+
static_findings = json.load(fh)
|
|
137
|
+
static_ran = True # the static pass ran (even if it found nothing) — a clean run is attestable
|
|
138
|
+
except (OSError, ValueError) as exc:
|
|
139
|
+
print(f"warn: could not read static findings {args.static_json}: {exc}", file=sys.stderr)
|
|
140
|
+
|
|
141
|
+
blocks = extract_json_blocks(raw)
|
|
142
|
+
selected, authenticated, ambiguous = select_appendix(raw, blocks, args.nonce)
|
|
143
|
+
if args.nonce and blocks and not authenticated:
|
|
144
|
+
# JSON block(s) present but none is uniquely nonce-authenticated (missing marker, or
|
|
145
|
+
# ambiguous — >1 authed). Possible injection: a blueprint getting the model to echo a
|
|
146
|
+
# forged block. Fail safe — never write report.json, and WITHHOLD the model's prose
|
|
147
|
+
# (a clean-looking narrative an operator might share) rather than emit it invisibly.
|
|
148
|
+
reason = "ambiguous nonce (more than one authenticated block)" if ambiguous else "no run nonce"
|
|
149
|
+
with open(args.out_md, "w", encoding="utf-8") as fh:
|
|
150
|
+
fh.write("# ⚠ Pre-audit REFUSED — possible prompt injection\n\n"
|
|
151
|
+
f"The model's JSON appendix was not authenticated by this run's nonce ({reason}). "
|
|
152
|
+
"A blueprint may have injected a forged 'all clear' block, so report.json was NOT "
|
|
153
|
+
"written and the model's prose is withheld. Re-run the audit.\n")
|
|
154
|
+
print(f"::error::SECURITY — the JSON appendix is not nonce-authenticated ({reason}); refusing it. "
|
|
155
|
+
"report.json NOT written; re-run the audit.", file=sys.stderr)
|
|
156
|
+
return 3
|
|
157
|
+
|
|
158
|
+
span = None
|
|
159
|
+
if selected is not None:
|
|
160
|
+
span, obj = selected
|
|
161
|
+
for f in obj.get("findings", []):
|
|
162
|
+
f.setdefault("source", "llm")
|
|
163
|
+
if static_findings: # hybrid run — merge static into the LLM findings
|
|
164
|
+
obj["findings"] = sak_lib.merge_findings(obj.get("findings", []), static_findings)
|
|
165
|
+
elif static_ran:
|
|
166
|
+
# No LLM block — assemble the report from the static pass alone (static-only tier). Build
|
|
167
|
+
# it even when the pass found NOTHING: a clean audit is a first-class, attestable result
|
|
168
|
+
# and the file-mode gate / L3 attestation both need a report.json to exist.
|
|
169
|
+
obj = sak_lib.build_report(static_findings)
|
|
170
|
+
else:
|
|
171
|
+
# Neither — keep the human report, skip JSON. Never fail the pipeline.
|
|
172
|
+
with open(args.out_md, "w", encoding="utf-8") as fh:
|
|
173
|
+
fh.write(raw if raw.endswith("\n") else raw + "\n")
|
|
174
|
+
print("warn: no machine-readable JSON block found — wrote markdown only", file=sys.stderr)
|
|
175
|
+
return 0
|
|
176
|
+
|
|
177
|
+
# Stamp authoritative provenance — overrides whatever the model put there.
|
|
178
|
+
obj["schema_version"] = "1.0"
|
|
179
|
+
kit = obj.setdefault("kit", {})
|
|
180
|
+
kit.update({
|
|
181
|
+
"version": args.kit_version,
|
|
182
|
+
"model": args.model,
|
|
183
|
+
"checklist_version": args.checklist_version,
|
|
184
|
+
"generated_at": args.generated_at,
|
|
185
|
+
})
|
|
186
|
+
if args.reference_set:
|
|
187
|
+
kit["reference_set"] = args.reference_set
|
|
188
|
+
target = obj.setdefault("target", {})
|
|
189
|
+
if args.repo:
|
|
190
|
+
target["repo"] = args.repo
|
|
191
|
+
if args.package:
|
|
192
|
+
target["package"] = args.package
|
|
193
|
+
if args.source_hash:
|
|
194
|
+
target["source_hash"] = args.source_hash
|
|
195
|
+
if args.files:
|
|
196
|
+
target["files_analyzed"] = args.files
|
|
197
|
+
|
|
198
|
+
problems = soft_validate(obj, args.schema) if args.schema else []
|
|
199
|
+
for p in problems:
|
|
200
|
+
print(f"warn: schema: {p}", file=sys.stderr)
|
|
201
|
+
|
|
202
|
+
with open(args.out_json, "w", encoding="utf-8") as fh:
|
|
203
|
+
json.dump(obj, fh, indent=2, ensure_ascii=False)
|
|
204
|
+
fh.write("\n")
|
|
205
|
+
|
|
206
|
+
# Cleaned markdown: the human body (minus the JSON appendix) + a static-findings section.
|
|
207
|
+
cleaned = strip_json_appendix(raw, span) if span is not None else (raw.rstrip() + "\n")
|
|
208
|
+
if static_findings:
|
|
209
|
+
cleaned = cleaned.rstrip() + "\n\n" + sak_lib.render_findings_md(
|
|
210
|
+
static_findings, "Static analysis (deterministic)")
|
|
211
|
+
header = (
|
|
212
|
+
f"<!-- scrypto-audit-kit {args.kit_version} · model {args.model} · "
|
|
213
|
+
f"checklist {args.checklist_version} · source {args.source_hash[:12]} · {args.generated_at} -->\n"
|
|
214
|
+
)
|
|
215
|
+
with open(args.out_md, "w", encoding="utf-8") as fh:
|
|
216
|
+
fh.write(header + cleaned)
|
|
217
|
+
|
|
218
|
+
findings = obj.get("findings", [])
|
|
219
|
+
by_sev = {}
|
|
220
|
+
for f in findings:
|
|
221
|
+
by_sev[f.get("severity", "?")] = by_sev.get(f.get("severity", "?"), 0) + 1
|
|
222
|
+
sev_str = ", ".join(f"{k}:{v}" for k, v in sorted(by_sev.items())) or "none"
|
|
223
|
+
extra = f"; {len(static_findings)} from static" if static_findings else ""
|
|
224
|
+
print(f"report.json: {len(findings)} findings ({sev_str}){extra}")
|
|
225
|
+
return 0
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
if __name__ == "__main__":
|
|
229
|
+
sys.exit(main())
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Generate scrypto-test property-test scaffolds from a blueprint's surface.
|
|
3
|
+
|
|
4
|
+
Deterministic, no API. Reads the `enable_method_auth!` surface (roles + per-method gating)
|
|
5
|
+
and the struct's Vault fields, then emits compilable `#[test] #[ignore]` scaffolds with
|
|
6
|
+
`todo!()` bodies:
|
|
7
|
+
|
|
8
|
+
- auth negative-path tests for each role-restricted method (the most-missed coverage),
|
|
9
|
+
- happy-path tests for each public method,
|
|
10
|
+
- a value-conservation invariant test when the blueprint custodies vaults.
|
|
11
|
+
|
|
12
|
+
The scaffolds compile and are `#[ignore]`d, so they never break `cargo test`; the developer
|
|
13
|
+
(or an agent) removes `#[ignore]` and fills in each `todo!()`. The kit stays read-only — this
|
|
14
|
+
prints/returns the scaffolds, it never writes into the audited package.
|
|
15
|
+
|
|
16
|
+
Stdlib only; reuses the comment/string stripper from static_analysis. Importable + CLI.
|
|
17
|
+
"""
|
|
18
|
+
import argparse
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import re
|
|
22
|
+
import sys
|
|
23
|
+
|
|
24
|
+
import static_analysis
|
|
25
|
+
|
|
26
|
+
_GATING_RE = re.compile(r"(\w+)\s*=>\s*(PUBLIC|restrict_to:\s*\[\s*([\w, ]+?)\s*\])")
|
|
27
|
+
_IMPL_RE = re.compile(r"\bimpl\s+(\w+)")
|
|
28
|
+
_VAULT_RE = re.compile(r"(\w+)\s*:\s*(?:Vault|KeyValueStore<[^>]*\bVault\b[^>]*>)")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _stripped_sources(pkg_dir):
|
|
32
|
+
src_dir = os.path.join(pkg_dir, "src")
|
|
33
|
+
base = src_dir if os.path.isdir(src_dir) else pkg_dir
|
|
34
|
+
chunks = []
|
|
35
|
+
for root, _dirs, files in os.walk(base):
|
|
36
|
+
for name in sorted(files):
|
|
37
|
+
if name.endswith(".rs"):
|
|
38
|
+
with open(os.path.join(root, name), encoding="utf-8", errors="replace") as fh:
|
|
39
|
+
chunks.append(static_analysis.strip_comments_and_strings(fh.read()))
|
|
40
|
+
return "\n".join(chunks)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def extract_surface(pkg_dir):
|
|
44
|
+
"""Return {blueprint, methods:[{name,gating,roles}], roles:[...], vault_fields:[...]}."""
|
|
45
|
+
src = _stripped_sources(pkg_dir)
|
|
46
|
+
|
|
47
|
+
methods, roles = [], set()
|
|
48
|
+
for m in _GATING_RE.finditer(src):
|
|
49
|
+
name, gating = m.group(1), m.group(2)
|
|
50
|
+
if gating.startswith("PUBLIC"):
|
|
51
|
+
methods.append({"name": name, "gating": "public", "roles": []})
|
|
52
|
+
else:
|
|
53
|
+
mr = [r.strip() for r in m.group(3).split(",") if r.strip()]
|
|
54
|
+
roles.update(mr)
|
|
55
|
+
methods.append({"name": name, "gating": "restricted", "roles": mr})
|
|
56
|
+
|
|
57
|
+
impls = _IMPL_RE.findall(src)
|
|
58
|
+
blueprint = impls[0] if impls else "Blueprint"
|
|
59
|
+
vault_fields = sorted(set(_VAULT_RE.findall(src)))
|
|
60
|
+
return {"blueprint": blueprint, "methods": methods, "roles": sorted(roles), "vault_fields": vault_fields}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def propose_specs(surface):
|
|
64
|
+
"""Turn a surface into an ordered list of property-test specs."""
|
|
65
|
+
specs = []
|
|
66
|
+
for meth in surface["methods"]:
|
|
67
|
+
if meth["gating"] == "restricted":
|
|
68
|
+
badge = " / ".join(meth["roles"]) or "required"
|
|
69
|
+
specs.append({
|
|
70
|
+
"name": f"{meth['name']}_rejects_unauthorized_caller",
|
|
71
|
+
"kind": "auth-negative",
|
|
72
|
+
"target": meth["name"],
|
|
73
|
+
"invariant": f"`{meth['name']}` must fail when called without the `{badge}` badge",
|
|
74
|
+
"hint": f"instantiate, then call {meth['name']} WITHOUT the {badge} badge and assert "
|
|
75
|
+
f"the call fails (an auth error)",
|
|
76
|
+
})
|
|
77
|
+
for meth in surface["methods"]:
|
|
78
|
+
if meth["gating"] == "public":
|
|
79
|
+
specs.append({
|
|
80
|
+
"name": f"{meth['name']}_happy_path",
|
|
81
|
+
"kind": "happy-path",
|
|
82
|
+
"target": meth["name"],
|
|
83
|
+
"invariant": f"`{meth['name']}` succeeds on a valid call and makes the expected state change",
|
|
84
|
+
"hint": f"instantiate, call {meth['name']} with valid inputs, assert the expected result/state",
|
|
85
|
+
})
|
|
86
|
+
if surface["vault_fields"]:
|
|
87
|
+
fields = ", ".join(surface["vault_fields"])
|
|
88
|
+
specs.append({
|
|
89
|
+
"name": "value_conservation_invariant",
|
|
90
|
+
"kind": "invariant",
|
|
91
|
+
"target": None,
|
|
92
|
+
"invariant": "custodied value is conserved across operations (no value created or lost)",
|
|
93
|
+
"hint": f"after each public call, assert the sum of vault balances ({fields}) matches the "
|
|
94
|
+
f"tracked totals — value is neither minted nor lost",
|
|
95
|
+
})
|
|
96
|
+
return specs
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def render_rust(specs, surface):
|
|
100
|
+
"""Render specs as a compilable, #[ignore]d scrypto-test scaffold file."""
|
|
101
|
+
out = [
|
|
102
|
+
"//! Property-test scaffolds generated by scrypto-audit-kit.",
|
|
103
|
+
f"//! Blueprint: {surface['blueprint']}. These compile and are #[ignore]d — remove the",
|
|
104
|
+
"//! #[ignore] and fill in each todo!() to turn a coverage gap into an executable check.",
|
|
105
|
+
"#![allow(unused)]",
|
|
106
|
+
"use scrypto_test::prelude::*;",
|
|
107
|
+
"",
|
|
108
|
+
]
|
|
109
|
+
if not specs:
|
|
110
|
+
out.append("// No role-restricted methods, public methods, or vaults detected.")
|
|
111
|
+
out.append("")
|
|
112
|
+
return "\n".join(out)
|
|
113
|
+
for spec in specs:
|
|
114
|
+
out.append(f"/// {spec['invariant']}.")
|
|
115
|
+
out.append("#[test]")
|
|
116
|
+
out.append('#[ignore = "scaffold from scrypto-audit-kit — implement, then un-ignore"]')
|
|
117
|
+
out.append(f"fn {spec['name']}() {{")
|
|
118
|
+
out.append(f' todo!("{spec["hint"]}");')
|
|
119
|
+
out.append("}")
|
|
120
|
+
out.append("")
|
|
121
|
+
return "\n".join(out)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def propose_tests(pkg_dir):
|
|
125
|
+
"""Top-level: returns {blueprint, count, specs, rust} for a package."""
|
|
126
|
+
surface = extract_surface(pkg_dir)
|
|
127
|
+
specs = propose_specs(surface)
|
|
128
|
+
return {
|
|
129
|
+
"blueprint": surface["blueprint"],
|
|
130
|
+
"count": len(specs),
|
|
131
|
+
"specs": specs,
|
|
132
|
+
"rust": render_rust(specs, surface),
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def main():
|
|
137
|
+
ap = argparse.ArgumentParser(description="Generate scrypto-test property-test scaffolds.")
|
|
138
|
+
ap.add_argument("package", help="path to the Scrypto package (or a src dir)")
|
|
139
|
+
ap.add_argument("--json", action="store_true", help="print the specs as JSON instead of Rust")
|
|
140
|
+
ap.add_argument("--out", help="write the Rust scaffold to this file")
|
|
141
|
+
args = ap.parse_args()
|
|
142
|
+
|
|
143
|
+
result = propose_tests(args.package)
|
|
144
|
+
if args.json:
|
|
145
|
+
json.dump({k: result[k] for k in ("blueprint", "count", "specs")}, sys.stdout, indent=2)
|
|
146
|
+
sys.stdout.write("\n")
|
|
147
|
+
elif args.out:
|
|
148
|
+
with open(args.out, "w", encoding="utf-8") as fh:
|
|
149
|
+
fh.write(result["rust"])
|
|
150
|
+
else:
|
|
151
|
+
sys.stdout.write(result["rust"])
|
|
152
|
+
sys.stderr.write(f"[gen-tests] {result['count']} scaffold(s) for {result['blueprint']}\n")
|
|
153
|
+
return 0
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
if __name__ == "__main__":
|
|
157
|
+
sys.exit(main())
|