From c5666dfe4b5238da1d6ca3ae136a69e5b3a3135c Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Mon, 24 Aug 2026 10:00:08 -0700 Subject: [PATCH 1/3] security: move runtime to Node 24 and Debian 13 --- .github/workflows/ci.yml | 2 +- .github/workflows/image-security.yml | 84 ++++++++++++++++++++++++++++ Dockerfile | 2 +- Dockerfile.runtime-base | 2 +- docs/devops.md | 13 +++++ scripts/k8s/build-local-image.sh | 4 +- 6 files changed, 102 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/image-security.yml 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..8fd0bc7 --- /dev/null +++ b/.github/workflows/image-security.yml @@ -0,0 +1,84 @@ +name: Runtime Image Security + +on: + pull_request: + paths: + - .github/workflows/image-security.yml + - Dockerfile + - Dockerfile.runtime-base + - package.json + - package-lock.json + push: + branches: + - master + paths: + - .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: | + set -euo pipefail + critical="$(jq '[.Results[]?.Vulnerabilities[]? | select(.Severity == "CRITICAL")] | length' trivy-image.json)" + high="$(jq '[.Results[]?.Vulnerabilities[]? | select(.Severity == "HIGH")] | length' trivy-image.json)" + fixable_high="$(jq '[.Results[]?.Vulnerabilities[]? | select(.Severity == "HIGH" and (.FixedVersion // "") != "")] | length' trivy-image.json)" + { + echo "### PaperTrade runtime image security" + echo + echo "- Critical occurrences: ${critical}" + echo "- High occurrences: ${high}" + echo "- Fixable high occurrences: ${fixable_high}" + } >> "${GITHUB_STEP_SUMMARY}" + if [[ "${critical}" -ne 0 || "${fixable_high}" -ne 0 ]]; then + echo "Runtime image policy failed: critical=${critical} fixable_high=${fixable_high}" >&2 + exit 1 + fi + - 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..b4dfad4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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} AS build ARG VITE_APP_VERSION=browser 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..e3a5cc4 100644 --- a/docs/devops.md +++ b/docs/devops.md @@ -85,3 +85,16 @@ 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. + +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}" From 0a33b6897caafc1e1115da3661817f483687cbc3 Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Mon, 24 Aug 2026 10:02:20 -0700 Subject: [PATCH 2/3] fix: declare runtime base before build stages --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index b4dfad4..fd4f28d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,5 @@ 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 From 58c74185e1d17f979f5ce6d340e66f61cddee991 Mon Sep 17 00:00:00 2001 From: Ty J Everett Date: Mon, 24 Aug 2026 10:57:37 -0700 Subject: [PATCH 3/3] security: gate temporary Debian critical exceptions --- .github/scripts/enforce-image-security.sh | 111 ++++++++++++++++++ .../security/trivy-critical-allowlist.json | 74 ++++++++++++ .github/workflows/image-security.yml | 21 +--- docs/devops.md | 18 +++ 4 files changed, 208 insertions(+), 16 deletions(-) create mode 100755 .github/scripts/enforce-image-security.sh create mode 100644 .github/security/trivy-critical-allowlist.json 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/image-security.yml b/.github/workflows/image-security.yml index 8fd0bc7..592cc2b 100644 --- a/.github/workflows/image-security.yml +++ b/.github/workflows/image-security.yml @@ -3,6 +3,8 @@ 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 @@ -12,6 +14,8 @@ on: branches: - master paths: + - .github/scripts/enforce-image-security.sh + - .github/security/trivy-critical-allowlist.json - .github/workflows/image-security.yml - Dockerfile - Dockerfile.runtime-base @@ -59,22 +63,7 @@ jobs: --output /workspace/trivy-image.json \ papertrade:security-candidate - name: Enforce the production image policy - run: | - set -euo pipefail - critical="$(jq '[.Results[]?.Vulnerabilities[]? | select(.Severity == "CRITICAL")] | length' trivy-image.json)" - high="$(jq '[.Results[]?.Vulnerabilities[]? | select(.Severity == "HIGH")] | length' trivy-image.json)" - fixable_high="$(jq '[.Results[]?.Vulnerabilities[]? | select(.Severity == "HIGH" and (.FixedVersion // "") != "")] | length' trivy-image.json)" - { - echo "### PaperTrade runtime image security" - echo - echo "- Critical occurrences: ${critical}" - echo "- High occurrences: ${high}" - echo "- Fixable high occurrences: ${fixable_high}" - } >> "${GITHUB_STEP_SUMMARY}" - if [[ "${critical}" -ne 0 || "${fixable_high}" -ne 0 ]]; then - echo "Runtime image policy failed: critical=${critical} fixable_high=${fixable_high}" >&2 - exit 1 - fi + run: .github/scripts/enforce-image-security.sh - name: Retain the scanner report if: always() uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 diff --git a/docs/devops.md b/docs/devops.md index e3a5cc4..dcfc2fb 100644 --- a/docs/devops.md +++ b/docs/devops.md @@ -93,6 +93,24 @@ 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