|
| 1 | +"""Pure canonical contract identity schema and digest helpers.""" |
| 2 | + |
| 3 | +from __future__ import annotations |
| 4 | + |
| 5 | +import hashlib |
| 6 | +import hmac |
| 7 | +import json |
| 8 | +import re |
| 9 | +import unicodedata |
| 10 | +from collections import defaultdict |
| 11 | +from dataclasses import dataclass |
| 12 | +from typing import Any |
| 13 | + |
| 14 | +SCHEMA = "contract_identity.v2" |
| 15 | +CANONICALIZER_VERSION = "operator_tokens.v1" |
| 16 | +ALLOWED_CATEGORIES = frozenset( |
| 17 | + {"bug", "contract", "logic", "performance", "reliability", "security"} |
| 18 | +) |
| 19 | +ALLOWED_SEVERITIES = frozenset({"critical", "high", "medium", "low"}) |
| 20 | +ALLOWED_ANCHOR_KINDS = frozenset( |
| 21 | + {"config", "endpoint", "field", "identifier", "schema", "symbol", "type"} |
| 22 | +) |
| 23 | + |
| 24 | +MAX_REPO_BYTES = 200 |
| 25 | +MAX_FILE_BYTES = 512 |
| 26 | +MAX_ANCHOR_BYTES = 256 |
| 27 | +MAX_TOKEN_BYTES = 256 |
| 28 | +MAX_EVIDENCE_BYTES = 256 |
| 29 | +MAX_ITEMS = 32 |
| 30 | +MAX_TOKENS_PER_CLAUSE = 128 |
| 31 | +MAX_CANONICAL_BYTES = 16_384 |
| 32 | + |
| 33 | +_TOP_REQUIRED = frozenset( |
| 34 | + { |
| 35 | + "schema", |
| 36 | + "canonicalizer_version", |
| 37 | + "scope", |
| 38 | + "anchors", |
| 39 | + "predicates", |
| 40 | + "required_behavior", |
| 41 | + "forbidden_behavior", |
| 42 | + "ordering_constraints", |
| 43 | + "evidence", |
| 44 | + } |
| 45 | +) |
| 46 | +_DIGEST_FIELDS = frozenset({"contract_key", "behavior_digest", "fingerprint_v2"}) |
| 47 | +_OPERATORS = ("===", "!==", ">=", "<=", "==", "!=", "->", "=>", "::", ">", "<") |
| 48 | +_TOKEN_RE = re.compile( |
| 49 | + r"<SECRET:[A-Z_]+:\d+>|===|!==|>=|<=|==|!=|->|=>|::|>|<|[\w.$]+|[^\s]", |
| 50 | + re.UNICODE, |
| 51 | +) |
| 52 | +_PLACEHOLDER_RE = re.compile(r"<SECRET:[A-Z_]+:\d+>") |
| 53 | +_SECRET_PATTERNS = ( |
| 54 | + ("PRIVATE_KEY", re.compile(r"-----BEGIN [^-]+-----.*?-----END [^-]+-----", re.I | re.S)), |
| 55 | + ("AWS", re.compile(r"\b(?:AKIA|ASIA|AIDA|AROA)[A-Z0-9]{16}\b")), |
| 56 | + ("SLACK", re.compile(r"\bxox[baprs]-[A-Za-z0-9-]{10,}\b")), |
| 57 | + ("GITLAB", re.compile(r"\bglpat-[A-Za-z0-9_-]{10,}\b")), |
| 58 | + ("JWT", re.compile(r"\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b")), |
| 59 | + ("DSN", re.compile(r"\b[a-z][a-z0-9+.-]*://[^\s/@:]+:[^\s/@]+@[^\s]+", re.I)), |
| 60 | + ("BEARER", re.compile(r"(?i)\bbearer\s+\S+")), |
| 61 | + ("CREDENTIAL", re.compile( |
| 62 | + r"(?i)(?<!<)\b(?:token|secret|password|api[ _-]?key|authorization|cookie)\b\s*[:=]\s*\S+" |
| 63 | + )), |
| 64 | + ("API_KEY", re.compile(r"\b(?:gh[pousr]_|sk-)[A-Za-z0-9_-]{20,}\b")), |
| 65 | +) |
| 66 | + |
| 67 | + |
| 68 | +class IdentityValidationError(ValueError): |
| 69 | + """Raised when canonical identity input cannot be safely validated.""" |
| 70 | + |
| 71 | + |
| 72 | +@dataclass(frozen=True) |
| 73 | +class Scope: |
| 74 | + repo: str |
| 75 | + file: str |
| 76 | + category: str |
| 77 | + |
| 78 | + def as_dict(self) -> dict[str, str]: |
| 79 | + return {"repo": self.repo, "file": self.file, "category": self.category} |
| 80 | + |
| 81 | + |
| 82 | +@dataclass(frozen=True) |
| 83 | +class Anchor: |
| 84 | + kind: str |
| 85 | + value: str |
| 86 | + |
| 87 | + def as_dict(self) -> dict[str, str]: |
| 88 | + return {"kind": self.kind, "value": self.value} |
| 89 | + |
| 90 | + |
| 91 | +@dataclass(frozen=True) |
| 92 | +class Evidence: |
| 93 | + head_sha: str |
| 94 | + diff_digest: str |
| 95 | + file: str |
| 96 | + location_or_hunk_digest: str |
| 97 | + |
| 98 | + def as_dict(self) -> dict[str, str]: |
| 99 | + return { |
| 100 | + "head_sha": self.head_sha, |
| 101 | + "diff_digest": self.diff_digest, |
| 102 | + "file": self.file, |
| 103 | + "location_or_hunk_digest": self.location_or_hunk_digest, |
| 104 | + } |
| 105 | + |
| 106 | + |
| 107 | +@dataclass(frozen=True) |
| 108 | +class ContractIdentity: |
| 109 | + scope: Scope |
| 110 | + anchors: tuple[Anchor, ...] |
| 111 | + predicates: tuple[tuple[str, ...], ...] |
| 112 | + required_behavior: tuple[tuple[str, ...], ...] |
| 113 | + forbidden_behavior: tuple[tuple[str, ...], ...] |
| 114 | + ordering_constraints: tuple[tuple[str, ...], ...] |
| 115 | + evidence: Evidence |
| 116 | + severity: str | None |
| 117 | + contract_key: str |
| 118 | + behavior_digest: str |
| 119 | + fingerprint_v2: str |
| 120 | + |
| 121 | + def as_record(self) -> dict[str, Any]: |
| 122 | + record: dict[str, Any] = { |
| 123 | + "schema": SCHEMA, |
| 124 | + "canonicalizer_version": CANONICALIZER_VERSION, |
| 125 | + "scope": self.scope.as_dict(), |
| 126 | + "anchors": [anchor.as_dict() for anchor in self.anchors], |
| 127 | + "predicates": [list(clause) for clause in self.predicates], |
| 128 | + "required_behavior": [list(clause) for clause in self.required_behavior], |
| 129 | + "forbidden_behavior": [list(clause) for clause in self.forbidden_behavior], |
| 130 | + "ordering_constraints": [list(clause) for clause in self.ordering_constraints], |
| 131 | + "evidence": self.evidence.as_dict(), |
| 132 | + "contract_key": self.contract_key, |
| 133 | + "behavior_digest": self.behavior_digest, |
| 134 | + "fingerprint_v2": self.fingerprint_v2, |
| 135 | + } |
| 136 | + if self.severity is not None: |
| 137 | + record["severity"] = self.severity |
| 138 | + return record |
| 139 | + |
| 140 | + |
| 141 | +class _SecretRedactor: |
| 142 | + def __init__(self, *, allow_placeholders: bool) -> None: |
| 143 | + self.allow_placeholders = allow_placeholders |
| 144 | + self.counts: dict[str, int] = defaultdict(int) |
| 145 | + |
| 146 | + def redact(self, value: str, field: str) -> str: |
| 147 | + if "<SECRET:" in value and not self.allow_placeholders: |
| 148 | + raise IdentityValidationError(f"{field} contains a reserved secret placeholder") |
| 149 | + if self.allow_placeholders and any(pattern.search(value) for _, pattern in _SECRET_PATTERNS): |
| 150 | + raise IdentityValidationError(f"{field} persists an unredacted secret") |
| 151 | + text = value |
| 152 | + for secret_type, pattern in _SECRET_PATTERNS: |
| 153 | + def replacement(_match: re.Match[str], kind: str = secret_type) -> str: |
| 154 | + self.counts[kind] += 1 |
| 155 | + return f"<SECRET:{kind}:{self.counts[kind]}>" |
| 156 | + |
| 157 | + text = pattern.sub(replacement, text) |
| 158 | + malformed = re.search(r"<SECRET:[^>]*>", text) |
| 159 | + if malformed and not _PLACEHOLDER_RE.fullmatch(malformed.group(0)): |
| 160 | + raise IdentityValidationError(f"{field} contains an invalid secret placeholder") |
| 161 | + return text |
| 162 | + |
| 163 | + |
| 164 | +def _exact_fields(value: Any, required: frozenset[str], optional: frozenset[str], field: str) -> dict[str, Any]: |
| 165 | + if not isinstance(value, dict) or set(value) != required | (set(value) & optional): |
| 166 | + raise IdentityValidationError(f"{field} has missing or extra fields") |
| 167 | + return value |
| 168 | + |
| 169 | + |
| 170 | +def _text(value: Any, field: str, max_bytes: int, redactor: _SecretRedactor) -> str: |
| 171 | + if not isinstance(value, str): |
| 172 | + raise IdentityValidationError(f"{field} must be a string") |
| 173 | + normalized = unicodedata.normalize("NFC", value) |
| 174 | + if not normalized or any(unicodedata.category(char).startswith("C") for char in normalized): |
| 175 | + raise IdentityValidationError(f"{field} is empty or contains control characters") |
| 176 | + normalized = redactor.redact(normalized, field) |
| 177 | + if len(normalized.encode("utf-8")) > max_bytes: |
| 178 | + raise IdentityValidationError(f"{field} exceeds {max_bytes} bytes") |
| 179 | + return normalized |
| 180 | + |
| 181 | + |
| 182 | +def _repo(value: Any, field: str, redactor: _SecretRedactor) -> str: |
| 183 | + repo = _text(value, field, MAX_REPO_BYTES, redactor) |
| 184 | + if not re.fullmatch(r"[A-Za-z0-9._-]+/[A-Za-z0-9._-]+", repo): |
| 185 | + raise IdentityValidationError(f"{field} must be owner/name") |
| 186 | + return repo |
| 187 | + |
| 188 | + |
| 189 | +def _relative_path(value: Any, field: str, redactor: _SecretRedactor) -> str: |
| 190 | + path = _text(value, field, MAX_FILE_BYTES, redactor) |
| 191 | + parts = path.split("/") |
| 192 | + if ( |
| 193 | + "<SECRET:" in path |
| 194 | + or path.startswith("/") |
| 195 | + or "\\" in path |
| 196 | + or any(not part or part in {".", ".."} for part in parts) |
| 197 | + ): |
| 198 | + raise IdentityValidationError(f"{field} must be a repository-relative POSIX path") |
| 199 | + return path |
| 200 | + |
| 201 | + |
| 202 | +def _reference(value: Any, field: str, redactor: _SecretRedactor) -> str: |
| 203 | + reference = _text(value, field, MAX_EVIDENCE_BYTES, redactor) |
| 204 | + if "<SECRET:" in reference: |
| 205 | + raise IdentityValidationError(f"{field} must not contain a secret placeholder") |
| 206 | + return reference |
| 207 | + |
| 208 | + |
| 209 | +def _clauses( |
| 210 | + value: Any, field: str, redactor: _SecretRedactor, *, allow_empty: bool |
| 211 | +) -> tuple[tuple[str, ...], ...]: |
| 212 | + if not isinstance(value, list) or len(value) > MAX_ITEMS or (not value and not allow_empty): |
| 213 | + raise IdentityValidationError(f"{field} must be a bounded list") |
| 214 | + clauses: list[tuple[str, ...]] = [] |
| 215 | + for index, clause in enumerate(value): |
| 216 | + if not isinstance(clause, list) or not clause: |
| 217 | + raise IdentityValidationError(f"{field}[{index}] must be a non-empty token list") |
| 218 | + tokens: list[str] = [] |
| 219 | + for raw_token in clause: |
| 220 | + token = _text(raw_token, f"{field}[{index}]", MAX_TOKEN_BYTES, redactor) |
| 221 | + tokens.extend(_TOKEN_RE.findall(token)) |
| 222 | + if not tokens or len(tokens) > MAX_TOKENS_PER_CLAUSE: |
| 223 | + raise IdentityValidationError(f"{field}[{index}] has invalid token count") |
| 224 | + clauses.append(tuple(tokens)) |
| 225 | + return tuple(clauses) |
| 226 | + |
| 227 | + |
| 228 | +def _stable_json(value: Any) -> bytes: |
| 229 | + return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8") |
| 230 | + |
| 231 | + |
| 232 | +def _digest(value: Any) -> str: |
| 233 | + return hashlib.sha256(_stable_json(value)).hexdigest() |
| 234 | + |
| 235 | + |
| 236 | +def build_contract_identity(payload: dict[str, Any], *, _allow_placeholders: bool = False) -> ContractIdentity: |
| 237 | + top = _exact_fields(payload, _TOP_REQUIRED, frozenset({"severity"}), "identity") |
| 238 | + if top["schema"] != SCHEMA or top["canonicalizer_version"] != CANONICALIZER_VERSION: |
| 239 | + raise IdentityValidationError("unsupported identity schema or canonicalizer") |
| 240 | + redactor = _SecretRedactor(allow_placeholders=_allow_placeholders) |
| 241 | + |
| 242 | + raw_scope = _exact_fields(top["scope"], frozenset({"repo", "file", "category"}), frozenset(), "scope") |
| 243 | + category = str(raw_scope["category"]).lower() if isinstance(raw_scope["category"], str) else "" |
| 244 | + if category not in ALLOWED_CATEGORIES: |
| 245 | + raise IdentityValidationError("scope.category is not controlled") |
| 246 | + scope = Scope( |
| 247 | + repo=_repo(raw_scope["repo"], "scope.repo", redactor), |
| 248 | + file=_relative_path(raw_scope["file"], "scope.file", redactor), |
| 249 | + category=category, |
| 250 | + ) |
| 251 | + |
| 252 | + raw_anchors = top["anchors"] |
| 253 | + if not isinstance(raw_anchors, list) or not raw_anchors or len(raw_anchors) > MAX_ITEMS: |
| 254 | + raise IdentityValidationError("anchors must be a non-empty bounded list") |
| 255 | + anchors: list[Anchor] = [] |
| 256 | + for index, raw_anchor in enumerate(raw_anchors): |
| 257 | + anchor = _exact_fields(raw_anchor, frozenset({"kind", "value"}), frozenset(), f"anchors[{index}]") |
| 258 | + kind = str(anchor["kind"]).lower() if isinstance(anchor["kind"], str) else "" |
| 259 | + if kind not in ALLOWED_ANCHOR_KINDS: |
| 260 | + raise IdentityValidationError(f"anchors[{index}].kind is not controlled") |
| 261 | + anchors.append(Anchor(kind, _text(anchor["value"], f"anchors[{index}].value", MAX_ANCHOR_BYTES, redactor))) |
| 262 | + |
| 263 | + predicates = _clauses(top["predicates"], "predicates", redactor, allow_empty=False) |
| 264 | + required = _clauses( |
| 265 | + top["required_behavior"], "required_behavior", redactor, allow_empty=False |
| 266 | + ) |
| 267 | + forbidden = _clauses( |
| 268 | + top["forbidden_behavior"], "forbidden_behavior", redactor, allow_empty=True |
| 269 | + ) |
| 270 | + ordering = _clauses( |
| 271 | + top["ordering_constraints"], "ordering_constraints", redactor, allow_empty=True |
| 272 | + ) |
| 273 | + |
| 274 | + raw_evidence = _exact_fields( |
| 275 | + top["evidence"], |
| 276 | + frozenset({"head_sha", "diff_digest", "file", "location_or_hunk_digest"}), |
| 277 | + frozenset(), |
| 278 | + "evidence", |
| 279 | + ) |
| 280 | + head_sha = _text(raw_evidence["head_sha"], "evidence.head_sha", 64, redactor).lower() |
| 281 | + diff_digest = _text(raw_evidence["diff_digest"], "evidence.diff_digest", 64, redactor).lower() |
| 282 | + if not re.fullmatch(r"[0-9a-f]{7,64}", head_sha) or not re.fullmatch(r"[0-9a-f]{64}", diff_digest): |
| 283 | + raise IdentityValidationError("evidence digests are malformed") |
| 284 | + evidence = Evidence( |
| 285 | + head_sha=head_sha, |
| 286 | + diff_digest=diff_digest, |
| 287 | + file=_relative_path(raw_evidence["file"], "evidence.file", redactor), |
| 288 | + location_or_hunk_digest=_reference( |
| 289 | + raw_evidence["location_or_hunk_digest"], |
| 290 | + "evidence.location_or_hunk_digest", |
| 291 | + redactor, |
| 292 | + ), |
| 293 | + ) |
| 294 | + if evidence.file != scope.file: |
| 295 | + raise IdentityValidationError("evidence.file must match scope.file") |
| 296 | + severity = top.get("severity") |
| 297 | + if severity is not None: |
| 298 | + severity = str(severity).lower() if isinstance(severity, str) else "" |
| 299 | + if severity not in ALLOWED_SEVERITIES: |
| 300 | + raise IdentityValidationError("severity is not controlled") |
| 301 | + |
| 302 | + contract_payload = { |
| 303 | + "schema": SCHEMA, |
| 304 | + "canonicalizer_version": CANONICALIZER_VERSION, |
| 305 | + "scope": scope.as_dict(), |
| 306 | + "anchors": [anchor.as_dict() for anchor in anchors], |
| 307 | + "predicates": [list(clause) for clause in predicates], |
| 308 | + } |
| 309 | + contract_key = _digest(contract_payload) |
| 310 | + behavior_payload = { |
| 311 | + "contract_key": contract_key, |
| 312 | + "required_behavior": [list(clause) for clause in required], |
| 313 | + "forbidden_behavior": [list(clause) for clause in forbidden], |
| 314 | + "ordering_constraints": [list(clause) for clause in ordering], |
| 315 | + } |
| 316 | + behavior_digest = _digest(behavior_payload) |
| 317 | + fingerprint_v2 = _digest( |
| 318 | + {"contract_key": contract_key, "behavior_digest": behavior_digest} |
| 319 | + ) |
| 320 | + identity = ContractIdentity( |
| 321 | + scope, tuple(anchors), predicates, required, forbidden, ordering, |
| 322 | + evidence, severity, contract_key, behavior_digest, fingerprint_v2, |
| 323 | + ) |
| 324 | + if len(_stable_json(identity.as_record())) > MAX_CANONICAL_BYTES: |
| 325 | + raise IdentityValidationError("canonical identity exceeds total byte limit") |
| 326 | + return identity |
| 327 | + |
| 328 | + |
| 329 | +def verify_persisted_identity(record: dict[str, Any]) -> ContractIdentity: |
| 330 | + top = _exact_fields(record, _TOP_REQUIRED | _DIGEST_FIELDS, frozenset({"severity"}), "record") |
| 331 | + expected = {field: top[field] for field in _DIGEST_FIELDS} |
| 332 | + if any(not isinstance(value, str) or not re.fullmatch(r"[0-9a-f]{64}", value) for value in expected.values()): |
| 333 | + raise IdentityValidationError("persisted identity digest is malformed") |
| 334 | + payload = {key: value for key, value in top.items() if key not in _DIGEST_FIELDS} |
| 335 | + identity = build_contract_identity(payload, _allow_placeholders=True) |
| 336 | + if not all(hmac.compare_digest(expected[field], getattr(identity, field)) for field in _DIGEST_FIELDS): |
| 337 | + raise IdentityValidationError("persisted identity digest mismatch") |
| 338 | + return identity |
| 339 | + |
| 340 | + |
| 341 | +def canonical_json(identity: ContractIdentity) -> str: |
| 342 | + return _stable_json(identity.as_record()).decode("utf-8") |
| 343 | + |
| 344 | + |
| 345 | +def operators() -> tuple[str, ...]: |
| 346 | + return _OPERATORS |
0 commit comments