contextwall 0.1.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.
Files changed (66) hide show
  1. context_firewall/__init__.py +3 -0
  2. context_firewall/analytics/__init__.py +5 -0
  3. context_firewall/analytics/engine.py +234 -0
  4. context_firewall/api/__init__.py +5 -0
  5. context_firewall/api/app.py +1020 -0
  6. context_firewall/api/models.py +54 -0
  7. context_firewall/classifier/__init__.py +5 -0
  8. context_firewall/classifier/classifier.py +165 -0
  9. context_firewall/cli/__init__.py +5 -0
  10. context_firewall/cli/main.py +513 -0
  11. context_firewall/compliance/__init__.py +1 -0
  12. context_firewall/compliance/baa.py +57 -0
  13. context_firewall/compliance/chain.py +84 -0
  14. context_firewall/compliance/control_mappings.py +92 -0
  15. context_firewall/compliance/export.py +280 -0
  16. context_firewall/compliance/keys.py +94 -0
  17. context_firewall/config.py +234 -0
  18. context_firewall/control_plane/__init__.py +0 -0
  19. context_firewall/control_plane/client.py +107 -0
  20. context_firewall/control_plane/models.py +72 -0
  21. context_firewall/control_plane/pusher.py +377 -0
  22. context_firewall/daemon/__init__.py +5 -0
  23. context_firewall/daemon/main.py +461 -0
  24. context_firewall/db/__init__.py +6 -0
  25. context_firewall/db/connection.py +30 -0
  26. context_firewall/db/migrations.py +408 -0
  27. context_firewall/entropy/__init__.py +5 -0
  28. context_firewall/entropy/engine.py +271 -0
  29. context_firewall/graph/__init__.py +5 -0
  30. context_firewall/graph/engine.py +162 -0
  31. context_firewall/lint/__init__.py +0 -0
  32. context_firewall/lint/engine.py +334 -0
  33. context_firewall/mcp/__init__.py +5 -0
  34. context_firewall/mcp/server.py +237 -0
  35. context_firewall/metrics.py +100 -0
  36. context_firewall/models.py +130 -0
  37. context_firewall/policy/__init__.py +10 -0
  38. context_firewall/policy/detectors/__init__.py +0 -0
  39. context_firewall/policy/detectors/injection.py +480 -0
  40. context_firewall/policy/dsl/__init__.py +0 -0
  41. context_firewall/policy/dsl/evaluator.py +208 -0
  42. context_firewall/policy/dsl/loader.py +150 -0
  43. context_firewall/policy/dsl/types.py +112 -0
  44. context_firewall/policy/engine.py +532 -0
  45. context_firewall/provenance/__init__.py +5 -0
  46. context_firewall/provenance/engine.py +658 -0
  47. context_firewall/provenance/models.py +91 -0
  48. context_firewall/proxy/__init__.py +1 -0
  49. context_firewall/proxy/router.py +519 -0
  50. context_firewall/proxy/scanner.py +214 -0
  51. context_firewall/proxy/tokens.py +156 -0
  52. context_firewall/runtime/__init__.py +5 -0
  53. context_firewall/runtime/engine.py +303 -0
  54. context_firewall/source/__init__.py +1 -0
  55. context_firewall/source/registry.py +299 -0
  56. context_firewall/source/types.py +12 -0
  57. context_firewall/synthesizer/__init__.py +5 -0
  58. context_firewall/synthesizer/synthesizer.py +255 -0
  59. context_firewall/trust/__init__.py +5 -0
  60. context_firewall/trust/engine.py +139 -0
  61. context_firewall/trust/signals.py +184 -0
  62. contextwall-0.1.0.dist-info/METADATA +431 -0
  63. contextwall-0.1.0.dist-info/RECORD +66 -0
  64. contextwall-0.1.0.dist-info/WHEEL +4 -0
  65. contextwall-0.1.0.dist-info/entry_points.txt +3 -0
  66. contextwall-0.1.0.dist-info/licenses/LICENSE +33 -0
@@ -0,0 +1,513 @@
1
+ """ctxfw CLI - analyze, daemon, replay, refresh, init."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ import click
10
+ import httpx
11
+
12
+ try:
13
+ from rich.console import Console
14
+ from rich.table import Table
15
+ from rich.syntax import Syntax
16
+ _rich = True
17
+ except ImportError:
18
+ _rich = False
19
+
20
+
21
+ console = Console() if _rich else None
22
+
23
+
24
+ def _print(data, output_format: str):
25
+ if output_format == "json":
26
+ click.echo(json.dumps(data, indent=2, default=str))
27
+ elif _rich and console:
28
+ if isinstance(data, str):
29
+ console.print(data)
30
+ else:
31
+ console.print_json(json.dumps(data, default=str))
32
+ else:
33
+ click.echo(json.dumps(data, indent=2, default=str))
34
+
35
+
36
+ @click.group()
37
+ @click.option("--config", default="ctxfw.yaml", envvar="CTXFW_CONFIG", help="Config file path")
38
+ @click.option("--api-url", default="http://localhost:8080", envvar="CTXFW_API_URL", help="Daemon API URL")
39
+ @click.option("--token", envvar="CTXFW_API_TOKEN", help="API token", default="")
40
+ @click.option("--output", type=click.Choice(["human", "json"]), default="human", help="Output format")
41
+ @click.pass_context
42
+ def cli(ctx, config, api_url, token, output):
43
+ """ContextWall CLI."""
44
+ ctx.ensure_object(dict)
45
+ ctx.obj["config"] = config
46
+ ctx.obj["api_url"] = api_url.rstrip("/")
47
+ ctx.obj["token"] = token
48
+ ctx.obj["output"] = output
49
+
50
+
51
+ def _headers(ctx) -> dict:
52
+ token = ctx.obj.get("token", "")
53
+ if token:
54
+ return {"Authorization": f"Bearer {token}"}
55
+ return {}
56
+
57
+
58
+ @cli.command()
59
+ @click.argument("task")
60
+ @click.option("--session-id", default=None, help="Session ID")
61
+ @click.option("--repository-root", default=None, help="Repository root path")
62
+ @click.option("--trust-cutoff", default=0.30, help="Minimum trust score")
63
+ @click.pass_context
64
+ def analyze(ctx, task, session_id, repository_root, trust_cutoff):
65
+ """Analyze a task and return ranked candidate files."""
66
+ api_url = ctx.obj["api_url"]
67
+ output = ctx.obj["output"]
68
+ try:
69
+ resp = httpx.post(
70
+ f"{api_url}/analyze",
71
+ json={
72
+ "task": task,
73
+ "session_id": session_id,
74
+ "repository_root": repository_root,
75
+ "options": {"trust_cutoff": trust_cutoff},
76
+ },
77
+ headers=_headers(ctx),
78
+ timeout=35,
79
+ )
80
+ resp.raise_for_status()
81
+ data = resp.json()
82
+
83
+ if output == "human" and _rich and console:
84
+ table = Table(title=f"Analysis: {task[:60]}")
85
+ table.add_column("File", style="cyan")
86
+ table.add_column("Trust", style="green")
87
+ table.add_column("Tokens", style="yellow")
88
+ for s in data.get("slices", []):
89
+ table.add_row(s["file_path"], f"{s['trust_score']:.2f}", str(s["token_count"]))
90
+ console.print(table)
91
+ console.print(f"\nTask type: {data.get('task_type')} | Total tokens: {data.get('total_tokens')}")
92
+ else:
93
+ _print(data, output)
94
+ except httpx.HTTPError as e:
95
+ click.echo(f"Error: {e}", err=True)
96
+ sys.exit(1)
97
+
98
+
99
+ @cli.command()
100
+ @click.option("--host", default="0.0.0.0", help="Host to bind to")
101
+ @click.option("--port", default=None, type=int, help="Port override")
102
+ @click.option("--config", "config_path", default="ctxfw.yaml", envvar="CTXFW_CONFIG")
103
+ def daemon(host, port, config_path):
104
+ """Start the ContextWall daemon (API + MCP + background jobs)."""
105
+ import asyncio
106
+ from context_firewall.daemon.main import run_daemon
107
+ from context_firewall.config import load_config
108
+
109
+ cfg = load_config(config_path)
110
+ if port:
111
+ cfg.rest_api.port = port
112
+
113
+ asyncio.run(run_daemon(cfg, host=host))
114
+
115
+
116
+ @cli.command()
117
+ @click.argument("session_id")
118
+ @click.option("--request-id", default=None, help="Filter to specific request")
119
+ @click.pass_context
120
+ def replay(ctx, session_id, request_id):
121
+ """Replay provenance events for a session."""
122
+ api_url = ctx.obj["api_url"]
123
+ output = ctx.obj["output"]
124
+ params = {"session_id": session_id}
125
+ if request_id:
126
+ params["request_id"] = request_id
127
+ try:
128
+ resp = httpx.get(
129
+ f"{api_url}/provenance/replay",
130
+ params=params,
131
+ headers=_headers(ctx),
132
+ timeout=15,
133
+ )
134
+ resp.raise_for_status()
135
+ _print(resp.json(), output)
136
+ except httpx.HTTPError as e:
137
+ click.echo(f"Error: {e}", err=True)
138
+ sys.exit(1)
139
+
140
+
141
+ @cli.command()
142
+ @click.argument("files", nargs=-1)
143
+ @click.pass_context
144
+ def refresh(ctx, files):
145
+ """Trigger incremental graph refresh for changed files."""
146
+ api_url = ctx.obj["api_url"]
147
+ output = ctx.obj["output"]
148
+ file_list = list(files) if files else []
149
+
150
+ try:
151
+ resp = httpx.post(
152
+ f"{api_url}/webhook/ci",
153
+ json={"changed_files": file_list, "event": "push"},
154
+ headers=_headers(ctx),
155
+ timeout=10,
156
+ )
157
+ resp.raise_for_status()
158
+ _print({"status": "refresh triggered", "files": file_list}, output)
159
+ except httpx.HTTPError as e:
160
+ click.echo(f"Error: {e}", err=True)
161
+ sys.exit(1)
162
+
163
+
164
+ @cli.group("sources")
165
+ def sources_group():
166
+ """Manage source trust registry entries."""
167
+
168
+
169
+ @sources_group.command("add")
170
+ @click.option("--id", "source_id", required=True, help="Unique source identifier")
171
+ @click.option("--type", "source_type", default="unknown", help="Source type (web, code_repository, api, etc.)")
172
+ @click.option("--tier", required=True, type=click.Choice(["internal", "external", "untrusted", "regulated"]), help="Trust tier")
173
+ @click.option("--owner", default="", help="Owner team or person")
174
+ @click.option("--region", default="", help="Data region (e.g. us-east-1)")
175
+ @click.option("--classification", default="", help="Data classification (phi, pii, pci, financial, etc.)")
176
+ @click.pass_context
177
+ def sources_add(ctx, source_id, source_type, tier, owner, region, classification):
178
+ """Register or update a source in the trust registry."""
179
+ api_url = ctx.obj["api_url"]
180
+ output = ctx.obj["output"]
181
+ try:
182
+ resp = httpx.post(
183
+ f"{api_url}/v1/sources",
184
+ json={
185
+ "id": source_id,
186
+ "type": source_type,
187
+ "trust_tier": tier,
188
+ "owner": owner,
189
+ "region": region,
190
+ "data_classification": classification,
191
+ },
192
+ headers=_headers(ctx),
193
+ timeout=10,
194
+ )
195
+ resp.raise_for_status()
196
+ _print(resp.json(), output)
197
+ except httpx.HTTPError as e:
198
+ click.echo(f"Error: {e}", err=True)
199
+ sys.exit(1)
200
+
201
+
202
+ @sources_group.command("list")
203
+ @click.pass_context
204
+ def sources_list(ctx):
205
+ """List all registered sources."""
206
+ api_url = ctx.obj["api_url"]
207
+ output = ctx.obj["output"]
208
+ try:
209
+ resp = httpx.get(f"{api_url}/v1/sources", headers=_headers(ctx), timeout=10)
210
+ resp.raise_for_status()
211
+ data = resp.json()
212
+ if output == "human" and _rich and console:
213
+ table = Table(title="Registered Sources")
214
+ table.add_column("ID", style="cyan")
215
+ table.add_column("Type", style="yellow")
216
+ table.add_column("Tier", style="green")
217
+ table.add_column("Owner")
218
+ table.add_column("Classification")
219
+ for s in data.get("sources", []):
220
+ table.add_row(
221
+ s["id"], s["type"], s["trust_tier"],
222
+ s.get("owner", ""), s.get("data_classification", ""),
223
+ )
224
+ console.print(table)
225
+ else:
226
+ _print(data, output)
227
+ except httpx.HTTPError as e:
228
+ click.echo(f"Error: {e}", err=True)
229
+ sys.exit(1)
230
+
231
+
232
+ @sources_group.command("get")
233
+ @click.argument("source_id")
234
+ @click.pass_context
235
+ def sources_get(ctx, source_id):
236
+ """Get details for a specific source."""
237
+ api_url = ctx.obj["api_url"]
238
+ output = ctx.obj["output"]
239
+ try:
240
+ resp = httpx.get(f"{api_url}/v1/sources/{source_id}", headers=_headers(ctx), timeout=10)
241
+ resp.raise_for_status()
242
+ _print(resp.json(), output)
243
+ except httpx.HTTPError as e:
244
+ click.echo(f"Error: {e}", err=True)
245
+ sys.exit(1)
246
+
247
+
248
+ @sources_group.command("update")
249
+ @click.argument("source_id")
250
+ @click.option("--tier", type=click.Choice(["internal", "external", "untrusted", "regulated"]), help="New trust tier")
251
+ @click.option("--owner", help="New owner")
252
+ @click.option("--region", help="New region")
253
+ @click.option("--classification", help="New data classification")
254
+ @click.pass_context
255
+ def sources_update(ctx, source_id, tier, owner, region, classification):
256
+ """Update fields on an existing source."""
257
+ api_url = ctx.obj["api_url"]
258
+ output = ctx.obj["output"]
259
+ body: dict = {}
260
+ if tier:
261
+ body["trust_tier"] = tier
262
+ if owner is not None:
263
+ body["owner"] = owner
264
+ if region is not None:
265
+ body["region"] = region
266
+ if classification is not None:
267
+ body["data_classification"] = classification
268
+ if not body:
269
+ click.echo("Nothing to update. Provide at least one option.", err=True)
270
+ sys.exit(1)
271
+ try:
272
+ resp = httpx.patch(f"{api_url}/v1/sources/{source_id}", json=body, headers=_headers(ctx), timeout=10)
273
+ resp.raise_for_status()
274
+ _print(resp.json(), output)
275
+ except httpx.HTTPError as e:
276
+ click.echo(f"Error: {e}", err=True)
277
+ sys.exit(1)
278
+
279
+
280
+ @sources_group.command("remove")
281
+ @click.argument("source_id")
282
+ @click.option("--confirm", is_flag=True, help="Skip confirmation prompt")
283
+ @click.pass_context
284
+ def sources_remove(ctx, source_id, confirm):
285
+ """Soft-delete a source from the registry."""
286
+ api_url = ctx.obj["api_url"]
287
+ output = ctx.obj["output"]
288
+ if not confirm:
289
+ click.confirm(f"Remove source '{source_id}'?", abort=True)
290
+ try:
291
+ resp = httpx.delete(f"{api_url}/v1/sources/{source_id}", headers=_headers(ctx), timeout=10)
292
+ resp.raise_for_status()
293
+ _print(resp.json(), output)
294
+ except httpx.HTTPError as e:
295
+ click.echo(f"Error: {e}", err=True)
296
+ sys.exit(1)
297
+
298
+
299
+ @cli.group("policy")
300
+ def policy_group():
301
+ """Policy management - validate and inspect policy files."""
302
+
303
+
304
+ @policy_group.command("validate")
305
+ @click.argument("path")
306
+ def policy_validate(path):
307
+ """Validate a policy YAML file or directory.
308
+
309
+ Exits 0 on success. Reports errors with context.
310
+ """
311
+ import yaml as _yaml
312
+ from context_firewall.policy.dsl.evaluator import validate_policy_file
313
+
314
+ target = Path(path)
315
+ files = list(target.glob("*.yaml")) + list(target.glob("*.yml")) if target.is_dir() else [target]
316
+
317
+ total_errors = 0
318
+ for f in files:
319
+ try:
320
+ raw = _yaml.safe_load(f.read_text()) or {}
321
+ errors = validate_policy_file(raw)
322
+ if errors:
323
+ click.echo(f"ERRORS in {f}:", err=True)
324
+ for e in errors:
325
+ click.echo(f" {e}", err=True)
326
+ total_errors += len(errors)
327
+ else:
328
+ click.echo(f"OK: {f} ({len(raw.get('rules', []))} rules)")
329
+ except Exception as e:
330
+ click.echo(f"ERROR reading {f}: {e}", err=True)
331
+ total_errors += 1
332
+
333
+ if total_errors:
334
+ sys.exit(1)
335
+ click.echo(f"All {len(files)} file(s) valid.")
336
+
337
+
338
+ @cli.group("compliance")
339
+ def compliance_group():
340
+ """Compliance export - generate and verify signed audit bundles."""
341
+
342
+
343
+ @compliance_group.command("export")
344
+ @click.option("--session", "session_id", default=None, help="Export a specific session")
345
+ @click.option("--from", "from_ts", default=None, help="Start timestamp (ISO 8601)")
346
+ @click.option("--to", "to_ts", default=None, help="End timestamp (ISO 8601)")
347
+ @click.option("--framework", default=None, type=click.Choice(["hipaa", "soc2", "fedramp"]), help="Filter by framework")
348
+ @click.option("--out", default=None, help="Output file path (default: stdout)")
349
+ @click.pass_context
350
+ def compliance_export(ctx, session_id, from_ts, to_ts, framework, out):
351
+ """Export a signed compliance bundle."""
352
+ api_url = ctx.obj["api_url"]
353
+ body: dict = {}
354
+ if session_id:
355
+ endpoint = f"{api_url}/v1/compliance/export/{session_id}"
356
+ params: dict = {}
357
+ if framework:
358
+ params["framework"] = framework
359
+ try:
360
+ resp = httpx.get(endpoint, params=params, headers=_headers(ctx), timeout=30)
361
+ except httpx.HTTPError as e:
362
+ click.echo(f"Error: {e}", err=True)
363
+ sys.exit(1)
364
+ else:
365
+ if from_ts:
366
+ body["from_ts"] = from_ts
367
+ if to_ts:
368
+ body["to_ts"] = to_ts
369
+ if framework:
370
+ body["framework"] = framework
371
+ try:
372
+ resp = httpx.post(f"{api_url}/v1/compliance/export", json=body, headers=_headers(ctx), timeout=30)
373
+ except httpx.HTTPError as e:
374
+ click.echo(f"Error: {e}", err=True)
375
+ sys.exit(1)
376
+
377
+ try:
378
+ resp.raise_for_status()
379
+ except httpx.HTTPStatusError as e:
380
+ click.echo(f"Error: {e}", err=True)
381
+ sys.exit(1)
382
+
383
+ bundle_json = json.dumps(resp.json(), indent=2)
384
+ if out:
385
+ Path(out).write_text(bundle_json)
386
+ click.echo(f"Bundle written to {out}")
387
+ else:
388
+ click.echo(bundle_json)
389
+
390
+
391
+ @compliance_group.command("verify")
392
+ @click.argument("bundle_file")
393
+ @click.pass_context
394
+ def compliance_verify(ctx, bundle_file):
395
+ """Verify a compliance bundle file."""
396
+ api_url = ctx.obj["api_url"]
397
+ try:
398
+ bundle = json.loads(Path(bundle_file).read_text())
399
+ except Exception as e:
400
+ click.echo(f"Error reading bundle: {e}", err=True)
401
+ sys.exit(1)
402
+ try:
403
+ resp = httpx.post(f"{api_url}/v1/compliance/verify", json=bundle, headers=_headers(ctx), timeout=15)
404
+ resp.raise_for_status()
405
+ data = resp.json()
406
+ except httpx.HTTPError as e:
407
+ click.echo(f"Error: {e}", err=True)
408
+ sys.exit(1)
409
+
410
+ if data.get("valid"):
411
+ click.echo(f"VALID - {data.get('message', 'chain intact, signature verified')}")
412
+ sys.exit(0)
413
+ else:
414
+ click.echo(f"INVALID - {data.get('message', 'verification failed')}", err=True)
415
+ sys.exit(1)
416
+
417
+
418
+ @cli.group("keys")
419
+ def keys_group():
420
+ """Manage tenant signing keypair for compliance bundle signatures."""
421
+
422
+
423
+ @keys_group.command("generate")
424
+ @click.option("--key-dir", default=".ctxfw/keys", help="Directory to write keys")
425
+ def keys_generate(key_dir):
426
+ """Generate an Ed25519 signing keypair."""
427
+ from context_firewall.compliance.keys import generate_keypair
428
+ _, pub_pem = generate_keypair(Path(key_dir))
429
+ click.echo(f"Keypair generated in {key_dir}")
430
+ click.echo(f"Public key:\n{pub_pem}")
431
+
432
+
433
+ @keys_group.command("export-public")
434
+ @click.option("--key-dir", default=".ctxfw/keys", help="Directory containing keys")
435
+ def keys_export_public(key_dir):
436
+ """Print the public key PEM."""
437
+ from context_firewall.compliance.keys import load_public_key_pem
438
+ pem = load_public_key_pem(Path(key_dir))
439
+ if not pem:
440
+ click.echo("No public key found. Run 'ctxfw keys generate' first.", err=True)
441
+ sys.exit(1)
442
+ click.echo(pem)
443
+
444
+
445
+ @cli.command("init")
446
+ @click.option("--force", is_flag=True, help="Overwrite existing config")
447
+ def init_cmd(force):
448
+ """Initialize ContextWall in the current directory."""
449
+ config_path = Path("ctxfw.yaml")
450
+ policy_dir = Path(".ctxfw/policies")
451
+ policy_dir.mkdir(parents=True, exist_ok=True)
452
+ db_dir = Path(".ctxfw")
453
+ db_dir.mkdir(exist_ok=True)
454
+
455
+ if config_path.exists() and not force:
456
+ click.echo("ctxfw.yaml already exists. Use --force to overwrite.")
457
+ sys.exit(1)
458
+
459
+ config_path.write_text(
460
+ "# ContextWall configuration\n"
461
+ "repository_root: .\n\n"
462
+ "rest_api:\n"
463
+ " port: 8080\n"
464
+ " auth:\n"
465
+ " enabled: false\n\n"
466
+ "mcp:\n"
467
+ " transport: stdio\n\n"
468
+ "graph:\n"
469
+ " max_depth: 4\n"
470
+ " max_nodes: 50\n"
471
+ " trust_cutoff: 0.30\n\n"
472
+ "storage:\n"
473
+ " db_path: .ctxfw/cre.db\n\n"
474
+ "# Compliance configuration (Phase 2)\n"
475
+ "# compliance_hmac_key: change-this-to-a-random-secret\n"
476
+ "# compliance_baa_mode: false # set true for HIPAA BAA deployments\n"
477
+ "# compliance_packs:\n"
478
+ "# - hipaa\n"
479
+ "# - soc2\n"
480
+ "# - fedramp\n\n"
481
+ "# Policy DSL - four-layer hierarchy (Phase 3)\n"
482
+ "# Place policy YAML files in subdirectories of the policy_dir:\n"
483
+ "# .ctxfw/policies/fleet/ - tenant-wide, deny-wins (highest priority)\n"
484
+ "# .ctxfw/policies/org/ - organization-wide\n"
485
+ "# .ctxfw/policies/team/ - team-level\n"
486
+ "# .ctxfw/policies/repo/ - repository-level (lowest priority)\n"
487
+ "# Flat policy files in policy_dir/ continue to work unchanged.\n"
488
+ "# Validate with: ctxfw policy validate .ctxfw/policies/\n"
489
+ )
490
+
491
+ default_policy = Path(".ctxfw/policies/default.yaml")
492
+ if not default_policy.exists():
493
+ default_policy.write_text(
494
+ "# Default ContextWall policy\n"
495
+ "rules:\n"
496
+ " - name: block_secrets\n"
497
+ " scope: content\n"
498
+ " detector: secret\n"
499
+ " action: exclude\n"
500
+ " reason: secret pattern detected\n"
501
+ " - name: redact_pii\n"
502
+ " scope: content\n"
503
+ " detector: pii\n"
504
+ " action: redact\n"
505
+ " reason: PII pattern detected\n"
506
+ "denied_paths: []\n"
507
+ )
508
+
509
+ click.echo("ContextWall initialized.")
510
+ click.echo(" Config: ctxfw.yaml")
511
+ click.echo(" Policy: .ctxfw/policies/default.yaml")
512
+ click.echo(" Database: .ctxfw/cre.db (created on first run)")
513
+ click.echo("\nStart the daemon with: ctxfwd")
@@ -0,0 +1 @@
1
+ """Compliance export - Merkle-chained audit bundles for HIPAA/SOC2/FedRAMP."""
@@ -0,0 +1,57 @@
1
+ """BAA (Business Associate Agreement) mode - PHI/PII redaction and retention enforcement.
2
+
3
+ Tasks 8.10–8.11.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import logging
9
+ import re
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+ # BAA minimum retention: 6 years = 2190 days (45 CFR 164.530(j))
14
+ BAA_MIN_RETENTION_DAYS = 2190
15
+
16
+ _REDACTION_PATTERNS: list[tuple[str, re.Pattern, str]] = [
17
+ ("ssn", re.compile(r"\b\d{3}-\d{2}-\d{4}\b"), "[REDACTED-SSN]"),
18
+ ("email", re.compile(r"\b[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}\b"), "[REDACTED-EMAIL]"),
19
+ # MRN: varies by system; common patterns
20
+ ("mrn", re.compile(r"\bMRN[:\s#]*\d{5,10}\b", re.IGNORECASE), "[REDACTED-MRN]"),
21
+ # Date of birth
22
+ ("dob", re.compile(r"\bDOB[:\s]*\d{1,2}[/\-]\d{1,2}[/\-]\d{2,4}\b", re.IGNORECASE), "[REDACTED-DOB]"),
23
+ # Phone numbers (US format)
24
+ ("phone", re.compile(r"\b(?:\+1[\s.-]?)?\(?\d{3}\)?[\s.\-]\d{3}[\s.\-]\d{4}\b"), "[REDACTED-PHONE]"),
25
+ ]
26
+
27
+
28
+ def redact_phi(text: str) -> tuple[str, list[str]]:
29
+ """Redact PHI/PII patterns from text before provenance write.
30
+
31
+ Returns (redacted_text, list_of_pattern_names_that_fired).
32
+ """
33
+ fired: list[str] = []
34
+ result = text
35
+ for name, pattern, replacement in _REDACTION_PATTERNS:
36
+ new_result, count = pattern.subn(replacement, result)
37
+ if count > 0:
38
+ fired.append(name)
39
+ result = new_result
40
+ return result, fired
41
+
42
+
43
+ def enforce_baa_retention(configured_days: int) -> tuple[int, bool]:
44
+ """Override retention below the 6-year BAA minimum.
45
+
46
+ Returns (effective_days, was_overridden). Emits warning if overridden.
47
+ """
48
+ if configured_days < BAA_MIN_RETENTION_DAYS:
49
+ logger.warning(
50
+ "baa-retention-override: configured retention %d days is below BAA minimum %d days; "
51
+ "overriding to %d days",
52
+ configured_days,
53
+ BAA_MIN_RETENTION_DAYS,
54
+ BAA_MIN_RETENTION_DAYS,
55
+ )
56
+ return BAA_MIN_RETENTION_DAYS, True
57
+ return configured_days, False
@@ -0,0 +1,84 @@
1
+ """HMAC-SHA256 Merkle-style chain for tamper-evident provenance logs.
2
+
3
+ Tasks 8.1–8.4: hash computation, sequence counter, write-path integration,
4
+ and chain verification.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+ import hmac
11
+ import json
12
+ import logging
13
+ from dataclasses import dataclass
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+ CHAIN_GENESIS_HASH = "0" * 64 # sentinel for the first entry
18
+
19
+
20
+ @dataclass
21
+ class ChainEntry:
22
+ sequence: int
23
+ event_id: str
24
+ payload_json: str
25
+ prev_hash: str
26
+ entry_hash: str
27
+
28
+
29
+ def compute_entry_hash(
30
+ event_id: str,
31
+ payload_json: str,
32
+ sequence: int,
33
+ prev_hash: str,
34
+ hmac_key: str,
35
+ ) -> str:
36
+ """Compute HMAC-SHA256 over the canonical entry fields."""
37
+ message = json.dumps(
38
+ {
39
+ "sequence": sequence,
40
+ "event_id": event_id,
41
+ "prev_hash": prev_hash,
42
+ "payload": payload_json,
43
+ },
44
+ separators=(",", ":"),
45
+ sort_keys=True,
46
+ ).encode()
47
+ return hmac.new(hmac_key.encode(), message, hashlib.sha256).hexdigest()
48
+
49
+
50
+ def verify_chain(entries: list[ChainEntry], hmac_key: str) -> tuple[bool, str]:
51
+ """Verify chain integrity: no gaps, no reordering, no hash mismatches.
52
+
53
+ Returns (ok, error_message). ok=True means chain is intact.
54
+ """
55
+ if not entries:
56
+ return True, ""
57
+
58
+ for i, entry in enumerate(entries):
59
+ expected_seq = entries[0].sequence + i
60
+ if entry.sequence != expected_seq:
61
+ return False, f"sequence gap at position {i}: expected {expected_seq}, got {entry.sequence}"
62
+
63
+ expected_prev = CHAIN_GENESIS_HASH if i == 0 else entries[i - 1].entry_hash
64
+ if entry.prev_hash != expected_prev:
65
+ return (
66
+ False,
67
+ f"prev_hash mismatch at sequence {entry.sequence}: "
68
+ f"expected {expected_prev[:16]}…, got {entry.prev_hash[:16]}…",
69
+ )
70
+
71
+ recomputed = compute_entry_hash(
72
+ entry.event_id,
73
+ entry.payload_json,
74
+ entry.sequence,
75
+ entry.prev_hash,
76
+ hmac_key,
77
+ )
78
+ if recomputed != entry.entry_hash:
79
+ return (
80
+ False,
81
+ f"entry_hash mismatch at sequence {entry.sequence}: hash was tampered",
82
+ )
83
+
84
+ return True, ""