From a516eec7ec036cad0fc7d6aadc3741b55b61bd59 Mon Sep 17 00:00:00 2001 From: Advait Patel Date: Fri, 17 Jul 2026 18:58:44 -0500 Subject: [PATCH 1/3] Privacy redaction, waiver file, cache correctness, and slim core install --- CHANGELOG.md | 53 +++++++++++++++ README.md | 67 ++++++++++++++++++- docksec/cli.py | 52 ++++++++++++++- docksec/compose_scanner.py | 59 ++++++++++++++--- docksec/config.py | 38 +++++++---- docksec/docker_scanner.py | 118 +++++++++++++++++++++++++++------- docksec/ignore.py | 97 ++++++++++++++++++++++++++++ docksec/redact.py | 109 +++++++++++++++++++++++++++++++ docksec/score_calculator.py | 2 +- docksec/utils.py | 56 +++++----------- requirements.txt | 39 ++++------- setup.py | 33 +++++----- tests/test_compose_scanner.py | 37 +++++++++-- tests/test_docker_scanner.py | 40 ++++++++++++ tests/test_ignore.py | 76 ++++++++++++++++++++++ tests/test_redact.py | 73 +++++++++++++++++++++ 16 files changed, 816 insertions(+), 133 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 docksec/ignore.py create mode 100644 docksec/redact.py create mode 100644 tests/test_ignore.py create mode 100644 tests/test_redact.py diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..69807ce --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,53 @@ +# Changelog + +All notable changes to DockSec are documented in this file. + +## 2026.7.4 + +Industry-readiness release: privacy hardening, cache correctness, waivers, and a +slimmer install. + +### Added + +- Secret redaction before AI analysis: secret-looking values (passwords, tokens, + API keys, private key blocks) in Dockerfiles and compose files are masked before + any content is sent to the configured LLM provider. Key names remain visible so + exposed credentials are still flagged. Opt out with `--no-redact`. +- Ignore file support (`--ignore-file`, or an auto-detected `.docksec-ignore.yml`): + suppress individual triaged findings by vulnerability or rule ID, with a required + reason and optional expiry date per entry. Suppressions apply to scoring, reports, + `--json` output, and the `--fail-on` gate. +- `--no-cache` flag to bypass the scan results cache for a run. +- Cache TTL: cached scan results now expire (default 24 hours; configurable with + `DOCKSEC_CACHE_TTL_HOURS`). +- "Data flow and privacy" documentation describing exactly what leaves the machine. +- Optional dependency extra: `pip install "docksec[ai]"` installs AI analysis + support; the base `pip install docksec` is now a slim, scan-only core with no + LLM dependencies. + +### Changed + +- AI analysis input limits raised from 50 lines / 2,000 characters to 400 lines / + 16,000 characters for Dockerfiles (600 lines / 24,000 characters for compose + files), and a warning is now printed whenever input is truncated. +- Scan cache is keyed by the image content digest instead of the tag, so a rebuilt + tag (for example a reused `:latest`) never serves stale results. Full-scan cache + entries also include the Dockerfile content hash, so results are never reused + across different Dockerfiles that share an image. +- Compose rule severities tuned to reduce noise: `compose-no-non-root-user` is now + MEDIUM (was HIGH); `compose-no-resource-limits` and `compose-writable-root-fs` + are now LOW (was MEDIUM). +- `compose-port-bound-all-interfaces` now flags only sensitive ports (remote admin, + databases, caches, brokers, Docker API) instead of every published port, and now + correctly flags bare container-port entries (for example `"6379"`), which bind + 0.0.0.0. +- GitHub Action usage examples now reference the pinned release tag instead of + `@main`. +- Dependency pins relaxed from exact (`==`) to compatible ranges, and unused + dependencies (pandas, tqdm, tenacity) removed. + +### Fixed + +- A narrower cached scan could previously be reused in situations where the image + had been rebuilt under the same tag; digest keying fixes this class of stale + results. diff --git a/README.md b/README.md index fb14bee..6c587e3 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,7 @@ Integrate DockSec into your GitHub Actions workflow: ```yaml - name: Run DockSec AI Scanner - uses: OWASP/DockSec@main + uses: OWASP/DockSec@v2026.7.4 with: dockerfile: 'Dockerfile' openai_api_key: ${{ secrets.OPENAI_API_KEY }} @@ -84,7 +84,10 @@ Integrate DockSec into your GitHub Actions workflow: ### CLI Usage ```bash -# Install DockSec +# Install DockSec with AI analysis support +pip install "docksec[ai]" + +# Or install the slim, scan-only core (no LLM dependencies) pip install docksec # Scan a Dockerfile (AI-powered) @@ -131,6 +134,15 @@ docksec install-skill docksec -i myapp:latest --image-only --baseline .docksec-baseline.json --update-baseline docksec -i myapp:latest --image-only --baseline .docksec-baseline.json --fail-on high +# Suppress triaged findings with an auditable ignore file +docksec -i myapp:latest --image-only --ignore-file .docksec-ignore.yml + +# Force a fresh scan, bypassing the results cache +docksec -i myapp:latest --image-only --no-cache + +# Send file content to the AI provider unmasked (secrets are redacted by default) +docksec Dockerfile --no-redact + # Reduce output to warnings, errors, and the result summary docksec Dockerfile --scan-only --quiet @@ -185,7 +197,7 @@ directly on pull requests and in the Security tab: ```yaml - name: Run DockSec - uses: OWASP/DockSec@main + uses: OWASP/DockSec@v2026.7.4 with: dockerfile: 'Dockerfile' sarif: 'true' @@ -274,6 +286,53 @@ valid as unrelated findings come and go. Re-run with `--update-baseline` wheneve to accept the current state as the new baseline (e.g. after triaging and deciding to defer a finding). +### Ignoring findings (waivers) + +`--ignore-file FILE` suppresses individual findings a team has triaged and accepted. +Unlike the baseline (a point-in-time snapshot), the ignore file is an explicit, +reviewable list where every entry carries a reason and an optional expiry date. +If a `.docksec-ignore.yml` file exists in the current directory, it is picked up +automatically. + +```yaml +# .docksec-ignore.yml +ignores: + - id: CVE-2023-45853 # Trivy vulnerability ID or DockSec rule ID + reason: "zlib CVE; code path not reachable, vendor fix pending" + expires: 2026-12-31 # optional; entry stops applying after this date + - id: compose-missing-healthcheck + reason: "healthchecks are handled by the orchestrator" +``` + +Suppressed findings are removed before scoring, reports, `--json` output, and the +`--fail-on` gate. Expired entries stop applying automatically (with a warning), and +entries without a reason are flagged so waivers stay auditable. Commit the file to +version control so suppressions are reviewed like any other change. + +### Data flow and privacy + +DockSec is designed so you always know what leaves your machine: + +- **Scanning is fully local.** Trivy, Hadolint, and the security score run on your + machine. Image contents are never uploaded anywhere by DockSec. +- **AI analysis sends only the scanned file.** When the AI pass runs, the Dockerfile + or compose file content (plus a short summary of vulnerability counts for scoring) + is sent to the LLM provider you configured. Nothing else is transmitted. +- **Secrets are redacted before they leave.** Secret-looking values (passwords, + tokens, API keys, private key blocks) in the file are masked before the content is + sent to the AI provider. Key names stay visible so exposed credentials are still + flagged. Use `--no-redact` to opt out. +- **Fully local AI is supported.** Use `--provider ollama` to keep the AI analysis on + your own hardware, or `--scan-only` / `--offline` to skip AI entirely. +- **No telemetry.** DockSec collects no usage data and phones home to nothing. + +### Scan results cache + +Image scan results are cached (default: 24 hours, override with +`DOCKSEC_CACHE_TTL_HOURS`) and keyed by the image's content digest, so a rebuilt tag +such as a reused `:latest` always gets a fresh scan. Use `--no-cache` (or +`DOCKSEC_USE_CACHE=false`) to bypass the cache for a run. + ### Exit codes DockSec uses CI-friendly exit codes so builds and shells can react to results: @@ -302,6 +361,8 @@ severity is widened automatically so the gate can observe those findings. - **Rich Formats**: Professional exports in HTML (interactive), PDF, JSON, CSV, SARIF, and CycloneDX SBOM. - **Supply-Chain Ready**: Generate a CycloneDX SBOM (`--sbom`) of any image for Dependency-Track and other consumers. - **Offline Mode**: Scan fully air-gapped (`--offline`) using the local Trivy database, no network required. +- **Privacy First**: Secret values are redacted before any content reaches an AI provider, scanning is fully local, and there is no telemetry. +- **Auditable Waivers**: Suppress triaged findings with an ignore file that records a reason and expiry date per entry. - **CI/CD Ready**: Designed for easy integration into GitHub Actions and build pipelines. - **AI-Assistant Skills**: `docksec install-skill` teaches Claude Code, Cursor, Copilot, and others how to run DockSec in your repo. - **GitHub Action**: Available on the GitHub Marketplace for automated security scans. diff --git a/docksec/cli.py b/docksec/cli.py index 57be95e..4b66ba3 100644 --- a/docksec/cli.py +++ b/docksec/cli.py @@ -71,6 +71,9 @@ def main() -> None: parser.add_argument('--sarif', dest='sarif', action='store_true', help='Write a SARIF 2.1.0 report for GitHub Code Scanning and other SARIF-compatible tools') parser.add_argument('--sbom', dest='sbom', action='store_true', help='Write a CycloneDX SBOM (.cdx.json) of the scanned image for supply-chain tooling (requires an image)') parser.add_argument('--offline', dest='offline', action='store_true', help='Run without network access: use the local Trivy DB (no DB update) and skip AI analysis') + parser.add_argument('--no-redact', dest='no_redact', action='store_true', help='Do not mask secret-looking values before sending file content to the AI provider') + parser.add_argument('--no-cache', dest='no_cache', action='store_true', help='Bypass the scan results cache and force a fresh scan') + parser.add_argument('--ignore-file', dest='ignore_file', metavar='FILE', help='Path to an ignore file listing findings to suppress (default: .docksec-ignore.yml in the current directory, if present)') parser.add_argument('--baseline', dest='baseline', metavar='FILE', help='Path to a baseline file; with --fail-on, only findings not present in the baseline trigger the gate') parser.add_argument('--update-baseline', dest='update_baseline', action='store_true', help='Write the current scan findings to --baseline instead of gating against it') parser.add_argument('--quiet', action='store_true', help='Reduce output to warnings, errors, and the result summary') @@ -100,6 +103,11 @@ def main() -> None: if args.compact_output: os.environ["DOCKSEC_COMPACT_OUTPUT"] = "true" + # --no-cache: the scanner (and every per-service scanner in compose runs) + # reads DOCKSEC_USE_CACHE at construction time. + if args.no_cache: + os.environ["DOCKSEC_USE_CACHE"] = "false" + if args.verbose and not os.getenv("DOCKSEC_LOG_LEVEL"): os.environ["DOCKSEC_LOG_LEVEL"] = "INFO" @@ -321,8 +329,29 @@ def main() -> None: output.error(f"No {file_type} content found.") return - # Truncate content to reduce token usage - truncated_content = truncate_dockerfile(filecontent, max_lines=150, max_chars=4000) if run_compose_analysis else truncate_dockerfile(filecontent, max_lines=50, max_chars=2000) + # Redact secret-looking values before the content leaves the + # machine. Keys stay visible so the model can still flag exposed + # credentials; the secret material itself is masked. + if not args.no_redact: + from docksec.redact import redact_content + filecontent, redacted_count = redact_content(filecontent) + if redacted_count: + output.info( + f"Masked {redacted_count} secret-looking value(s) before AI analysis " + f"(--no-redact to disable)" + ) + + # Cap very large inputs to bound token usage; warn when anything + # is dropped so a partial analysis is never mistaken for a full one. + if run_compose_analysis: + truncated_content = truncate_dockerfile(filecontent, max_lines=600, max_chars=24000) + else: + truncated_content = truncate_dockerfile(filecontent, max_lines=400, max_chars=16000) + if truncated_content != filecontent: + output.warn( + f"{file_type} is very large; AI analysis covers only the first part " + f"of the file. Scanner results (Trivy/Hadolint) are unaffected." + ) response = analyser_chain.invoke({"filecontent": truncated_content}) ai_findings = analyze_security(response, compact=True, report_path=output_dir) @@ -379,6 +408,25 @@ def main() -> None: # Full scan including Dockerfile results = scanner.run_full_scan(severity) + # Apply ignore-file suppressions before scoring, reports, JSON + # output, and the --fail-on gate see the findings. + ignore_path = args.ignore_file + if not ignore_path: + from docksec.ignore import find_default_ignore_file + ignore_path = find_default_ignore_file() + if ignore_path: + from docksec.ignore import load_ignore_file, apply_ignores + ignore_entries, ignore_warnings = load_ignore_file(ignore_path) + for warning in ignore_warnings: + output.warn(warning) + kept_findings, suppressed_count = apply_ignores( + results.get("json_data", []), ignore_entries) + if suppressed_count: + results["json_data"] = kept_findings + output.info( + f"{suppressed_count} finding(s) suppressed by ignore file {ignore_path}" + ) + # Calculate security score scanner.analysis_score = scanner.get_security_score(results) diff --git a/docksec/compose_scanner.py b/docksec/compose_scanner.py index a8ff38a..3e82b58 100644 --- a/docksec/compose_scanner.py +++ b/docksec/compose_scanner.py @@ -209,18 +209,59 @@ def _is_secret_key(self, key: str) -> bool: key = str(key).lower() return any(s in key for s in ['password', 'secret', 'token', 'api_key', 'private_key', 'private-key']) + # Ports whose exposure on all interfaces is a real risk signal: remote + # admin, databases, caches, message brokers, and the Docker API itself. + # Ordinary web ports (80/443/8080/...) are excluded on purpose - flagging + # every published port buries the findings that matter. + SENSITIVE_PORTS = { + 22, 23, 111, 445, 1433, 1521, 2181, 2375, 2376, 2379, 3306, 3389, + 5432, 5900, 5984, 6379, 8086, 8500, 9092, 9200, 9300, 11211, 15672, + 27017, 27018, + } + + @staticmethod + def _parse_port_entry(port) -> tuple: + """Normalize a compose port entry to (host_ip, published_port). + + Handles short syntax ("8080", "8080:80", "127.0.0.1:8080:80", with + optional /protocol suffix) and long syntax dicts. host_ip is None when + the entry binds all interfaces; published_port is None when it cannot + be determined (e.g. port ranges). + """ + host_ip = None + published = None + if isinstance(port, dict): + host_ip = port.get('host_ip') + published = port.get('published') or port.get('target') + else: + port_str = str(port).split('/')[0] + parts = port_str.rsplit(':', 2) + if len(parts) == 3: + host_ip, published = parts[0], parts[1] + elif len(parts) == 2: + published = parts[0] + else: + # Bare container port: published to a host port on 0.0.0.0. + published = parts[0] + try: + published = int(str(published)) + except (TypeError, ValueError): + published = None + return host_ip, published + def _check_port_bound_all_interfaces(self, service: str, config: dict, default_line: int): ports = config.get('ports', []) if not isinstance(ports, list): return + local_ips = {'127.0.0.1', 'localhost', '::1'} for port in ports: - port_str = str(port) - # If it's just "8080:80" or "8080", it binds to 0.0.0.0 by default - # If it's "127.0.0.1:8080:80", it's bound to localhost - if ':' in port_str and not port_str.startswith('127.0.0.1:') and not port_str.startswith('localhost:'): + host_ip, published = self._parse_port_entry(port) + if host_ip and str(host_ip) in local_ips: + continue + if published in self.SENSITIVE_PORTS: self._add_finding( - "compose-port-bound-all-interfaces", Severity.HIGH, "Port Bound to All Interfaces", - "A sensitive or admin port published with no host IP, binding 0.0.0.0.", + "compose-port-bound-all-interfaces", Severity.HIGH, "Sensitive Port Bound to All Interfaces", + f"Port {published} (admin/database/broker) is published with no host IP, binding 0.0.0.0.", "Bind to 127.0.0.1, or use expose for internal-only traffic.", service, self._get_line(ports, default_line) ) @@ -242,7 +283,7 @@ def _check_disabled_security_opt(self, service: str, config: dict, default_line: def _check_no_non_root_user(self, service: str, config: dict, default_line: int): if 'user' not in config: self._add_finding( - "compose-no-non-root-user", Severity.HIGH, "No Non-Root User", + "compose-no-non-root-user", Severity.MEDIUM, "No Non-Root User", "A service has no user directive and the image likely runs as root.", "Set a non-root user.", service, default_line @@ -269,7 +310,7 @@ def _check_no_resource_limits(self, service: str, config: dict, default_line: in if not has_limits: self._add_finding( - "compose-no-resource-limits", Severity.MEDIUM, "No Resource Limits", + "compose-no-resource-limits", Severity.LOW, "No Resource Limits", "No memory or CPU limits.", "Set limits to bound the DoS and blast-radius surface.", service, default_line @@ -287,7 +328,7 @@ def _check_env_file_secret_risk(self, service: str, config: dict, default_line: def _check_writable_root_fs(self, service: str, config: dict, default_line: int): if config.get('read_only') is not True: self._add_finding( - "compose-writable-root-fs", Severity.MEDIUM, "Writable Root Filesystem", + "compose-writable-root-fs", Severity.LOW, "Writable Root Filesystem", "no read_only: true on a service that does not need a writable root.", "Set read_only with explicit tmpfs where needed.", service, default_line diff --git a/docksec/config.py b/docksec/config.py index 9f80bc2..b693f06 100644 --- a/docksec/config.py +++ b/docksec/config.py @@ -2,7 +2,6 @@ import os from typing import Optional, Dict, List, Any from collections import defaultdict -from langchain_core.prompts import PromptTemplate load_dotenv() @@ -92,7 +91,7 @@ def get_html_template() -> str: # Helper functions for token optimization -def truncate_dockerfile(content: str, max_lines: int = 50, max_chars: int = 2000) -> str: +def truncate_dockerfile(content: str, max_lines: int = 400, max_chars: int = 16000) -> str: """ Truncate Dockerfile to reduce token usage for LLM analysis. Keeps first N lines and limits total characters. @@ -164,11 +163,6 @@ def summarize_vulnerabilities(vulnerabilities: List[Dict[str, Any]], max_count: {filecontent} """ -docker_agent_prompt = PromptTemplate( - input_variables=["filecontent"], - template=docker_agent_template -) - docker_score_template = """ Score Docker security 1-100. Output ONLY JSON: {{"score": N}} 90-100: Excellent, 70-89: Good, 50-69: Fair, 0-49: Poor. @@ -176,7 +170,29 @@ def summarize_vulnerabilities(vulnerabilities: List[Dict[str, Any]], max_count: {results} """ -docker_score_prompt = PromptTemplate( - input_variables=["results"], - template=docker_score_template -) + +def _build_prompt(template: str, input_variables: List[str]): + """Build a LangChain PromptTemplate on demand. + + langchain-core is part of the optional [ai] extra, so the import lives + here rather than at module level: the core (scan-only) install imports + docksec.config for RESULTS_DIR and helpers without pulling in LangChain. + """ + try: + from langchain_core.prompts import PromptTemplate + except ImportError: + raise ImportError( + "AI analysis requested but the AI dependencies are not installed. " + "Install them with: pip install \"docksec[ai]\"" + ) + return PromptTemplate(input_variables=input_variables, template=template) + + +def __getattr__(name: str): + # PEP 562 lazy attributes: prompts are only materialized (and LangChain + # only imported) when an AI code path actually asks for them. + if name == "docker_agent_prompt": + return _build_prompt(docker_agent_template, ["filecontent"]) + if name == "docker_score_prompt": + return _build_prompt(docker_score_template, ["results"]) + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/docksec/docker_scanner.py b/docksec/docker_scanner.py index 9e38e62..e3b4e80 100644 --- a/docksec/docker_scanner.py +++ b/docksec/docker_scanner.py @@ -8,7 +8,7 @@ import re from pathlib import Path from docksec import output as ui # aliased; a local var named `output` is used below -from docksec.config import RESULTS_DIR, docker_score_prompt +from docksec.config import RESULTS_DIR from docksec.enums import Severity from docksec.utils import ScoreResponse, get_llm, print_section, get_custom_logger from collections import defaultdict @@ -19,10 +19,6 @@ class ScanResultsCache: """Simple cache for scan results to avoid re-scanning same images.""" - def __init__(self, cache_dir: str = RESULTS_DIR): - self.cache_file = os.path.join(cache_dir, ".docksec_cache.json") - self.cache = self._load_cache() - def _load_cache(self) -> Dict: """Load cache from disk.""" if os.path.exists(self.cache_file): @@ -41,21 +37,57 @@ def _save_cache(self) -> None: except IOError as e: logger.warning(f"Failed to save cache: {e}") - def get_key(self, image_name: str, severity: str = "CRITICAL,HIGH") -> str: - """Generate cache key from image name and severity filter.""" + # Default time-to-live for cache entries, in hours. Vulnerability databases + # move daily, so even a digest-accurate scan goes stale; override with + # DOCKSEC_CACHE_TTL_HOURS. + DEFAULT_TTL_HOURS = 24 + + def __init__(self, cache_dir: str = RESULTS_DIR): + self.cache_file = os.path.join(cache_dir, ".docksec_cache.json") + self.cache = self._load_cache() + try: + self.ttl_hours = float(os.getenv("DOCKSEC_CACHE_TTL_HOURS", self.DEFAULT_TTL_HOURS)) + except ValueError: + self.ttl_hours = self.DEFAULT_TTL_HOURS + + def get_key(self, image_id: str, severity: str = "CRITICAL,HIGH", extra: str = "") -> str: + """Generate cache key from image identity, severity filter, and any + extra scan-input identity (e.g. the Dockerfile content hash). + + image_id should be the image digest/ID when available so a rebuilt + tag (e.g. a reused :latest) never serves stale results. + """ normalized_severity = ",".join(sorted(s.strip().upper() for s in severity.split(","))) - return hashlib.md5(f"{image_name}|{normalized_severity}".encode()).hexdigest() + return hashlib.md5(f"{image_id}|{normalized_severity}|{extra}".encode()).hexdigest() + + def _is_expired(self, entry: Dict) -> bool: + try: + entry_time = datetime.fromisoformat(entry.get("timestamp", "")) + except (ValueError, TypeError): + return True + from datetime import timedelta + return datetime.now() - entry_time > timedelta(hours=self.ttl_hours) - def get(self, image_name: str, severity: str = "CRITICAL,HIGH") -> Optional[Dict]: - """Get cached results for an image scanned at a given severity.""" - key = self.get_key(image_name, severity) - return self.cache.get(key) + def get(self, image_id: str, severity: str = "CRITICAL,HIGH", extra: str = "") -> Optional[Dict]: + """Get cached results for an image scanned at a given severity. - def set(self, image_name: str, results: Dict, severity: str = "CRITICAL,HIGH") -> None: + Entries older than the TTL are dropped and treated as a miss. + """ + key = self.get_key(image_id, severity, extra) + entry = self.cache.get(key) + if entry is None: + return None + if self._is_expired(entry): + del self.cache[key] + self._save_cache() + return None + return entry + + def set(self, image_id: str, results: Dict, severity: str = "CRITICAL,HIGH", extra: str = "") -> None: """Cache scan results for an image scanned at a given severity.""" - key = self.get_key(image_name, severity) + key = self.get_key(image_id, severity, extra) self.cache[key] = { - "image": image_name, + "image": image_id, "severity": severity, "timestamp": datetime.now().isoformat(), "results": results @@ -255,6 +287,7 @@ def __init__(self, dockerfile_path: Optional[str], image_name: Optional[str], re try: from docksec.enums import LLMProvider from docksec.config_manager import get_config + from docksec.config import docker_score_prompt config = get_config() provider = config.llm_provider llm = get_llm() @@ -323,6 +356,38 @@ def __init__(self, dockerfile_path: Optional[str], image_name: Optional[str], re raise ValueError( "Docker command not found. Please ensure Docker is installed and accessible in your PATH." ) + def _cache_image_id(self) -> str: + """Resolve the image's content digest/ID for cache keying. + + Using the image ID (not the tag) means a rebuilt tag such as a reused + :latest is a cache miss instead of silently serving stale findings. + Falls back to the image name if the ID cannot be resolved. + """ + if not self.image_name: + return "no-image" + try: + result = subprocess.run( + ['docker', 'image', 'inspect', '-f', '{{.Id}}', self.image_name], + capture_output=True, text=True, timeout=30, shell=False + ) + image_id = (result.stdout or "").strip() + if result.returncode == 0 and image_id: + return image_id + except Exception: + pass + return self.image_name + + def _dockerfile_fingerprint(self) -> str: + """Content hash of the Dockerfile so cached full-scan results are never + reused across different Dockerfiles that share an image.""" + if not self.dockerfile_path: + return "no-dockerfile" + try: + with open(self.dockerfile_path, 'rb') as f: + return hashlib.md5(f.read()).hexdigest() + except OSError: + return f"unreadable:{self.dockerfile_path}" + def run_image_only_scan(self, severity: str = "CRITICAL,HIGH") -> Dict: """ Run image-only security scan without Dockerfile analysis. @@ -336,12 +401,13 @@ def run_image_only_scan(self, severity: str = "CRITICAL,HIGH") -> Dict: # Validate severity input severity = self._validate_severity(severity) - # Check cache first + # Check cache first (keyed by image digest so rebuilt tags miss) + cache_id = self._cache_image_id() if self.use_cache else None if self.use_cache: - cached = self.cache.get(self.image_name, severity) + cached = self.cache.get(cache_id, severity) if cached: ui.info(f"Using cached scan results for {self.image_name} (scanned at {cached.get('timestamp', 'N/A')})") - ui.detail("Tip: set DOCKSEC_USE_CACHE=false to bypass the cache") + ui.detail("Tip: use --no-cache (or DOCKSEC_USE_CACHE=false) to bypass the cache") return cached.get('results', {}) logger.info(f"Starting image-only scan for {self.image_name}") @@ -375,7 +441,7 @@ def run_image_only_scan(self, severity: str = "CRITICAL,HIGH") -> Dict: # Cache results if self.use_cache: - self.cache.set(self.image_name, results, severity) + self.cache.set(cache_id, results, severity) # Print final summary if not json_data: @@ -787,12 +853,18 @@ def run_full_scan(self, severity: str = "CRITICAL,HIGH") -> Dict: # Validate severity input severity = self._validate_severity(severity) - # Check cache first (only if image name is provided) + # Check cache first (only if image name is provided). The key includes + # the image digest and the Dockerfile content hash so cached results + # are never reused for a rebuilt tag or a different Dockerfile. + cache_id = None + dockerfile_fp = None if self.image_name and self.use_cache: - cached = self.cache.get(self.image_name, severity) + cache_id = self._cache_image_id() + dockerfile_fp = self._dockerfile_fingerprint() + cached = self.cache.get(cache_id, severity, extra=dockerfile_fp) if cached: ui.info(f"Using cached scan results for {self.image_name} (scanned at {cached.get('timestamp', 'N/A')})") - ui.detail("Tip: set DOCKSEC_USE_CACHE=false to bypass the cache") + ui.detail("Tip: use --no-cache (or DOCKSEC_USE_CACHE=false) to bypass the cache") return cached.get('results', {}) scan_status = True @@ -840,7 +912,7 @@ def run_full_scan(self, severity: str = "CRITICAL,HIGH") -> Dict: # Cache results if self.use_cache: - self.cache.set(self.image_name, results, severity) + self.cache.set(cache_id, results, severity, extra=dockerfile_fp) # Print final summary target_name = self.image_name if self.image_name else self.dockerfile_path diff --git a/docksec/ignore.py b/docksec/ignore.py new file mode 100644 index 0000000..d4da93f --- /dev/null +++ b/docksec/ignore.py @@ -0,0 +1,97 @@ +"""Targeted, auditable suppression of findings via an ignore file. + +Baseline mode (--baseline) snapshots everything at a point in time; the ignore +file is the complement: an explicit, reviewable list of individual findings a +team has triaged and accepted, each with a reason and an optional expiry date. + +File format (.docksec-ignore.yml, or any path via --ignore-file): + + ignores: + - id: CVE-2023-45853 # Trivy vulnerability ID or DockSec rule ID + reason: "zlib CVE; not reachable, vendor fix pending" + expires: 2026-12-31 # optional; entry stops applying after this date + - id: compose-missing-healthcheck + reason: "healthchecks handled by the platform" + +Suppressed findings are removed before scoring, reports, JSON output, and the +--fail-on gate. Entries match on VulnerabilityID (case-insensitive). +""" + +import os +from datetime import date, datetime +from typing import Dict, List, Optional, Tuple + +from docksec.utils import get_custom_logger + +logger = get_custom_logger(__name__) + +DEFAULT_IGNORE_FILE = ".docksec-ignore.yml" + + +def find_default_ignore_file(directory: Optional[str] = None) -> Optional[str]: + """Return the default ignore file path if one exists in the directory.""" + path = os.path.join(directory or os.getcwd(), DEFAULT_IGNORE_FILE) + return path if os.path.isfile(path) else None + + +def load_ignore_file(path: str) -> Tuple[List[Dict], List[str]]: + """Load and validate an ignore file. + + Returns (active_entries, warnings). Malformed or expired entries are + dropped and reported as warnings rather than failing the scan. + """ + from ruamel.yaml import YAML + + warnings: List[str] = [] + try: + with open(path, 'r', encoding='utf-8') as f: + data = YAML(typ='safe').load(f) + except FileNotFoundError: + return [], [f"Ignore file not found: {path}"] + except Exception as e: + return [], [f"Failed to parse ignore file {path}: {e}"] + + if not isinstance(data, dict) or not isinstance(data.get('ignores'), list): + return [], [f"Ignore file {path} must contain a top-level 'ignores' list"] + + active: List[Dict] = [] + for i, entry in enumerate(data['ignores'], start=1): + if not isinstance(entry, dict) or not entry.get('id'): + warnings.append(f"Ignore entry {i} has no 'id'; skipped") + continue + entry_id = str(entry['id']).strip() + if not entry.get('reason'): + warnings.append(f"Ignore entry '{entry_id}' has no 'reason'; add one for auditability") + + expires = entry.get('expires') + if expires is not None: + expiry = _parse_date(expires) + if expiry is None: + warnings.append(f"Ignore entry '{entry_id}' has invalid 'expires' date '{expires}'; entry skipped") + continue + if expiry < date.today(): + warnings.append(f"Ignore entry '{entry_id}' expired on {expiry.isoformat()}; no longer applied") + continue + + active.append({'id': entry_id, 'reason': entry.get('reason'), 'expires': expires}) + + return active, warnings + + +def _parse_date(value) -> Optional[date]: + if isinstance(value, date): + return value + try: + return datetime.strptime(str(value).strip(), "%Y-%m-%d").date() + except ValueError: + return None + + +def apply_ignores(findings: List[Dict], entries: List[Dict]) -> Tuple[List[Dict], int]: + """Filter suppressed findings out. Returns (kept_findings, suppressed_count).""" + if not entries: + return findings, 0 + ignored_ids = {e['id'].upper() for e in entries} + kept = [f for f in findings + if str(f.get('VulnerabilityID', '')).upper() not in ignored_ids] + return kept, len(findings) - len(kept) diff --git a/docksec/redact.py b/docksec/redact.py new file mode 100644 index 0000000..02e752c --- /dev/null +++ b/docksec/redact.py @@ -0,0 +1,109 @@ +"""Redact secret-looking values from file content before it is sent to an LLM. + +DockSec's AI pass sends Dockerfile / compose file content to the configured +LLM provider. Files under analysis frequently contain the very credentials +DockSec is meant to flag, so by default the values (never the keys) of +secret-looking assignments are masked before the content leaves the machine. +The key names stay visible, which is all the model needs to flag an exposed +credential; the secret material itself is replaced with a marker. + +Disable with --no-redact (e.g. when scanning files known to hold only dummy +values and full-fidelity analysis is preferred). +""" + +import re +from typing import Tuple + +REDACTED = "[REDACTED-BY-DOCKSEC]" + +# Key names that suggest the assigned value is a secret. +_SECRET_KEY = re.compile( + r"(password|passwd|pwd|secret|token|api[_-]?key|apikey|access[_-]?key|" + r"private[_-]?key|credential|auth)", + re.IGNORECASE, +) + +# Values that look like secret material regardless of the key name. +_SECRET_VALUE_PATTERNS = [ + re.compile(r"AKIA[0-9A-Z]{16}"), # AWS access key id + re.compile(r"ghp_[A-Za-z0-9]{36,}"), # GitHub personal access token + re.compile(r"gho_[A-Za-z0-9]{36,}"), # GitHub OAuth token + re.compile(r"github_pat_[A-Za-z0-9_]{22,}"), # GitHub fine-grained PAT + re.compile(r"xox[baprs]-[A-Za-z0-9-]{10,}"), # Slack token + re.compile(r"sk-[A-Za-z0-9_-]{20,}"), # OpenAI-style API key + re.compile(r"eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{5,}"), # JWT +] + +_PRIVATE_KEY_BLOCK = re.compile( + r"-----BEGIN [A-Z ]*PRIVATE KEY-----.*?(?:-----END [A-Z ]*PRIVATE KEY-----|\Z)", + re.DOTALL, +) + +# KEY=value pairs (Dockerfile ENV/ARG, compose list-style environment, .env +# lines). Values may be quoted; interpolations like ${VAR} are left alone. +_ASSIGN_EQ = re.compile( + r"(?P[A-Za-z_][A-Za-z0-9_.-]*)=(?P\"[^\"]*\"|'[^']*'|[^\s]+)" +) + +# YAML mapping style: KEY: value (compose dict-style environment). +_ASSIGN_COLON = re.compile( + r"^(?P\s*-?\s*)(?P[A-Za-z_][A-Za-z0-9_.-]*)\s*:\s+(?P\"[^\"]*\"|'[^']*'|\S[^#\n]*)$" +) + +# Dockerfile space form: ENV KEY value +_ENV_SPACE = re.compile( + r"^(?P\s*(?:ENV|ARG)\s+)(?P[A-Za-z_][A-Za-z0-9_.-]*)\s+(?P[^=\s].*)$", + re.IGNORECASE, +) + + +def _is_placeholder(value: str) -> bool: + """Interpolations and empty values carry no secret material.""" + stripped = value.strip().strip("\"'") + return not stripped or stripped.startswith("${") or stripped.startswith("$(") + + +def redact_content(content: str) -> Tuple[str, int]: + """Mask secret-looking values in content. Returns (redacted_text, count).""" + count = 0 + + def _sub_private_key(match: re.Match) -> str: + nonlocal count + count += 1 + return REDACTED + + content = _PRIVATE_KEY_BLOCK.sub(_sub_private_key, content) + + def _sub_eq(match: re.Match) -> str: + nonlocal count + key, val = match.group("key"), match.group("val") + if _SECRET_KEY.search(key) and not _is_placeholder(val): + count += 1 + return f"{key}={REDACTED}" + return match.group(0) + + lines = [] + for line in content.split("\n"): + original = line + line = _ASSIGN_EQ.sub(_sub_eq, line) + + if line == original: + m = _ASSIGN_COLON.match(line) + if m and _SECRET_KEY.search(m.group("key")) and not _is_placeholder(m.group("val")): + count += 1 + line = f"{m.group('lead')}{m.group('key')}: {REDACTED}" + + if line == original: + m = _ENV_SPACE.match(line) + if m and _SECRET_KEY.search(m.group("key")) and not _is_placeholder(m.group("val")): + count += 1 + line = f"{m.group('lead')}{m.group('key')} {REDACTED}" + + # Value-shaped secrets (AWS keys, PATs, JWTs, ...) regardless of key name. + for pattern in _SECRET_VALUE_PATTERNS: + line, n = pattern.subn(REDACTED, line) + count += n + + lines.append(line) + + return "\n".join(lines), count diff --git a/docksec/score_calculator.py b/docksec/score_calculator.py index 90835e6..5790922 100644 --- a/docksec/score_calculator.py +++ b/docksec/score_calculator.py @@ -7,7 +7,6 @@ import re from typing import Dict -from docksec.config import docker_score_prompt from docksec.enums import Severity from docksec.utils import ScoreResponse, get_llm, get_custom_logger @@ -35,6 +34,7 @@ def __init__(self, skip_llm: bool = False): from docksec.enums import LLMProvider from docksec.config_manager import get_config + from docksec.config import docker_score_prompt config = get_config() provider = config.llm_provider llm = get_llm() diff --git a/docksec/utils.py b/docksec/utils.py index b61101a..8c5b28a 100644 --- a/docksec/utils.py +++ b/docksec/utils.py @@ -3,16 +3,15 @@ import os from typing import Union, List, Optional, Dict -# Import OpenAI (required) +# All LLM providers are optional: the core (scan-only) install ships without +# the AI dependency stack. Install AI support with: pip install "docksec[ai]" try: from langchain_openai import ChatOpenAI -except ImportError as e: - raise ImportError( - f"Failed to import langchain_openai: {e}. " - "Please install: pip install langchain-openai" - ) + OPENAI_AVAILABLE = True +except ImportError: + ChatOpenAI = None + OPENAI_AVAILABLE = False -# Import optional providers try: from langchain_anthropic import ChatAnthropic ANTHROPIC_AVAILABLE = True @@ -46,20 +45,6 @@ ) from colorama import init from rich.console import Console -from tenacity import ( - retry, - stop_after_attempt, - wait_exponential, - retry_if_exception_type, - before_sleep_log, - after_log -) -from openai import ( - APIError, - RateLimitError, - APIConnectionError, - APITimeoutError -) def get_custom_logger(name: str = 'Docksec', user_facing: bool = False): logger = logging.getLogger(name) @@ -138,22 +123,7 @@ class AnalyzesResponse(BaseModel): class ScoreResponse(BaseModel): score: float = Field(description="Security score for the Dockerfile") -@retry( - stop=stop_after_attempt(3), - wait=wait_exponential(multiplier=1, min=2, max=10), - retry=retry_if_exception_type((APIError, APIConnectionError, APITimeoutError, RateLimitError)), - before_sleep=before_sleep_log(logger, logging.WARNING), - after=after_log(logger, logging.INFO) -) -def _call_llm_with_retry(llm, *args, **kwargs): - """ - Internal function to call LLM with retry logic. - Retries on transient errors with exponential backoff. - """ - return llm.invoke(*args, **kwargs) - - -def get_llm() -> Union[ChatOpenAI, 'ChatAnthropic', 'ChatGoogleGenerativeAI', 'ChatOllama']: +def get_llm() -> Union['ChatOpenAI', 'ChatAnthropic', 'ChatGoogleGenerativeAI', 'ChatOllama']: """ Get LLM instance with retry logic and rate limiting support. @@ -194,6 +164,12 @@ def get_llm() -> Union[ChatOpenAI, 'ChatAnthropic', 'ChatGoogleGenerativeAI', 'C logger.info(f"Initializing LLM with provider: {provider}, model: {model}") if provider == LLMProvider.OPENAI: + if not OPENAI_AVAILABLE: + raise ImportError( + "AI analysis requested but the AI dependencies are not installed. " + "Install them with: pip install \"docksec[ai]\" " + "(or use --scan-only for local scanning without AI)" + ) api_key = config.get_api_key_for_provider() if not os.getenv("OPENAI_API_KEY"): os.environ["OPENAI_API_KEY"] = api_key @@ -211,7 +187,7 @@ def get_llm() -> Union[ChatOpenAI, 'ChatAnthropic', 'ChatGoogleGenerativeAI', 'C if not ANTHROPIC_AVAILABLE: raise ImportError( "Anthropic provider requested but langchain-anthropic is not installed. " - "Install it with: pip install langchain-anthropic" + "Install it with: pip install langchain-anthropic (or: pip install \"docksec[ai]\")" ) api_key = config.get_api_key_for_provider() if not os.getenv("ANTHROPIC_API_KEY"): @@ -234,7 +210,7 @@ def get_llm() -> Union[ChatOpenAI, 'ChatAnthropic', 'ChatGoogleGenerativeAI', 'C if not GOOGLE_AVAILABLE: raise ImportError( "Google provider requested but langchain-google-genai is not installed. " - "Install it with: pip install langchain-google-genai" + "Install it with: pip install langchain-google-genai (or: pip install \"docksec[ai]\")" ) api_key = config.get_api_key_for_provider() if not os.getenv("GOOGLE_API_KEY"): @@ -257,7 +233,7 @@ def get_llm() -> Union[ChatOpenAI, 'ChatAnthropic', 'ChatGoogleGenerativeAI', 'C if not OLLAMA_AVAILABLE: raise ImportError( "Ollama provider requested but langchain-ollama is not installed. " - "Install it with: pip install langchain-ollama" + "Install it with: pip install langchain-ollama (or: pip install \"docksec[ai]\")" ) llm = ChatOllama( model=model, diff --git a/requirements.txt b/requirements.txt index b561ebc..70f46fc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,27 +1,16 @@ -# Core dependencies - pinned to specific versions for fast, reliable installs -pydantic==2.13.4 -langchain-core==1.3.3 -langchain==1.2.18 -langchain-openai==1.2.1 -langchain-anthropic==1.4.3 -langchain-google-genai==4.2.2 -langchain-ollama==1.1.0 -python-dotenv==1.2.2 -pandas==3.0.3 - -# UI and progress -tqdm==4.67.3 -colorama==0.4.6 -rich==15.0.0 - -# PDF generation -fpdf2==2.8.7 - -# Retry logic -tenacity==9.1.4 - -# YAML parsing +# Core dependencies (scan-only install, no LLM stack) +pydantic>=2.13,<3 +python-dotenv>=1.0,<2 +colorama>=0.4.6,<1 +rich>=13.0,<16 +fpdf2>=2.8,<3 ruamel.yaml>=0.18.6 +setuptools>=82.0.1 -# Build tools -setuptools>=82.0.1 \ No newline at end of file +# AI analysis dependencies (the docksec[ai] extra) +langchain-core>=1.3,<2 +langchain>=1.2,<2 +langchain-openai>=1.2,<2 +langchain-anthropic>=1.4,<2 +langchain-google-genai>=4.2,<5 +langchain-ollama>=1.1,<2 diff --git a/setup.py b/setup.py index fd70696..102babb 100644 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ setup( name="docksec", - version="2026.7.3", + version="2026.7.4", description="AI-Powered Docker Security Analyzer", long_description=long_description, long_description_content_type="text/markdown", @@ -23,24 +23,27 @@ "Source Code": "https://github.com/OWASP/DockSec", }, python_requires=">=3.12", + # Core install is scan-only (Trivy/Hadolint wrappers, reports, scoring) + # with no LLM stack. AI analysis needs the [ai] extra. install_requires=[ - "pydantic==2.13.4", - "langchain-core==1.3.3", - "langchain==1.2.18", - "langchain-openai==1.2.1", - "langchain-anthropic==1.4.3", - "langchain-google-genai==4.2.2", - "langchain-ollama==1.1.0", - "python-dotenv==1.2.2", - "pandas==3.0.3", - "tqdm==4.67.3", - "colorama==0.4.6", - "rich==15.0.0", - "fpdf2==2.8.7", - "tenacity==9.1.4", + "pydantic>=2.13,<3", + "python-dotenv>=1.0,<2", + "colorama>=0.4.6,<1", + "rich>=13.0,<16", + "fpdf2>=2.8,<3", "setuptools>=65.0.0", "ruamel.yaml>=0.18.6", ], + extras_require={ + "ai": [ + "langchain-core>=1.3,<2", + "langchain>=1.2,<2", + "langchain-openai>=1.2,<2", + "langchain-anthropic>=1.4,<2", + "langchain-google-genai>=4.2,<5", + "langchain-ollama>=1.1,<2", + ], + }, classifiers=[ "Programming Language :: Python :: 3", "License :: OSI Approved :: MIT License", diff --git a/tests/test_compose_scanner.py b/tests/test_compose_scanner.py index c25e386..c540b37 100644 --- a/tests/test_compose_scanner.py +++ b/tests/test_compose_scanner.py @@ -56,6 +56,7 @@ def vulnerable_compose_file(tmp_path): image: nginx:latest ports: - "80:80" + - "3306:3306" privileged: true volumes: - /var/run/docker.sock:/var/run/docker.sock @@ -102,18 +103,46 @@ def test_compose_scanner_vulnerable(vulnerable_compose_file): assert "compose-plaintext-secret-env" in finding_ids assert "compose-port-bound-all-interfaces" in finding_ids assert "compose-disabled-security-opt" in finding_ids - assert "compose-no-non-root-user" in finding_ids - + # MEDIUM + assert "compose-no-non-root-user" in finding_ids assert "compose-latest-or-untagged-image" in finding_ids + + # LOW assert "compose-no-resource-limits" in finding_ids assert "compose-writable-root-fs" in finding_ids - - # LOW assert "compose-no-new-privileges" in finding_ids assert "compose-missing-healthcheck" in finding_ids assert "compose-no-network-segmentation" in finding_ids + # The port rule flags only sensitive ports: 3306 fires, plain HTTP 80 + # does not. + port_findings = [f for f in findings if f['VulnerabilityID'] == 'compose-port-bound-all-interfaces'] + assert len(port_findings) == 1 + assert "3306" in port_findings[0]['Description'] + + +def test_port_rule_sensitivity(tmp_path): + compose_content = """ +services: + app: + image: myapp:1.0 + ports: + - "8080:80" + - "6379" + - "127.0.0.1:5432:5432" +""" + p = tmp_path / "docker-compose.yml" + p.write_text(compose_content) + scanner = ComposeScanner(str(p)) + assert scanner.parse() is True + findings = scanner.scan() + port_findings = [f for f in findings if f['VulnerabilityID'] == 'compose-port-bound-all-interfaces'] + # 8080:80 is a plain web port (not flagged); bare "6379" binds 0.0.0.0 + # (flagged); 127.0.0.1:5432 is localhost-only (not flagged). + assert len(port_findings) == 1 + assert "6379" in port_findings[0]['Description'] + def test_line_numbers(vulnerable_compose_file): scanner = ComposeScanner(vulnerable_compose_file) scanner.parse() diff --git a/tests/test_docker_scanner.py b/tests/test_docker_scanner.py index 54be10c..d3728ea 100644 --- a/tests/test_docker_scanner.py +++ b/tests/test_docker_scanner.py @@ -261,6 +261,46 @@ def test_scan_results_cache_get_key(self): finally: shutil.rmtree(temp_dir) + def test_scan_results_cache_extra_identity(self): + """Different extra identity (e.g. Dockerfile hash) means a cache miss.""" + from docksec.docker_scanner import ScanResultsCache + import tempfile + import shutil + + temp_dir = tempfile.mkdtemp() + try: + cache = ScanResultsCache(temp_dir) + cache.set("sha256:abc", {"image": "test"}, extra="dockerfile-hash-1") + + self.assertIsNotNone(cache.get("sha256:abc", extra="dockerfile-hash-1")) + self.assertIsNone(cache.get("sha256:abc", extra="dockerfile-hash-2")) + self.assertIsNone(cache.get("sha256:abc")) + finally: + shutil.rmtree(temp_dir) + + def test_scan_results_cache_ttl_expiry(self): + """Entries older than the TTL are treated as a miss and evicted.""" + from docksec.docker_scanner import ScanResultsCache + from datetime import datetime, timedelta + import tempfile + import shutil + + temp_dir = tempfile.mkdtemp() + try: + cache = ScanResultsCache(temp_dir) + cache.set("sha256:abc", {"image": "test"}) + + # Age the entry beyond the TTL + key = cache.get_key("sha256:abc") + old = datetime.now() - timedelta(hours=cache.ttl_hours + 1) + cache.cache[key]["timestamp"] = old.isoformat() + cache._save_cache() + + self.assertIsNone(cache.get("sha256:abc")) + self.assertNotIn(key, cache.cache) + finally: + shutil.rmtree(temp_dir) + def test_scan_results_cache_persistence(self): """Test ScanResultsCache persistence to disk.""" from docksec.docker_scanner import ScanResultsCache diff --git a/tests/test_ignore.py b/tests/test_ignore.py new file mode 100644 index 0000000..aa26482 --- /dev/null +++ b/tests/test_ignore.py @@ -0,0 +1,76 @@ +from docksec.ignore import ( + apply_ignores, + find_default_ignore_file, + load_ignore_file, + DEFAULT_IGNORE_FILE, +) + + +def _write(tmp_path, content, name=DEFAULT_IGNORE_FILE): + p = tmp_path / name + p.write_text(content) + return str(p) + + +def test_load_valid_ignore_file(tmp_path): + path = _write(tmp_path, ( + "ignores:\n" + " - id: CVE-2023-45853\n" + " reason: not reachable\n" + " - id: compose-missing-healthcheck\n" + " reason: platform handles healthchecks\n" + " expires: 2099-12-31\n" + )) + entries, warnings = load_ignore_file(path) + assert len(entries) == 2 + assert warnings == [] + + +def test_expired_entry_dropped_with_warning(tmp_path): + path = _write(tmp_path, ( + "ignores:\n" + " - id: CVE-2020-0001\n" + " reason: old waiver\n" + " expires: 2020-01-01\n" + )) + entries, warnings = load_ignore_file(path) + assert entries == [] + assert any("expired" in w for w in warnings) + + +def test_missing_reason_warns_but_applies(tmp_path): + path = _write(tmp_path, "ignores:\n - id: CVE-2024-1111\n") + entries, warnings = load_ignore_file(path) + assert len(entries) == 1 + assert any("reason" in w for w in warnings) + + +def test_invalid_file_shape(tmp_path): + path = _write(tmp_path, "not_ignores: []\n") + entries, warnings = load_ignore_file(path) + assert entries == [] + assert len(warnings) == 1 + + +def test_apply_ignores_filters_case_insensitive(): + findings = [ + {"VulnerabilityID": "CVE-2024-1111", "Severity": "HIGH"}, + {"VulnerabilityID": "cve-2024-2222", "Severity": "LOW"}, + {"VulnerabilityID": "CVE-2024-3333", "Severity": "CRITICAL"}, + ] + entries = [{"id": "cve-2024-1111"}, {"id": "CVE-2024-2222"}] + kept, suppressed = apply_ignores(findings, entries) + assert suppressed == 2 + assert [f["VulnerabilityID"] for f in kept] == ["CVE-2024-3333"] + + +def test_apply_ignores_noop_without_entries(): + findings = [{"VulnerabilityID": "CVE-2024-1111"}] + kept, suppressed = apply_ignores(findings, []) + assert kept == findings and suppressed == 0 + + +def test_find_default_ignore_file(tmp_path): + assert find_default_ignore_file(str(tmp_path)) is None + _write(tmp_path, "ignores: []\n") + assert find_default_ignore_file(str(tmp_path)) is not None diff --git a/tests/test_redact.py b/tests/test_redact.py new file mode 100644 index 0000000..64ff5a9 --- /dev/null +++ b/tests/test_redact.py @@ -0,0 +1,73 @@ +from docksec.redact import redact_content, REDACTED + + +def test_redacts_dockerfile_env_equals(): + content = 'FROM python:3.12\nENV DB_PASSWORD=hunter2\nENV APP_NAME=myapp\n' + redacted, count = redact_content(content) + assert count == 1 + assert 'hunter2' not in redacted + assert f'DB_PASSWORD={REDACTED}' in redacted + assert 'APP_NAME=myapp' in redacted + + +def test_redacts_dockerfile_env_space_form(): + content = 'ENV API_KEY abc123secretvalue\n' + redacted, count = redact_content(content) + assert count == 1 + assert 'abc123secretvalue' not in redacted + assert 'API_KEY' in redacted + + +def test_redacts_arg_and_multiple_pairs(): + content = 'ARG GITHUB_TOKEN=ghx123\nENV A=1 SECRET_KEY=s3cr3t B=2\n' + redacted, count = redact_content(content) + assert count == 2 + assert 'ghx123' not in redacted + assert 's3cr3t' not in redacted + assert 'A=1' in redacted and 'B=2' in redacted + + +def test_redacts_compose_environment_styles(): + content = ( + 'services:\n' + ' db:\n' + ' environment:\n' + ' - MYSQL_ROOT_PASSWORD=secret\n' + ' POSTGRES_PASSWORD: alsosecret\n' + ) + redacted, count = redact_content(content) + assert count == 2 + assert 'secret' not in redacted.replace(REDACTED, '') + + +def test_leaves_interpolations_alone(): + content = 'ENV DB_PASSWORD=${DB_PASSWORD}\n' + redacted, count = redact_content(content) + assert count == 0 + assert redacted == content + + +def test_redacts_value_shaped_secrets_regardless_of_key(): + content = 'RUN aws configure set aws_access_key_id AKIAIOSFODNN7EXAMPLE\n' + redacted, count = redact_content(content) + assert count >= 1 + assert 'AKIAIOSFODNN7EXAMPLE' not in redacted + + +def test_redacts_private_key_block(): + content = ( + 'COPY key.pem /app\n' + '-----BEGIN RSA PRIVATE KEY-----\n' + 'MIIEpAIBAAKCAQEA\n' + '-----END RSA PRIVATE KEY-----\n' + ) + redacted, count = redact_content(content) + assert count == 1 + assert 'MIIEpAIBAAKCAQEA' not in redacted + + +def test_no_secrets_no_change(): + content = 'FROM alpine:3.19\nRUN apk add --no-cache curl\nUSER nobody\n' + redacted, count = redact_content(content) + assert count == 0 + assert redacted == content From 61ccd19d3c4e58acbd9d95674b4326bc39692e81 Mon Sep 17 00:00:00 2001 From: Advait Patel Date: Fri, 17 Jul 2026 19:09:58 -0500 Subject: [PATCH 2/3] Output polish Output polish: image-only runs get the correct AI hint, waived findings are surfaced in the terminal summary, --json, and HTML report, the HTML report gains a score rating badge and a "Fixed In" column, and the Trivy spinner no longer garbles CI logs. --- CHANGELOG.md | 14 +++++++ docksec/cli.py | 16 ++++++- docksec/docker_scanner.py | 4 +- docksec/report_generator.py | 58 ++++++++++++++++++++++---- docksec/templates/report_template.html | 35 ++++++++++++++++ tests/test_cli.py | 17 ++++++++ tests/test_report_generator.py | 53 +++++++++++++++++++++++ 7 files changed, 187 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 69807ce..5211af5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,13 @@ slimmer install. support; the base `pip install docksec` is now a slim, scan-only core with no LLM dependencies. +- HTML report improvements: a rating badge (Excellent/Good/Fair/Poor) next to the + security score matching the terminal bands, a "Fixed In" column in the + vulnerability table, a "fix available" summary line, and a note showing how many + findings were waived via the ignore file. Waiver information also appears in the + terminal Quick take and in `--json` output (`scan_info.suppressed_count`, + `scan_info.ignore_file`). + ### Changed - AI analysis input limits raised from 50 lines / 2,000 characters to 400 lines / @@ -48,6 +55,13 @@ slimmer install. ### Fixed +- The Quick take in `--image-only` runs suggested removing `--scan-only` (the wrong + flag for that mode); it now suggests adding a Dockerfile scan. +- The Trivy progress spinner no longer prints a half-drawn progress bar into + non-terminal output such as CI logs. +- The Dockerfile scan block in the HTML report used a hardcoded light background + that was unreadable in dark mode; it now follows the report theme. + - A narrower cached scan could previously be reused in situations where the image had been rebuilt under the same tag; digest keying fixes this class of stale results. diff --git a/docksec/cli.py b/docksec/cli.py index 4b66ba3..51ff641 100644 --- a/docksec/cli.py +++ b/docksec/cli.py @@ -423,6 +423,10 @@ def main() -> None: results.get("json_data", []), ignore_entries) if suppressed_count: results["json_data"] = kept_findings + # Recorded so the reports and JSON payload can state that + # findings were waived rather than silently absent. + results["suppressed_count"] = suppressed_count + results["ignore_file"] = ignore_path output.info( f"{suppressed_count} finding(s) suppressed by ignore file {ignore_path}" ) @@ -614,6 +618,9 @@ def _print_json_results(results, scanner, report_paths): "vulnerabilities": vulnerabilities, "severity_counts": output.count_by_severity(vulnerabilities), } + if results.get("suppressed_count"): + payload["scan_info"]["suppressed_count"] = results["suppressed_count"] + payload["scan_info"]["ignore_file"] = results.get("ignore_file") if "ai_findings" in results: payload["ai_analysis"] = results["ai_findings"] if report_paths: @@ -668,8 +675,15 @@ def _quick_take_lines(results, counts, run_ai): if exposed: lines.append(f"{len(exposed)} likely exposed credential(s) flagged by AI analysis") + suppressed = results.get("suppressed_count") + if suppressed: + lines.append(f"{suppressed} triaged finding(s) suppressed via ignore file") + if not run_ai and not results.get("ai_findings"): - lines.append("Run without --scan-only to add AI-powered explanations and fixes") + if results.get("scan_mode") == "image_only": + lines.append("Add a Dockerfile scan for AI-powered explanations and fixes: docksec -i ") + else: + lines.append("Run without --scan-only to add AI-powered explanations and fixes") return lines diff --git a/docksec/docker_scanner.py b/docksec/docker_scanner.py index e3b4e80..e8dc64f 100644 --- a/docksec/docker_scanner.py +++ b/docksec/docker_scanner.py @@ -626,7 +626,9 @@ def scan_image_json(self, severity: str = "CRITICAL,HIGH") -> Tuple[bool, Option # honors --json (which redirects human output to stderr) and # never pollutes the machine-readable stdout payload. console=ui.get_console(), - disable=ui.is_quiet(), + # No live spinner when quiet or when output is not a terminal + # (CI logs would otherwise capture a half-drawn progress bar). + disable=ui.is_quiet() or not ui.get_console().is_terminal, ) as progress: scan_task = progress.add_task( f"[cyan]Scanning {self.image_name}...", diff --git a/docksec/report_generator.py b/docksec/report_generator.py index 1642a44..4e51833 100644 --- a/docksec/report_generator.py +++ b/docksec/report_generator.py @@ -742,13 +742,28 @@ def _prepare_html_template_vars(self, results: Dict) -> Dict[str, str]: ), } - # Security Score Section + # Security Score Section (rating bands match the terminal summary in + # docksec.output._score_band) + score_rating_html = "" + if self.analysis_score is not None: + score = float(self.analysis_score) + if score >= 90: + rating, rating_class = "Excellent", "rating-excellent" + elif score >= 70: + rating, rating_class = "Good", "rating-good" + elif score >= 50: + rating, rating_class = "Fair", "rating-fair" + else: + rating, rating_class = "Poor", "rating-poor" + score_rating_html = f'
{rating}
' + template_vars["SECURITY_SCORE_SECTION"] = f"""

Security Score

Overall Security Score
-
{self.analysis_score if self.analysis_score else 'N/A'}/100
+
{self.analysis_score if self.analysis_score is not None else 'N/A'}/100
+ {score_rating_html}
""" @@ -836,7 +851,7 @@ def _prepare_html_template_vars(self, results: Dict) -> Dict[str, str]: ) else: dockerfile_output = results["dockerfile_scan"].get("output", "") - dockerfile_content = f'
{self._escape_html(dockerfile_output[:2000])}
' + dockerfile_content = f'
{self._escape_html(dockerfile_output[:2000])}
' if len(dockerfile_output) > 2000: dockerfile_content += ( "

Output truncated for display...

" @@ -853,9 +868,15 @@ def _prepare_html_template_vars(self, results: Dict) -> Dict[str, str]: # Vulnerability Summary if not vulnerabilities: - template_vars["VULNERABILITY_SUMMARY"] = ( - '
No vulnerabilities found
' - ) + no_issues_html = '
No vulnerabilities found
' + suppressed = results.get("suppressed_count") + if suppressed: + ignore_file = self._escape_html(str(results.get("ignore_file", ""))) + no_issues_html += ( + f"

Waived: {suppressed} triaged finding(s) " + f"suppressed via ignore file {ignore_file}

" + ) + template_vars["VULNERABILITY_SUMMARY"] = no_issues_html template_vars["DETAILED_VULNERABILITIES_SECTION"] = "" else: severity_counts = self._count_by_severity(vulnerabilities) @@ -882,6 +903,20 @@ def _prepare_html_template_vars(self, results: Dict) -> Dict[str, str]:

Total vulnerabilities: {len(vulnerabilities)}

""" + fixable = sum(1 for v in vulnerabilities if v.get("FixedVersion")) + if fixable: + severity_html += ( + f"

Fix available: {fixable} of " + f"{len(vulnerabilities)} findings have a fixed version upstream

" + ) + suppressed = results.get("suppressed_count") + if suppressed: + ignore_file = self._escape_html(str(results.get("ignore_file", ""))) + severity_html += ( + f"

Waived: {suppressed} triaged finding(s) " + f"suppressed via ignore file {ignore_file}

" + ) + template_vars["VULNERABILITY_SUMMARY"] = severity_html # Detailed vulnerabilities table @@ -895,7 +930,8 @@ def _prepare_html_template_vars(self, results: Dict) -> Dict[str, str]: ID Severity Package - Version + Installed + Fixed In Title CVSS Status @@ -928,6 +964,11 @@ def _prepare_html_template_vars(self, results: Dict) -> Dict[str, str]: vuln_id = vuln.get('VulnerabilityID') or 'N/A' pkg_name = vuln.get('PkgName') or 'N/A' installed_version = vuln.get('InstalledVersion') or 'N/A' + fixed_version = vuln.get('FixedVersion') or '' + fixed_cell = ( + f'{self._escape_html(fixed_version)}' + if fixed_version else 'none yet' + ) title = vuln.get('Title') or 'N/A' display_title = (title[:80] + '...') if len(title) > 80 else title @@ -937,6 +978,7 @@ def _prepare_html_template_vars(self, results: Dict) -> Dict[str, str]: {vuln.get('Severity', 'N/A')} {self._escape_html(pkg_name)} {self._escape_html(installed_version)} + {fixed_cell} {self._escape_html(display_title)} {cvss_score} {status} @@ -950,7 +992,7 @@ def _prepare_html_template_vars(self, results: Dict) -> Dict[str, str]: """ if len(vulnerabilities) > 50: - table_html += f'

Showing 50 of {len(vulnerabilities)} vulnerabilities. See CSV/JSON for complete list.

' + table_html += f'

Showing 50 of {len(vulnerabilities)} vulnerabilities. See CSV/JSON for complete list.

' table_html += "" template_vars["DETAILED_VULNERABILITIES_SECTION"] = table_html diff --git a/docksec/templates/report_template.html b/docksec/templates/report_template.html index 7477902..4fef3a5 100644 --- a/docksec/templates/report_template.html +++ b/docksec/templates/report_template.html @@ -293,6 +293,41 @@ font-weight: 600; } + .score-rating { + display: inline-block; + margin-top: 10px; + padding: 4px 14px; + border-radius: 999px; + font-size: 0.8em; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; + } + + .rating-excellent { background: rgba(5, 150, 105, 0.14); color: var(--ok); } + .rating-good { background: rgba(5, 150, 105, 0.10); color: var(--ok); } + .rating-fair { background: rgba(217, 119, 6, 0.14); color: var(--medium); } + .rating-poor { background: rgba(220, 38, 38, 0.12); color: var(--critical); } + + .fixed-version { color: var(--ok); font-weight: 600; } + .no-fix { color: var(--text-muted); font-style: italic; } + + .table-note { + margin-top: 15px; + font-style: italic; + color: var(--text-muted); + } + + .mono-block { + background: var(--surface-alt); + border: 1px solid var(--border); + color: var(--text); + padding: 15px; + border-radius: 8px; + overflow-x: auto; + font-size: 0.9em; + } + .config-issues { margin-top: 8px; } .config-category { margin-bottom: 18px; } .config-category:last-child { margin-bottom: 0; } diff --git a/tests/test_cli.py b/tests/test_cli.py index 4ea11c8..b78b2a7 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -398,6 +398,23 @@ def test_quick_take_suggests_ai_when_scan_only(self): lines = _quick_take_lines(results, counts, run_ai=False) self.assertTrue(any("--scan-only" in line for line in lines)) + def test_quick_take_image_only_hint_does_not_mention_scan_only(self): + from docksec.cli import _quick_take_lines + + results = {"dockerfile_scan": {"skipped": True}, "scan_mode": "image_only"} + counts = {"CRITICAL": 0, "HIGH": 0, "MEDIUM": 0, "LOW": 0} + lines = _quick_take_lines(results, counts, run_ai=False) + self.assertFalse(any("--scan-only" in line for line in lines)) + self.assertTrue(any("Dockerfile" in line for line in lines)) + + def test_quick_take_reports_suppressed_findings(self): + from docksec.cli import _quick_take_lines + + results = {"dockerfile_scan": {"skipped": True}, "suppressed_count": 4} + counts = {"CRITICAL": 0, "HIGH": 0, "MEDIUM": 0, "LOW": 0} + lines = _quick_take_lines(results, counts, run_ai=True) + self.assertTrue(any("4 triaged finding(s) suppressed" in line for line in lines)) + def test_suggest_next_command_recommends_image_scan(self): from docksec.cli import _suggest_next_command diff --git a/tests/test_report_generator.py b/tests/test_report_generator.py index caed856..7b8755b 100644 --- a/tests/test_report_generator.py +++ b/tests/test_report_generator.py @@ -302,6 +302,59 @@ def test_html_ai_findings_are_escaped(tmp_path): assert "<script>" in content +def test_html_shows_score_rating_fixed_version_and_waivers(tmp_path): + vulns = [ + { + "VulnerabilityID": "CVE-2023-1234", + "Severity": "CRITICAL", + "PkgName": "openssl", + "InstalledVersion": "1.0.0", + "FixedVersion": "1.0.2", + "Title": "Buffer overflow in openssl", + "CVSS": 9.8, + "Status": "fixed", + "Target": "python:3.9-slim", + }, + { + "VulnerabilityID": "CVE-2023-9999", + "Severity": "HIGH", + "PkgName": "zlib", + "InstalledVersion": "1.2.0", + "Title": "zlib issue", + "CVSS": 7.0, + "Status": "affected", + "Target": "python:3.9-slim", + }, + ] + results = make_results(vulns) + results["suppressed_count"] = 3 + results["ignore_file"] = ".docksec-ignore.yml" + rg = ReportGenerator(image_name="test-image", results_dir=str(tmp_path)) + rg.set_analysis_score(55) + output_path = rg.generate_html_report(results) + with open(output_path, encoding="utf-8") as f: + content = f.read() + + assert 'class="score-rating rating-fair">Fair<' in content + assert "Fixed In" in content + assert '1.0.2' in content + assert 'none yet' in content + assert "1 of 2 findings have a fixed version upstream" in content + assert "3 triaged finding(s) suppressed via ignore file .docksec-ignore.yml" in content + + +def test_html_waiver_note_shown_when_all_findings_suppressed(tmp_path): + results = make_results([]) + results["suppressed_count"] = 5 + results["ignore_file"] = ".docksec-ignore.yml" + rg = ReportGenerator(image_name="test-image", results_dir=str(tmp_path)) + output_path = rg.generate_html_report(results) + with open(output_path, encoding="utf-8") as f: + content = f.read() + assert "No vulnerabilities found" in content + assert "5 triaged finding(s) suppressed" in content + + def test_html_omits_ai_section_without_findings(tmp_path): results = make_results([]) # no ai_findings key rg = ReportGenerator(image_name="test-image", results_dir=str(tmp_path)) From fea32dbe646b1bc42cd2cceb789099f19463f00a Mon Sep 17 00:00:00 2001 From: Advait Patel Date: Fri, 17 Jul 2026 19:15:19 -0500 Subject: [PATCH 3/3] fixing codeql suggestions --- docksec/docker_scanner.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/docksec/docker_scanner.py b/docksec/docker_scanner.py index e8dc64f..37ff4d1 100644 --- a/docksec/docker_scanner.py +++ b/docksec/docker_scanner.py @@ -373,8 +373,15 @@ def _cache_image_id(self) -> str: image_id = (result.stdout or "").strip() if result.returncode == 0 and image_id: return image_id - except Exception: - pass + logger.debug( + f"docker image inspect returned no ID for {self.image_name} " + f"(rc={result.returncode}); caching by image name instead" + ) + except (subprocess.SubprocessError, OSError) as e: + logger.debug( + f"Could not resolve image ID for {self.image_name} ({e}); " + f"caching by image name instead" + ) return self.image_name def _dockerfile_fingerprint(self) -> str: