chore: pre-migration snapshot
Layer0, MCP servers, Terraform consolidation
This commit is contained in:
392
layer0/learn.py
Normal file
392
layer0/learn.py
Normal file
@@ -0,0 +1,392 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable
|
||||
|
||||
from .pattern_store import (
|
||||
normalize_query_for_matching,
|
||||
pattern_dict,
|
||||
write_pattern_snapshot,
|
||||
)
|
||||
|
||||
THIS_FILE = Path(__file__).resolve()
|
||||
LAYER0_DIR = THIS_FILE.parent
|
||||
REPO_ROOT = LAYER0_DIR.parent.parent
|
||||
|
||||
|
||||
def _utc_now_iso_z() -> str:
|
||||
return (
|
||||
datetime.now(timezone.utc)
|
||||
.replace(microsecond=0)
|
||||
.isoformat()
|
||||
.replace("+00:00", "Z")
|
||||
)
|
||||
|
||||
|
||||
def _default_db_path() -> Path:
|
||||
for key in ("LEDGER_DB_PATH", "VAULTMESH_LEDGER_DB"):
|
||||
v = (os.environ.get(key) or "").strip()
|
||||
if v:
|
||||
return Path(v).expanduser().resolve()
|
||||
return (REPO_ROOT / ".state" / "ledger.sqlite").resolve()
|
||||
|
||||
|
||||
def _default_candidate_path() -> Path:
|
||||
return (REPO_ROOT / ".state" / "layer0_patterns_candidate.json").resolve()
|
||||
|
||||
|
||||
def _read_jsonl(paths: Iterable[Path]) -> list[dict[str, Any]]:
|
||||
events: list[dict[str, Any]] = []
|
||||
for path in paths:
|
||||
if not path.exists():
|
||||
continue
|
||||
for line in path.read_text(encoding="utf-8").splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
obj = json.loads(line)
|
||||
except Exception:
|
||||
continue
|
||||
if isinstance(obj, dict):
|
||||
events.append(obj)
|
||||
return events
|
||||
|
||||
|
||||
def _telemetry_actor(event: dict[str, Any]) -> str | None:
|
||||
v = event.get("actor") or event.get("user") or event.get("account")
|
||||
if isinstance(v, str) and v.strip():
|
||||
return v.strip()
|
||||
meta = event.get("metadata")
|
||||
if isinstance(meta, dict):
|
||||
v2 = meta.get("actor") or meta.get("account")
|
||||
if isinstance(v2, str) and v2.strip():
|
||||
return v2.strip()
|
||||
return None
|
||||
|
||||
|
||||
def _telemetry_trace_id(event: dict[str, Any]) -> str | None:
|
||||
for k in ("trace_id", "layer0_trace_id", "trace", "id"):
|
||||
v = event.get(k)
|
||||
if isinstance(v, str) and v.strip():
|
||||
return v.strip()
|
||||
return None
|
||||
|
||||
|
||||
def _telemetry_ts(event: dict[str, Any]) -> str | None:
|
||||
for k in ("timestamp", "ts", "time"):
|
||||
v = event.get(k)
|
||||
if isinstance(v, str) and v.strip():
|
||||
return v.strip()
|
||||
return None
|
||||
|
||||
|
||||
def _telemetry_query(event: dict[str, Any]) -> str:
|
||||
v = event.get("query") or event.get("prompt") or event.get("input")
|
||||
if isinstance(v, str):
|
||||
return v
|
||||
meta = event.get("metadata")
|
||||
if isinstance(meta, dict) and isinstance(meta.get("query"), str):
|
||||
return str(meta.get("query"))
|
||||
return ""
|
||||
|
||||
|
||||
def _outcome(event: dict[str, Any]) -> str | None:
|
||||
v = event.get("outcome") or event.get("result") or event.get("status")
|
||||
if isinstance(v, str) and v.strip():
|
||||
return v.strip()
|
||||
return None
|
||||
|
||||
|
||||
def _layer0_classification(event: dict[str, Any]) -> str | None:
|
||||
v = event.get("layer0_classification") or event.get("classification")
|
||||
if isinstance(v, str) and v.strip():
|
||||
return v.strip()
|
||||
return None
|
||||
|
||||
|
||||
def _infer_target_from_event(
|
||||
event: dict[str, Any], *, include_relaxations: bool
|
||||
) -> tuple[str, str] | None:
|
||||
"""
|
||||
Returns (mode, classification) or None.
|
||||
|
||||
mode:
|
||||
- "escalate": adds/strengthens detection immediately
|
||||
- "relax": can reduce severity only after replay + explicit approval
|
||||
"""
|
||||
outcome = (_outcome(event) or "").lower()
|
||||
l0 = (_layer0_classification(event) or "").lower()
|
||||
|
||||
# Ground-truth blocked downstream: L0 should tighten.
|
||||
if outcome in {
|
||||
"blocked_by_guardrails",
|
||||
"blocked_by_policy",
|
||||
"blocked",
|
||||
"denied",
|
||||
} and l0 in {"blessed", "ambiguous"}:
|
||||
return ("escalate", "forbidden")
|
||||
|
||||
if (
|
||||
outcome in {"fail_closed", "catastrophic", "blocked_catastrophic"}
|
||||
and l0 != "catastrophic"
|
||||
):
|
||||
return ("escalate", "catastrophic")
|
||||
|
||||
# Preboot logs (already blocked) can still be used to learn more specific signatures.
|
||||
if not outcome and l0 in {"forbidden", "catastrophic"}:
|
||||
return ("escalate", l0)
|
||||
|
||||
# False positives: relax only after replay + approval.
|
||||
if include_relaxations and outcome in {"success", "ok"} and l0 in {"forbidden"}:
|
||||
return ("relax", "blessed")
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _default_risk_score(classification: str) -> int:
|
||||
if classification == "catastrophic":
|
||||
return 5
|
||||
if classification == "forbidden":
|
||||
return 3
|
||||
if classification == "ambiguous":
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Bucket:
|
||||
traces: set[str]
|
||||
actors: set[str]
|
||||
last_seen: str | None
|
||||
|
||||
|
||||
def _ensure_ledger_schema(conn: sqlite3.Connection) -> None:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS migrations (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS proof_artifacts (
|
||||
id TEXT PRIMARY KEY,
|
||||
ts TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
kind TEXT NOT NULL,
|
||||
path TEXT,
|
||||
sha256_hex TEXT,
|
||||
blake3_hex TEXT,
|
||||
size_bytes INTEGER,
|
||||
meta_json TEXT,
|
||||
trace_id TEXT
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def _log_artifact(
|
||||
*,
|
||||
kind: str,
|
||||
path: Path | None,
|
||||
meta: dict[str, Any],
|
||||
trace_id: str | None,
|
||||
db_path: Path,
|
||||
) -> str:
|
||||
try:
|
||||
from ledger.db import log_proof_artifact # type: ignore
|
||||
|
||||
return log_proof_artifact(
|
||||
kind=kind,
|
||||
path=path,
|
||||
meta=meta,
|
||||
trace_id=trace_id,
|
||||
db_path=db_path,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
artifact_id = str(uuid.uuid4())
|
||||
rel_path: str | None = None
|
||||
sha256_hex: str | None = None
|
||||
size_bytes: int | None = None
|
||||
|
||||
if path is not None:
|
||||
try:
|
||||
rel_path = str(path.resolve().relative_to(REPO_ROOT))
|
||||
except Exception:
|
||||
rel_path = str(path)
|
||||
if path.exists() and path.is_file():
|
||||
data = path.read_bytes()
|
||||
sha256_hex = hashlib.sha256(data).hexdigest()
|
||||
size_bytes = len(data)
|
||||
|
||||
db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = sqlite3.connect(str(db_path), isolation_level=None)
|
||||
try:
|
||||
_ensure_ledger_schema(conn)
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO proof_artifacts (
|
||||
id, ts, kind, path, sha256_hex, blake3_hex, size_bytes, meta_json, trace_id
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, NULL, ?, ?, ?);
|
||||
""",
|
||||
(
|
||||
artifact_id,
|
||||
_utc_now_iso_z(),
|
||||
kind,
|
||||
rel_path,
|
||||
sha256_hex,
|
||||
size_bytes,
|
||||
json.dumps(meta, ensure_ascii=False, sort_keys=True),
|
||||
trace_id,
|
||||
),
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
return artifact_id
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Layer0: build candidate patterns from telemetry."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--telemetry-jsonl",
|
||||
action="append",
|
||||
default=[],
|
||||
help="Path to telemetry JSONL (repeatable). Defaults include anomalies/preboot_shield.jsonl if present.",
|
||||
)
|
||||
parser.add_argument("--min-support", type=int, default=3)
|
||||
parser.add_argument("--min-actors", type=int, default=2)
|
||||
parser.add_argument("--max-tokens", type=int, default=8)
|
||||
parser.add_argument(
|
||||
"--include-relaxations",
|
||||
action="store_true",
|
||||
help="Generate relaxation candidates (still requires replay + explicit promotion).",
|
||||
)
|
||||
parser.add_argument("--out", type=str, default=str(_default_candidate_path()))
|
||||
parser.add_argument("--db", type=str, default=None)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
paths: list[Path] = []
|
||||
for p in args.telemetry_jsonl:
|
||||
if p:
|
||||
paths.append(Path(p).expanduser())
|
||||
|
||||
default_preboot = REPO_ROOT / "anomalies" / "preboot_shield.jsonl"
|
||||
if default_preboot.exists() and default_preboot not in paths:
|
||||
paths.append(default_preboot)
|
||||
|
||||
events = _read_jsonl(paths)
|
||||
|
||||
buckets: dict[tuple[str, str, tuple[str, ...]], _Bucket] = {}
|
||||
for ev in events:
|
||||
inferred = _infer_target_from_event(
|
||||
ev, include_relaxations=bool(args.include_relaxations)
|
||||
)
|
||||
if not inferred:
|
||||
continue
|
||||
mode, target = inferred
|
||||
|
||||
norm = normalize_query_for_matching(_telemetry_query(ev))
|
||||
tokens = norm.split()
|
||||
if len(tokens) < 2:
|
||||
continue
|
||||
if args.max_tokens and len(tokens) > args.max_tokens:
|
||||
tokens = tokens[: int(args.max_tokens)]
|
||||
|
||||
key = (mode, target, tuple(tokens))
|
||||
b = buckets.get(key)
|
||||
if b is None:
|
||||
b = _Bucket(traces=set(), actors=set(), last_seen=None)
|
||||
buckets[key] = b
|
||||
|
||||
trace = _telemetry_trace_id(ev)
|
||||
if trace:
|
||||
b.traces.add(trace)
|
||||
actor = _telemetry_actor(ev)
|
||||
if actor:
|
||||
b.actors.add(actor)
|
||||
ts = _telemetry_ts(ev)
|
||||
if ts and (b.last_seen is None or ts > b.last_seen):
|
||||
b.last_seen = ts
|
||||
|
||||
patterns: list[dict[str, Any]] = []
|
||||
for (mode, target, tokens), bucket in buckets.items():
|
||||
support = len(bucket.traces) if bucket.traces else 0
|
||||
actors = len(bucket.actors)
|
||||
if support < int(args.min_support):
|
||||
continue
|
||||
if actors and actors < int(args.min_actors):
|
||||
continue
|
||||
|
||||
patterns.append(
|
||||
pattern_dict(
|
||||
tokens_all=tokens,
|
||||
classification=target,
|
||||
reason="telemetry_learned",
|
||||
risk_score=_default_risk_score(target),
|
||||
flags=["telemetry_learned"],
|
||||
min_support=support,
|
||||
last_seen=bucket.last_seen,
|
||||
source={"support_traces": support, "support_actors": actors},
|
||||
mode=mode,
|
||||
pattern_id=str(uuid.uuid4()),
|
||||
)
|
||||
)
|
||||
|
||||
# Deterministic ordering: most severe, then most specific/support.
|
||||
severity_rank = {
|
||||
"blessed": 0,
|
||||
"ambiguous": 1,
|
||||
"forbidden": 2,
|
||||
"catastrophic": 3,
|
||||
}
|
||||
patterns.sort(
|
||||
key=lambda p: (
|
||||
severity_rank.get(p["classification"], 0),
|
||||
int(p.get("specificity_score") or 0),
|
||||
int(p.get("min_support") or 0),
|
||||
str(p.get("last_seen") or ""),
|
||||
),
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
out_path = Path(args.out).expanduser().resolve()
|
||||
write_pattern_snapshot(out_path, patterns)
|
||||
|
||||
db_path = Path(args.db).expanduser().resolve() if args.db else _default_db_path()
|
||||
artifact_id = _log_artifact(
|
||||
kind="shadow_pattern_candidate",
|
||||
path=out_path,
|
||||
meta={
|
||||
"patterns": len(patterns),
|
||||
"min_support": int(args.min_support),
|
||||
"min_actors": int(args.min_actors),
|
||||
"inputs": [str(p) for p in paths],
|
||||
},
|
||||
trace_id=None,
|
||||
db_path=db_path,
|
||||
)
|
||||
|
||||
print(f"Wrote {len(patterns)} candidate patterns to {out_path}")
|
||||
print(f"Logged artifact {artifact_id} to {db_path}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
331
layer0/pattern_store.py
Normal file
331
layer0/pattern_store.py
Normal file
@@ -0,0 +1,331 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable, Sequence
|
||||
|
||||
THIS_FILE = Path(__file__).resolve()
|
||||
LAYER0_DIR = THIS_FILE.parent
|
||||
REPO_ROOT = LAYER0_DIR.parent.parent
|
||||
|
||||
|
||||
_RE_URL = re.compile(r"\bhttps?://\S+\b", re.IGNORECASE)
|
||||
_RE_EMAIL = re.compile(r"\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b", re.IGNORECASE)
|
||||
_RE_IPV4 = re.compile(r"\b(?:\d{1,3}\.){3}\d{1,3}\b")
|
||||
_RE_IPV6 = re.compile(r"\b(?:[0-9a-f]{0,4}:){2,}[0-9a-f]{0,4}\b", re.IGNORECASE)
|
||||
_RE_UUID = re.compile(
|
||||
r"\b[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_RE_HEX_LONG = re.compile(r"\b[0-9a-f]{32,}\b", re.IGNORECASE)
|
||||
_RE_BASE64ISH = re.compile(r"\b[A-Za-z0-9+/]{28,}={0,2}\b")
|
||||
_RE_PATHISH = re.compile(r"(?:(?:\.\.?/)|/)[A-Za-z0-9._~/-]{2,}")
|
||||
_RE_NUMBER = re.compile(r"\b\d+\b")
|
||||
_RE_TOKEN = re.compile(r"[a-z][a-z_-]{1,31}", re.IGNORECASE)
|
||||
|
||||
|
||||
SAFE_VOCAB = {
|
||||
# Governance / safety verbs
|
||||
"disable",
|
||||
"override",
|
||||
"bypass",
|
||||
"skip",
|
||||
"ignore",
|
||||
"evade",
|
||||
"break",
|
||||
"force",
|
||||
"apply",
|
||||
"deploy",
|
||||
"destroy",
|
||||
"delete",
|
||||
"drop",
|
||||
"remove",
|
||||
"exfiltrate",
|
||||
# Critical nouns / domains
|
||||
"guardrails",
|
||||
"permissions",
|
||||
"governance",
|
||||
"git",
|
||||
"gitops",
|
||||
"dashboard",
|
||||
"manual",
|
||||
"prod",
|
||||
"production",
|
||||
"staging",
|
||||
"terraform",
|
||||
"waf",
|
||||
"dns",
|
||||
"tunnel",
|
||||
"access",
|
||||
"token",
|
||||
"secret",
|
||||
"key",
|
||||
"credential",
|
||||
"admin",
|
||||
"root",
|
||||
# Phrases often seen in L0 rules (tokenized)
|
||||
"self",
|
||||
"modifying",
|
||||
"directly",
|
||||
}
|
||||
|
||||
|
||||
def _utc_now_iso_z() -> str:
|
||||
return (
|
||||
datetime.now(timezone.utc)
|
||||
.replace(microsecond=0)
|
||||
.isoformat()
|
||||
.replace("+00:00", "Z")
|
||||
)
|
||||
|
||||
|
||||
def normalize_query_for_matching(query: str) -> str:
|
||||
"""
|
||||
Produce a low-leakage normalized string suitable for storing and matching.
|
||||
|
||||
Invariants:
|
||||
- Never stores raw URLs, IPs, emails, long hex strings, base64ish blobs, UUIDs, or paths.
|
||||
- Numbers are stripped to <NUM>.
|
||||
- Only safe vocabulary tokens are preserved; other words are dropped.
|
||||
"""
|
||||
q = (query or "").lower().strip()
|
||||
if not q:
|
||||
return ""
|
||||
|
||||
# Keep placeholders lowercase to make matching stable across sources.
|
||||
q = _RE_URL.sub("<url>", q)
|
||||
q = _RE_EMAIL.sub("<email>", q)
|
||||
q = _RE_IPV4.sub("<ip>", q)
|
||||
q = _RE_IPV6.sub("<ip>", q)
|
||||
q = _RE_UUID.sub("<uuid>", q)
|
||||
q = _RE_PATHISH.sub("<path>", q)
|
||||
q = _RE_HEX_LONG.sub("<hex>", q)
|
||||
q = _RE_BASE64ISH.sub("<b64>", q)
|
||||
q = _RE_NUMBER.sub("<num>", q)
|
||||
|
||||
# Tokenize; keep placeholders and a tight safe vocabulary.
|
||||
tokens: list[str] = []
|
||||
for raw in re.split(r"[^a-z0-9_<>\-_/]+", q):
|
||||
t = raw.strip()
|
||||
if not t:
|
||||
continue
|
||||
if t.startswith("<") and t.endswith(">"):
|
||||
tokens.append(t)
|
||||
continue
|
||||
if _RE_TOKEN.fullmatch(t) and t in SAFE_VOCAB:
|
||||
tokens.append(t)
|
||||
|
||||
# De-dupe while preserving order.
|
||||
seen: set[str] = set()
|
||||
out: list[str] = []
|
||||
for t in tokens:
|
||||
if t in seen:
|
||||
continue
|
||||
seen.add(t)
|
||||
out.append(t)
|
||||
return " ".join(out)
|
||||
|
||||
|
||||
def normalized_tokens(query: str) -> list[str]:
|
||||
s = normalize_query_for_matching(query)
|
||||
return s.split() if s else []
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LearnedPattern:
|
||||
pattern_id: str
|
||||
tokens_all: tuple[str, ...]
|
||||
classification: str
|
||||
reason: str | None
|
||||
risk_score: int
|
||||
flags: tuple[str, ...]
|
||||
specificity_score: int
|
||||
min_support: int
|
||||
last_seen: str | None
|
||||
source: dict[str, Any] | None
|
||||
mode: str # "escalate" | "relax"
|
||||
|
||||
def matches(self, normalized_query: str) -> bool:
|
||||
if not normalized_query:
|
||||
return False
|
||||
hay = set(normalized_query.split())
|
||||
return all(t in hay for t in self.tokens_all)
|
||||
|
||||
|
||||
def _default_active_path() -> Path:
|
||||
configured = os.environ.get("LAYER0_ACTIVE_PATTERNS_PATH")
|
||||
if configured:
|
||||
return Path(configured).expanduser().resolve()
|
||||
return (REPO_ROOT / ".state" / "layer0_patterns_active.json").resolve()
|
||||
|
||||
|
||||
class PatternStore:
|
||||
"""
|
||||
Read-only active pattern snapshot.
|
||||
|
||||
This is intentionally immutable during request handling; mutations happen in
|
||||
offline jobs (learn/replay) that write a new snapshot and log an artifact.
|
||||
"""
|
||||
|
||||
def __init__(self, active_path: Path | None = None):
|
||||
self._active_path = active_path or _default_active_path()
|
||||
self._active: list[LearnedPattern] = []
|
||||
self._loaded = False
|
||||
|
||||
@property
|
||||
def active_path(self) -> Path:
|
||||
return self._active_path
|
||||
|
||||
def load(self) -> None:
|
||||
if self._loaded:
|
||||
return
|
||||
self._loaded = True
|
||||
self._active = self._load_patterns_file(self._active_path)
|
||||
|
||||
def patterns(self) -> list[LearnedPattern]:
|
||||
self.load()
|
||||
return list(self._active)
|
||||
|
||||
def match_ordered(self, normalized_query: str) -> list[LearnedPattern]:
|
||||
self.load()
|
||||
matched = [p for p in self._active if p.matches(normalized_query)]
|
||||
severity_rank = {
|
||||
"blessed": 0,
|
||||
"ambiguous": 1,
|
||||
"forbidden": 2,
|
||||
"catastrophic": 3,
|
||||
}
|
||||
matched.sort(
|
||||
key=lambda p: (
|
||||
severity_rank.get(p.classification, 0),
|
||||
p.specificity_score,
|
||||
p.min_support,
|
||||
p.last_seen or "",
|
||||
),
|
||||
reverse=True,
|
||||
)
|
||||
return matched
|
||||
|
||||
@staticmethod
|
||||
def _load_patterns_file(path: Path) -> list[LearnedPattern]:
|
||||
if not path.exists():
|
||||
return []
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
items = data.get("patterns") if isinstance(data, dict) else data
|
||||
if not isinstance(items, list):
|
||||
return []
|
||||
|
||||
patterns: list[LearnedPattern] = []
|
||||
for item in items:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
tokens = item.get("tokens_all") or item.get("tokens") or []
|
||||
if not isinstance(tokens, list) or not tokens:
|
||||
continue
|
||||
tokens_norm = tuple(
|
||||
t.lower() if isinstance(t, str) else ""
|
||||
for t in tokens
|
||||
if isinstance(t, str)
|
||||
and t
|
||||
and (t.startswith("<") or t.lower() in SAFE_VOCAB)
|
||||
)
|
||||
if not tokens_norm:
|
||||
continue
|
||||
|
||||
classification = item.get("classification")
|
||||
if classification not in {
|
||||
"blessed",
|
||||
"ambiguous",
|
||||
"forbidden",
|
||||
"catastrophic",
|
||||
}:
|
||||
continue
|
||||
|
||||
flags = item.get("flags") or []
|
||||
if not isinstance(flags, list):
|
||||
flags = []
|
||||
|
||||
mode = item.get("mode") or "escalate"
|
||||
if mode not in {"escalate", "relax"}:
|
||||
mode = "escalate"
|
||||
|
||||
min_support = int(item.get("min_support") or item.get("support") or 0)
|
||||
specificity = int(item.get("specificity_score") or len(tokens_norm))
|
||||
risk_score = int(item.get("risk_score") or 0)
|
||||
|
||||
patterns.append(
|
||||
LearnedPattern(
|
||||
pattern_id=str(item.get("pattern_id") or item.get("id") or ""),
|
||||
tokens_all=tokens_norm,
|
||||
classification=classification,
|
||||
reason=item.get("reason"),
|
||||
risk_score=risk_score,
|
||||
flags=tuple(str(f) for f in flags if isinstance(f, str)),
|
||||
specificity_score=specificity,
|
||||
min_support=min_support,
|
||||
last_seen=item.get("last_seen"),
|
||||
source=item.get("source")
|
||||
if isinstance(item.get("source"), dict)
|
||||
else None,
|
||||
mode=mode,
|
||||
)
|
||||
)
|
||||
|
||||
severity_rank = {
|
||||
"blessed": 0,
|
||||
"ambiguous": 1,
|
||||
"forbidden": 2,
|
||||
"catastrophic": 3,
|
||||
}
|
||||
patterns.sort(
|
||||
key=lambda p: (
|
||||
severity_rank.get(p.classification, 0),
|
||||
p.specificity_score,
|
||||
p.min_support,
|
||||
p.last_seen or "",
|
||||
),
|
||||
reverse=True,
|
||||
)
|
||||
return patterns
|
||||
|
||||
|
||||
def pattern_dict(
|
||||
*,
|
||||
tokens_all: Sequence[str],
|
||||
classification: str,
|
||||
reason: str | None,
|
||||
risk_score: int,
|
||||
flags: Sequence[str],
|
||||
min_support: int,
|
||||
last_seen: str | None = None,
|
||||
source: dict[str, Any] | None = None,
|
||||
mode: str = "escalate",
|
||||
pattern_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
tokens = [t for t in tokens_all if isinstance(t, str) and t]
|
||||
return {
|
||||
"pattern_id": pattern_id or "",
|
||||
"tokens_all": tokens,
|
||||
"classification": classification,
|
||||
"reason": reason,
|
||||
"risk_score": int(risk_score),
|
||||
"flags": list(flags),
|
||||
"specificity_score": int(len(tokens)),
|
||||
"min_support": int(min_support),
|
||||
"last_seen": last_seen or _utc_now_iso_z(),
|
||||
"source": source or {},
|
||||
"mode": mode,
|
||||
}
|
||||
|
||||
|
||||
def write_pattern_snapshot(path: Path, patterns: Iterable[dict[str, Any]]) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
payload = {"generated_at": _utc_now_iso_z(), "patterns": list(patterns)}
|
||||
path.write_text(
|
||||
json.dumps(payload, ensure_ascii=False, sort_keys=True, indent=2) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
@@ -1,22 +1,134 @@
|
||||
import datetime
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sqlite3
|
||||
from typing import Optional
|
||||
|
||||
from .shadow_classifier import ShadowEvalResult, Classification
|
||||
from .pattern_store import normalize_query_for_matching
|
||||
from .shadow_classifier import Classification, ShadowEvalResult
|
||||
|
||||
|
||||
class PrebootLogger:
|
||||
LOG_PATH = "anomalies/preboot_shield.jsonl"
|
||||
|
||||
@staticmethod
|
||||
def _ledger_db_path() -> str | None:
|
||||
return os.getenv("VAULTMESH_LEDGER_DB") or os.getenv("LEDGER_DB_PATH")
|
||||
|
||||
@staticmethod
|
||||
def _normalize_for_shadow_receipt(query: str) -> str:
|
||||
"""
|
||||
Poison-resistant normalizer for ShadowReceipt emission.
|
||||
|
||||
Goals:
|
||||
- Normalize casing/whitespace
|
||||
- Replace common secret/identifier carriers with placeholders
|
||||
- Keep output stable and compact
|
||||
"""
|
||||
s = (query or "").lower().strip()
|
||||
s = re.sub(r"\s+", " ", s)
|
||||
s = re.sub(r"\bhttps?://\S+\b", "<URL>", s)
|
||||
s = re.sub(r"\b\d{1,3}(?:\.\d{1,3}){3}\b", "<IP>", s)
|
||||
s = re.sub(
|
||||
r"\b[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\b",
|
||||
"<HEX>",
|
||||
s,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
s = re.sub(r"(?:(?:\.\.?/)|/|~\/)[A-Za-z0-9._~/-]{2,}", "<PATH>", s)
|
||||
s = re.sub(r"\b[0-9a-f]{16,}\b", "<HEX>", s, flags=re.IGNORECASE)
|
||||
s = re.sub(r"\b\d+\b", "<N>", s)
|
||||
return s.strip()
|
||||
|
||||
@staticmethod
|
||||
def _sha256_hex(text: str) -> str:
|
||||
return hashlib.sha256(text.encode("utf-8", errors="ignore")).hexdigest()
|
||||
|
||||
@staticmethod
|
||||
def _try_emit_shadow_receipt(
|
||||
*,
|
||||
query: str,
|
||||
classification: str,
|
||||
reason: str | None,
|
||||
flags: list[str],
|
||||
trace_id: str | None,
|
||||
) -> None:
|
||||
"""
|
||||
Best-effort ShadowReceipt emission into the local-first SQLite ledger.
|
||||
|
||||
Hard constraints:
|
||||
- No dependency on vaultmesh-orgine-mobile code
|
||||
- Fail silently on any error (Layer 0 must never crash)
|
||||
"""
|
||||
db_path = PrebootLogger._ledger_db_path()
|
||||
if not db_path:
|
||||
return
|
||||
|
||||
try:
|
||||
norm = PrebootLogger._normalize_for_shadow_receipt(query)
|
||||
cf_hash = PrebootLogger._sha256_hex(norm)
|
||||
|
||||
placeholders: list[str] = []
|
||||
for p in ("<URL>", "<IP>", "<PATH>", "<HEX>", "<N>"):
|
||||
if p in norm:
|
||||
placeholders.append(p)
|
||||
|
||||
meta = {
|
||||
"ts_utc": datetime.datetime.now(datetime.timezone.utc)
|
||||
.replace(microsecond=0)
|
||||
.isoformat()
|
||||
.replace("+00:00", "Z"),
|
||||
"classification": classification,
|
||||
"reason": reason,
|
||||
"flags": (flags or [])[:64],
|
||||
"normalized_query_features": {
|
||||
"placeholders": placeholders,
|
||||
"length": len(norm),
|
||||
},
|
||||
}
|
||||
|
||||
conn = sqlite3.connect(db_path, timeout=0.25)
|
||||
try:
|
||||
conn.execute("PRAGMA foreign_keys=ON;")
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO shadow_receipts (
|
||||
id, horizon_id, counterfactual_hash, entropy_delta,
|
||||
reason_unrealized, observer_signature, trace_id, meta_json
|
||||
)
|
||||
VALUES (?, ?, ?, NULL, ?, NULL, ?, ?);
|
||||
""",
|
||||
(
|
||||
PrebootLogger._sha256_hex(
|
||||
meta["ts_utc"] + "|" + (trace_id or "") + "|" + cf_hash
|
||||
),
|
||||
"layer0_block",
|
||||
cf_hash,
|
||||
"layer0_block",
|
||||
trace_id,
|
||||
json.dumps(meta, separators=(",", ":"), ensure_ascii=False),
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
except Exception:
|
||||
return
|
||||
|
||||
@staticmethod
|
||||
def log(event: ShadowEvalResult, query: str, reason_override: Optional[str] = None):
|
||||
if event.classification not in (Classification.CATASTROPHIC, Classification.FORBIDDEN):
|
||||
if event.classification not in (
|
||||
Classification.CATASTROPHIC,
|
||||
Classification.FORBIDDEN,
|
||||
):
|
||||
return # Only violations get logged
|
||||
|
||||
record = {
|
||||
"timestamp": datetime.datetime.utcnow().isoformat() + "Z",
|
||||
"query": query,
|
||||
# Store a normalized, low-leakage representation (never raw strings).
|
||||
"query": normalize_query_for_matching(query),
|
||||
"classification": event.classification.value,
|
||||
"reason": reason_override or event.reason,
|
||||
"trace_id": event.trace_id,
|
||||
@@ -31,3 +143,11 @@ class PrebootLogger:
|
||||
|
||||
with open(PrebootLogger.LOG_PATH, "a", encoding="utf-8") as f:
|
||||
f.write(json.dumps(record) + "\n")
|
||||
|
||||
PrebootLogger._try_emit_shadow_receipt(
|
||||
query=query,
|
||||
classification=event.classification.value,
|
||||
reason=reason_override or event.reason,
|
||||
flags=event.flags,
|
||||
trace_id=event.trace_id,
|
||||
)
|
||||
|
||||
443
layer0/replay.py
Normal file
443
layer0/replay.py
Normal file
@@ -0,0 +1,443 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable
|
||||
|
||||
from .pattern_store import PatternStore, write_pattern_snapshot
|
||||
from .shadow_classifier import Classification, ShadowClassifier
|
||||
|
||||
THIS_FILE = Path(__file__).resolve()
|
||||
LAYER0_DIR = THIS_FILE.parent
|
||||
REPO_ROOT = LAYER0_DIR.parent.parent
|
||||
|
||||
|
||||
def _utc_now_iso_z() -> str:
|
||||
return (
|
||||
datetime.now(timezone.utc)
|
||||
.replace(microsecond=0)
|
||||
.isoformat()
|
||||
.replace("+00:00", "Z")
|
||||
)
|
||||
|
||||
|
||||
def _default_db_path() -> Path:
|
||||
for key in ("LEDGER_DB_PATH", "VAULTMESH_LEDGER_DB"):
|
||||
v = (os.environ.get(key) or "").strip()
|
||||
if v:
|
||||
return Path(v).expanduser().resolve()
|
||||
return (REPO_ROOT / ".state" / "ledger.sqlite").resolve()
|
||||
|
||||
|
||||
def _read_jsonl(paths: Iterable[Path], *, limit: int | None) -> list[dict[str, Any]]:
|
||||
rows: list[dict[str, Any]] = []
|
||||
for path in paths:
|
||||
if not path.exists():
|
||||
continue
|
||||
for line in path.read_text(encoding="utf-8").splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
obj = json.loads(line)
|
||||
except Exception:
|
||||
continue
|
||||
if isinstance(obj, dict):
|
||||
rows.append(obj)
|
||||
|
||||
if limit is not None and limit > 0 and len(rows) > limit:
|
||||
return rows[-limit:]
|
||||
return rows
|
||||
|
||||
|
||||
def _telemetry_query(event: dict[str, Any]) -> str:
|
||||
v = event.get("query") or event.get("prompt") or event.get("input")
|
||||
return v if isinstance(v, str) else ""
|
||||
|
||||
|
||||
def _outcome(event: dict[str, Any]) -> str | None:
|
||||
v = event.get("outcome") or event.get("result") or event.get("status")
|
||||
if isinstance(v, str) and v.strip():
|
||||
return v.strip()
|
||||
return None
|
||||
|
||||
|
||||
def _ground_truth(event: dict[str, Any]) -> Classification | None:
|
||||
outcome = (_outcome(event) or "").lower()
|
||||
if outcome in {"success", "ok"}:
|
||||
return Classification.BLESSED
|
||||
if outcome in {"blocked_by_guardrails", "blocked_by_policy", "blocked", "denied"}:
|
||||
return Classification.FORBIDDEN
|
||||
if outcome in {"fail_closed", "catastrophic", "blocked_catastrophic"}:
|
||||
return Classification.CATASTROPHIC
|
||||
return None
|
||||
|
||||
|
||||
def _ensure_ledger_schema(conn: sqlite3.Connection) -> None:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS migrations (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS proof_artifacts (
|
||||
id TEXT PRIMARY KEY,
|
||||
ts TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
kind TEXT NOT NULL,
|
||||
path TEXT,
|
||||
sha256_hex TEXT,
|
||||
blake3_hex TEXT,
|
||||
size_bytes INTEGER,
|
||||
meta_json TEXT,
|
||||
trace_id TEXT
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def _log_artifact(
|
||||
*,
|
||||
kind: str,
|
||||
path: Path | None,
|
||||
meta: dict[str, Any],
|
||||
trace_id: str | None,
|
||||
db_path: Path,
|
||||
) -> str:
|
||||
try:
|
||||
from ledger.db import log_proof_artifact # type: ignore
|
||||
|
||||
return log_proof_artifact(
|
||||
kind=kind,
|
||||
path=path,
|
||||
meta=meta,
|
||||
trace_id=trace_id,
|
||||
db_path=db_path,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
artifact_id = str(uuid.uuid4())
|
||||
rel_path: str | None = None
|
||||
sha256_hex: str | None = None
|
||||
size_bytes: int | None = None
|
||||
|
||||
if path is not None:
|
||||
try:
|
||||
rel_path = str(path.resolve().relative_to(REPO_ROOT))
|
||||
except Exception:
|
||||
rel_path = str(path)
|
||||
if path.exists() and path.is_file():
|
||||
data = path.read_bytes()
|
||||
sha256_hex = hashlib.sha256(data).hexdigest()
|
||||
size_bytes = len(data)
|
||||
|
||||
db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = sqlite3.connect(str(db_path), isolation_level=None)
|
||||
try:
|
||||
_ensure_ledger_schema(conn)
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO proof_artifacts (
|
||||
id, ts, kind, path, sha256_hex, blake3_hex, size_bytes, meta_json, trace_id
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, NULL, ?, ?, ?);
|
||||
""",
|
||||
(
|
||||
artifact_id,
|
||||
_utc_now_iso_z(),
|
||||
kind,
|
||||
rel_path,
|
||||
sha256_hex,
|
||||
size_bytes,
|
||||
json.dumps(meta, ensure_ascii=False, sort_keys=True),
|
||||
trace_id,
|
||||
),
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
return artifact_id
|
||||
|
||||
|
||||
def _load_patterns_file(path: Path) -> list[dict[str, Any]]:
|
||||
if not path.exists():
|
||||
return []
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
items = data.get("patterns") if isinstance(data, dict) else data
|
||||
return items if isinstance(items, list) else []
|
||||
|
||||
|
||||
def _merge_patterns(
|
||||
active: list[dict[str, Any]], extra: list[dict[str, Any]]
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Candidate patterns win on identical (mode, tokens_all, classification).
|
||||
"""
|
||||
|
||||
def key(p: dict[str, Any]) -> tuple[str, tuple[str, ...], str]:
|
||||
mode = str(p.get("mode") or "escalate")
|
||||
cls = str(p.get("classification") or "")
|
||||
tokens = p.get("tokens_all") or p.get("tokens") or []
|
||||
if not isinstance(tokens, list):
|
||||
tokens = []
|
||||
return (mode, tuple(str(t).lower() for t in tokens), cls)
|
||||
|
||||
merged: dict[tuple[str, tuple[str, ...], str], dict[str, Any]] = {}
|
||||
for p in active:
|
||||
if isinstance(p, dict):
|
||||
merged[key(p)] = p
|
||||
for p in extra:
|
||||
if isinstance(p, dict):
|
||||
merged[key(p)] = p
|
||||
return list(merged.values())
|
||||
|
||||
|
||||
@dataclass
|
||||
class ReplayMetrics:
|
||||
total: int
|
||||
baseline_false_pos: int
|
||||
baseline_false_neg: int
|
||||
candidate_false_pos: int
|
||||
candidate_false_neg: int
|
||||
catastrophic_boundary_unchanged: bool
|
||||
|
||||
|
||||
def _is_false_positive(pred: Classification, truth: Classification) -> bool:
|
||||
return truth == Classification.BLESSED and pred in {
|
||||
Classification.FORBIDDEN,
|
||||
Classification.CATASTROPHIC,
|
||||
}
|
||||
|
||||
|
||||
def _is_false_negative(pred: Classification, truth: Classification) -> bool:
|
||||
return truth in {
|
||||
Classification.FORBIDDEN,
|
||||
Classification.CATASTROPHIC,
|
||||
} and pred in {
|
||||
Classification.BLESSED,
|
||||
Classification.AMBIGUOUS,
|
||||
}
|
||||
|
||||
|
||||
def _compute_metrics(
|
||||
events: list[dict[str, Any]],
|
||||
baseline: ShadowClassifier,
|
||||
candidate: ShadowClassifier,
|
||||
) -> ReplayMetrics:
|
||||
total = 0
|
||||
b_fp = b_fn = 0
|
||||
c_fp = c_fn = 0
|
||||
catastrophic_ok = True
|
||||
|
||||
for ev in events:
|
||||
truth = _ground_truth(ev)
|
||||
if truth is None:
|
||||
continue
|
||||
q = _telemetry_query(ev)
|
||||
total += 1
|
||||
|
||||
b = baseline.classify(q).classification
|
||||
c = candidate.classify(q).classification
|
||||
|
||||
if _is_false_positive(b, truth):
|
||||
b_fp += 1
|
||||
if _is_false_negative(b, truth):
|
||||
b_fn += 1
|
||||
if _is_false_positive(c, truth):
|
||||
c_fp += 1
|
||||
if _is_false_negative(c, truth):
|
||||
c_fn += 1
|
||||
|
||||
if b == Classification.CATASTROPHIC and c != Classification.CATASTROPHIC:
|
||||
catastrophic_ok = False
|
||||
|
||||
return ReplayMetrics(
|
||||
total=total,
|
||||
baseline_false_pos=b_fp,
|
||||
baseline_false_neg=b_fn,
|
||||
candidate_false_pos=c_fp,
|
||||
candidate_false_neg=c_fn,
|
||||
catastrophic_boundary_unchanged=catastrophic_ok,
|
||||
)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Layer0: replay candidate patterns against recent telemetry."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--candidate",
|
||||
required=True,
|
||||
help="Candidate snapshot JSON (from layer0.learn).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--telemetry-jsonl",
|
||||
action="append",
|
||||
default=[],
|
||||
help="Path to telemetry JSONL (repeatable). Must include outcome=success|blocked_by_guardrails|... for scoring.",
|
||||
)
|
||||
parser.add_argument("--limit", type=int, default=2000)
|
||||
parser.add_argument(
|
||||
"--active",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Active patterns snapshot (defaults to .state).",
|
||||
)
|
||||
parser.add_argument("--db", type=str, default=None)
|
||||
parser.add_argument("--report-out", type=str, default=None)
|
||||
parser.add_argument(
|
||||
"--promote",
|
||||
action="store_true",
|
||||
help="If replay passes, write active snapshot update.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--allow-relaxations",
|
||||
action="store_true",
|
||||
help="Allow promotion of relaxation-mode patterns (requires replay pass).",
|
||||
)
|
||||
parser.add_argument("--max-fp-increase", type=int, default=0)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
telemetry_paths = [Path(p).expanduser() for p in args.telemetry_jsonl if p]
|
||||
if not telemetry_paths:
|
||||
default_preboot = REPO_ROOT / "anomalies" / "preboot_shield.jsonl"
|
||||
if default_preboot.exists():
|
||||
telemetry_paths = [default_preboot]
|
||||
|
||||
events = _read_jsonl(telemetry_paths, limit=int(args.limit))
|
||||
|
||||
active_path = (
|
||||
Path(args.active).expanduser().resolve()
|
||||
if args.active
|
||||
else PatternStore().active_path
|
||||
)
|
||||
active_patterns = _load_patterns_file(active_path)
|
||||
candidate_path = Path(args.candidate).expanduser().resolve()
|
||||
candidate_patterns_all = _load_patterns_file(candidate_path)
|
||||
candidate_patterns = [
|
||||
p
|
||||
for p in candidate_patterns_all
|
||||
if isinstance(p, dict)
|
||||
and (args.allow_relaxations or str(p.get("mode") or "escalate") != "relax")
|
||||
]
|
||||
|
||||
baseline_classifier = ShadowClassifier(
|
||||
pattern_store=PatternStore(active_path=active_path)
|
||||
)
|
||||
|
||||
merged = _merge_patterns(active_patterns, candidate_patterns)
|
||||
merged_path = (
|
||||
REPO_ROOT / ".state" / "layer0_patterns_merged_replay.json"
|
||||
).resolve()
|
||||
write_pattern_snapshot(merged_path, merged)
|
||||
candidate_classifier = ShadowClassifier(
|
||||
pattern_store=PatternStore(active_path=merged_path)
|
||||
)
|
||||
|
||||
metrics = _compute_metrics(events, baseline_classifier, candidate_classifier)
|
||||
|
||||
passes = (
|
||||
metrics.catastrophic_boundary_unchanged
|
||||
and metrics.candidate_false_pos
|
||||
<= metrics.baseline_false_pos + int(args.max_fp_increase)
|
||||
and metrics.candidate_false_neg <= metrics.baseline_false_neg
|
||||
)
|
||||
|
||||
report = {
|
||||
"generated_at": _utc_now_iso_z(),
|
||||
"telemetry_inputs": [str(p) for p in telemetry_paths],
|
||||
"candidate_snapshot": str(candidate_path),
|
||||
"active_snapshot": str(active_path),
|
||||
"merged_snapshot": str(merged_path),
|
||||
"allow_relaxations": bool(args.allow_relaxations),
|
||||
"max_fp_increase": int(args.max_fp_increase),
|
||||
"metrics": {
|
||||
"total_scored": metrics.total,
|
||||
"baseline_false_positives": metrics.baseline_false_pos,
|
||||
"baseline_false_negatives": metrics.baseline_false_neg,
|
||||
"candidate_false_positives": metrics.candidate_false_pos,
|
||||
"candidate_false_negatives": metrics.candidate_false_neg,
|
||||
"catastrophic_boundary_unchanged": metrics.catastrophic_boundary_unchanged,
|
||||
},
|
||||
"passes": passes,
|
||||
"promotion": {
|
||||
"requested": bool(args.promote),
|
||||
"performed": False,
|
||||
"active_written_to": str(active_path),
|
||||
"patterns_added": len(candidate_patterns),
|
||||
},
|
||||
}
|
||||
|
||||
report_out = (
|
||||
Path(args.report_out).expanduser().resolve()
|
||||
if args.report_out
|
||||
else (REPO_ROOT / ".state" / "layer0_shadow_replay_report.json").resolve()
|
||||
)
|
||||
report_out.parent.mkdir(parents=True, exist_ok=True)
|
||||
report_out.write_text(
|
||||
json.dumps(report, ensure_ascii=False, sort_keys=True, indent=2) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
db_path = Path(args.db).expanduser().resolve() if args.db else _default_db_path()
|
||||
report_artifact_id = _log_artifact(
|
||||
kind="shadow_replay_report",
|
||||
path=report_out,
|
||||
meta={
|
||||
"passes": passes,
|
||||
"total_scored": metrics.total,
|
||||
"baseline_fp": metrics.baseline_false_pos,
|
||||
"baseline_fn": metrics.baseline_false_neg,
|
||||
"candidate_fp": metrics.candidate_false_pos,
|
||||
"candidate_fn": metrics.candidate_false_neg,
|
||||
},
|
||||
trace_id=None,
|
||||
db_path=db_path,
|
||||
)
|
||||
|
||||
if args.promote and passes:
|
||||
# Promotion = merged active snapshot (existing + candidates), written atomically.
|
||||
tmp_path = active_path.with_suffix(active_path.suffix + ".tmp")
|
||||
write_pattern_snapshot(tmp_path, merged)
|
||||
tmp_path.replace(active_path)
|
||||
promo_artifact_id = _log_artifact(
|
||||
kind="shadow_pattern_promotion",
|
||||
path=active_path,
|
||||
meta={
|
||||
"added": len(candidate_patterns),
|
||||
"source_candidate": str(candidate_path),
|
||||
"merged_snapshot": str(merged_path),
|
||||
},
|
||||
trace_id=None,
|
||||
db_path=db_path,
|
||||
)
|
||||
report["promotion"]["performed"] = True
|
||||
report["promotion"]["artifact_id"] = promo_artifact_id
|
||||
report_out.write_text(
|
||||
json.dumps(report, ensure_ascii=False, sort_keys=True, indent=2) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
print(f"Replay report: {report_out} (passes={passes})")
|
||||
print(f"Logged artifact {report_artifact_id} to {db_path}")
|
||||
if args.promote:
|
||||
print(
|
||||
f"Promotion {'performed' if (args.promote and passes) else 'skipped'}; active={active_path}"
|
||||
)
|
||||
return 0 if passes else 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
376
layer0/security_classifier.py
Normal file
376
layer0/security_classifier.py
Normal file
@@ -0,0 +1,376 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Enhanced Security Classification Framework for Layer0
|
||||
Provides advanced classification capabilities for Cloudflare infrastructure operations
|
||||
"""
|
||||
|
||||
from enum import Enum
|
||||
from typing import Dict, List, Optional, Any
|
||||
from dataclasses import dataclass
|
||||
import re
|
||||
|
||||
|
||||
class SecurityLevel(str, Enum):
|
||||
"""Security classification levels"""
|
||||
|
||||
LOW_RISK = "low_risk"
|
||||
MEDIUM_RISK = "medium_risk"
|
||||
HIGH_RISK = "high_risk"
|
||||
CRITICAL_RISK = "critical_risk"
|
||||
|
||||
|
||||
class OperationType(str, Enum):
|
||||
"""Types of infrastructure operations"""
|
||||
|
||||
READ_ONLY = "read_only"
|
||||
CONFIGURATION_CHANGE = "configuration_change"
|
||||
INFRASTRUCTURE_MODIFICATION = "infrastructure_modification"
|
||||
SECURITY_MODIFICATION = "security_modification"
|
||||
ACCESS_CONTROL_CHANGE = "access_control_change"
|
||||
|
||||
|
||||
class ResourceType(str, Enum):
|
||||
"""Types of Cloudflare resources"""
|
||||
|
||||
DNS_RECORD = "dns_record"
|
||||
WAF_RULE = "waf_rule"
|
||||
ACCESS_RULE = "access_rule"
|
||||
TUNNEL = "tunnel"
|
||||
ZONE_SETTINGS = "zone_settings"
|
||||
ACCOUNT_SETTINGS = "account_settings"
|
||||
|
||||
|
||||
@dataclass
|
||||
class SecurityClassification:
|
||||
"""Result of security classification"""
|
||||
|
||||
level: SecurityLevel
|
||||
operation_type: OperationType
|
||||
resource_type: ResourceType
|
||||
confidence: float # 0.0 to 1.0
|
||||
flags: List[str]
|
||||
rationale: str
|
||||
requires_approval: bool
|
||||
approval_threshold: Optional[str] = None
|
||||
|
||||
|
||||
class SecurityClassifier:
|
||||
"""
|
||||
Advanced security classifier for Cloudflare infrastructure operations
|
||||
Provides multi-dimensional risk assessment and classification
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
# Pattern definitions for different risk levels
|
||||
self.critical_patterns = [
|
||||
r"delete.*all",
|
||||
r"destroy.*infrastructure",
|
||||
r"disable.*waf",
|
||||
r"remove.*firewall",
|
||||
r"bypass.*security",
|
||||
r"expose.*credentials",
|
||||
r"terraform.*destroy",
|
||||
r"drop.*database",
|
||||
]
|
||||
|
||||
self.high_risk_patterns = [
|
||||
r"modify.*dns",
|
||||
r"change.*tunnel",
|
||||
r"update.*waf",
|
||||
r"create.*rule",
|
||||
r"modify.*access",
|
||||
r"terraform.*apply",
|
||||
]
|
||||
|
||||
self.medium_risk_patterns = [
|
||||
r"create.*record",
|
||||
r"update.*settings",
|
||||
r"configure.*zone",
|
||||
r"modify.*page",
|
||||
r"change.*cache",
|
||||
]
|
||||
|
||||
self.low_risk_patterns = [
|
||||
r"list.*records",
|
||||
r"get.*status",
|
||||
r"show.*config",
|
||||
r"read.*logs",
|
||||
r"monitor.*health",
|
||||
]
|
||||
|
||||
# Operation type patterns
|
||||
self.operation_patterns = {
|
||||
OperationType.READ_ONLY: [
|
||||
r"list",
|
||||
r"get",
|
||||
r"show",
|
||||
r"read",
|
||||
r"monitor",
|
||||
r"status",
|
||||
],
|
||||
OperationType.CONFIGURATION_CHANGE: [
|
||||
r"configure",
|
||||
r"update.*settings",
|
||||
r"change.*config",
|
||||
],
|
||||
OperationType.INFRASTRUCTURE_MODIFICATION: [
|
||||
r"create",
|
||||
r"modify",
|
||||
r"update",
|
||||
r"delete",
|
||||
r"destroy",
|
||||
],
|
||||
OperationType.SECURITY_MODIFICATION: [
|
||||
r"waf",
|
||||
r"firewall",
|
||||
r"security",
|
||||
r"block",
|
||||
r"allow",
|
||||
],
|
||||
OperationType.ACCESS_CONTROL_CHANGE: [
|
||||
r"access",
|
||||
r"permission",
|
||||
r"role",
|
||||
r"policy",
|
||||
],
|
||||
}
|
||||
|
||||
# Resource type patterns
|
||||
self.resource_patterns = {
|
||||
ResourceType.DNS_RECORD: [r"dns", r"record", r"domain", r"zone"],
|
||||
ResourceType.WAF_RULE: [r"waf", r"firewall", r"rule", r"security"],
|
||||
ResourceType.ACCESS_RULE: [r"access", r"policy", r"permission"],
|
||||
ResourceType.TUNNEL: [r"tunnel", r"connector", r"proxy"],
|
||||
ResourceType.ZONE_SETTINGS: [r"zone.*settings", r"domain.*config"],
|
||||
ResourceType.ACCOUNT_SETTINGS: [r"account.*settings", r"billing"],
|
||||
}
|
||||
|
||||
def classify_operation(
|
||||
self, operation_description: str, context: Optional[Dict[str, Any]] = None
|
||||
) -> SecurityClassification:
|
||||
"""
|
||||
Classify an infrastructure operation based on description and context
|
||||
"""
|
||||
description_lower = operation_description.lower()
|
||||
|
||||
# Determine security level
|
||||
security_level = self._determine_security_level(description_lower)
|
||||
|
||||
# Determine operation type
|
||||
operation_type = self._determine_operation_type(description_lower)
|
||||
|
||||
# Determine resource type
|
||||
resource_type = self._determine_resource_type(description_lower)
|
||||
|
||||
# Calculate confidence
|
||||
confidence = self._calculate_confidence(description_lower, security_level)
|
||||
|
||||
# Generate flags
|
||||
flags = self._generate_flags(description_lower, security_level, context)
|
||||
|
||||
# Generate rationale
|
||||
rationale = self._generate_rationale(
|
||||
security_level, operation_type, resource_type
|
||||
)
|
||||
|
||||
# Determine if approval is required
|
||||
requires_approval = security_level in [
|
||||
SecurityLevel.HIGH_RISK,
|
||||
SecurityLevel.CRITICAL_RISK,
|
||||
]
|
||||
approval_threshold = self._determine_approval_threshold(security_level)
|
||||
|
||||
return SecurityClassification(
|
||||
level=security_level,
|
||||
operation_type=operation_type,
|
||||
resource_type=resource_type,
|
||||
confidence=confidence,
|
||||
flags=flags,
|
||||
rationale=rationale,
|
||||
requires_approval=requires_approval,
|
||||
approval_threshold=approval_threshold,
|
||||
)
|
||||
|
||||
def _determine_security_level(self, description: str) -> SecurityLevel:
|
||||
"""Determine the security risk level"""
|
||||
for pattern in self.critical_patterns:
|
||||
if re.search(pattern, description):
|
||||
return SecurityLevel.CRITICAL_RISK
|
||||
|
||||
for pattern in self.high_risk_patterns:
|
||||
if re.search(pattern, description):
|
||||
return SecurityLevel.HIGH_RISK
|
||||
|
||||
for pattern in self.medium_risk_patterns:
|
||||
if re.search(pattern, description):
|
||||
return SecurityLevel.MEDIUM_RISK
|
||||
|
||||
for pattern in self.low_risk_patterns:
|
||||
if re.search(pattern, description):
|
||||
return SecurityLevel.LOW_RISK
|
||||
|
||||
# Default to medium risk for unknown operations
|
||||
return SecurityLevel.MEDIUM_RISK
|
||||
|
||||
def _determine_operation_type(self, description: str) -> OperationType:
|
||||
"""Determine the type of operation"""
|
||||
for op_type, patterns in self.operation_patterns.items():
|
||||
for pattern in patterns:
|
||||
if re.search(pattern, description):
|
||||
return op_type
|
||||
|
||||
# Default to infrastructure modification for safety
|
||||
return OperationType.INFRASTRUCTURE_MODIFICATION
|
||||
|
||||
def _determine_resource_type(self, description: str) -> ResourceType:
|
||||
"""Determine the type of resource being operated on"""
|
||||
for resource_type, patterns in self.resource_patterns.items():
|
||||
for pattern in patterns:
|
||||
if re.search(pattern, description):
|
||||
return resource_type
|
||||
|
||||
# Default to DNS records (most common)
|
||||
return ResourceType.DNS_RECORD
|
||||
|
||||
def _calculate_confidence(
|
||||
self, description: str, security_level: SecurityLevel
|
||||
) -> float:
|
||||
"""Calculate confidence score for classification"""
|
||||
base_confidence = 0.7
|
||||
|
||||
# Increase confidence for longer, more specific descriptions
|
||||
word_count = len(description.split())
|
||||
if word_count > 10:
|
||||
base_confidence += 0.2
|
||||
elif word_count > 5:
|
||||
base_confidence += 0.1
|
||||
|
||||
# Adjust based on security level
|
||||
if security_level == SecurityLevel.CRITICAL_RISK:
|
||||
base_confidence += 0.1 # Critical patterns are usually clear
|
||||
|
||||
return min(1.0, base_confidence)
|
||||
|
||||
def _generate_flags(
|
||||
self,
|
||||
description: str,
|
||||
security_level: SecurityLevel,
|
||||
context: Optional[Dict[str, Any]],
|
||||
) -> List[str]:
|
||||
"""Generate security flags for the operation"""
|
||||
flags = []
|
||||
|
||||
# Basic flags based on security level
|
||||
if security_level == SecurityLevel.CRITICAL_RISK:
|
||||
flags.extend(
|
||||
["critical_risk", "requires_emergency_approval", "multi_factor_auth"]
|
||||
)
|
||||
elif security_level == SecurityLevel.HIGH_RISK:
|
||||
flags.extend(["high_risk", "requires_senior_approval", "audit_trail"])
|
||||
elif security_level == SecurityLevel.MEDIUM_RISK:
|
||||
flags.extend(["medium_risk", "requires_standard_approval"])
|
||||
else:
|
||||
flags.extend(["low_risk", "auto_approved"])
|
||||
|
||||
# Context-based flags
|
||||
if context:
|
||||
environment = context.get("environment", "")
|
||||
if environment.lower() in ["prod", "production"]:
|
||||
flags.append("production_environment")
|
||||
|
||||
user_role = context.get("user_role", "")
|
||||
if user_role.lower() in ["admin", "root"]:
|
||||
flags.append("privileged_user")
|
||||
|
||||
# Pattern-based flags
|
||||
if re.search(r"delete|destroy|remove", description):
|
||||
flags.append("destructive_operation")
|
||||
|
||||
if re.search(r"waf|firewall|security", description):
|
||||
flags.append("security_related")
|
||||
|
||||
if re.search(r"dns|domain|zone", description):
|
||||
flags.append("dns_related")
|
||||
|
||||
return flags
|
||||
|
||||
def _generate_rationale(
|
||||
self,
|
||||
security_level: SecurityLevel,
|
||||
operation_type: OperationType,
|
||||
resource_type: ResourceType,
|
||||
) -> str:
|
||||
"""Generate rationale for the classification"""
|
||||
rationales = {
|
||||
SecurityLevel.CRITICAL_RISK: "Critical risk operation involving infrastructure destruction or security bypass",
|
||||
SecurityLevel.HIGH_RISK: "High risk operation modifying core infrastructure or security settings",
|
||||
SecurityLevel.MEDIUM_RISK: "Medium risk operation involving configuration changes",
|
||||
SecurityLevel.LOW_RISK: "Low risk read-only operation",
|
||||
}
|
||||
|
||||
base_rationale = rationales.get(
|
||||
security_level, "Standard infrastructure operation"
|
||||
)
|
||||
|
||||
# Add operation-specific details
|
||||
if operation_type == OperationType.INFRASTRUCTURE_MODIFICATION:
|
||||
base_rationale += " with infrastructure modification capabilities"
|
||||
elif operation_type == OperationType.SECURITY_MODIFICATION:
|
||||
base_rationale += " affecting security controls"
|
||||
|
||||
# Add resource-specific details
|
||||
if resource_type == ResourceType.DNS_RECORD:
|
||||
base_rationale += " on DNS infrastructure"
|
||||
elif resource_type == ResourceType.WAF_RULE:
|
||||
base_rationale += " on WAF security rules"
|
||||
|
||||
return base_rationale
|
||||
|
||||
def _determine_approval_threshold(
|
||||
self, security_level: SecurityLevel
|
||||
) -> Optional[str]:
|
||||
"""Determine the approval threshold required"""
|
||||
thresholds = {
|
||||
SecurityLevel.CRITICAL_RISK: "Emergency Change Advisory Board (ECAB)",
|
||||
SecurityLevel.HIGH_RISK: "Senior Infrastructure Engineer",
|
||||
SecurityLevel.MEDIUM_RISK: "Team Lead",
|
||||
SecurityLevel.LOW_RISK: None,
|
||||
}
|
||||
return thresholds.get(security_level)
|
||||
|
||||
|
||||
# Example usage and testing
|
||||
def main():
|
||||
"""Example usage of the security classifier"""
|
||||
classifier = SecurityClassifier()
|
||||
|
||||
# Test cases
|
||||
test_cases = [
|
||||
"Delete all DNS records for domain example.com",
|
||||
"Update WAF rule to allow traffic from China",
|
||||
"Create new DNS record for subdomain",
|
||||
"List all current tunnels and their status",
|
||||
"Modify zone settings to enable development mode",
|
||||
"Destroy all terraform infrastructure",
|
||||
]
|
||||
|
||||
print("🔐 Security Classification Framework Test")
|
||||
print("=" * 60)
|
||||
|
||||
for test_case in test_cases:
|
||||
classification = classifier.classify_operation(test_case)
|
||||
|
||||
print(f"\nOperation: {test_case}")
|
||||
print(f"Security Level: {classification.level.value}")
|
||||
print(f"Operation Type: {classification.operation_type.value}")
|
||||
print(f"Resource Type: {classification.resource_type.value}")
|
||||
print(f"Confidence: {classification.confidence:.2f}")
|
||||
print(f"Requires Approval: {classification.requires_approval}")
|
||||
if classification.approval_threshold:
|
||||
print(f"Approval Threshold: {classification.approval_threshold}")
|
||||
print(f"Flags: {', '.join(classification.flags)}")
|
||||
print(f"Rationale: {classification.rationale}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,6 +1,8 @@
|
||||
from enum import Enum
|
||||
from typing import Optional, List
|
||||
import uuid
|
||||
from enum import Enum
|
||||
from typing import Any, List, Mapping, Optional
|
||||
|
||||
from .pattern_store import PatternStore, normalize_query_for_matching
|
||||
|
||||
|
||||
class Classification(str, Enum):
|
||||
@@ -39,55 +41,136 @@ class ShadowClassifier:
|
||||
Minimal doctrinal classifier for Layer 0 (Shadow Eval).
|
||||
"""
|
||||
|
||||
def classify(self, query: str) -> ShadowEvalResult:
|
||||
def __init__(self, pattern_store: PatternStore | None = None):
|
||||
self._patterns = pattern_store or PatternStore()
|
||||
|
||||
def classify(
|
||||
self, query: str, *, context: Mapping[str, Any] | None = None
|
||||
) -> ShadowEvalResult:
|
||||
"""Return a doctrinal classification for the incoming query."""
|
||||
|
||||
q = query.lower().strip()
|
||||
q = (query or "").lower().strip()
|
||||
q_norm = normalize_query_for_matching(query or "")
|
||||
|
||||
# 1. Catastrophic (fail closed)
|
||||
if any(x in q for x in [
|
||||
"disable guardrails",
|
||||
"override agent permissions",
|
||||
"bypass governance",
|
||||
"self-modifying",
|
||||
]):
|
||||
return ShadowEvalResult(
|
||||
classification=Classification.CATASTROPHIC,
|
||||
reason="catastrophic_indicator",
|
||||
risk_score=5,
|
||||
flags=["permission_override", "guardrail_disable"],
|
||||
# 0. Catastrophic boundary (fail closed): never relaxed at runtime.
|
||||
if any(
|
||||
x in q
|
||||
for x in [
|
||||
"disable guardrails",
|
||||
"override agent permissions",
|
||||
"bypass governance",
|
||||
"self-modifying",
|
||||
]
|
||||
):
|
||||
return self._apply_context(
|
||||
ShadowEvalResult(
|
||||
classification=Classification.CATASTROPHIC,
|
||||
reason="catastrophic_indicator",
|
||||
risk_score=5,
|
||||
flags=["permission_override", "guardrail_disable"],
|
||||
),
|
||||
context,
|
||||
)
|
||||
|
||||
# 2. Forbidden (governance violation)
|
||||
if any(x in q for x in [
|
||||
"skip git",
|
||||
"apply directly",
|
||||
"dashboard",
|
||||
"manual change",
|
||||
]):
|
||||
return ShadowEvalResult(
|
||||
classification=Classification.FORBIDDEN,
|
||||
reason="governance_violation",
|
||||
risk_score=3,
|
||||
flags=["gitops_bypass"],
|
||||
# 1. Learned patterns (highest specificity/support first)
|
||||
learned = self._patterns.match_ordered(q_norm)
|
||||
if learned:
|
||||
p = learned[0]
|
||||
return self._apply_context(
|
||||
ShadowEvalResult(
|
||||
classification=Classification(p.classification),
|
||||
reason=p.reason or "telemetry_learned",
|
||||
risk_score=int(p.risk_score),
|
||||
flags=list(p.flags) + ["telemetry_learned"],
|
||||
),
|
||||
context,
|
||||
)
|
||||
|
||||
# 3. Ambiguous (needs clarification)
|
||||
if any(x in q for x in [
|
||||
"fix it",
|
||||
"change this",
|
||||
"update stuff",
|
||||
]) or len(q.split()) <= 2:
|
||||
return ShadowEvalResult(
|
||||
classification=Classification.AMBIGUOUS,
|
||||
reason="insufficient_context",
|
||||
risk_score=1,
|
||||
flags=["needs_clarification"],
|
||||
# 2. Static patterns
|
||||
|
||||
# 2a. Forbidden (governance violation)
|
||||
if any(
|
||||
x in q
|
||||
for x in [
|
||||
"skip git",
|
||||
"apply directly",
|
||||
"dashboard",
|
||||
"manual change",
|
||||
]
|
||||
):
|
||||
return self._apply_context(
|
||||
ShadowEvalResult(
|
||||
classification=Classification.FORBIDDEN,
|
||||
reason="governance_violation",
|
||||
risk_score=3,
|
||||
flags=["gitops_bypass"],
|
||||
),
|
||||
context,
|
||||
)
|
||||
|
||||
# 2b. Ambiguous (needs clarification)
|
||||
if (
|
||||
any(
|
||||
x in q
|
||||
for x in [
|
||||
"fix it",
|
||||
"change this",
|
||||
"update stuff",
|
||||
]
|
||||
)
|
||||
or len(q.split()) <= 2
|
||||
):
|
||||
return self._apply_context(
|
||||
ShadowEvalResult(
|
||||
classification=Classification.AMBIGUOUS,
|
||||
reason="insufficient_context",
|
||||
risk_score=1,
|
||||
flags=["needs_clarification"],
|
||||
),
|
||||
context,
|
||||
)
|
||||
|
||||
# 4. Blessed (valid + lawful)
|
||||
return ShadowEvalResult(
|
||||
classification=Classification.BLESSED,
|
||||
reason=None,
|
||||
risk_score=0,
|
||||
return self._apply_context(
|
||||
ShadowEvalResult(
|
||||
classification=Classification.BLESSED,
|
||||
reason=None,
|
||||
risk_score=0,
|
||||
),
|
||||
context,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _apply_context(
|
||||
result: ShadowEvalResult, context: Mapping[str, Any] | None
|
||||
) -> ShadowEvalResult:
|
||||
if not context:
|
||||
return result
|
||||
|
||||
env = str(context.get("environment") or "").lower()
|
||||
realm = str(context.get("realm") or "").lower()
|
||||
capability = str(context.get("capability") or "").lower()
|
||||
role = str(context.get("actor_role") or context.get("role") or "").lower()
|
||||
|
||||
mult = 1.0
|
||||
if env in {"prod", "production"}:
|
||||
mult *= 2.0
|
||||
elif env in {"staging", "stage"}:
|
||||
mult *= 1.5
|
||||
elif env in {"dev", "development", "test"}:
|
||||
mult *= 1.0
|
||||
|
||||
if capability in {"destroy", "delete", "write"}:
|
||||
mult *= 1.5
|
||||
elif capability in {"read"}:
|
||||
mult *= 1.0
|
||||
|
||||
if role in {"admin", "root"}:
|
||||
mult *= 1.2
|
||||
|
||||
if realm in {"terraform", "gitops", "cloudflare"}:
|
||||
mult *= 1.1
|
||||
|
||||
weighted = int(round(result.risk_score * mult))
|
||||
result.risk_score = max(0, min(5, weighted))
|
||||
return result
|
||||
|
||||
Reference in New Issue
Block a user