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.
@@ -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()