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.
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",