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.
- context_firewall/__init__.py +3 -0
- context_firewall/analytics/__init__.py +5 -0
- context_firewall/analytics/engine.py +234 -0
- context_firewall/api/__init__.py +5 -0
- context_firewall/api/app.py +1020 -0
- context_firewall/api/models.py +54 -0
- context_firewall/classifier/__init__.py +5 -0
- context_firewall/classifier/classifier.py +165 -0
- context_firewall/cli/__init__.py +5 -0
- context_firewall/cli/main.py +513 -0
- context_firewall/compliance/__init__.py +1 -0
- context_firewall/compliance/baa.py +57 -0
- context_firewall/compliance/chain.py +84 -0
- context_firewall/compliance/control_mappings.py +92 -0
- context_firewall/compliance/export.py +280 -0
- context_firewall/compliance/keys.py +94 -0
- context_firewall/config.py +234 -0
- context_firewall/control_plane/__init__.py +0 -0
- context_firewall/control_plane/client.py +107 -0
- context_firewall/control_plane/models.py +72 -0
- context_firewall/control_plane/pusher.py +377 -0
- context_firewall/daemon/__init__.py +5 -0
- context_firewall/daemon/main.py +461 -0
- context_firewall/db/__init__.py +6 -0
- context_firewall/db/connection.py +30 -0
- context_firewall/db/migrations.py +408 -0
- context_firewall/entropy/__init__.py +5 -0
- context_firewall/entropy/engine.py +271 -0
- context_firewall/graph/__init__.py +5 -0
- context_firewall/graph/engine.py +162 -0
- context_firewall/lint/__init__.py +0 -0
- context_firewall/lint/engine.py +334 -0
- context_firewall/mcp/__init__.py +5 -0
- context_firewall/mcp/server.py +237 -0
- context_firewall/metrics.py +100 -0
- context_firewall/models.py +130 -0
- context_firewall/policy/__init__.py +10 -0
- context_firewall/policy/detectors/__init__.py +0 -0
- context_firewall/policy/detectors/injection.py +480 -0
- context_firewall/policy/dsl/__init__.py +0 -0
- context_firewall/policy/dsl/evaluator.py +208 -0
- context_firewall/policy/dsl/loader.py +150 -0
- context_firewall/policy/dsl/types.py +112 -0
- context_firewall/policy/engine.py +532 -0
- context_firewall/provenance/__init__.py +5 -0
- context_firewall/provenance/engine.py +658 -0
- context_firewall/provenance/models.py +91 -0
- context_firewall/proxy/__init__.py +1 -0
- context_firewall/proxy/router.py +519 -0
- context_firewall/proxy/scanner.py +214 -0
- context_firewall/proxy/tokens.py +156 -0
- context_firewall/runtime/__init__.py +5 -0
- context_firewall/runtime/engine.py +303 -0
- context_firewall/source/__init__.py +1 -0
- context_firewall/source/registry.py +299 -0
- context_firewall/source/types.py +12 -0
- context_firewall/synthesizer/__init__.py +5 -0
- context_firewall/synthesizer/synthesizer.py +255 -0
- context_firewall/trust/__init__.py +5 -0
- context_firewall/trust/engine.py +139 -0
- context_firewall/trust/signals.py +184 -0
- contextwall-0.1.0.dist-info/METADATA +431 -0
- contextwall-0.1.0.dist-info/RECORD +66 -0
- contextwall-0.1.0.dist-info/WHEEL +4 -0
- contextwall-0.1.0.dist-info/entry_points.txt +3 -0
- 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, ""
|