diff --git a/argus/cli.py b/argus/cli.py index 47c9e096..543b0353 100644 --- a/argus/cli.py +++ b/argus/cli.py @@ -642,6 +642,18 @@ def _build_scan_parser(subparsers: argparse._SubParsersAction) -> None: action="store_true", help="Disable animated spinner output", ) + scan_parser.add_argument( + "--no-update-check", + action="store_true", + help="Skip the once-per-day check for a newer argus release. The " + "check runs in the background during the scan (zero latency " + "cost) and prints a soft notice at the end of the command " + "when an upgrade is available. Also disabled by setting the " + "ARGUS_NO_UPDATE_CHECK environment variable, which is the " + "right move for CI / air-gapped environments. Override the " + "PyPI URL via ARGUS_UPDATE_CHECK_URL for TestPyPI or " + "private mirrors.", + ) scan_parser.add_argument( "--no-timestamp", action="store_true", @@ -1425,6 +1437,14 @@ def _cmd_source_scan(args: argparse.Namespace) -> int: output_dir = _make_run_dir(config.reporting.output_dir) config.reporting.output_dir = output_dir log = get_logger("argus", output_dir=output_dir, verbose=args.verbose) + + # Kick off the update check in a daemon thread now so it runs in + # parallel with the scan — by end-of-command it's already done. + # Returns ``None`` when suppressed (env var, --no-update-check, + # --quiet, or dev install); callers must null-check. + from argus.update_check import start_background_check + update_check = start_background_check(args) + manifest = create_manifest( config_path=args.config, scan_targets=[args.path], @@ -1701,6 +1721,14 @@ def _cmd_source_scan(args: argparse.Namespace) -> int: if view_interface: _launch_view_after_scan(view_interface, output_dir) + # Surface the update notice as the very last thing — past PASS/FAIL + # status, past viewer launch, so it never hides scan results behind + # version chatter. ``None`` when suppressed or when up-to-date. + if update_check is not None: + notice = update_check.notice() + if notice: + print(notice, file=sys.stderr) + return exit_code @@ -1965,6 +1993,12 @@ def _cmd_container_scan( ) manifest.execution_backend = config.get("backend", "auto") + # Kick off the update check in a daemon thread now so it runs in + # parallel with the (typically multi-minute) container scan. By + # end-of-command the result is already cached in memory. + from argus.update_check import start_background_check + update_check = start_background_check(args) + # Decide whether to persist raw per-scanner outputs alongside the # canonical argus-results.json. Default is ON — the user just ran # a scan and would expect those artifacts to be available for @@ -2077,6 +2111,14 @@ def _cmd_container_scan( output_dir=output_dir, ) log.info("Audit manifest written to %s/argus-audit.json", output_dir) + + # Surface the update notice last — past PASS/FAIL, past audit + # finalize — so it never hides scan results behind version chatter. + if update_check is not None: + notice = update_check.notice() + if notice: + print(notice, file=sys.stderr) + return exit_code diff --git a/argus/tests/test_update_check.py b/argus/tests/test_update_check.py new file mode 100644 index 00000000..5ad4b5bf --- /dev/null +++ b/argus/tests/test_update_check.py @@ -0,0 +1,320 @@ +"""Tests for argus.update_check. + +Locks in the air-gap-friendly contract: the update check NEVER fails +loudly, NEVER hangs the scan, and ALWAYS honors the suppression hooks. +A new release notification is a UX nicety; the security tool's actual +job (scanning) must keep working under every degraded condition. +""" + +import argparse +import json +from datetime import datetime, timedelta, timezone +from pathlib import Path +from unittest.mock import patch +from urllib.error import URLError + +import pytest + +from argus import update_check + + +# ────────────────────────────────────────────────────────────────── +# Suppression hooks # +# ────────────────────────────────────────────────────────────────── + + +def _ns(**flags) -> argparse.Namespace: + base = {"no_update_check": False, "quiet": False} + base.update(flags) + return argparse.Namespace(**base) + + +class TestShouldCheck: + + def test_default_is_enabled(self, monkeypatch): + monkeypatch.delenv("ARGUS_NO_UPDATE_CHECK", raising=False) + monkeypatch.setattr(update_check, "_is_dev_install", lambda: False) + assert update_check.should_check(_ns()) is True + + def test_env_var_disables(self, monkeypatch): + monkeypatch.setenv("ARGUS_NO_UPDATE_CHECK", "1") + monkeypatch.setattr(update_check, "_is_dev_install", lambda: False) + assert update_check.should_check(_ns()) is False + + def test_no_update_check_flag_disables(self, monkeypatch): + monkeypatch.delenv("ARGUS_NO_UPDATE_CHECK", raising=False) + monkeypatch.setattr(update_check, "_is_dev_install", lambda: False) + assert update_check.should_check(_ns(no_update_check=True)) is False + + def test_quiet_flag_disables(self, monkeypatch): + monkeypatch.delenv("ARGUS_NO_UPDATE_CHECK", raising=False) + monkeypatch.setattr(update_check, "_is_dev_install", lambda: False) + assert update_check.should_check(_ns(quiet=True)) is False + + def test_dev_install_disables(self, monkeypatch): + monkeypatch.delenv("ARGUS_NO_UPDATE_CHECK", raising=False) + monkeypatch.setattr(update_check, "_is_dev_install", lambda: True) + assert update_check.should_check(_ns()) is False + + def test_no_args_passed_is_still_handled(self, monkeypatch): + monkeypatch.delenv("ARGUS_NO_UPDATE_CHECK", raising=False) + monkeypatch.setattr(update_check, "_is_dev_install", lambda: False) + # Some callers don't have an argparse Namespace — must not crash. + assert update_check.should_check(None) is True + + +class TestIsDevInstall: + + @pytest.mark.parametrize("version", [ + "0.7.2.dev0", + "0.7.2.dev0+g123abc", + "0.7.2+dirty", + "0.7.2rc1", + ]) + def test_dev_markers_skip(self, monkeypatch, version): + monkeypatch.setattr(update_check, "__version__", version) + assert update_check._is_dev_install() is True + + @pytest.mark.parametrize("version", ["0.7.2", "1.0.0", "0.7.2.post1"]) + def test_release_versions_dont_skip(self, monkeypatch, version): + monkeypatch.setattr(update_check, "__version__", version) + assert update_check._is_dev_install() is False + + +# ────────────────────────────────────────────────────────────────── +# URL resolution # +# ────────────────────────────────────────────────────────────────── + + +class TestPyPIUrl: + + def test_default(self, monkeypatch): + monkeypatch.delenv("ARGUS_UPDATE_CHECK_URL", raising=False) + assert update_check._pypi_url() == update_check.DEFAULT_PYPI_URL + + def test_env_var_override(self, monkeypatch): + monkeypatch.setenv( + "ARGUS_UPDATE_CHECK_URL", + "https://test.pypi.org/pypi/argus-security/json", + ) + assert "test.pypi.org" in update_check._pypi_url() + + +# ────────────────────────────────────────────────────────────────── +# Fetch and cache # +# ────────────────────────────────────────────────────────────────── + + +class TestFetchLatestVersion: + + def test_returns_version_on_success(self): + # Mock the json response from PyPI. + class _Resp: + def __enter__(self): return self + def __exit__(self, *a): pass + def read(self): + return json.dumps({"info": {"version": "0.8.1"}}).encode() + with patch("argus.update_check.urlopen", return_value=_Resp()): + assert update_check.fetch_latest_version() == "0.8.1" + + def test_network_error_returns_none(self): + with patch("argus.update_check.urlopen", side_effect=URLError("offline")): + assert update_check.fetch_latest_version() is None + + def test_oserror_returns_none(self): + with patch("argus.update_check.urlopen", side_effect=OSError("conn refused")): + assert update_check.fetch_latest_version() is None + + def test_malformed_json_returns_none(self): + class _Resp: + def __enter__(self): return self + def __exit__(self, *a): pass + def read(self): return b"not json" + with patch("argus.update_check.urlopen", return_value=_Resp()): + assert update_check.fetch_latest_version() is None + + def test_missing_version_field_returns_none(self): + class _Resp: + def __enter__(self): return self + def __exit__(self, *a): pass + def read(self): return json.dumps({"info": {}}).encode() + with patch("argus.update_check.urlopen", return_value=_Resp()): + assert update_check.fetch_latest_version() is None + + +class TestCachedLatestVersion: + + def test_uses_fresh_cache(self, tmp_path, monkeypatch): + cache_path = tmp_path / "argus" / "update-check.json" + cache_path.parent.mkdir() + cache_path.write_text(json.dumps({ + "checked_at": datetime.now(timezone.utc).isoformat(), + "latest_version": "0.9.0", + })) + monkeypatch.setattr(update_check, "_cache_path", lambda: cache_path) + + with patch("argus.update_check.fetch_latest_version") as mock_fetch: + result = update_check.cached_latest_version() + mock_fetch.assert_not_called() + assert result == "0.9.0" + + def test_refetches_when_cache_stale(self, tmp_path, monkeypatch): + cache_path = tmp_path / "argus" / "update-check.json" + cache_path.parent.mkdir() + cache_path.write_text(json.dumps({ + "checked_at": (datetime.now(timezone.utc) - timedelta(days=2)).isoformat(), + "latest_version": "0.6.0", + })) + monkeypatch.setattr(update_check, "_cache_path", lambda: cache_path) + + with patch( + "argus.update_check.fetch_latest_version", + return_value="0.9.0", + ): + result = update_check.cached_latest_version() + assert result == "0.9.0" + + # New value was written to cache. + new_cache = json.loads(cache_path.read_text()) + assert new_cache["latest_version"] == "0.9.0" + + def test_no_cache_falls_through_to_fetch(self, tmp_path, monkeypatch): + cache_path = tmp_path / "argus" / "update-check.json" + monkeypatch.setattr(update_check, "_cache_path", lambda: cache_path) + with patch( + "argus.update_check.fetch_latest_version", + return_value="0.9.0", + ): + result = update_check.cached_latest_version() + assert result == "0.9.0" + assert cache_path.is_file() + + def test_corrupt_cache_doesnt_crash(self, tmp_path, monkeypatch): + cache_path = tmp_path / "argus" / "update-check.json" + cache_path.parent.mkdir() + cache_path.write_text("{not json") + monkeypatch.setattr(update_check, "_cache_path", lambda: cache_path) + with patch( + "argus.update_check.fetch_latest_version", + return_value="0.9.0", + ): + result = update_check.cached_latest_version() + assert result == "0.9.0" + + +# ────────────────────────────────────────────────────────────────── +# Version comparison + notice formatting # +# ────────────────────────────────────────────────────────────────── + + +class TestIsNewer: + + def test_strictly_newer_returns_true(self): + assert update_check.is_newer("0.7.2", "0.8.0") is True + + def test_same_version_returns_false(self): + assert update_check.is_newer("0.7.2", "0.7.2") is False + + def test_older_returns_false(self): + assert update_check.is_newer("0.8.0", "0.7.2") is False + + +class TestFormatNotice: + + def test_includes_both_versions(self): + msg = update_check.format_notice("0.7.2", "0.8.1") + assert "0.7.2" in msg + assert "0.8.1" in msg + + def test_includes_pip_upgrade_command(self): + msg = update_check.format_notice("0.7.2", "0.8.1") + assert "pip install --upgrade argus-security" in msg + + def test_pip_style_notice_prefix(self): + msg = update_check.format_notice("0.7.2", "0.8.1") + assert "[notice]" in msg + + +# ────────────────────────────────────────────────────────────────── +# get_notice_if_outdated — the convenience entry point # +# ────────────────────────────────────────────────────────────────── + + +class TestGetNoticeIfOutdated: + + def test_returns_notice_when_outdated(self, monkeypatch): + monkeypatch.setattr(update_check, "__version__", "0.7.2") + monkeypatch.setattr(update_check, "_is_dev_install", lambda: False) + monkeypatch.setattr( + update_check, "cached_latest_version", lambda: "0.9.0", + ) + notice = update_check.get_notice_if_outdated() + assert notice is not None + assert "0.7.2" in notice and "0.9.0" in notice + + def test_returns_none_when_up_to_date(self, monkeypatch): + monkeypatch.setattr(update_check, "__version__", "0.9.0") + monkeypatch.setattr(update_check, "_is_dev_install", lambda: False) + monkeypatch.setattr( + update_check, "cached_latest_version", lambda: "0.9.0", + ) + assert update_check.get_notice_if_outdated() is None + + def test_returns_none_on_dev_install(self, monkeypatch): + monkeypatch.setattr(update_check, "_is_dev_install", lambda: True) + # Should not even consult cache when dev install. + with patch("argus.update_check.cached_latest_version") as mock: + assert update_check.get_notice_if_outdated() is None + mock.assert_not_called() + + def test_returns_none_when_fetch_fails(self, monkeypatch): + monkeypatch.setattr(update_check, "_is_dev_install", lambda: False) + monkeypatch.setattr( + update_check, "cached_latest_version", lambda: None, + ) + assert update_check.get_notice_if_outdated() is None + + +# ────────────────────────────────────────────────────────────────── +# Background check — daemon thread # +# ────────────────────────────────────────────────────────────────── + + +class TestBackgroundCheck: + + def test_swallows_exceptions(self): + # An unexpected exception inside the thread must not propagate + # — air-gap friendly contract. + check = update_check.BackgroundCheck() + with patch( + "argus.update_check.get_notice_if_outdated", + side_effect=RuntimeError("unexpected"), + ): + check.start() + assert check.notice(timeout=1.0) is None + + def test_returns_notice_when_set(self): + check = update_check.BackgroundCheck() + with patch( + "argus.update_check.get_notice_if_outdated", + return_value="my notice", + ): + check.start() + assert check.notice(timeout=1.0) == "my notice" + + +class TestStartBackgroundCheck: + + def test_returns_none_when_suppressed(self, monkeypatch): + monkeypatch.setenv("ARGUS_NO_UPDATE_CHECK", "1") + assert update_check.start_background_check(_ns()) is None + + def test_returns_check_when_enabled(self, monkeypatch): + monkeypatch.delenv("ARGUS_NO_UPDATE_CHECK", raising=False) + monkeypatch.setattr(update_check, "_is_dev_install", lambda: False) + with patch( + "argus.update_check.get_notice_if_outdated", return_value=None, + ): + check = update_check.start_background_check(_ns()) + assert check is not None + assert check.notice(timeout=1.0) is None diff --git a/argus/update_check.py b/argus/update_check.py new file mode 100644 index 00000000..99127030 --- /dev/null +++ b/argus/update_check.py @@ -0,0 +1,266 @@ +"""Background update check — surface a soft notice when a newer argus is available. + +A security tool that's months out of date is missing CVE-database +updates, new severity-classification rules, and bug fixes that affect +scan correctness. This module polls PyPI once per day per machine, +compares the published version to the installed one, and prints a +``pip``-style notice at the end of long-running commands when a newer +release exists. + +Design constraints (in priority order): + +1. **Air-gap friendly.** Silently skip on any network error. Air-gapped + environments and offline CI runners must not see a tool slowdown, + error message, or visible failure because PyPI is unreachable. +2. **Privacy-respectful.** One HTTP request per machine per 24 hours, + cached in ``~/.cache/argus/update-check.json``. The same data PyPI + already gets from ``pip install argus-security``. +3. **Zero scan-latency cost.** Runs in a daemon thread alongside the + scan; the result is consumed at end-of-command. The scan was + already going to take seconds-to-minutes; the update check + completes in <500ms typically and runs in parallel. +4. **Override-friendly.** Three suppression hooks for different + audiences: + * ``ARGUS_NO_UPDATE_CHECK=1`` env var — set once for CI / air-gap + * ``--no-update-check`` flag — per-invocation + * ``--quiet`` flag — auto-respects explicit silence + Plus ``ARGUS_UPDATE_CHECK_URL`` to point at TestPyPI / private + mirrors when the user knows their distribution channel differs from + the public PyPI. +5. **Pre-release-aware.** Editable / dev / RC installs auto-skip — a + contributor on ``0.7.2.dev0+g123abc`` doesn't want to be told + ``0.7.2`` is "available." +""" + +from __future__ import annotations + +import json +import logging +import os +import threading +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Optional +from urllib.error import URLError +from urllib.request import Request, urlopen + +from argus import __version__ + +logger = logging.getLogger("argus.update_check") + +DEFAULT_PYPI_URL = "https://pypi.org/pypi/argus-security/json" +CACHE_TTL = timedelta(hours=24) +HTTP_TIMEOUT = 2.0 # seconds; keep tight to avoid stalling --quiet runs + + +def _pypi_url() -> str: + """Resolve the index URL — env var override wins.""" + return os.environ.get("ARGUS_UPDATE_CHECK_URL", DEFAULT_PYPI_URL) + + +def _cache_path() -> Path: + """Locate the on-disk cache file. Honors XDG_CACHE_HOME.""" + base = os.environ.get("XDG_CACHE_HOME") or str(Path.home() / ".cache") + return Path(base) / "argus" / "update-check.json" + + +def _is_dev_install() -> bool: + """Return True for editable / pre-release / dirty installs. + + These users typically don't want update notifications — they're + on bleeding-edge or experimenting. ``__version__`` strings like + ``0.7.2.dev0+g123abc`` or ``0.7.2+dirty`` indicate non-release. + """ + v = __version__ + return any(marker in v for marker in (".dev", "+", "rc")) + + +def should_check(args=None) -> bool: + """Honor every suppression hook in order of preference. + + Order matters — env var wins so air-gapped/CI users with one + persistent setting don't have to add ``--no-update-check`` to + every invocation. + """ + if os.environ.get("ARGUS_NO_UPDATE_CHECK"): + return False + if args is not None and getattr(args, "no_update_check", False): + return False + if args is not None and getattr(args, "quiet", False): + # User explicitly asked for less output — respect it. + return False + if _is_dev_install(): + return False + return True + + +def _read_cache() -> Optional[dict]: + path = _cache_path() + if not path.is_file(): + return None + try: + return json.loads(path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError): + return None + + +def _write_cache(latest_version: str) -> None: + path = _cache_path() + try: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + json.dumps({ + "checked_at": datetime.now(timezone.utc).isoformat(), + "latest_version": latest_version, + }), + encoding="utf-8", + ) + except OSError: + # Cache failure is non-fatal — we just won't cache this round. + logger.debug("update-check cache write failed", exc_info=True) + + +def _cache_is_fresh(cache: dict) -> bool: + try: + checked_at = datetime.fromisoformat(cache["checked_at"]) + except (KeyError, ValueError, TypeError): + return False + return datetime.now(timezone.utc) - checked_at < CACHE_TTL + + +def fetch_latest_version() -> Optional[str]: + """Hit PyPI directly. ``None`` on any failure (network, parse, missing).""" + url = _pypi_url() + try: + req = Request( + url, + headers={"User-Agent": f"argus-security/{__version__}"}, + ) + with urlopen(req, timeout=HTTP_TIMEOUT) as resp: + data = json.loads(resp.read().decode("utf-8")) + version = data.get("info", {}).get("version") + return version if isinstance(version, str) else None + except (URLError, json.JSONDecodeError, OSError, ValueError): + # Air-gap-friendly: every network/parse failure is silent. + logger.debug("update-check fetch failed for %s", url, exc_info=True) + return None + + +def cached_latest_version() -> Optional[str]: + """Read from cache if fresh; otherwise fetch + write. + + Returns ``None`` only when both the cache miss AND the network + fetch fail. + """ + cache = _read_cache() + if cache and _cache_is_fresh(cache): + return cache.get("latest_version") + + latest = fetch_latest_version() + if latest: + _write_cache(latest) + return latest + + +def is_newer(current: str, latest: str) -> bool: + """``True`` when *latest* > *current*. Falls back to inequality on parse failure.""" + try: + from packaging.version import parse, InvalidVersion + try: + return parse(latest) > parse(current) + except InvalidVersion: + return latest != current + except ImportError: + # ``packaging`` is a transitive dep of pip+setuptools; this + # branch is mostly theoretical but keeps the helper robust on + # exotic minimal environments. + return latest != current + + +def format_notice(current: str, latest: str) -> str: + """Match ``pip``'s notice shape so it reads familiar. + + Two lines, ``[notice]`` prefix, the version transition, and the + upgrade command. End with a trailing newline so it doesn't bleed + into a shell prompt. + """ + return ( + f"\n[notice] A new release of argus-security is available: " + f"{current} → {latest}\n" + f"[notice] To update, run: pip install --upgrade argus-security\n" + ) + + +def get_notice_if_outdated() -> Optional[str]: + """End-to-end convenience: cached check + comparison + format. + + ``None`` when up-to-date, when the check failed, or when the + user is on a dev/RC build. + """ + if _is_dev_install(): + return None + latest = cached_latest_version() + if not latest: + return None + if not is_newer(__version__, latest): + return None + return format_notice(__version__, latest) + + +# ──────────────────────────────────────────────────────────────────── +# Async/background helpers — run alongside the scan, consume at end +# ──────────────────────────────────────────────────────────────────── + + +class BackgroundCheck: + """Daemon-thread wrapper for ``get_notice_if_outdated``. + + Usage:: + + check = start_background_check(args) + # ... do scan work ... + if check is not None: + notice = check.notice() + if notice: + print(notice, file=sys.stderr) + """ + + def __init__(self): + self._notice: Optional[str] = None + self._thread: Optional[threading.Thread] = None + + def start(self) -> "BackgroundCheck": + self._thread = threading.Thread(target=self._run, daemon=True) + self._thread.start() + return self + + def _run(self) -> None: + try: + self._notice = get_notice_if_outdated() + except Exception: # never leak bg-thread exceptions to the user + logger.debug("update check failed", exc_info=True) + self._notice = None + + def notice(self, timeout: float = 0.1) -> Optional[str]: + """Block briefly for the bg thread to finish; return notice or None. + + Default timeout is 100ms — enough for the local-cache-hit path + to complete, short enough that an unexpected hang doesn't + delay the user. Network-fetch path completes during the scan + itself, so by end-of-command the result is already in memory. + """ + if self._thread: + self._thread.join(timeout=timeout) + return self._notice + + +def start_background_check(args=None) -> Optional[BackgroundCheck]: + """Kick off the update check in a daemon thread. + + Returns ``None`` when suppressed (env var, flag, dev install, or + ``--quiet``). Callers should null-check the return value before + consuming. + """ + if not should_check(args): + return None + return BackgroundCheck().start() diff --git a/docs/cli-reference.md b/docs/cli-reference.md index b579c43c..4acd9294 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -72,9 +72,9 @@ argus scan [-h] [--path PATH] [--config CONFIG] [--severity-threshold {critical,high,medium,low,none}] [--format {terminal,markdown,sarif,json}] [--list] [--verbose] [--debug] [--quiet] [--no-spinner] - [--no-timestamp] [--output-vars FILE] [--exclude PATTERNS] - [--no-default-excludes] [--dry-run] [--sbom PATH] - [--interface {terminal,browser}] [--fail-fast] + [--no-update-check] [--no-timestamp] [--output-vars FILE] + [--exclude PATTERNS] [--no-default-excludes] [--dry-run] + [--sbom PATH] [--interface {terminal,browser}] [--fail-fast] [--fail-on-scanner-error] [--timeout SECONDS] [--no-parallel] [--allow-local-versions] [--no-cache] [--no-keep-raw] [--discover [PATH]] [--image REF] @@ -102,6 +102,7 @@ argus scan [-h] [--path PATH] [--config CONFIG] | `--debug` | Full firehose: subprocess output, vulnerability-DB updates, every engine log line. Use when troubleshooting; the default phase-aware progress is enough for normal scans. | `false` | | `--quiet`, `-q` | Suppress per-phase progress lines. The spinner still draws (use --no-spinner to suppress that too). Final summary still prints. Compose with --no-spinner for fully silent CI exit-code-only mode. | `false` | | `--no-spinner` | Disable animated spinner output | `false` | +| `--no-update-check` | Skip the once-per-day check for a newer argus release. The check runs in the background during the scan (zero latency cost) and prints a soft notice at the end of the command when an upgrade is available. Also disabled by setting the ARGUS_NO_UPDATE_CHECK environment variable, which is the right move for CI / air-gapped environments. Override the PyPI URL via ARGUS_UPDATE_CHECK_URL for TestPyPI or private mirrors. | `false` | | `--no-timestamp` | Write output directly to --output-dir without a timestamped subdirectory. Useful in CI where a predictable output path is needed. | `false` | | `--output-vars` | Write scan result counts as key=value pairs to FILE. Useful in CI: cat FILE >> $GITHUB_OUTPUT. Keys: critical_count, high_count, medium_count, low_count, total_count, passed. | | | `--exclude`, `-e` | Comma-separated paths or patterns to exclude from scanning. Added on top of .gitignore, .dockerignore, and built-in defaults. | `` |