From 0f620e0e9abcfc31ce27a233bf5a7a0e6bd0d78b Mon Sep 17 00:00:00 2001 From: Aditi Rawat Date: Thu, 28 May 2026 02:38:33 +0530 Subject: [PATCH 1/6] feat(docker): harden backend and frontend images with non-root user and Trivy CVE scanning --- .github/workflows/docker-image-scan.yml | 263 +++++++++++++ backend/Dockerfile | 34 +- docker-compose.yml | 21 +- docs/base_image_update_policy.md | 130 +++++++ frontend/Dockerfile | 23 +- frontend/nginx.conf | 33 ++ .../integration/test_docker_hardening.py | 350 ++++++++++++++++++ 7 files changed, 839 insertions(+), 15 deletions(-) create mode 100644 .github/workflows/docker-image-scan.yml create mode 100644 docs/base_image_update_policy.md create mode 100644 frontend/nginx.conf create mode 100644 testing/backend/integration/test_docker_hardening.py diff --git a/.github/workflows/docker-image-scan.yml b/.github/workflows/docker-image-scan.yml new file mode 100644 index 00000000..73026300 --- /dev/null +++ b/.github/workflows/docker-image-scan.yml @@ -0,0 +1,263 @@ +name: Docker Image Hardening & Vulnerability Scan + +on: + push: + branches: [main] + paths: + - "backend/Dockerfile" + - "frontend/Dockerfile" + - "backend/requirements*.txt" + - "frontend/package*.json" + - ".github/workflows/docker-image-scan.yml" + pull_request: + branches: [main] + paths: + - "backend/Dockerfile" + - "frontend/Dockerfile" + - "backend/requirements*.txt" + - "frontend/package*.json" + - ".github/workflows/docker-image-scan.yml" + schedule: + # Run weekly on Monday at 06:00 UTC to catch new CVEs in pinned base images + - cron: "0 6 * * 1" + workflow_dispatch: + +permissions: + contents: read + security-events: write # required to upload SARIF to GitHub Security tab + +jobs: + + # 1. Build both images + + build: + name: Build ${{ matrix.service }} image + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - service: backend + context: ./backend + dockerfile: ./backend/Dockerfile + image: secuscan-backend + - service: frontend + context: ./frontend + dockerfile: ./frontend/Dockerfile + image: secuscan-frontend + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build ${{ matrix.service }} image + uses: docker/build-push-action@v6 + with: + context: ${{ matrix.context }} + file: ${{ matrix.dockerfile }} + push: false + load: true + tags: ${{ matrix.image }}:ci + cache-from: type=gha,scope=${{ matrix.service }} + cache-to: type=gha,scope=${{ matrix.service }},mode=max + + - name: Save image as tar + run: docker save ${{ matrix.image }}:ci -o /tmp/${{ matrix.image }}.tar + + - name: Upload image artifact + uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.image }}-tar + path: /tmp/${{ matrix.image }}.tar + retention-days: 1 + + # 2. Trivy vulnerability scan + + trivy-scan: + name: Trivy scan – ${{ matrix.service }} + runs-on: ubuntu-latest + needs: build + strategy: + fail-fast: false + matrix: + include: + - service: backend + image: secuscan-backend + - service: frontend + image: secuscan-frontend + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Download image artifact + uses: actions/download-artifact@v4 + with: + name: ${{ matrix.image }}-tar + path: /tmp + + - name: Load image + run: docker load -i /tmp/${{ matrix.image }}.tar + + - name: Run Trivy – table output (human-readable) + uses: aquasecurity/trivy-action@0.30.0 + with: + image-ref: ${{ matrix.image }}:ci + format: table + exit-code: "0" # don't fail here; fail only on SARIF/JSON step + ignore-unfixed: true + vuln-type: os,library + severity: CRITICAL,HIGH + + - name: Run Trivy – SARIF report (upload to Security tab) + uses: aquasecurity/trivy-action@0.30.0 + with: + image-ref: ${{ matrix.image }}:ci + format: sarif + output: trivy-${{ matrix.service }}.sarif + ignore-unfixed: true + vuln-type: os,library + severity: CRITICAL,HIGH + + - name: Upload SARIF to GitHub Security + uses: github/codeql-action/upload-sarif@v3 + if: always() + with: + sarif_file: trivy-${{ matrix.service }}.sarif + category: trivy-${{ matrix.service }} + + - name: Run Trivy – JSON report (artifact) + uses: aquasecurity/trivy-action@0.30.0 + with: + image-ref: ${{ matrix.image }}:ci + format: json + output: trivy-${{ matrix.service }}.json + ignore-unfixed: true + vuln-type: os,library + severity: CRITICAL,HIGH + + - name: Upload JSON vulnerability report + uses: actions/upload-artifact@v4 + with: + name: trivy-report-${{ matrix.service }} + path: trivy-${{ matrix.service }}.json + retention-days: 30 + + - name: Fail on CRITICAL vulnerabilities (policy gate) + # Re-run with exit-code 1 so the job fails if there are unfixed CRITICALs + uses: aquasecurity/trivy-action@0.30.0 + with: + image-ref: ${{ matrix.image }}:ci + format: table + exit-code: "1" + ignore-unfixed: true + vuln-type: os,library + severity: CRITICAL + + # 3. Structural hardening checks + + hardening-check: + name: Hardening checks – ${{ matrix.service }} + runs-on: ubuntu-latest + needs: build + strategy: + fail-fast: false + matrix: + include: + - service: backend + image: secuscan-backend + - service: frontend + image: secuscan-frontend + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Download image artifact + uses: actions/download-artifact@v4 + with: + name: ${{ matrix.image }}-tar + path: /tmp + + - name: Load image + run: docker load -i /tmp/${{ matrix.image }}.tar + + # Non-root user check + - name: Assert container does NOT run as root + run: | + WHOAMI=$(docker run --rm ${{ matrix.image }}:ci whoami 2>/dev/null || true) + UID_VAL=$(docker run --rm ${{ matrix.image }}:ci id -u 2>/dev/null || echo "0") + echo "Container user: ${WHOAMI} (UID=${UID_VAL})" + if [ "${UID_VAL}" = "0" ]; then + echo "FAIL: ${{ matrix.service }} container runs as root (UID 0)." + echo " Add a non-root USER instruction to the Dockerfile." + exit 1 + fi + echo "PASS: running as non-root user '${WHOAMI}' (UID=${UID_VAL})" + + # No SUID/SGID binaries + - name: Check for unexpected SUID/SGID binaries + run: | + SUID_FILES=$(docker run --rm --entrypoint find ${{ matrix.image }}:ci \ + / -xdev \( -perm -4000 -o -perm -2000 \) -type f 2>/dev/null || true) + if [ -n "${SUID_FILES}" ]; then + echo "WARNING: SUID/SGID binaries found in ${{ matrix.service }} image:" + echo "${SUID_FILES}" + # Warn but don't fail – some base images include ping/su; document if intentional + else + echo "PASS: No unexpected SUID/SGID binaries." + fi + + # No secrets baked into image + - name: Scan image layers for secrets (Trivy secret scanner) + uses: aquasecurity/trivy-action@0.30.0 + with: + image-ref: ${{ matrix.image }}:ci + format: table + exit-code: "1" + scanners: secret + severity: CRITICAL,HIGH,MEDIUM + + # Dockerfile lint (hadolint) + - name: Lint ${{ matrix.service }} Dockerfile with hadolint + uses: hadolint/hadolint-action@v3.1.0 + with: + dockerfile: ./${{ matrix.service }}/Dockerfile + failure-threshold: error + + + # 4. Synthetic CVE baseline test + # Verifies the scan correctly fails when a + # known-vulnerable image is provided. + + synthetic-cve-test: + name: Synthetic CVE policy gate test + runs-on: ubuntu-latest + + steps: + - name: Pull deliberately vulnerable image (python:3.8-slim – old, has known CVEs) + run: docker pull python:3.8.20-slim-bullseye + + - name: Trivy scan of vulnerable image – expect non-zero exit + id: vuln_scan + continue-on-error: true + uses: aquasecurity/trivy-action@0.30.0 + with: + image-ref: python:3.8.20-slim-bullseye + format: table + exit-code: "1" + ignore-unfixed: false + vuln-type: os,library + severity: CRITICAL + + - name: Assert scan correctly failed (policy gate works) + run: | + if [ "${{ steps.vuln_scan.outcome }}" = "failure" ]; then + echo "PASS: Policy gate correctly rejected a known-vulnerable image." + else + echo "FAIL: Policy gate did NOT reject a known-vulnerable image. Check Trivy config." + exit 1 + fi \ No newline at end of file diff --git a/backend/Dockerfile b/backend/Dockerfile index 82cbdbe9..ed057a91 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,13 +1,37 @@ -FROM python:3.11-slim +# Base image policy: see docs/BASE_IMAGE_UPDATE_POLICY.md +FROM python:3.11-slim-bookworm + +ENV PYTHONUNBUFFERED=1 WORKDIR /app COPY requirements.txt ./ -RUN pip install --no-cache-dir -r requirements.txt + +# Patch CVE-2026-31789: OpenSSL heap buffer overflow +# Remove once python:3.11-slim-bookworm ships with patched openssl +RUN apt-get update && apt-get upgrade -y --no-install-recommends openssl \ + && apt-get install -y --no-install-recommends \ + gcc \ + libcairo2-dev \ + pkg-config \ + python3-dev \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +RUN pip install --no-cache-dir --upgrade pip \ + && pip install --no-cache-dir -r requirements.txt COPY secuscan ./secuscan -COPY plugins ./plugins -EXPOSE 8081 +# Non-root user +RUN groupadd --gid 1001 secuscan \ + && useradd --uid 1001 --gid secuscan --shell /usr/sbin/nologin --create-home secuscan \ + && chown -R secuscan:secuscan /app + +USER secuscan -CMD ["uvicorn", "secuscan.api:app", "--host", "127.0.0.1", "--port", "8081"] +EXPOSE 8081 +HEALTHCHECK --interval=30s --timeout=10s --start-period=15s --retries=3 \ + CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8081/api/v1/health')" || exit 1 + +CMD ["uvicorn", "secuscan.main:app", "--host", "0.0.0.0", "--port", "8081"] \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 7deffd31..307932d9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -36,13 +36,12 @@ services: context: ./backend dockerfile: Dockerfile volumes: - - ./backend:/app - - ./plugins:/app/plugins + - ./plugins:/app/plugins # plugins only — don't overwrite /app wholesale - ./data:/app/data ports: - "127.0.0.1:8081:8081" environment: - - SECUSCAN_BIND_ADDRESS=127.0.0.1 + - SECUSCAN_BIND_ADDRESS=0.0.0.0 # fix: was 127.0.0.1, unreachable inside Docker - SECUSCAN_BIND_PORT=8081 - SECUSCAN_POSTGRES_DSN=postgresql://secuscan:secuscan@postgres:5432/secuscan - SECUSCAN_REDIS_URL=redis://redis:6379/0 @@ -52,12 +51,19 @@ services: condition: service_healthy redis: condition: service_healthy + healthcheck: + test: ["CMD", "python", "-c", + "import urllib.request; urllib.request.urlopen('http://localhost:8081/api/v1/health')"] + interval: 30s + timeout: 10s + start_period: 15s + retries: 3 restart: unless-stopped frontend: - image: node:20-alpine + image: node:20-alpine # intentional: dev mode uses node directly working_dir: /app - command: sh -c "npm ci && npm run dev" + command: sh -c "npm ci && npm run dev -- --host 0.0.0.0" # fix: expose to Docker network ports: - "127.0.0.1:5173:5173" environment: @@ -65,9 +71,10 @@ services: volumes: - ./frontend:/app depends_on: - - api + api: + condition: service_healthy # wait for api to be ready, not just started restart: unless-stopped volumes: postgres_data: - redis_data: + redis_data: \ No newline at end of file diff --git a/docs/base_image_update_policy.md b/docs/base_image_update_policy.md new file mode 100644 index 00000000..06622b75 --- /dev/null +++ b/docs/base_image_update_policy.md @@ -0,0 +1,130 @@ +# Base Image Update Policy + +> **Scope:** This document covers the Docker base images used by the SecuScan +> backend (`python:3.11-slim-bookworm`) and frontend (`nginx:1.27-alpine`). +> It defines when and how those images must be updated to keep the container +> supply chain safe. + +--- + +## 1. Why this matters + +Base images inherit all OS-level packages from the upstream distribution. +New CVEs are published daily, and a pinned base image that was clean at build +time can become vulnerable within weeks. Failing to update means: + +- Trivy scans will start failing the CRITICAL policy gate in CI. +- SecuScan containers may be deployed with known exploitable vulnerabilities. + +--- + +## 2. Scheduled review cadence + +| Trigger | Who | Action | +|---|---|---| +| Weekly (Monday CI cron) | CI bot | Trivy scans run automatically. If new CRITICALs appear, the `docker-image-scan` workflow fails and surfaces alerts in GitHub Actions/Security tab. | +| New upstream minor/patch release | Maintainer | Update the `FROM` line within **5 business days** of release. | +| Zero-day or CRITICAL CVE advisory | Maintainer / any contributor | Update within **24 hours** of public disclosure. | +| Quarterly | Maintainer | Full review of all pinned versions (OS packages, base tag, and digest). | + +--- + +## 3. How to update a base image + +### 3.1 Pull the latest tag and verify + +```bash +# Backend +docker pull python:3.11-slim-bookworm +docker inspect python:3.11-slim-bookworm --format '{{index .RepoDigests 0}}' + +# Frontend +docker pull nginx:1.27-alpine +docker inspect nginx:1.27-alpine --format '{{index .RepoDigests 0}}' +``` + +### 3.2 Update the Dockerfile + +Change the `FROM` line in: + +- `backend/Dockerfile` +- `frontend/Dockerfile` +Example (pinning by digest for full reproducibility): + +```dockerfile +FROM python:3.11-slim-bookworm@sha256: AS base +``` + +> **Note:** Tag-only pins (`python:3.11-slim-bookworm`) are acceptable for +> development velocity; digest pins are required for any release/production +> build. The CI workflow accepts tag pins and will still catch new CVEs via +> the weekly cron. + +### 3.3 Run scans locally before pushing + +```bash +# Build and scan backend +docker build -t secuscan-backend:local ./backend +trivy image --severity CRITICAL,HIGH --ignore-unfixed secuscan-backend:local + +# Build and scan frontend +docker build -t secuscan-frontend:local ./frontend +trivy image --severity CRITICAL,HIGH --ignore-unfixed secuscan-frontend:local +``` + +Install Trivy locally: https://aquasecurity.github.io/trivy/latest/getting-started/installation/ + +### 3.4 Open a pull request + +The PR title should follow: `chore(docker): update base images YYYY-MM-DD` + +Include in the PR description: + +- Old tag/digest → new tag/digest +- Link to upstream changelog or CVE advisory (if emergency update) +- Trivy output showing zero CRITICALs before and after +--- + +## 4. Accepting a known vulnerability (suppression) + +If a CVE is: + +- **Unfixed upstream** (no patched version available), and +- **Not exploitable** in the SecuScan threat model (e.g., vulnerability is in + a library component that SecuScan never calls) +…it may be suppressed with a Trivy `.trivyignore` entry. The entry **must**: + +1. Reference the CVE ID and a comment explaining why it is not exploitable. +2. Include an `expires` date no more than 90 days out. +3. Be approved by a maintainer in a PR review. +Example `.trivyignore`: + +``` +# CVE-2024-XXXXX: affects libssl's QUIC path; SecuScan does not use QUIC. +# Re-evaluate when python:3.11-slim-bookworm ships OpenSSL ≥3.x. +# Expires: 2026-08-01 +CVE-2024-XXXXX +``` + +--- + +## 5. Non-root user requirement + +Both Dockerfiles **must** run application processes as a non-root user. +The CI hardening check (`hardening-check` job) enforces this automatically +and will fail if `id -u` inside the container returns `0`. + +- Backend: user `secuscan` (UID 1001) +- Frontend: user `nginx` (UID 101, built into `nginx:*-alpine`) +If a new base image changes the default UID, update the Dockerfile and +this document accordingly. + +--- + +## 6. Contacts + +| Role | Contact | +|---|---| +| Security issues | Open a private advisory via GitHub Security tab | +| General update PRs | Open a standard pull request and tag a maintainer | + \ No newline at end of file diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 2cc4c3e2..8e11548e 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -7,7 +7,24 @@ COPY . . RUN npm run build # Production stage -FROM nginx:alpine -COPY --from=build /app/dist /usr/share/nginx/html -EXPOSE 80 +FROM nginx:1.27-alpine + +RUN apk upgrade --no-cache libcrypto3 libssl3 + +# Non-root user +RUN chown -R nginx:nginx /var/cache/nginx /var/log/nginx /etc/nginx/conf.d \ + && touch /var/run/nginx.pid \ + && chown nginx:nginx /var/run/nginx.pid + +COPY --chown=nginx:nginx nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=build --chown=nginx:nginx /app/dist /usr/share/nginx/html + +USER nginx + +EXPOSE 8080 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD wget -qO- http://localhost:8080/ || exit 1 + + CMD ["nginx", "-g", "daemon off;"] diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 00000000..196973f8 --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,33 @@ +server { + listen 8080; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + # SPA fallback: unknown routes → index.html + location / { + try_files $uri $uri/ /index.html; + } + + # Disable server token in response headers + server_tokens off; + + # Security headers + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + + # Cache static assets aggressively + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } + + # Health check endpoint (returns 200) + location /healthz { + return 200 'ok'; + add_header Content-Type text/plain; + } +} \ No newline at end of file diff --git a/testing/backend/integration/test_docker_hardening.py b/testing/backend/integration/test_docker_hardening.py new file mode 100644 index 00000000..601d84db --- /dev/null +++ b/testing/backend/integration/test_docker_hardening.py @@ -0,0 +1,350 @@ +""" +testing/backend/test_docker_hardening.py + +Integration tests that validate the Docker image hardening requirements +defined in docs/BASE_IMAGE_UPDATE_POLICY.md and enforced by CI. + +These tests require Docker to be running. They are skipped automatically +when Docker is not available (e.g., in a non-Docker CI environment). + +Run with: + pytest testing/backend/test_docker_hardening.py -v +""" + + +import json +import subprocess +import shutil +import pytest +from pathlib import Path + +# Helpers + +REPO_ROOT = Path(__file__).resolve().parents[3] + +IMAGES: dict[str, dict] = { + "backend": { + "context": str(REPO_ROOT / "backend"), + "dockerfile": str(REPO_ROOT / "backend" / "Dockerfile"), + "tag": "secuscan-backend:test-hardening", + }, + "frontend": { + "context": str(REPO_ROOT / "frontend"), + "dockerfile": str(REPO_ROOT / "frontend" / "Dockerfile"), + "tag": "secuscan-frontend:test-hardening", + }, +} + +TRIVY_MIN_VERSION = (0, 50, 0) + + +def _run(cmd: list[str], **kwargs) -> subprocess.CompletedProcess: + return subprocess.run(cmd, capture_output=True, text=True, **kwargs) + + +def _docker_available() -> bool: + result = _run(["docker", "info"]) + return result.returncode == 0 + + +def _trivy_available() -> bool: + if not shutil.which("trivy"): + return False + result = _run(["trivy", "--version"]) + # trivy --version output: "Version: 0.XX.Y" + try: + version_str = result.stdout.strip().splitlines()[0].split()[-1] + parts = tuple(int(x) for x in version_str.split(".")) + return parts >= TRIVY_MIN_VERSION + except Exception: + return False + + +def _build_image(service: str) -> str: + info = IMAGES[service] + result = _run( + [ + "docker", "build", + "-t", info["tag"], + "-f", info["dockerfile"], + info["context"], + ] + ) + assert result.returncode == 0, ( + f"Failed to build {service} image:\n{result.stderr}" + ) + return info["tag"] + + +def _container_uid(tag: str) -> int: + result = _run(["docker", "run", "--rm", tag, "id", "-u"]) + assert result.returncode == 0, f"Could not get UID from container: {result.stderr}" + return int(result.stdout.strip()) + + +def _container_user(tag: str) -> str: + result = _run(["docker", "run", "--rm", tag, "whoami"]) + assert result.returncode == 0, f"Could not get username from container: {result.stderr}" + return result.stdout.strip() + + +def _suid_files(tag: str) -> list[str]: + result = _run( + [ + "docker", "run", "--rm", "--entrypoint", "find", + tag, + "/", "-xdev", "(", "-perm", "-4000", "-o", "-perm", "-2000", ")", + "-type", "f", + ] + ) + lines = [l for l in result.stdout.splitlines() if l.strip()] + return lines + + +def _trivy_critical_count(tag: str) -> int: + """Return number of CRITICAL CVEs found by Trivy.""" + result = _run( + [ + "trivy", "image", + "--format", "json", + "--severity", "CRITICAL", + "--ignore-unfixed", + "--quiet", + tag, + ] + ) + try: + data = json.loads(result.stdout) + except json.JSONDecodeError: + pytest.skip("Trivy returned non-JSON output; skipping CVE count check.") + count = 0 + for target in data.get("Results", []): + count += len(target.get("Vulnerabilities") or []) + return count + + +# Fixtures + +requires_docker = pytest.mark.skipif( + not _docker_available(), reason="Docker daemon not available" +) +requires_trivy = pytest.mark.skipif( + not _trivy_available(), + reason=f"Trivy >= {'.'.join(str(x) for x in TRIVY_MIN_VERSION)} not available", +) + + +@pytest.fixture(scope="module") +def backend_image(): + return _build_image("backend") + + +@pytest.fixture(scope="module") +def frontend_image(): + return _build_image("frontend") + + +# Tests: non-root user + +@requires_docker +class TestNonRootUser: + """The container must not run as root (UID 0).""" + + def test_backend_non_root_uid(self, backend_image): + uid = _container_uid(backend_image) + assert uid != 0, ( + f"Backend container runs as root (UID 0). " + f"Add a non-root USER instruction to backend/Dockerfile." + ) + + def test_frontend_non_root_uid(self, frontend_image): + uid = _container_uid(frontend_image) + assert uid != 0, ( + f"Frontend container runs as root (UID 0). " + f"Add a non-root USER instruction to frontend/Dockerfile." + ) + + def test_backend_user_is_secuscan(self, backend_image): + user = _container_user(backend_image) + assert user == "secuscan", ( + f"Expected backend container user to be 'secuscan', got '{user}'." + ) + + def test_frontend_user_is_nginx(self, frontend_image): + user = _container_user(frontend_image) + assert user == "nginx", ( + f"Expected frontend container user to be 'nginx', got '{user}'." + ) + + +# Tests: SUID/SGID + +# Known-safe SUID binaries shipped by Alpine/Debian base images. +# These are documented and intentional; any file NOT in this set is a failure. +ALLOWED_SUID = { + "/bin/ping", + "/bin/su", + "/usr/bin/newgrp", + "/usr/bin/passwd", + "/usr/bin/chfn", + "/usr/bin/chsh", + "/usr/bin/gpasswd", + "/sbin/unix_chkpwd", + "/usr/bin/chage", + "/usr/bin/expiry", + "/usr/bin/mount", + "/usr/bin/su", + "/usr/bin/umount", + "/usr/sbin/unix_chkpwd", +} + + +@requires_docker +class TestSUIDFiles: + def test_backend_no_unexpected_suid(self, backend_image): + suid = set(_suid_files(backend_image)) + unexpected = suid - ALLOWED_SUID + assert not unexpected, ( + f"Unexpected SUID/SGID binaries in backend image:\n" + + "\n".join(sorted(unexpected)) + ) + + def test_frontend_no_unexpected_suid(self, frontend_image): + suid = set(_suid_files(frontend_image)) + unexpected = suid - ALLOWED_SUID + assert not unexpected, ( + f"Unexpected SUID/SGID binaries in frontend image:\n" + + "\n".join(sorted(unexpected)) + ) + + +# Tests: Dockerfile structural checks (static analysis) + +class TestDockerfileStructure: + """Parse Dockerfiles to confirm structural hardening without Docker.""" + + def _read_dockerfile(self, service: str) -> str: + path = REPO_ROOT / service / "Dockerfile" + assert path.exists(), f"Dockerfile not found at {path}" + return path.read_text() + + def test_backend_dockerfile_has_user_instruction(self): + content = self._read_dockerfile("backend") + user_lines = [l for l in content.splitlines() if l.strip().startswith("USER ")] + assert user_lines, "backend/Dockerfile must contain a USER instruction." + # Ensure it's not USER root + for line in user_lines: + assert "root" not in line.lower(), ( + f"backend/Dockerfile switches to root: {line}" + ) + + def test_frontend_dockerfile_has_user_instruction(self): + content = self._read_dockerfile("frontend") + user_lines = [l for l in content.splitlines() if l.strip().startswith("USER ")] + assert user_lines, "frontend/Dockerfile must contain a USER instruction." + for line in user_lines: + assert "root" not in line.lower(), ( + f"frontend/Dockerfile switches to root: {line}" + ) + + def test_backend_dockerfile_pinned_base(self): + content = self._read_dockerfile("backend") + from_lines = [l for l in content.splitlines() if l.strip().startswith("FROM ")] + assert from_lines, "backend/Dockerfile has no FROM line." + base = from_lines[0] + # Must not use 'latest' tag + assert ":latest" not in base and " latest" not in base, ( + f"backend/Dockerfile must not use ':latest' tag. Found: {base}" + ) + + def test_frontend_dockerfile_pinned_base(self): + content = self._read_dockerfile("frontend") + from_lines = [l for l in content.splitlines() if l.strip().startswith("FROM ")] + assert from_lines, "frontend/Dockerfile has no FROM line." + base = from_lines[0] + assert ":latest" not in base and " latest" not in base, ( + f"frontend/Dockerfile must not use ':latest' tag. Found: {base}" + ) + + def test_backend_dockerfile_has_healthcheck(self): + content = self._read_dockerfile("backend") + assert "HEALTHCHECK" in content, ( + "backend/Dockerfile must define a HEALTHCHECK instruction." + ) + + def test_frontend_dockerfile_has_healthcheck(self): + content = self._read_dockerfile("frontend") + assert "HEALTHCHECK" in content, ( + "frontend/Dockerfile must define a HEALTHCHECK instruction." + ) + + def test_backend_no_apt_cache_left_behind(self): + content = self._read_dockerfile("backend") + # Any RUN apt-get install line must be paired with cleanup in the same RUN + import re + run_blocks = re.findall( + r"RUN (.+?)(?=\nRUN |\nCOPY |\nUSER |\nFROM |\Z)", content, re.DOTALL + ) + for block in run_blocks: + if "apt-get install" in block or "apt-get update" in block: + assert "rm -rf /var/lib/apt/lists" in block, ( + "apt-get install block must clean up apt lists in the same RUN layer." + ) + + +# Tests: Trivy CVE gate + +@requires_docker +@requires_trivy +class TestTrivyCVEGate: + """Fail if unfixed CRITICAL CVEs are present in the built image.""" + + def test_backend_no_critical_cves(self, backend_image): + count = _trivy_critical_count(backend_image) + assert count == 0, ( + f"Backend image has {count} unfixed CRITICAL CVE(s). " + f"Update the base image per docs/BASE_IMAGE_UPDATE_POLICY.md." + ) + + def test_frontend_no_critical_cves(self, frontend_image): + count = _trivy_critical_count(frontend_image) + assert count == 0, ( + f"Frontend image has {count} unfixed CRITICAL CVE(s). " + f"Update the base image per docs/BASE_IMAGE_UPDATE_POLICY.md." + ) + + def test_policy_gate_detects_vulnerable_image(self): + """ + Negative test: the policy gate must correctly flag a known-vulnerable image. + Uses an old Python 3.8 slim image which reliably has CRITICAL CVEs. + """ + vulnerable_tag = "python:3.8.20-slim-bullseye" + result = _run(["docker", "pull", vulnerable_tag]) + if result.returncode != 0: + pytest.skip("Could not pull vulnerable test image; skipping negative test.") + + result = subprocess.run( + [ + "trivy", "image", + "--format", "json", + "--severity", "CRITICAL", + "--quiet", + vulnerable_tag, + ], + capture_output=True, + text=True, + ) + try: + data = json.loads(result.stdout) + except json.JSONDecodeError: + pytest.skip("Trivy returned non-JSON output for vulnerable image.") + + count = sum( + len(t.get("Vulnerabilities") or []) + for t in data.get("Results", []) + ) + assert count > 0, ( + "Expected to find CRITICAL CVEs in the known-vulnerable image " + f"({vulnerable_tag}), but found none. " + "Trivy may not be scanning correctly." + ) \ No newline at end of file From 990dc6991e52ef56fc274c50e654a70dbb468bc3 Mon Sep 17 00:00:00 2001 From: Aditi Rawat Date: Thu, 28 May 2026 02:53:59 +0530 Subject: [PATCH 2/6] fix(ci): correct trivy-action version to 0.28.0 --- .github/workflows/docker-image-scan.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/docker-image-scan.yml b/.github/workflows/docker-image-scan.yml index 73026300..81d5cc9e 100644 --- a/.github/workflows/docker-image-scan.yml +++ b/.github/workflows/docker-image-scan.yml @@ -103,7 +103,7 @@ jobs: run: docker load -i /tmp/${{ matrix.image }}.tar - name: Run Trivy – table output (human-readable) - uses: aquasecurity/trivy-action@0.30.0 + uses: aquasecurity/trivy-action@0.28.0 with: image-ref: ${{ matrix.image }}:ci format: table @@ -113,7 +113,7 @@ jobs: severity: CRITICAL,HIGH - name: Run Trivy – SARIF report (upload to Security tab) - uses: aquasecurity/trivy-action@0.30.0 + uses: aquasecurity/trivy-action@0.28.0 with: image-ref: ${{ matrix.image }}:ci format: sarif @@ -130,7 +130,7 @@ jobs: category: trivy-${{ matrix.service }} - name: Run Trivy – JSON report (artifact) - uses: aquasecurity/trivy-action@0.30.0 + uses: aquasecurity/trivy-action@0.28.0 with: image-ref: ${{ matrix.image }}:ci format: json @@ -148,7 +148,7 @@ jobs: - name: Fail on CRITICAL vulnerabilities (policy gate) # Re-run with exit-code 1 so the job fails if there are unfixed CRITICALs - uses: aquasecurity/trivy-action@0.30.0 + uses: aquasecurity/trivy-action@0.28.0 with: image-ref: ${{ matrix.image }}:ci format: table @@ -213,7 +213,7 @@ jobs: # No secrets baked into image - name: Scan image layers for secrets (Trivy secret scanner) - uses: aquasecurity/trivy-action@0.30.0 + uses: aquasecurity/trivy-action@0.28.0 with: image-ref: ${{ matrix.image }}:ci format: table @@ -244,7 +244,7 @@ jobs: - name: Trivy scan of vulnerable image – expect non-zero exit id: vuln_scan continue-on-error: true - uses: aquasecurity/trivy-action@0.30.0 + uses: aquasecurity/trivy-action@0.28.0 with: image-ref: python:3.8.20-slim-bullseye format: table From a8f30016477be78b78b9f6547cbf5aebacbbefc8 Mon Sep 17 00:00:00 2001 From: Aditi Rawat Date: Thu, 28 May 2026 02:59:07 +0530 Subject: [PATCH 3/6] fix(ci): update trivy-action to v0.36.0 --- .github/workflows/docker-image-scan.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/docker-image-scan.yml b/.github/workflows/docker-image-scan.yml index 81d5cc9e..68881fcc 100644 --- a/.github/workflows/docker-image-scan.yml +++ b/.github/workflows/docker-image-scan.yml @@ -103,7 +103,7 @@ jobs: run: docker load -i /tmp/${{ matrix.image }}.tar - name: Run Trivy – table output (human-readable) - uses: aquasecurity/trivy-action@0.28.0 + uses: aquasecurity/trivy-action@0.36.0 with: image-ref: ${{ matrix.image }}:ci format: table @@ -113,7 +113,7 @@ jobs: severity: CRITICAL,HIGH - name: Run Trivy – SARIF report (upload to Security tab) - uses: aquasecurity/trivy-action@0.28.0 + uses: aquasecurity/trivy-action@0.36.0 with: image-ref: ${{ matrix.image }}:ci format: sarif @@ -130,7 +130,7 @@ jobs: category: trivy-${{ matrix.service }} - name: Run Trivy – JSON report (artifact) - uses: aquasecurity/trivy-action@0.28.0 + uses: aquasecurity/trivy-action@0.36.0 with: image-ref: ${{ matrix.image }}:ci format: json @@ -148,7 +148,7 @@ jobs: - name: Fail on CRITICAL vulnerabilities (policy gate) # Re-run with exit-code 1 so the job fails if there are unfixed CRITICALs - uses: aquasecurity/trivy-action@0.28.0 + uses: aquasecurity/trivy-action@0.36.0 with: image-ref: ${{ matrix.image }}:ci format: table @@ -213,7 +213,7 @@ jobs: # No secrets baked into image - name: Scan image layers for secrets (Trivy secret scanner) - uses: aquasecurity/trivy-action@0.28.0 + uses: aquasecurity/trivy-action@0.36.0 with: image-ref: ${{ matrix.image }}:ci format: table @@ -244,7 +244,7 @@ jobs: - name: Trivy scan of vulnerable image – expect non-zero exit id: vuln_scan continue-on-error: true - uses: aquasecurity/trivy-action@0.28.0 + uses: aquasecurity/trivy-action@0.36.0 with: image-ref: python:3.8.20-slim-bullseye format: table From a4a2fbfb97651c709f836e308cbc9768fd8554b8 Mon Sep 17 00:00:00 2001 From: Aditi Rawat Date: Fri, 29 May 2026 00:15:06 +0530 Subject: [PATCH 4/6] fix(ci): update trivy-action to v0.36.0 and remove trailing whitespace --- .github/workflows/docker-image-scan.yml | 524 ++++++------- backend/Dockerfile | 4 +- docs/base_image_update_policy.md | 259 ++++--- frontend/Dockerfile | 2 +- frontend/nginx.conf | 64 +- .../integration/test_docker_hardening.py | 698 +++++++++--------- 6 files changed, 775 insertions(+), 776 deletions(-) diff --git a/.github/workflows/docker-image-scan.yml b/.github/workflows/docker-image-scan.yml index 68881fcc..69dcf3bd 100644 --- a/.github/workflows/docker-image-scan.yml +++ b/.github/workflows/docker-image-scan.yml @@ -1,263 +1,263 @@ -name: Docker Image Hardening & Vulnerability Scan - -on: - push: - branches: [main] - paths: - - "backend/Dockerfile" - - "frontend/Dockerfile" - - "backend/requirements*.txt" - - "frontend/package*.json" - - ".github/workflows/docker-image-scan.yml" - pull_request: - branches: [main] - paths: - - "backend/Dockerfile" - - "frontend/Dockerfile" - - "backend/requirements*.txt" - - "frontend/package*.json" - - ".github/workflows/docker-image-scan.yml" - schedule: - # Run weekly on Monday at 06:00 UTC to catch new CVEs in pinned base images - - cron: "0 6 * * 1" - workflow_dispatch: - -permissions: - contents: read - security-events: write # required to upload SARIF to GitHub Security tab - -jobs: - - # 1. Build both images - - build: - name: Build ${{ matrix.service }} image - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - include: - - service: backend - context: ./backend - dockerfile: ./backend/Dockerfile - image: secuscan-backend - - service: frontend - context: ./frontend - dockerfile: ./frontend/Dockerfile - image: secuscan-frontend - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Build ${{ matrix.service }} image - uses: docker/build-push-action@v6 - with: - context: ${{ matrix.context }} - file: ${{ matrix.dockerfile }} - push: false - load: true - tags: ${{ matrix.image }}:ci - cache-from: type=gha,scope=${{ matrix.service }} - cache-to: type=gha,scope=${{ matrix.service }},mode=max - - - name: Save image as tar - run: docker save ${{ matrix.image }}:ci -o /tmp/${{ matrix.image }}.tar - - - name: Upload image artifact - uses: actions/upload-artifact@v4 - with: - name: ${{ matrix.image }}-tar - path: /tmp/${{ matrix.image }}.tar - retention-days: 1 - - # 2. Trivy vulnerability scan - - trivy-scan: - name: Trivy scan – ${{ matrix.service }} - runs-on: ubuntu-latest - needs: build - strategy: - fail-fast: false - matrix: - include: - - service: backend - image: secuscan-backend - - service: frontend - image: secuscan-frontend - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Download image artifact - uses: actions/download-artifact@v4 - with: - name: ${{ matrix.image }}-tar - path: /tmp - - - name: Load image - run: docker load -i /tmp/${{ matrix.image }}.tar - - - name: Run Trivy – table output (human-readable) - uses: aquasecurity/trivy-action@0.36.0 - with: - image-ref: ${{ matrix.image }}:ci - format: table - exit-code: "0" # don't fail here; fail only on SARIF/JSON step - ignore-unfixed: true - vuln-type: os,library - severity: CRITICAL,HIGH - - - name: Run Trivy – SARIF report (upload to Security tab) - uses: aquasecurity/trivy-action@0.36.0 - with: - image-ref: ${{ matrix.image }}:ci - format: sarif - output: trivy-${{ matrix.service }}.sarif - ignore-unfixed: true - vuln-type: os,library - severity: CRITICAL,HIGH - - - name: Upload SARIF to GitHub Security - uses: github/codeql-action/upload-sarif@v3 - if: always() - with: - sarif_file: trivy-${{ matrix.service }}.sarif - category: trivy-${{ matrix.service }} - - - name: Run Trivy – JSON report (artifact) - uses: aquasecurity/trivy-action@0.36.0 - with: - image-ref: ${{ matrix.image }}:ci - format: json - output: trivy-${{ matrix.service }}.json - ignore-unfixed: true - vuln-type: os,library - severity: CRITICAL,HIGH - - - name: Upload JSON vulnerability report - uses: actions/upload-artifact@v4 - with: - name: trivy-report-${{ matrix.service }} - path: trivy-${{ matrix.service }}.json - retention-days: 30 - - - name: Fail on CRITICAL vulnerabilities (policy gate) - # Re-run with exit-code 1 so the job fails if there are unfixed CRITICALs - uses: aquasecurity/trivy-action@0.36.0 - with: - image-ref: ${{ matrix.image }}:ci - format: table - exit-code: "1" - ignore-unfixed: true - vuln-type: os,library - severity: CRITICAL - - # 3. Structural hardening checks - - hardening-check: - name: Hardening checks – ${{ matrix.service }} - runs-on: ubuntu-latest - needs: build - strategy: - fail-fast: false - matrix: - include: - - service: backend - image: secuscan-backend - - service: frontend - image: secuscan-frontend - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Download image artifact - uses: actions/download-artifact@v4 - with: - name: ${{ matrix.image }}-tar - path: /tmp - - - name: Load image - run: docker load -i /tmp/${{ matrix.image }}.tar - - # Non-root user check - - name: Assert container does NOT run as root - run: | - WHOAMI=$(docker run --rm ${{ matrix.image }}:ci whoami 2>/dev/null || true) - UID_VAL=$(docker run --rm ${{ matrix.image }}:ci id -u 2>/dev/null || echo "0") - echo "Container user: ${WHOAMI} (UID=${UID_VAL})" - if [ "${UID_VAL}" = "0" ]; then - echo "FAIL: ${{ matrix.service }} container runs as root (UID 0)." - echo " Add a non-root USER instruction to the Dockerfile." - exit 1 - fi - echo "PASS: running as non-root user '${WHOAMI}' (UID=${UID_VAL})" - - # No SUID/SGID binaries - - name: Check for unexpected SUID/SGID binaries - run: | - SUID_FILES=$(docker run --rm --entrypoint find ${{ matrix.image }}:ci \ - / -xdev \( -perm -4000 -o -perm -2000 \) -type f 2>/dev/null || true) - if [ -n "${SUID_FILES}" ]; then - echo "WARNING: SUID/SGID binaries found in ${{ matrix.service }} image:" - echo "${SUID_FILES}" - # Warn but don't fail – some base images include ping/su; document if intentional - else - echo "PASS: No unexpected SUID/SGID binaries." - fi - - # No secrets baked into image - - name: Scan image layers for secrets (Trivy secret scanner) - uses: aquasecurity/trivy-action@0.36.0 - with: - image-ref: ${{ matrix.image }}:ci - format: table - exit-code: "1" - scanners: secret - severity: CRITICAL,HIGH,MEDIUM - - # Dockerfile lint (hadolint) - - name: Lint ${{ matrix.service }} Dockerfile with hadolint - uses: hadolint/hadolint-action@v3.1.0 - with: - dockerfile: ./${{ matrix.service }}/Dockerfile - failure-threshold: error - - - # 4. Synthetic CVE baseline test - # Verifies the scan correctly fails when a - # known-vulnerable image is provided. - - synthetic-cve-test: - name: Synthetic CVE policy gate test - runs-on: ubuntu-latest - - steps: - - name: Pull deliberately vulnerable image (python:3.8-slim – old, has known CVEs) - run: docker pull python:3.8.20-slim-bullseye - - - name: Trivy scan of vulnerable image – expect non-zero exit - id: vuln_scan - continue-on-error: true - uses: aquasecurity/trivy-action@0.36.0 - with: - image-ref: python:3.8.20-slim-bullseye - format: table - exit-code: "1" - ignore-unfixed: false - vuln-type: os,library - severity: CRITICAL - - - name: Assert scan correctly failed (policy gate works) - run: | - if [ "${{ steps.vuln_scan.outcome }}" = "failure" ]; then - echo "PASS: Policy gate correctly rejected a known-vulnerable image." - else - echo "FAIL: Policy gate did NOT reject a known-vulnerable image. Check Trivy config." - exit 1 +name: Docker Image Hardening & Vulnerability Scan + +on: + push: + branches: [main] + paths: + - "backend/Dockerfile" + - "frontend/Dockerfile" + - "backend/requirements*.txt" + - "frontend/package*.json" + - ".github/workflows/docker-image-scan.yml" + pull_request: + branches: [main] + paths: + - "backend/Dockerfile" + - "frontend/Dockerfile" + - "backend/requirements*.txt" + - "frontend/package*.json" + - ".github/workflows/docker-image-scan.yml" + schedule: + # Run weekly on Monday at 06:00 UTC to catch new CVEs in pinned base images + - cron: "0 6 * * 1" + workflow_dispatch: + +permissions: + contents: read + security-events: write # required to upload SARIF to GitHub Security tab + +jobs: + + # 1. Build both images + + build: + name: Build ${{ matrix.service }} image + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - service: backend + context: ./backend + dockerfile: ./backend/Dockerfile + image: secuscan-backend + - service: frontend + context: ./frontend + dockerfile: ./frontend/Dockerfile + image: secuscan-frontend + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build ${{ matrix.service }} image + uses: docker/build-push-action@v6 + with: + context: ${{ matrix.context }} + file: ${{ matrix.dockerfile }} + push: false + load: true + tags: ${{ matrix.image }}:ci + cache-from: type=gha,scope=${{ matrix.service }} + cache-to: type=gha,scope=${{ matrix.service }},mode=max + + - name: Save image as tar + run: docker save ${{ matrix.image }}:ci -o /tmp/${{ matrix.image }}.tar + + - name: Upload image artifact + uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.image }}-tar + path: /tmp/${{ matrix.image }}.tar + retention-days: 1 + + # 2. Trivy vulnerability scan + + trivy-scan: + name: Trivy scan – ${{ matrix.service }} + runs-on: ubuntu-latest + needs: build + strategy: + fail-fast: false + matrix: + include: + - service: backend + image: secuscan-backend + - service: frontend + image: secuscan-frontend + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Download image artifact + uses: actions/download-artifact@v4 + with: + name: ${{ matrix.image }}-tar + path: /tmp + + - name: Load image + run: docker load -i /tmp/${{ matrix.image }}.tar + + - name: Run Trivy – table output (human-readable) + uses: aquasecurity/trivy-action@v0.36.0 + with: + image-ref: ${{ matrix.image }}:ci + format: table + exit-code: "0" # don't fail here; fail only on SARIF/JSON step + ignore-unfixed: true + vuln-type: os,library + severity: CRITICAL,HIGH + + - name: Run Trivy – SARIF report (upload to Security tab) + uses: aquasecurity/trivy-action@v0.36.0 + with: + image-ref: ${{ matrix.image }}:ci + format: sarif + output: trivy-${{ matrix.service }}.sarif + ignore-unfixed: true + vuln-type: os,library + severity: CRITICAL,HIGH + + - name: Upload SARIF to GitHub Security + uses: github/codeql-action/upload-sarif@v3 + if: always() + with: + sarif_file: trivy-${{ matrix.service }}.sarif + category: trivy-${{ matrix.service }} + + - name: Run Trivy – JSON report (artifact) + uses: aquasecurity/trivy-action@v0.36.0 + with: + image-ref: ${{ matrix.image }}:ci + format: json + output: trivy-${{ matrix.service }}.json + ignore-unfixed: true + vuln-type: os,library + severity: CRITICAL,HIGH + + - name: Upload JSON vulnerability report + uses: actions/upload-artifact@v4 + with: + name: trivy-report-${{ matrix.service }} + path: trivy-${{ matrix.service }}.json + retention-days: 30 + + - name: Fail on CRITICAL vulnerabilities (policy gate) + # Re-run with exit-code 1 so the job fails if there are unfixed CRITICALs + uses: aquasecurity/trivy-action@v0.36.0 + with: + image-ref: ${{ matrix.image }}:ci + format: table + exit-code: "1" + ignore-unfixed: true + vuln-type: os,library + severity: CRITICAL + + # 3. Structural hardening checks + + hardening-check: + name: Hardening checks – ${{ matrix.service }} + runs-on: ubuntu-latest + needs: build + strategy: + fail-fast: false + matrix: + include: + - service: backend + image: secuscan-backend + - service: frontend + image: secuscan-frontend + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Download image artifact + uses: actions/download-artifact@v4 + with: + name: ${{ matrix.image }}-tar + path: /tmp + + - name: Load image + run: docker load -i /tmp/${{ matrix.image }}.tar + + # Non-root user check + - name: Assert container does NOT run as root + run: | + WHOAMI=$(docker run --rm ${{ matrix.image }}:ci whoami 2>/dev/null || true) + UID_VAL=$(docker run --rm ${{ matrix.image }}:ci id -u 2>/dev/null || echo "0") + echo "Container user: ${WHOAMI} (UID=${UID_VAL})" + if [ "${UID_VAL}" = "0" ]; then + echo "FAIL: ${{ matrix.service }} container runs as root (UID 0)." + echo " Add a non-root USER instruction to the Dockerfile." + exit 1 + fi + echo "PASS: running as non-root user '${WHOAMI}' (UID=${UID_VAL})" + + # No SUID/SGID binaries + - name: Check for unexpected SUID/SGID binaries + run: | + SUID_FILES=$(docker run --rm --entrypoint find ${{ matrix.image }}:ci \ + / -xdev \( -perm -4000 -o -perm -2000 \) -type f 2>/dev/null || true) + if [ -n "${SUID_FILES}" ]; then + echo "WARNING: SUID/SGID binaries found in ${{ matrix.service }} image:" + echo "${SUID_FILES}" + # Warn but don't fail – some base images include ping/su; document if intentional + else + echo "PASS: No unexpected SUID/SGID binaries." + fi + + # No secrets baked into image + - name: Scan image layers for secrets (Trivy secret scanner) + uses: aquasecurity/trivy-action@v0.36.0 + with: + image-ref: ${{ matrix.image }}:ci + format: table + exit-code: "1" + scanners: secret + severity: CRITICAL,HIGH,MEDIUM + + # Dockerfile lint (hadolint) + - name: Lint ${{ matrix.service }} Dockerfile with hadolint + uses: hadolint/hadolint-action@v3.1.0 + with: + dockerfile: ./${{ matrix.service }}/Dockerfile + failure-threshold: error + + + # 4. Synthetic CVE baseline test + # Verifies the scan correctly fails when a + # known-vulnerable image is provided. + + synthetic-cve-test: + name: Synthetic CVE policy gate test + runs-on: ubuntu-latest + + steps: + - name: Pull deliberately vulnerable image (python:3.8-slim – old, has known CVEs) + run: docker pull python:3.8.20-slim-bullseye + + - name: Trivy scan of vulnerable image – expect non-zero exit + id: vuln_scan + continue-on-error: true + uses: aquasecurity/trivy-action@v0.36.0 + with: + image-ref: python:3.8.20-slim-bullseye + format: table + exit-code: "1" + ignore-unfixed: false + vuln-type: os,library + severity: CRITICAL + + - name: Assert scan correctly failed (policy gate works) + run: | + if [ "${{ steps.vuln_scan.outcome }}" = "failure" ]; then + echo "PASS: Policy gate correctly rejected a known-vulnerable image." + else + echo "FAIL: Policy gate did NOT reject a known-vulnerable image. Check Trivy config." + exit 1 fi \ No newline at end of file diff --git a/backend/Dockerfile b/backend/Dockerfile index ed057a91..64a5b14a 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -22,7 +22,7 @@ RUN pip install --no-cache-dir --upgrade pip \ COPY secuscan ./secuscan -# Non-root user +# Non-root user RUN groupadd --gid 1001 secuscan \ && useradd --uid 1001 --gid secuscan --shell /usr/sbin/nologin --create-home secuscan \ && chown -R secuscan:secuscan /app @@ -33,5 +33,5 @@ EXPOSE 8081 HEALTHCHECK --interval=30s --timeout=10s --start-period=15s --retries=3 \ CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8081/api/v1/health')" || exit 1 - + CMD ["uvicorn", "secuscan.main:app", "--host", "0.0.0.0", "--port", "8081"] \ No newline at end of file diff --git a/docs/base_image_update_policy.md b/docs/base_image_update_policy.md index 06622b75..2990015a 100644 --- a/docs/base_image_update_policy.md +++ b/docs/base_image_update_policy.md @@ -1,130 +1,129 @@ -# Base Image Update Policy - -> **Scope:** This document covers the Docker base images used by the SecuScan -> backend (`python:3.11-slim-bookworm`) and frontend (`nginx:1.27-alpine`). -> It defines when and how those images must be updated to keep the container -> supply chain safe. - ---- - -## 1. Why this matters - -Base images inherit all OS-level packages from the upstream distribution. -New CVEs are published daily, and a pinned base image that was clean at build -time can become vulnerable within weeks. Failing to update means: - -- Trivy scans will start failing the CRITICAL policy gate in CI. -- SecuScan containers may be deployed with known exploitable vulnerabilities. - ---- - -## 2. Scheduled review cadence - -| Trigger | Who | Action | -|---|---|---| -| Weekly (Monday CI cron) | CI bot | Trivy scans run automatically. If new CRITICALs appear, the `docker-image-scan` workflow fails and surfaces alerts in GitHub Actions/Security tab. | -| New upstream minor/patch release | Maintainer | Update the `FROM` line within **5 business days** of release. | -| Zero-day or CRITICAL CVE advisory | Maintainer / any contributor | Update within **24 hours** of public disclosure. | -| Quarterly | Maintainer | Full review of all pinned versions (OS packages, base tag, and digest). | - ---- - -## 3. How to update a base image - -### 3.1 Pull the latest tag and verify - -```bash -# Backend -docker pull python:3.11-slim-bookworm -docker inspect python:3.11-slim-bookworm --format '{{index .RepoDigests 0}}' - -# Frontend -docker pull nginx:1.27-alpine -docker inspect nginx:1.27-alpine --format '{{index .RepoDigests 0}}' -``` - -### 3.2 Update the Dockerfile - -Change the `FROM` line in: - -- `backend/Dockerfile` -- `frontend/Dockerfile` -Example (pinning by digest for full reproducibility): - -```dockerfile -FROM python:3.11-slim-bookworm@sha256: AS base -``` - -> **Note:** Tag-only pins (`python:3.11-slim-bookworm`) are acceptable for -> development velocity; digest pins are required for any release/production -> build. The CI workflow accepts tag pins and will still catch new CVEs via -> the weekly cron. - -### 3.3 Run scans locally before pushing - -```bash -# Build and scan backend -docker build -t secuscan-backend:local ./backend -trivy image --severity CRITICAL,HIGH --ignore-unfixed secuscan-backend:local - -# Build and scan frontend -docker build -t secuscan-frontend:local ./frontend -trivy image --severity CRITICAL,HIGH --ignore-unfixed secuscan-frontend:local -``` - -Install Trivy locally: https://aquasecurity.github.io/trivy/latest/getting-started/installation/ - -### 3.4 Open a pull request - -The PR title should follow: `chore(docker): update base images YYYY-MM-DD` - -Include in the PR description: - -- Old tag/digest → new tag/digest -- Link to upstream changelog or CVE advisory (if emergency update) -- Trivy output showing zero CRITICALs before and after ---- - -## 4. Accepting a known vulnerability (suppression) - -If a CVE is: - -- **Unfixed upstream** (no patched version available), and -- **Not exploitable** in the SecuScan threat model (e.g., vulnerability is in - a library component that SecuScan never calls) -…it may be suppressed with a Trivy `.trivyignore` entry. The entry **must**: - -1. Reference the CVE ID and a comment explaining why it is not exploitable. -2. Include an `expires` date no more than 90 days out. -3. Be approved by a maintainer in a PR review. -Example `.trivyignore`: - -``` -# CVE-2024-XXXXX: affects libssl's QUIC path; SecuScan does not use QUIC. -# Re-evaluate when python:3.11-slim-bookworm ships OpenSSL ≥3.x. -# Expires: 2026-08-01 -CVE-2024-XXXXX -``` - ---- - -## 5. Non-root user requirement - -Both Dockerfiles **must** run application processes as a non-root user. -The CI hardening check (`hardening-check` job) enforces this automatically -and will fail if `id -u` inside the container returns `0`. - -- Backend: user `secuscan` (UID 1001) -- Frontend: user `nginx` (UID 101, built into `nginx:*-alpine`) -If a new base image changes the default UID, update the Dockerfile and -this document accordingly. - ---- - -## 6. Contacts - -| Role | Contact | -|---|---| -| Security issues | Open a private advisory via GitHub Security tab | -| General update PRs | Open a standard pull request and tag a maintainer | - \ No newline at end of file +# Base Image Update Policy + +> **Scope:** This document covers the Docker base images used by the SecuScan +> backend (`python:3.11-slim-bookworm`) and frontend (`nginx:1.27-alpine`). +> It defines when and how those images must be updated to keep the container +> supply chain safe. + +--- + +## 1. Why this matters + +Base images inherit all OS-level packages from the upstream distribution. +New CVEs are published daily, and a pinned base image that was clean at build +time can become vulnerable within weeks. Failing to update means: + +- Trivy scans will start failing the CRITICAL policy gate in CI. +- SecuScan containers may be deployed with known exploitable vulnerabilities. + +--- + +## 2. Scheduled review cadence + +| Trigger | Who | Action | +|---|---|---| +| Weekly (Monday CI cron) | CI bot | Trivy scans run automatically. If new CRITICALs appear, the `docker-image-scan` workflow fails and surfaces alerts in GitHub Actions/Security tab. | +| New upstream minor/patch release | Maintainer | Update the `FROM` line within **5 business days** of release. | +| Zero-day or CRITICAL CVE advisory | Maintainer / any contributor | Update within **24 hours** of public disclosure. | +| Quarterly | Maintainer | Full review of all pinned versions (OS packages, base tag, and digest). | + +--- + +## 3. How to update a base image + +### 3.1 Pull the latest tag and verify + +```bash +# Backend +docker pull python:3.11-slim-bookworm +docker inspect python:3.11-slim-bookworm --format '{{index .RepoDigests 0}}' + +# Frontend +docker pull nginx:1.27-alpine +docker inspect nginx:1.27-alpine --format '{{index .RepoDigests 0}}' +``` + +### 3.2 Update the Dockerfile + +Change the `FROM` line in: + +- `backend/Dockerfile` +- `frontend/Dockerfile` +Example (pinning by digest for full reproducibility): + +```dockerfile +FROM python:3.11-slim-bookworm@sha256: AS base +``` + +> **Note:** Tag-only pins (`python:3.11-slim-bookworm`) are acceptable for +> development velocity; digest pins are required for any release/production +> build. The CI workflow accepts tag pins and will still catch new CVEs via +> the weekly cron. + +### 3.3 Run scans locally before pushing + +```bash +# Build and scan backend +docker build -t secuscan-backend:local ./backend +trivy image --severity CRITICAL,HIGH --ignore-unfixed secuscan-backend:local + +# Build and scan frontend +docker build -t secuscan-frontend:local ./frontend +trivy image --severity CRITICAL,HIGH --ignore-unfixed secuscan-frontend:local +``` + +Install Trivy locally: https://aquasecurity.github.io/trivy/latest/getting-started/installation/ + +### 3.4 Open a pull request + +The PR title should follow: `chore(docker): update base images YYYY-MM-DD` + +Include in the PR description: + +- Old tag/digest → new tag/digest +- Link to upstream changelog or CVE advisory (if emergency update) +- Trivy output showing zero CRITICALs before and after +--- + +## 4. Accepting a known vulnerability (suppression) + +If a CVE is: + +- **Unfixed upstream** (no patched version available), and +- **Not exploitable** in the SecuScan threat model (e.g., vulnerability is in + a library component that SecuScan never calls) +…it may be suppressed with a Trivy `.trivyignore` entry. The entry **must**: + +1. Reference the CVE ID and a comment explaining why it is not exploitable. +2. Include an `expires` date no more than 90 days out. +3. Be approved by a maintainer in a PR review. +Example `.trivyignore`: + +``` +# CVE-2024-XXXXX: affects libssl's QUIC path; SecuScan does not use QUIC. +# Re-evaluate when python:3.11-slim-bookworm ships OpenSSL ≥3.x. +# Expires: 2026-08-01 +CVE-2024-XXXXX +``` + +--- + +## 5. Non-root user requirement + +Both Dockerfiles **must** run application processes as a non-root user. +The CI hardening check (`hardening-check` job) enforces this automatically +and will fail if `id -u` inside the container returns `0`. + +- Backend: user `secuscan` (UID 1001) +- Frontend: user `nginx` (UID 101, built into `nginx:*-alpine`) +If a new base image changes the default UID, update the Dockerfile and +this document accordingly. + +--- + +## 6. Contacts + +| Role | Contact | +|---|---| +| Security issues | Open a private advisory via GitHub Security tab | +| General update PRs | Open a standard pull request and tag a maintainer | \ No newline at end of file diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 8e11548e..94960ccd 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -11,7 +11,7 @@ FROM nginx:1.27-alpine RUN apk upgrade --no-cache libcrypto3 libssl3 -# Non-root user +# Non-root user RUN chown -R nginx:nginx /var/cache/nginx /var/log/nginx /etc/nginx/conf.d \ && touch /var/run/nginx.pid \ && chown nginx:nginx /var/run/nginx.pid diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 196973f8..92a45970 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -1,33 +1,33 @@ -server { - listen 8080; - server_name _; - - root /usr/share/nginx/html; - index index.html; - - # SPA fallback: unknown routes → index.html - location / { - try_files $uri $uri/ /index.html; - } - - # Disable server token in response headers - server_tokens off; - - # Security headers - add_header X-Frame-Options "SAMEORIGIN" always; - add_header X-Content-Type-Options "nosniff" always; - add_header X-XSS-Protection "1; mode=block" always; - add_header Referrer-Policy "strict-origin-when-cross-origin" always; - - # Cache static assets aggressively - location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ { - expires 1y; - add_header Cache-Control "public, immutable"; - } - - # Health check endpoint (returns 200) - location /healthz { - return 200 'ok'; - add_header Content-Type text/plain; - } +server { + listen 8080; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + # SPA fallback: unknown routes → index.html + location / { + try_files $uri $uri/ /index.html; + } + + # Disable server token in response headers + server_tokens off; + + # Security headers + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + + # Cache static assets aggressively + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } + + # Health check endpoint (returns 200) + location /healthz { + return 200 'ok'; + add_header Content-Type text/plain; + } } \ No newline at end of file diff --git a/testing/backend/integration/test_docker_hardening.py b/testing/backend/integration/test_docker_hardening.py index 601d84db..fe82b391 100644 --- a/testing/backend/integration/test_docker_hardening.py +++ b/testing/backend/integration/test_docker_hardening.py @@ -1,350 +1,350 @@ -""" -testing/backend/test_docker_hardening.py - -Integration tests that validate the Docker image hardening requirements -defined in docs/BASE_IMAGE_UPDATE_POLICY.md and enforced by CI. - -These tests require Docker to be running. They are skipped automatically -when Docker is not available (e.g., in a non-Docker CI environment). - -Run with: - pytest testing/backend/test_docker_hardening.py -v -""" - - -import json -import subprocess -import shutil -import pytest -from pathlib import Path - -# Helpers - -REPO_ROOT = Path(__file__).resolve().parents[3] - -IMAGES: dict[str, dict] = { - "backend": { - "context": str(REPO_ROOT / "backend"), - "dockerfile": str(REPO_ROOT / "backend" / "Dockerfile"), - "tag": "secuscan-backend:test-hardening", - }, - "frontend": { - "context": str(REPO_ROOT / "frontend"), - "dockerfile": str(REPO_ROOT / "frontend" / "Dockerfile"), - "tag": "secuscan-frontend:test-hardening", - }, -} - -TRIVY_MIN_VERSION = (0, 50, 0) - - -def _run(cmd: list[str], **kwargs) -> subprocess.CompletedProcess: - return subprocess.run(cmd, capture_output=True, text=True, **kwargs) - - -def _docker_available() -> bool: - result = _run(["docker", "info"]) - return result.returncode == 0 - - -def _trivy_available() -> bool: - if not shutil.which("trivy"): - return False - result = _run(["trivy", "--version"]) - # trivy --version output: "Version: 0.XX.Y" - try: - version_str = result.stdout.strip().splitlines()[0].split()[-1] - parts = tuple(int(x) for x in version_str.split(".")) - return parts >= TRIVY_MIN_VERSION - except Exception: - return False - - -def _build_image(service: str) -> str: - info = IMAGES[service] - result = _run( - [ - "docker", "build", - "-t", info["tag"], - "-f", info["dockerfile"], - info["context"], - ] - ) - assert result.returncode == 0, ( - f"Failed to build {service} image:\n{result.stderr}" - ) - return info["tag"] - - -def _container_uid(tag: str) -> int: - result = _run(["docker", "run", "--rm", tag, "id", "-u"]) - assert result.returncode == 0, f"Could not get UID from container: {result.stderr}" - return int(result.stdout.strip()) - - -def _container_user(tag: str) -> str: - result = _run(["docker", "run", "--rm", tag, "whoami"]) - assert result.returncode == 0, f"Could not get username from container: {result.stderr}" - return result.stdout.strip() - - -def _suid_files(tag: str) -> list[str]: - result = _run( - [ - "docker", "run", "--rm", "--entrypoint", "find", - tag, - "/", "-xdev", "(", "-perm", "-4000", "-o", "-perm", "-2000", ")", - "-type", "f", - ] - ) - lines = [l for l in result.stdout.splitlines() if l.strip()] - return lines - - -def _trivy_critical_count(tag: str) -> int: - """Return number of CRITICAL CVEs found by Trivy.""" - result = _run( - [ - "trivy", "image", - "--format", "json", - "--severity", "CRITICAL", - "--ignore-unfixed", - "--quiet", - tag, - ] - ) - try: - data = json.loads(result.stdout) - except json.JSONDecodeError: - pytest.skip("Trivy returned non-JSON output; skipping CVE count check.") - count = 0 - for target in data.get("Results", []): - count += len(target.get("Vulnerabilities") or []) - return count - - -# Fixtures - -requires_docker = pytest.mark.skipif( - not _docker_available(), reason="Docker daemon not available" -) -requires_trivy = pytest.mark.skipif( - not _trivy_available(), - reason=f"Trivy >= {'.'.join(str(x) for x in TRIVY_MIN_VERSION)} not available", -) - - -@pytest.fixture(scope="module") -def backend_image(): - return _build_image("backend") - - -@pytest.fixture(scope="module") -def frontend_image(): - return _build_image("frontend") - - -# Tests: non-root user - -@requires_docker -class TestNonRootUser: - """The container must not run as root (UID 0).""" - - def test_backend_non_root_uid(self, backend_image): - uid = _container_uid(backend_image) - assert uid != 0, ( - f"Backend container runs as root (UID 0). " - f"Add a non-root USER instruction to backend/Dockerfile." - ) - - def test_frontend_non_root_uid(self, frontend_image): - uid = _container_uid(frontend_image) - assert uid != 0, ( - f"Frontend container runs as root (UID 0). " - f"Add a non-root USER instruction to frontend/Dockerfile." - ) - - def test_backend_user_is_secuscan(self, backend_image): - user = _container_user(backend_image) - assert user == "secuscan", ( - f"Expected backend container user to be 'secuscan', got '{user}'." - ) - - def test_frontend_user_is_nginx(self, frontend_image): - user = _container_user(frontend_image) - assert user == "nginx", ( - f"Expected frontend container user to be 'nginx', got '{user}'." - ) - - -# Tests: SUID/SGID - -# Known-safe SUID binaries shipped by Alpine/Debian base images. -# These are documented and intentional; any file NOT in this set is a failure. -ALLOWED_SUID = { - "/bin/ping", - "/bin/su", - "/usr/bin/newgrp", - "/usr/bin/passwd", - "/usr/bin/chfn", - "/usr/bin/chsh", - "/usr/bin/gpasswd", - "/sbin/unix_chkpwd", - "/usr/bin/chage", - "/usr/bin/expiry", - "/usr/bin/mount", - "/usr/bin/su", - "/usr/bin/umount", - "/usr/sbin/unix_chkpwd", -} - - -@requires_docker -class TestSUIDFiles: - def test_backend_no_unexpected_suid(self, backend_image): - suid = set(_suid_files(backend_image)) - unexpected = suid - ALLOWED_SUID - assert not unexpected, ( - f"Unexpected SUID/SGID binaries in backend image:\n" - + "\n".join(sorted(unexpected)) - ) - - def test_frontend_no_unexpected_suid(self, frontend_image): - suid = set(_suid_files(frontend_image)) - unexpected = suid - ALLOWED_SUID - assert not unexpected, ( - f"Unexpected SUID/SGID binaries in frontend image:\n" - + "\n".join(sorted(unexpected)) - ) - - -# Tests: Dockerfile structural checks (static analysis) - -class TestDockerfileStructure: - """Parse Dockerfiles to confirm structural hardening without Docker.""" - - def _read_dockerfile(self, service: str) -> str: - path = REPO_ROOT / service / "Dockerfile" - assert path.exists(), f"Dockerfile not found at {path}" - return path.read_text() - - def test_backend_dockerfile_has_user_instruction(self): - content = self._read_dockerfile("backend") - user_lines = [l for l in content.splitlines() if l.strip().startswith("USER ")] - assert user_lines, "backend/Dockerfile must contain a USER instruction." - # Ensure it's not USER root - for line in user_lines: - assert "root" not in line.lower(), ( - f"backend/Dockerfile switches to root: {line}" - ) - - def test_frontend_dockerfile_has_user_instruction(self): - content = self._read_dockerfile("frontend") - user_lines = [l for l in content.splitlines() if l.strip().startswith("USER ")] - assert user_lines, "frontend/Dockerfile must contain a USER instruction." - for line in user_lines: - assert "root" not in line.lower(), ( - f"frontend/Dockerfile switches to root: {line}" - ) - - def test_backend_dockerfile_pinned_base(self): - content = self._read_dockerfile("backend") - from_lines = [l for l in content.splitlines() if l.strip().startswith("FROM ")] - assert from_lines, "backend/Dockerfile has no FROM line." - base = from_lines[0] - # Must not use 'latest' tag - assert ":latest" not in base and " latest" not in base, ( - f"backend/Dockerfile must not use ':latest' tag. Found: {base}" - ) - - def test_frontend_dockerfile_pinned_base(self): - content = self._read_dockerfile("frontend") - from_lines = [l for l in content.splitlines() if l.strip().startswith("FROM ")] - assert from_lines, "frontend/Dockerfile has no FROM line." - base = from_lines[0] - assert ":latest" not in base and " latest" not in base, ( - f"frontend/Dockerfile must not use ':latest' tag. Found: {base}" - ) - - def test_backend_dockerfile_has_healthcheck(self): - content = self._read_dockerfile("backend") - assert "HEALTHCHECK" in content, ( - "backend/Dockerfile must define a HEALTHCHECK instruction." - ) - - def test_frontend_dockerfile_has_healthcheck(self): - content = self._read_dockerfile("frontend") - assert "HEALTHCHECK" in content, ( - "frontend/Dockerfile must define a HEALTHCHECK instruction." - ) - - def test_backend_no_apt_cache_left_behind(self): - content = self._read_dockerfile("backend") - # Any RUN apt-get install line must be paired with cleanup in the same RUN - import re - run_blocks = re.findall( - r"RUN (.+?)(?=\nRUN |\nCOPY |\nUSER |\nFROM |\Z)", content, re.DOTALL - ) - for block in run_blocks: - if "apt-get install" in block or "apt-get update" in block: - assert "rm -rf /var/lib/apt/lists" in block, ( - "apt-get install block must clean up apt lists in the same RUN layer." - ) - - -# Tests: Trivy CVE gate - -@requires_docker -@requires_trivy -class TestTrivyCVEGate: - """Fail if unfixed CRITICAL CVEs are present in the built image.""" - - def test_backend_no_critical_cves(self, backend_image): - count = _trivy_critical_count(backend_image) - assert count == 0, ( - f"Backend image has {count} unfixed CRITICAL CVE(s). " - f"Update the base image per docs/BASE_IMAGE_UPDATE_POLICY.md." - ) - - def test_frontend_no_critical_cves(self, frontend_image): - count = _trivy_critical_count(frontend_image) - assert count == 0, ( - f"Frontend image has {count} unfixed CRITICAL CVE(s). " - f"Update the base image per docs/BASE_IMAGE_UPDATE_POLICY.md." - ) - - def test_policy_gate_detects_vulnerable_image(self): - """ - Negative test: the policy gate must correctly flag a known-vulnerable image. - Uses an old Python 3.8 slim image which reliably has CRITICAL CVEs. - """ - vulnerable_tag = "python:3.8.20-slim-bullseye" - result = _run(["docker", "pull", vulnerable_tag]) - if result.returncode != 0: - pytest.skip("Could not pull vulnerable test image; skipping negative test.") - - result = subprocess.run( - [ - "trivy", "image", - "--format", "json", - "--severity", "CRITICAL", - "--quiet", - vulnerable_tag, - ], - capture_output=True, - text=True, - ) - try: - data = json.loads(result.stdout) - except json.JSONDecodeError: - pytest.skip("Trivy returned non-JSON output for vulnerable image.") - - count = sum( - len(t.get("Vulnerabilities") or []) - for t in data.get("Results", []) - ) - assert count > 0, ( - "Expected to find CRITICAL CVEs in the known-vulnerable image " - f"({vulnerable_tag}), but found none. " - "Trivy may not be scanning correctly." +""" +testing/backend/test_docker_hardening.py + +Integration tests that validate the Docker image hardening requirements +defined in docs/BASE_IMAGE_UPDATE_POLICY.md and enforced by CI. + +These tests require Docker to be running. They are skipped automatically +when Docker is not available (e.g., in a non-Docker CI environment). + +Run with: + pytest testing/backend/test_docker_hardening.py -v +""" + + +import json +import subprocess +import shutil +import pytest +from pathlib import Path + +# Helpers + +REPO_ROOT = Path(__file__).resolve().parents[3] + +IMAGES: dict[str, dict] = { + "backend": { + "context": str(REPO_ROOT / "backend"), + "dockerfile": str(REPO_ROOT / "backend" / "Dockerfile"), + "tag": "secuscan-backend:test-hardening", + }, + "frontend": { + "context": str(REPO_ROOT / "frontend"), + "dockerfile": str(REPO_ROOT / "frontend" / "Dockerfile"), + "tag": "secuscan-frontend:test-hardening", + }, +} + +TRIVY_MIN_VERSION = (0, 50, 0) + + +def _run(cmd: list[str], **kwargs) -> subprocess.CompletedProcess: + return subprocess.run(cmd, capture_output=True, text=True, **kwargs) + + +def _docker_available() -> bool: + result = _run(["docker", "info"]) + return result.returncode == 0 + + +def _trivy_available() -> bool: + if not shutil.which("trivy"): + return False + result = _run(["trivy", "--version"]) + # trivy --version output: "Version: 0.XX.Y" + try: + version_str = result.stdout.strip().splitlines()[0].split()[-1] + parts = tuple(int(x) for x in version_str.split(".")) + return parts >= TRIVY_MIN_VERSION + except Exception: + return False + + +def _build_image(service: str) -> str: + info = IMAGES[service] + result = _run( + [ + "docker", "build", + "-t", info["tag"], + "-f", info["dockerfile"], + info["context"], + ] + ) + assert result.returncode == 0, ( + f"Failed to build {service} image:\n{result.stderr}" + ) + return info["tag"] + + +def _container_uid(tag: str) -> int: + result = _run(["docker", "run", "--rm", tag, "id", "-u"]) + assert result.returncode == 0, f"Could not get UID from container: {result.stderr}" + return int(result.stdout.strip()) + + +def _container_user(tag: str) -> str: + result = _run(["docker", "run", "--rm", tag, "whoami"]) + assert result.returncode == 0, f"Could not get username from container: {result.stderr}" + return result.stdout.strip() + + +def _suid_files(tag: str) -> list[str]: + result = _run( + [ + "docker", "run", "--rm", "--entrypoint", "find", + tag, + "/", "-xdev", "(", "-perm", "-4000", "-o", "-perm", "-2000", ")", + "-type", "f", + ] + ) + lines = [l for l in result.stdout.splitlines() if l.strip()] + return lines + + +def _trivy_critical_count(tag: str) -> int: + """Return number of CRITICAL CVEs found by Trivy.""" + result = _run( + [ + "trivy", "image", + "--format", "json", + "--severity", "CRITICAL", + "--ignore-unfixed", + "--quiet", + tag, + ] + ) + try: + data = json.loads(result.stdout) + except json.JSONDecodeError: + pytest.skip("Trivy returned non-JSON output; skipping CVE count check.") + count = 0 + for target in data.get("Results", []): + count += len(target.get("Vulnerabilities") or []) + return count + + +# Fixtures + +requires_docker = pytest.mark.skipif( + not _docker_available(), reason="Docker daemon not available" +) +requires_trivy = pytest.mark.skipif( + not _trivy_available(), + reason=f"Trivy >= {'.'.join(str(x) for x in TRIVY_MIN_VERSION)} not available", +) + + +@pytest.fixture(scope="module") +def backend_image(): + return _build_image("backend") + + +@pytest.fixture(scope="module") +def frontend_image(): + return _build_image("frontend") + + +# Tests: non-root user + +@requires_docker +class TestNonRootUser: + """The container must not run as root (UID 0).""" + + def test_backend_non_root_uid(self, backend_image): + uid = _container_uid(backend_image) + assert uid != 0, ( + f"Backend container runs as root (UID 0). " + f"Add a non-root USER instruction to backend/Dockerfile." + ) + + def test_frontend_non_root_uid(self, frontend_image): + uid = _container_uid(frontend_image) + assert uid != 0, ( + f"Frontend container runs as root (UID 0). " + f"Add a non-root USER instruction to frontend/Dockerfile." + ) + + def test_backend_user_is_secuscan(self, backend_image): + user = _container_user(backend_image) + assert user == "secuscan", ( + f"Expected backend container user to be 'secuscan', got '{user}'." + ) + + def test_frontend_user_is_nginx(self, frontend_image): + user = _container_user(frontend_image) + assert user == "nginx", ( + f"Expected frontend container user to be 'nginx', got '{user}'." + ) + + +# Tests: SUID/SGID + +# Known-safe SUID binaries shipped by Alpine/Debian base images. +# These are documented and intentional; any file NOT in this set is a failure. +ALLOWED_SUID = { + "/bin/ping", + "/bin/su", + "/usr/bin/newgrp", + "/usr/bin/passwd", + "/usr/bin/chfn", + "/usr/bin/chsh", + "/usr/bin/gpasswd", + "/sbin/unix_chkpwd", + "/usr/bin/chage", + "/usr/bin/expiry", + "/usr/bin/mount", + "/usr/bin/su", + "/usr/bin/umount", + "/usr/sbin/unix_chkpwd", +} + + +@requires_docker +class TestSUIDFiles: + def test_backend_no_unexpected_suid(self, backend_image): + suid = set(_suid_files(backend_image)) + unexpected = suid - ALLOWED_SUID + assert not unexpected, ( + f"Unexpected SUID/SGID binaries in backend image:\n" + + "\n".join(sorted(unexpected)) + ) + + def test_frontend_no_unexpected_suid(self, frontend_image): + suid = set(_suid_files(frontend_image)) + unexpected = suid - ALLOWED_SUID + assert not unexpected, ( + f"Unexpected SUID/SGID binaries in frontend image:\n" + + "\n".join(sorted(unexpected)) + ) + + +# Tests: Dockerfile structural checks (static analysis) + +class TestDockerfileStructure: + """Parse Dockerfiles to confirm structural hardening without Docker.""" + + def _read_dockerfile(self, service: str) -> str: + path = REPO_ROOT / service / "Dockerfile" + assert path.exists(), f"Dockerfile not found at {path}" + return path.read_text() + + def test_backend_dockerfile_has_user_instruction(self): + content = self._read_dockerfile("backend") + user_lines = [l for l in content.splitlines() if l.strip().startswith("USER ")] + assert user_lines, "backend/Dockerfile must contain a USER instruction." + # Ensure it's not USER root + for line in user_lines: + assert "root" not in line.lower(), ( + f"backend/Dockerfile switches to root: {line}" + ) + + def test_frontend_dockerfile_has_user_instruction(self): + content = self._read_dockerfile("frontend") + user_lines = [l for l in content.splitlines() if l.strip().startswith("USER ")] + assert user_lines, "frontend/Dockerfile must contain a USER instruction." + for line in user_lines: + assert "root" not in line.lower(), ( + f"frontend/Dockerfile switches to root: {line}" + ) + + def test_backend_dockerfile_pinned_base(self): + content = self._read_dockerfile("backend") + from_lines = [l for l in content.splitlines() if l.strip().startswith("FROM ")] + assert from_lines, "backend/Dockerfile has no FROM line." + base = from_lines[0] + # Must not use 'latest' tag + assert ":latest" not in base and " latest" not in base, ( + f"backend/Dockerfile must not use ':latest' tag. Found: {base}" + ) + + def test_frontend_dockerfile_pinned_base(self): + content = self._read_dockerfile("frontend") + from_lines = [l for l in content.splitlines() if l.strip().startswith("FROM ")] + assert from_lines, "frontend/Dockerfile has no FROM line." + base = from_lines[0] + assert ":latest" not in base and " latest" not in base, ( + f"frontend/Dockerfile must not use ':latest' tag. Found: {base}" + ) + + def test_backend_dockerfile_has_healthcheck(self): + content = self._read_dockerfile("backend") + assert "HEALTHCHECK" in content, ( + "backend/Dockerfile must define a HEALTHCHECK instruction." + ) + + def test_frontend_dockerfile_has_healthcheck(self): + content = self._read_dockerfile("frontend") + assert "HEALTHCHECK" in content, ( + "frontend/Dockerfile must define a HEALTHCHECK instruction." + ) + + def test_backend_no_apt_cache_left_behind(self): + content = self._read_dockerfile("backend") + # Any RUN apt-get install line must be paired with cleanup in the same RUN + import re + run_blocks = re.findall( + r"RUN (.+?)(?=\nRUN |\nCOPY |\nUSER |\nFROM |\Z)", content, re.DOTALL + ) + for block in run_blocks: + if "apt-get install" in block or "apt-get update" in block: + assert "rm -rf /var/lib/apt/lists" in block, ( + "apt-get install block must clean up apt lists in the same RUN layer." + ) + + +# Tests: Trivy CVE gate + +@requires_docker +@requires_trivy +class TestTrivyCVEGate: + """Fail if unfixed CRITICAL CVEs are present in the built image.""" + + def test_backend_no_critical_cves(self, backend_image): + count = _trivy_critical_count(backend_image) + assert count == 0, ( + f"Backend image has {count} unfixed CRITICAL CVE(s). " + f"Update the base image per docs/BASE_IMAGE_UPDATE_POLICY.md." + ) + + def test_frontend_no_critical_cves(self, frontend_image): + count = _trivy_critical_count(frontend_image) + assert count == 0, ( + f"Frontend image has {count} unfixed CRITICAL CVE(s). " + f"Update the base image per docs/BASE_IMAGE_UPDATE_POLICY.md." + ) + + def test_policy_gate_detects_vulnerable_image(self): + """ + Negative test: the policy gate must correctly flag a known-vulnerable image. + Uses an old Python 3.8 slim image which reliably has CRITICAL CVEs. + """ + vulnerable_tag = "python:3.8.20-slim-bullseye" + result = _run(["docker", "pull", vulnerable_tag]) + if result.returncode != 0: + pytest.skip("Could not pull vulnerable test image; skipping negative test.") + + result = subprocess.run( + [ + "trivy", "image", + "--format", "json", + "--severity", "CRITICAL", + "--quiet", + vulnerable_tag, + ], + capture_output=True, + text=True, + ) + try: + data = json.loads(result.stdout) + except json.JSONDecodeError: + pytest.skip("Trivy returned non-JSON output for vulnerable image.") + + count = sum( + len(t.get("Vulnerabilities") or []) + for t in data.get("Results", []) + ) + assert count > 0, ( + "Expected to find CRITICAL CVEs in the known-vulnerable image " + f"({vulnerable_tag}), but found none. " + "Trivy may not be scanning correctly." ) \ No newline at end of file From f293d51612864c818304927b02e8619afbdd419e Mon Sep 17 00:00:00 2001 From: Aditi Rawat Date: Tue, 2 Jun 2026 01:05:07 +0530 Subject: [PATCH 5/6] fix(docker): pin apt package versions and split CI into hardening and trivy workflows --- .github/workflows/docker-hardening.yml | 130 ++++++ .../{docker-image-scan.yml => trivy-scan.yml} | 431 +++++++----------- backend/Dockerfile | 8 +- 3 files changed, 303 insertions(+), 266 deletions(-) create mode 100644 .github/workflows/docker-hardening.yml rename .github/workflows/{docker-image-scan.yml => trivy-scan.yml} (51%) diff --git a/.github/workflows/docker-hardening.yml b/.github/workflows/docker-hardening.yml new file mode 100644 index 00000000..412c7c76 --- /dev/null +++ b/.github/workflows/docker-hardening.yml @@ -0,0 +1,130 @@ +name: Docker Hardening Checks + +on: + push: + branches: [main] + paths: + - "backend/Dockerfile" + - "frontend/Dockerfile" + - ".github/workflows/docker-hardening.yml" + pull_request: + branches: [main] + paths: + - "backend/Dockerfile" + - "frontend/Dockerfile" + - ".github/workflows/docker-hardening.yml" + workflow_dispatch: + +jobs: + build: + name: Build ${{ matrix.service }} image + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - service: backend + context: ./backend + dockerfile: ./backend/Dockerfile + image: secuscan-backend + - service: frontend + context: ./frontend + dockerfile: ./frontend/Dockerfile + image: secuscan-frontend + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build ${{ matrix.service }} image + uses: docker/build-push-action@v6 + with: + context: ${{ matrix.context }} + file: ${{ matrix.dockerfile }} + push: false + load: true + tags: ${{ matrix.image }}:ci + cache-from: type=gha,scope=${{ matrix.service }} + cache-to: type=gha,scope=${{ matrix.service }},mode=max + + - name: Save image as tar + run: docker save ${{ matrix.image }}:ci -o /tmp/${{ matrix.image }}.tar + + - name: Upload image artifact + uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.image }}-tar + path: /tmp/${{ matrix.image }}.tar + retention-days: 1 + + hardening-check: + name: Hardening checks – ${{ matrix.service }} + runs-on: ubuntu-latest + needs: build + strategy: + fail-fast: false + matrix: + include: + - service: backend + image: secuscan-backend + - service: frontend + image: secuscan-frontend + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Download image artifact + uses: actions/download-artifact@v4 + with: + name: ${{ matrix.image }}-tar + path: /tmp + + - name: Load image + run: docker load -i /tmp/${{ matrix.image }}.tar + + # Non-root user check + - name: Assert container does NOT run as root + run: | + WHOAMI=$(docker run --rm ${{ matrix.image }}:ci whoami 2>/dev/null || true) + UID_VAL=$(docker run --rm ${{ matrix.image }}:ci id -u 2>/dev/null || echo "0") + echo "Container user: ${WHOAMI} (UID=${UID_VAL})" + if [ "${UID_VAL}" = "0" ]; then + echo "FAIL: ${{ matrix.service }} container runs as root (UID 0)." + echo " Add a non-root USER instruction to the Dockerfile." + exit 1 + fi + echo "PASS: running as non-root user '${WHOAMI}' (UID=${UID_VAL})" + + # No SUID/SGID binaries + - name: Check for unexpected SUID/SGID binaries + run: | + SUID_FILES=$(docker run --rm --entrypoint find ${{ matrix.image }}:ci \ + / -xdev \( -perm -4000 -o -perm -2000 \) -type f 2>/dev/null || true) + if [ -n "${SUID_FILES}" ]; then + echo "WARNING: SUID/SGID binaries found in ${{ matrix.service }} image:" + echo "${SUID_FILES}" + # Warn but don't fail – some base images include ping/su; document if intentional + else + echo "PASS: No unexpected SUID/SGID binaries." + fi + + # No secrets baked into image + - name: Scan image layers for secrets (Trivy secret scanner) + uses: aquasecurity/trivy-action@v0.36.0 + with: + image-ref: ${{ matrix.image }}:ci + format: table + exit-code: "1" + scanners: secret + severity: CRITICAL,HIGH,MEDIUM + + # Dockerfile lint (hadolint) + - name: Lint ${{ matrix.service }} Dockerfile with hadolint + uses: hadolint/hadolint-action@v3.1.0 + with: + dockerfile: ./${{ matrix.service }}/Dockerfile + failure-threshold: error diff --git a/.github/workflows/docker-image-scan.yml b/.github/workflows/trivy-scan.yml similarity index 51% rename from .github/workflows/docker-image-scan.yml rename to .github/workflows/trivy-scan.yml index 69dcf3bd..bd63c566 100644 --- a/.github/workflows/docker-image-scan.yml +++ b/.github/workflows/trivy-scan.yml @@ -1,263 +1,170 @@ -name: Docker Image Hardening & Vulnerability Scan - -on: - push: - branches: [main] - paths: - - "backend/Dockerfile" - - "frontend/Dockerfile" - - "backend/requirements*.txt" - - "frontend/package*.json" - - ".github/workflows/docker-image-scan.yml" - pull_request: - branches: [main] - paths: - - "backend/Dockerfile" - - "frontend/Dockerfile" - - "backend/requirements*.txt" - - "frontend/package*.json" - - ".github/workflows/docker-image-scan.yml" - schedule: - # Run weekly on Monday at 06:00 UTC to catch new CVEs in pinned base images - - cron: "0 6 * * 1" - workflow_dispatch: - -permissions: - contents: read - security-events: write # required to upload SARIF to GitHub Security tab - -jobs: - - # 1. Build both images - - build: - name: Build ${{ matrix.service }} image - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - include: - - service: backend - context: ./backend - dockerfile: ./backend/Dockerfile - image: secuscan-backend - - service: frontend - context: ./frontend - dockerfile: ./frontend/Dockerfile - image: secuscan-frontend - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Build ${{ matrix.service }} image - uses: docker/build-push-action@v6 - with: - context: ${{ matrix.context }} - file: ${{ matrix.dockerfile }} - push: false - load: true - tags: ${{ matrix.image }}:ci - cache-from: type=gha,scope=${{ matrix.service }} - cache-to: type=gha,scope=${{ matrix.service }},mode=max - - - name: Save image as tar - run: docker save ${{ matrix.image }}:ci -o /tmp/${{ matrix.image }}.tar - - - name: Upload image artifact - uses: actions/upload-artifact@v4 - with: - name: ${{ matrix.image }}-tar - path: /tmp/${{ matrix.image }}.tar - retention-days: 1 - - # 2. Trivy vulnerability scan - - trivy-scan: - name: Trivy scan – ${{ matrix.service }} - runs-on: ubuntu-latest - needs: build - strategy: - fail-fast: false - matrix: - include: - - service: backend - image: secuscan-backend - - service: frontend - image: secuscan-frontend - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Download image artifact - uses: actions/download-artifact@v4 - with: - name: ${{ matrix.image }}-tar - path: /tmp - - - name: Load image - run: docker load -i /tmp/${{ matrix.image }}.tar - - - name: Run Trivy – table output (human-readable) - uses: aquasecurity/trivy-action@v0.36.0 - with: - image-ref: ${{ matrix.image }}:ci - format: table - exit-code: "0" # don't fail here; fail only on SARIF/JSON step - ignore-unfixed: true - vuln-type: os,library - severity: CRITICAL,HIGH - - - name: Run Trivy – SARIF report (upload to Security tab) - uses: aquasecurity/trivy-action@v0.36.0 - with: - image-ref: ${{ matrix.image }}:ci - format: sarif - output: trivy-${{ matrix.service }}.sarif - ignore-unfixed: true - vuln-type: os,library - severity: CRITICAL,HIGH - - - name: Upload SARIF to GitHub Security - uses: github/codeql-action/upload-sarif@v3 - if: always() - with: - sarif_file: trivy-${{ matrix.service }}.sarif - category: trivy-${{ matrix.service }} - - - name: Run Trivy – JSON report (artifact) - uses: aquasecurity/trivy-action@v0.36.0 - with: - image-ref: ${{ matrix.image }}:ci - format: json - output: trivy-${{ matrix.service }}.json - ignore-unfixed: true - vuln-type: os,library - severity: CRITICAL,HIGH - - - name: Upload JSON vulnerability report - uses: actions/upload-artifact@v4 - with: - name: trivy-report-${{ matrix.service }} - path: trivy-${{ matrix.service }}.json - retention-days: 30 - - - name: Fail on CRITICAL vulnerabilities (policy gate) - # Re-run with exit-code 1 so the job fails if there are unfixed CRITICALs - uses: aquasecurity/trivy-action@v0.36.0 - with: - image-ref: ${{ matrix.image }}:ci - format: table - exit-code: "1" - ignore-unfixed: true - vuln-type: os,library - severity: CRITICAL - - # 3. Structural hardening checks - - hardening-check: - name: Hardening checks – ${{ matrix.service }} - runs-on: ubuntu-latest - needs: build - strategy: - fail-fast: false - matrix: - include: - - service: backend - image: secuscan-backend - - service: frontend - image: secuscan-frontend - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Download image artifact - uses: actions/download-artifact@v4 - with: - name: ${{ matrix.image }}-tar - path: /tmp - - - name: Load image - run: docker load -i /tmp/${{ matrix.image }}.tar - - # Non-root user check - - name: Assert container does NOT run as root - run: | - WHOAMI=$(docker run --rm ${{ matrix.image }}:ci whoami 2>/dev/null || true) - UID_VAL=$(docker run --rm ${{ matrix.image }}:ci id -u 2>/dev/null || echo "0") - echo "Container user: ${WHOAMI} (UID=${UID_VAL})" - if [ "${UID_VAL}" = "0" ]; then - echo "FAIL: ${{ matrix.service }} container runs as root (UID 0)." - echo " Add a non-root USER instruction to the Dockerfile." - exit 1 - fi - echo "PASS: running as non-root user '${WHOAMI}' (UID=${UID_VAL})" - - # No SUID/SGID binaries - - name: Check for unexpected SUID/SGID binaries - run: | - SUID_FILES=$(docker run --rm --entrypoint find ${{ matrix.image }}:ci \ - / -xdev \( -perm -4000 -o -perm -2000 \) -type f 2>/dev/null || true) - if [ -n "${SUID_FILES}" ]; then - echo "WARNING: SUID/SGID binaries found in ${{ matrix.service }} image:" - echo "${SUID_FILES}" - # Warn but don't fail – some base images include ping/su; document if intentional - else - echo "PASS: No unexpected SUID/SGID binaries." - fi - - # No secrets baked into image - - name: Scan image layers for secrets (Trivy secret scanner) - uses: aquasecurity/trivy-action@v0.36.0 - with: - image-ref: ${{ matrix.image }}:ci - format: table - exit-code: "1" - scanners: secret - severity: CRITICAL,HIGH,MEDIUM - - # Dockerfile lint (hadolint) - - name: Lint ${{ matrix.service }} Dockerfile with hadolint - uses: hadolint/hadolint-action@v3.1.0 - with: - dockerfile: ./${{ matrix.service }}/Dockerfile - failure-threshold: error - - - # 4. Synthetic CVE baseline test - # Verifies the scan correctly fails when a - # known-vulnerable image is provided. - - synthetic-cve-test: - name: Synthetic CVE policy gate test - runs-on: ubuntu-latest - - steps: - - name: Pull deliberately vulnerable image (python:3.8-slim – old, has known CVEs) - run: docker pull python:3.8.20-slim-bullseye - - - name: Trivy scan of vulnerable image – expect non-zero exit - id: vuln_scan - continue-on-error: true - uses: aquasecurity/trivy-action@v0.36.0 - with: - image-ref: python:3.8.20-slim-bullseye - format: table - exit-code: "1" - ignore-unfixed: false - vuln-type: os,library - severity: CRITICAL - - - name: Assert scan correctly failed (policy gate works) - run: | - if [ "${{ steps.vuln_scan.outcome }}" = "failure" ]; then - echo "PASS: Policy gate correctly rejected a known-vulnerable image." - else - echo "FAIL: Policy gate did NOT reject a known-vulnerable image. Check Trivy config." - exit 1 +name: Trivy Vulnerability Scan + +on: + push: + branches: [main] + paths: + - "backend/Dockerfile" + - "frontend/Dockerfile" + - "backend/requirements*.txt" + - "frontend/package*.json" + - ".github/workflows/trivy-scan.yml" + pull_request: + branches: [main] + paths: + - "backend/Dockerfile" + - "frontend/Dockerfile" + - "backend/requirements*.txt" + - "frontend/package*.json" + - ".github/workflows/trivy-scan.yml" + schedule: + - cron: "0 6 * * 1" + workflow_dispatch: + +permissions: + contents: read + security-events: write + +jobs: + build: + name: Build ${{ matrix.service }} image + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - service: backend + context: ./backend + dockerfile: ./backend/Dockerfile + image: secuscan-backend + - service: frontend + context: ./frontend + dockerfile: ./frontend/Dockerfile + image: secuscan-frontend + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build ${{ matrix.service }} image + uses: docker/build-push-action@v6 + with: + context: ${{ matrix.context }} + file: ${{ matrix.dockerfile }} + push: false + load: true + tags: ${{ matrix.image }}:ci + cache-from: type=gha,scope=${{ matrix.service }} + cache-to: type=gha,scope=${{ matrix.service }},mode=max + + - name: Save image as tar + run: docker save ${{ matrix.image }}:ci -o /tmp/${{ matrix.image }}.tar + + - name: Upload image artifact + uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.image }}-tar + path: /tmp/${{ matrix.image }}.tar + retention-days: 1 + + trivy-scan: + name: Trivy scan - ${{ matrix.service }} + runs-on: ubuntu-latest + needs: build + strategy: + fail-fast: false + matrix: + include: + - service: backend + image: secuscan-backend + - service: frontend + image: secuscan-frontend + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Download image artifact + uses: actions/download-artifact@v4 + with: + name: ${{ matrix.image }}-tar + path: /tmp + + - name: Load image + run: docker load -i /tmp/${{ matrix.image }}.tar + + - name: Run Trivy - table output + uses: aquasecurity/trivy-action@v0.36.0 + with: + image-ref: ${{ matrix.image }}:ci + format: table + exit-code: "0" + ignore-unfixed: true + vuln-type: os,library + severity: CRITICAL,HIGH + + - name: Run Trivy - SARIF report + uses: aquasecurity/trivy-action@v0.36.0 + with: + image-ref: ${{ matrix.image }}:ci + format: sarif + output: trivy-${{ matrix.service }}.sarif + ignore-unfixed: true + vuln-type: os,library + severity: CRITICAL,HIGH + + - name: Upload SARIF to GitHub Security + uses: github/codeql-action/upload-sarif@v3 + if: always() + with: + sarif_file: trivy-${{ matrix.service }}.sarif + category: trivy-${{ matrix.service }} + + - name: Upload JSON vulnerability report + uses: actions/upload-artifact@v4 + with: + name: trivy-report-${{ matrix.service }} + path: trivy-${{ matrix.service }}.json + retention-days: 30 + + - name: Fail on CRITICAL vulnerabilities + uses: aquasecurity/trivy-action@v0.36.0 + with: + image-ref: ${{ matrix.image }}:ci + format: table + exit-code: "1" + ignore-unfixed: true + vuln-type: os,library + severity: CRITICAL + + synthetic-cve-test: + name: Synthetic CVE policy gate test + runs-on: ubuntu-latest + + steps: + - name: Pull deliberately vulnerable image + run: docker pull python:3.8.20-slim-bullseye + + - name: Trivy scan of vulnerable image - expect non-zero exit + id: vuln_scan + continue-on-error: true + uses: aquasecurity/trivy-action@v0.36.0 + with: + image-ref: python:3.8.20-slim-bullseye + format: table + exit-code: "1" + ignore-unfixed: false + vuln-type: os,library + severity: CRITICAL + + - name: Assert scan correctly failed + run: | + if [ "${{ steps.vuln_scan.outcome }}" = "failure" ]; then + echo "PASS: Policy gate correctly rejected a known-vulnerable image." + else + echo "FAIL: Policy gate did NOT reject a known-vulnerable image." + exit 1 fi \ No newline at end of file diff --git a/backend/Dockerfile b/backend/Dockerfile index 64a5b14a..6e44b3ac 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -10,10 +10,10 @@ COPY requirements.txt ./ # Remove once python:3.11-slim-bookworm ships with patched openssl RUN apt-get update && apt-get upgrade -y --no-install-recommends openssl \ && apt-get install -y --no-install-recommends \ - gcc \ - libcairo2-dev \ - pkg-config \ - python3-dev \ + gcc=4:12.2.0-3 \ + libcairo2-dev=1.16.0-7 \ + pkg-config=1.8.1-1 \ + python3-dev=3.11.2-1+b1 \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* From 57f76ccf16bcedfb344151b7af2bc63e8184e660 Mon Sep 17 00:00:00 2001 From: Utkarsh Singh <183999732+utksh1@users.noreply.github.com> Date: Tue, 2 Jun 2026 01:21:50 +0530 Subject: [PATCH 6/6] fix(ci): clean trivy workflow formatting --- .github/workflows/trivy-scan.yml | 350 ++++++++++++++++--------------- backend/Dockerfile | 7 +- 2 files changed, 182 insertions(+), 175 deletions(-) diff --git a/.github/workflows/trivy-scan.yml b/.github/workflows/trivy-scan.yml index bd63c566..492576a0 100644 --- a/.github/workflows/trivy-scan.yml +++ b/.github/workflows/trivy-scan.yml @@ -1,170 +1,180 @@ -name: Trivy Vulnerability Scan - -on: - push: - branches: [main] - paths: - - "backend/Dockerfile" - - "frontend/Dockerfile" - - "backend/requirements*.txt" - - "frontend/package*.json" - - ".github/workflows/trivy-scan.yml" - pull_request: - branches: [main] - paths: - - "backend/Dockerfile" - - "frontend/Dockerfile" - - "backend/requirements*.txt" - - "frontend/package*.json" - - ".github/workflows/trivy-scan.yml" - schedule: - - cron: "0 6 * * 1" - workflow_dispatch: - -permissions: - contents: read - security-events: write - -jobs: - build: - name: Build ${{ matrix.service }} image - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - include: - - service: backend - context: ./backend - dockerfile: ./backend/Dockerfile - image: secuscan-backend - - service: frontend - context: ./frontend - dockerfile: ./frontend/Dockerfile - image: secuscan-frontend - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Build ${{ matrix.service }} image - uses: docker/build-push-action@v6 - with: - context: ${{ matrix.context }} - file: ${{ matrix.dockerfile }} - push: false - load: true - tags: ${{ matrix.image }}:ci - cache-from: type=gha,scope=${{ matrix.service }} - cache-to: type=gha,scope=${{ matrix.service }},mode=max - - - name: Save image as tar - run: docker save ${{ matrix.image }}:ci -o /tmp/${{ matrix.image }}.tar - - - name: Upload image artifact - uses: actions/upload-artifact@v4 - with: - name: ${{ matrix.image }}-tar - path: /tmp/${{ matrix.image }}.tar - retention-days: 1 - - trivy-scan: - name: Trivy scan - ${{ matrix.service }} - runs-on: ubuntu-latest - needs: build - strategy: - fail-fast: false - matrix: - include: - - service: backend - image: secuscan-backend - - service: frontend - image: secuscan-frontend - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Download image artifact - uses: actions/download-artifact@v4 - with: - name: ${{ matrix.image }}-tar - path: /tmp - - - name: Load image - run: docker load -i /tmp/${{ matrix.image }}.tar - - - name: Run Trivy - table output - uses: aquasecurity/trivy-action@v0.36.0 - with: - image-ref: ${{ matrix.image }}:ci - format: table - exit-code: "0" - ignore-unfixed: true - vuln-type: os,library - severity: CRITICAL,HIGH - - - name: Run Trivy - SARIF report - uses: aquasecurity/trivy-action@v0.36.0 - with: - image-ref: ${{ matrix.image }}:ci - format: sarif - output: trivy-${{ matrix.service }}.sarif - ignore-unfixed: true - vuln-type: os,library - severity: CRITICAL,HIGH - - - name: Upload SARIF to GitHub Security - uses: github/codeql-action/upload-sarif@v3 - if: always() - with: - sarif_file: trivy-${{ matrix.service }}.sarif - category: trivy-${{ matrix.service }} - - - name: Upload JSON vulnerability report - uses: actions/upload-artifact@v4 - with: - name: trivy-report-${{ matrix.service }} - path: trivy-${{ matrix.service }}.json - retention-days: 30 - - - name: Fail on CRITICAL vulnerabilities - uses: aquasecurity/trivy-action@v0.36.0 - with: - image-ref: ${{ matrix.image }}:ci - format: table - exit-code: "1" - ignore-unfixed: true - vuln-type: os,library - severity: CRITICAL - - synthetic-cve-test: - name: Synthetic CVE policy gate test - runs-on: ubuntu-latest - - steps: - - name: Pull deliberately vulnerable image - run: docker pull python:3.8.20-slim-bullseye - - - name: Trivy scan of vulnerable image - expect non-zero exit - id: vuln_scan - continue-on-error: true - uses: aquasecurity/trivy-action@v0.36.0 - with: - image-ref: python:3.8.20-slim-bullseye - format: table - exit-code: "1" - ignore-unfixed: false - vuln-type: os,library - severity: CRITICAL - - - name: Assert scan correctly failed - run: | - if [ "${{ steps.vuln_scan.outcome }}" = "failure" ]; then - echo "PASS: Policy gate correctly rejected a known-vulnerable image." - else - echo "FAIL: Policy gate did NOT reject a known-vulnerable image." - exit 1 - fi \ No newline at end of file +name: Trivy Vulnerability Scan + +on: + push: + branches: [main] + paths: + - "backend/Dockerfile" + - "frontend/Dockerfile" + - "backend/requirements*.txt" + - "frontend/package*.json" + - ".github/workflows/trivy-scan.yml" + pull_request: + branches: [main] + paths: + - "backend/Dockerfile" + - "frontend/Dockerfile" + - "backend/requirements*.txt" + - "frontend/package*.json" + - ".github/workflows/trivy-scan.yml" + schedule: + - cron: "0 6 * * 1" + workflow_dispatch: + +permissions: + contents: read + security-events: write + +jobs: + build: + name: Build ${{ matrix.service }} image + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - service: backend + context: ./backend + dockerfile: ./backend/Dockerfile + image: secuscan-backend + - service: frontend + context: ./frontend + dockerfile: ./frontend/Dockerfile + image: secuscan-frontend + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build ${{ matrix.service }} image + uses: docker/build-push-action@v6 + with: + context: ${{ matrix.context }} + file: ${{ matrix.dockerfile }} + push: false + load: true + tags: ${{ matrix.image }}:ci + cache-from: type=gha,scope=${{ matrix.service }} + cache-to: type=gha,scope=${{ matrix.service }},mode=max + + - name: Save image as tar + run: docker save ${{ matrix.image }}:ci -o /tmp/${{ matrix.image }}.tar + + - name: Upload image artifact + uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.image }}-tar + path: /tmp/${{ matrix.image }}.tar + retention-days: 1 + + trivy-scan: + name: Trivy scan - ${{ matrix.service }} + runs-on: ubuntu-latest + needs: build + strategy: + fail-fast: false + matrix: + include: + - service: backend + image: secuscan-backend + - service: frontend + image: secuscan-frontend + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Download image artifact + uses: actions/download-artifact@v4 + with: + name: ${{ matrix.image }}-tar + path: /tmp + + - name: Load image + run: docker load -i /tmp/${{ matrix.image }}.tar + + - name: Run Trivy - table output + uses: aquasecurity/trivy-action@v0.36.0 + with: + image-ref: ${{ matrix.image }}:ci + format: table + exit-code: "0" + ignore-unfixed: true + vuln-type: os,library + severity: CRITICAL,HIGH + + - name: Run Trivy - SARIF report + uses: aquasecurity/trivy-action@v0.36.0 + with: + image-ref: ${{ matrix.image }}:ci + format: sarif + output: trivy-${{ matrix.service }}.sarif + ignore-unfixed: true + vuln-type: os,library + severity: CRITICAL,HIGH + + - name: Upload SARIF to GitHub Security + uses: github/codeql-action/upload-sarif@v3 + if: always() + with: + sarif_file: trivy-${{ matrix.service }}.sarif + category: trivy-${{ matrix.service }} + + - name: Run Trivy - JSON report + uses: aquasecurity/trivy-action@v0.36.0 + with: + image-ref: ${{ matrix.image }}:ci + format: json + output: trivy-${{ matrix.service }}.json + ignore-unfixed: true + vuln-type: os,library + severity: CRITICAL,HIGH + + - name: Upload JSON vulnerability report + uses: actions/upload-artifact@v4 + with: + name: trivy-report-${{ matrix.service }} + path: trivy-${{ matrix.service }}.json + retention-days: 30 + + - name: Fail on CRITICAL vulnerabilities + uses: aquasecurity/trivy-action@v0.36.0 + with: + image-ref: ${{ matrix.image }}:ci + format: table + exit-code: "1" + ignore-unfixed: true + vuln-type: os,library + severity: CRITICAL + + synthetic-cve-test: + name: Synthetic CVE policy gate test + runs-on: ubuntu-latest + + steps: + - name: Pull deliberately vulnerable image + run: docker pull python:3.8.20-slim-bullseye + + - name: Trivy scan of vulnerable image - expect non-zero exit + id: vuln_scan + continue-on-error: true + uses: aquasecurity/trivy-action@v0.36.0 + with: + image-ref: python:3.8.20-slim-bullseye + format: table + exit-code: "1" + ignore-unfixed: false + vuln-type: os,library + severity: CRITICAL + + - name: Assert scan correctly failed + run: | + if [ "${{ steps.vuln_scan.outcome }}" = "failure" ]; then + echo "PASS: Policy gate correctly rejected a known-vulnerable image." + else + echo "FAIL: Policy gate did NOT reject a known-vulnerable image." + exit 1 + fi diff --git a/backend/Dockerfile b/backend/Dockerfile index 9f47909f..847a9f98 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,4 +1,4 @@ -# Base image policy: see docs/BASE_IMAGE_UPDATE_POLICY.md +# Base image policy: see docs/base_image_update_policy.md FROM python:3.11-slim-bookworm ENV PYTHONUNBUFFERED=1 @@ -30,12 +30,9 @@ RUN groupadd --gid 1001 secuscan \ USER secuscan -EXPOSE 8081 - HEALTHCHECK --interval=30s --timeout=10s --start-period=15s --retries=3 \ CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8081/api/v1/health')" || exit 1 - EXPOSE 8081 -CMD ["uvicorn", "secuscan.main:app", "--host", "0.0.0.0", "--port", "8081"] \ No newline at end of file +CMD ["uvicorn", "secuscan.main:app", "--host", "0.0.0.0", "--port", "8081"]