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/trivy-scan.yml b/.github/workflows/trivy-scan.yml new file mode 100644 index 00000000..492576a0 --- /dev/null +++ b/.github/workflows/trivy-scan.yml @@ -0,0 +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: 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 b9b5acfb..847a9f98 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,12 +1,38 @@ -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=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/* + +RUN pip install --no-cache-dir --upgrade pip \ + && pip install --no-cache-dir -r requirements.txt COPY secuscan ./secuscan +# 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 + +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"] 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..2990015a --- /dev/null +++ b/docs/base_image_update_policy.md @@ -0,0 +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 diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 2cc4c3e2..94960ccd 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..92a45970 --- /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..fe82b391 --- /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