Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions .github/workflows/welcome.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
7 changes: 5 additions & 2 deletions docksec/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
46 changes: 25 additions & 21 deletions docksec/docker_scanner.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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': {
Expand Down Expand Up @@ -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
Expand Down
14 changes: 10 additions & 4 deletions docksec/report_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"""
<tr>
<td><strong>{self._escape_html(vuln.get('VulnerabilityID', 'N/A'))}</strong></td>
<td><strong>{self._escape_html(vuln_id)}</strong></td>
<td><span class="severity-badge {severity_class}">{vuln.get('Severity', 'N/A')}</span></td>
<td>{self._escape_html(vuln.get('PkgName', 'N/A'))}</td>
<td>{self._escape_html(vuln.get('InstalledVersion', 'N/A'))}</td>
<td>{self._escape_html((vuln.get('Title', '')[:80] + '...') if len(vuln.get('Title', '')) > 80 else vuln.get('Title', 'N/A'))}</td>
<td>{self._escape_html(pkg_name)}</td>
<td>{self._escape_html(installed_version)}</td>
<td>{self._escape_html(display_title)}</td>
<td>{cvss_score}</td>
<td><span class="status-badge {status_class}">{status}</span></td>
</tr>
Expand Down
89 changes: 61 additions & 28 deletions docksec/score_calculator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand All @@ -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()

Expand All @@ -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)
Expand Down
11 changes: 11 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading