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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 130 additions & 0 deletions .github/workflows/docker-hardening.yml
Original file line number Diff line number Diff line change
@@ -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
180 changes: 180 additions & 0 deletions .github/workflows/trivy-scan.yml
Original file line number Diff line number Diff line change
@@ -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
32 changes: 29 additions & 3 deletions backend/Dockerfile
Original file line number Diff line number Diff line change
@@ -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 \

Check failure on line 21 in backend/Dockerfile

View workflow job for this annotation

GitHub Actions / Hardening checks – backend

DL3013 warning: Pin versions in pip. Instead of `pip install <package>` use `pip install <package>==<version>` or `pip install --requirement <requirements file>`
&& 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"]
CMD ["uvicorn", "secuscan.main:app", "--host", "0.0.0.0", "--port", "8081"]
Loading
Loading