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
111 changes: 111 additions & 0 deletions .github/scripts/enforce-image-security.sh
Original file line number Diff line number Diff line change
@@ -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}"
74 changes: 74 additions & 0 deletions .github/security/trivy-critical-allowlist.json
Original file line number Diff line number Diff line change
@@ -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"
}
]
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
73 changes: 73 additions & 0 deletions .github/workflows/image-security.yml
Original file line number Diff line number Diff line change
@@ -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
4 changes: 2 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion Dockerfile.runtime-base
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
31 changes: 31 additions & 0 deletions docs/devops.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
4 changes: 2 additions & 2 deletions scripts/k8s/build-local-image.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 <short-sha>-production-<utc-date>.
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
<REGISTRY_PULL>/p2ppsr/papertrade-runtime-base:<tag>.
REGISTRY_PUSH Push registry. Defaults to 10.152.183.28:5000.
Expand All @@ -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}"
Expand Down
Loading