causal-memory-layer 0.4.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.
cml/audit.py ADDED
@@ -0,0 +1,440 @@
1
+ """
2
+ cml.audit — Audit engine (v0.6)
3
+
4
+ Implements read-only causal coherence analysis for vCML logs.
5
+ Rules: R1, R2, R3, R4 + custom rules (see vcml/audit.md)
6
+
7
+ Audit does NOT block, enforce, or replace security products.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import yaml
13
+ from dataclasses import dataclass, field
14
+ from pathlib import Path
15
+ from typing import Optional
16
+ from .record import CausalRecord, records_to_index
17
+ from .chain import group_by_pid, ancestors
18
+ from .ctag import CLASS
19
+ from .experimental.cause_band import (
20
+ DEFAULT_FIXTURE,
21
+ evaluate_fixture,
22
+ load_fixture,
23
+ resolve_fixture_path,
24
+ )
25
+
26
+
27
+ # ---------------------------------------------------------------------------
28
+ # Severity
29
+ # ---------------------------------------------------------------------------
30
+
31
+ class Severity:
32
+ OK = "OK"
33
+ WARN = "WARN"
34
+ FAIL = "FAIL"
35
+
36
+ _ALLOWED = {OK, WARN, FAIL}
37
+
38
+ @staticmethod
39
+ def normalize(value: str) -> str:
40
+ normalized = str(value).upper()
41
+ if normalized not in Severity._ALLOWED:
42
+ raise ValueError(
43
+ f"Unknown severity: {value!r}. Allowed values: {sorted(Severity._ALLOWED)}"
44
+ )
45
+ return normalized
46
+
47
+
48
+ # ---------------------------------------------------------------------------
49
+ # Finding
50
+ # ---------------------------------------------------------------------------
51
+
52
+ @dataclass
53
+ class Finding:
54
+ code: str
55
+ severity: str
56
+ record_id: str
57
+ message: str
58
+ chain_ids: list[str] = field(default_factory=list)
59
+
60
+ def to_dict(self) -> dict:
61
+ d = {
62
+ "code": self.code,
63
+ "severity": self.severity,
64
+ "record_id": self.record_id,
65
+ "message": self.message,
66
+ }
67
+ if self.chain_ids:
68
+ d["chain_ids"] = self.chain_ids
69
+ return d
70
+
71
+
72
+ # ---------------------------------------------------------------------------
73
+ # Custom rules
74
+ # ---------------------------------------------------------------------------
75
+
76
+ @dataclass
77
+ class CustomRule:
78
+ """User-defined audit rule parsed from YAML ``custom_rules`` section."""
79
+ id: str
80
+ description: str
81
+ trigger_class: int # CLASS enum value
82
+ severity: str = Severity.FAIL
83
+ code: str = ""
84
+ require_ancestor_class: Optional[int] = None
85
+ require_ancestor_permitted_by_prefix: Optional[str] = None
86
+
87
+ def to_dict(self) -> dict:
88
+ d: dict = {
89
+ "id": self.id,
90
+ "description": self.description,
91
+ "trigger_class": CLASS.name(self.trigger_class),
92
+ "severity": self.severity,
93
+ "code": self.code,
94
+ }
95
+ if self.require_ancestor_class is not None:
96
+ d["require_ancestor_class"] = CLASS.name(self.require_ancestor_class)
97
+ if self.require_ancestor_permitted_by_prefix is not None:
98
+ d["require_ancestor_permitted_by_prefix"] = self.require_ancestor_permitted_by_prefix
99
+ return d
100
+
101
+
102
+ def _effective_class(record: CausalRecord) -> int:
103
+ """Determine a record's CLASS from its CTAG or action string."""
104
+ if record.ctag is not None:
105
+ return (record.ctag >> 8) & 0xF
106
+ return CLASS.from_action(record.action)
107
+
108
+
109
+ # ---------------------------------------------------------------------------
110
+ # Audit configuration
111
+ # ---------------------------------------------------------------------------
112
+
113
+ @dataclass
114
+ class AuditConfig:
115
+ root_event_prefix: str = "root_event:"
116
+ secret_classifications: list[str] = field(default_factory=lambda: ["SECRET"])
117
+ secret_path_prefixes: list[str] = field(default_factory=lambda: ["/secrets/"])
118
+ secret_extensions: list[str] = field(default_factory=lambda: [".key", ".pem"])
119
+ net_out_actions: list[str] = field(default_factory=lambda: ["connect", "send"])
120
+ rules_enabled: dict[str, bool] = field(default_factory=lambda: {
121
+ "R1": True, "R2": True, "R3": True, "R4": True
122
+ })
123
+ custom_rules: list[CustomRule] = field(default_factory=list)
124
+ enable_experimental_cause_band: bool = False
125
+ experimental_cause_band_fixture: Optional[str] = None
126
+
127
+ @staticmethod
128
+ def _apply_raw(cfg: "AuditConfig", raw: dict) -> "AuditConfig":
129
+ if not isinstance(raw, dict):
130
+ raise ValueError("Audit config root must be a mapping/object")
131
+
132
+ cfg.root_event_prefix = raw.get("root_event_prefix", cfg.root_event_prefix)
133
+ s = raw.get("secret", {})
134
+ if "classifications" in s:
135
+ cfg.secret_classifications = s["classifications"]
136
+ if "path_prefixes" in s:
137
+ cfg.secret_path_prefixes = s["path_prefixes"]
138
+ if "extensions" in s:
139
+ cfg.secret_extensions = s["extensions"]
140
+ n = raw.get("net_out", {})
141
+ if "actions" in n:
142
+ cfg.net_out_actions = n["actions"]
143
+ experimental = raw.get("experimental", {})
144
+ if isinstance(experimental, dict):
145
+ cfg.enable_experimental_cause_band = bool(
146
+ experimental.get(
147
+ "enable_cause_band",
148
+ cfg.enable_experimental_cause_band,
149
+ )
150
+ )
151
+ if "cause_band_fixture" in experimental:
152
+ validated_fixture = resolve_fixture_path(Path(str(experimental["cause_band_fixture"])))
153
+ base_dir = DEFAULT_FIXTURE.parent.resolve()
154
+ cfg.experimental_cause_band_fixture = str(validated_fixture.relative_to(base_dir))
155
+ for rule in raw.get("rules", []):
156
+ rid = rule.get("id")
157
+ enabled = rule.get("enabled", True)
158
+ if rid:
159
+ cfg.rules_enabled[rid] = enabled
160
+ for cr in raw.get("custom_rules", []):
161
+ try:
162
+ trigger = CLASS.from_name(cr["trigger_class"])
163
+ except KeyError:
164
+ raise ValueError(f"Unknown trigger_class: {cr['trigger_class']}")
165
+ anc_cls = None
166
+ if "require_ancestor_class" in cr:
167
+ try:
168
+ anc_cls = CLASS.from_name(cr["require_ancestor_class"])
169
+ except KeyError:
170
+ raise ValueError(f"Unknown require_ancestor_class: {cr['require_ancestor_class']}")
171
+ cfg.custom_rules.append(CustomRule(
172
+ id=cr["id"],
173
+ description=cr.get("description", ""),
174
+ trigger_class=trigger,
175
+ severity=Severity.normalize(cr.get("severity", Severity.FAIL)),
176
+ code=cr.get("code", f"CML-AUDIT-{cr['id']}"),
177
+ require_ancestor_class=anc_cls,
178
+ require_ancestor_permitted_by_prefix=cr.get("require_ancestor_permitted_by_prefix"),
179
+ ))
180
+ # Auto-enable the custom rule unless explicitly disabled
181
+ cfg.rules_enabled.setdefault(cr["id"], True)
182
+ return cfg
183
+
184
+ @staticmethod
185
+ def from_yaml(path: str) -> "AuditConfig":
186
+ with open(path) as f:
187
+ raw = yaml.safe_load(f) or {}
188
+ return AuditConfig._apply_raw(AuditConfig(), raw)
189
+
190
+ @staticmethod
191
+ def from_yaml_string(text: str) -> "AuditConfig":
192
+ raw = yaml.safe_load(text) or {}
193
+ return AuditConfig._apply_raw(AuditConfig(), raw)
194
+
195
+ def is_secret(self, record: CausalRecord) -> bool:
196
+ obj = record.object
197
+ if isinstance(obj, dict):
198
+ if obj.get("classification") in self.secret_classifications:
199
+ return True
200
+ path = obj.get("path", "")
201
+ else:
202
+ path = str(obj)
203
+ if any(path.startswith(p) for p in self.secret_path_prefixes):
204
+ return True
205
+ if any(path.endswith(e) for e in self.secret_extensions):
206
+ return True
207
+ return False
208
+
209
+ def is_net_out(self, record: CausalRecord) -> bool:
210
+ return record.action in self.net_out_actions
211
+
212
+ def is_root(self, record: CausalRecord) -> bool:
213
+ return (
214
+ record.parent_cause is None
215
+ and isinstance(record.permitted_by, str)
216
+ and record.permitted_by.startswith(self.root_event_prefix)
217
+ )
218
+
219
+
220
+ # ---------------------------------------------------------------------------
221
+ # Audit result
222
+ # ---------------------------------------------------------------------------
223
+
224
+ @dataclass
225
+ class AuditResult:
226
+ total: int = 0
227
+ ok: int = 0
228
+ warnings: int = 0
229
+ failures: int = 0
230
+ findings: list[Finding] = field(default_factory=list)
231
+
232
+ def add(self, finding: Finding):
233
+ finding.severity = Severity.normalize(finding.severity)
234
+ self.findings.append(finding)
235
+ if finding.severity == Severity.OK:
236
+ self.ok += 1
237
+ elif finding.severity == Severity.WARN:
238
+ self.warnings += 1
239
+ elif finding.severity == Severity.FAIL:
240
+ self.failures += 1
241
+
242
+ def passed(self) -> bool:
243
+ return self.failures == 0
244
+
245
+ def to_dict(self) -> dict:
246
+ return {
247
+ "summary": {
248
+ "total": self.total,
249
+ "ok": self.ok,
250
+ "warnings": self.warnings,
251
+ "failures": self.failures,
252
+ "passed": self.passed(),
253
+ },
254
+ "findings": [f.to_dict() for f in self.findings],
255
+ }
256
+
257
+
258
+ # ---------------------------------------------------------------------------
259
+ # Audit engine
260
+ # ---------------------------------------------------------------------------
261
+
262
+ class AuditEngine:
263
+ def __init__(self, config: Optional[AuditConfig] = None):
264
+ self.config = config or AuditConfig()
265
+
266
+ def run(self, records: list[CausalRecord]) -> AuditResult:
267
+ cfg = self.config
268
+ index = records_to_index(records)
269
+ result = AuditResult(total=len(records))
270
+
271
+ r1_enabled = cfg.rules_enabled.get("R1", True)
272
+ r2_enabled = cfg.rules_enabled.get("R2", True)
273
+ r3_enabled = cfg.rules_enabled.get("R3", True)
274
+ r4_enabled = cfg.rules_enabled.get("R4", True)
275
+
276
+ for record in records:
277
+ # ----------------------------------------------------------
278
+ # R1 — Reference Integrity
279
+ # ----------------------------------------------------------
280
+ if r1_enabled and record.parent_cause is not None:
281
+ if record.parent_cause not in index:
282
+ result.add(Finding(
283
+ code="CML-AUDIT-R1-MISSING_PARENT",
284
+ severity=Severity.FAIL,
285
+ record_id=record.id,
286
+ message=(
287
+ f"parent_cause '{record.parent_cause}' "
288
+ f"does not exist in the log."
289
+ ),
290
+ ))
291
+
292
+ # ----------------------------------------------------------
293
+ # R2 / R4 — Gap Marking & Root Identification (mutually exclusive)
294
+ #
295
+ # For null-parent non-root records:
296
+ # R4 fires when permitted_by looks like a *near-miss* root label
297
+ # (starts with the root prefix stem but lacks the separator).
298
+ # Example: "root_event" instead of "root_event:system_boot".
299
+ # R2 fires for all other unlabeled cases (arbitrary permitted_by
300
+ # that is not "unobserved_parent" and not a near-miss root).
301
+ # ----------------------------------------------------------
302
+ if record.parent_cause is None and not cfg.is_root(record):
303
+ # Strip the last character (separator) to get the stem.
304
+ # Using slice instead of rstrip() which strips *characters*
305
+ # and would mangle multi-char separators like "::".
306
+ prefix_stem = cfg.root_event_prefix[:-1] if cfg.root_event_prefix else ""
307
+ near_miss = (
308
+ bool(prefix_stem)
309
+ and isinstance(record.permitted_by, str)
310
+ and record.permitted_by != "unobserved_parent"
311
+ and record.permitted_by.startswith(prefix_stem)
312
+ and not record.permitted_by.startswith(cfg.root_event_prefix)
313
+ )
314
+
315
+ if r4_enabled and near_miss:
316
+ result.add(Finding(
317
+ code="CML-AUDIT-R4-AMBIGUOUS_ROOT",
318
+ severity=Severity.WARN,
319
+ record_id=record.id,
320
+ message=(
321
+ f"Near-miss root label: permitted_by='{record.permitted_by}' "
322
+ f"looks like '{cfg.root_event_prefix}' but is missing the "
323
+ f"required separator. Did you mean "
324
+ f"'{cfg.root_event_prefix}<cause>'?"
325
+ ),
326
+ ))
327
+ elif r2_enabled and not near_miss and record.permitted_by != "unobserved_parent":
328
+ result.add(Finding(
329
+ code="CML-AUDIT-R2-GAP_NOT_MARKED",
330
+ severity=Severity.WARN,
331
+ record_id=record.id,
332
+ message=(
333
+ f"Causal gap: parent_cause=null but permitted_by="
334
+ f"'{record.permitted_by}' (expected 'unobserved_parent')."
335
+ ),
336
+ ))
337
+
338
+ # ------------------------------------------------------------------
339
+ # R3 — SECRET → NET_OUT Chain
340
+ # ------------------------------------------------------------------
341
+ if r3_enabled:
342
+ by_pid = group_by_pid(records)
343
+ for pid, pid_records in by_pid.items():
344
+ secret_ids: list[str] = []
345
+ for r in pid_records:
346
+ if cfg.is_secret(r):
347
+ secret_ids.append(r.id)
348
+
349
+ if cfg.is_net_out(r) and secret_ids:
350
+ # Precompute ancestor set once per NET_OUT (O(chain_depth))
351
+ # instead of calling has_path per secret (O(S × depth)).
352
+ # Exclude r.id itself: ancestors() includes the record
353
+ # being tested, so a NET_OUT record that is also
354
+ # classified SECRET would falsely match itself.
355
+ anc = ancestors(r.id, index) - {r.id}
356
+ linked = bool(anc & set(secret_ids))
357
+ if not linked:
358
+ result.add(Finding(
359
+ code="CML-AUDIT-R3-SECRET_NET_MISSING_CHAIN",
360
+ severity=Severity.FAIL,
361
+ record_id=r.id,
362
+ message=(
363
+ f"NET_OUT '{r.action}' (pid={pid}) "
364
+ f"has no causal link to preceding "
365
+ f"SECRET access(es): {secret_ids}."
366
+ ),
367
+ chain_ids=list(secret_ids),
368
+ ))
369
+
370
+ # ------------------------------------------------------------------
371
+ # Custom rules (R5+)
372
+ #
373
+ # Ancestor sets are computed once per record (keyed by id) and
374
+ # reused across all rules, reducing O(R × N × D) to O(N × D + R × N).
375
+ # The cache is built lazily — only triggered records pay the cost.
376
+ # ------------------------------------------------------------------
377
+ if cfg.custom_rules:
378
+ _anc_cache: dict[str, set[str]] = {}
379
+
380
+ def _anc(rid: str) -> set[str]:
381
+ if rid not in _anc_cache:
382
+ _anc_cache[rid] = ancestors(rid, index) - {rid}
383
+ return _anc_cache[rid]
384
+
385
+ for rule in cfg.custom_rules:
386
+ rule_severity = Severity.normalize(rule.severity)
387
+ if not cfg.rules_enabled.get(rule.id, True):
388
+ continue
389
+ for record in records:
390
+ if _effective_class(record) != rule.trigger_class:
391
+ continue
392
+ satisfied = False
393
+ for aid in _anc(record.id):
394
+ anc = index.get(aid)
395
+ if anc is None:
396
+ continue
397
+ cls_ok = (
398
+ rule.require_ancestor_class is None
399
+ or _effective_class(anc) == rule.require_ancestor_class
400
+ )
401
+ prefix_ok = (
402
+ rule.require_ancestor_permitted_by_prefix is None
403
+ or (
404
+ isinstance(anc.permitted_by, str)
405
+ and anc.permitted_by.startswith(
406
+ rule.require_ancestor_permitted_by_prefix
407
+ )
408
+ )
409
+ )
410
+ if cls_ok and prefix_ok:
411
+ satisfied = True
412
+ break
413
+ if not satisfied:
414
+ result.add(Finding(
415
+ code=rule.code,
416
+ severity=rule_severity,
417
+ record_id=record.id,
418
+ message=f"Custom rule {rule.id}: {rule.description}",
419
+ ))
420
+
421
+ # ------------------------------------------------------------------
422
+ # Experimental Cause Band sidecar evaluation
423
+ # ------------------------------------------------------------------
424
+ if cfg.enable_experimental_cause_band and cfg.experimental_cause_band_fixture:
425
+ cause_band_raw = load_fixture(Path(cfg.experimental_cause_band_fixture))
426
+ cause_band_result = evaluate_fixture(cause_band_raw)
427
+ for code in cause_band_result["predicted_codes"]:
428
+ result.add(Finding(
429
+ code=code,
430
+ severity=Severity.FAIL,
431
+ record_id=str(cause_band_result.get("case_id") or "experimental-cause-band"),
432
+ message=(
433
+ "Experimental Cause Band finding from sidecar fixture: "
434
+ f"{code}. This finding is non-normative and opt-in only."
435
+ ),
436
+ chain_ids=[str(band) for band in cause_band_result.get("bands", [])],
437
+ ))
438
+
439
+ result.ok = max(0, result.total - result.warnings - result.failures)
440
+ return result
cml/chain.py ADDED
@@ -0,0 +1,115 @@
1
+ """
2
+ cml.chain — Causal chain reconstruction
3
+
4
+ Provides utilities to walk parent_cause links and reconstruct full
5
+ causal chains from an indexed log.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Optional
11
+ from .record import CausalRecord
12
+
13
+
14
+ # ---------------------------------------------------------------------------
15
+ # Chain reconstruction
16
+ # ---------------------------------------------------------------------------
17
+
18
+ def reconstruct_chain(
19
+ record_id: str,
20
+ index: dict[str, CausalRecord],
21
+ max_depth: int = 256,
22
+ ) -> list[CausalRecord]:
23
+ """
24
+ Walk parent_cause links from `record_id` to the root.
25
+
26
+ Returns the chain ordered root-first (oldest → newest).
27
+ Stops at max_depth to prevent infinite loops on corrupt logs.
28
+ """
29
+ chain: list[CausalRecord] = []
30
+ visited: set[str] = set()
31
+ current_id: Optional[str] = record_id
32
+
33
+ while current_id is not None:
34
+ if current_id in visited:
35
+ break # cycle guard
36
+ if len(chain) >= max_depth:
37
+ break
38
+ record = index.get(current_id)
39
+ if record is None:
40
+ break
41
+ chain.append(record)
42
+ visited.add(current_id)
43
+ current_id = record.parent_cause
44
+
45
+ chain.reverse()
46
+ return chain
47
+
48
+
49
+ def find_root(
50
+ record_id: str,
51
+ index: dict[str, CausalRecord],
52
+ ) -> Optional[CausalRecord]:
53
+ """Return the root CausalRecord for the given record's chain."""
54
+ chain = reconstruct_chain(record_id, index)
55
+ return chain[0] if chain else None
56
+
57
+
58
+ # ---------------------------------------------------------------------------
59
+ # Chain path check
60
+ # ---------------------------------------------------------------------------
61
+
62
+ def has_path(
63
+ from_id: str,
64
+ to_id: str,
65
+ index: dict[str, CausalRecord],
66
+ max_depth: int = 256,
67
+ ) -> bool:
68
+ """
69
+ Return True if `to_id` is an ancestor of `from_id` via parent_cause links.
70
+
71
+ Public utility for point-to-point reachability queries. The audit engine
72
+ uses ancestors() + set intersection for batch checks (O(chain_depth) vs
73
+ O(S × chain_depth)), but has_path is useful when testing a single pair.
74
+ """
75
+ visited: set[str] = set()
76
+ start = index.get(from_id)
77
+ if start is None:
78
+ return False
79
+ current: Optional[str] = start.parent_cause
80
+
81
+ depth = 0
82
+ while current is not None and depth < max_depth:
83
+ if current == to_id:
84
+ return True
85
+ if current in visited:
86
+ return False
87
+ visited.add(current)
88
+ record = index.get(current)
89
+ if record is None:
90
+ return False
91
+ current = record.parent_cause
92
+ depth += 1
93
+ return False
94
+
95
+
96
+ # ---------------------------------------------------------------------------
97
+ # Process context grouping
98
+ # ---------------------------------------------------------------------------
99
+
100
+ def group_by_pid(records: list[CausalRecord]) -> dict[int, list[CausalRecord]]:
101
+ """Group records by actor.pid, sorted by timestamp."""
102
+ groups: dict[int, list[CausalRecord]] = {}
103
+ for r in sorted(records, key=lambda x: x.timestamp):
104
+ pid = r.actor.pid
105
+ groups.setdefault(pid, []).append(r)
106
+ return groups
107
+
108
+
109
+ def ancestors(
110
+ record_id: str,
111
+ index: dict[str, CausalRecord],
112
+ ) -> set[str]:
113
+ """Return the set of all ancestor ids for a record."""
114
+ chain = reconstruct_chain(record_id, index)
115
+ return {r.id for r in chain}