From a7c9fbf4f87fb06fc6135ae9454ade8744750873 Mon Sep 17 00:00:00 2001 From: Advait Patel Date: Thu, 2 Jul 2026 15:02:37 -0500 Subject: [PATCH 1/2] Fix cache severity bug, AI exit code, HTML crash, and score weighting gaps (2026.7.2) --- docksec/cli.py | 7 ++- docksec/docker_scanner.py | 46 ++++++++++--------- docksec/report_generator.py | 14 ++++-- docksec/score_calculator.py | 89 +++++++++++++++++++++++++------------ docs/CHANGELOG.md | 11 +++++ setup.py | 2 +- 6 files changed, 113 insertions(+), 56 deletions(-) diff --git a/docksec/cli.py b/docksec/cli.py index ee6bb63..10ec376 100644 --- a/docksec/cli.py +++ b/docksec/cli.py @@ -176,7 +176,7 @@ def main() -> None: sys.exit(2) # In scan-only mode, if no image is provided, we'll only run Dockerfile analysis - if args.scan_only and not args.image: + if args.scan_only and not args.image and not args.compose: output.info("No image provided for scan-only mode. Running Dockerfile analysis only.") # Determine which tools to run @@ -239,6 +239,7 @@ def main() -> None: ai_findings = None # Run the AI-based recommendation tool + ai_ok = None # None = AI not run, True = success, False = failed if run_ai: output.section("AI-based Dockerfile analysis") try: @@ -284,12 +285,14 @@ def main() -> None: response = analyser_chain.invoke({"filecontent": truncated_content}) ai_findings = analyze_security(response, compact=True, report_path=output_dir) + ai_ok = True except ImportError as e: output.error(f"Required modules not found - {e}") sys.exit(3) except Exception as e: output.error(f"AI analysis failed: {e}") + ai_ok = False # Run the scanner tool scan_ok = None # None = scan not run, True = success, False = failed @@ -405,7 +408,7 @@ def main() -> None: output.warn("No analysis performed. Use --help for usage information.") sys.exit(2) - if scan_ok is False: + if scan_ok is False or ai_ok is False: sys.exit(3) if gate_triggered: diff --git a/docksec/docker_scanner.py b/docksec/docker_scanner.py index ab9bda2..12b12cd 100644 --- a/docksec/docker_scanner.py +++ b/docksec/docker_scanner.py @@ -41,20 +41,22 @@ def _save_cache(self) -> None: except IOError as e: logger.warning(f"Failed to save cache: {e}") - def get_key(self, image_name: str) -> str: - """Generate cache key from image name.""" - return hashlib.md5(image_name.encode()).hexdigest() - - def get(self, image_name: str) -> Optional[Dict]: - """Get cached results for an image.""" - key = self.get_key(image_name) + def get_key(self, image_name: str, severity: str = "CRITICAL,HIGH") -> str: + """Generate cache key from image name and severity filter.""" + normalized_severity = ",".join(sorted(s.strip().upper() for s in severity.split(","))) + return hashlib.md5(f"{image_name}|{normalized_severity}".encode()).hexdigest() + + 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 set(self, image_name: str, results: Dict) -> None: - """Cache scan results for an image.""" - key = self.get_key(image_name) + + def set(self, image_name: str, results: Dict, severity: str = "CRITICAL,HIGH") -> None: + """Cache scan results for an image scanned at a given severity.""" + key = self.get_key(image_name, severity) self.cache[key] = { "image": image_name, + "severity": severity, "timestamp": datetime.now().isoformat(), "results": results } @@ -327,16 +329,17 @@ def run_image_only_scan(self, severity: str = "CRITICAL,HIGH") -> Dict: Returns: Dictionary containing scan results """ + # Validate severity input + severity = self._validate_severity(severity) + # Check cache first if self.use_cache: - cached = self.cache.get(self.image_name) + cached = self.cache.get(self.image_name, 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") return cached.get('results', {}) - - # Validate severity input - severity = self._validate_severity(severity) + logger.info(f"Starting image-only scan for {self.image_name}") results = { @@ -368,7 +371,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) + self.cache.set(self.image_name, results, severity) # Print final summary if not json_data: @@ -711,16 +714,17 @@ def run_full_scan(self, severity: str = "CRITICAL,HIGH") -> Dict: Returns: Dictionary containing scan results """ + # Validate severity input + severity = self._validate_severity(severity) + # Check cache first (only if image name is provided) if self.image_name and self.use_cache: - cached = self.cache.get(self.image_name) + cached = self.cache.get(self.image_name, 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") return cached.get('results', {}) - - # Validate severity input - severity = self._validate_severity(severity) + scan_status = True results = { 'dockerfile_scan': { @@ -766,7 +770,7 @@ def run_full_scan(self, severity: str = "CRITICAL,HIGH") -> Dict: # Cache results if self.use_cache: - self.cache.set(self.image_name, results) + self.cache.set(self.image_name, results, severity) # Print final summary target_name = self.image_name if self.image_name else self.dockerfile_path diff --git a/docksec/report_generator.py b/docksec/report_generator.py index c7416fb..d4bda72 100644 --- a/docksec/report_generator.py +++ b/docksec/report_generator.py @@ -854,13 +854,19 @@ def _prepare_html_template_vars(self, results: Dict) -> Dict[str, str]: else str(cvss_score) ) + vuln_id = vuln.get('VulnerabilityID') or 'N/A' + pkg_name = vuln.get('PkgName') or 'N/A' + installed_version = vuln.get('InstalledVersion') or 'N/A' + title = vuln.get('Title') or 'N/A' + display_title = (title[:80] + '...') if len(title) > 80 else title + table_html += f""" - {self._escape_html(vuln.get('VulnerabilityID', 'N/A'))} + {self._escape_html(vuln_id)} {vuln.get('Severity', 'N/A')} - {self._escape_html(vuln.get('PkgName', 'N/A'))} - {self._escape_html(vuln.get('InstalledVersion', 'N/A'))} - {self._escape_html((vuln.get('Title', '')[:80] + '...') if len(vuln.get('Title', '')) > 80 else vuln.get('Title', 'N/A'))} + {self._escape_html(pkg_name)} + {self._escape_html(installed_version)} + {self._escape_html(display_title)} {cvss_score} {status} diff --git a/docksec/score_calculator.py b/docksec/score_calculator.py index 6ef08d3..2d82ee1 100644 --- a/docksec/score_calculator.py +++ b/docksec/score_calculator.py @@ -141,8 +141,12 @@ def get_score_breakdown(self, results: Dict) -> Dict[str, float]: output = results.get('dockerfile_scan', {}).get('output', '') issue_count = len(output.split('\n')) if output else 0 breakdown['dockerfile'] = max(0, 100 - (issue_count * 5)) - + + dockerfile_content = self._read_dockerfile(results.get('dockerfile_path', '')) + has_exposed_credentials = self._has_exposed_credentials(dockerfile_content) + # Calculate vulnerability score + image_scan_skipped = results.get('image_scan', {}).get('skipped', False) vulnerabilities = results.get('json_data', []) if not vulnerabilities: breakdown['vulnerabilities'] = 100.0 @@ -158,21 +162,63 @@ def get_score_breakdown(self, results: Dict) -> Dict[str, float]: for sev, weight in severity_weights.items() ) breakdown['vulnerabilities'] = max(0, 100 - deduction) - + # Configuration score derived from actual Dockerfile analysis - breakdown['configuration'] = self._calculate_config_score(results) + breakdown['configuration'] = self._calculate_config_score(results, dockerfile_content) - # Overall score (weighted average) - breakdown['overall'] = ( - breakdown['dockerfile'] * 0.3 + - breakdown['vulnerabilities'] * 0.5 + - breakdown['configuration'] * 0.2 - ) + # Overall score (weighted average). When no image was scanned AND no + # other vulnerability data (e.g. compose static findings) exists, the + # vulnerabilities score is not an earned 100 - it's unmeasured, so its + # weight is redistributed to the axes that were actually evaluated. + # If findings are present (from an image scan or compose static + # rules), the vulnerabilities axis was measured and always counts. + if image_scan_skipped and not vulnerabilities: + breakdown['overall'] = ( + breakdown['dockerfile'] * 0.5 + + breakdown['configuration'] * 0.5 + ) + else: + breakdown['overall'] = ( + breakdown['dockerfile'] * 0.3 + + breakdown['vulnerabilities'] * 0.5 + + breakdown['configuration'] * 0.2 + ) + + # Hardcoded, plaintext credentials in the image are an unambiguous, + # high-severity issue on their own regardless of how the rest of the + # blended score comes out - cap the overall score so it can't read + # as a middling result while secrets are shipped in the image. + if has_exposed_credentials: + breakdown['overall'] = min(breakdown['overall'], 20.0) logger.info(f"Score breakdown: {breakdown}") return breakdown - def _calculate_config_score(self, results: Dict) -> float: + @staticmethod + def _read_dockerfile(dockerfile_path: str) -> str: + """Read Dockerfile content for config scoring, or '' if unavailable.""" + if not dockerfile_path: + return '' + try: + with open(dockerfile_path, 'r', encoding='utf-8', errors='ignore') as fh: + return fh.read() + except (OSError, IOError) as e: + logger.debug("Could not read Dockerfile for config scoring: %s", e) + return '' + + @staticmethod + def _has_exposed_credentials(dockerfile_content: str) -> bool: + """Check whether the Dockerfile sets a credential-looking ENV var.""" + if not dockerfile_content: + return False + credential_pattern = re.compile( + r'^\s*ENV\s+\S*(?:PASSWORD|SECRET|API_KEY|TOKEN|PASSWD|PRIVATE_KEY|AUTH_KEY|ACCESS_KEY)\S*' + r'\s*[=\s]\s*\S+', + re.MULTILINE | re.IGNORECASE, + ) + return bool(credential_pattern.search(dockerfile_content)) + + def _calculate_config_score(self, results: Dict, dockerfile_content: str = None) -> float: """ Calculate a configuration security score from Dockerfile content and Hadolint output. @@ -194,17 +240,10 @@ def _calculate_config_score(self, results: Dict) -> float: float: Configuration score between 0 and 100 (higher is better). """ score = 100.0 - dockerfile_path = results.get('dockerfile_path', '') hadolint_output = results.get('dockerfile_scan', {}).get('output', '') - # Read the Dockerfile content if a path is available - dockerfile_content = '' - if dockerfile_path: - try: - with open(dockerfile_path, 'r', encoding='utf-8', errors='ignore') as fh: - dockerfile_content = fh.read() - except (OSError, IOError) as e: - logger.debug("Could not read Dockerfile for config scoring: %s", e) + if dockerfile_content is None: + dockerfile_content = self._read_dockerfile(results.get('dockerfile_path', '')) content_lower = dockerfile_content.lower() @@ -227,15 +266,9 @@ def _calculate_config_score(self, results: Dict) -> float: # ENV instructions that set variables with names matching common # credential patterns and assign a non-empty value are flagged. # ------------------------------------------------------------------ - if dockerfile_content: - credential_pattern = re.compile( - r'^\s*ENV\s+\S*(?:PASSWORD|SECRET|API_KEY|TOKEN|PASSWD|PRIVATE_KEY|AUTH_KEY|ACCESS_KEY)\S*' - r'\s*[=\s]\s*\S+', - re.MULTILINE | re.IGNORECASE, - ) - if credential_pattern.search(dockerfile_content): - logger.debug("Config score: exposed credentials in ENV detected (-30)") - score -= 30 + if self._has_exposed_credentials(dockerfile_content): + logger.debug("Config score: exposed credentials in ENV detected (-30)") + score -= 30 # ------------------------------------------------------------------ # Check 3: Mutable base image tag (-15 points) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index a476eba..e8215ab 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [2026.7.2] - 2026-07-02 + +### Fixed + +- **Scan cache ignored `--severity`**: `ScanResultsCache` keyed cached results by image name only, so scanning an image at a narrow severity (e.g. `CRITICAL`) and then re-scanning the same image at a wider severity (e.g. `CRITICAL,HIGH,MEDIUM`) silently served the stale, narrower cached result instead of re-scanning, dropping HIGH/MEDIUM findings from the report. The cache key now includes the normalized severity list. +- **AI analysis failures exited 0**: an exception during the AI analysis pass (bad provider/API key, model error) printed `error AI analysis failed: ...` but still exited `0`, contradicting the documented exit-code contract. AI failures now exit `3` (tool/runtime error), matching scan failures. +- **HTML report crashed on a null vulnerability title**: Trivy can return `"Title": null` for some findings; the HTML report writer called `len()` on that field unconditionally and crashed generation for the whole report, silently dropping HTML off the report list whenever a scan hit one of these findings. Vulnerability ID, package name, installed version, and title are now null-safe in the HTML report. +- **`--compose --scan-only` printed an unrelated Dockerfile message**: "No image provided for scan-only mode. Running Dockerfile analysis only." fired for any `--scan-only` run without `--image`, including pure `--compose` runs where no Dockerfile is involved. Now scoped to non-compose runs. +- **Compose vulnerability findings could be invisible to the security score**: when every per-service image scan in a compose file failed (e.g. images not pulled locally), the score calculator treated the vulnerabilities axis as unmeasured and excluded it from the weighted average, even though compose static-misconfiguration findings (privileged mode, host network, etc.) were present. A compose file with multiple CRITICAL findings could score "GOOD". The vulnerabilities axis is now always included whenever findings exist, regardless of whether the image-scan sub-check ran. +- **Security score understated hardcoded credential exposure**: a Dockerfile with hardcoded secrets, no `USER` directive, and other severe misconfigurations could still land in the mid-40s ("POOR" but not alarming) because the blended dockerfile/vulnerabilities/configuration average diluted the credential-exposure penalty. The overall score is now capped at 20/100 whenever a hardcoded credential-looking `ENV` variable (password/secret/API key/token) is detected in the Dockerfile. + ### Added - Docker Compose security scanning support (`--compose` flag). - Detection for compose-level misconfigurations (e.g., privileged mode, host network, missing resource limits). diff --git a/setup.py b/setup.py index 5822dd5..acf3f3f 100644 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ setup( name="docksec", - version="2026.7.1", + version="2026.7.2", description="AI-Powered Docker Security Analyzer", long_description=long_description, long_description_content_type="text/markdown", From fddee4322c855aeb42720f83d4ebdd72120150d2 Mon Sep 17 00:00:00 2001 From: Advait Patel Date: Thu, 2 Jul 2026 15:04:50 -0500 Subject: [PATCH 2/2] fixing welcome workflow --- .github/workflows/welcome.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/welcome.yml b/.github/workflows/welcome.yml index 21c9a33..c2f1cda 100644 --- a/.github/workflows/welcome.yml +++ b/.github/workflows/welcome.yml @@ -17,14 +17,14 @@ jobs: pull-requests: write runs-on: ubuntu-latest steps: - - uses: actions/first-interaction@1d02526d949a90f18ba22cd8c1e7cddd97ba6ee8 # v3.0.0 + - uses: actions/first-interaction@753c925c8d1ac6fede23781875376600628d9b5d # v3.0.0 with: - repo-token: ${{ secrets.GITHUB_TOKEN }} - issue-message: > + repo_token: ${{ secrets.GITHUB_TOKEN }} + issue_message: > Thanks for opening your first issue in DockSec! A maintainer will take a look soon. In the meantime, please make sure you've included enough detail to reproduce the problem — see CONTRIBUTING.md for guidelines. - pr-message: > + pr_message: > Thanks for your first pull request to DockSec! Please make sure the test suite passes and take a look at CONTRIBUTING.md if you haven't already. A maintainer will review this shortly.