diff --git a/.gitignore b/.gitignore index 994aa639..d7d12323 100644 --- a/.gitignore +++ b/.gitignore @@ -229,6 +229,12 @@ app/AVAX/ .DS_Store Thumbs.db desktop.ini +app/security_audit.jsonl +app/credential_audit.jsonl + +# Contributor working notes (not project docs) +CLAUDE.md +LEARNINGS.md # Local-only skills (not committed to repo) .github/skills/ diff --git a/app/pt_security_logger.py b/app/pt_security_logger.py index 6d0c9e0b..b418b73f 100644 --- a/app/pt_security_logger.py +++ b/app/pt_security_logger.py @@ -47,7 +47,6 @@ # Security event types # --------------------------------------------------------------------------- class SecurityEventType(Enum): - AUTH_ATTEMPT = "auth_attempt" # API key authentication attempt AUTH_SUCCESS = "auth_success" # Successful authentication AUTH_FAILURE = "auth_failure" # Failed authentication CREDENTIAL_USE = "credential_use" # Credential accessed/used @@ -248,16 +247,20 @@ def _create_handler(self) -> _SecureRotatingFileHandler: def close(self) -> None: """Flush and close the underlying handler (call on shutdown). - Idempotent: safe to call multiple times.""" - if self._handler is None: - return - handler = self._handler - self._handler = None - try: - handler.flush() - handler.close() - finally: - self._audit_logger.removeHandler(handler) + Idempotent: safe to call multiple times. + + Acquires the same lock as ``_emit`` so a concurrent emit cannot + write to a handler that is mid-teardown.""" + with self._lock: + if self._handler is None: + return + handler = self._handler + self._handler = None + try: + handler.flush() + handler.close() + finally: + self._audit_logger.removeHandler(handler) def _emit(self, event: SecurityEvent) -> None: """Write security event to audit log (thread-safe).""" @@ -477,7 +480,11 @@ def log_system_event( message: str, details: Optional[Dict[str, Any]] = None, ) -> None: - """Log a system lifecycle event (start, stop, config change, rate limit).""" + """Log a system event under the given ``event_type``. + + Accepts any ``SecurityEventType`` (start/stop, config change, rate + limit, permission denied, etc.); the listed examples are illustrative, + not exhaustive.""" self._emit(self._make_event(event_type, message, details=details)) def get_recent_events(self, limit: int = 100) -> List[dict]: @@ -499,13 +506,22 @@ def get_recent_events(self, limit: int = 100) -> List[dict]: with open(self._audit_path, "r", encoding="utf-8") as f: tail = collections.deque(f, maxlen=limit) events = [] + corrupt = 0 for line in tail: line = line.strip() if line: try: events.append(json.loads(line)) except json.JSONDecodeError: - pass + # Surface, don't hide: a malformed line in an audit + # trail may indicate truncation or tampering. + corrupt += 1 + if corrupt: + logger.warning( + "Skipped %d unparseable line(s) in audit log %s", + corrupt, + self._audit_path, + ) return events except OSError: return [] diff --git a/app/test_security_logger.py b/app/test_security_logger.py index c180edd6..fd892092 100644 --- a/app/test_security_logger.py +++ b/app/test_security_logger.py @@ -106,7 +106,7 @@ def test_new_id_uses_utc(self): def test_to_dict_has_required_fields(self): event = SecurityEvent( event_id="SEC_001", - event_type=SecurityEventType.AUTH_ATTEMPT.value, + event_type=SecurityEventType.AUTH_SUCCESS.value, timestamp=time.time(), message="test", correlation_id=None,