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,92 @@
|
|
|
1
|
+
"""Control mapping definitions for HIPAA, SOC2 Type II, and FedRAMP Moderate.
|
|
2
|
+
|
|
3
|
+
Task 8.7: walk active policy rules, map to framework controls, set satisfied flag.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class ControlRef:
|
|
13
|
+
framework: str
|
|
14
|
+
control_id: str
|
|
15
|
+
description: str
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# Built-in control mappings per rule name prefix / action type
|
|
19
|
+
RULE_CONTROL_MAP: dict[str, list[ControlRef]] = {
|
|
20
|
+
# HIPAA minimum-necessary rule
|
|
21
|
+
"hipaa-minimum-necessary": [
|
|
22
|
+
ControlRef("hipaa", "45 CFR 164.502(b)", "Minimum Necessary Standard"),
|
|
23
|
+
ControlRef("hipaa", "45 CFR 164.514(d)", "Minimum Necessary - workforce access"),
|
|
24
|
+
],
|
|
25
|
+
# HIPAA audit control
|
|
26
|
+
"hipaa-audit-control": [
|
|
27
|
+
ControlRef("hipaa", "45 CFR 164.312(b)", "Audit Controls"),
|
|
28
|
+
],
|
|
29
|
+
# Untrusted source sanitization
|
|
30
|
+
"untrusted-source-sanitization": [
|
|
31
|
+
ControlRef("soc2", "CC6.1", "Logical and Physical Access Controls"),
|
|
32
|
+
ControlRef("soc2", "CC6.6", "External Threats - logical access controls"),
|
|
33
|
+
ControlRef("fedramp", "SI-3", "Malicious Code Protection"),
|
|
34
|
+
],
|
|
35
|
+
# Secret detection
|
|
36
|
+
"block_secrets": [
|
|
37
|
+
ControlRef("soc2", "CC6.1", "Logical and Physical Access Controls"),
|
|
38
|
+
ControlRef("fedramp", "AC-3", "Access Enforcement"),
|
|
39
|
+
],
|
|
40
|
+
# PII redaction
|
|
41
|
+
"redact_pii": [
|
|
42
|
+
ControlRef("hipaa", "45 CFR 164.502(b)", "Minimum Necessary Standard"),
|
|
43
|
+
ControlRef("gdpr", "Art. 5(1)(c)", "Data Minimisation"),
|
|
44
|
+
],
|
|
45
|
+
# SOC2 logging
|
|
46
|
+
"soc2-policy-action-logging": [
|
|
47
|
+
ControlRef("soc2", "CC7.2", "System Monitoring"),
|
|
48
|
+
ControlRef("soc2", "CC8.1", "Change Management"),
|
|
49
|
+
ControlRef("fedramp", "AU-2", "Audit Events"),
|
|
50
|
+
ControlRef("fedramp", "AU-9", "Protection of Audit Information"),
|
|
51
|
+
],
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass
|
|
56
|
+
class ControlMapping:
|
|
57
|
+
framework: str
|
|
58
|
+
control_id: str
|
|
59
|
+
description: str
|
|
60
|
+
satisfied: bool
|
|
61
|
+
rule_name: str
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def resolve_control_mappings(
|
|
65
|
+
fired_rule_names: set[str],
|
|
66
|
+
requested_framework: str | None = None,
|
|
67
|
+
) -> list[ControlMapping]:
|
|
68
|
+
"""Map fired rules to compliance controls.
|
|
69
|
+
|
|
70
|
+
satisfied=True when the rule fired in the session (evidence exists).
|
|
71
|
+
satisfied=False when the control is required but the rule did not fire.
|
|
72
|
+
"""
|
|
73
|
+
mappings: list[ControlMapping] = []
|
|
74
|
+
seen: set[tuple[str, str]] = set()
|
|
75
|
+
|
|
76
|
+
for rule_name, refs in RULE_CONTROL_MAP.items():
|
|
77
|
+
for ref in refs:
|
|
78
|
+
if requested_framework and ref.framework != requested_framework:
|
|
79
|
+
continue
|
|
80
|
+
key = (ref.framework, ref.control_id)
|
|
81
|
+
if key in seen:
|
|
82
|
+
continue
|
|
83
|
+
seen.add(key)
|
|
84
|
+
mappings.append(ControlMapping(
|
|
85
|
+
framework=ref.framework,
|
|
86
|
+
control_id=ref.control_id,
|
|
87
|
+
description=ref.description,
|
|
88
|
+
satisfied=rule_name in fired_rule_names,
|
|
89
|
+
rule_name=rule_name,
|
|
90
|
+
))
|
|
91
|
+
|
|
92
|
+
return mappings
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
"""Compliance export bundle assembler.
|
|
2
|
+
|
|
3
|
+
Generates signed, Merkle-chained ComplianceExportBundle sliced by session ID,
|
|
4
|
+
time range, or framework filter.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
import uuid
|
|
13
|
+
from dataclasses import asdict, dataclass, field
|
|
14
|
+
from datetime import datetime, timezone
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
import aiosqlite
|
|
19
|
+
|
|
20
|
+
from context_firewall.compliance.chain import (
|
|
21
|
+
CHAIN_GENESIS_HASH,
|
|
22
|
+
ChainEntry,
|
|
23
|
+
compute_entry_hash,
|
|
24
|
+
verify_chain,
|
|
25
|
+
)
|
|
26
|
+
from context_firewall.compliance.control_mappings import (
|
|
27
|
+
ControlMapping,
|
|
28
|
+
resolve_control_mappings,
|
|
29
|
+
)
|
|
30
|
+
from context_firewall.compliance.keys import load_public_key_pem, sign_bundle
|
|
31
|
+
|
|
32
|
+
logger = logging.getLogger(__name__)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class ChainProof:
|
|
37
|
+
first_sequence: int
|
|
38
|
+
last_sequence: int
|
|
39
|
+
entry_count: int
|
|
40
|
+
first_hash: str
|
|
41
|
+
last_hash: str
|
|
42
|
+
hmac_algorithm: str = "HMAC-SHA256"
|
|
43
|
+
chain_valid: bool = True
|
|
44
|
+
chain_error: str = ""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class TenantMetadata:
|
|
49
|
+
tenant_id: str = ""
|
|
50
|
+
deployment_mode: str = "self-hosted"
|
|
51
|
+
baa_mode: bool = False
|
|
52
|
+
export_generated_at: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
|
|
53
|
+
cre_version: str = "0.1.0"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class RetentionPolicy:
|
|
58
|
+
retention_days: int = 365
|
|
59
|
+
baa_override_applied: bool = False
|
|
60
|
+
framework: str = ""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass
|
|
64
|
+
class ComplianceExportBundle:
|
|
65
|
+
bundle_id: str
|
|
66
|
+
export_scope: dict[str, Any]
|
|
67
|
+
provenance_entries: list[dict[str, Any]]
|
|
68
|
+
chain_proof: ChainProof
|
|
69
|
+
control_mappings: list[dict[str, Any]]
|
|
70
|
+
tenant_metadata: TenantMetadata
|
|
71
|
+
retention_policy: RetentionPolicy
|
|
72
|
+
public_key_pem: str = ""
|
|
73
|
+
signature: str = ""
|
|
74
|
+
|
|
75
|
+
def canonical_json(self) -> bytes:
|
|
76
|
+
"""Deterministic JSON for signing - excludes signature field."""
|
|
77
|
+
doc = {
|
|
78
|
+
"bundle_id": self.bundle_id,
|
|
79
|
+
"export_scope": self.export_scope,
|
|
80
|
+
"chain_proof": asdict(self.chain_proof),
|
|
81
|
+
"control_mappings": self.control_mappings,
|
|
82
|
+
"tenant_metadata": asdict(self.tenant_metadata),
|
|
83
|
+
"retention_policy": asdict(self.retention_policy),
|
|
84
|
+
"entry_count": len(self.provenance_entries),
|
|
85
|
+
}
|
|
86
|
+
return json.dumps(doc, sort_keys=True, separators=(",", ":")).encode()
|
|
87
|
+
|
|
88
|
+
def to_dict(self) -> dict[str, Any]:
|
|
89
|
+
return {
|
|
90
|
+
"bundle_id": self.bundle_id,
|
|
91
|
+
"export_scope": self.export_scope,
|
|
92
|
+
"provenance_entries": self.provenance_entries,
|
|
93
|
+
"chain_proof": asdict(self.chain_proof),
|
|
94
|
+
"control_mappings": self.control_mappings,
|
|
95
|
+
"tenant_metadata": asdict(self.tenant_metadata),
|
|
96
|
+
"retention_policy": asdict(self.retention_policy),
|
|
97
|
+
"public_key_pem": self.public_key_pem,
|
|
98
|
+
"signature": self.signature,
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@dataclass
|
|
103
|
+
class ExportScope:
|
|
104
|
+
session_id: str | None = None
|
|
105
|
+
from_ts: str | None = None
|
|
106
|
+
to_ts: str | None = None
|
|
107
|
+
framework: str | None = None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class ComplianceExporter:
|
|
111
|
+
def __init__(
|
|
112
|
+
self,
|
|
113
|
+
db_path: str,
|
|
114
|
+
hmac_key: str,
|
|
115
|
+
baa_mode: bool = False,
|
|
116
|
+
key_dir: Path | None = None,
|
|
117
|
+
tenant_id: str = "",
|
|
118
|
+
) -> None:
|
|
119
|
+
self._db_path = db_path
|
|
120
|
+
self._hmac_key = hmac_key
|
|
121
|
+
self._baa_mode = baa_mode
|
|
122
|
+
self._key_dir = key_dir or Path(".ctxfw/keys")
|
|
123
|
+
self._tenant_id = tenant_id
|
|
124
|
+
|
|
125
|
+
async def export(self, scope: ExportScope) -> ComplianceExportBundle:
|
|
126
|
+
"""Build and sign a ComplianceExportBundle for the given scope."""
|
|
127
|
+
entries = await self._fetch_entries(scope)
|
|
128
|
+
chain_entries = self._to_chain_entries(entries)
|
|
129
|
+
chain_proof = self._build_chain_proof(chain_entries)
|
|
130
|
+
|
|
131
|
+
fired_rules = self._collect_fired_rules(entries)
|
|
132
|
+
control_mappings = resolve_control_mappings(fired_rules, scope.framework)
|
|
133
|
+
|
|
134
|
+
bundle_id = str(uuid.uuid4())
|
|
135
|
+
retention = self._build_retention_policy(scope.framework)
|
|
136
|
+
tenant_meta = TenantMetadata(
|
|
137
|
+
tenant_id=self._tenant_id,
|
|
138
|
+
baa_mode=self._baa_mode,
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
bundle = ComplianceExportBundle(
|
|
142
|
+
bundle_id=bundle_id,
|
|
143
|
+
export_scope={
|
|
144
|
+
"session_id": scope.session_id,
|
|
145
|
+
"from_ts": scope.from_ts,
|
|
146
|
+
"to_ts": scope.to_ts,
|
|
147
|
+
"framework": scope.framework,
|
|
148
|
+
},
|
|
149
|
+
provenance_entries=entries,
|
|
150
|
+
chain_proof=chain_proof,
|
|
151
|
+
control_mappings=[
|
|
152
|
+
{
|
|
153
|
+
"framework": cm.framework,
|
|
154
|
+
"control_id": cm.control_id,
|
|
155
|
+
"description": cm.description,
|
|
156
|
+
"satisfied": cm.satisfied,
|
|
157
|
+
"rule_name": cm.rule_name,
|
|
158
|
+
}
|
|
159
|
+
for cm in control_mappings
|
|
160
|
+
],
|
|
161
|
+
tenant_metadata=tenant_meta,
|
|
162
|
+
retention_policy=retention,
|
|
163
|
+
public_key_pem=load_public_key_pem(self._key_dir) or "",
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
sig = sign_bundle(bundle.canonical_json(), self._key_dir)
|
|
167
|
+
bundle.signature = sig or ""
|
|
168
|
+
|
|
169
|
+
return bundle
|
|
170
|
+
|
|
171
|
+
async def verify(self, bundle: ComplianceExportBundle) -> tuple[bool, str]:
|
|
172
|
+
"""Verify chain integrity and signature of a bundle."""
|
|
173
|
+
chain_entries = self._to_chain_entries(bundle.provenance_entries)
|
|
174
|
+
chain_ok, chain_err = verify_chain(chain_entries, self._hmac_key)
|
|
175
|
+
if not chain_ok:
|
|
176
|
+
return False, f"chain-integrity-violation: {chain_err}"
|
|
177
|
+
|
|
178
|
+
if bundle.signature and bundle.public_key_pem:
|
|
179
|
+
from context_firewall.compliance.keys import verify_signature
|
|
180
|
+
sig_ok = verify_signature(
|
|
181
|
+
bundle.canonical_json(), bundle.signature, bundle.public_key_pem
|
|
182
|
+
)
|
|
183
|
+
if not sig_ok:
|
|
184
|
+
return False, "signature-verification-failed"
|
|
185
|
+
|
|
186
|
+
return True, "ok"
|
|
187
|
+
|
|
188
|
+
async def _fetch_entries(self, scope: ExportScope) -> list[dict[str, Any]]:
|
|
189
|
+
query = """
|
|
190
|
+
SELECT pe.event_id, pe.event_type, pe.session_id, pe.request_id,
|
|
191
|
+
pe.occurred_at, pe.payload,
|
|
192
|
+
COALESCE(pc.sequence, 0) AS sequence,
|
|
193
|
+
COALESCE(pc.prev_hash, ?) AS prev_hash,
|
|
194
|
+
COALESCE(pc.entry_hash, '') AS entry_hash
|
|
195
|
+
FROM provenance_events pe
|
|
196
|
+
LEFT JOIN provenance_chain pc ON pc.event_id = pe.event_id
|
|
197
|
+
"""
|
|
198
|
+
conditions: list[str] = []
|
|
199
|
+
params: list[Any] = [CHAIN_GENESIS_HASH]
|
|
200
|
+
|
|
201
|
+
if scope.session_id:
|
|
202
|
+
conditions.append("pe.session_id = ?")
|
|
203
|
+
params.append(scope.session_id)
|
|
204
|
+
if scope.from_ts:
|
|
205
|
+
conditions.append("pe.occurred_at >= ?")
|
|
206
|
+
params.append(scope.from_ts)
|
|
207
|
+
if scope.to_ts:
|
|
208
|
+
conditions.append("pe.occurred_at <= ?")
|
|
209
|
+
params.append(scope.to_ts)
|
|
210
|
+
|
|
211
|
+
if conditions:
|
|
212
|
+
query += " WHERE " + " AND ".join(conditions)
|
|
213
|
+
query += " ORDER BY COALESCE(pc.sequence, 0), pe.occurred_at ASC"
|
|
214
|
+
|
|
215
|
+
try:
|
|
216
|
+
async with aiosqlite.connect(self._db_path) as db:
|
|
217
|
+
db.row_factory = aiosqlite.Row
|
|
218
|
+
async with db.execute(query, params) as cur:
|
|
219
|
+
rows = await cur.fetchall()
|
|
220
|
+
return [dict(r) for r in rows]
|
|
221
|
+
except Exception as e:
|
|
222
|
+
logger.error("export fetch failed: %s", e)
|
|
223
|
+
return []
|
|
224
|
+
|
|
225
|
+
def _to_chain_entries(self, entries: list[dict[str, Any]]) -> list[ChainEntry]:
|
|
226
|
+
return [
|
|
227
|
+
ChainEntry(
|
|
228
|
+
sequence=e.get("sequence", i),
|
|
229
|
+
event_id=e.get("event_id", ""),
|
|
230
|
+
payload_json=e.get("payload", "{}"),
|
|
231
|
+
prev_hash=e.get("prev_hash", CHAIN_GENESIS_HASH),
|
|
232
|
+
entry_hash=e.get("entry_hash", ""),
|
|
233
|
+
)
|
|
234
|
+
for i, e in enumerate(entries)
|
|
235
|
+
]
|
|
236
|
+
|
|
237
|
+
def _build_chain_proof(self, entries: list[ChainEntry]) -> ChainProof:
|
|
238
|
+
if not entries:
|
|
239
|
+
return ChainProof(
|
|
240
|
+
first_sequence=0, last_sequence=0, entry_count=0,
|
|
241
|
+
first_hash=CHAIN_GENESIS_HASH, last_hash=CHAIN_GENESIS_HASH,
|
|
242
|
+
)
|
|
243
|
+
ok, err = verify_chain(entries, self._hmac_key)
|
|
244
|
+
return ChainProof(
|
|
245
|
+
first_sequence=entries[0].sequence,
|
|
246
|
+
last_sequence=entries[-1].sequence,
|
|
247
|
+
entry_count=len(entries),
|
|
248
|
+
first_hash=entries[0].entry_hash or CHAIN_GENESIS_HASH,
|
|
249
|
+
last_hash=entries[-1].entry_hash or CHAIN_GENESIS_HASH,
|
|
250
|
+
chain_valid=ok,
|
|
251
|
+
chain_error=err,
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
def _collect_fired_rules(self, entries: list[dict[str, Any]]) -> set[str]:
|
|
255
|
+
fired: set[str] = set()
|
|
256
|
+
for entry in entries:
|
|
257
|
+
try:
|
|
258
|
+
payload = json.loads(entry.get("payload", "{}"))
|
|
259
|
+
rule_name = payload.get("rule_name", "")
|
|
260
|
+
if rule_name:
|
|
261
|
+
fired.add(rule_name)
|
|
262
|
+
except (json.JSONDecodeError, TypeError):
|
|
263
|
+
pass
|
|
264
|
+
return fired
|
|
265
|
+
|
|
266
|
+
def _build_retention_policy(self, framework: str | None) -> RetentionPolicy:
|
|
267
|
+
from context_firewall.compliance.baa import BAA_MIN_RETENTION_DAYS, enforce_baa_retention
|
|
268
|
+
base_days = 365
|
|
269
|
+
if framework in ("hipaa", "fedramp"):
|
|
270
|
+
base_days = 2190 # 6 years
|
|
271
|
+
elif framework == "soc2":
|
|
272
|
+
base_days = 365 # 1 year typical
|
|
273
|
+
if self._baa_mode:
|
|
274
|
+
effective, overridden = enforce_baa_retention(base_days)
|
|
275
|
+
return RetentionPolicy(
|
|
276
|
+
retention_days=effective,
|
|
277
|
+
baa_override_applied=overridden,
|
|
278
|
+
framework=framework or "",
|
|
279
|
+
)
|
|
280
|
+
return RetentionPolicy(retention_days=base_days, framework=framework or "")
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Ed25519 keypair management for compliance bundle signing.
|
|
2
|
+
|
|
3
|
+
Key store: encrypted private key written to <key_dir>/signing.key.enc
|
|
4
|
+
Public key exported as PEM to <key_dir>/signing.pub
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import base64
|
|
10
|
+
import hashlib
|
|
11
|
+
import json
|
|
12
|
+
import logging
|
|
13
|
+
import os
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from cryptography.hazmat.primitives import serialization
|
|
17
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
_DEFAULT_KEY_DIR = Path(".ctxfw/keys")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def generate_keypair(key_dir: Path = _DEFAULT_KEY_DIR) -> tuple[str, str]:
|
|
25
|
+
"""Generate an Ed25519 signing keypair and persist to disk.
|
|
26
|
+
|
|
27
|
+
Returns (private_key_pem, public_key_pem).
|
|
28
|
+
"""
|
|
29
|
+
key_dir.mkdir(parents=True, exist_ok=True)
|
|
30
|
+
private_key = Ed25519PrivateKey.generate()
|
|
31
|
+
|
|
32
|
+
private_pem = private_key.private_bytes(
|
|
33
|
+
encoding=serialization.Encoding.PEM,
|
|
34
|
+
format=serialization.PrivateFormat.PKCS8,
|
|
35
|
+
encryption_algorithm=serialization.NoEncryption(),
|
|
36
|
+
).decode()
|
|
37
|
+
|
|
38
|
+
public_key = private_key.public_key()
|
|
39
|
+
public_pem = public_key.public_bytes(
|
|
40
|
+
encoding=serialization.Encoding.PEM,
|
|
41
|
+
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
|
42
|
+
).decode()
|
|
43
|
+
|
|
44
|
+
(key_dir / "signing.key").write_text(private_pem)
|
|
45
|
+
(key_dir / "signing.key").chmod(0o600)
|
|
46
|
+
(key_dir / "signing.pub").write_text(public_pem)
|
|
47
|
+
|
|
48
|
+
logger.info("Ed25519 keypair generated in %s", key_dir)
|
|
49
|
+
return private_pem, public_pem
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def load_private_key(key_dir: Path = _DEFAULT_KEY_DIR) -> Ed25519PrivateKey | None:
|
|
53
|
+
key_path = key_dir / "signing.key"
|
|
54
|
+
if not key_path.exists():
|
|
55
|
+
return None
|
|
56
|
+
pem = key_path.read_bytes()
|
|
57
|
+
return serialization.load_pem_private_key(pem, password=None)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def load_public_key_pem(key_dir: Path = _DEFAULT_KEY_DIR) -> str | None:
|
|
61
|
+
pub_path = key_dir / "signing.pub"
|
|
62
|
+
if not pub_path.exists():
|
|
63
|
+
return None
|
|
64
|
+
return pub_path.read_text()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def sign_bundle(canonical_json: bytes, key_dir: Path = _DEFAULT_KEY_DIR) -> str | None:
|
|
68
|
+
"""Sign canonical JSON with the tenant private key.
|
|
69
|
+
|
|
70
|
+
Returns base64-encoded Ed25519 signature, or None if no key exists.
|
|
71
|
+
"""
|
|
72
|
+
private_key = load_private_key(key_dir)
|
|
73
|
+
if private_key is None:
|
|
74
|
+
return None
|
|
75
|
+
signature = private_key.sign(canonical_json)
|
|
76
|
+
return base64.b64encode(signature).decode()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def verify_signature(
|
|
80
|
+
canonical_json: bytes,
|
|
81
|
+
signature_b64: str,
|
|
82
|
+
public_key_pem: str,
|
|
83
|
+
) -> bool:
|
|
84
|
+
"""Verify a bundle signature against the provided public key."""
|
|
85
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
|
86
|
+
from cryptography.exceptions import InvalidSignature
|
|
87
|
+
|
|
88
|
+
pub_key = serialization.load_pem_public_key(public_key_pem.encode())
|
|
89
|
+
signature = base64.b64decode(signature_b64)
|
|
90
|
+
try:
|
|
91
|
+
pub_key.verify(signature, canonical_json)
|
|
92
|
+
return True
|
|
93
|
+
except InvalidSignature:
|
|
94
|
+
return False
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import yaml
|
|
9
|
+
from pydantic import BaseModel, Field
|
|
10
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class SourceDeclaration(BaseModel):
|
|
14
|
+
"""Declarative source definition - declared in ctxfw.yaml, registered on startup."""
|
|
15
|
+
id: str
|
|
16
|
+
type: str = "unknown"
|
|
17
|
+
trust_tier: str = "untrusted"
|
|
18
|
+
owner: str = ""
|
|
19
|
+
region: str = ""
|
|
20
|
+
data_classification: str = ""
|
|
21
|
+
config: dict[str, Any] = Field(default_factory=dict)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class DetectionConfig(BaseModel):
|
|
25
|
+
"""Injection detector sensitivity knobs."""
|
|
26
|
+
injection_block_threshold: float = 0.55
|
|
27
|
+
injection_warn_threshold: float = 0.35
|
|
28
|
+
default_source_trust_tier: str = "untrusted"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class EnforcementConfig(BaseModel):
|
|
32
|
+
"""Trust penalty / reward tuning."""
|
|
33
|
+
penalty_increment: float = 0.15
|
|
34
|
+
decay_half_life_days: float = 1.0
|
|
35
|
+
reward_factor: float = 0.90
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ComplianceFrameworksConfig(BaseModel):
|
|
39
|
+
"""Maps data_classification keywords to compliance framework identifiers.
|
|
40
|
+
|
|
41
|
+
Override to add custom classifications or additional frameworks.
|
|
42
|
+
Keys are substrings matched against data_classification (case-insensitive).
|
|
43
|
+
"""
|
|
44
|
+
classification_frameworks: dict[str, list[str]] = Field(
|
|
45
|
+
default_factory=lambda: {
|
|
46
|
+
"phi": ["hipaa"],
|
|
47
|
+
"pii": ["hipaa", "gdpr"],
|
|
48
|
+
"pci": ["pci-dss"],
|
|
49
|
+
"financial": ["sox", "fedramp"],
|
|
50
|
+
"federal": ["fedramp"],
|
|
51
|
+
"classified": ["fedramp"],
|
|
52
|
+
"sensitive": ["soc2"],
|
|
53
|
+
"internal_code": [],
|
|
54
|
+
}
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _expand_env_vars(text: str) -> str:
|
|
59
|
+
"""Expand ${VAR:-default} and ${VAR} patterns using os.environ."""
|
|
60
|
+
def _replace(m: re.Match) -> str:
|
|
61
|
+
var, _, default = m.group(1).partition(":-")
|
|
62
|
+
return os.environ.get(var, default)
|
|
63
|
+
return re.sub(r"\$\{([^}]+)\}", _replace, text)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class DaemonJobConfig(BaseModel):
|
|
67
|
+
schedule: str
|
|
68
|
+
timeout_min: int = 30
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class DaemonIndexingConfig(BaseModel):
|
|
72
|
+
debounce_ms: int = 2000
|
|
73
|
+
max_batch_size: int = 100
|
|
74
|
+
max_concurrent_refreshes: int = 2
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class DaemonConfig(BaseModel):
|
|
78
|
+
pid_file: str = ".ctxfw/daemon.pid"
|
|
79
|
+
shutdown_timeout_sec: int = 30
|
|
80
|
+
indexing: DaemonIndexingConfig = Field(default_factory=DaemonIndexingConfig)
|
|
81
|
+
jobs: dict[str, DaemonJobConfig] = Field(default_factory=lambda: {
|
|
82
|
+
"entropy_computation": DaemonJobConfig(schedule="0 */6 * * *", timeout_min=30),
|
|
83
|
+
"weight_calibration": DaemonJobConfig(schedule="0 2 * * *", timeout_min=15),
|
|
84
|
+
"provenance_compaction": DaemonJobConfig(schedule="0 3 * * *", timeout_min=60),
|
|
85
|
+
"annotation_invalidation": DaemonJobConfig(schedule="0 * * * *", timeout_min=10),
|
|
86
|
+
"symbol_table_refresh": DaemonJobConfig(schedule="*/15 * * * *", timeout_min=2),
|
|
87
|
+
"runtime_signal_aggregation": DaemonJobConfig(schedule="*/30 * * * *", timeout_min=5),
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class RestApiAuthConfig(BaseModel):
|
|
92
|
+
enabled: bool = True
|
|
93
|
+
tokens: list[dict[str, Any]] = Field(default_factory=list)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class RestApiConfig(BaseModel):
|
|
97
|
+
port: int = 8080
|
|
98
|
+
read_timeout_sec: int = 30
|
|
99
|
+
write_timeout_sec: int = 60
|
|
100
|
+
auth: RestApiAuthConfig = Field(default_factory=RestApiAuthConfig)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class McpConfig(BaseModel):
|
|
104
|
+
transport: str = "stdio"
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class GraphConfig(BaseModel):
|
|
108
|
+
max_depth: int = 4
|
|
109
|
+
max_nodes: int = 50
|
|
110
|
+
trust_cutoff: float = 0.30
|
|
111
|
+
traversal_timeout_ms: int = 100
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class TrustConfig(BaseModel):
|
|
115
|
+
weights: dict[str, float] = Field(default_factory=lambda: {
|
|
116
|
+
"structural_relevance": 0.30,
|
|
117
|
+
"runtime_evidence": 0.25,
|
|
118
|
+
"freshness": 0.10,
|
|
119
|
+
"stability": 0.10,
|
|
120
|
+
"verification": 0.10,
|
|
121
|
+
"consistency": 0.05,
|
|
122
|
+
"injection_risk": -0.05,
|
|
123
|
+
"entropy_contribution": -0.15,
|
|
124
|
+
})
|
|
125
|
+
fallback_score: float = 0.50
|
|
126
|
+
phase2_learning_rate: float = 0.05
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class EntropyConfig(BaseModel):
|
|
130
|
+
default_score: float = 0.30
|
|
131
|
+
high_entropy_threshold: float = 0.60
|
|
132
|
+
high_entropy_consecutive: int = 3
|
|
133
|
+
staleness_penalty: float = 0.15
|
|
134
|
+
analysis_cadence_hours: int = 6
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class PolicyConfig(BaseModel):
|
|
138
|
+
policy_dir: str = ".ctxfw/policies"
|
|
139
|
+
max_file_size_kb: int = 50
|
|
140
|
+
exempt_private_ips: bool = True
|
|
141
|
+
cross_repo_auth_required: bool = True
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class ProvenanceConfig(BaseModel):
|
|
145
|
+
queue_size: int = 10_000
|
|
146
|
+
session_timeout_min: int = 30
|
|
147
|
+
archival_after_days: int = 90
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class SynthesizerConfig(BaseModel):
|
|
151
|
+
default_budget: int = 80_000
|
|
152
|
+
reserve_for_summary: int = 500
|
|
153
|
+
per_task_budgets: dict[str, int] = Field(default_factory=lambda: {
|
|
154
|
+
"BUG_FIX": 60_000,
|
|
155
|
+
"NEW_FEATURE": 80_000,
|
|
156
|
+
"REFACTOR": 100_000,
|
|
157
|
+
"SECURITY_REVIEW": 50_000,
|
|
158
|
+
"DEPENDENCY_AUDIT": 40_000,
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class OtelConfig(BaseModel):
|
|
163
|
+
enabled: bool = True
|
|
164
|
+
grpc_port: int = 4317
|
|
165
|
+
http_port: int = 4318
|
|
166
|
+
queue_size: int = 10_000
|
|
167
|
+
consumer_workers: int = 4
|
|
168
|
+
trace_assembly_timeout_sec: int = 30
|
|
169
|
+
signal_batch_flush_ms: int = 500
|
|
170
|
+
signal_batch_size: int = 100
|
|
171
|
+
fuzzy_threshold: float = 0.75
|
|
172
|
+
latency_degraded_threshold_ms: int = 2000
|
|
173
|
+
exception_rate_high_threshold: float = 0.10
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class AnalyticsConfig(BaseModel):
|
|
177
|
+
enabled: bool = True
|
|
178
|
+
degradation_threshold: float = 0.15
|
|
179
|
+
degradation_window_days: int = 30
|
|
180
|
+
entropy_trend_snapshots: int = 5
|
|
181
|
+
high_entropy_threshold: float = 0.60
|
|
182
|
+
high_entropy_consecutive: int = 3
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
class StorageConfig(BaseModel):
|
|
186
|
+
db_path: str = ".ctxfw/cre.db"
|
|
187
|
+
object_storage_bucket: str = ""
|
|
188
|
+
object_storage_prefix: str = "cre-provenance/"
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
class ControlPlaneConfig(BaseModel):
|
|
192
|
+
"""Optional connection to the ContextWall control plane.
|
|
193
|
+
|
|
194
|
+
Leave url empty to run in fully local mode (no data leaves the host).
|
|
195
|
+
The registration_token is issued from the webapp Settings page.
|
|
196
|
+
"""
|
|
197
|
+
url: str = ""
|
|
198
|
+
registration_token: str = ""
|
|
199
|
+
daemon_name: str = ""
|
|
200
|
+
push_interval_seconds: int = 60
|
|
201
|
+
heartbeat_interval_seconds: int = 30
|
|
202
|
+
policy_pull: bool = True
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
class Config(BaseModel):
|
|
206
|
+
repository_root: str = "."
|
|
207
|
+
compliance_hmac_key: str = ""
|
|
208
|
+
sources: list[SourceDeclaration] = Field(default_factory=list)
|
|
209
|
+
detection: DetectionConfig = Field(default_factory=DetectionConfig)
|
|
210
|
+
enforcement: EnforcementConfig = Field(default_factory=EnforcementConfig)
|
|
211
|
+
compliance: ComplianceFrameworksConfig = Field(default_factory=ComplianceFrameworksConfig)
|
|
212
|
+
daemon: DaemonConfig = Field(default_factory=DaemonConfig)
|
|
213
|
+
rest_api: RestApiConfig = Field(default_factory=RestApiConfig)
|
|
214
|
+
mcp: McpConfig = Field(default_factory=McpConfig)
|
|
215
|
+
graph: GraphConfig = Field(default_factory=GraphConfig)
|
|
216
|
+
trust: TrustConfig = Field(default_factory=TrustConfig)
|
|
217
|
+
entropy: EntropyConfig = Field(default_factory=EntropyConfig)
|
|
218
|
+
policy: PolicyConfig = Field(default_factory=PolicyConfig)
|
|
219
|
+
provenance: ProvenanceConfig = Field(default_factory=ProvenanceConfig)
|
|
220
|
+
synthesizer: SynthesizerConfig = Field(default_factory=SynthesizerConfig)
|
|
221
|
+
otel: OtelConfig = Field(default_factory=OtelConfig)
|
|
222
|
+
analytics: AnalyticsConfig = Field(default_factory=AnalyticsConfig)
|
|
223
|
+
storage: StorageConfig = Field(default_factory=StorageConfig)
|
|
224
|
+
control_plane: ControlPlaneConfig = Field(default_factory=ControlPlaneConfig)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def load_config(config_path: str | Path = "ctxfw.yaml") -> Config:
|
|
228
|
+
path = Path(config_path)
|
|
229
|
+
if not path.exists():
|
|
230
|
+
return Config()
|
|
231
|
+
with path.open() as f:
|
|
232
|
+
content = _expand_env_vars(f.read())
|
|
233
|
+
raw = yaml.safe_load(content) or {}
|
|
234
|
+
return Config.model_validate(raw)
|
|
File without changes
|