diff --git a/.github/scripts/enforce-image-security.sh b/.github/scripts/enforce-image-security.sh new file mode 100755 index 0000000..0f27cc7 --- /dev/null +++ b/.github/scripts/enforce-image-security.sh @@ -0,0 +1,111 @@ +#!/usr/bin/env bash +set -euo pipefail + +report_file="${1:-trivy-image.json}" +allowlist_file="${2:-.github/security/trivy-critical-allowlist.json}" +policy_date="${SECURITY_POLICY_DATE:-$(date -u +%F)}" +summary_file="${GITHUB_STEP_SUMMARY:-/dev/null}" + +if [[ ! -s "$report_file" || ! -s "$allowlist_file" ]]; then + echo "Image security policy input is missing" >&2 + exit 2 +fi + +jq -e ' + type == "array" and length > 0 and + all(.[]; + (.vulnerability | test("^CVE-[0-9]{4}-[0-9]+$")) and + (.package | type == "string" and length > 0) and + (.installed_version | type == "string" and length > 0) and + (.reviewed_on | test("^[0-9]{4}-[0-9]{2}-[0-9]{2}$")) and + (.expires | test("^[0-9]{4}-[0-9]{2}-[0-9]{2}$")) and + (.expires >= .reviewed_on) and + (.rationale | type == "string" and length >= 40) and + (.source | test("^https://security-tracker\\.debian\\.org/tracker/CVE-"))) and + ([.[] | [.vulnerability, .package, .installed_version] | join("|")] | + length == (unique | length)) +' "$allowlist_file" >/dev/null + +policy_json="$(jq -n \ + --slurpfile report "$report_file" \ + --slurpfile allowlist "$allowlist_file" \ + --arg today "$policy_date" ' + def findings: $report[0].Results[]?.Vulnerabilities[]?; + def same_finding($finding; $exception): + $exception.vulnerability == $finding.VulnerabilityID and + $exception.package == $finding.PkgName and + $exception.installed_version == $finding.InstalledVersion; + def compact_finding: + { + vulnerability: .VulnerabilityID, + package: .PkgName, + installed_version: .InstalledVersion, + fixed_version: (.FixedVersion // ""), + status: (.Status // "unknown") + }; + { + critical: [findings | select(.Severity == "CRITICAL")] | length, + high: [findings | select(.Severity == "HIGH")] | length, + fixable_critical: [ + findings | + select(.Severity == "CRITICAL" and ((.FixedVersion // "") | length > 0)) | + compact_finding + ], + fixable_high: [ + findings | + select(.Severity == "HIGH" and ((.FixedVersion // "") | length > 0)) | + compact_finding + ], + unexpected_critical: [ + findings | + select(.Severity == "CRITICAL") as $finding | + select([ + $allowlist[0][] | + select(same_finding($finding; .) and .expires >= $today) + ] | length == 0) | + compact_finding + ], + expired_exceptions: [ + $allowlist[0][] | select(.expires < $today) | + {vulnerability, package, installed_version, expires} + ], + stale_exceptions: [ + $allowlist[0][] as $exception | + select([ + findings | select(same_finding(.; $exception)) + ] | length == 0) | + $exception | {vulnerability, package, installed_version, expires} + ] + } +')" + +critical="$(jq -r '.critical' <<<"$policy_json")" +high="$(jq -r '.high' <<<"$policy_json")" +fixable_critical="$(jq -r '.fixable_critical | length' <<<"$policy_json")" +fixable_high="$(jq -r '.fixable_high | length' <<<"$policy_json")" +unexpected_critical="$(jq -r '.unexpected_critical | length' <<<"$policy_json")" +expired_exceptions="$(jq -r '.expired_exceptions | length' <<<"$policy_json")" +stale_exceptions="$(jq -r '.stale_exceptions | length' <<<"$policy_json")" + +{ + echo "### PaperTrade runtime image security" + echo + echo "- Policy date: ${policy_date}" + echo "- Critical occurrences: ${critical}" + echo "- High occurrences: ${high}" + echo "- Fixable critical occurrences: ${fixable_critical}" + echo "- Fixable high occurrences: ${fixable_high}" + echo "- Unexpected or expired critical occurrences: ${unexpected_critical}" + echo "- Expired exception records: ${expired_exceptions}" + echo "- Stale exception records: ${stale_exceptions}" +} >>"$summary_file" + +if (( fixable_critical > 0 || fixable_high > 0 || unexpected_critical > 0 || + expired_exceptions > 0 || stale_exceptions > 0 )); then + echo "Runtime image policy failed" >&2 + jq '{fixable_critical, fixable_high, unexpected_critical, expired_exceptions, stale_exceptions}' \ + <<<"$policy_json" >&2 + exit 1 +fi + +echo "Runtime image policy passed: critical=${critical} high=${high} exceptions=${critical}" diff --git a/.github/security/trivy-critical-allowlist.json b/.github/security/trivy-critical-allowlist.json new file mode 100644 index 0000000..319bf2a --- /dev/null +++ b/.github/security/trivy-critical-allowlist.json @@ -0,0 +1,74 @@ +[ + { + "vulnerability": "CVE-2026-58016", + "package": "libglib2.0-0t64", + "installed_version": "2.84.4-3~deb13u3", + "reviewed_on": "2026-08-24", + "expires": "2026-09-07", + "rationale": "Debian Trixie marks the GDBus introspection XML denial of service as minor/no-DSA and has no Trixie fix; PaperTrade does not parse caller-supplied D-Bus introspection XML.", + "source": "https://security-tracker.debian.org/tracker/CVE-2026-58016" + }, + { + "vulnerability": "CVE-2026-34873", + "package": "libmbedcrypto16", + "installed_version": "3.6.5-0.1~deb13u1", + "reviewed_on": "2026-08-24", + "expires": "2026-09-07", + "rationale": "Debian Trixie marks this TLS 1.3 resumption issue as minor/no-DSA and has no Trixie fix; the runtime carries the crypto library transitively and does not use Mbed TLS for its public HTTPS termination.", + "source": "https://security-tracker.debian.org/tracker/CVE-2026-34873" + }, + { + "vulnerability": "CVE-2026-34875", + "package": "libmbedcrypto16", + "installed_version": "3.6.5-0.1~deb13u1", + "reviewed_on": "2026-08-24", + "expires": "2026-09-07", + "rationale": "Debian Trixie marks the FFDH export overflow as minor/no-DSA and has no Trixie fix; PaperTrade does not expose an Mbed TLS FFDH key-export operation.", + "source": "https://security-tracker.debian.org/tracker/CVE-2026-34875" + }, + { + "vulnerability": "CVE-2026-6653", + "package": "libxml2", + "installed_version": "2.12.7+dfsg+really2.9.14-2.1+deb13u3", + "reviewed_on": "2026-08-24", + "expires": "2026-09-07", + "rationale": "Debian Trixie marks the crafted-XML denial of service as minor/no-DSA and has no Trixie fix. Document conversion can process untrusted input, so this remains a short exception that must be removed as soon as Debian publishes a fix.", + "source": "https://security-tracker.debian.org/tracker/CVE-2026-6653" + }, + { + "vulnerability": "CVE-2026-13221", + "package": "perl-base", + "installed_version": "5.40.1-6", + "reviewed_on": "2026-08-24", + "expires": "2026-09-07", + "rationale": "Trixie has no fixed Perl package; PaperTrade does not construct attacker-controlled Perl regular expressions with more than 65,535 literal branches.", + "source": "https://security-tracker.debian.org/tracker/CVE-2026-13221" + }, + { + "vulnerability": "CVE-2026-42496", + "package": "perl-base", + "installed_version": "5.40.1-6", + "reviewed_on": "2026-08-24", + "expires": "2026-09-07", + "rationale": "Debian postponed the Trixie Archive::Tar symlink fix while upstream regressions are resolved; the application does not extract caller-supplied archives through Perl Archive::Tar.", + "source": "https://security-tracker.debian.org/tracker/CVE-2026-42496" + }, + { + "vulnerability": "CVE-2026-8376", + "package": "perl-base", + "installed_version": "5.40.1-6", + "reviewed_on": "2026-08-24", + "expires": "2026-09-07", + "rationale": "The overflow requires a 32-bit Perl build and an attacker-controlled regular expression; the production image is Linux/amd64. Debian classifies it as minor/no-DSA with no current Trixie fix.", + "source": "https://security-tracker.debian.org/tracker/CVE-2026-8376" + }, + { + "vulnerability": "CVE-2026-54058", + "package": "python3-pil", + "installed_version": "11.1.0-5+deb13u4", + "reviewed_on": "2026-08-24", + "expires": "2026-09-07", + "rationale": "Debian Trixie marks the McIDAS AREA mmap disclosure/denial of service as minor/no-DSA and has no Trixie fix. The affected decoder is not a supported PaperTrade input path, but conversion handles untrusted files, so the exception is deliberately short.", + "source": "https://security-tracker.debian.org/tracker/CVE-2026-54058" + } +] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 71b437c..498722f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,7 +13,7 @@ jobs: - uses: actions/checkout@v7.0.0 - uses: actions/setup-node@v6.4.0 with: - node-version: 22 + node-version: 24 cache: npm - run: npm ci env: diff --git a/.github/workflows/image-security.yml b/.github/workflows/image-security.yml new file mode 100644 index 0000000..592cc2b --- /dev/null +++ b/.github/workflows/image-security.yml @@ -0,0 +1,73 @@ +name: Runtime Image Security + +on: + pull_request: + paths: + - .github/scripts/enforce-image-security.sh + - .github/security/trivy-critical-allowlist.json + - .github/workflows/image-security.yml + - Dockerfile + - Dockerfile.runtime-base + - package.json + - package-lock.json + push: + branches: + - master + paths: + - .github/scripts/enforce-image-security.sh + - .github/security/trivy-critical-allowlist.json + - .github/workflows/image-security.yml + - Dockerfile + - Dockerfile.runtime-base + - package.json + - package-lock.json + schedule: + - cron: "41 11 * * 2" + workflow_dispatch: + +permissions: + contents: read + +jobs: + scan: + runs-on: ubuntu-24.04 + timeout-minutes: 60 + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - name: Build current runtime base + run: | + set -euo pipefail + docker build \ + --file Dockerfile.runtime-base \ + --tag papertrade-runtime-base:security-candidate \ + . + - name: Build current application image + run: | + set -euo pipefail + docker build \ + --build-arg RUNTIME_BASE_IMAGE=papertrade-runtime-base:security-candidate \ + --build-arg VITE_APP_VERSION="${GITHUB_SHA}" \ + --file Dockerfile \ + --tag papertrade:security-candidate \ + . + - name: Scan the exact candidate image + run: | + set -euo pipefail + docker run --rm \ + --volume /var/run/docker.sock:/var/run/docker.sock \ + --volume "${GITHUB_WORKSPACE}:/workspace" \ + aquasec/trivy:0.73.0@sha256:7cced7cae583819fc7806d4cbc0dbbc7cad18b99f7d3e235192e6da8c091045c \ + image \ + --scanners vuln \ + --format json \ + --output /workspace/trivy-image.json \ + papertrade:security-candidate + - name: Enforce the production image policy + run: .github/scripts/enforce-image-security.sh + - name: Retain the scanner report + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: papertrade-trivy-${{ github.sha }} + path: trivy-image.json + if-no-files-found: warn diff --git a/Dockerfile b/Dockerfile index 3d6f565..fd4f28d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,5 @@ -ARG NODE_IMAGE=node:22-bookworm-slim@sha256:d649c27dae7ba0137b3cef5dd75baa422c08dc3d9e3fc0c23dfb172dc3cc6436 +ARG NODE_IMAGE=node:24-trixie-slim@sha256:0711b541c1c33a8a530ac4f0d391baa9a15b3d804695b1b24a47daa5fb60e74d +ARG RUNTIME_BASE_IMAGE=papertrade-runtime-base:local FROM ${NODE_IMAGE} AS build ARG VITE_APP_VERSION=browser @@ -15,7 +16,6 @@ RUN npm ci && npm cache clean --force COPY . . RUN npm run build && npm prune --omit=dev -ARG RUNTIME_BASE_IMAGE=papertrade-runtime-base:local FROM ${RUNTIME_BASE_IMAGE} ENV NODE_ENV=production diff --git a/Dockerfile.runtime-base b/Dockerfile.runtime-base index 37a8bd7..5510aa2 100644 --- a/Dockerfile.runtime-base +++ b/Dockerfile.runtime-base @@ -1,4 +1,4 @@ -ARG NODE_IMAGE=node:22-bookworm-slim@sha256:d649c27dae7ba0137b3cef5dd75baa422c08dc3d9e3fc0c23dfb172dc3cc6436 +ARG NODE_IMAGE=node:24-trixie-slim@sha256:0711b541c1c33a8a530ac4f0d391baa9a15b3d804695b1b24a47daa5fb60e74d FROM ${NODE_IMAGE} LABEL org.opencontainers.image.title="PaperTrade runtime base" diff --git a/docs/devops.md b/docs/devops.md index 814e740..dcfc2fb 100644 --- a/docs/devops.md +++ b/docs/devops.md @@ -85,3 +85,34 @@ The workflow accepts an optional `source_sha` and a `build_runtime_base` switch. Keep `build_runtime_base=false` for normal deploys. Set it to `true` only after reviewing changes to `Dockerfile.runtime-base`, Node major versions, or document conversion dependencies. + +## Runtime image security + +`.github/workflows/image-security.yml` builds the complete runtime image on a +GitHub-hosted Linux/amd64 runner for every runtime-input change, once a week, +and on manual dispatch. It scans the exact built image with digest-pinned +Trivy `0.73.0`, retains the JSON report, and rejects any critical occurrence +or any high occurrence for which the distribution publishes a fixed version. +An unfixable critical can pass only when its exact CVE, binary package, and +installed version appear in +`.github/security/trivy-critical-allowlist.json` with a current review, +Debian-tracker source, risk rationale, and unexpired deadline. New, expired, +stale, or newly fixable criticals fail the build. Exception records are short: +the initial Debian Trixie set expires on `2026-09-07`, so a weekly scan cannot +turn a temporary upstream wait into permanent acceptance. + +The 2026-08-24 review accepted eight such occurrences for at most fourteen +days. Debian classifies the GLib, Mbed TLS, libxml2, Perl Archive::Tar, 32-bit +Perl regex, and Pillow findings as minor/no-DSA or postponed in Trixie. The +runtime does not expose the affected D-Bus introspection, Mbed TLS termination, +Perl archive extraction, or enormous/32-bit Perl regex paths. PaperTrade does +process untrusted documents, so the libxml2 and Pillow exceptions remain +deliberately short even though their specific XML and McIDAS AREA paths are not +supported application inputs. Remove an exception as soon as its finding +disappears; the gate rejects a stale record rather than silently accumulating +waivers. + +The scanner gate runs outside the production cluster. This keeps large +LibreOffice and Calibre rebuild downloads off the Evans Creek Starlink links +and prevents a security candidate build from competing with production pods. +The production deploy remains a separate, explicitly dispatched workflow. diff --git a/scripts/k8s/build-local-image.sh b/scripts/k8s/build-local-image.sh index 9c873bb..9ab0892 100755 --- a/scripts/k8s/build-local-image.sh +++ b/scripts/k8s/build-local-image.sh @@ -13,7 +13,7 @@ Environment: BUILD_TARGET app, runtime-base, or all. Defaults to app. SOURCE_SHA Source commit SHA. Defaults to current git HEAD. IMAGE_TAG App image tag. Defaults to -production-. - RUNTIME_BASE_TAG Runtime base tag. Defaults to node22-bookworm-docs-2026-08-16-r1. + RUNTIME_BASE_TAG Runtime base tag. Defaults to node24-trixie-docs-2026-08-24-r1. RUNTIME_BASE_IMAGE Pull image used as Dockerfile runtime base. Defaults to /p2ppsr/papertrade-runtime-base:. REGISTRY_PUSH Push registry. Defaults to 10.152.183.28:5000. @@ -36,7 +36,7 @@ source_sha="${SOURCE_SHA:-$(git rev-parse HEAD)}" short_sha="${source_sha:0:12}" image_date="${IMAGE_DATE:-$(date -u +%F)}" image_tag="${IMAGE_TAG:-${short_sha}-production-${image_date}}" -runtime_base_tag="${RUNTIME_BASE_TAG:-node22-bookworm-docs-2026-08-16-r1}" +runtime_base_tag="${RUNTIME_BASE_TAG:-node24-trixie-docs-2026-08-24-r1}" registry_push="${REGISTRY_PUSH:-10.152.183.28:5000}" registry_pull="${REGISTRY_PULL:-registry.cars-operator-system.svc.cluster.local:5000}" kubectl_cmd="${KUBECTL:-kubectl}"