From e8baac79bdfa726e319814f3fc9f51aceccc537a Mon Sep 17 00:00:00 2001 From: mldangelo Date: Mon, 16 Mar 2026 02:51:45 -0700 Subject: [PATCH 1/7] fix(config): disable implicit local rule config loading --- modelaudit/cache/trusted_config_store.py | 170 +++++++++++++++++++++++ modelaudit/config/local_config.py | 71 ++++++++++ modelaudit/config/rule_config.py | 42 ++++-- tests/test_rules.py | 85 ++++++++++++ 4 files changed, 354 insertions(+), 14 deletions(-) create mode 100644 modelaudit/cache/trusted_config_store.py create mode 100644 modelaudit/config/local_config.py diff --git a/modelaudit/cache/trusted_config_store.py b/modelaudit/cache/trusted_config_store.py new file mode 100644 index 000000000..e4900e21f --- /dev/null +++ b/modelaudit/cache/trusted_config_store.py @@ -0,0 +1,170 @@ +"""Secure persistence for trusted local ModelAudit configuration files.""" + +from __future__ import annotations + +import hashlib +import json +import os +from contextlib import suppress +from dataclasses import dataclass +from pathlib import Path +from uuid import uuid4 + +from ..config.local_config import LocalConfigCandidate + +TRUST_STORE_VERSION = 1 + + +@dataclass(frozen=True) +class TrustedConfigRecord: + """Persisted trust metadata for a local config directory.""" + + config_path: str + config_sha256: str + + +class TrustedConfigStore: + """Read and write trusted local config state under the cache directory.""" + + def __init__(self, store_path: Path | None = None): + self.store_path = store_path or (Path.home() / ".modelaudit" / "cache" / "trusted_local_configs.json") + + def is_trusted(self, candidate: LocalConfigCandidate) -> bool: + """Return True when a candidate matches a previously trusted config hash.""" + records = self._load_records() + key = str(candidate.config_dir) + record = records.get(key) + if record is None: + return False + + if record.config_path != str(candidate.config_path): + return False + + current_hash = self._hash_config(candidate.config_path) + return current_hash is not None and current_hash == record.config_sha256 + + def trust(self, candidate: LocalConfigCandidate) -> None: + """Persist trust for a resolved local config candidate.""" + config_hash = self._hash_config(candidate.config_path) + if config_hash is None: + return + + records = self._load_records() + records[str(candidate.config_dir)] = TrustedConfigRecord( + config_path=str(candidate.config_path), + config_sha256=config_hash, + ) + self._write_records(records) + + def _load_records(self) -> dict[str, TrustedConfigRecord]: + """Load trusted config records from disk.""" + if not self._is_secure_target(self.store_path): + return {} + + try: + if not self.store_path.exists(): + return {} + if self.store_path.is_symlink() or not self.store_path.is_file(): + return {} + + with self.store_path.open(encoding="utf-8") as handle: + payload = json.load(handle) + except Exception: + return {} + + if not isinstance(payload, dict) or payload.get("version") != TRUST_STORE_VERSION: + return {} + + repos = payload.get("repos", {}) + if not isinstance(repos, dict): + return {} + + records: dict[str, TrustedConfigRecord] = {} + for key, value in repos.items(): + if not isinstance(key, str) or not isinstance(value, dict): + continue + config_path = value.get("config_path") + config_sha256 = value.get("config_sha256") + if isinstance(config_path, str) and isinstance(config_sha256, str): + records[key] = TrustedConfigRecord(config_path=config_path, config_sha256=config_sha256) + return records + + def _write_records(self, records: dict[str, TrustedConfigRecord]) -> None: + """Write the current trust records atomically with private permissions.""" + parent = self.store_path.parent + if not _ensure_secure_directory(parent): + return + + payload = { + "version": TRUST_STORE_VERSION, + "repos": { + key: {"config_path": record.config_path, "config_sha256": record.config_sha256} + for key, record in records.items() + }, + } + temp_path = parent / f".trusted_local_configs.{uuid4().hex}.tmp" + flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL + if hasattr(os, "O_NOFOLLOW"): + flags |= os.O_NOFOLLOW + + try: + fd = os.open(temp_path, flags, 0o600) + with os.fdopen(fd, "w", encoding="utf-8") as handle: + json.dump(payload, handle, indent=2, sort_keys=True) + _tighten_permissions(temp_path, 0o600) + os.replace(temp_path, self.store_path) + _tighten_permissions(self.store_path, 0o600) + except Exception: + with suppress(OSError): + temp_path.unlink() + + def _hash_config(self, config_path: Path) -> str | None: + """Return a stable hash for the config file contents.""" + try: + return hashlib.sha256(config_path.read_bytes()).hexdigest() + except Exception: + return None + + def _is_secure_target(self, path: Path) -> bool: + """Return True when the parent path is suitable for reads and writes.""" + return not _has_symlink_component(path) + + +def _tighten_permissions(path: Path, mode: int) -> None: + """Best-effort permission hardening for cache trust paths.""" + if os.name == "nt": + return + + with suppress(OSError): + path.chmod(mode) + + +def _has_symlink_component(path: Path) -> bool: + """Return True when path or an ancestor is a symlink.""" + current = path + while True: + try: + if current.exists() and current.is_symlink(): + return True + except OSError: + return True + if current == current.parent: + return False + current = current.parent + + +def _ensure_secure_directory(path: Path) -> bool: + """Create a directory when possible and reject symlinked targets.""" + if _has_symlink_component(path): + return False + + try: + path.mkdir(parents=True, mode=0o700, exist_ok=True) + except OSError: + return False + + if not path.is_dir() or _has_symlink_component(path): + return False + + _tighten_permissions(path, 0o700) + return True diff --git a/modelaudit/config/local_config.py b/modelaudit/config/local_config.py new file mode 100644 index 000000000..e62a0bce6 --- /dev/null +++ b/modelaudit/config/local_config.py @@ -0,0 +1,71 @@ +"""Helpers for resolving local ModelAudit configuration files.""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path + +try: + import tomllib +except ImportError: # Python < 3.11 + import tomli as tomllib # type: ignore + + +@dataclass(frozen=True) +class LocalConfigCandidate: + """Resolved local config file discovered from scan targets.""" + + config_dir: Path + config_path: Path + source: str + + +def find_local_config_for_paths(paths: list[str]) -> LocalConfigCandidate | None: + """Return a shared local config candidate when all local paths resolve to one.""" + if not paths: + return None + + resolved_candidates: list[LocalConfigCandidate] = [] + for path_str in paths: + path = Path(path_str) + if not path.exists(): + return None + + resolved_path = path.resolve() + start_dir = resolved_path if resolved_path.is_dir() else resolved_path.parent + candidate = _find_local_config(start_dir) + if candidate is None: + return None + resolved_candidates.append(candidate) + + first_candidate = resolved_candidates[0] + if all(candidate == first_candidate for candidate in resolved_candidates[1:]): + return first_candidate + return None + + +def _find_local_config(start_dir: Path) -> LocalConfigCandidate | None: + """Walk parent directories and return the nearest supported config file.""" + current = start_dir + while True: + modelaudit_toml = current / ".modelaudit.toml" + if modelaudit_toml.exists() and modelaudit_toml.is_file(): + return LocalConfigCandidate(config_dir=current, config_path=modelaudit_toml, source="modelaudit_toml") + + pyproject_toml = current / "pyproject.toml" + if pyproject_toml.exists() and pyproject_toml.is_file() and _has_modelaudit_section(pyproject_toml): + return LocalConfigCandidate(config_dir=current, config_path=pyproject_toml, source="pyproject_toml") + + if current == current.parent: + return None + current = current.parent + + +def _has_modelaudit_section(pyproject_path: Path) -> bool: + """Return True when pyproject.toml contains a [tool.modelaudit] section.""" + try: + with pyproject_path.open("rb") as handle: + data = tomllib.load(handle) + return bool(data.get("tool", {}).get("modelaudit")) + except Exception: + return False diff --git a/modelaudit/config/rule_config.py b/modelaudit/config/rule_config.py index f5c44644d..159cd0e49 100644 --- a/modelaudit/config/rule_config.py +++ b/modelaudit/config/rule_config.py @@ -58,15 +58,24 @@ class ModelAuditConfig: ignore: dict[str, list[str]] = field(default_factory=dict) options: dict[str, Any] = field(default_factory=dict) + def copy(self) -> ModelAuditConfig: + """Return a shallow copy of this configuration.""" + return ModelAuditConfig( + suppress=set(self.suppress), + severity=dict(self.severity), + ignore={pattern: list(rules) for pattern, rules in self.ignore.items()}, + options=dict(self.options), + ) + @classmethod - def load(cls, path: Path | None = None) -> ModelAuditConfig: + def load(cls, path: Path | None = None, *, discover_local: bool = False) -> ModelAuditConfig: """ Load configuration from file or use defaults. Search order: 1. Specified path (if provided) - 2. .modelaudit.toml in current directory - 3. pyproject.toml [tool.modelaudit] section + 2. .modelaudit.toml in current directory (when discover_local=True) + 3. pyproject.toml [tool.modelaudit] section (when discover_local=True) 4. Default empty config """ config = cls() @@ -75,15 +84,16 @@ def load(cls, path: Path | None = None) -> ModelAuditConfig: config._load_from_file(path) return config - modelaudit_toml = Path(".modelaudit.toml") - if modelaudit_toml.exists(): - config._load_from_file(modelaudit_toml) - return config + if discover_local: + modelaudit_toml = Path(".modelaudit.toml") + if modelaudit_toml.exists(): + config._load_from_file(modelaudit_toml) + return config - pyproject_toml = Path("pyproject.toml") - if pyproject_toml.exists(): - config._load_from_pyproject(pyproject_toml) - return config + pyproject_toml = Path("pyproject.toml") + if pyproject_toml.exists(): + config._load_from_pyproject(pyproject_toml) + return config return config @@ -195,12 +205,16 @@ def get_severity(self, rule_code: str, default: Severity) -> Severity: @classmethod def from_cli_args( - cls, suppress: list[str] | None = None, severity: dict[str, str] | None = None + cls, + suppress: list[str] | None = None, + severity: dict[str, str] | None = None, + *, + base_config: ModelAuditConfig | None = None, ) -> ModelAuditConfig: """ Create config from CLI arguments merged with file config. """ - config = cls.load() + config = base_config.copy() if base_config is not None else cls.load(discover_local=False) valid_rule_codes = set(RuleRegistry.get_all_rules().keys()) if suppress: @@ -234,7 +248,7 @@ def get_config() -> ModelAuditConfig: """Get the global configuration instance.""" global _global_config if _global_config is None: - _global_config = ModelAuditConfig.load() + _global_config = ModelAuditConfig.load(discover_local=False) return _global_config diff --git a/tests/test_rules.py b/tests/test_rules.py index bd9bb0185..721aebecb 100644 --- a/tests/test_rules.py +++ b/tests/test_rules.py @@ -5,7 +5,9 @@ import pytest +from modelaudit.cache.trusted_config_store import TrustedConfigStore from modelaudit.config import ModelAuditConfig, reset_config, set_config +from modelaudit.config.local_config import find_local_config_for_paths from modelaudit.rules import RuleRegistry, Severity from modelaudit.scanners.base import Issue, IssueSeverity, ScanResult @@ -166,6 +168,16 @@ def test_from_cli_args_rejects_invalid_severity(self): with pytest.raises(ValueError, match="Invalid severity"): ModelAuditConfig.from_cli_args(severity={"S301": "SEVERE"}) + def test_from_cli_args_uses_provided_base_config(self): + """CLI overrides should merge onto an explicitly supplied base config.""" + base_config = ModelAuditConfig() + base_config.suppress = {"S710"} + + config = ModelAuditConfig.from_cli_args(suppress=["S801"], base_config=base_config) + + assert config.suppress == {"S710", "S801"} + assert base_config.suppress == {"S710"} + def test_ignore_range_expansion(self): """Test that ignore ranges expand correctly.""" config = ModelAuditConfig() @@ -192,6 +204,79 @@ def test_parse_config_filters_unknown_codes(self): assert set(config.ignore["tests/**"]) == {"S201", "S202", "ALL"} +def test_load_does_not_auto_discover_local_config_by_default(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Local config files should not be auto-loaded unless explicitly enabled.""" + (tmp_path / ".modelaudit.toml").write_text('suppress = ["S710"]\n') + monkeypatch.chdir(tmp_path) + + config = ModelAuditConfig.load() + + assert "S710" not in config.suppress + + +def test_load_can_discover_local_config_when_opted_in(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Local config discovery remains available for explicit trusted flows.""" + (tmp_path / ".modelaudit.toml").write_text('suppress = ["S710"]\n') + monkeypatch.chdir(tmp_path) + + config = ModelAuditConfig.load(discover_local=True) + + assert "S710" in config.suppress + + +def test_find_local_config_for_paths_uses_shared_ancestor(tmp_path: Path) -> None: + """All scanned paths under the same config root should resolve one candidate.""" + root = tmp_path / "repo" + nested = root / "models" / "nested" + nested.mkdir(parents=True) + (root / ".modelaudit.toml").write_text('suppress = ["S710"]\n') + file_path = nested / "model.pkl" + file_path.write_bytes(b"test") + + candidate = find_local_config_for_paths([str(file_path)]) + + assert candidate is not None + assert candidate.config_dir == root + assert candidate.config_path == root / ".modelaudit.toml" + + +def test_find_local_config_for_paths_returns_none_for_mixed_roots(tmp_path: Path) -> None: + """Mixed local config roots should not auto-resolve to any one candidate.""" + repo_one = tmp_path / "repo-one" + repo_two = tmp_path / "repo-two" + (repo_one / "models").mkdir(parents=True) + (repo_two / "models").mkdir(parents=True) + (repo_one / ".modelaudit.toml").write_text('suppress = ["S710"]\n') + (repo_two / ".modelaudit.toml").write_text('suppress = ["S801"]\n') + first = repo_one / "models" / "model-one.pkl" + second = repo_two / "models" / "model-two.pkl" + first.write_bytes(b"one") + second.write_bytes(b"two") + + candidate = find_local_config_for_paths([str(first), str(second)]) + + assert candidate is None + + +def test_trusted_config_store_invalidates_when_config_changes(tmp_path: Path) -> None: + """Changing a trusted config should require trust to be re-established.""" + repo_root = tmp_path / "repo" + repo_root.mkdir() + config_path = repo_root / ".modelaudit.toml" + config_path.write_text('suppress = ["S710"]\n') + store = TrustedConfigStore(tmp_path / "cache" / "trusted_local_configs.json") + + candidate = find_local_config_for_paths([str(config_path)]) + assert candidate is not None + + store.trust(candidate) + assert store.is_trusted(candidate) + + config_path.write_text('suppress = ["S801"]\n') + + assert not store.is_trusted(candidate) + + class TestScanResultIntegration: """Test integration with ScanResult class.""" From 10d69cd9b07050aa31ef7edbece1ce5bfd995e7b Mon Sep 17 00:00:00 2001 From: mldangelo Date: Mon, 16 Mar 2026 02:54:52 -0700 Subject: [PATCH 2/7] feat(cli): prompt before trusting local scan config --- modelaudit/cli.py | 118 +++++++++++++++++++++++++++++++++++++++++----- tests/test_cli.py | 105 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 211 insertions(+), 12 deletions(-) diff --git a/modelaudit/cli.py b/modelaudit/cli.py index 609fcfa6b..cb57fde60 100644 --- a/modelaudit/cli.py +++ b/modelaudit/cli.py @@ -23,7 +23,9 @@ is_delegated_from_promptfoo, set_user_email, ) +from .cache.trusted_config_store import TrustedConfigStore from .config import ModelAuditConfig, set_config +from .config.local_config import find_local_config_for_paths from .core import determine_exit_code, scan_model_directory_or_file from .integrations.jfrog import scan_jfrog_artifact from .integrations.sarif_formatter import format_sarif_output @@ -41,7 +43,12 @@ record_scan_started, ) from .utils import resolve_dvc_file -from .utils.helpers.auto_defaults import apply_auto_overrides, generate_auto_defaults, parse_size_string +from .utils.helpers.auto_defaults import ( + apply_auto_overrides, + detect_ci_environment, + generate_auto_defaults, + parse_size_string, +) from .utils.helpers.interrupt_handler import interruptible_scan from .utils.sources.cloud_storage import download_from_cloud, is_cloud_url from .utils.sources.huggingface import ( @@ -78,6 +85,83 @@ def style_text(text: str, **kwargs: Any) -> str: return text +def get_trusted_config_store() -> TrustedConfigStore: + """Return the persistent store used for trusted local configs.""" + return TrustedConfigStore() + + +def can_use_trusted_local_config(output_format: str) -> bool: + """Return True when the current scan mode supports trusted local configs.""" + return ( + output_format == "text" + and sys.stdin.isatty() + and sys.stdout.isatty() + and not detect_ci_environment() + and not is_delegated_from_promptfoo() + ) + + +def maybe_load_trusted_local_config( + paths: list[str], + output_format: str, + *, + quiet: bool, +) -> tuple[ModelAuditConfig | None, bool, Path | None]: + """Load a trusted local config for interactive text scans when available.""" + if not can_use_trusted_local_config(output_format): + return None, False, None + + candidate = find_local_config_for_paths(paths) + if candidate is None: + return None, False, None + + store = get_trusted_config_store() + if store.is_trusted(candidate): + return ModelAuditConfig.load(candidate.config_path), True, candidate.config_path + + if quiet: + return None, False, None + + click.echo(style_text(f"Found local ModelAudit config at {candidate.config_path}", fg="cyan")) + click.echo("It can suppress findings or change severities.") + choice = click.prompt( + "Use it? [y] once, [a] always, [n] no", + type=click.Choice(["y", "a", "n"], case_sensitive=False), + default="n", + show_choices=False, + ).lower() + + if choice == "n": + return None, False, None + + if choice == "a": + store.trust(candidate) + + return ModelAuditConfig.load(candidate.config_path), True, candidate.config_path + + +def build_scan_rule_config( + paths: list[str], + suppress: tuple[str, ...], + severity_overrides: dict[str, str], + *, + output_format: str, + quiet: bool, +) -> tuple[ModelAuditConfig, bool, Path | None]: + """Build the effective scan rule config, including trusted local policy when enabled.""" + base_config, local_config_applied, local_config_path = maybe_load_trusted_local_config( + paths, + output_format, + quiet=quiet, + ) + cli_config = ModelAuditConfig.from_cli_args( + suppress=list(suppress) if suppress else None, + severity=severity_overrides if severity_overrides else None, + base_config=base_config, + ) + return cli_config, local_config_applied, local_config_path + + def expand_paths(paths: tuple[str, ...]) -> tuple[list[str], list[str]]: """Expand and validate input paths with type safety.""" expanded: list[str] = [] @@ -770,17 +854,6 @@ def scan_command( flush_telemetry() sys.exit(2) - # Apply rule configuration from CLI and config files - severity_overrides = parse_severity_overrides(severity) - try: - cli_config = ModelAuditConfig.from_cli_args( - suppress=list(suppress) if suppress else None, - severity=severity_overrides if severity_overrides else None, - ) - except ValueError as exc: - raise click.BadParameter(str(exc)) from exc - set_config(cli_config) - # Generate defaults based on input analysis auto_defaults = generate_auto_defaults(expanded_paths) @@ -846,6 +919,27 @@ def scan_command( final_skip_files = config.get("skip_non_model_files", True) final_strict_license = config.get("strict_license", False) + # Apply rule configuration from CLI and any trusted local config for this scan mode. + severity_overrides = parse_severity_overrides(severity) + try: + cli_config, local_config_applied, local_config_path = build_scan_rule_config( + expanded_paths, + suppress, + severity_overrides, + output_format=final_format, + quiet=quiet, + ) + except ValueError as exc: + raise click.BadParameter(str(exc)) from exc + set_config(cli_config) + + if local_config_applied: + if final_cache: + final_cache = False + if not quiet and show_styled_output and local_config_path is not None: + click.echo(style_text(f"Using local ModelAudit config: {local_config_path}", fg="cyan")) + click.echo(style_text("Scan result cache disabled for this run.", fg="yellow")) + # Handle max download size from automatic defaults or max_size override max_download_bytes = None if max_size is not None: diff --git a/tests/test_cli.py b/tests/test_cli.py index 9cbf1ce44..e6a25b761 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -8,6 +8,7 @@ from click.testing import CliRunner from modelaudit import __version__ +from modelaudit.cache.trusted_config_store import TrustedConfigStore from modelaudit.cli import cli, expand_paths, format_text_output from modelaudit.core import scan_model_directory_or_file from modelaudit.models import create_initial_audit_result @@ -155,6 +156,110 @@ def test_scan_unknown_rule_code_in_suppress_option(tmp_path): assert "S9999" in result.output +def test_scan_does_not_auto_load_untrusted_local_config(tmp_path: Path) -> None: + """Scanning should not auto-apply suppressions from local config files.""" + import tarfile + + model_file = tmp_path / "evil.tar" + with tarfile.open(model_file, "w") as tar: + payload = tmp_path / "payload.txt" + payload.write_text("content") + tar.add(payload, arcname="../evil.txt") + + (tmp_path / ".modelaudit.toml").write_text('suppress = ["S405"]\n') + + runner = CliRunner() + result = runner.invoke(cli, ["scan", str(model_file), "--format", "json"], catch_exceptions=False) + + assert result.exit_code == 1 + payload = json.loads(result.output) + assert any(issue.get("rule_code") == "S405" for issue in payload.get("issues", [])) + + +def test_scan_can_apply_local_config_once_when_confirmed(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None: + """Interactive scans can apply a local config for the current run only.""" + import tarfile + + model_file = tmp_path / "evil.tar" + with tarfile.open(model_file, "w") as tar: + payload = tmp_path / "payload.txt" + payload.write_text("content") + tar.add(payload, arcname="../evil.txt") + + (tmp_path / ".modelaudit.toml").write_text('suppress = ["S405"]\n') + monkeypatch.chdir(tmp_path) + monkeypatch.setattr("modelaudit.cli.can_use_trusted_local_config", lambda output_format: output_format == "text") + monkeypatch.setattr( + "modelaudit.cli.get_trusted_config_store", + lambda: TrustedConfigStore(tmp_path / "cache" / "trusted_local_configs.json"), + ) + + runner = CliRunner() + result = runner.invoke(cli, ["scan", str(model_file)], input="y\n", catch_exceptions=False) + output = strip_ansi(result.output) + + assert result.exit_code == 0 + assert "Found local ModelAudit config" in output + assert "Using local ModelAudit config" in output + assert "NO ISSUES FOUND" in output + + +def test_scan_can_remember_trusted_local_config(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None: + """Choosing to trust a local config should persist for future interactive runs.""" + import tarfile + + model_file = tmp_path / "evil.tar" + with tarfile.open(model_file, "w") as tar: + payload = tmp_path / "payload.txt" + payload.write_text("content") + tar.add(payload, arcname="../evil.txt") + + (tmp_path / ".modelaudit.toml").write_text('suppress = ["S405"]\n') + trust_store = TrustedConfigStore(tmp_path / "cache" / "trusted_local_configs.json") + + monkeypatch.chdir(tmp_path) + monkeypatch.setattr("modelaudit.cli.can_use_trusted_local_config", lambda output_format: output_format == "text") + monkeypatch.setattr("modelaudit.cli.get_trusted_config_store", lambda: trust_store) + + runner = CliRunner() + first_result = runner.invoke(cli, ["scan", str(model_file)], input="a\n", catch_exceptions=False) + second_result = runner.invoke(cli, ["scan", str(model_file)], catch_exceptions=False) + + assert first_result.exit_code == 0 + assert second_result.exit_code == 0 + assert trust_store.store_path.exists() + assert "Found local ModelAudit config" in strip_ansi(first_result.output) + assert "Found local ModelAudit config" not in strip_ansi(second_result.output) + + +def test_scan_disables_cache_when_local_config_is_applied(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Applying a local config should bypass scan-result caching for safety.""" + target_file = tmp_path / "model.dat" + target_file.write_bytes(b"test content") + (tmp_path / ".modelaudit.toml").write_text('suppress = ["S710"]\n') + + captured: dict[str, object] = {} + + def fake_scan_model_directory_or_file(path: str, **kwargs): + captured["path"] = path + captured.update(kwargs) + return create_mock_scan_result() + + monkeypatch.chdir(tmp_path) + monkeypatch.setattr("modelaudit.cli.can_use_trusted_local_config", lambda output_format: output_format == "text") + monkeypatch.setattr( + "modelaudit.cli.get_trusted_config_store", + lambda: TrustedConfigStore(tmp_path / "cache" / "trusted_local_configs.json"), + ) + monkeypatch.setattr("modelaudit.cli.scan_model_directory_or_file", fake_scan_model_directory_or_file) + + runner = CliRunner() + result = runner.invoke(cli, ["scan", str(target_file)], input="y\n", catch_exceptions=False) + + assert result.exit_code == 0 + assert captured["cache_enabled"] is False + + def test_scan_nonexistent_file(): """Test scanning a nonexistent file.""" runner = CliRunner() From 0252e85c6d7858d0bba8668eab8d6178788cce93 Mon Sep 17 00:00:00 2001 From: mldangelo Date: Mon, 16 Mar 2026 02:57:36 -0700 Subject: [PATCH 3/7] fix(config): honor explicit pyproject rule config --- modelaudit/config/rule_config.py | 5 ++++- tests/test_rules.py | 19 +++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/modelaudit/config/rule_config.py b/modelaudit/config/rule_config.py index 159cd0e49..45ad83003 100644 --- a/modelaudit/config/rule_config.py +++ b/modelaudit/config/rule_config.py @@ -81,7 +81,10 @@ def load(cls, path: Path | None = None, *, discover_local: bool = False) -> Mode config = cls() if path and path.exists(): - config._load_from_file(path) + if path.name == "pyproject.toml": + config._load_from_pyproject(path) + else: + config._load_from_file(path) return config if discover_local: diff --git a/tests/test_rules.py b/tests/test_rules.py index 721aebecb..286f3c9b5 100644 --- a/tests/test_rules.py +++ b/tests/test_rules.py @@ -111,6 +111,25 @@ def test_load_from_toml(self): finally: config_path.unlink() + def test_load_from_pyproject(self, tmp_path: Path) -> None: + """Explicit pyproject paths should load the [tool.modelaudit] section.""" + config_path = tmp_path / "pyproject.toml" + config_path.write_text( + """ +[tool.modelaudit] +suppress = ["S710"] + +[tool.modelaudit.severity] +S301 = "HIGH" +""".strip() + + "\n" + ) + + config = ModelAuditConfig.load(config_path) + + assert config.suppress == {"S710"} + assert config.severity["S301"] == Severity.HIGH + def test_is_suppressed(self): """Test rule suppression checking.""" config = ModelAuditConfig() From 11ec3e16302aa108053f43832975b45e99a15145 Mon Sep 17 00:00:00 2001 From: mldangelo Date: Mon, 16 Mar 2026 02:58:09 -0700 Subject: [PATCH 4/7] docs: describe trusted local scan policy --- CHANGELOG.md | 1 + docs/user/security-model.md | 7 +++++++ 2 files changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 44ce0e287..787f16326 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -81,6 +81,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **security:** stop auto-applying local `.modelaudit.toml` and `pyproject.toml` rule config during scans unless a human explicitly trusts that config in an interactive text run; remembered trust is stored securely under the local ModelAudit cache and invalidated when the config changes - **security:** remove `dill.load` / `dill.loads` from the pickle safe-global allowlist so recursive dill deserializers stay flagged as dangerous loader entry points - **security:** add exact dangerous helper coverage for validated torch and NumPy refs such as `numpy.f2py.crackfortran.getlincoef`, `torch._dynamo.guards.GuardBuilder.get`, and `torch.utils.collect_env.run` - **security:** add exact dangerous-global coverage for `numpy.load`, `site.main`, `_io.FileIO`, `test.support.script_helper.assert_python_ok`, `_osx_support._read_output`, `_aix_support._read_cmd_output`, `_pyrepl.pager.pipe_pager`, `torch.serialization.load`, and `torch._inductor.codecache.compile_file` (9 PickleScan-only loader and execution primitives) diff --git a/docs/user/security-model.md b/docs/user/security-model.md index 9fb6ebe00..8df9cd6fa 100644 --- a/docs/user/security-model.md +++ b/docs/user/security-model.md @@ -27,6 +27,13 @@ ModelAudit is a static security scanner for model artifacts. It analyzes files a - `modelaudit metadata` defaults to non-deserializing extraction for untrusted inputs. - `--trust-loaders` may deserialize model content and should only be used on trusted artifacts in isolated environments. +## Local scan policy files + +- Local `.modelaudit.toml` or `pyproject.toml` policy files are not applied implicitly during scans. +- Interactive text scans may offer to trust a detected local policy file for future runs on that same config directory. +- Remembered trust is stored in the local ModelAudit cache and is invalidated automatically if the config file changes. +- CI and other non-interactive scans should use explicit configuration rather than relying on remembered local trust. + ## Interpreting scan results - `CRITICAL`: High-confidence risk indicator. Block release/use by default. From 173d99819cbf5dd368a89673a2b1f30e986e6451 Mon Sep 17 00:00:00 2001 From: mldangelo Date: Mon, 16 Mar 2026 04:07:48 -0700 Subject: [PATCH 5/7] test: tighten trusted local config regressions --- modelaudit/cache/trusted_config_store.py | 2 +- tests/test_cli.py | 16 +++++++----- tests/test_rules.py | 31 +++++++++++++++++++++--- 3 files changed, 38 insertions(+), 11 deletions(-) diff --git a/modelaudit/cache/trusted_config_store.py b/modelaudit/cache/trusted_config_store.py index e4900e21f..7a119c39a 100644 --- a/modelaudit/cache/trusted_config_store.py +++ b/modelaudit/cache/trusted_config_store.py @@ -144,7 +144,7 @@ def _has_symlink_component(path: Path) -> bool: current = path while True: try: - if current.exists() and current.is_symlink(): + if current.is_symlink(): return True except OSError: return True diff --git a/tests/test_cli.py b/tests/test_cli.py index e6a25b761..303d69dee 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -2,6 +2,7 @@ import os import re from pathlib import Path +from typing import Any from unittest.mock import patch import pytest @@ -11,15 +12,15 @@ from modelaudit.cache.trusted_config_store import TrustedConfigStore from modelaudit.cli import cli, expand_paths, format_text_output from modelaudit.core import scan_model_directory_or_file -from modelaudit.models import create_initial_audit_result +from modelaudit.models import ModelAuditResultModel, create_initial_audit_result -def strip_ansi(text): +def strip_ansi(text: str) -> str: """Strip ANSI color codes from text for testing.""" return re.sub(r"\x1b\[[0-9;]*m", "", text) -def create_mock_scan_result(**kwargs): +def create_mock_scan_result(**kwargs: Any) -> ModelAuditResultModel: """Create a mock ModelAuditResultModel for testing.""" result = create_initial_audit_result() @@ -176,7 +177,7 @@ def test_scan_does_not_auto_load_untrusted_local_config(tmp_path: Path) -> None: assert any(issue.get("rule_code") == "S405" for issue in payload.get("issues", [])) -def test_scan_can_apply_local_config_once_when_confirmed(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None: +def test_scan_can_apply_local_config_once_when_confirmed(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """Interactive scans can apply a local config for the current run only.""" import tarfile @@ -203,8 +204,11 @@ def test_scan_can_apply_local_config_once_when_confirmed(tmp_path, monkeypatch: assert "Using local ModelAudit config" in output assert "NO ISSUES FOUND" in output + trust_store = TrustedConfigStore(tmp_path / "cache" / "trusted_local_configs.json") + assert not trust_store.store_path.exists() + -def test_scan_can_remember_trusted_local_config(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None: +def test_scan_can_remember_trusted_local_config(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """Choosing to trust a local config should persist for future interactive runs.""" import tarfile @@ -240,7 +244,7 @@ def test_scan_disables_cache_when_local_config_is_applied(tmp_path: Path, monkey captured: dict[str, object] = {} - def fake_scan_model_directory_or_file(path: str, **kwargs): + def fake_scan_model_directory_or_file(path: str, **kwargs: Any) -> ModelAuditResultModel: captured["path"] = path captured.update(kwargs) return create_mock_scan_result() diff --git a/tests/test_rules.py b/tests/test_rules.py index 286f3c9b5..c35184dae 100644 --- a/tests/test_rules.py +++ b/tests/test_rules.py @@ -187,7 +187,7 @@ def test_from_cli_args_rejects_invalid_severity(self): with pytest.raises(ValueError, match="Invalid severity"): ModelAuditConfig.from_cli_args(severity={"S301": "SEVERE"}) - def test_from_cli_args_uses_provided_base_config(self): + def test_from_cli_args_uses_provided_base_config(self) -> None: """CLI overrides should merge onto an explicitly supplied base config.""" base_config = ModelAuditConfig() base_config.suppress = {"S710"} @@ -247,12 +247,16 @@ def test_find_local_config_for_paths_uses_shared_ancestor(tmp_path: Path) -> Non """All scanned paths under the same config root should resolve one candidate.""" root = tmp_path / "repo" nested = root / "models" / "nested" + sibling = root / "models" / "other" nested.mkdir(parents=True) + sibling.mkdir(parents=True) (root / ".modelaudit.toml").write_text('suppress = ["S710"]\n') - file_path = nested / "model.pkl" - file_path.write_bytes(b"test") + first_file = nested / "model.pkl" + second_file = sibling / "model.pkl" + first_file.write_bytes(b"test") + second_file.write_bytes(b"other") - candidate = find_local_config_for_paths([str(file_path)]) + candidate = find_local_config_for_paths([str(first_file), str(second_file)]) assert candidate is not None assert candidate.config_dir == root @@ -296,6 +300,25 @@ def test_trusted_config_store_invalidates_when_config_changes(tmp_path: Path) -> assert not store.is_trusted(candidate) +def test_trusted_config_store_rejects_broken_symlink_ancestor(tmp_path: Path, requires_symlinks: None) -> None: + """Trust records should not be written through broken symlink ancestors.""" + cache_root = tmp_path / "redirected-cache" + cache_root.symlink_to(tmp_path / "missing-cache", target_is_directory=True) + + repo_root = tmp_path / "repo" + repo_root.mkdir() + config_path = repo_root / ".modelaudit.toml" + config_path.write_text('suppress = ["S710"]\n') + candidate = find_local_config_for_paths([str(config_path)]) + + assert candidate is not None + + store = TrustedConfigStore(cache_root / "trusted_local_configs.json") + store.trust(candidate) + + assert not store.store_path.exists() + + class TestScanResultIntegration: """Test integration with ScanResult class.""" From ddaa6fcbea2a38231881484eb6b6dbedaf4f8393 Mon Sep 17 00:00:00 2001 From: mldangelo Date: Mon, 16 Mar 2026 09:58:29 -0700 Subject: [PATCH 6/7] test(cli): stabilize trusted config coverage in ci --- tests/test_cli.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/tests/test_cli.py b/tests/test_cli.py index 303d69dee..54692d3df 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -196,18 +196,15 @@ def test_scan_can_apply_local_config_once_when_confirmed(tmp_path: Path, monkeyp ) runner = CliRunner() - result = runner.invoke(cli, ["scan", str(model_file)], input="y\n", catch_exceptions=False) + result = runner.invoke(cli, ["scan", str(model_file), "--format", "text"], input="y\n", catch_exceptions=False) output = strip_ansi(result.output) assert result.exit_code == 0 assert "Found local ModelAudit config" in output assert "Using local ModelAudit config" in output assert "NO ISSUES FOUND" in output - trust_store = TrustedConfigStore(tmp_path / "cache" / "trusted_local_configs.json") assert not trust_store.store_path.exists() - - def test_scan_can_remember_trusted_local_config(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """Choosing to trust a local config should persist for future interactive runs.""" import tarfile @@ -226,8 +223,13 @@ def test_scan_can_remember_trusted_local_config(tmp_path: Path, monkeypatch: pyt monkeypatch.setattr("modelaudit.cli.get_trusted_config_store", lambda: trust_store) runner = CliRunner() - first_result = runner.invoke(cli, ["scan", str(model_file)], input="a\n", catch_exceptions=False) - second_result = runner.invoke(cli, ["scan", str(model_file)], catch_exceptions=False) + first_result = runner.invoke( + cli, + ["scan", str(model_file), "--format", "text"], + input="a\n", + catch_exceptions=False, + ) + second_result = runner.invoke(cli, ["scan", str(model_file), "--format", "text"], catch_exceptions=False) assert first_result.exit_code == 0 assert second_result.exit_code == 0 From b64b9b60d17844911cf62a21370798028cf276c7 Mon Sep 17 00:00:00 2001 From: mldangelo Date: Mon, 16 Mar 2026 11:03:22 -0700 Subject: [PATCH 7/7] style: apply ruff formatting to trusted config cli tests --- tests/test_cli.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_cli.py b/tests/test_cli.py index 54692d3df..dcbc7d7d0 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -205,6 +205,8 @@ def test_scan_can_apply_local_config_once_when_confirmed(tmp_path: Path, monkeyp assert "NO ISSUES FOUND" in output trust_store = TrustedConfigStore(tmp_path / "cache" / "trusted_local_configs.json") assert not trust_store.store_path.exists() + + def test_scan_can_remember_trusted_local_config(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """Choosing to trust a local config should persist for future interactive runs.""" import tarfile