---
name: docker-image-scan
description: Scan a Docker container image for vulnerabilities using the ECR scan tool from python-deployment-utils. Use this skill whenever asked to scan a container image, check a Docker image for CVEs, run an image scan, or interpret container vulnerability findings.
compatibility:
  tools:
    - Bash
---

# Docker Image Scan

Scans a built Docker image for OS and package vulnerabilities using Amazon Inspector via the shared tool in the `python-deployment-utils` repo (`ecr_scan/`).

## Prerequisites

- Docker must be running
- AWS credentials reachable via `awsume` (profile is usually `shared`, but may differ — confirm with the user if unsure)
- `python-deployment-utils` available locally (see Setup below)

## Setup: get python-deployment-utils

`scan.sh` requires `ECR_SCAN_DIR` to be set to the `ecr_scan/` subdirectory. First check if it's already checked out somewhere on the machine:

```bash
find ~ -maxdepth 6 -path "*/python-deployment-utils/ecr_scan" -type d 2>/dev/null
```

If found, point `ECR_SCAN_DIR` at it:

```bash
export ECR_SCAN_DIR=<path-found-above>
```

If not found, clone it into `/tmp/python-deployment-utils` and set `ECR_SCAN_DIR`:

1. Try `gh repo clone theorchard/python-deployment-utils /tmp/python-deployment-utils -- --depth=1` if the `gh` CLI is available.
2. Otherwise, try `git clone` over SSH (`git@github.com:theorchard/python-deployment-utils.git`).
3. If SSH fails, fall back to HTTPS (`https://github.com/theorchard/python-deployment-utils.git`).

Then: `export ECR_SCAN_DIR=/tmp/python-deployment-utils/ecr_scan`

## Pre-flight check

Before running the scan, verify `ECR_SCAN_DIR` is set and points to a real directory:

```bash
[[ -d "${ECR_SCAN_DIR:-}" ]] && echo "OK: $ECR_SCAN_DIR" || echo "NOT SET or missing"
```

If it is not set or missing, follow the Setup section above before proceeding. Do not attempt to run `scan.sh` until this check passes — the script will exit immediately with an error if `ECR_SCAN_DIR` is unset.

## Process

### Step 0: Locate the project directory

Determine where the project lives before doing anything else:

1. **User provided a path** — use it directly.
2. **No path given, but current working directory has a `docker-compose.yml` or `Dockerfile`** — check whether the CWD directory name matches (or closely matches) the service name the user asked to scan. If it does, use `pwd`. If it doesn't, fall through to step 3 to search for the correct directory.
3. **No path given and CWD has neither** — search for a directory whose name matches the service name and contains a project root marker (`docker-compose.yml` or `Dockerfile`):
   ```bash
   find ~ -maxdepth 6 -type d -name "<service-name>" 2>/dev/null | while read dir; do
     { [ -f "$dir/docker-compose.yml" ] || [ -f "$dir/Dockerfile" ]; } && echo "$dir"
   done
   ```
   If that turns up one clear match, use it. If it turns up multiple or none, ask the user where the project is checked out.

If none of the above methods yield a clear answer, ask the user: "Where is `<service-name>` checked out on your machine?"

All subsequent steps run from this project directory.

### Step 1: Identify the build target

Service names in `docker-compose.yml` are inconsistent across repos. Run this from the project root to determine the correct build path:

```bash
python3 -m venv /tmp/scan-venv && /tmp/scan-venv/bin/pip install pyyaml --quiet
/tmp/scan-venv/bin/python3 - <<'EOF'
import yaml, sys, os
for fname in ("docker-compose.yml", "docker-compose.yaml", "compose.yml", "compose.yaml"):
    if os.path.exists(fname):
        with open(fname) as f:
            cfg = yaml.safe_load(f)
        break
else:
    print("PATH=unknown  — no docker-compose file found"); sys.exit(0)
services = cfg.get("services", {})
# Priority 1: compose service whose build target is the deploy stage
for svc, spec in services.items():
    build = spec.get("build", {})
    if isinstance(build, dict) and build.get("target") == "deploy":
        print(f"PATH=compose  SERVICE={svc}"); sys.exit(0)
# Priority 2: Dockerfile has a deploy stage not exposed by any compose service
if os.path.exists("Dockerfile"):
    with open("Dockerfile") as f:
        for line in f:
            if line.startswith("FROM") and " AS deploy" in line:
                print("PATH=dockerfile  TARGET=deploy"); sys.exit(0)
# Priority 3: well-known compose service names (lambdas, graphql services)
for name in ("function", "service"):
    if name in services:
        print(f"PATH=compose  SERVICE={name}"); sys.exit(0)
print("PATH=unknown  — inspect docker-compose.yml and ask the user which service to build")
EOF
```

Do **not** remove `/tmp/scan-venv` here — Step 2 reuses it to read `docker-compose.yml`.

- **`PATH=compose`**: build via `docker compose build --pull <SERVICE>` in Step 3
- **`PATH=dockerfile`**: the Dockerfile has a `deploy` stage but no compose service targets it; build directly with `docker build` in Step 3
- **`PATH=unknown`**: ask the user to identify the correct target before continuing

### Step 2: Check for required build secrets

**For `PATH=compose`**: read the top-level `secrets:` block in `docker-compose.yml` — each entry maps a secret name to the env var that supplies it:

```yaml
secrets:
  github_npm_token:
    environment: GITHUB_NPM_TOKEN   # this env var must be set in the shell
```

Use the venv from Step 1 to parse the compose file:

```bash
/tmp/scan-venv/bin/python3 - <<'EOF'
import yaml, sys, os
for fname in ("docker-compose.yml", "docker-compose.yaml", "compose.yml", "compose.yaml"):
    if os.path.exists(fname):
        with open(fname) as f:
            cfg = yaml.safe_load(f)
        break
else:
    print("No docker-compose file found"); sys.exit(0)
secrets = cfg.get("secrets", {})
if not secrets:
    print("No top-level secrets block")
else:
    for name, spec in secrets.items():
        env = spec.get("environment", "<no env var mapped>") if isinstance(spec, dict) else spec
        print(f"  {name}: env var = {env}")
EOF
rm -rf /tmp/scan-venv
```

Docker Compose passes the env var value through automatically when the Dockerfile uses `--mount=type=secret,id=github_npm_token` — no extra flags needed on the build command.

**For `PATH=dockerfile`**: clean up the venv (no longer needed), then grep the Dockerfile for secret mount IDs:

```bash
rm -rf /tmp/scan-venv
```

```bash
grep -- '--mount=type=secret' Dockerfile | grep -oE 'id=[A-Za-z0-9_]+' | sed 's/id=//' | sort -u
```

Each ID will be passed as `--secret id=<ID>,env=<ENV_VAR>` in Step 3. If the ID and env var name differ, check the compose `secrets:` block to find the mapping.

**Both paths**: also check for plain build args (`args:` in docker-compose.yml or `ARG` in Dockerfile without a default) — these must be provided at build time too.

For each required secret or arg not already set in the shell, ask the user how to obtain it. They may either:
- Tell you an action to take (e.g. run a function or script) — execute that action
- Provide the value directly — export it:

```bash
export <VAR_NAME>=<value-from-user>
```

Repeat for every missing secret or arg before proceeding.

### Step 3: Build the image

Before building, ensure AWS credentials are active — base images are pulled from internal ECR:

```bash
eval "$(awsume shared -s)"  # replace "shared" with the correct profile if needed
```

**`PATH=compose`**:
```bash
cd <project-dir>
docker compose build --pull <SERVICE>
```
The image is named `<project-dir-basename>-<SERVICE>:latest` by default. Confirm with `docker images` if unsure.

**`PATH=dockerfile`**:
```bash
cd <project-dir>
docker build --pull --target deploy \
  --secret id=<ID>,env=<ENV_VAR> \
  -t <project-dir-basename>:deploy .
```
Repeat `--secret` for each ID found in Step 2. The `-t` name is arbitrary — use it consistently when passing the image name to `scan.sh`.

`--pull` always fetches the latest digest of the FROM image from its registry. If the parent image digest changed, all downstream layers rebuild automatically; COPY layers also re-evaluate based on file content, so local code changes are always picked up.

> **Do not use `--no-cache`** unless explicitly asked. `--no-cache` forces every layer — including pip/poetry installs — to re-run from scratch, which requires network access to public package registries (pypi.org, etc.) that may be unreachable from inside Docker on some network configurations. `--pull` achieves the goal of "latest parent image" without that risk.

**If the build fails:**

- **Missing secret / auth error**: re-check that all required env vars from Step 2 are exported in the current shell.
- **`ENOSPC` (no space left on device)**: Docker VM disk is full from accumulated build cache and stopped containers. Free space and retry:
  ```bash
  docker container prune -f && docker image prune -f && docker builder prune -f
  ```
- **Exit code 137** (build step killed without explanation): the OOM killer terminated a build step — the Docker VM doesn't have enough memory. Ask the user to increase their Docker VM's memory allocation, restart Docker, and retry.
- **Network errors fetching private packages** (e.g. `pypi.theorchard.io`): these registries are only reachable over VPN. Connect to VPN and retry.
- **Network errors fetching public packages** (e.g. `pypi.org`) with `--no-cache`: Docker's DNS may fail to resolve public registries on certain network setups. Switch to `--pull` instead of `--no-cache` and retry.

### Step 4: Run the scan

Use `scan.sh` from this skill's `scripts/` directory. Pass the image name, optionally the awsume profile (defaults to `shared`), and optionally the project directory from Step 0:

```bash
bash ~/.claude/skills/docker-image-scan/scripts/scan.sh <image-name:tag> [awsume-profile] [project-dir]
```

Always pass `project-dir` when you know it. If a `Jenkinsfile` is present there containing a `dockerScan(vulnerabilitiesToIgnore: [...])` call, `scan.sh` will automatically write those CVE IDs as `VULNERABILITIES_TO_IGNORE` into the scanner's `.env` before the scan runs.

> **Do not run multiple scans in parallel.** All scans share the same `ecr_scan/image.tar` path. Concurrent scans will overwrite each other's tarballs, producing results for the wrong image. When scanning multiple projects, run scans sequentially — chain them in a single shell command or wait for each to finish before starting the next.

The image name is what was built in Step 3 — `<project-dir-basename>-<SERVICE>:latest` for the compose path, or the name you passed to `-t` for the dockerfile path. Confirm with `docker images` if unsure.

`scan.sh` handles the rest:
- Validates `ECR_SCAN_DIR` and fails with a clear message if not set
- Creates and patches `.env` if needed (copies from `.env.shadow`, fills in empty integer fields with defaults, enforces `IMAGE_PATH=./image.tar`)
- If `project-dir` is given and contains a `Jenkinsfile` with `vulnerabilitiesToIgnore`, writes those CVE IDs as `VULNERABILITIES_TO_IGNORE` into `.env`
- Saves the image tarball into `ecr_scan/`
- Authenticates via `awsume`
- Builds and runs the scanner, then cleans up the tarball

The awsume profile is usually `shared` but may differ — confirm with the user if a first attempt fails with an auth error.

## Interpreting Results

The scanner filters findings to the severities in `VULNERABILITY_SEVERITIES_TO_FAIL` (default: `CRITICAL,HIGH`). Each finding shows:

| Column | Meaning |
|--------|---------|
| Vulnerability ID | CVE or Inspector finding ID |
| Installed Version | Package PURL identifying the affected component |
| Fixed Version | Patched version, or `N/A` if none exists yet |
| Severity | CRITICAL / HIGH |
| Blocking | Whether the finding fails the pipeline |
| Grace Period | Days remaining before it becomes blocking |

### Exit codes

- `0`: no blocking findings
- `1`: one or more findings past their grace period (pipeline fails)
- `2`: findings exist but all within grace period (warnings only — pipeline passes)

Exit code 2 is the normal result for newly-discovered issues and does not require immediate action.

### Remediation routing

Once you have scan results, route each finding to the appropriate skill based on where the vulnerable package lives:

| Finding type | Signal | Skill to invoke |
|---|---|---|
| Python/pip package | path contains `site-packages` | `resolve-code-security-findings` |
| npm — app-owned | `pkg:npm/` at `/var/app/node_modules/` | `resolve-code-security-findings` |
| npm — bundled in npm CLI | `pkg:npm/` at `/usr/local/lib/node_modules/npm/node_modules/` | `resolve-container-image-security-findings` |
| npm — bundled in Node binary | `pkg:npm/` at `/usr/local/include/node/` | `resolve-container-image-security-findings` |
| OS package | `pkg:deb/` or `pkg:rpm/` | `resolve-container-image-security-findings` |

**No fixed version (`N/A`)**: nothing actionable yet. Track the grace period — file a ticket to revisit before it reaches zero.
