-
Notifications
You must be signed in to change notification settings - Fork 0
fix: harden audit logger and add search input validation #29
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -2,13 +2,16 @@ | |||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| from __future__ import annotations | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| import logging | ||||||||||||||||||||||||||||||||
| from datetime import UTC, datetime | ||||||||||||||||||||||||||||||||
| from pathlib import Path | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| from pydantic import BaseModel, Field, model_serializer | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| _SENSITIVE_KEYS = {"token", "password", "secret", "api_key", "authorization"} | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| logger = logging.getLogger(__name__) | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| def _redact_sensitive(data: dict[str, object]) -> dict[str, object]: | ||||||||||||||||||||||||||||||||
| """Remove sensitive keys from a dictionary.""" | ||||||||||||||||||||||||||||||||
|
|
@@ -51,16 +54,31 @@ def serialize(self) -> dict[str, object]: | |||||||||||||||||||||||||||||||
| class AuditLogger: | ||||||||||||||||||||||||||||||||
| def __init__(self, audit_file: Path) -> None: | ||||||||||||||||||||||||||||||||
| self._audit_file = Path(audit_file) | ||||||||||||||||||||||||||||||||
| self._dir_created = False | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| def _ensure_directory(self) -> None: | ||||||||||||||||||||||||||||||||
| """Create parent directories on first write, with symlink check.""" | ||||||||||||||||||||||||||||||||
| if self._dir_created: | ||||||||||||||||||||||||||||||||
| return | ||||||||||||||||||||||||||||||||
| parent = self._audit_file.parent | ||||||||||||||||||||||||||||||||
| parent.mkdir(parents=True, exist_ok=True) | ||||||||||||||||||||||||||||||||
| # Reject symlinked audit file — could redirect entries to attacker-controlled path | ||||||||||||||||||||||||||||||||
| if self._audit_file.exists() and self._audit_file.is_symlink(): | ||||||||||||||||||||||||||||||||
| raise OSError(f"Audit log path is a symlink — refusing to write: {self._audit_file}") | ||||||||||||||||||||||||||||||||
| self._dir_created = True | ||||||||||||||||||||||||||||||||
|
Comment on lines
+61
to
+68
|
||||||||||||||||||||||||||||||||
| if self._dir_created: | |
| return | |
| parent = self._audit_file.parent | |
| parent.mkdir(parents=True, exist_ok=True) | |
| # Reject symlinked audit file — could redirect entries to attacker-controlled path | |
| if self._audit_file.exists() and self._audit_file.is_symlink(): | |
| raise OSError(f"Audit log path is a symlink — refusing to write: {self._audit_file}") | |
| self._dir_created = True | |
| parent = self._audit_file.parent | |
| if not self._dir_created: | |
| parent.mkdir(parents=True, exist_ok=True) | |
| self._dir_created = True | |
| # Reject symlinked audit file — could redirect entries to attacker-controlled path | |
| if self._audit_file.exists() and self._audit_file.is_symlink(): | |
| raise OSError(f"Audit log path is a symlink — refusing to write: {self._audit_file}") |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -1,6 +1,9 @@ | ||||||||||||||||||||||||||||||||||||
| """Tests for structured audit logging.""" | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| import json | ||||||||||||||||||||||||||||||||||||
| import os | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| import pytest | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| from comfyui_mcp.audit import AuditLogger, AuditRecord | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
|
|
@@ -81,3 +84,22 @@ def test_log_strips_sensitive_keys_from_extra(self, tmp_path): | |||||||||||||||||||||||||||||||||||
| content = log_file.read_text() | ||||||||||||||||||||||||||||||||||||
| assert "secret-value" not in content | ||||||||||||||||||||||||||||||||||||
| assert "a cat" in content | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| def test_log_raises_on_write_failure(self, tmp_path): | ||||||||||||||||||||||||||||||||||||
| log_file = tmp_path / "readonly" / "audit.log" | ||||||||||||||||||||||||||||||||||||
| (tmp_path / "readonly").mkdir() | ||||||||||||||||||||||||||||||||||||
| (tmp_path / "readonly").chmod(0o444) | ||||||||||||||||||||||||||||||||||||
| logger = AuditLogger(audit_file=log_file) | ||||||||||||||||||||||||||||||||||||
| with pytest.raises(OSError): | ||||||||||||||||||||||||||||||||||||
| logger.log(tool="test", action="called") | ||||||||||||||||||||||||||||||||||||
| # Restore permissions for cleanup | ||||||||||||||||||||||||||||||||||||
| (tmp_path / "readonly").chmod(0o755) | ||||||||||||||||||||||||||||||||||||
|
Comment on lines
+90
to
+96
|
||||||||||||||||||||||||||||||||||||
| (tmp_path / "readonly").mkdir() | |
| (tmp_path / "readonly").chmod(0o444) | |
| logger = AuditLogger(audit_file=log_file) | |
| with pytest.raises(OSError): | |
| logger.log(tool="test", action="called") | |
| # Restore permissions for cleanup | |
| (tmp_path / "readonly").chmod(0o755) | |
| readonly_dir = tmp_path / "readonly" | |
| readonly_dir.mkdir() | |
| try: | |
| readonly_dir.chmod(0o444) | |
| logger = AuditLogger(audit_file=log_file) | |
| with pytest.raises(OSError): | |
| logger.log(tool="test", action="called") | |
| finally: | |
| # Restore permissions for cleanup | |
| readonly_dir.chmod(0o755) |
Copilot
AI
Mar 12, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The new symlink protection is only tested for a symlink pointing to an existing target. Consider adding coverage for a dangling symlink (target missing) and for replacing the audit log with a symlink after an initial successful write, to prevent regressions in the symlink-hardening logic.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The symlink check is currently gated by
self._audit_file.exists(). A dangling symlink returnsexists() == Falsebutis_symlink() == True, so this would not be rejected andopen(..., 'a')would follow the symlink target. Consider checkingis_symlink()unconditionally (noexists()guard), ideally immediately before each open to reduce TOCTOU risk.