diff --git a/docs/local-audit-event-schema.md b/docs/local-audit-event-schema.md index befd0aa..0844ad6 100644 --- a/docs/local-audit-event-schema.md +++ b/docs/local-audit-event-schema.md @@ -4,7 +4,15 @@ ## Storage and append-only rule -Store one event per UTF-8 newline-delimited JSON (JSONL) line, normally under the selected local AetherMesh home. `append_local_audit_event(path, event)` validates an event and opens the file only in append mode; it never reads, replaces, edits, or deletes prior records. Consumers must treat an existing line as immutable evidence. Local filesystem access can still alter files outside this API, so this phase does not claim tamper resistance. +Store one event per UTF-8 newline-delimited JSON (JSONL) line, normally under the selected local AetherMesh home. `append_local_audit_event(path, event)` sanitizes and validates an event before opening the file in append mode; it never reads, replaces, edits, or deletes prior records. Consumers must treat an existing line as immutable evidence. Local filesystem access can still alter files outside this API, so this phase does not claim tamper resistance. + +## Log-safety policy + +Audit events preserve intentionally safe accountability fields: node and work IDs, manifest hashes and references, validation receipt IDs, lineage, and contribution attribution. They should contain hashes, IDs, safe relative references, and short summaries instead of raw request or result content. + +`sanitize_local_audit_event` recursively replaces values under secret-bearing keys (`token`, `secret`, `password`, `privateKey`, `apiKey`, `seed`, `credential`, and `authorization`, including common compound forms) with `[REDACTED]`. Environment maps and raw request payload fields are also redacted. Absolute Unix, home-relative, and Windows paths become `local-path/` labels; callers should supply a project-relative artifact reference when that reference is needed for validation. Do not place environment-variable dumps, private keys, credentials, full prompts, request payloads, or node-local configuration bodies in audit fields. + +Sanitization is a persistence boundary, not a general secret detector. Callers must still prefer the compact schema and must not hide arbitrary secret text inside an unrelated identifier or summary field. ## Required fields diff --git a/src/aethermesh_core/local_audit_event.py b/src/aethermesh_core/local_audit_event.py index 81745c8..31f2b78 100644 --- a/src/aethermesh_core/local_audit_event.py +++ b/src/aethermesh_core/local_audit_event.py @@ -11,12 +11,45 @@ import json import os +import re from collections.abc import Mapping from datetime import datetime from pathlib import Path, PureWindowsPath from typing import Any AUDIT_EVENT_SCHEMA_VERSION = 2 +AUDIT_REDACTED_VALUE = "[REDACTED]" +_SENSITIVE_KEY_PARTS = frozenset( + { + "token", + "secret", + "password", + "privatekey", + "apikey", + "seed", + "credential", + "authorization", + } +) +_PRIVATE_CONTENT_KEYS = frozenset( + { + "env", + "environment", + "environmentmap", + "environmentvariables", + "envmap", + "envvars", + "payload", + "rawpayload", + "rawrequest", + "rawrequestpayload", + "requestbody", + "requestpayload", + } +) +_LOCAL_PATH_IN_TEXT = re.compile( + r"(? dict[str, Any]: + """Return an audit-safe copy without secrets or host-specific absolute paths.""" + + return {key: _sanitize_audit_value(value, key=key) for key, value in event.items()} + + def validate_local_audit_event(event: Mapping[str, Any]) -> dict[str, Any]: """Validate and return one canonical local audit event without writing it.""" @@ -193,7 +232,7 @@ def append_local_audit_event( ) -> dict[str, Any]: """Validate and append one event; this API never updates prior JSONL records.""" - document = validate_local_audit_event(event) + document = validate_local_audit_event(sanitize_local_audit_event(event)) audit_path = Path(path) audit_path.parent.mkdir(parents=True, exist_ok=True) with audit_path.open("a", encoding="utf-8") as handle: @@ -203,6 +242,45 @@ def append_local_audit_event( return document +def _sanitize_audit_value(value: Any, *, key: object | None = None) -> Any: + if isinstance(key, str) and _is_private_audit_key(key): + return AUDIT_REDACTED_VALUE + if isinstance(value, Mapping): + return { + child_key: _sanitize_audit_value(child_value, key=child_key) + for child_key, child_value in value.items() + } + if isinstance(value, list): + return [_sanitize_audit_value(item) for item in value] + if isinstance(value, str): + return _sanitize_local_path(value) + return value + + +def _is_private_audit_key(key: str) -> bool: + normalized = re.sub(r"[^a-z0-9]", "", key.lower()) + if normalized in _SENSITIVE_KEY_PARTS or normalized in _PRIVATE_CONTENT_KEYS: + return True + return any(part in normalized for part in _SENSITIVE_KEY_PARTS) + + +def _sanitize_local_path(value: str) -> str: + path_variants = (Path(value), PureWindowsPath(value)) + if not value.startswith("~") and not any( + path.is_absolute() for path in path_variants + ): + return _LOCAL_PATH_IN_TEXT.sub( + lambda match: _local_path_label(match.group()), value + ) + return _local_path_label(value) + + +def _local_path_label(value: str) -> str: + path_variants = (Path(value), PureWindowsPath(value)) + name = next((path.name for path in reversed(path_variants) if path.name), "") + return f"local-path/{name}" if name else "local-path" + + def _require_text(value: object, label: str) -> None: if not isinstance(value, str) or not value.strip(): raise LocalAuditEventError(f"{label} must be a non-empty string") diff --git a/tests/test_local_audit_event.py b/tests/test_local_audit_event.py index 0cff853..accc3b4 100644 --- a/tests/test_local_audit_event.py +++ b/tests/test_local_audit_event.py @@ -5,13 +5,91 @@ from aethermesh_core.local_audit_event import ( AUDIT_EVENT_TYPES, + AUDIT_REDACTED_VALUE, LocalAuditEventError, append_local_audit_event, + sanitize_local_audit_event, validate_local_audit_event, ) class LocalAuditEventTests(unittest.TestCase): + def test_sanitizes_secrets_private_content_and_absolute_paths(self) -> None: + event = { + **_referenced_event(), + "related_file_paths": ["/Users/private/node/manifests/work-001.json"], + "hashes": { + "result_hash": "sha256:example", + "apiKey": "private-api-key", + "service_api_key": "private-service-api-key", + "access_token": "private-token", + "environment": "HOME=/Users/private", + "environment_variables": "HOME=/Users/private", + }, + "signatures": { + "privateKey": "private-key", + "signingPrivateKey": "private-signing-key", + "authorization": "Bearer private-token", + "credential": "private-credential", + "password": "private-password", + "secret": "private-secret", + "seed": "private-seed", + }, + } + + sanitized = sanitize_local_audit_event(event) + + self.assertEqual(sanitized["related_file_paths"], ["local-path/work-001.json"]) + self.assertEqual(sanitized["hashes"]["result_hash"], "sha256:example") + for field in ( + "apiKey", + "service_api_key", + "access_token", + "environment", + "environment_variables", + ): + self.assertEqual(sanitized["hashes"][field], AUDIT_REDACTED_VALUE) + for value in sanitized["signatures"].values(): + self.assertEqual(value, AUDIT_REDACTED_VALUE) + + def test_appended_sanitized_event_keeps_audit_attribution(self) -> None: + event = { + **_referenced_event(), + "related_file_paths": ["/home/private/aethermesh/work-001.json"], + "signatures": {"token": "must-not-be-written"}, + } + with tempfile.TemporaryDirectory() as temp_dir: + path = Path(temp_dir) / "events.jsonl" + written = append_local_audit_event(path, event) + output = path.read_text(encoding="utf-8") + + self.assertNotIn("must-not-be-written", output) + self.assertNotIn("/home/private", output) + self.assertEqual(written["creator_node_id"], "creator-local-a") + self.assertEqual(written["manifest_id"], "manifest-work-001") + self.assertEqual(written["validation_receipt_id"], "receipt-work-001") + self.assertEqual(written["lineage_parent_ids"], ["work-parent-001"]) + self.assertEqual( + written["contribution_attribution_ids"], + ["local-contribution-work-001"], + ) + + def test_sanitizes_embedded_local_path_in_short_summary(self) -> None: + event = { + **_referenced_event(), + "error_summary": ( + "could not read path:/Users/private/node/key.pem or " + "file:///home/private/node/backup.pem" + ), + } + + sanitized = sanitize_local_audit_event(event) + + self.assertEqual( + sanitized["error_summary"], + "could not read path:local-path/key.pem or local-path/backup.pem", + ) + def test_validates_required_fields_and_allows_missing_optional_fields(self) -> None: event = _minimal_event()