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
36 changes: 36 additions & 0 deletions .github/workflows/docker-image-smoke.yml
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,42 @@ jobs:
CMD ["node","server.js"]
EOF

# The image must also behave like the CLI it wraps. `--version` and
# `--help` are the first two commands anyone runs against an unfamiliar
# image, and both failed in 2026.9.20: entrypoint.sh built its arguments
# only from INPUT_* and never forwarded "$@". Every other step in this
# file reaches DockSec through `--entrypoint sh` or INPUT_*, so nothing
# here caught it.
- name: Assert command-line arguments reach the CLI
run: |
set -euo pipefail

# The entrypoint echoes the command it is about to run, so match
# anywhere in the output rather than anchoring at the start.
version_output="$(docker run --rm docksec:smoke --version)"
echo "${version_output}"
case "${version_output}" in
*DockSec*) ;;
*) echo "::error::--version did not reach the CLI"; exit 1 ;;
esac

help_output="$(docker run --rm docksec:smoke --help)"
case "${help_output}" in
*"usage: docksec"*) ;;
*) echo "::error::--help did not reach the CLI"; exit 1 ;;
esac

# A positional argument plus a flag: the shape a direct CLI user types.
docker run --rm \
-v "${PWD}/smoke-workspace:/work" -w /work \
docksec:smoke Dockerfile --scan-only > cli-form.txt 2>&1 || true
if ! grep -q "security findings" cli-form.txt; then
echo "::error::a positional Dockerfile argument did not reach the CLI"
cat cli-form.txt
exit 1
fi
echo "command-line arguments reach the CLI"

# The core assertion: a scan runs to completion through the Action's own
# entrypoint and produces findings. Uses --scan-only so no API key is
# needed; AI analysis is not what this test covers.
Expand Down
34 changes: 23 additions & 11 deletions .github/workflows/self-scan.yml
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,13 @@ jobs:
--sarif --output-dir ./self-scan-results

# The bundled examples are part of the product: the insecure one must
# produce findings and the hardened one must not. A rule that starts
# firing on the hardened example is a false positive users will hit.
# produce findings and the hardened one must not trip a config rule.
#
# "Hardened" is a statement about configuration, so that is what this
# gates on. A CVE in a pinned upstream base image is a true positive, not
# a rule misfiring, and gating on those would fail this job every time
# upstream publishes an advisory against a pin the example deliberately
# holds. Image CVEs are printed so the count stays visible.
- name: Verify the bundled examples still behave
if: always()
run: |
Expand All @@ -74,15 +79,22 @@ jobs:
python3 - <<'PY'
import json, sys
data = json.load(open("secure.json"))
counts = data["severity_counts"]
serious = counts.get("CRITICAL", 0) + counts.get("HIGH", 0)
if serious:
sys.exit(f"::error::the hardened example produced {serious} CRITICAL/HIGH "
f"finding(s); a false positive users will hit")
score = data["scan_info"]["analysis_score"]
if score is not None and score < 70:
sys.exit(f"::error::the hardened example scored {score}; it should score well")
print(f"hardened example: {serious} serious findings, score {score}")
findings = data.get("vulnerabilities", [])
serious = [
f for f in findings
if str(f.get("Severity", "")).upper() in ("CRITICAL", "HIGH")
]
config_findings = [
f for f in serious
if not str(f.get("VulnerabilityID", "")).startswith("CVE-")
]
if config_findings:
ids = ", ".join(sorted({str(f.get("VulnerabilityID")) for f in config_findings}))
sys.exit(f"::error::the hardened example tripped config rule(s): {ids}; "
f"a false positive users will hit")
cve_count = len(serious) - len(config_findings)
print(f"hardened example: 0 config findings, "
f"{cve_count} CRITICAL/HIGH CVE(s) in its pinned base images")
PY

docksec --compose examples/compose/docker-compose-insecure.yml --no-config \
Expand Down
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -201,10 +201,13 @@ CURSOR_CONFIGURATION.md
docs/promptfoo-benchmark-review.md
docs/docksec-strategy-2026.md
docs/cve-lite-cli-comparison.md
docs/session-handoff.md
ROADMAP.md

# Claude Code project instructions (local only, not for public repository)
CLAUDE.md

# macOS
.DS_Store
.DS_Store
docs/e2e-findings-2026-09-20.md

38 changes: 38 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,44 @@ All notable changes to DockSec are documented in this file.

### Fixed

- **The container image ignored every command-line argument.** `entrypoint.sh`
built its argument list only from the Action's `INPUT_*` variables and never
forwarded `"$@"`, so `docker run ghcr.io/owasp/docksec:latest --version` and
`--help` - the first two commands anyone runs against an unfamiliar image -
both failed with "Dockerfile path is required". The Action path was
unaffected. Command-line arguments are now appended after the `INPUT_*`
handling, so both forms work and can be combined.

- **Fix commands ignored the EPSS priority DockSec had just computed.** The
output printed a `Fix Now` / `Fix Soon` breakdown and then ordered the
commands by severity and package name, so on a Postgres image the three
genuinely-exploited OpenSSL CVEs sat alphabetically among 160+ lower-priority
entries with nothing marking them. Commands are now ordered by priority tier
first, each row is labelled with its tier, and where several CVEs share a
package the one driving the tier is kept in the displayed IDs.

- **SARIF reported no severity for findings without a CVSS score.** The
`security-severity` property was set to `str(CVSS or "")`, which is an empty
string for every Hadolint and `trivy config` rule and for many CVEs - 14 of 25
rules on an nginx scan. GitHub Code Scanning ranks by that property, so those
findings did not surface at the severity DockSec assigned. It now falls back
to the severity band when no CVSS score is available.

- **SARIF carried no EPSS data.** Code Scanning users, the largest integration
surface, saw none of the exploitation signal driving DockSec's own ordering.
Results now carry `epss`, `epssPercentile` and `priority` properties.

- **Compose scans never pulled images.** A service whose image was not already
local failed with "not found locally", so on a clean machine or a fresh CI
runner the insecure compose example reported both services unscanned and
scored from static rules alone. Missing images are now pulled on demand. This
never happens under `--offline`, and can be disabled with
`DOCKSEC_PULL_MISSING_IMAGES=false`.

- Coverage notes now state the Trivy and Hadolint versions used. The published
image pins versions a local install does not, so the same Dockerfile could
yield 9 findings locally and 11 in CI with nothing explaining the difference.

- DockSec's own Dockerfile was missing `--no-install-recommends`, which the new
self-scan caught. The root-user finding is waived in a committed
`.docksec-ignore.yml` with the reason recorded: the image backs the GitHub
Expand Down
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -615,6 +615,16 @@ Image scan results are cached (default: 24 hours, override with
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.

### Pulling images that are not local

Scanning an image that is not present locally pulls it first. A compose stack
routinely names images the machine has never pulled, and without this every one
of those services is reported as unscanned.

Set `DOCKSEC_PULL_MISSING_IMAGES=false` to turn this off and fail instead, which
is worth doing on a metered connection or a shared runner. `--offline` never
pulls, regardless of this setting.

---

## AI-assistant skills (`install-skill`)
Expand Down
52 changes: 52 additions & 0 deletions docksec/completeness.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,48 @@
scope from silence.
"""

import re
import subprocess
from typing import Dict, List, Optional

DETECTION = "detection"
REMEDIATION = "remediation"

# Cached for the process: these shell out, and the versions cannot change
# mid-run.
_TOOL_VERSIONS: Optional[Dict[str, str]] = None


def _tool_version(tool: str) -> Optional[str]:
"""Return a scanner's version string, or None if it cannot be determined."""
try:
proc = subprocess.run(
[tool, "--version"],
capture_output=True,
text=True,
timeout=10,
shell=False,
)
except (OSError, subprocess.SubprocessError):
return None
if proc.returncode != 0:
return None
match = re.search(r"(\d+\.\d+(?:\.\d+)?)", proc.stdout or "")
return match.group(1) if match else None


def tool_versions() -> Dict[str, str]:
"""The scanner versions this run used, for the coverage block."""
global _TOOL_VERSIONS
if _TOOL_VERSIONS is None:
found = {}
for tool in ("trivy", "hadolint"):
version = _tool_version(tool)
if version:
found[tool] = version
_TOOL_VERSIONS = found
return _TOOL_VERSIONS


class ScanCompleteness:
"""Accumulates gaps encountered during a scan."""
Expand Down Expand Up @@ -100,6 +137,21 @@ def coverage_notes(results: Dict, ai_ran: bool = False) -> List[str]:
)

notes.append("Secrets committed elsewhere in the build context are not scanned.")

# Results depend on the scanner versions, and the published image pins
# versions a local install does not. Stating them turns "CI found one more
# finding than my laptop" from a suspected bug into a visible difference.
# An explicit (even empty) mapping in results wins; detection is only the
# fallback for callers that do not record versions themselves.
versions = results.get("tool_versions")
if versions is None:
versions = tool_versions()
reported = [f"{name} {version}" for name, version in sorted(versions.items()) if version]
if reported:
notes.append(
f"Findings depend on scanner versions: {', '.join(reported)}. "
f"A different version may report a different set."
)
return notes


Expand Down
54 changes: 52 additions & 2 deletions docksec/docker_scanner.py
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,14 @@ def __init__(self, dockerfile_path: Optional[str], image_name: Optional[str], re
self.cache = ScanResultsCache(self.RESULTS_DIR)
self.use_cache = os.getenv("DOCKSEC_USE_CACHE", "true").lower() == "true"

# A compose stack routinely names images the user has never pulled, and
# refusing to fetch them reports every service as unscanned. Pulling is
# a network operation that can move gigabytes, so it stays switchable
# and is never attempted under --offline.
self.pull_missing_images = (
os.getenv("DOCKSEC_PULL_MISSING_IMAGES", "true").lower() == "true"
)

# Verify required tools
missing_tools = self._check_tools()
if missing_tools:
Expand Down Expand Up @@ -353,8 +361,26 @@ def __init__(self, dockerfile_path: Optional[str], image_name: Optional[str], re
f" 3. If you must use sudo, run DockSec with sudo (not recommended for security reasons)\n"
f"Original error: {e.stderr.strip() if e.stderr else str(e)}"
)
# If it's not a permission error, assume the image doesn't exist
raise ValueError(f"Docker image '{self.image_name}' not found locally")
# Not a permission problem, so the image is simply not present
# locally. A compose stack routinely names images the user has
# never pulled, and refusing there reports every service as
# unscanned on an otherwise healthy machine. Pull it once, then
# let a second failure stand.
if self.offline or not self.pull_missing_images:
reason = (
"--offline forbids pulling it"
if self.offline
else "DOCKSEC_PULL_MISSING_IMAGES=false forbids pulling it"
)
raise ValueError(
f"Docker image '{self.image_name}' not found locally and "
f"{reason}. Pull it before scanning."
)
if not self._pull_image():
raise ValueError(
f"Docker image '{self.image_name}' not found locally and could "
f"not be pulled. Check the name and your registry access."
)
except FileNotFoundError:
raise ValueError(
"Docker command not found. Please ensure Docker is installed and accessible in your PATH."
Expand Down Expand Up @@ -465,6 +491,30 @@ def run_image_only_scan(self, severity: str = "CRITICAL,HIGH") -> Dict:

return results

def _pull_image(self) -> bool:
"""Fetch an image that is not present locally.

Returns True when the image is available afterwards. Network failures,
bad names and private registries all surface the same way - as a False
the caller turns into a clear error - because the distinction does not
change what the user has to do.
"""
logger.info(f"Image {self.image_name} not present locally; pulling it")
try:
subprocess.run(
['docker', 'pull', self.image_name],
capture_output=True,
check=True,
text=True,
timeout=600,
shell=False,
)
return True
except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError) as e:
stderr = getattr(e, 'stderr', '') or ''
logger.error(f"Failed to pull {self.image_name}: {stderr.strip() or e}")
return False

def _check_tools(self) -> List[str]:
"""Check if all required tools are installed and return list of missing tools."""
missing_tools = []
Expand Down
20 changes: 14 additions & 6 deletions docksec/output.py
Original file line number Diff line number Diff line change
Expand Up @@ -260,19 +260,27 @@ def fix_plan(plan) -> None:
console = get_console()

if plan.package_upgrades:
from docksec.epss import PRIORITY_LABELS

console.print()
_line(Text("Fix commands", style="bold cyan"))
for item in plan.package_upgrades:
ids = ", ".join(item["ids"])
more = "" if item["finding_count"] <= len(item["ids"]) else f" +{item['finding_count'] - len(item['ids'])}"
_line(Text.assemble((" > ", "dim"), (item["command"], "green")))
_line(
Text(
f" {item['severity']} - {item['installed']} -> {item['fixed']}"
f" ({ids}{more})",
style="dim",
)
# The commands are ordered by priority, which is invisible in a long
# list unless each row says which tier put it there.
priority = item.get("priority")
label = PRIORITY_LABELS.get(priority) if priority else None
detail = Text(" ", style="dim")
if label:
detail.append(f"{label} ", style=_PRIORITY_STYLES.get(priority, "default"))
detail.append(
f"{item['severity']} - {item['installed']} -> {item['fixed']}"
f" ({ids}{more})",
style="dim",
)
_line(detail)

if plan.dockerfile_edits:
console.print()
Expand Down
Loading
Loading