ski-audit-ledger 3.0.3__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.
- audit_ledger/__init__.py +22 -0
- audit_ledger/canonical.py +56 -0
- audit_ledger/cli.py +304 -0
- audit_ledger/ledger.py +508 -0
- audit_ledger/models.py +144 -0
- audit_ledger/py.typed +0 -0
- audit_ledger/replay.py +271 -0
- ski_audit_ledger-3.0.3.dist-info/METADATA +581 -0
- ski_audit_ledger-3.0.3.dist-info/RECORD +12 -0
- ski_audit_ledger-3.0.3.dist-info/WHEEL +5 -0
- ski_audit_ledger-3.0.3.dist-info/entry_points.txt +2 -0
- ski_audit_ledger-3.0.3.dist-info/top_level.txt +1 -0
audit_ledger/__init__.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""audit-ledger — verify, export, report, and back up the SKI Framework audit ledger."""
|
|
2
|
+
|
|
3
|
+
from .canonical import canonical_entry_payload
|
|
4
|
+
from .ledger import Ledger
|
|
5
|
+
from .models import (
|
|
6
|
+
BackupResult,
|
|
7
|
+
ExportResult,
|
|
8
|
+
LedgerEntry,
|
|
9
|
+
ReportResult,
|
|
10
|
+
VerificationResult,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
__version__ = "3.0.3"
|
|
14
|
+
__all__ = [
|
|
15
|
+
"BackupResult",
|
|
16
|
+
"ExportResult",
|
|
17
|
+
"Ledger",
|
|
18
|
+
"LedgerEntry",
|
|
19
|
+
"ReportResult",
|
|
20
|
+
"VerificationResult",
|
|
21
|
+
"canonical_entry_payload",
|
|
22
|
+
]
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Canonical serialization used to compute the audit-ledger entry hash.
|
|
2
|
+
|
|
3
|
+
This module is duplicated (deliberately and exactly) between the
|
|
4
|
+
reference-implementation and the audit-ledger tool so that third parties
|
|
5
|
+
can re-verify the ledger using either codebase — or by reimplementing the
|
|
6
|
+
function from this docstring alone.
|
|
7
|
+
|
|
8
|
+
Algorithm
|
|
9
|
+
---------
|
|
10
|
+
1. Build a JSON object with the following keys in any order:
|
|
11
|
+
sequence_number, previous_hash, timestamp, verdict, telemetry_id,
|
|
12
|
+
telemetry_hash, rule_id, kg_version, ski_model_version, reasoning, track.
|
|
13
|
+
2. Serialise using:
|
|
14
|
+
json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
|
|
15
|
+
3. Encode as UTF-8.
|
|
16
|
+
4. Hash with SHA-256.
|
|
17
|
+
|
|
18
|
+
`timestamp` is the ISO-8601 string with timezone offset, as written to the
|
|
19
|
+
database. `previous_hash` is the prior entry's entry_hash; for sequence 1
|
|
20
|
+
it is "0" * 64.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import json
|
|
26
|
+
from typing import Optional
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def canonical_entry_payload(
|
|
30
|
+
*,
|
|
31
|
+
sequence_number: int,
|
|
32
|
+
previous_hash: str,
|
|
33
|
+
timestamp_iso: str,
|
|
34
|
+
verdict: str,
|
|
35
|
+
telemetry_id: str,
|
|
36
|
+
telemetry_hash: str,
|
|
37
|
+
rule_id: Optional[str],
|
|
38
|
+
kg_version: Optional[str],
|
|
39
|
+
ski_model_version: str,
|
|
40
|
+
reasoning: Optional[str],
|
|
41
|
+
track: Optional[str],
|
|
42
|
+
) -> bytes:
|
|
43
|
+
payload = {
|
|
44
|
+
"sequence_number": sequence_number,
|
|
45
|
+
"previous_hash": previous_hash,
|
|
46
|
+
"timestamp": timestamp_iso,
|
|
47
|
+
"verdict": verdict,
|
|
48
|
+
"telemetry_id": telemetry_id,
|
|
49
|
+
"telemetry_hash": telemetry_hash,
|
|
50
|
+
"rule_id": rule_id,
|
|
51
|
+
"kg_version": kg_version,
|
|
52
|
+
"ski_model_version": ski_model_version,
|
|
53
|
+
"reasoning": reasoning,
|
|
54
|
+
"track": track,
|
|
55
|
+
}
|
|
56
|
+
return json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
|
audit_ledger/cli.py
ADDED
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
"""CLI for the audit-ledger tool.
|
|
2
|
+
|
|
3
|
+
v2.1 changes:
|
|
4
|
+
* Five-verdict taxonomy in `--verdict-filter` (NULL → NULL_UNMAPPED, NULL_STALE).
|
|
5
|
+
* `verify` reports entry-hash recomputation separately from chain
|
|
6
|
+
linkage — these are different integrity checks.
|
|
7
|
+
* `backup` actually invokes `pg_dump` and verifies with `pg_restore --list`.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
import click
|
|
15
|
+
|
|
16
|
+
from .ledger import Ledger
|
|
17
|
+
|
|
18
|
+
VERDICT_CHOICES = ["CLEAR", "FLAG", "NULL_UNMAPPED", "NULL_STALE", "DISCRETIONARY"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@click.group()
|
|
22
|
+
@click.version_option(version="0.1.0a0")
|
|
23
|
+
def main() -> None:
|
|
24
|
+
"""audit-ledger — manage SKI Framework compliance ledger."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@main.command()
|
|
28
|
+
@click.option("--ledger-db", required=True, envvar="LEDGER_DSN", help="PostgreSQL connection string.")
|
|
29
|
+
@click.option("--verbose", is_flag=True)
|
|
30
|
+
@click.option("--output", help="Write a plain-text summary to file.")
|
|
31
|
+
def verify(ledger_db: str, verbose: bool, output: Optional[str]) -> None:
|
|
32
|
+
"""Verify ledger integrity: chain linkage AND entry-hash recomputation."""
|
|
33
|
+
ledger = Ledger(ledger_db)
|
|
34
|
+
result = ledger.verify_integrity(verbose=verbose)
|
|
35
|
+
|
|
36
|
+
lines = [
|
|
37
|
+
"Ledger integrity verification",
|
|
38
|
+
"=" * 40,
|
|
39
|
+
f"Total entries: {result.total_entries}",
|
|
40
|
+
f"Sequence range: {result.sequence_range}",
|
|
41
|
+
f"Time range: {result.time_range}",
|
|
42
|
+
f"Sequence continuity: {'OK' if result.chain_continuity else 'FAIL'}",
|
|
43
|
+
f"Timestamp ordering: {'OK' if result.timestamp_ordering else 'FAIL'}",
|
|
44
|
+
f"Chain linkage verified: {result.chain_link_verified_count} / {result.hash_verification_total}",
|
|
45
|
+
f"Entry-hash recomputation: {result.entry_hash_verified_count} / {result.hash_verification_total}",
|
|
46
|
+
"",
|
|
47
|
+
"Verdict distribution:",
|
|
48
|
+
]
|
|
49
|
+
for v in VERDICT_CHOICES:
|
|
50
|
+
lines.append(f" {v}: {result.verdict_distribution.get(v, 0)}")
|
|
51
|
+
|
|
52
|
+
lines += [
|
|
53
|
+
"",
|
|
54
|
+
f"Overall: {'PASS' if result.is_valid else 'FAIL'}",
|
|
55
|
+
f"Recommendation: {result.recommendation}",
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
if result.issues:
|
|
59
|
+
lines.append("")
|
|
60
|
+
lines.append("Issues:")
|
|
61
|
+
for issue in result.issues:
|
|
62
|
+
lines.append(
|
|
63
|
+
f" [{issue.severity}] {issue.issue_type} @ seq={issue.sequence_number}: {issue.description}"
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
out = "\n".join(lines)
|
|
67
|
+
click.echo(out)
|
|
68
|
+
if output:
|
|
69
|
+
with open(output, "w") as f:
|
|
70
|
+
f.write(out + "\n")
|
|
71
|
+
if not result.is_valid:
|
|
72
|
+
raise SystemExit(1)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@main.command()
|
|
76
|
+
@click.option("--source", required=True, envvar="LEDGER_DSN")
|
|
77
|
+
@click.option("--output", required=True)
|
|
78
|
+
@click.option("--format", "out_format", type=click.Choice(["json", "csv", "jsonl"]), default="json")
|
|
79
|
+
@click.option("--fields")
|
|
80
|
+
@click.option("--date-range", help='"YYYY-MM-DD,YYYY-MM-DD"')
|
|
81
|
+
@click.option("--verdict-filter", type=click.Choice(VERDICT_CHOICES))
|
|
82
|
+
@click.option("--rule-id")
|
|
83
|
+
@click.option("--limit", type=int)
|
|
84
|
+
def export(
|
|
85
|
+
source: str,
|
|
86
|
+
output: str,
|
|
87
|
+
out_format: str,
|
|
88
|
+
fields: Optional[str],
|
|
89
|
+
date_range: Optional[str],
|
|
90
|
+
verdict_filter: Optional[str],
|
|
91
|
+
rule_id: Optional[str],
|
|
92
|
+
limit: Optional[int],
|
|
93
|
+
) -> None:
|
|
94
|
+
"""Export ledger entries for analysis."""
|
|
95
|
+
ledger = Ledger(source)
|
|
96
|
+
start_date = end_date = None
|
|
97
|
+
if date_range:
|
|
98
|
+
parts = [s.strip() or None for s in date_range.split(",")]
|
|
99
|
+
start_date = parts[0] if parts else None
|
|
100
|
+
end_date = parts[1] if len(parts) > 1 else None
|
|
101
|
+
field_list = [s.strip() for s in fields.split(",")] if fields else None
|
|
102
|
+
|
|
103
|
+
result = ledger.export_entries(
|
|
104
|
+
output_file=output,
|
|
105
|
+
format=out_format,
|
|
106
|
+
start_date=start_date,
|
|
107
|
+
end_date=end_date,
|
|
108
|
+
verdict_filter=verdict_filter,
|
|
109
|
+
rule_id_filter=rule_id,
|
|
110
|
+
fields=field_list,
|
|
111
|
+
limit=limit,
|
|
112
|
+
)
|
|
113
|
+
click.echo(f"Exported {result.entry_count} entries → {output}")
|
|
114
|
+
click.echo(f"Size: {result.size_bytes} bytes SHA-256: {result.checksum}")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@main.command()
|
|
118
|
+
@click.option("--source", required=True, envvar="LEDGER_DSN")
|
|
119
|
+
@click.option("--output", required=True)
|
|
120
|
+
@click.option("--start-date", required=True)
|
|
121
|
+
@click.option("--end-date", required=True)
|
|
122
|
+
@click.option("--include-violations/--no-include-violations", default=True)
|
|
123
|
+
@click.option("--organization", default="Organization")
|
|
124
|
+
def report(
|
|
125
|
+
source: str, output: str, start_date: str, end_date: str, include_violations: bool, organization: str
|
|
126
|
+
) -> None:
|
|
127
|
+
"""Generate an HTML compliance report from ledger data."""
|
|
128
|
+
ledger = Ledger(source)
|
|
129
|
+
result = ledger.generate_report(
|
|
130
|
+
start_date=start_date,
|
|
131
|
+
end_date=end_date,
|
|
132
|
+
include_violations=include_violations,
|
|
133
|
+
organization=organization,
|
|
134
|
+
)
|
|
135
|
+
with open(output, "w") as f:
|
|
136
|
+
f.write(_html_report(result, "Compliance report"))
|
|
137
|
+
click.echo(f"Report written to {output}")
|
|
138
|
+
click.echo(f"Period: {start_date} → {end_date}")
|
|
139
|
+
click.echo(f"Total entries analysed: {result.total_entries_analyzed}")
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@main.command()
|
|
143
|
+
@click.option("--source", required=True, envvar="LEDGER_DSN")
|
|
144
|
+
@click.option("--output", "--out", required=True, help="pg_dump custom-format output path.")
|
|
145
|
+
@click.option("--compress/--no-compress", default=False)
|
|
146
|
+
@click.option("--verify/--no-verify", default=True, help="Run `pg_restore --list` after dump.")
|
|
147
|
+
def backup(source: str, output: str, compress: bool, verify: bool) -> None:
|
|
148
|
+
"""Create a real backup via pg_dump. No stub."""
|
|
149
|
+
ledger = Ledger(source)
|
|
150
|
+
result = ledger.backup_database(output_file=output, compress=compress, verify=verify)
|
|
151
|
+
click.echo(f"Backup created: {result.backup_file}")
|
|
152
|
+
click.echo(f"Backup date: {result.backup_date}")
|
|
153
|
+
click.echo(f"Size: {result.size_bytes} bytes")
|
|
154
|
+
click.echo(f"Compressed: {result.compressed}")
|
|
155
|
+
click.echo(f"Verified: {result.verified}")
|
|
156
|
+
click.echo(f"SHA-256: {result.checksum}")
|
|
157
|
+
if result.verification_status:
|
|
158
|
+
click.echo(f"Verification: {result.verification_status}")
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@main.command()
|
|
162
|
+
@click.option("--source", required=True, envvar="LEDGER_DSN")
|
|
163
|
+
@click.option(
|
|
164
|
+
"--from-sequence",
|
|
165
|
+
"from_sequence",
|
|
166
|
+
required=True,
|
|
167
|
+
type=int,
|
|
168
|
+
help="Lowest sequence number to replay (inclusive).",
|
|
169
|
+
)
|
|
170
|
+
@click.option(
|
|
171
|
+
"--to-sequence",
|
|
172
|
+
"to_sequence",
|
|
173
|
+
required=True,
|
|
174
|
+
type=int,
|
|
175
|
+
help="Highest sequence number to replay (inclusive).",
|
|
176
|
+
)
|
|
177
|
+
@click.option(
|
|
178
|
+
"--kg-path",
|
|
179
|
+
required=True,
|
|
180
|
+
type=click.Path(exists=True, dir_okay=False),
|
|
181
|
+
help="Signed KG used during the original evaluation.",
|
|
182
|
+
)
|
|
183
|
+
@click.option("--tenant-id", default="default", show_default=True)
|
|
184
|
+
@click.option("--output", default=None, help="Optional path to write the JSON replay report.")
|
|
185
|
+
@click.option(
|
|
186
|
+
"--strict/--no-strict", default=True, show_default=True, help="Exit non-zero on any verdict divergence."
|
|
187
|
+
)
|
|
188
|
+
def replay(
|
|
189
|
+
source: str,
|
|
190
|
+
from_sequence: int,
|
|
191
|
+
to_sequence: int,
|
|
192
|
+
kg_path: str,
|
|
193
|
+
tenant_id: str,
|
|
194
|
+
output: Optional[str],
|
|
195
|
+
strict: bool,
|
|
196
|
+
) -> None:
|
|
197
|
+
"""v0.2.0 — replay ledger entries against the recorded KG and buffer.
|
|
198
|
+
|
|
199
|
+
For every entry in [from-sequence, to-sequence] this command re-runs
|
|
200
|
+
the Symbolic Evaluator against the telemetry buffer state at the
|
|
201
|
+
entry's timestamp and compares the produced verdict to what was
|
|
202
|
+
recorded. v0.1 ledger entries and Track 2 (LLM) entries are skipped
|
|
203
|
+
with a note.
|
|
204
|
+
"""
|
|
205
|
+
import json as _json
|
|
206
|
+
|
|
207
|
+
from .replay import replay as run_replay
|
|
208
|
+
|
|
209
|
+
report = run_replay(
|
|
210
|
+
dsn=source,
|
|
211
|
+
from_sequence=from_sequence,
|
|
212
|
+
to_sequence=to_sequence,
|
|
213
|
+
kg_path=kg_path,
|
|
214
|
+
tenant_id=tenant_id,
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
click.echo(
|
|
218
|
+
f"Replay: {report.replayed_entries}/{report.total_entries} entries replayed, "
|
|
219
|
+
f"{report.matched_entries} matched, {len(report.mismatches)} diverged, "
|
|
220
|
+
f"{report.skipped_entries} skipped."
|
|
221
|
+
)
|
|
222
|
+
for note in report.notes[:5]:
|
|
223
|
+
click.echo(f" note: {note}")
|
|
224
|
+
if len(report.notes) > 5:
|
|
225
|
+
click.echo(f" ... and {len(report.notes) - 5} more notes")
|
|
226
|
+
if report.mismatches:
|
|
227
|
+
click.echo("\nDivergences:")
|
|
228
|
+
for m in report.mismatches[:10]:
|
|
229
|
+
click.echo(
|
|
230
|
+
f" seq={m.sequence_number}: recorded={m.recorded_verdict} replayed={m.replayed_verdict} ({m.reason})"
|
|
231
|
+
)
|
|
232
|
+
if len(report.mismatches) > 10:
|
|
233
|
+
click.echo(f" ... and {len(report.mismatches) - 10} more")
|
|
234
|
+
if output:
|
|
235
|
+
with open(output, "w") as f:
|
|
236
|
+
_json.dump(report.to_dict(), f, indent=2, default=str)
|
|
237
|
+
click.echo(f"\nFull report: {output}")
|
|
238
|
+
|
|
239
|
+
if strict and not report.is_clean:
|
|
240
|
+
raise SystemExit(1)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
# ---------------------------------------------------------------------------
|
|
244
|
+
# HTML report (5-verdict)
|
|
245
|
+
# ---------------------------------------------------------------------------
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _html_report(result, title: str) -> str:
|
|
249
|
+
vs = result.verdict_summary
|
|
250
|
+
rows = [
|
|
251
|
+
("CLEAR", vs.clear, vs.clear_percent, "verdict-clear"),
|
|
252
|
+
("FLAG", vs.flag, vs.flag_percent, "verdict-flag"),
|
|
253
|
+
("NULL_UNMAPPED", vs.null_unmapped, vs.null_unmapped_percent, "verdict-null"),
|
|
254
|
+
("NULL_STALE", vs.null_stale, vs.null_stale_percent, "verdict-null"),
|
|
255
|
+
("DISCRETIONARY", vs.discretionary, vs.discretionary_percent, "verdict-discretionary"),
|
|
256
|
+
]
|
|
257
|
+
rows_html = "\n".join(
|
|
258
|
+
f'<tr><td class="{cls}">{name}</td><td>{count}</td><td>{pct:.1f}%</td></tr>'
|
|
259
|
+
for name, count, pct, cls in rows
|
|
260
|
+
)
|
|
261
|
+
return f"""<!doctype html>
|
|
262
|
+
<html lang="en"><head><meta charset="utf-8"><title>{title}</title>
|
|
263
|
+
<style>
|
|
264
|
+
body{{font-family:system-ui,Arial,sans-serif;margin:20px;}}
|
|
265
|
+
.header{{border-bottom:2px solid #333;padding-bottom:10px;margin-bottom:20px;}}
|
|
266
|
+
.section{{margin-bottom:30px;}}
|
|
267
|
+
.section h2{{background-color:#f0f0f0;padding:10px;}}
|
|
268
|
+
table{{border-collapse:collapse;width:100%;margin-top:10px;}}
|
|
269
|
+
th,td{{border:1px solid #ddd;padding:8px;text-align:left;}}
|
|
270
|
+
th{{background-color:#f0f0f0;}}
|
|
271
|
+
.verdict-clear{{color:green;}}
|
|
272
|
+
.verdict-flag{{color:red;}}
|
|
273
|
+
.verdict-discretionary{{color:orange;}}
|
|
274
|
+
.verdict-null{{color:gray;}}
|
|
275
|
+
</style></head><body>
|
|
276
|
+
<div class="header">
|
|
277
|
+
<h1>{title}</h1>
|
|
278
|
+
<p><strong>Organization:</strong> {result.organization}</p>
|
|
279
|
+
<p><strong>Period:</strong> {result.start_date.date()} to {result.end_date.date()}</p>
|
|
280
|
+
<p><strong>Generated:</strong> {result.report_date}</p>
|
|
281
|
+
</div>
|
|
282
|
+
<div class="section">
|
|
283
|
+
<h2>Verdict summary</h2>
|
|
284
|
+
<table>
|
|
285
|
+
<tr><th>Verdict</th><th>Count</th><th>Percent</th></tr>
|
|
286
|
+
{rows_html}
|
|
287
|
+
</table>
|
|
288
|
+
</div>
|
|
289
|
+
<div class="section">
|
|
290
|
+
<h2>Analysis</h2>
|
|
291
|
+
<p><strong>Total entries analysed:</strong> {result.total_entries_analyzed}</p>
|
|
292
|
+
<p><strong>Compliance status:</strong>
|
|
293
|
+
{("CLEAR" if vs.flag == 0 else "BREACHES DETECTED")}
|
|
294
|
+
</p>
|
|
295
|
+
</div>
|
|
296
|
+
<div class="section">
|
|
297
|
+
<p><em>Generated by audit-ledger (SKI Framework v2.1).</em></p>
|
|
298
|
+
</div>
|
|
299
|
+
</body></html>
|
|
300
|
+
"""
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
if __name__ == "__main__": # pragma: no cover
|
|
304
|
+
main()
|