From bf854ec787851aa1737033ea581e25acf44ca536 Mon Sep 17 00:00:00 2001 From: Ayla Croft Date: Thu, 8 Oct 2026 03:15:24 -0400 Subject: [PATCH 1/4] feat(s130): the hardened headless image and the evidence about it The hardening half of slice 130. Release composition waits on slice 133. One recipe, two artifacts. ci/ironbank/ is laid out as the repo1 project root (Dockerfile, hardening_manifest.yaml, LICENSE, README.md, scripts/entrypoint.sh): OTP built from source with FIPS on UBI9, the release copied onto UBI9 micro, the base through ARG BASE_*, every input a manifest resource with its digest and no network except dnf, UID 10001, Erlang distribution off and no baked cookie. ci/headless/ keeps what belongs to our published image: digest pins, justifications, allowances, STIG dispositions and the submission holds. The Debian Containerfile from slice 061 is removed. Four tasks carry the checks: - trinity.ironbank.lint covers AC2, AC2a, AC6 and AC6a on the tree. - trinity.image.inspect covers AC1 and AC6 on the built image. - trinity.image.findings covers AC3 and AC4, keyed the way Iron Bank's tracker keys a finding. - trinity.image.stig covers AC5 from OpenSCAP's own results. The scanner is grype, chosen by measurement over Trivy: only grype found OTP inside the release. Red evidence R1 to R11 is in the slice NOTES with the logs in proof/. The live image's scan is red on 14 OTP findings, fixed in 28.5.0.7; the toolchain bump is the owner's to approve, so they stay unjustified. Signed-off-by: Ayla Croft --- .github/workflows/headless-image.yml | 145 ++++ CHANGELOG.md | 21 + VERSIONS.md | 4 + ci/headless/Containerfile | 50 -- ci/headless/bases.env | 18 + ci/headless/image_allowances.yaml | 55 ++ ci/headless/justifications.yaml | 105 +++ ci/headless/stig_dispositions.yaml | 40 + ci/headless/submission_holds.yaml | 35 + ci/ironbank/Dockerfile | 154 ++++ ci/ironbank/LICENSE | 202 +++++ ci/ironbank/README.md | 139 ++++ ci/ironbank/hardening_manifest.yaml | 111 +++ ci/ironbank/scripts/entrypoint.sh | 18 + config/config.exs | 8 + docs/mcp-server.md | 8 +- docs/regulated/README.md | 5 +- docs/regulated/headless-image.md | 129 ++++ docs/regulated/stig-applicability.md | 507 +++++++++++++ lib/mix/tasks/trinity.image.findings.ex | 221 ++++++ lib/mix/tasks/trinity.image.inspect.ex | 641 ++++++++++++++++ lib/mix/tasks/trinity.image.stig.ex | 405 ++++++++++ lib/mix/tasks/trinity.ironbank.lint.ex | 698 ++++++++++++++++++ lib/trinity/versions.ex | 38 + mix.exs | 2 +- scripts/headless_image.sh | 129 ++++ scripts/stig_scan.sh | 77 ++ test/ironbank_submission_test.exs | 358 +++++++++ .../mix/tasks/trinity_image_findings_test.exs | 209 ++++++ test/mix/tasks/trinity_image_inspect_test.exs | 302 ++++++++ test/mix/tasks/trinity_image_stig_test.exs | 209 ++++++ 31 files changed, 4988 insertions(+), 55 deletions(-) create mode 100644 .github/workflows/headless-image.yml delete mode 100644 ci/headless/Containerfile create mode 100644 ci/headless/bases.env create mode 100644 ci/headless/image_allowances.yaml create mode 100644 ci/headless/justifications.yaml create mode 100644 ci/headless/stig_dispositions.yaml create mode 100644 ci/headless/submission_holds.yaml create mode 100644 ci/ironbank/Dockerfile create mode 100644 ci/ironbank/LICENSE create mode 100644 ci/ironbank/README.md create mode 100644 ci/ironbank/hardening_manifest.yaml create mode 100755 ci/ironbank/scripts/entrypoint.sh create mode 100644 docs/regulated/headless-image.md create mode 100644 docs/regulated/stig-applicability.md create mode 100644 lib/mix/tasks/trinity.image.findings.ex create mode 100644 lib/mix/tasks/trinity.image.inspect.ex create mode 100644 lib/mix/tasks/trinity.image.stig.ex create mode 100644 lib/mix/tasks/trinity.ironbank.lint.ex create mode 100755 scripts/headless_image.sh create mode 100755 scripts/stig_scan.sh create mode 100644 test/ironbank_submission_test.exs create mode 100644 test/mix/tasks/trinity_image_findings_test.exs create mode 100644 test/mix/tasks/trinity_image_inspect_test.exs create mode 100644 test/mix/tasks/trinity_image_stig_test.exs diff --git a/.github/workflows/headless-image.yml b/.github/workflows/headless-image.yml new file mode 100644 index 0000000..44acefd --- /dev/null +++ b/.github/workflows/headless-image.yml @@ -0,0 +1,145 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +# +# Slice 130: builds the hardened headless image from ci/ironbank/Dockerfile the way Iron Bank's +# pipeline would (scripts/headless_image.sh stages every input from hardening_manifest.yaml and +# checks its digest; the Dockerfile reaches no network), then checks the built image, never its +# recipe (docs/regulated/headless-image.md): +# +# the submission tree and the published build's base pins mix trinity.ironbank.lint +# the image starts its runtime bin/headless eval, then start +# AC1 and AC6: user and layer contents mix trinity.image.inspect +# AC3 and AC4: vulnerability scan against justifications grype, then mix trinity.image.findings +# AC5: STIG applicability statement scripts/stig_scan.sh, then mix trinity.image.stig +# +# Every check after the build runs and reports even when an earlier one fails, so one red check +# does not hide the others; the job fails if any did. It runs when an input of the image or of the +# checks changes, on demand, and daily, because new advisories appear against an image that has +# not changed (Iron Bank rescans its images on a schedule for the same reason). Nothing is pushed: +# publishing and signing the image is slice 131's. +name: headless-image + +on: + push: + paths: + - ci/ironbank/** + - ci/headless/** + - scripts/headless_image.sh + - scripts/stig_scan.sh + - lib/mix/tasks/trinity.ironbank.lint.ex + - lib/mix/tasks/trinity.image.*.ex + - mix.exs + - mix.lock + - config/** + - .tool-versions + - .github/workflows/headless-image.yml + schedule: + - cron: "17 6 * * *" + workflow_dispatch: + +permissions: + contents: read + +jobs: + image: + runs-on: ubuntu-latest + # Not measured on a runner yet: OTP from source took 40 s and the dependency compile, EXLA's C++ + # included, about 155 s on a 32-core machine (slice 130 NOTES). The ceiling is generous until the + # first runs say otherwise. + timeout-minutes: 120 + env: + IMAGE: trinity-headless:ci + # grype v0.119.0, pinned by digest: a scanner is code that reads the image and runs in CI, and + # a tag can be moved (slice 130 NOTES, D-130-6). + GRYPE: anchore/grype:v0.119.0@sha256:8c2c9234a345577a6d321a4753aa3ee1276d8975c8452d2344a56b57733ecad3 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + + - uses: erlef/setup-beam@54075bcc5e249e4758d363f27d099f55d843f124 # v1.24.1 + with: + version-file: .tool-versions + version-type: strict + + - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + with: + path: | + deps + _build + key: ${{ runner.os }}-mix-image-${{ hashFiles('mix.lock') }} + restore-keys: ${{ runner.os }}-mix-image- + + - name: Compile the checks + run: | + mix deps.get + mix compile + + - name: The submission tree and the published build's base pins + run: mix trinity.ironbank.lint + + - name: Build the image from staged, digest-checked resources + id: build + run: | + scripts/headless_image.sh build "$IMAGE" + docker image inspect "$IMAGE" --format 'id={{.Id}} size={{.Size}} user={{.Config.User}}' + echo "digest=$(docker image inspect "$IMAGE" --format '{{.Id}}')" >> "$GITHUB_OUTPUT" + + # `bin/headless version` exits 0 without starting the VM (slice 130 NOTES, measurement 1), so + # this starts it: eval runs the runtime, and start must answer on its one port with a 401 for a + # request without the bearer. + - name: The image starts its runtime, read-only + if: ${{ !cancelled() && steps.build.outcome == 'success' }} + run: | + docker run --rm --read-only --tmpfs /tmp "$IMAGE" /opt/trinity/bin/headless eval \ + 'IO.puts("otp=#{System.otp_release()} fips_provider=#{inspect(:crypto.info()[:fips_provider_available])} node=#{inspect(Node.self())}")' + cid=$(docker run -d --read-only --tmpfs /tmp --cap-drop ALL --security-opt no-new-privileges \ + -p 127.0.0.1:4000:4000 -e TRINITY_MCP_SERVER_TOKEN=ci-token "$IMAGE") + for _ in $(seq 1 60); do + curl -s -o /dev/null http://127.0.0.1:4000/ && break + sleep 1 + done + status=$(curl -s -o /dev/null -w '%{http_code}' -X POST http://127.0.0.1:4000/mcp \ + -H 'content-type: application/json' -d '{"jsonrpc":"2.0","id":1,"method":"ping"}') + docker logs "$cid" | tail -20 + docker rm -f "$cid" >/dev/null + echo "POST /mcp without a bearer: $status" + test "$status" = 401 + + - name: "AC1 and AC6: the image's user and its layers" + if: ${{ !cancelled() && steps.build.outcome == 'success' }} + run: mix trinity.image.inspect "$IMAGE" + + - name: "AC3: scan the image, with no network at scan time" + id: scan + if: ${{ !cancelled() && steps.build.outcome == 'success' }} + run: | + mkdir -p out/grype-db out/tmp + docker save "$IMAGE" -o out/image.tar + docker run --rm --user "$(id -u):$(id -g)" -v "$PWD/out:/s" -e GRYPE_DB_CACHE_DIR=/s/grype-db "$GRYPE" db update + docker run --rm --network none --user "$(id -u):$(id -g)" -v "$PWD/out:/s" \ + -e TMPDIR=/s/tmp -e GRYPE_DB_CACHE_DIR=/s/grype-db \ + -e GRYPE_DB_AUTO_UPDATE=false -e GRYPE_CHECK_FOR_APP_UPDATE=false \ + "$GRYPE" docker-archive:/s/image.tar -o json --file /s/grype.json + rm -f out/image.tar + + - name: "AC3 and AC4: findings against ci/headless/justifications.yaml" + if: ${{ !cancelled() && steps.scan.outcome == 'success' }} + run: mix trinity.image.findings --scan out/grype.json --out out/findings.csv + + - name: "AC5: the STIG applicability statement" + if: ${{ !cancelled() && steps.build.outcome == 'success' }} + run: | + scripts/stig_scan.sh "$IMAGE" out/stig + mix trinity.image.stig --results out/stig/stig-results.xml --out out/stig/stig-applicability.md \ + --image "$IMAGE" --digest "${{ steps.build.outputs.digest }}" + + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + if: ${{ !cancelled() }} + with: + name: headless-image-evidence + path: | + out/grype.json + out/findings.csv + out/stig/stig-results.xml + out/stig/stig-report.html + out/stig/stig-applicability.md + if-no-files-found: warn diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d5f19a..8215e47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,27 @@ evidence for each increment is retained by the maintainers and summarised here. tree, engineering rules consolidated into `docs/03-conventions.md`, and this changelog added as the public record of delivered work. +## 2026-10-08 + +### `slice/130`: the headless image, hardened, and checked as built + +The headless container is what a government program would actually run, and it was built to run +rather than to be assessed: Debian, a package manager and `curl` in the final image, and an Erlang +distribution listener on every interface whose cookie was baked into the image for anyone who +pulled it. It is now built on UBI9 micro from one recipe, `ci/ironbank/Dockerfile`, which is laid +out as an Iron Bank submission and reaches no network: every input arrives as a file with its +digest. The image this project publishes is the same file built with its bases pinned by digest. + +What is claimed about the image is checked against the built image, never against its recipe. It +runs as UID 10001 with a read-only root filesystem and distribution off. Its layers carry no +package manager, compiler, shell history, documentation, SUID file or key material beyond what is +allowed by name with a reason. A vulnerability scan fails on any High or Critical finding without a +justification, and on a justification for something no longer found. Its STIG applicability +statement is generated from OpenSCAP's evaluation of it, with every rule of the profile given a +disposition. The scanner was chosen by measurement: of the two tried, only grype sees the Erlang +runtime inside a release. The image is not in Iron Bank and this is not a compliance determination; +`docs/regulated/headless-image.md` says what it is, how to check it, and what is still open. + ## 2026-09-26 ### `slice/128`: the checks that guard attribution now say what they check diff --git a/VERSIONS.md b/VERSIONS.md index 4cf769e..55f2a32 100644 --- a/VERSIONS.md +++ b/VERSIONS.md @@ -75,6 +75,10 @@ never pin a version hex marks as retired or vulnerable. | `Tauri CLI` | **2.11.4** | 📐 `_build/_tauri/bin/cargo-tauri tauri --version` | Measured at Slice 001 line 3. Not on `PATH` and not pinned by any file in the tree: `ex_tauri` provisions it with `cargo install tauri-cli --version ^2 --root .` inside `_build/_tauri`, which is gitignored, so `cargo tauri --version` exits 101 on a fresh machine. 📐 rather than ✅ because nothing at this sha verifies it. The `^2` floats; 2.11.4 is what it resolved to on 2026-09-06. | | `Zig` | **0.16.0** | ✅ `.tool-versions` | Measured at Slice 001 line 3: burrito 1.6.0 compares Zig for **equality**, not a range (`@zig_version_expected` in `deps/burrito/lib/burrito.ex`), and exits 1 on any other version. `zig version` reports 0.16.0, exit 0. Installed through the asdf zig plugin, added this slice. Corrected 2026-09-06: this row previously read `version required by Burrito | ✅ .tool-versions` and that file carried no zig line. | | `FIPS leg base image (UBI9)` | **sha256:9295c5c688f487fa5cf27a734fa55ecd57aeb7dc0904ba537da4f42dfa1d0acb** | ✅ `ci/fips/Containerfile` | Added at Slice 003: the base of the FIPS build leg's image (docs/fips-leg.md), `registry.access.redhat.com/ubi9/ubi:latest` resolved by digest on 2026-09-20 (Red Hat Enterprise Linux release 9.8, `openssl-libs 3.5.8-1.el9_8`, `openssl-fips-provider-so 3.0.7-11.el9_8`). The image itself is OTP 28.5.0.5 built from source with `--enable-fips` against that OpenSSL, plus Elixir 1.20.4; the two archives are pinned by SHA-256 in the Containerfile and the image tag is `scripts/fips_image_tag.sh` over `.tool-versions` and the Containerfile. The FIPS provider the image runs is what the distribution ships and names; docs/fips-leg.md states what Trinity does and does not claim about it. | +| `Headless image runtime base (UBI9 micro)` | **sha256:932aec77f5b86a5dba854a18b14a38725b296967e7dc9c7a1f4d7f0bf82e1ce5** | ✅ `ci/headless/bases.env` | Added at Slice 130: the final stage of the hardened headless image as this project publishes it (`ci/ironbank/Dockerfile`, docs/regulated/headless-image.md), `registry.access.redhat.com/ubi9/ubi-micro` 9.8. The digest is the one Iron Bank's own ubi9-micro image declares as its source, so the published build and an Iron Bank build start from the same bytes. Iron Bank builds the same Dockerfile from its own registry with the tags in `ci/ironbank/hardening_manifest.yaml`. | +| `Headless image builder base (UBI9)` | **sha256:c26665a762876d5de27d1ed50e029ab3bc80ef3a3e155824ee7ab43e70055b63** | ✅ `ci/headless/bases.env` | Added at Slice 130: the builder stages of the hardened headless image (OTP from source, the release, the runtime root filesystem), `registry.access.redhat.com/ubi9` 9.8, the digest Iron Bank's own ubi9 image declares as its source. Nothing from these stages reaches the final image except the release and the root filesystem `dnf --installroot` assembles. | +| `Image vulnerability scanner (grype)` | **v0.119.0** | ✅ `.github/workflows/headless-image.yml` | Added at Slice 130 and chosen by measurement over Trivy 0.74.0: grype identifies the Erlang/OTP runtime inside a release from `beam.smp` and matches its advisories, which Trivy does not see at all (slice 130 NOTES, D-130-6). Pinned by image digest in the workflow, and to a release at least two weeks old when chosen. | +| `SCAP Security Guide` | **0.1.82** | ✅ `scripts/stig_scan.sh` | Added at Slice 130: the datastream (`ssg-rhel9-ds.xml`) and DISA STIG profile OpenSCAP evaluates the headless image against, the profile Iron Bank's pipeline uses for a UBI9 image. The release zip is checked against its published SHA-512, pinned beside the version in the script. | ### Core libraries diff --git a/ci/headless/Containerfile b/ci/headless/Containerfile deleted file mode 100644 index 293be5d..0000000 --- a/ci/headless/Containerfile +++ /dev/null @@ -1,50 +0,0 @@ -# SPDX-FileCopyrightText: Sudo Apt Holdings LLC -# SPDX-License-Identifier: Apache-2.0 -# -# Slice 061: the headless release in a container. Two stages: the release is assembled on the -# Elixir image whose toolchain is the tree's (.tool-versions: Erlang 28.5.0.5, Elixir 1.20.4), -# then copied onto a slim Debian of the same generation, so the ERTS the release carries finds -# the libc it was built against. No desktop shell, no Burrito: `mix release headless`. -# -# docker build -f ci/headless/Containerfile -t trinity-headless:local . -# docker run --rm -p 4000:4000 -e TRINITY_MCP_SERVER_TOKEN=... trinity-headless:local -# -# The container binds 0.0.0.0 inside its own network namespace (TRINITY_BIND) so the published -# port reaches it; the bearer is required on /mcp whatever the bind. The data directory is -# /data/trinity (XDG_DATA_HOME=/data, a volume): the databases, the receipt keys, the -# envelope key, the generated token. -FROM hexpm/elixir:1.20.4-erlang-28.5.0.5-debian-bookworm-20260824-slim AS build - -RUN apt-get update -y && apt-get install -y --no-install-recommends \ - build-essential git curl ca-certificates cmake python3 pkg-config \ - && apt-get clean && rm -rf /var/lib/apt/lists/* - -WORKDIR /app -ENV MIX_ENV=prod LANG=C.UTF-8 -RUN mix local.hex --force && mix local.rebar --force - -COPY mix.exs mix.lock ./ -COPY config config -RUN mix deps.get --only prod - -COPY lib lib -COPY priv priv -COPY assets assets -RUN mix assets.deploy && mix compile && mix release headless --overwrite - -FROM debian:bookworm-20260824-slim AS app - -RUN apt-get update -y && apt-get install -y --no-install-recommends \ - libstdc++6 openssl libncurses6 ca-certificates curl locales \ - && apt-get clean && rm -rf /var/lib/apt/lists/* \ - && sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen - -ENV LANG=en_US.UTF-8 LANGUAGE=en_US:en LC_ALL=en_US.UTF-8 -ENV TRINITY_MODE=headless TRINITY_BIND=0.0.0.0 PORT=4000 XDG_DATA_HOME=/data -WORKDIR /app -RUN useradd --create-home --uid 10001 trinity && mkdir -p /data && chown trinity:trinity /data /app -COPY --from=build --chown=trinity:trinity /app/_build/prod/rel/headless ./ -USER trinity -VOLUME ["/data"] -EXPOSE 4000 -CMD ["bin/headless", "start"] diff --git a/ci/headless/bases.env b/ci/headless/bases.env new file mode 100644 index 0000000..6fec2f1 --- /dev/null +++ b/ci/headless/bases.env @@ -0,0 +1,18 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +# +# The bases of the headless image this project publishes, as build arguments for +# ci/ironbank/Dockerfile (slice 130). Iron Bank builds the same file with its own registry and +# the tags in hardening_manifest.yaml; this build pins every base by digest instead, through the +# tag argument, so a base change is a diff here and never a silent drift under a moving tag. +# `test/ironbank_submission_test.exs` resolves every FROM in the Dockerfile against this file and +# fails on one that does not carry a digest. +# +# Both digests are the ones Iron Bank's own UBI 9.8 images declare as their sources +# (dsop/redhat/ubi/9.x/ubi9-micro and .../ubi9, hardening_manifest.yaml, read 2026-10-08), so +# this build and theirs start from the same bytes. +BASE_REGISTRY=registry.access.redhat.com +BASE_IMAGE=ubi9/ubi-micro +BASE_TAG=9.8@sha256:932aec77f5b86a5dba854a18b14a38725b296967e7dc9c7a1f4d7f0bf82e1ce5 +BUILDER_IMAGE=ubi9 +BUILDER_TAG=9.8@sha256:c26665a762876d5de27d1ed50e029ab3bc80ef3a3e155824ee7ab43e70055b63 diff --git a/ci/headless/image_allowances.yaml b/ci/headless/image_allowances.yaml new file mode 100644 index 0000000..4c6845b --- /dev/null +++ b/ci/headless/image_allowances.yaml @@ -0,0 +1,55 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +# +# Findings of `mix trinity.image.inspect` that the headless image carries on purpose (slice 130, +# AC6; NOTES D-130-10). Only four kinds can be allowed: certificate, documentation, build_library +# and private_key. A package manager, a build tool, shell history, a SUID or SGID bit, a +# world-writable path, a distribution cookie, /etc/passwd's mode and the image's user cannot. +# +# `path` is a file, or a directory prefix ending in `/`. A private_key allowance names one file +# and the SHA-256 of each PEM block in it (header to footer inclusive), so another key, or a +# changed one, fails again. An allowance that allows nothing in the image fails as stale: when a +# reason stops being true, the row has to go. +--- +allowances: + # The operating system's trust store. openssl-libs requires ca-certificates, so the store and + # its READMEs arrive with the libraries the release links. Public CA certificates, which the + # runtime verifies model providers' TLS servers against; Iron Bank's own ubi9-micro carries the + # same store "for default certificates". A deployment adds its own anchors under + # /etc/pki/ca-trust/source/anchors. + - path: "/etc/pki/ca-trust/" + kinds: ["certificate", "documentation"] + reason: "the OS trust store, installed by ca-certificates, a dependency of openssl-libs" + - path: "/usr/share/pki/" + kinds: ["certificate", "documentation"] + reason: "the OS trust store's sources, installed by ca-certificates" + + # castore's bundle of public CA certificates, a dependency of tokenizers. Slice 133 removes + # tokenizers from the regulated image; if castore goes with it, this row goes too. + - path: "/opt/trinity/lib/castore-1.0.21/priv/cacerts.pem" + kinds: ["certificate"] + reason: "public CA certificates bundled by castore, a dependency of tokenizers (release composition, slice 133)" + + # jose's fixed RSA test key pair: deps/jose/src/jose_server.erl, rsa_private_key/0, which its + # capability probe signs with. The private half is published in jose's source; it is the same + # in every copy of jose and protects nothing. Iron Bank's rule names test material, so this is + # for the owner's AC7 review to see, not to hide. + - path: "/opt/trinity/lib/jose-1.11.12/ebin/jose_server.beam" + kinds: ["private_key"] + sha256: ["01bd092767f5183944bb4f93406659a6db0e9c3f89b664004eb8a07da83012bb"] + reason: "jose's published RSA test key pair, used by its capability probe (jose_server.erl rsa_private_key/0)" + + # Build-time libraries the release carries as applications because a dependency does not mark + # them runtime: false. Release composition is slice 133's; these rows go when it removes them. + - path: "/opt/trinity/lib/rustler-0.38.0/" + kinds: ["build_library", "documentation"] + reason: "rustler, a NIF build library, carried as a release application (release composition, slice 133)" + - path: "/opt/trinity/lib/igniter-0.8.4" + kinds: ["build_library"] + reason: "igniter, a code generator, carried as a release application (release composition, slice 133)" + - path: "/opt/trinity/lib/burrito-1.6.0" + kinds: ["build_library"] + reason: "burrito, the desktop packager, carried as a release application (release composition, slice 133)" + - path: "/opt/trinity/lib/ex_tauri-0.2.0" + kinds: ["build_library"] + reason: "ex_tauri, the desktop shell's build library, carried as a release application (release composition, slice 133)" diff --git a/ci/headless/justifications.yaml b/ci/headless/justifications.yaml new file mode 100644 index 0000000..7237b2d --- /dev/null +++ b/ci/headless/justifications.yaml @@ -0,0 +1,105 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +# +# Justifications for High and Critical findings of the vulnerability scan of the headless image +# (slice 130, AC3 and AC4; docs/regulated/headless-image.md). `mix trinity.image.findings` fails on +# a High or Critical finding with no row here, and on a row whose finding the scan no longer +# reports. +# +# A row is keyed the way Iron Bank's Vulnerability Assessment Tracker keys a finding: `finding`, +# `package` as name-version, and `packagePath`, which is null for a package the OS package database +# reports (their `pkgdb`). So a row covers one version of one package, and upgrading the package +# retires it. +# +# What a justification here must be: facts someone else can check, with the command or file that +# shows them. "Not exploitable" with no evidence is not one. A finding that has a fix available is +# fixed, not justified: the OTP findings fixed in 28.5.0.7 have no rows, on purpose (slice 130 +# NOTES, D-130-9). +--- +justifications: + # pcre2: inherited from ubi9-micro 9.8, whose own scan reports the same three CVEs on the same + # two packages. Red Hat lists no fix (grype fix state `not-fixed`, Red Hat's namespace). + - finding: "CVE-2026-86145" + package: "pcre2-10.40-6.el9" + packagePath: null + severity: "high" + scanSource: "grype" + justification: >- + Inherited from the ubi9-micro 9.8 base image, which reports the same finding; Red Hat lists + no fix. The flaw is in pcre2_jit_match. In this image libpcre2-8 is linked only by the base's + libselinux and libpcre2-posix (readelf -d over every executable and shared object); the + Erlang runtime does not link it, and the PCRE2 copy ERTS compiles in (erts/emulator/pcre, + 10.47) contains no JIT source (no pcre2_jit_*.c in OTP 28.5.0.5's tree). + - finding: "CVE-2026-86145" + package: "pcre2-syntax-10.40-6.el9" + packagePath: null + severity: "high" + scanSource: "grype" + justification: >- + The syntax-data subpackage of the pcre2 finding of the same identifier: inherited from the + ubi9-micro 9.8 base image, Red Hat lists no fix, and the JIT code the flaw is in is not + loaded by anything Trinity runs (see the pcre2 row). + - finding: "CVE-2026-89161" + package: "pcre2-10.40-6.el9" + packagePath: null + severity: "high" + scanSource: "grype" + justification: >- + Inherited from the ubi9-micro 9.8 base image, which reports the same finding; Red Hat lists + no fix. The flaw needs an attacker-controlled pattern and the JIT API. libpcre2-8 is linked + only by the base's libselinux and libpcre2-posix; the Erlang runtime does not link it, and + ERTS's own PCRE2 10.47 copy contains no JIT source. + - finding: "CVE-2026-89161" + package: "pcre2-syntax-10.40-6.el9" + packagePath: null + severity: "high" + scanSource: "grype" + justification: >- + The syntax-data subpackage of the pcre2 finding of the same identifier: inherited from the + ubi9-micro 9.8 base image, Red Hat lists no fix, and the JIT code the flaw is in is not + loaded by anything Trinity runs (see the pcre2 row). + - finding: "CVE-2026-103111" + package: "pcre2-10.40-6.el9" + packagePath: null + severity: "high" + scanSource: "grype" + justification: >- + Inherited from the ubi9-micro 9.8 base image, which reports the same finding; Red Hat lists + no fix. The flaw is in pcre2_dfa_match. libpcre2-8 is linked only by the base's libselinux + and libpcre2-posix; the Erlang runtime does not link it, and ERTS's own PCRE2 10.47 copy has + no pcre2_dfa_match.c (erts/emulator/pcre in OTP 28.5.0.5). + - finding: "CVE-2026-103111" + package: "pcre2-syntax-10.40-6.el9" + packagePath: null + severity: "high" + scanSource: "grype" + justification: >- + The syntax-data subpackage of the pcre2 finding of the same identifier: inherited from the + ubi9-micro 9.8 base image, Red Hat lists no fix, and the DFA matcher the flaw is in is not + loaded by anything Trinity runs (see the pcre2 row). + + # openssl-libs: added by this image (the crypto NIF links libcrypto). Red Hat lists no fix for + # either; `dnf upgrade` ran against the installroot at build time, so this is the newest build + # UBI's repositories carried. + - finding: "CVE-2026-75804" + package: "openssl-libs-1:3.5.8-1.el9_8" + packagePath: null + severity: "high" + scanSource: "grype" + justification: >- + The flaw is in OpenSSL's QUIC stack (connection-level flow control), which lives in + libssl. No executable or shared object in this image declares libssl.so.3 as a dependency + (readelf -d over all of them); Erlang's crypto NIF links libcrypto alone, and OTP's ssl + application implements TLS in Erlang without libssl. Red Hat lists no fix; the build ran dnf + upgrade, so this is the newest openssl-libs UBI carried. + - finding: "CVE-2026-84782" + package: "openssl-libs-1:3.5.8-1.el9_8" + packagePath: null + severity: "high" + scanSource: "grype" + justification: >- + The flaw is in OpenSSL's DTLS retransmission, which lives in libssl. No executable or shared + object in this image declares libssl.so.3 as a dependency (readelf -d over all of them); + Erlang's crypto NIF links libcrypto alone, and OTP's ssl application implements DTLS in + Erlang without libssl. Red Hat lists no fix; the build ran dnf upgrade, so this is the + newest openssl-libs UBI carried. diff --git a/ci/headless/stig_dispositions.yaml b/ci/headless/stig_dispositions.yaml new file mode 100644 index 0000000..ede48a2 --- /dev/null +++ b/ci/headless/stig_dispositions.yaml @@ -0,0 +1,40 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +# +# Dispositions for the STIG rules OpenSCAP neither passes nor finds inapplicable on the headless +# image (slice 130, AC5; docs/regulated/headless-image.md). `mix trinity.image.stig` gives every +# `pass` the disposition met and every `notapplicable` the disposition not applicable, with the +# rule's own applicability condition as the reason; everything else needs a row here, or the check +# fails. A row for a rule that no longer needs one fails too. +# +# disposition: met (the image meets it, and the reason says how), not_applicable (with a reason a +# reader can check), or deployment (the deployment that runs the image owns it, and the reason +# says what it does). +--- +dispositions: + - rules: + - "xccdf_org.ssgproject.content_rule_configure_crypto_policy" + disposition: "deployment" + reason: >- + The system-wide crypto policy (FIPS:STIG) goes with FIPS mode, which is a property of the host + kernel and of how the container is run (docs/fips-leg.md), so the deployment sets it. The + image's own TLS is OTP's ssl application, which crypto-policies has no back-end for; the + crypto NIF links the base's OpenSSL, whose FIPS provider the deployment's FIPS mode enables. + + - rules: + - "xccdf_org.ssgproject.content_rule_network_configure_name_resolution" + disposition: "deployment" + reason: >- + A container's /etc/resolv.conf is written by the container runtime from the deployment's DNS + configuration (the kubelet, or the engine), not taken from the image, so the number of name + servers is the deployment's. + + - rules: + - "xccdf_org.ssgproject.content_rule_security_patches_up_to_date" + disposition: "met" + reason: >- + The build runs dnf upgrade against the image's root filesystem, so every vendor package is the + newest build UBI's repositories carry when the image is built; OpenSCAP reports notchecked + because the guide ships no errata data to check against. Patches published after a build reach + the image when it is rebuilt. Components outside the vendor's packages are covered by the + image's vulnerability scan (mix trinity.image.findings), not by this rule. diff --git a/ci/headless/submission_holds.yaml b/ci/headless/submission_holds.yaml new file mode 100644 index 0000000..77cb40d --- /dev/null +++ b/ci/headless/submission_holds.yaml @@ -0,0 +1,35 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +# +# What stands between ci/ironbank/ and an Iron Bank submission (slice 130). Each entry is a hold: +# it names its owner, the date it was opened and a lift condition someone outside the project can +# check. `mix trinity.ironbank.lint` accepts a resource the Dockerfile copies only if the manifest +# declares it or a hold below names it, and `mix trinity.ironbank.lint --submission` refuses while +# this file holds anything. The owner's submission review (slice 130, AC7) runs that mode. +--- +resources: + # Produced locally by `scripts/headless_image.sh stage` from the tracked files of the checkout + # it runs in, until a source archive is published. + - filename: "trinity-src.tar.gz" + owner: "Ayla Croft" + opened: "2026-10-08" + lift: >- + A source archive of the commit being submitted is published at a stable URL and declared in + ci/ironbank/hardening_manifest.yaml with its SHA-256, and this entry is removed. + # Produced locally by `mix deps.get --only prod` and a deterministic tar, until the Hex tree is + # declared. Iron Bank has no Hex proxy, so the tree must arrive as checksummed resources. + - filename: "trinity-hex-deps.tar.gz" + owner: "Ayla Croft" + opened: "2026-10-08" + lift: >- + Slice 133 has settled the regulated release's composition and the Hex tree it needs is + declared in ci/ironbank/hardening_manifest.yaml as checksummed resources, one per package or + one archive, and this entry is removed. + +fields: + - path: "maintainers[0].username" + owner: "Ayla Croft" + opened: "2026-10-08" + lift: >- + A repo1 account exists for the maintainer, its username replaces PENDING-repo1-account, and + a maintainer with cht_member true is named, and this entry is removed. diff --git a/ci/ironbank/Dockerfile b/ci/ironbank/Dockerfile new file mode 100644 index 0000000..8ae6f56 --- /dev/null +++ b/ci/ironbank/Dockerfile @@ -0,0 +1,154 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +# +# The Trinity headless image, hardened (slice 130). This is the only recipe for it: the image the +# project publishes and the image submitted to Iron Bank are this file built with different +# arguments. Iron Bank's pipeline supplies BASE_REGISTRY and takes the other four from +# hardening_manifest.yaml; the published build takes them from ci/headless/bases.env, which pins +# each base by digest. docs/regulated/headless-image.md explains both. +# +# Nothing here reaches the network. Every input that is not a UBI package arrives in the build +# context as a file named in hardening_manifest.yaml, checked against its digest before the build +# starts (`scripts/headless_image.sh stage` does that for a local or CI build). dnf is the one +# exception, and it reaches only the UBI repositories, which Iron Bank proxies. +# +# scripts/headless_image.sh build trinity-headless:hardened +# docker run --rm -p 4000:4000 -e TRINITY_MCP_SERVER_TOKEN=... trinity-headless:hardened + +ARG BASE_REGISTRY=registry1.dso.mil/ironbank +ARG BASE_IMAGE=redhat/ubi/ubi9-micro +ARG BASE_TAG=9.8 +ARG BUILDER_IMAGE=redhat/ubi/ubi9 +ARG BUILDER_TAG=9.8 + +# The runtime base, named once so the root filesystem stage below can start from its contents. +FROM ${BASE_REGISTRY}/${BASE_IMAGE}:${BASE_TAG} AS runtime-base + +# The build packages, the one step that reaches a package repository (UBI's, which Iron Bank +# proxies). It is a stage of its own so that every later step can be shown to build with no network +# at all (docs/regulated/headless-image.md). Compilers stay in the build stages. +FROM ${BASE_REGISTRY}/${BUILDER_IMAGE}:${BUILDER_TAG} AS build-packages + +RUN dnf -y install --setopt=install_weak_deps=False --nodocs \ + gcc gcc-c++ make perl git ncurses-devel openssl-devel tar gzip unzip findutils \ + && dnf clean all \ + && rm -rf /var/cache/dnf + +# The toolchain: OTP from source and Elixir. +FROM build-packages AS toolchain + +COPY otp_src_28.5.0.5.tar.gz elixir-1.20.4-otp-28.zip hex-2.5.1-otp-28.ez rebar3-3.25.1-otp-28 /build/resources/ + +# --enable-fips compiles the crypto NIF's FIPS support, and --with-ssl links UBI's OpenSSL +# dynamically, which is where Red Hat's FIPS provider lives (docs/fips-leg.md). Applications with +# no place in a server are left out of the build. +WORKDIR /build/otp +RUN tar -xzf /build/resources/otp_src_28.5.0.5.tar.gz --strip-components=1 \ + && ./configure --prefix=/usr/local --enable-fips --with-ssl=/usr \ + --without-javac --without-jinterface --without-wx --without-odbc \ + --without-debugger --without-observer --without-et --without-megaco \ + && make -j"$(nproc)" \ + && make install \ + && rm -rf /build/otp/* + +ENV PATH=/usr/local/elixir/bin:/usr/local/bin:/usr/bin:/bin \ + LANG=C.UTF-8 \ + MIX_HOME=/opt/mix \ + HEX_HOME=/opt/hex \ + HEX_OFFLINE=1 + +WORKDIR /usr/local/elixir +RUN unzip -q /build/resources/elixir-1.20.4-otp-28.zip \ + && mix archive.install /build/resources/hex-2.5.1-otp-28.ez --force \ + && mix local.rebar rebar3 /build/resources/rebar3-3.25.1-otp-28 --force + +# The release. Native pieces arrive pre-staged: the precompiled NIFs in the cache their loaders +# read before they would download, the XLA archive by path, exqlite compiled from the C source in +# its Hex package, the asset tools where their Mix tasks look for them. +FROM toolchain AS release + +COPY trinity-src.tar.gz trinity-hex-deps.tar.gz \ + esbuild-linux-x64-0.25.4.tgz tailwindcss-linux-x64-4.3.0 \ + xla_extension-0.10.0-x86_64-linux-gnu-cpu.tar.gz \ + libex_tokenizers-v0.5.1-nif-2.15-x86_64-unknown-linux-gnu.so.tar.gz \ + libmdex_native_nif-v0.2.8-nif-2.15-x86_64-unknown-linux-gnu.so.tar.gz \ + /build/resources/ + +ENV MIX_ENV=prod \ + EXQLITE_FORCE_BUILD=1 \ + RUSTLER_PRECOMPILED_GLOBAL_CACHE_PATH=/build/nif-cache \ + XLA_ARCHIVE_PATH=/build/resources/xla_extension-0.10.0-x86_64-linux-gnu-cpu.tar.gz \ + XLA_CACHE_DIR=/build/xla-cache + +WORKDIR /build/trinity +RUN tar -xzf /build/resources/trinity-src.tar.gz \ + && tar -xzf /build/resources/trinity-hex-deps.tar.gz \ + && mkdir -p /build/nif-cache _build \ + && cp /build/resources/libex_tokenizers-v0.5.1-nif-2.15-x86_64-unknown-linux-gnu.so.tar.gz \ + /build/resources/libmdex_native_nif-v0.2.8-nif-2.15-x86_64-unknown-linux-gnu.so.tar.gz \ + /build/nif-cache/ \ + && tar -xzf /build/resources/esbuild-linux-x64-0.25.4.tgz -C /build package/bin/esbuild \ + && install -m 0755 /build/package/bin/esbuild _build/esbuild-linux-x64 \ + && install -m 0755 /build/resources/tailwindcss-linux-x64-4.3.0 _build/tailwind-linux-x64-4.3.0 + +# The release cookie is removed: a cookie baked into a published image is a credential everyone who +# pulls it holds. The image runs with distribution off (RELEASE_DISTRIBUTION=none below), and +# scripts/entrypoint.sh gives each container its own cookie, which the start script requires. +RUN mix deps.compile \ + && mix assets.deploy \ + && mix release headless --overwrite \ + && rm -f _build/prod/rel/headless/releases/COOKIE + +# The runtime root filesystem: ubi9-micro plus the libraries the release links, installed with +# dnf --installroot so micro's rpm database stays true to what is on disk, and the runtime user. +# The shells' default umask becomes 077, the STIG's value (RHEL-09-412055 and RHEL-09-412070), with +# the SCAP Security Guide's own fix expressions, as Iron Bank's own ubi9 sets it; nothing the image +# runs reads those files, so this changes no behaviour of the release. +FROM ${BASE_REGISTRY}/${BUILDER_IMAGE}:${BUILDER_TAG} AS rootfs + +COPY --from=runtime-base / /rootfs/ + +RUN dnf -y --installroot=/rootfs --releasever=9 --setopt=reposdir=/etc/yum.repos.d \ + --setopt=install_weak_deps=False --nodocs \ + install libstdc++ openssl-libs \ + && dnf -y --installroot=/rootfs --releasever=9 --setopt=reposdir=/etc/yum.repos.d \ + --setopt=install_weak_deps=False --nodocs \ + upgrade \ + && dnf --installroot=/rootfs clean all \ + && rm -rf /rootfs/var/cache/dnf /rootfs/var/lib/dnf /rootfs/var/log/dnf* /rootfs/var/log/hawkey.log \ + && dnf clean all \ + && rm -rf /var/cache/dnf \ + && groupadd --prefix /rootfs --gid 10001 trinity \ + && useradd --prefix /rootfs --uid 10001 --gid 10001 --no-log-init \ + --home-dir /data --no-create-home --shell /sbin/nologin trinity \ + && install -d -o 10001 -g 10001 -m 0700 /rootfs/data \ + && sed -i -E 's/^([^#]*\bumask)[[:space:]]+[[:digit:]]+/\1 077/g' /rootfs/etc/bashrc \ + && if grep -qsE '^[^#]*umask' /rootfs/etc/profile /rootfs/etc/profile.d/*.sh; then \ + sed -i -E 's/^(\s*umask\s*)[0-7]+/\1077/g' /rootfs/etc/profile; \ + else \ + printf 'umask 077\n' >> /rootfs/etc/profile; \ + fi + +FROM ${BASE_REGISTRY}/${BASE_IMAGE}:${BASE_TAG} + +COPY --from=rootfs /rootfs/ / +COPY --from=release --chown=0:0 /build/trinity/_build/prod/rel/headless /opt/trinity +COPY --chown=0:0 scripts/entrypoint.sh /usr/local/bin/trinity-entrypoint + +# Distribution is off: the image serves MCP and the web pages on PORT and nothing else. The data +# directory is /data (XDG_DATA_HOME), the only path the runtime user can write. +ENV LANG=C.UTF-8 \ + TRINITY_MODE=headless \ + TRINITY_BIND=0.0.0.0 \ + PORT=4000 \ + XDG_DATA_HOME=/data \ + HOME=/data \ + RELEASE_DISTRIBUTION=none \ + RELEASE_TMP=/tmp + +WORKDIR /opt/trinity +USER 10001:10001 +VOLUME ["/data"] +EXPOSE 4000 +ENTRYPOINT ["/usr/local/bin/trinity-entrypoint"] +CMD ["/opt/trinity/bin/headless", "start"] diff --git a/ci/ironbank/LICENSE b/ci/ironbank/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/ci/ironbank/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/ci/ironbank/README.md b/ci/ironbank/README.md new file mode 100644 index 0000000..3a20247 --- /dev/null +++ b/ci/ironbank/README.md @@ -0,0 +1,139 @@ + + +# Trinity headless + +Trinity is a personal AI agent built on Elixir and OTP. This image runs it headless: an MCP +server on `/mcp` and Trinity's web interface, on one port, with no desktop shell. It is +customer-controlled software. There is no Trinity-operated service behind it; the agent, its +memory and its signed record of every governed action live in the data volume you give it. + +This image is a candidate for Iron Bank. **It has not been accepted there, and nothing here claims +an authority to operate, a STIG compliance determination or a FedRAMP authorization.** The +evidence this project produces about the image (its layer assertions, vulnerability scan with +justifications, and STIG applicability statement) is described in the project's +`docs/regulated/headless-image.md`. + +## What is in the image + +* Erlang/OTP 28, built from source with FIPS support, linked against the base image's OpenSSL. +* The Trinity release, under `/opt/trinity`, owned by root and not writable by the runtime user. +* The UBI9 micro base and the two libraries the release links that micro lacks (`libstdc++`, + `openssl-libs`). No package manager, compiler or shell history. + +It runs as UID 10001. The only writable path it needs is `/data`, its data directory; `/tmp` is +used for the release's runtime files. Erlang distribution is off (`RELEASE_DISTRIBUTION=none`), so +the container listens on one port, 4000, and nothing else. + +## Running it on Kubernetes + +The MCP endpoint requires a bearer token on every request. Give it one through a Secret; without +one, Trinity generates a token into the data volume on first start (`/data/trinity/mcp-server-token`). +`SECRET_KEY_BASE` signs the web interface's cookies; without it, a new one is generated on every +start and signed cookies do not survive a restart. + +```yaml +apiVersion: v1 +kind: Secret +metadata: + name: trinity +stringData: + mcp-server-token: "" + secret-key-base: "" +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: trinity-data +spec: + accessModes: ["ReadWriteOnce"] + resources: + requests: + storage: 5Gi +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: trinity +spec: + replicas: 1 + selector: + matchLabels: {app: trinity} + template: + metadata: + labels: {app: trinity} + spec: + securityContext: + runAsNonRoot: true + runAsUser: 10001 + runAsGroup: 10001 + fsGroup: 10001 + seccompProfile: {type: RuntimeDefault} + containers: + - name: trinity + image: registry1.dso.mil/ironbank/opensource/scriptkittyos/trinity-headless:0.1.0 + ports: + - containerPort: 4000 + env: + - name: TRINITY_MCP_SERVER_TOKEN + valueFrom: + secretKeyRef: {name: trinity, key: mcp-server-token} + - name: SECRET_KEY_BASE + valueFrom: + secretKeyRef: {name: trinity, key: secret-key-base} + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: {drop: ["ALL"]} + volumeMounts: + - {name: data, mountPath: /data} + - {name: tmp, mountPath: /tmp} + readinessProbe: + tcpSocket: {port: 4000} + livenessProbe: + tcpSocket: {port: 4000} + initialDelaySeconds: 30 + resources: + requests: {cpu: "500m", memory: "1Gi"} + limits: {memory: "4Gi"} + volumes: + - name: data + persistentVolumeClaim: {claimName: trinity-data} + - name: tmp + emptyDir: {} +--- +apiVersion: v1 +kind: Service +metadata: + name: trinity +spec: + selector: {app: trinity} + ports: + - {port: 4000, targetPort: 4000} +``` + +One replica: this image keeps its state in SQLite in the data volume, and two pods must not share +one. + +Model providers are configured through the environment. The source repository's +`docs/regulated/` describes the regulated profile and the controls it puts on model endpoints. + +## Resource requirements + +Measured on 2026-10-08, on one host, in two runs of two builds of this image: the container used +**288 MiB and 330 MiB** of memory idle, about 20 seconds after start, before any session +(`docker stats`). The image is 655 MB. Memory grows with the number and length of conversations, +and with the local embedding model if a deployment uses it, which loads on first use; neither was +measured under load. The request and limit in the example above are a starting point with that +headroom, not a measured requirement; size them to the workload. The data volume holds Trinity's +SQLite databases and its signed receipt chain, both of which grow with use; 5 GiB is a starting +point. + +One process runs in the container. It needs no capabilities, no privilege escalation and no +writable root filesystem. + +## References + +* Source and documentation: +* The MCP server: `docs/mcp-server.md` in the source repository +* The regulated deployment pack: `docs/regulated/README.md` in the source repository +* Security reports: , as `SECURITY.md` in the source repository says diff --git a/ci/ironbank/hardening_manifest.yaml b/ci/ironbank/hardening_manifest.yaml new file mode 100644 index 0000000..5b133e0 --- /dev/null +++ b/ci/ironbank/hardening_manifest.yaml @@ -0,0 +1,111 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +# +# Iron Bank's description of the Trinity headless image (slice 130). This directory is laid out +# as the repo1 project root, file for file, so a submission is a copy of it plus the resources +# below. The schema is Iron Bank's own, `schema/hardening_manifest.schema.json` in +# ironbank-tools/ironbank-pipeline; `test/ironbank_submission_test.exs` holds the parts of it +# this tree can check without their pipeline. +# +# What is not here yet, and why, is in `ci/headless/submission_holds.yaml`: the Trinity source +# archive and the Hex dependency tree have no published URL yet, and the maintainer below has no +# repo1 account. `mix trinity.ironbank.lint --submission` refuses while any hold remains. +--- +apiVersion: v1 + +# The repository name in registry1, without the `ironbank/` prefix, which the pipeline adds. +name: "opensource/scriptkittyos/trinity-headless" + +# The most specific tag first. It is the `version` in mix.exs, which the lint test checks. +tags: + - "0.1.0" + - "latest" + +# Build arguments. `BASE_REGISTRY` is the pipeline's and may not appear here. The runtime base is +# ubi9-micro, which carries no package manager; the builder is ubi9, which carries dnf and the +# compilers the build needs and none of which reach the final stage. +args: + BASE_IMAGE: "redhat/ubi/ubi9-micro" + BASE_TAG: "9.8" + BUILDER_IMAGE: "redhat/ubi/ubi9" + BUILDER_TAG: "9.8" + +# Labels live here, never in the Dockerfile; their lint fails a Dockerfile line starting LABEL. +labels: + org.opencontainers.image.title: "trinity-headless" + org.opencontainers.image.description: "Trinity, a customer-controlled AI agent, as a headless MCP server and web interface" + org.opencontainers.image.licenses: "Apache-2.0" + org.opencontainers.image.url: "https://github.com/ScriptKittyOS/Trinity" + org.opencontainers.image.vendor: "Sudo Apt Holdings LLC" + org.opencontainers.image.version: "0.1.0" + mil.dso.ironbank.image.keywords: "ai,agent,mcp,elixir,erlang" + mil.dso.ironbank.image.type: "opensource" + mil.dso.ironbank.product.name: "Trinity" + +# Everything the build needs that is not in a UBI repository. The pipeline downloads each one, +# checks it against its digest, and places it in the build context under its filename; the +# Dockerfile copies it from there and nothing in the build reaches the network. +# `scripts/headless_image.sh` plays that part for the image this project publishes. +resources: + # Erlang/OTP, built from source in the builder with --enable-fips against UBI's OpenSSL. + - url: "https://github.com/erlang/otp/releases/download/OTP-28.5.0.5/otp_src_28.5.0.5.tar.gz" + filename: "otp_src_28.5.0.5.tar.gz" + validation: + type: "sha256" + value: "5231ba18f31f8041c2d6514cc8842e46954d3b39a53f1617f03f2abe6fea59c7" + # Elixir, precompiled for OTP 28 (BEAM files only, no native code). + - url: "https://github.com/elixir-lang/elixir/releases/download/v1.20.4/elixir-otp-28.zip" + filename: "elixir-1.20.4-otp-28.zip" + validation: + type: "sha256" + value: "ea7ff98bc1ed76c663a5d034c863c6fd37e65c9c855b6bbf6ccb2034d806673c" + # Hex, Mix's package client. Needed to compile a tree whose dependencies come from Hex, even + # offline. The build for Elixir 1.20 on OTP 28; the digest is the SHA-512 builds.hex.pm + # publishes for it in installs/hex.csv. + - url: "https://builds.hex.pm/installs/1.20.0/hex-2.5.1-otp-28.ez" + filename: "hex-2.5.1-otp-28.ez" + validation: + type: "sha512" + value: "690f12f6933088bb32f1d7125485fb13426318c03cfeabcbd3cce55824cddf2a701104b09b7f4cd7092fe7f76d5b669e391a072428ec4526879b36f62820ae5d" + # rebar3, which compiles the Erlang dependencies; SHA-512 from installs/rebar.csv. + - url: "https://builds.hex.pm/installs/1.18.4/rebar3-3.25.1-otp-28" + filename: "rebar3-3.25.1-otp-28" + validation: + type: "sha512" + value: "992fd755b7926fae455e5e07d9d195f4d3e7f181609eed1b9cabfe548624df10d148cd4b59bda40bebb185d3d68f9a9fd68a70b294101c8ad9cf0fadcc683d24" + # The two asset tools at the versions config/config.exs names. Build only; neither reaches + # the final image. + - url: "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.4.tgz" + filename: "esbuild-linux-x64-0.25.4.tgz" + validation: + type: "sha256" + value: "387f77dd2ed4f66ab2b02150943554c6131431a7df36a62191112ec21453f009" + - url: "https://github.com/tailwindlabs/tailwindcss/releases/download/v4.3.0/tailwindcss-linux-x64" + filename: "tailwindcss-linux-x64-4.3.0" + validation: + type: "sha256" + value: "73f0e5459054e5cfaa8ab6f3b940f3fbe0f13cc7fd83bc24e7c655033c203400" + # Today's release composition carries the local embedder (exla, which links this archive, and + # tokenizers). Slice 133 removes both from the regulated image; these two rows go with them. + - url: "https://github.com/elixir-nx/xla/releases/download/v0.10.0/xla_extension-0.10.0-x86_64-linux-gnu-cpu.tar.gz" + filename: "xla_extension-0.10.0-x86_64-linux-gnu-cpu.tar.gz" + validation: + type: "sha256" + value: "d5a1f138af13795c48c245561dcb76e1007afe5237baad5c828a704de619fc1d" + - url: "https://github.com/elixir-nx/tokenizers/releases/download/v0.5.1/libex_tokenizers-v0.5.1-nif-2.15-x86_64-unknown-linux-gnu.so.tar.gz" + filename: "libex_tokenizers-v0.5.1-nif-2.15-x86_64-unknown-linux-gnu.so.tar.gz" + validation: + type: "sha256" + value: "d060813cb758b5ae7d1a4ce4ed94bb58e6255dc231ba315ec42a577efb44769f" + # The Markdown renderer's NIF, precompiled. Slice 133 moves it to a source build in Iron Bank's + # Rust builder image, at which point this row becomes that image and a vendored crate tree. + - url: "https://github.com/leandrocp/mdex_native/releases/download/v0.2.8/libmdex_native_nif-v0.2.8-nif-2.15-x86_64-unknown-linux-gnu.so.tar.gz" + filename: "libmdex_native_nif-v0.2.8-nif-2.15-x86_64-unknown-linux-gnu.so.tar.gz" + validation: + type: "sha256" + value: "963238414ea5a6bb4545986c09c825dffcd75e9327db9ab7306fb1aa6cbd4cec" + +maintainers: + - name: "Ayla Croft" + # A repo1 account does not exist yet. Held in ci/headless/submission_holds.yaml. + username: "PENDING-repo1-account" diff --git a/ci/ironbank/scripts/entrypoint.sh b/ci/ironbank/scripts/entrypoint.sh new file mode 100755 index 0000000..c7da7c1 --- /dev/null +++ b/ci/ironbank/scripts/entrypoint.sh @@ -0,0 +1,18 @@ +#!/bin/sh +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +# +# The headless image's entrypoint (slice 130). The release's start script reads an Erlang +# distribution cookie and stops without one. A cookie baked into a published image is a credential +# everyone who pulls the image holds, so the image carries none, and this script gives each +# container its own random one unless RELEASE_COOKIE is already set. The image runs with +# distribution off (RELEASE_DISTRIBUTION=none), so the cookie only satisfies the script; if an +# operator turns distribution on, it is at least not a value anyone else knows. +set -eu + +if [ -z "${RELEASE_COOKIE:-}" ]; then + RELEASE_COOKIE=$(od -An -N32 -tx1 /dev/urandom | tr -d ' \n') + export RELEASE_COOKIE +fi + +exec "$@" diff --git a/config/config.exs b/config/config.exs index 7e20d42..71a837b 100644 --- a/config/config.exs +++ b/config/config.exs @@ -74,6 +74,14 @@ if nif_target != "" do config :mdex_native, MDExNative.Native, target: nif_target end +# Slice 130: the hardened headless image builds exqlite's NIF from the C source in its Hex package +# instead of downloading a precompiled artifact, because its build has no network +# (ci/ironbank/Dockerfile). exqlite reads this when its own mix.exs is evaluated, so it is compile +# time. Unset, every other build downloads the precompiled artifact as before. +if System.get_env("EXQLITE_FORCE_BUILD") in ~w(1 true) do + config :exqlite, force_build: true +end + # Slice 022: the core tools, every environment, and the toolsets they belong to. A tool is a # module implementing Trinity.Tools.Tool plus a line here (docs/03). The shell answers # available?/0 false on Windows and is skipped there with a logged reason. diff --git a/docs/mcp-server.md b/docs/mcp-server.md index 37846f9..880b204 100644 --- a/docs/mcp-server.md +++ b/docs/mcp-server.md @@ -151,11 +151,13 @@ and serves the web pages and `/mcp`. The bearer is required on `/mcp` whatever t pages carry no authentication yet, which is why the default bind is the loopback and why a LAN bind belongs behind a reverse proxy that authenticates. -A container: `ci/headless/Containerfile` builds the release on the Elixir image whose toolchain is -the tree's and runs it on a slim Debian, the data directory on the `/data` volume: +A container: `ci/ironbank/Dockerfile` builds the release on UBI9, with OTP from source, and runs it +on UBI9 micro as UID 10001, the data directory on the `/data` volume. Slice 130 replaced the slice +061 image (`ci/headless/Containerfile`, Debian) with it; `docs/regulated/headless-image.md` says how +it is built and what is checked about it. ``` -docker build -f ci/headless/Containerfile -t trinity-headless:local . +scripts/headless_image.sh build trinity-headless:local docker run --rm -p 4000:4000 -v trinity-data:/data \ -e TRINITY_MCP_SERVER_TOKEN= trinity-headless:local ``` diff --git a/docs/regulated/README.md b/docs/regulated/README.md index 0decb16..ba45354 100644 --- a/docs/regulated/README.md +++ b/docs/regulated/README.md @@ -63,11 +63,14 @@ model prompt and what may not. Read both before the others. | `incident.md` | Reporting clocks and who to contact | | `receipts-verify.md` | How an assessor verifies the record without trusting the application | | `crypto-inventory.md` | Every cryptographic capability, its module, and its certificate status | +| `headless-image.md` | How the container image is built and hardened, and the checks run against it | +| `stig-applicability.md` | The image's STIG applicability statement, generated from OpenSCAP's evaluation of it | ## Status of this pack This directory is documentation only. It adds no code, changes no behaviour, and asserts no control -that is not already in the tree. Where the tree does not implement something this pack names, the +that is not already in the tree. `stig-applicability.md` is generated rather than written, and says +how. Where the tree does not implement something this pack names, the row says **NOT IN TREE** rather than describing an intention. This pack documents the gap; it does not close it. diff --git a/docs/regulated/headless-image.md b/docs/regulated/headless-image.md new file mode 100644 index 0000000..8ca6904 --- /dev/null +++ b/docs/regulated/headless-image.md @@ -0,0 +1,129 @@ + + +# The headless image: how it is built, and what is checked about it + +For an operator deploying Trinity as a container, and for an assessor who wants to know what the +image is and how the claims about it are checked. Every check named here is a command in this +repository; run it and read its exit code rather than taking this page's word for it. + +## What this is not + +**The image is not in Iron Bank.** It is built to Iron Bank's published rules and the submission is +laid out the way their pipeline reads it, but it has not been submitted, reviewed or accepted. +**Nothing here is an authority to operate, a STIG compliance determination for a host, or a FedRAMP +authorization.** The STIG applicability statement below is a statement about one image; whether a +system that runs it is compliant is decided for that system by whoever authorizes it. + +## One recipe, two artifacts + +`ci/ironbank/Dockerfile` is the only recipe for the headless image. It is built two ways: + +| Artifact | Bases | Where the inputs come from | +|---|---|---| +| The image this project publishes | `ci/headless/bases.env`: Red Hat's UBI9 and UBI9 micro, **pinned by digest** | `scripts/headless_image.sh` downloads each resource in `ci/ironbank/hardening_manifest.yaml` and checks its digest | +| An Iron Bank build | Their registry and the tags in `hardening_manifest.yaml`, supplied as `BASE_REGISTRY`, `BASE_IMAGE`, `BASE_TAG`, `BUILDER_IMAGE`, `BUILDER_TAG` | Their pipeline downloads the same resources and checks the same digests | + +Both start from the same bytes: the digests in `bases.env` are the ones Iron Bank's own UBI 9.8 +images declare as their sources. + +``` +scripts/headless_image.sh build trinity-headless:local +docker run --rm -p 4000:4000 -v trinity-data:/data \ + -e TRINITY_MCP_SERVER_TOKEN= trinity-headless:local +``` + +The build reaches no network. Every input that is not a UBI package (the OTP and Elixir sources, +Hex and rebar3, the asset tools, and today's precompiled native pieces) is a file in the build +context, named in the manifest with its SHA-256 or SHA-512. dnf is the one exception, and reaches +only UBI's repositories, which Iron Bank proxies. `scripts/headless_image.sh` is what reaches the +network, playing the part of Iron Bank's prebuild step; it is not part of the submission. + +## What is in the image + +* **UBI9 micro**, with the two libraries the release links that micro lacks (`libstdc++` and + `openssl-libs`, which brings the CA trust store with it as `ca-certificates`), installed with + `dnf --installroot` so the image's rpm database stays true to what is on disk. No package + manager, compiler or shell history. +* **Erlang/OTP 28**, built from source with FIPS support against that OpenSSL + (`docs/fips-leg.md`). +* **The Trinity release**, under `/opt/trinity`, owned by root and not writable at run time. + +It runs as UID 10001. The only path it writes is `/data` (the data directory, a volume) and `/tmp`; +it runs with a read-only root filesystem, all capabilities dropped and no privilege escalation. +**Erlang distribution is off** (`RELEASE_DISTRIBUTION=none`) and **the image carries no +distribution cookie**: each container gets its own at start (`ci/ironbank/scripts/entrypoint.sh`). +The image listens on one port, 4000. + +## The checks + +All of them read the **built image** or the files, never a description of either. The workflow +`.github/workflows/headless-image.yml` runs every one on each change to an input of the image, +daily, and on demand, and uploads the scan, the findings and the STIG results as evidence. + +| What | Command | Fails when | +|---|---|---| +| The submission tree and the base pins | `mix trinity.ironbank.lint` | a `FROM` names a registry, or the published build resolves a base without a digest; the Dockerfile or a submission script mentions `curl`, `wget` or a URL, or uses `ADD`; a copied file is not a declared resource; a `chmod` makes something world-writable or SUID; `--nogpgcheck`; `/etc/passwd` is written; the layout or the manifest departs from Iron Bank's | +| The image's user and layers | `mix trinity.image.inspect IMAGE` | the image runs as root or below UID 1000; a layer it adds carries a package manager, a build tool, a build-time library, shell history, documentation, a certificate, a SUID or SGID file, or a world-writable path; `/etc/passwd` changed mode; anything in the image holds a private key, a keystore or a distribution cookie | +| Vulnerabilities | grype, then `mix trinity.image.findings --scan grype.json` | a High or Critical finding has no row in `ci/headless/justifications.yaml`, or a row justifies a finding the scan no longer reports | +| STIG applicability | `scripts/stig_scan.sh IMAGE OUT`, then `mix trinity.image.stig --results OUT/stig-results.xml` | a rule of the DISA STIG profile has no disposition, or a disposition is kept for a rule that no longer needs one | + +### Allowances, and why there are any + +Some findings of the layer check are the image working as intended, and +`ci/headless/image_allowances.yaml` allows them, each with its reason: the operating system's CA +trust store (which the runtime verifies model providers against), a CA bundle a dependency carries, +and build-time libraries the release currently carries as applications. **One is a private key:** +the `jose` library embeds a published RSA test key pair that its capability probe signs with. It +protects nothing, but Iron Bank's rule names test material, so it is listed for review rather than +hidden; its allowance names the exact file and the SHA-256 of the key block, so any other key fails. +Only those four kinds can be allowed, and an allowance that no longer allows anything fails as +stale. + +### The scanner, and what it cannot see + +The scan is grype, chosen by measuring it against Trivy on this project's images: both run with no +network at scan time and no token, both read UBI's rpm database, and **only grype identifies the +Erlang/OTP runtime** inside a release and matches advisories against it. Iron Bank's own CVE scan is +Anchore's, whose engine is grype. Neither scanner inventories the Hex packages inside a release; +those are covered by `mix deps.audit` over `mix.lock` in the quality gate. + +### Justifications are keyed the way Iron Bank's tracker keys a finding + +A row in `ci/headless/justifications.yaml` names the finding, the package as `name-version`, and the +package path (empty for a package the OS database reports), so it covers one version of one package +and an upgrade retires it. A justification states facts someone else can check; a finding with a fix +available is fixed rather than justified. + +### The STIG applicability statement + +`docs/regulated/stig-applicability.md` is **generated**: OpenSCAP evaluates the image's root +filesystem against the DISA STIG profile for RHEL 9 from the SCAP Security Guide (the profile and +datastream Iron Bank's pipeline uses for a UBI9 image), and `mix trinity.image.stig` gives every +selected rule a disposition. A rule that passes is met; a rule OpenSCAP finds inapplicable is not +applicable, with the rule's own applicability condition as the reason (most STIG rules are about a +machine with a kernel, a boot loader and services, which a container image is not); anything else +needs a row in `ci/headless/stig_dispositions.yaml` saying how the image meets it, why it does not +apply, or why it is the deployment's. The file carries the image digest, the guide's version and the +commands that produced it. + +## The Iron Bank submission + +`ci/ironbank/` is laid out as the root of an Iron Bank project, file for file: `Dockerfile`, +`hardening_manifest.yaml`, `LICENSE`, `README.md` and `scripts/`. Labels live in the manifest, never +in the Dockerfile. There is no configuration directory: the image takes its configuration from the +environment. + +What stands between this tree and a submission is listed in `ci/headless/submission_holds.yaml`, +each with an owner and a condition for lifting it, and `mix trinity.ironbank.lint --submission` +refuses while any remains: the Trinity source archive and the Hex dependency tree have no published, +checksummed URL yet, and the maintainer has no repo1 account. The submission itself, and the review +of it against repo1's intake requirements, are the maintainer's. + +## Known gaps, stated + +* **The OTP runtime has open advisories** fixed in OTP 28.5.0.7, including a Critical one in `ssl`. + They are not justified, so the vulnerability check fails on the image until the toolchain moves. +* **The release still carries the local embedder** (`exla` and the XLA library, `tokenizers`) and + some build-time libraries. Removing them from the regulated image is a change to what the release + contains, made separately; it removes resources from the manifest and rows from the allowances, + and changes nothing in how the image is checked. diff --git a/docs/regulated/stig-applicability.md b/docs/regulated/stig-applicability.md new file mode 100644 index 0000000..c41b875 --- /dev/null +++ b/docs/regulated/stig-applicability.md @@ -0,0 +1,507 @@ + + +# STIG applicability statement: the Trinity headless image + +**Generated, not written.** `mix trinity.image.stig` derived this file from OpenSCAP's evaluation of +one built image; edit `ci/headless/stig_dispositions.yaml` and regenerate, never this file. + +**This is a statement about an image, not a compliance determination.** It says, for each rule of +the DISA STIG profile for RHEL 9 that the SCAP Security Guide selects, whether this container image +meets it, why it does not apply to a container image, or why it belongs to the deployment that runs +the image. It is not a STIG compliance determination for any host, and it is not an authorization. + +| | | +|---|---| +| Image | trinity-headless:hardened (scripts/headless_image.sh build, 2026-10-08) | +| Image ID | sha256:13a03661dc98472b9cbc27c653ef01f87b21ebf30aa6691334a011b88d6b8bba | +| Profile | `xccdf_org.ssgproject.content_profile_stig` | +| SCAP Security Guide | 0.1.82 (`ssg-rhel9-ds.xml`) | +| Scanner | OpenSCAP 1.3.14 | +| Evaluated | 2026-10-08T06:49:27+00:00 | +| Deriving commands | `scripts/stig_scan.sh IMAGE OUT`, then `mix trinity.image.stig --results OUT/stig-results.xml --out PATH --image IMAGE --digest IMAGE_ID` | + +477 rules selected. OpenSCAP: 2 fail, 410 notapplicable, 1 notchecked, 64 pass. Dispositions: 65 met, +410 not applicable, 2 the deployment's, +0 without one. + +Every rule has a disposition. + +| STIG ID | Rule | Severity | OpenSCAP | Disposition | Reason | +|---|---|---|---|---|---| +| RHEL-09-171011 | Set the GNOME3 Login Warning Banner Text | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where package_gdm holds | +| RHEL-09-211010 | The Installed Operating System Is Vendor Supported | high | pass | met | OpenSCAP: pass | +| RHEL-09-211015 | Ensure Software Patches Installed | medium | notchecked | met | The build runs dnf upgrade against the image's root filesystem, so every vendor package is the newest build UBI's repositories carry when the image is built; OpenSCAP reports notchecked because the guide ships no errata data to check against. Patches published after a build reach the image when it is rebuilt. Components outside the vendor's packages are covered by the image's vulnerability scan (mix trinity.image.findings), not by this rule. | +| RHEL-09-211020 | Modify the System Login Banner | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-211030 | Disable Graphical Environment Startup By Setting Default Target | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-211040 | Enable systemd-journald Service | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-211045 | Disable Ctrl-Alt-Del Burst Action | high | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_systemd hold | +| RHEL-09-211050 | Disable Ctrl-Alt-Del Reboot Activation | high | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-211055 | Disable debug-shell SystemD Service | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-212010 | Set Boot Loader Password in grub2 | high | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where grub2_and_system_with_kernel and not_container hold | +| RHEL-09-212015 | Verify that Interactive Boot is Disabled | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and grub2 hold | +| RHEL-09-212020 | Set the Boot Loader Admin Username to a Non-Default Value | high | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where grub2_and_system_with_kernel holds | +| RHEL-09-212025 | Verify /boot/grub2/grub.cfg Group Ownership | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where grub2_and_system_with_kernel and not_container hold | +| RHEL-09-212030 | Verify /boot/grub2/grub.cfg User Ownership | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where grub2_and_system_with_kernel and not_container hold | +| RHEL-09-212035 | Disable vsyscalls | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where grub2_and_system_with_kernel and x86_64_arch hold | +| RHEL-09-212040 | Enable page allocator poisoning | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and grub2 hold | +| RHEL-09-212045 | The system must booted with init_on_free=1 | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where grub2_and_system_with_kernel and grub2 hold | +| RHEL-09-212050 | Enable Kernel Page-Table Isolation (KPTI) | low | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where grub2_and_system_with_kernel holds | +| RHEL-09-212055 | Enable Auditing for Processes Which Start Prior to the Audit Daemon | low | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and grub2 hold | +| RHEL-09-213010 | Restrict Access to Kernel Message Buffer | low | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-213015 | Disallow kernel profiling by unprivileged users | low | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-213020 | Disable Kernel Image Loading | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-213025 | Restrict Exposed Kernel Pointer Addresses Access | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-213030 | Enable Kernel Parameter to Enforce DAC on Hardlinks | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-213035 | Enable Kernel Parameter to Enforce DAC on Symlinks | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-213040 | Disable storing core dumps | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-213045 | Disable ATM Support | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-213050 | Disable CAN Support | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-213055 | Disable IEEE 1394 (FireWire) Support | low | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-213060 | Disable SCTP Support | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-213065 | Disable TIPC Support | low | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-213070 | Enable Randomized Layout of Virtual Address Space | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-213075 | Disable Access to Network bpf() Syscall From Unprivileged Processes | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-213080 | Restrict usage of ptrace to descendant processes | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-213105 | Disable the use of user namespaces | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-213110 | Enable ExecShield via sysctl | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel_and_x86_64_arch holds | +| RHEL-09-213115 | Disable KDump Kernel Crash Analyzer (kdump) | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-214010 | Ensure Red Hat GPG Key Installed | high | pass | met | OpenSCAP: pass | +| RHEL-09-214015 | Ensure gpgcheck Enabled In Main dnf Configuration | high | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where package_dnf holds | +| RHEL-09-214020 | Ensure gpgcheck Enabled for Local Packages | high | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where package_dnf holds | +| RHEL-09-214025 | Ensure gpgcheck Enabled for All dnf Package Repositories | high | pass | met | OpenSCAP: pass | +| RHEL-09-214035 | Ensure dnf Removes Previous Package Versions | low | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where not_bootc_and_package_dnf holds | +| RHEL-09-215010 | Install subscription-manager Package | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-215015 | Uninstall vsftpd Package | high | pass | met | OpenSCAP: pass | +| RHEL-09-215020 | Uninstall Sendmail Package | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-215025 | Uninstall nfs-utils Package | low | pass | met | OpenSCAP: pass | +| RHEL-09-215035 | Ensure EPEL Repository is Disabled | medium | pass | met | OpenSCAP: pass | +| RHEL-09-215040 | Uninstall telnet-server Package | high | pass | met | OpenSCAP: pass | +| RHEL-09-215045 | Uninstall gssproxy Package | medium | pass | met | OpenSCAP: pass | +| RHEL-09-215050 | Uninstall iprutils Package | medium | pass | met | OpenSCAP: pass | +| RHEL-09-215055 | Uninstall tuned Package | medium | pass | met | OpenSCAP: pass | +| RHEL-09-215060 | Uninstall tftp-server Package | high | pass | met | OpenSCAP: pass | +| RHEL-09-215070 | Disable graphical user interface | medium | pass | met | OpenSCAP: pass | +| RHEL-09-215075 | Install Smart Card Packages For Multifactor Authentication | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and not_s390x_arch hold | +| RHEL-09-215080 | Ensure gnutls-utils is installed | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-215085 | Ensure nss-tools is installed | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-215090 | Install rng-tools Package | low | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where not_runtime_kernel_fips_enabled_and_system_with_kernel holds | +| RHEL-09-215095 | The s-nail Package Is Installed | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-215100 | Install crypto-policies package | medium | pass | met | OpenSCAP: pass | +| RHEL-09-215101 | The Postfix package is installed | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-215105, RHEL-09-672030 | Configure System Cryptography Policy | high | fail | the deployment's | The system-wide crypto policy (FIPS:STIG) goes with FIPS mode, which is a property of the host kernel and of how the container is run (docs/fips-leg.md), so the deployment sets it. The image's own TLS is OTP's ssl application, which crypto-policies has no back-end for; the crypto NIF links the base's OpenSSL, whose FIPS provider the deployment's FIPS mode enables. | +| RHEL-09-215105 | FIPS Must Use a Supported Subpolicy | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-215105 | Implement STIG Sub Crypto Policy | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-231010 | Ensure /home Located On Separate Partition | low | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where not_bootc_and_not_container holds | +| RHEL-09-231015 | Ensure /tmp Located On Separate Partition | low | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where not_bootc_and_not_container holds | +| RHEL-09-231020 | Ensure /var Located On Separate Partition | low | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where not_bootc_and_not_container holds | +| RHEL-09-231025 | Ensure /var/log Located On Separate Partition | low | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where not_bootc_and_not_container holds | +| RHEL-09-231030 | Ensure /var/log/audit Located On Separate Partition | low | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where not_bootc_and_not_container holds | +| RHEL-09-231035 | Ensure /var/tmp Located On Separate Partition | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where not_bootc_and_not_container holds | +| RHEL-09-231040 | Disable the Automounter | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where package_autofs_and_system_with_kernel holds | +| RHEL-09-231045 | Add nodev Option to /home | unknown | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where not_bootc_and_not_container and mount_home hold | +| RHEL-09-231050 | Add nosuid Option to /home | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where not_bootc_and_not_container and mount_home hold | +| RHEL-09-231055 | Add noexec Option to /home | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where not_bootc_and_not_container holds | +| RHEL-09-231065 | Mount Remote Filesystems with nodev | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and nfs_mount_defined hold | +| RHEL-09-231070 | Mount Remote Filesystems with noexec | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and nfs_mount_defined hold | +| RHEL-09-231075 | Mount Remote Filesystems with nosuid | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and nfs_mount_defined hold | +| RHEL-09-231080 | Add noexec Option to Removable Media Partitions | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where not_bootc_and_not_container holds | +| RHEL-09-231085 | Add nodev Option to Removable Media Partitions | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where not_bootc_and_not_container holds | +| RHEL-09-231090 | Add nosuid Option to Removable Media Partitions | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where not_bootc_and_not_container holds | +| RHEL-09-231095 | Add nodev Option to /boot | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where not_bootc_and_not_container holds | +| RHEL-09-231100 | Add nosuid Option to /boot | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where not_bootc_and_not_container holds | +| RHEL-09-231105 | Add nosuid Option to /boot/efi | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where not_bootc_and_not_container and mount_boot-efi hold | +| RHEL-09-231110 | Add nodev Option to /dev/shm | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where not_bootc_and_not_container holds | +| RHEL-09-231115 | Add noexec Option to /dev/shm | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where not_bootc_and_not_container holds | +| RHEL-09-231120 | Add nosuid Option to /dev/shm | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where not_bootc_and_not_container holds | +| RHEL-09-231125 | Add nodev Option to /tmp | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where not_bootc_and_not_container and mount_tmp hold | +| RHEL-09-231130 | Add noexec Option to /tmp | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where not_bootc_and_not_container and mount_tmp hold | +| RHEL-09-231135 | Add nosuid Option to /tmp | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where not_bootc_and_not_container and mount_tmp hold | +| RHEL-09-231140 | Add nodev Option to /var | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where not_bootc_and_not_container and mount_var hold | +| RHEL-09-231145 | Add nodev Option to /var/log | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where not_bootc_and_not_container and mount_var-log hold | +| RHEL-09-231150 | Add noexec Option to /var/log | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where not_bootc_and_not_container and mount_var-log hold | +| RHEL-09-231155 | Add nosuid Option to /var/log | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where not_bootc_and_not_container and mount_var-log hold | +| RHEL-09-231160 | Add nodev Option to /var/log/audit | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where not_bootc_and_not_container and mount_var-log-audit hold | +| RHEL-09-231165 | Add noexec Option to /var/log/audit | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where not_bootc_and_not_container and mount_var-log-audit hold | +| RHEL-09-231170 | Add nosuid Option to /var/log/audit | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where not_bootc_and_not_container and mount_var-log-audit hold | +| RHEL-09-231175 | Add nodev Option to /var/tmp | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where not_bootc_and_not_container and mount_var-tmp hold | +| RHEL-09-231180 | Add noexec Option to /var/tmp | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where not_bootc_and_not_container and mount_var-tmp hold | +| RHEL-09-231185 | Add nosuid Option to /var/tmp | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where not_bootc_and_not_container and mount_var-tmp hold | +| RHEL-09-231190 | Encrypt Partitions | high | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where not_bootc_and_not_container holds | +| RHEL-09-231195 | Disable Mounting of cramfs | low | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-231200 | Add nodev Option to Non-Root Local Partitions | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where not_bootc_and_not_container holds | +| RHEL-09-232010 | Verify that System Executables Have Restrictive Permissions | medium | pass | met | OpenSCAP: pass | +| RHEL-09-232015 | Verify that Shared Library Directories Have Restrictive Permissions | medium | pass | met | OpenSCAP: pass | +| RHEL-09-232020 | Verify that Shared Library Files Have Restrictive Permissions | medium | pass | met | OpenSCAP: pass | +| RHEL-09-232025 | Verify Permissions on /var/log Directory | medium | pass | met | OpenSCAP: pass | +| RHEL-09-232030 | Verify Permissions on /var/log/messages File | medium | pass | met | OpenSCAP: pass | +| RHEL-09-232035 | Audit Tools Must Have a Mode of 0755 or Less Permissive | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-232040 | Verify Permissions on Cron Configuration Files Are Not Modified | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-232045 | Ensure All User Initialization Files Have Mode 0740 Or Less Permissive | medium | pass | met | OpenSCAP: pass | +| RHEL-09-232045 | Ensure rootfiles tmpfile.d is Configured Correctly | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where package_rootfiles holds | +| RHEL-09-232050 | All Interactive User Home Directories Must Have mode 0750 Or Less Permissive | medium | pass | met | OpenSCAP: pass | +| RHEL-09-232055 | Verify Permissions on group File | medium | pass | met | OpenSCAP: pass | +| RHEL-09-232060 | Verify Permissions on Backup group File | medium | pass | met | OpenSCAP: pass | +| RHEL-09-232065 | Verify Permissions on gshadow File | medium | pass | met | OpenSCAP: pass | +| RHEL-09-232070 | Verify Permissions on Backup gshadow File | medium | pass | met | OpenSCAP: pass | +| RHEL-09-232075 | Verify Permissions on passwd File | medium | pass | met | OpenSCAP: pass | +| RHEL-09-232080 | Verify Permissions on Backup passwd File | medium | pass | met | OpenSCAP: pass | +| RHEL-09-232085 | Verify Permissions on Backup shadow File | medium | pass | met | OpenSCAP: pass | +| RHEL-09-232090 | Verify User Who Owns group File | medium | pass | met | OpenSCAP: pass | +| RHEL-09-232095 | Verify Group Who Owns group File | medium | pass | met | OpenSCAP: pass | +| RHEL-09-232100 | Verify User Who Owns Backup group File | medium | pass | met | OpenSCAP: pass | +| RHEL-09-232103 | Audit Configuration Files Must Be Owned By Root | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_audit hold | +| RHEL-09-232104 | Audit Configuration Files Must Be Owned By Group root | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_audit hold | +| RHEL-09-232105 | Verify Group Who Owns Backup group File | medium | pass | met | OpenSCAP: pass | +| RHEL-09-232110 | Verify User Who Owns gshadow File | medium | pass | met | OpenSCAP: pass | +| RHEL-09-232115 | Verify Group Who Owns gshadow File | medium | pass | met | OpenSCAP: pass | +| RHEL-09-232120 | Verify User Who Owns Backup gshadow File | medium | pass | met | OpenSCAP: pass | +| RHEL-09-232125 | Verify Group Who Owns Backup gshadow File | medium | pass | met | OpenSCAP: pass | +| RHEL-09-232130 | Verify User Who Owns passwd File | medium | pass | met | OpenSCAP: pass | +| RHEL-09-232135 | Verify Group Who Owns passwd File | medium | pass | met | OpenSCAP: pass | +| RHEL-09-232140 | Verify User Who Owns Backup passwd File | medium | pass | met | OpenSCAP: pass | +| RHEL-09-232145 | Verify Group Who Owns Backup passwd File | medium | pass | met | OpenSCAP: pass | +| RHEL-09-232150 | Verify User Who Owns shadow File | medium | pass | met | OpenSCAP: pass | +| RHEL-09-232155 | Verify Group Who Owns shadow File | medium | pass | met | OpenSCAP: pass | +| RHEL-09-232160 | Verify Group Who Owns Backup shadow File | medium | pass | met | OpenSCAP: pass | +| RHEL-09-232165 | Verify User Who Owns Backup shadow File | medium | pass | met | OpenSCAP: pass | +| RHEL-09-232170 | Verify User Who Owns /var/log Directory | medium | pass | met | OpenSCAP: pass | +| RHEL-09-232175 | Verify Group Who Owns /var/log Directory | medium | pass | met | OpenSCAP: pass | +| RHEL-09-232180 | Verify User Who Owns /var/log/messages File | medium | pass | met | OpenSCAP: pass | +| RHEL-09-232185 | Verify Group Who Owns /var/log/messages File | medium | pass | met | OpenSCAP: pass | +| RHEL-09-232190 | Verify that System Executables Have Root Ownership | medium | pass | met | OpenSCAP: pass | +| RHEL-09-232195 | Verify that system commands files are group owned by root or a system account | medium | pass | met | OpenSCAP: pass | +| RHEL-09-232200 | Verify that Shared Library Files Have Root Ownership | medium | pass | met | OpenSCAP: pass | +| RHEL-09-232205 | Verify the system-wide library files in directories "/lib", "/lib64", "/usr/lib/" and "/usr/lib64" are group-owned by root. | medium | pass | met | OpenSCAP: pass | +| RHEL-09-232210 | Verify that Shared Library Directories Have Root Ownership | medium | pass | met | OpenSCAP: pass | +| RHEL-09-232215 | Verify that Shared Library Directories Have Root Group Ownership | medium | pass | met | OpenSCAP: pass | +| RHEL-09-232220 | Audit Tools Must Be Owned by Root | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-232225 | Audit Tools Must Be Group-owned by Root | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-232230 | Verify Owner on cron.d | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-232230 | Verify Owner on cron.daily | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-232230 | Verify Owner on cron.deny | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-232230 | Verify Owner on cron.hourly | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-232230 | Verify Owner on cron.monthly | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-232230 | Verify Owner on cron.weekly | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-232230 | Verify Owner on crontab | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-232235 | Verify Group Who Owns cron.d | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-232235 | Verify Group Who Owns cron.daily | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-232235 | Verify Group Who Owns cron.deny | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-232235 | Verify Group Who Owns cron.hourly | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-232235 | Verify Group Who Owns cron.monthly | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-232235 | Verify Group Who Owns cron.weekly | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-232235 | Verify Group Who Owns Crontab | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-232240 | Ensure All World-Writable Directories Are Owned by a System Account | medium | pass | met | OpenSCAP: pass | +| RHEL-09-232245 | Verify that All World-Writable Directories Have Sticky Bits Set | medium | pass | met | OpenSCAP: pass | +| RHEL-09-232250 | Ensure All Files Are Owned by a Group | medium | pass | met | OpenSCAP: pass | +| RHEL-09-232255 | Ensure All Files Are Owned by a User | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-232260 | Ensure No Device Files are Unlabeled by SELinux | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-232270 | Verify Permissions on shadow File | medium | pass | met | OpenSCAP: pass | +| RHEL-09-251010 | Install firewalld Package | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-251015 | Verify firewalld Enabled | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_firewalld hold | +| RHEL-09-251020 | Firewalld Must Employ a Deny-all, Allow-by-exception Policy for Allowing Connections to Other Systems | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-251030 | Configure Firewalld to Use the Nftables Backend | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_firewalld hold | +| RHEL-09-251035 | Enable SSH Server firewalld Firewall Exception | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-251040 | Ensure System is Not Acting as a Network Sniffer | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where machine holds | +| RHEL-09-251045 | Harden the operation of the BPF just-in-time compiler | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-252010 | The Chrony package is installed | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-252015 | The Chronyd service is enabled | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_chrony hold | +| RHEL-09-252020 | Configure Time Service Maxpoll Interval | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_chrony_or_package_ntp hold | +| RHEL-09-252020 | Ensure Chrony is only configured with the server directive | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_chrony hold | +| RHEL-09-252020 | A remote time server for Chrony is configured | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_chrony hold | +| RHEL-09-252025 | Disable chrony daemon from acting as server | low | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_chrony hold | +| RHEL-09-252030 | Configure chrony-wait.service to use Unix socket | low | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_chrony hold | +| RHEL-09-252030 | Disable network management of chrony daemon | low | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_chrony hold | +| RHEL-09-252035 | Configure Multiple DNS Servers in /etc/resolv.conf | medium | fail | the deployment's | A container's /etc/resolv.conf is written by the container runtime from the deployment's DNS configuration (the kubelet, or the engine), not taken from the image, so the number of name servers is the deployment's. | +| RHEL-09-252040 | NetworkManager DNS Mode Must Be Must Configured | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where package_networkmanager holds | +| RHEL-09-252045 | Verify Any Configured IPSec Tunnel Connections | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-252050 | Prevent Unrestricted Mail Relaying | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_postfix hold | +| RHEL-09-252060 | Configure System to Forward All Mail From Postmaster to The Root Account | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-252065 | Install libreswan Package | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-252070 | Remove Host-Based Authentication Files | high | pass | met | OpenSCAP: pass | +| RHEL-09-252075 | Remove User Host-Based Authentication Files | high | pass | met | OpenSCAP: pass | +| RHEL-09-253010 | Enable Kernel Parameter to Use TCP Syncookies on Network Interfaces | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-253015 | Disable Accepting ICMP Redirects for All IPv4 Interfaces | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-253020 | Disable Kernel Parameter for Accepting Source-Routed Packets on all IPv4 Interfaces | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-253025 | Enable Kernel Parameter to Log Martian Packets on all IPv4 Interfaces | unknown | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-253030 | Enable Kernel Parameter to Log Martian Packets on all IPv4 Interfaces by Default | unknown | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-253035 | Enable Kernel Parameter to Use Reverse Path Filtering on all IPv4 Interfaces | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-253040 | Disable Kernel Parameter for Accepting ICMP Redirects by Default on IPv4 Interfaces | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-253045 | Disable Kernel Parameter for Accepting Source-Routed Packets on IPv4 Interfaces by Default | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-253050 | Enable Kernel Parameter to Use Reverse Path Filtering on all IPv4 Interfaces by Default | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-253055 | Enable Kernel Parameter to Ignore ICMP Broadcast Echo Requests on IPv4 Interfaces | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-253060 | Enable Kernel Parameter to Ignore Bogus ICMP Error Responses on IPv4 Interfaces | unknown | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-253065 | Disable Kernel Parameter for Sending ICMP Redirects on all IPv4 Interfaces | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-253070 | Disable Kernel Parameter for Sending ICMP Redirects on all IPv4 Interfaces by Default | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-253075 | Disable Kernel Parameter for IPv4 Forwarding on all IPv4 Interfaces | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-254010 | Configure Accepting Router Advertisements on All IPv6 Interfaces | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where ipv6_enabled and system_with_kernel hold | +| RHEL-09-254015 | Disable Accepting ICMP Redirects for All IPv6 Interfaces | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where ipv6_enabled and system_with_kernel hold | +| RHEL-09-254020 | Disable Kernel Parameter for Accepting Source-Routed Packets on all IPv6 Interfaces | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where ipv6_enabled and system_with_kernel hold | +| RHEL-09-254025 | Disable Kernel Parameter for IPv6 Forwarding | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where ipv6_enabled and system_with_kernel hold | +| RHEL-09-254030 | Disable Accepting Router Advertisements on all IPv6 Interfaces by Default | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where ipv6_enabled and system_with_kernel hold | +| RHEL-09-254035 | Disable Kernel Parameter for Accepting ICMP Redirects by Default on IPv6 Interfaces | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where ipv6_enabled and system_with_kernel hold | +| RHEL-09-254040 | Disable Kernel Parameter for Accepting Source-Routed Packets on IPv6 Interfaces by Default | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where ipv6_enabled and system_with_kernel hold | +| RHEL-09-255010 | Install the OpenSSH Server Package | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-255015 | Enable the OpenSSH Service | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-255020 | Install OpenSSH client software | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-255025 | Enable SSH Warning Banner | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-255030 | Set SSH Daemon LogLevel to VERBOSE | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-255035 | Enable Public Key Authentication | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-255040 | Disable SSH Access via Empty Passwords | high | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-255045 | Disable SSH Root Login | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-255050 | Enable PAM | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-255064 | Configure SSH Client to Use FIPS 140 Validated Ciphers: openssh.config | high | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where package_openssh holds | +| RHEL-09-255065 | Configure SSH Server to Use FIPS 140-2 Validated Ciphers: opensshserver.config | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-255065 | SSHD Must Include System Crypto Policy Config File | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-255070 | Configure SSH Client to Use FIPS 140-2 Validated MACs: openssh.config | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where package_openssh holds | +| RHEL-09-255075 | Configure SSH Server to Use FIPS 140-2 Validated MACs: opensshserver.config | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-255080 | Disable Host-Based Authentication | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-255085 | Do Not Allow SSH Environment Options | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-255090 | Force frequent session key renegotiation | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-255095 | Set SSH Client Alive Count Max | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-255100 | Set SSH Client Alive Interval | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-255105 | Verify Group Who Owns SSH Server Configuration Files | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-255105 | Verify Group Who Owns SSH Server config file | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-255105 | Verify Group Who Owns SSH Server Configuration Files | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-255105, RHEL-09-255110 | The File /etc/ssh/sshd_config.d/50-redhat.conf Must Exist | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-255110 | Verify Owner on SSH Server Configuration Files | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-255110 | Verify Owner on SSH Server config file | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-255110 | Verify Owner on SSH Server Configuration Files | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-255115 | Verify Permissions on SSH Server Config File | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-255115 | Verify Permissions on SSH Server config file | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-255115 | Verify Permissions on SSH Server Config File | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-255120 | Verify Permissions on SSH Server Private *_key Key Files | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-255125 | Verify Permissions on SSH Server Public *.pub Key Files | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-255135 | Disable GSSAPI Authentication | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-255140 | Disable Kerberos Authentication | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-255145 | Disable SSH Support for .rhosts Files | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-255150 | Disable SSH Support for User Known Hosts | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-255155 | Disable X11 Forwarding | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-255160 | Enable Use of Strict Mode Checking | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-255165 | Enable SSH Print Last Log | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-255175 | Prevent remote hosts from connecting to the proxy display | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-271010, RHEL-09-271015 | Enable GNOME3 Login Warning Banner | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where package_gdm holds | +| RHEL-09-271020, RHEL-09-271025 | Disable GNOME3 Automount Opening | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where package_gdm holds | +| RHEL-09-271030, RHEL-09-271035 | Disable GNOME3 Automount running | low | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where package_gdm holds | +| RHEL-09-271040 | Disable GDM Automatic Login | high | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where package_gdm holds | +| RHEL-09-271045, RHEL-09-271050 | Enable the GNOME3 Screen Locking On Smartcard Removal | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where package_gdm holds | +| RHEL-09-271055, RHEL-09-271060 | Enable GNOME3 Screensaver Lock After Idle Period | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where package_gdm holds | +| RHEL-09-271065 | Set GNOME3 Screensaver Inactivity Timeout | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where package_gdm holds | +| RHEL-09-271070 | Ensure Users Cannot Change GNOME3 Session Idle Settings | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where package_gdm holds | +| RHEL-09-271075 | Set GNOME3 Screensaver Lock Delay After Activation Period | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where package_gdm holds | +| RHEL-09-271080 | Ensure Users Cannot Change GNOME3 Screensaver Settings | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where package_gdm holds | +| RHEL-09-271085 | Implement Blank Screensaver | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where package_gdm holds | +| RHEL-09-271090 | Make sure that the dconf databases are up-to-date with regards to respective keyfiles | high | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where package_gdm and system_with_kernel hold | +| RHEL-09-271095, RHEL-09-271100 | Disable the GNOME3 Login Restart and Shutdown Buttons | high | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where package_gdm holds | +| RHEL-09-271105, RHEL-09-271110 | Disable Ctrl-Alt-Del Reboot Key Sequence in GNOME3 | high | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where package_gdm holds | +| RHEL-09-271115 | Disable the GNOME3 Login User List | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where package_gdm holds | +| RHEL-09-291010 | Disable Modprobe Loading of USB Storage Driver | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-291015 | Install usbguard Package | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where not_s390x_arch_and_system_with_kernel holds | +| RHEL-09-291020 | Enable the USBGuard Service | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where not_s390x_arch_and_system_with_kernel holds | +| RHEL-09-291025 | Log USBGuard daemon audit events using Linux Audit | low | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where not_s390x_arch_and_system_with_kernel and package_usbguard hold | +| RHEL-09-291030 | Generate USBGuard Policy | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where not_s390x_arch_and_system_with_kernel holds | +| RHEL-09-291035 | Disable Bluetooth Kernel Module | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-291040 | Deactivate Wireless Network Interfaces | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where not_container_and_wifi-iface holds | +| RHEL-09-411010 | Set Password Maximum Age | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_shadow-utils hold | +| RHEL-09-411015 | Set Existing Passwords Maximum Age | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-411020 | Ensure Home Directories are Created for New Users | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where package_shadow-utils_and_system_with_kernel holds | +| RHEL-09-411025 | Ensure the Default Umask is Set Correctly For Interactive Users | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-411030 | Ensure All Accounts on the System Have Unique User IDs | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-411035 | Ensure that System Accounts Do Not Run a Shell Upon Login | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-411040 | Assign Expiration Date to Temporary Accounts | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-411045 | All GIDs referenced in /etc/passwd must be defined in /etc/group | low | pass | met | OpenSCAP: pass | +| RHEL-09-411050 | Set Account Expiration Following Inactivity | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_shadow-utils hold | +| RHEL-09-411055 | Ensure that Users Path Contains Only Local Directories | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-411060 | All Interactive Users Must Have A Home Directory Defined | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-411065 | All Interactive Users Home Directories Must Exist | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-411070 | All Interactive User Home Directories Must Be Group-Owned By The Primary Group | medium | pass | met | OpenSCAP: pass | +| RHEL-09-411075 | Lock Accounts After Failed Password Attempts | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_pam hold | +| RHEL-09-411080 | Configure the root Account for Failed Password Attempts | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_pam hold | +| RHEL-09-411085 | Set Interval For Counting Failed Password Attempts | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_pam hold | +| RHEL-09-411090 | Set Lockout Time for Failed Password Attempts | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_pam hold | +| RHEL-09-411095 | Only Authorized Local User Accounts Exist on Operating System | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-411100 | Verify Only Root Has UID 0 | high | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-411105 | Lock Accounts Must Persist | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_pam hold | +| RHEL-09-411110 | Ensure All Groups on the System Have Unique Group ID | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-412035 | Set Interactive Session Timeout | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-412040 | Limit the Number of Concurrent Login Sessions Allowed Per User | low | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where package_pam_and_system_with_kernel holds | +| RHEL-09-412045 | Account Lockouts Must Be Logged | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_pam hold | +| RHEL-09-412050 | Ensure the Logon Failure Delay is Set Correctly in login.defs | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where package_shadow-utils_and_system_with_kernel holds | +| RHEL-09-412055 | Ensure the Default Bash Umask is Set Correctly | medium | pass | met | OpenSCAP: pass | +| RHEL-09-412060 | Ensure the Default C Shell Umask is Set Correctly | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where package_tcsh holds | +| RHEL-09-412065 | Ensure the Default Umask is Set Correctly in login.defs | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where package_shadow-utils_and_system_with_kernel holds | +| RHEL-09-412070 | Ensure the Default Umask is Set Correctly in /etc/profile | medium | pass | met | OpenSCAP: pass | +| RHEL-09-412080 | Configure Logind to terminate idle sessions after certain time of inactivity | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and os_linux_ol_gt_or_eq_8_7 and os_linux_rhel_gt_or_eq_8_7_and_os_linux_rhel_ne_9_0 and os_linux_sles_gt_or_eq_15 hold | +| RHEL-09-431010 | Ensure SELinux State is Enforcing | high | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-431015 | Configure SELinux Policy | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-431016 | Elevate The SELinux Context When An Administrator Calls The Sudo Command | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-431020 | An SELinux Context must be configured for the pam_faillock.so records directory | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-431025 | Install policycoreutils Package | low | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-431030 | Install policycoreutils-python-utils package | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-432010 | Install sudo Package | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-432015 | Require Re-Authentication When Using the sudo Command | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_sudo hold | +| RHEL-09-432020 | Ensure invoking users password for privilege escalation when using sudo | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_sudo hold | +| RHEL-09-432025 | Ensure Users Re-Authenticate for Privilege Escalation - sudo !authenticate | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-432030 | The operating system must restrict privilege elevation to authorized personnel | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_sudo hold | +| RHEL-09-432035 | Enforce usage of pam_wheel for su authentication | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where package_pam holds | +| RHEL-09-433010 | Install fapolicyd Package | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-433015 | Enable the File Access Policy Service | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-433016 | Configure Fapolicy Module to Employ a Deny-all, Permit-by-exception Policy to Allow the Execution of Authorized Software Programs. | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-611010 | Ensure PAM Enforces Password Requirements - Authentication Retry Prompts Permitted Per-Session in /etc/security/pwquality.conf | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-611025 | Prevent Login to Accounts With Empty Password | high | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-611030 | Configure the Use of the pam_faillock.so Module in the /etc/pam.d/system-auth File. | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-611035 | Configure the Use of the pam_faillock.so Module in the /etc/pam.d/password-auth File. | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-611040 | Ensure PAM password complexity module is enabled in password-auth | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_libpwquality hold | +| RHEL-09-611045 | Ensure PAM password complexity module is enabled in system-auth | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_libpwquality hold | +| RHEL-09-611050 | Set number of Password Hashing Rounds - password-auth | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where package_pam_and_system_with_kernel holds | +| RHEL-09-611055 | Set number of Password Hashing Rounds - system-auth | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where package_pam_and_system_with_kernel holds | +| RHEL-09-611060 | Ensure PAM Enforces Password Requirements - Enforce for root User | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_libpwquality hold | +| RHEL-09-611065 | Ensure PAM Enforces Password Requirements - Minimum Lowercase Characters | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_libpwquality hold | +| RHEL-09-611070 | Ensure PAM Enforces Password Requirements - Minimum Digit Characters | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_libpwquality hold | +| RHEL-09-611075 | Set Password Minimum Age | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_shadow-utils hold | +| RHEL-09-611080 | Set Existing Passwords Minimum Age | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-611085 | Ensure Users Re-Authenticate for Privilege Escalation - sudo NOPASSWD | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-611090 | Ensure PAM Enforces Password Requirements - Minimum Length | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_libpwquality hold | +| RHEL-09-611100 | Ensure PAM Enforces Password Requirements - Minimum Special Characters | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_libpwquality hold | +| RHEL-09-611105 | Ensure PAM Enforces Password Requirements - Prevent the Use of Dictionary Words | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_libpwquality hold | +| RHEL-09-611110 | Ensure PAM Enforces Password Requirements - Minimum Uppercase Characters | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_libpwquality hold | +| RHEL-09-611115 | Ensure PAM Enforces Password Requirements - Minimum Different Characters | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_libpwquality hold | +| RHEL-09-611120 | Ensure PAM Enforces Password Requirements - Maximum Consecutive Repeating Characters from Same Character Class | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_libpwquality hold | +| RHEL-09-611125 | Set Password Maximum Consecutive Repeating Characters | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_libpwquality hold | +| RHEL-09-611130 | Ensure PAM Enforces Password Requirements - Minimum Different Categories | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_libpwquality hold | +| RHEL-09-611135 | Set Password Hashing Algorithm in /etc/libuser.conf | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_libuser hold | +| RHEL-09-611140 | Set Password Hashing Algorithm in /etc/login.defs | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_shadow-utils hold | +| RHEL-09-611145 | Disallow Configuration to Bypass Password Requirements for Privilege Escalation | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where package_pam_and_system_with_kernel holds | +| RHEL-09-611155 | Ensure There Are No Accounts With Blank or Null Passwords | high | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-611160 | Configure opensc Smart Card Drivers | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-611165 | Enable Smartcards in SSSD | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where package_sssd holds | +| RHEL-09-611170 | Certificate status checking in SSSD | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where package_sssd holds | +| RHEL-09-611175 | Install the pcsc-lite package | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-611180 | Enable the pcscd Service | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-611185 | Install the opensc Package For Multifactor Authentication | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-611190 | Verify the SSH Private Key Files Have a Passcode | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where package_openssh-clients holds | +| RHEL-09-611195 | Require Authentication for Emergency Systemd Target | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-611200 | Require Authentication for Single User Mode | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-631010 | SSSD Has a Correct Trust Anchor | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where package_sssd holds | +| RHEL-09-631015 | Enable Certmap in SSSD | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where package_sssd holds | +| RHEL-09-631020 | Configure SSSD to Expire Offline Credentials | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where package_sssd holds | +| RHEL-09-651010 | Build and Test AIDE Database | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-651010 | Install AIDE | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-651015 | Configure Notification of Post-AIDE Scan Details | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-651020 | Configure AIDE to Use FIPS 140-2 for Validating Hashes | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-651025 | Configure AIDE to Verify the Audit Tools | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-651030 | Configure AIDE to Verify Access Control Lists (ACLs) | low | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-651035 | Configure AIDE to Verify Extended Attributes | low | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-652010 | Ensure rsyslog is Installed | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-652015 | Ensure rsyslog-gnutls is installed | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-652020 | Enable rsyslog Service | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-652025 | Ensure rsyslog Does Not Accept Remote Messages Unless Acting As Log Server | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-652030 | Ensure remote access methods are monitored in Rsyslog | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_rsyslog hold | +| RHEL-09-652040 | Ensure Rsyslog Authenticates Off-Loaded Audit Records | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_rsyslog hold | +| RHEL-09-652045 | Ensure Rsyslog Encrypts Off-Loaded Audit Records | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_rsyslog hold | +| RHEL-09-652050 | Ensure Rsyslog Encrypts Off-Loaded Audit Records | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_rsyslog hold | +| RHEL-09-652055 | Ensure Logs Sent To Remote Host | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-652060 | Ensure cron Is Logging To Rsyslog | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_rsyslog hold | +| RHEL-09-653010 | Ensure the audit Subsystem is Installed | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-653015 | Enable auditd Service | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_audit hold | +| RHEL-09-653020 | Configure auditd Disk Error Action on Disk Error | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_audit hold | +| RHEL-09-653025 | Configure auditd Disk Full Action when Disk Space Is Full | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_audit hold | +| RHEL-09-653030 | Configure a Sufficiently Large Partition for Audit Logs | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_audit hold | +| RHEL-09-653035 | Configure auditd space_left on Low Disk Space | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_audit hold | +| RHEL-09-653040 | Configure auditd space_left Action on Low Disk Space | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_audit hold | +| RHEL-09-653045 | Configure auditd admin_space_left on Low Disk Space | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_audit hold | +| RHEL-09-653050 | Configure auditd admin_space_left Action on Low Disk Space | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_audit hold | +| RHEL-09-653055 | Configure auditd max_log_file_action Upon Reaching Maximum Log Size | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_audit hold | +| RHEL-09-653060 | Set type of computer node name logging in audit logs | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_audit hold | +| RHEL-09-653065 | Appropriate Action Must be Setup When the Internal Audit Event Queue is Full | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_audit hold | +| RHEL-09-653070 | Configure auditd mail_acct Action on Low Disk Space | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_audit hold | +| RHEL-09-653075 | Include Local Events in Audit Logs | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_audit hold | +| RHEL-09-653080 | System Audit Directories Must Be Group Owned By Root | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_audit hold | +| RHEL-09-653085 | System Audit Directories Must Be Owned By Root | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_audit hold | +| RHEL-09-653090 | System Audit Logs Must Have Mode 0640 or Less Permissive | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_audit hold | +| RHEL-09-653095 | Set number of records to cause an explicit flush to audit logs | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_audit hold | +| RHEL-09-653100 | Resolve information before writing to audit logs | low | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_audit hold | +| RHEL-09-653105 | Write Audit Logs to the Disk | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_audit hold | +| RHEL-09-653110 | Audit Configuration Files Permissions are 600 or More Restrictive | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_audit hold | +| RHEL-09-653120 | Extend Audit Backlog Limit for the Audit Daemon | low | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and grub2 hold | +| RHEL-09-653125 | Configure System to Forward All Mail For The Root Account | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-653130 | Install audispd-plugins Package | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | +| RHEL-09-654010 | Record Events When Privileged Executables Are Run | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_audit hold | +| RHEL-09-654015 | Record Events that Modify the System's Discretionary Access Controls - chmod | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_audit and not_aarch64_arch hold | +| RHEL-09-654015 | Record Events that Modify the System's Discretionary Access Controls - fchmod | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_audit hold | +| RHEL-09-654015 | Record Events that Modify the System's Discretionary Access Controls - fchmodat | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_audit hold | +| RHEL-09-654020 | Record Events that Modify the System's Discretionary Access Controls - chown | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_audit and not_aarch64_arch hold | +| RHEL-09-654020 | Record Events that Modify the System's Discretionary Access Controls - fchown | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_audit hold | +| RHEL-09-654020 | Record Events that Modify the System's Discretionary Access Controls - fchownat | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_audit hold | +| RHEL-09-654020 | Record Events that Modify the System's Discretionary Access Controls - lchown | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_audit and not_aarch64_arch hold | +| RHEL-09-654025 | Record Events that Modify the System's Discretionary Access Controls - fremovexattr | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_audit hold | +| RHEL-09-654025 | Record Events that Modify the System's Discretionary Access Controls - fsetxattr | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_audit hold | +| RHEL-09-654025 | Record Events that Modify the System's Discretionary Access Controls - lremovexattr | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_audit hold | +| RHEL-09-654025 | Record Events that Modify the System's Discretionary Access Controls - lsetxattr | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_audit hold | +| RHEL-09-654025 | Record Events that Modify the System's Discretionary Access Controls - removexattr | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_audit hold | +| RHEL-09-654025 | Record Events that Modify the System's Discretionary Access Controls - setxattr | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_audit hold | +| RHEL-09-654030 | Ensure auditd Collects Information on the Use of Privileged Commands - umount | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_audit hold | +| RHEL-09-654035 | Record Any Attempts to Run chacl | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_audit hold | +| RHEL-09-654040 | Record Any Attempts to Run setfacl | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_audit hold | +| RHEL-09-654045 | Record Any Attempts to Run chcon | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_audit hold | +| RHEL-09-654050 | Record Any Attempts to Run semanage | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_audit hold | +| RHEL-09-654055 | Record Any Attempts to Run setfiles | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_audit hold | +| RHEL-09-654060 | Record Any Attempts to Run setsebool | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_audit hold | +| RHEL-09-654065 | Ensure auditd Collects File Deletion Events by User - rename | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_audit and not_aarch64_arch hold | +| RHEL-09-654065 | Ensure auditd Collects File Deletion Events by User - renameat | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_audit hold | +| RHEL-09-654065 | Ensure auditd Collects File Deletion Events by User - rmdir | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_audit and not_aarch64_arch hold | +| RHEL-09-654065 | Ensure auditd Collects File Deletion Events by User - unlink | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_audit and not_aarch64_arch hold | +| RHEL-09-654065 | Ensure auditd Collects File Deletion Events by User - unlinkat | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_audit hold | +| RHEL-09-654070 | Record Unsuccessful Access Attempts to Files - creat | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_audit and not_aarch64_arch hold | +| RHEL-09-654070 | Record Unsuccessful Access Attempts to Files - ftruncate | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_audit hold | +| RHEL-09-654070 | Record Unsuccessful Access Attempts to Files - open | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_audit and not_aarch64_arch hold | +| RHEL-09-654070 | Record Unsuccessful Access Attempts to Files - open_by_handle_at | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_audit hold | +| RHEL-09-654070 | Record Unsuccessful Access Attempts to Files - openat | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_audit hold | +| RHEL-09-654070 | Record Unsuccessful Access Attempts to Files - truncate | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_audit hold | +| RHEL-09-654075 | Ensure auditd Collects Information on Kernel Module Unloading - delete_module | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_audit hold | +| RHEL-09-654080 | Ensure auditd Collects Information on Kernel Module Loading and Unloading - finit_module | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_audit hold | +| RHEL-09-654080 | Ensure auditd Collects Information on Kernel Module Loading - init_module | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_audit hold | +| RHEL-09-654085 | Ensure auditd Collects Information on the Use of Privileged Commands - chage | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_audit hold | +| RHEL-09-654090 | Ensure auditd Collects Information on the Use of Privileged Commands - chsh | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_audit hold | +| RHEL-09-654095 | Ensure auditd Collects Information on the Use of Privileged Commands - crontab | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_audit hold | +| RHEL-09-654097 | Audit Any Script or Executable Called by Cron as Root or by Any Privileged User | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_audit hold | +| RHEL-09-654100 | Ensure auditd Collects Information on the Use of Privileged Commands - gpasswd | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_audit hold | +| RHEL-09-654105 | Ensure auditd Collects Information on the Use of Privileged Commands - kmod | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_audit hold | +| RHEL-09-654110 | Ensure auditd Collects Information on the Use of Privileged Commands - newgrp | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_audit hold | +| RHEL-09-654115 | Ensure auditd Collects Information on the Use of Privileged Commands - pam_timestamp_check | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_audit hold | +| RHEL-09-654120 | Ensure auditd Collects Information on the Use of Privileged Commands - passwd | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_audit hold | +| RHEL-09-654125 | Ensure auditd Collects Information on the Use of Privileged Commands - postdrop | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_audit hold | +| RHEL-09-654130 | Ensure auditd Collects Information on the Use of Privileged Commands - postqueue | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_audit hold | +| RHEL-09-654135 | Record Any Attempts to Run ssh-agent | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_audit hold | +| RHEL-09-654140 | Ensure auditd Collects Information on the Use of Privileged Commands - ssh-keysign | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_audit hold | +| RHEL-09-654145 | Ensure auditd Collects Information on the Use of Privileged Commands - su | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_audit hold | +| RHEL-09-654150 | Ensure auditd Collects Information on the Use of Privileged Commands - sudo | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_audit hold | +| RHEL-09-654155 | Ensure auditd Collects Information on the Use of Privileged Commands - sudoedit | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_audit hold | +| RHEL-09-654160 | Ensure auditd Collects Information on the Use of Privileged Commands - unix_chkpwd | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_audit hold | +| RHEL-09-654165 | Ensure auditd Collects Information on the Use of Privileged Commands - unix_update | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_audit hold | +| RHEL-09-654170 | Ensure auditd Collects Information on the Use of Privileged Commands - userhelper | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_audit hold | +| RHEL-09-654175 | Ensure auditd Collects Information on the Use of Privileged Commands - usermod | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_audit hold | +| RHEL-09-654180 | Ensure auditd Collects Information on the Use of Privileged Commands - mount | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_audit hold | +| RHEL-09-654185 | Ensure auditd Collects Information on the Use of Privileged Commands - init | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_audit hold | +| RHEL-09-654190 | Ensure auditd Collects Information on the Use of Privileged Commands - poweroff | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_audit hold | +| RHEL-09-654195 | Ensure auditd Collects Information on the Use of Privileged Commands - reboot | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_audit hold | +| RHEL-09-654200 | Ensure auditd Collects Information on the Use of Privileged Commands - shutdown | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_audit hold | +| RHEL-09-654205 | Record Events that Modify the System's Discretionary Access Controls - umount | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_audit and not_aarch64_arch hold | +| RHEL-09-654210 | Record Events that Modify the System's Discretionary Access Controls - umount2 | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_audit hold | +| RHEL-09-654215 | Ensure auditd Collects System Administrator Actions - /etc/sudoers | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_audit hold | +| RHEL-09-654220 | Ensure auditd Collects System Administrator Actions - /etc/sudoers.d/ | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_audit hold | +| RHEL-09-654225 | Record Events that Modify User/Group Information - /etc/group | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_audit hold | +| RHEL-09-654230 | Record Events that Modify User/Group Information - /etc/gshadow | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_audit hold | +| RHEL-09-654235 | Record Events that Modify User/Group Information - /etc/security/opasswd | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_audit hold | +| RHEL-09-654240 | Record Events that Modify User/Group Information - /etc/passwd | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_audit hold | +| RHEL-09-654245 | Record Events that Modify User/Group Information - /etc/shadow | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_audit hold | +| RHEL-09-654250 | Record Attempts to Alter Logon and Logout Events - faillock | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_audit hold | +| RHEL-09-654255 | Record Attempts to Alter Logon and Logout Events - lastlog | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_audit hold | +| RHEL-09-654265 | Shutdown System When Auditing Failures Occur | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_audit hold | +| RHEL-09-654275 | Make the auditd Configuration Immutable | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_audit hold | +| RHEL-09-671010 | Set kernel parameter 'crypto.fips_enabled' to 1 | high | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where not_osbuild_and_system_with_kernel holds | +| RHEL-09-671015 | Verify All Account Password Hashes are Shadowed with SHA512 | medium | pass | met | OpenSCAP: pass | +| RHEL-09-671020 | Configure Libreswan to use System Crypto Policy | high | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where package_libreswan_and_system_with_kernel holds | +| RHEL-09-671025 | Set PAM Password Hashing Algorithm - password-auth | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel and package_pam hold | +| RHEL-09-672020 | Ensure System Cryptographic Policy Is Not Overridden | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where not_osbuild_and_system_with_kernel holds | +| RHEL-09-672050 | Configure BIND to use System Crypto Policy | high | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where package_bind holds | +| | Enable authselect | medium | notapplicable | not applicable | OpenSCAP: notapplicable; the rule applies only where system_with_kernel holds | diff --git a/lib/mix/tasks/trinity.image.findings.ex b/lib/mix/tasks/trinity.image.findings.ex new file mode 100644 index 0000000..43c7b77 --- /dev/null +++ b/lib/mix/tasks/trinity.image.findings.ex @@ -0,0 +1,221 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Mix.Tasks.Trinity.Image.Findings do + @shortdoc "Gates a vulnerability scan of the headless image on its justifications (AC3, AC4)" + + @moduledoc """ + Reads a grype JSON report of the headless image and the justification file, and fails on a + High or Critical finding with no justification row (slice 130, AC3) or on a row whose finding + no longer appears in the scan (AC4). `docs/regulated/headless-image.md` describes the scan. + + mix trinity.image.findings --scan grype.json [--justifications PATH] [--out findings.csv] + + A finding is keyed the way Iron Bank's Vulnerability Assessment Tracker keys one: the + identifier, the package as `name-version`, and the package path, which is empty for a package + the OS package database reports (their `pkgdb`). So a justification is for one version of one + package: upgrading the package retires the row, and the staleness check says so rather than + letting the file keep an excuse for something that changed. + + Only High and Critical gate. Everything else is counted and written to `--out`, which is the + shape a submission's findings take: one row per finding with its justification, if any. + """ + + use Boundary, classify_to: Trinity + use Mix.Task + + @justifications "ci/headless/justifications.yaml" + @gated ~w(critical high) + @row_keys ~w(finding package packagePath severity scanSource justification) + + @typedoc "One finding, keyed by `{finding, package, packagePath}`." + @type finding :: %{ + finding: String.t(), + package: String.t(), + packagePath: String.t() | nil, + severity: String.t(), + scanSource: String.t(), + fix: String.t() + } + + @typedoc "The outcome of comparing a scan with the justification rows." + @type evaluation :: %{ + findings: [finding()], + unjustified: [finding()], + stale: [map()], + invalid: [String.t()], + justified: [{finding(), map()}] + } + + @impl Mix.Task + def run(argv) do + {opts, _, _} = + OptionParser.parse(argv, strict: [scan: :string, justifications: :string, out: :string]) + + scan = opts[:scan] || Mix.raise("usage: mix trinity.image.findings --scan grype.json") + path = opts[:justifications] || @justifications + report = scan |> File.read!() |> Jason.decode!() + evaluation = evaluate(findings(report), read_rows(path)) + + if out = opts[:out], do: File.write!(out, to_csv(evaluation)) + + counts = + evaluation.findings + |> Enum.frequencies_by(& &1.severity) + |> Enum.sort() + |> Enum.map_join(", ", fn {s, n} -> "#{n} #{s}" end) + + Mix.shell().info( + "trinity.image.findings: #{length(evaluation.findings)} finding(s): #{counts}" + ) + + Mix.shell().info("trinity.image.findings: #{length(evaluation.justified)} justified") + + case violations(evaluation, path) do + [] -> + Mix.shell().info("trinity.image.findings: OK") + + found -> + Enum.each(found, &Mix.shell().error("FAIL #{&1}")) + Mix.raise("trinity.image.findings: #{length(found)} violation(s)") + end + end + + @doc "The findings in a grype JSON report, one per key, severities lower-cased." + @spec findings(map()) :: [finding()] + def findings(report) do + report + |> Map.get("matches", []) + |> Enum.map(&to_finding/1) + |> Enum.uniq_by(&key/1) + |> Enum.sort_by(&{severity_rank(&1.severity), &1.finding, &1.package}) + end + + defp to_finding(match) do + artifact = match["artifact"] || %{} + vulnerability = match["vulnerability"] || %{} + fix = vulnerability["fix"] || %{} + + %{ + finding: vulnerability["id"], + package: "#{artifact["name"]}-#{artifact["version"]}", + packagePath: package_path(artifact), + severity: String.downcase(vulnerability["severity"] || "unknown"), + scanSource: "grype", + fix: Enum.join([fix["state"] | List.wrap(fix["versions"])] |> Enum.reject(&is_nil/1), " ") + } + end + + # A package the OS package database reports has no path in Iron Bank's key (their `pkgdb`). + defp package_path(%{"type" => type}) when type in ["rpm", "deb", "apk"], do: nil + + defp package_path(artifact) do + case artifact["locations"] do + [%{"path" => path} | _] -> path + _ -> nil + end + end + + @doc """ + Compares `findings` with justification `rows`: which gated findings have no row (AC3), which + rows match no finding (AC4), and which rows are malformed. + """ + @spec evaluate([finding()], [map()]) :: evaluation() + def evaluate(findings, rows) do + by_key = Map.new(findings, &{key(&1), &1}) + {valid, invalid} = Enum.split_with(rows, &(row_problems(&1) == [])) + rows_by_key = Map.new(valid, &{row_key(&1), &1}) + + %{ + findings: findings, + unjustified: + Enum.filter(findings, &(&1.severity in @gated and not Map.has_key?(rows_by_key, key(&1)))), + stale: Enum.reject(valid, &Map.has_key?(by_key, row_key(&1))), + invalid: + for( + row <- invalid, + problem <- row_problems(row), + do: "#{inspect(row_key(row))}: #{problem}" + ), + justified: for(f <- findings, row = rows_by_key[key(f)], row != nil, do: {f, row}) + } + end + + @doc "One line per violation in `evaluation`, each naming its criterion; empty when it passes." + @spec violations(evaluation(), Path.t()) :: [String.t()] + def violations(evaluation, path \\ @justifications) do + unjustified = + for f <- evaluation.unjustified do + "AC3: #{f.finding} (#{f.severity}) in #{f.package} at #{f.packagePath || "pkgdb"} " <> + "has no justification row in #{path}" <> fixed_in(f.fix) + end + + stale = + for row <- evaluation.stale do + "AC4: #{path} justifies #{row["finding"]} in #{row["package"]} at " <> + "#{row["packagePath"] || "pkgdb"}, which the scan no longer reports; remove the row" + end + + invalid = for line <- evaluation.invalid, do: "AC3: malformed justification row #{line}" + unjustified ++ stale ++ invalid + end + + defp fixed_in(""), do: "" + defp fixed_in(fix), do: " (fix: #{fix})" + + defp row_problems(row) when is_map(row) do + missing = for k <- @row_keys, not Map.has_key?(row, k), do: "lacks #{k}" + + empty = + if is_binary(row["justification"]) and String.trim(row["justification"]) != "", + do: [], + else: ["has no justification text"] + + if missing == [], do: empty, else: missing + end + + defp row_problems(_), do: ["is not a mapping"] + + defp key(f), do: {f.finding, f.package, f.packagePath} + defp row_key(row) when is_map(row), do: {row["finding"], row["package"], row["packagePath"]} + defp row_key(row), do: row + + defp severity_rank(severity) do + Enum.find_index(~w(critical high medium low negligible unknown), &(&1 == severity)) || 6 + end + + @doc "The findings as CSV in the Vulnerability Assessment Tracker's field order, justified or not." + @spec to_csv(evaluation()) :: String.t() + def to_csv(evaluation) do + rows = Map.new(evaluation.justified, fn {f, row} -> {key(f), row["justification"]} end) + header = "finding,severity,scanSource,package,packagePath,fix,justification" + + lines = + for f <- evaluation.findings do + [ + f.finding, + f.severity, + f.scanSource, + f.package, + f.packagePath || "", + f.fix, + rows[key(f)] || "" + ] + |> Enum.map_join(",", &csv_field/1) + end + + Enum.join([header | lines], "\n") <> "\n" + end + + defp csv_field(value) do + text = to_string(value) + + if String.contains?(text, [",", "\"", "\n"]), + do: "\"" <> String.replace(text, "\"", "\"\"") <> "\"", + else: text + end + + defp read_rows(path) do + {:ok, data} = YamlElixir.read_from_file(path) + (data || %{}) |> Map.get("justifications", []) |> List.wrap() + end +end diff --git a/lib/mix/tasks/trinity.image.inspect.ex b/lib/mix/tasks/trinity.image.inspect.ex new file mode 100644 index 0000000..af3b41e --- /dev/null +++ b/lib/mix/tasks/trinity.image.inspect.ex @@ -0,0 +1,641 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Mix.Tasks.Trinity.Image.Inspect do + @shortdoc "Asserts the built headless image's user and layer contents (slice 130, AC1 and AC6)" + + @moduledoc """ + Inspects a **built** image, never its Dockerfile, so a later layer that reverts a property + fails here whatever the recipe says (slice 130, AC1 and AC6; `docs/regulated/headless-image.md`). + + mix trinity.image.inspect IMAGE [--base REF] [--allowances PATH] + + `--base` defaults to the runtime base `ci/headless/bases.env` pins. The image's layers come from + `docker save`; the layers it adds over its base are found by diff ID, and every entry of every + added layer is checked, including entries a later layer deletes, because a deleted file is still + in the image's tarball. + + **AC1.** The image's configured user is not root: a numeric UID of 1000 or more, or a name the + image's own `/etc/passwd` resolves to one. + + **AC6, in the layers this image adds.** No package manager; no build tool (a compiler, a linker, + `make`, Mix); no build-time Elixir library carried as a release application; no shell history; + no documentation (`/usr/share/{doc,man,info}`, and files named README, CHANGELOG, CHANGES, + HISTORY or NEWS, outside `/usr/share/licenses`, which is licence text); no certificate that differs from the base's file at the same path; no SUID or + SGID file; no world-writable file or unsticky world-writable directory. `/etc/passwd` keeps the + base's mode, owner and group. + + **AC6, anywhere in the image, base included.** No private key, no keystore, no Erlang + distribution cookie. + + Base content that would otherwise be a finding is printed as inherited, because Iron Bank's rule + is not to remove what the base ships (slice 130 NOTES, D-130-5). A finding of an allowable kind + (`certificate`, `documentation`, `build_library`, `private_key`) may be allowed by a row in + `ci/headless/image_allowances.yaml` that names the path (or a directory prefix ending in `/`) and + a reason; a `private_key` allowance must name the exact file and its SHA-256. An allowance that + allows nothing fails as stale (D-130-10). Nothing else can be allowed. + + One more property, not an AC, checked because slice 130 found its absence in the slice 061 + image: `RELEASE_DISTRIBUTION=none`, so the release starts no distribution listener. + """ + + use Boundary, classify_to: Trinity + use Mix.Task + + @allowances "ci/headless/image_allowances.yaml" + @allowable ~w(certificate documentation build_library private_key) + + @package_managers ~w(rpm rpm2cpio rpmdb dnf dnf-3 dnf4 dnf5 yum microdnf apt apt-get dpkg apk + pip pip3 gem npm) + @build_tools ~w(gcc cc c++ g++ cpp clang clang++ ld ld.bfd ld.gold as make gmake cmake autoconf + automake libtool rustc cargo go javac pkg-config) + # Elixir libraries whose only job is building or packaging, which a release can still carry as + # applications when a dependency does not mark them `runtime: false`. + @build_libraries ~w(mix rustler elixir_make cc_precompiler burrito ex_tauri igniter fine) + @histories ~w(.bash_history .sh_history .ash_history .zsh_history .history .python_history + .mysql_history .psql_history .lesshst .viminfo .node_repl_history .erlang_history) + @key_extensions ~w(.key .p12 .pfx .jks .keystore) + @certificate_extensions ~w(.crt .cer .der) + @documentation_name ~r/^(README|CHANGELOG|CHANGES|HISTORY|NEWS)(\.(md|txt|rst|markdown))?$/i + # A PEM block, not its header: the header strings alone are constants inside every TLS library + # and in OTP's own public_key (measured on the slice 061 image: libgnutls, libssh2 and + # pubkey_pem.beam matched a header-only pattern, and only libgnutls carries a key). A block is + # the header, any RFC 1421 header lines, then a base64 body. + @private_key ~r/-----BEGIN (?:[A-Z0-9]+ )*PRIVATE KEY-----\r?\n(?:[A-Za-z-]+:[^\n]*\n)*\r?\n?[A-Za-z0-9+\/]{40}/ + @certificate ~r/-----BEGIN (?:TRUSTED )?CERTIFICATE-----\r?\n[A-Za-z0-9+\/]{40}/ + @chunk 1_048_576 + # Bytes carried from one chunk into the next, longer than the longest span either pattern needs + # to see, so a block that straddles a chunk boundary is still found. + @carry 512 + + @typedoc "One tar entry, as `:erl_tar.table/2` reports it with `:verbose`." + @type entry :: %{ + path: String.t(), + type: atom(), + mode: non_neg_integer(), + uid: non_neg_integer(), + gid: non_neg_integer() + } + + @typedoc "An unpacked layer: its entries and the directory its contents were written to." + @type layer :: %{entries: [entry()], dir: Path.t()} + + @typedoc """ + A finding before allowances: the criterion, the path, its kind and the reason, plus, for a + private key, the SHA-256 of each PEM block found. + """ + @type finding :: %{ + ac: String.t(), + path: String.t(), + kind: String.t(), + reason: String.t(), + blocks: [String.t()] + } + + @typedoc "What `check/1` reads." + @type input :: %{ + config: map(), + base: [layer()], + added: [layer()], + allowances: [map()] + } + + @typedoc "What `check/1` returns." + @type result :: %{violations: [String.t()], allowed: [String.t()], inherited: [String.t()]} + + @impl Mix.Task + def run(argv) do + {opts, args, _} = OptionParser.parse(argv, strict: [base: :string, allowances: :string]) + image = List.first(args) || Mix.raise("usage: mix trinity.image.inspect IMAGE [--base REF]") + base = opts[:base] || pinned_base() + + work = + Path.join(System.tmp_dir!(), "trinity-image-inspect-#{System.unique_integer([:positive])}") + + try do + {config, base_layers, added_layers} = unpack(image, base, work) + + %{config: config, base: base_layers, added: added_layers} + |> Map.put(:allowances, read_allowances(opts[:allowances] || @allowances)) + |> check() + |> report(image, base) + after + _ = System.cmd("chmod", ["-R", "u+rwX", work], stderr_to_stdout: true) + File.rm_rf(work) + end + end + + @doc """ + Every finding for `input`: violations, each naming its criterion; the findings allowances + allowed, with their reasons; and base content that would otherwise be a finding. + """ + @spec check(input()) :: result() + def check(input) do + layers = input.base ++ input.added + base_files = files_by_path(input.base) + + findings = + Enum.flat_map(input.added, &added_findings(&1, base_files)) ++ + Enum.flat_map(layers, &secret_findings/1) + + {allowed, refused} = Enum.split_with(findings, &allowance_for(&1, input.allowances)) + + stale = + for a <- input.allowances, not Enum.any?(allowed, &(allowance_for(&1, [a]) != nil)), do: a + + violations = + Enum.concat([ + user_violations(input.config, layers), + distribution_violations(input.config), + Enum.map(refused, &"#{&1.ac}: /#{&1.path}: #{&1.reason}"), + passwd_violations( + final_entry(layers, "etc/passwd"), + final_entry(input.base, "etc/passwd") + ), + allowance_violations(input.allowances), + Enum.map( + stale, + &"AC6: allowance #{&1["path"]} allows nothing in this image; remove it from #{@allowances}" + ) + ]) + + %{ + violations: Enum.uniq(violations), + allowed: + Enum.map( + allowed, + &"/#{&1.path}: #{&1.reason} (#{allowance_for(&1, input.allowances)["reason"]})" + ), + inherited: Enum.flat_map(input.base, &inherited/1) + } + end + + # AC1 ----------------------------------------------------------------------------------------- + + defp user_violations(config, layers) do + user = get_in(config, ["config", "User"]) || "" + name = user |> String.split(":") |> hd() + + cond do + name == "" -> + ["AC1: the image sets no user, so it runs as root"] + + name =~ ~r/^\d+$/ -> + uid_violation(String.to_integer(name), user) + + true -> + case passwd_uid(layers, name) do + nil -> ["AC1: the image's user #{user} is not in its /etc/passwd"] + uid -> uid_violation(uid, user) + end + end + end + + defp uid_violation(0, user), do: ["AC1: the image's user #{user} is root"] + + defp uid_violation(uid, user) when uid < 1000, + do: ["AC1: the image's user #{user} has UID #{uid}, below 1000"] + + defp uid_violation(_uid, _user), do: [] + + defp passwd_uid(layers, name) do + with %{file: file} <- final_entry(layers, "etc/passwd"), + {:ok, text} <- File.read(file) do + text |> String.split("\n") |> Enum.find_value(&uid_on_line(&1, name)) + else + _ -> nil + end + end + + defp uid_on_line(line, name) do + case String.split(line, ":") do + [^name, _, uid | _] -> String.to_integer(uid) + _ -> nil + end + end + + defp distribution_violations(config) do + if "RELEASE_DISTRIBUTION=none" in (get_in(config, ["config", "Env"]) || []), + do: [], + else: [ + "hardening: RELEASE_DISTRIBUTION is not none, so the release starts a distribution listener" + ] + end + + # AC6, the layers this image adds ------------------------------------------------------------- + + defp added_findings(layer, base_files) do + for entry <- layer.entries, + not whiteout?(entry.path), + file = Path.join(layer.dir, entry.path), + {kind, reason} <- entry_findings(entry, file, base_files), + do: %{ac: "AC6", path: entry.path, kind: kind, reason: reason, blocks: []} + end + + defp entry_findings(entry, file, base_files) do + name = Path.basename(entry.path) + in_bin? = entry.path =~ ~r{(^|/)s?bin/[^/]+$} + + Enum.concat([ + if(in_bin? and name in @package_managers, + do: [{"package_manager", "a package manager"}], + else: [] + ), + if(in_bin? and name in @build_tools, do: [{"build_tool", "a build tool"}], else: []), + build_library_findings(entry), + if(name in @histories, do: [{"history", "shell history"}], else: []), + documentation_findings(entry), + mode_findings(entry), + certificate_findings(entry, file, base_files) + ]) + end + + defp build_library_findings(%{type: :directory, path: path}) do + case Regex.run(~r{(?:^|/)lib/([a-z_]+)-[0-9][^/]*$}, path) do + [_, app] when app in @build_libraries -> + [{"build_library", "#{app}, a build-time library, carried as a release application"}] + + _ -> + [] + end + end + + defp build_library_findings(_), do: [] + + # /usr/share/licenses is licence text, which a redistributed package has to keep. + defp documentation_findings(%{type: :regular, path: path}) do + if not String.starts_with?(path, "usr/share/licenses/") and + (path =~ ~r{(^|/)usr/share/(doc|man|info)/} or Path.basename(path) =~ @documentation_name), + do: [{"documentation", "documentation"}], + else: [] + end + + defp documentation_findings(_), do: [] + + defp mode_findings(%{type: :symlink}), do: [] + + defp mode_findings(entry) do + setid = + if entry.type == :regular and Bitwise.band(entry.mode, 0o6000) != 0, + do: [{"setid", "SUID or SGID bit set (mode #{octal(entry.mode)})"}], + else: [] + + world = + cond do + Bitwise.band(entry.mode, 0o002) == 0 -> [] + entry.type == :directory and Bitwise.band(entry.mode, 0o1000) != 0 -> [] + true -> [{"world_writable", "world-writable (mode #{octal(entry.mode)})"}] + end + + setid ++ world + end + + defp certificate_findings(%{type: :regular} = entry, file, base_files) do + cond do + same_as_base?(file, base_files[entry.path]) -> + [] + + Path.extname(entry.path) in @certificate_extensions -> + [{"certificate", "certificate material"}] + + contains?(file, @certificate) -> + [{"certificate", "certificate material"}] + + true -> + [] + end + end + + defp certificate_findings(_, _, _), do: [] + + # AC6, anywhere in the image ------------------------------------------------------------------ + + defp secret_findings(layer) do + for %{type: :regular} = entry <- layer.entries, + file = Path.join(layer.dir, entry.path), + {kind, reason} <- secret_reasons(entry.path, file), + do: %{ + ac: "AC6", + path: entry.path, + kind: kind, + reason: reason, + blocks: key_blocks(kind, file) + } + end + + defp secret_reasons(path, file) do + name = Path.basename(path) + + cond do + name == ".erlang.cookie" or path =~ ~r{(^|/)releases/COOKIE$} -> + [ + {"cookie", + "an Erlang distribution cookie, a credential everyone who pulls the image would hold"} + ] + + name =~ ~r/^(id_(rsa|dsa|ecdsa|ed25519)|ssh_host_.*_key)$/ or + Path.extname(name) in @key_extensions -> + [{"key_file", "key material by its name"}] + + contains?(file, @private_key) -> + [{"private_key", "a private key"}] + + true -> + [] + end + end + + defp passwd_violations(nil, _), do: ["AC6: the image has no /etc/passwd"] + defp passwd_violations(_, nil), do: [] + + defp passwd_violations(final, base) do + fields = fn e -> {octal(Bitwise.band(e.mode, 0o7777)), e.uid, e.gid} end + + if fields.(final) == fields.(base), + do: [], + else: [ + "AC6: /etc/passwd is mode, owner, group #{inspect(fields.(final))}; the base's is #{inspect(fields.(base))}" + ] + end + + # Allowances ---------------------------------------------------------------------------------- + + # The allowance that allows `finding`, or nil. + defp allowance_for(finding, allowances) do + Enum.find(allowances, fn a -> + finding.kind in List.wrap(a["kinds"]) and finding.kind in @allowable and + path_matches?(a["path"], finding.path) and digest_matches?(a, finding) + end) + end + + defp path_matches?(path, entry) when is_binary(path) do + target = "/" <> entry + # A prefix ending in / covers the directory itself and everything under it. + if String.ends_with?(path, "/"), + do: String.starts_with?(target <> "/", path), + else: target == path + end + + defp path_matches?(_, _), do: false + + # A private key is allowed only when every PEM block in the file is one the allowance names by + # its SHA-256, so another key in the same file, or a changed one, fails again. + defp digest_matches?(a, %{kind: "private_key", blocks: blocks}) do + blocks != [] and Enum.all?(blocks, &(&1 in List.wrap(a["sha256"]))) + end + + defp digest_matches?(_a, _finding), do: true + + @private_key_block ~r/-----BEGIN ((?:[A-Z0-9]+ )*)PRIVATE KEY-----.*?-----END \1PRIVATE KEY-----/s + + # The SHA-256 of each private key PEM block in the file, header to footer inclusive. + defp key_blocks("private_key", file) do + file + |> File.read!() + |> then(&Regex.scan(@private_key_block, &1)) + |> Enum.map(fn [block | _] -> :crypto.hash(:sha256, block) |> Base.encode16(case: :lower) end) + end + + defp key_blocks(_kind, _file), do: [] + + defp allowance_violations(allowances) do + for a <- allowances, + reason <- allowance_problems(a), + do: "AC6: allowance #{inspect(a["path"])}: #{reason}" + end + + defp allowance_problems(a) do + kinds = List.wrap(a["kinds"]) + + Enum.concat([ + if(is_binary(a["path"]) and String.starts_with?(a["path"], "/"), + do: [], + else: ["needs an absolute path"] + ), + if(kinds != [] and Enum.all?(kinds, &(&1 in @allowable)), + do: [], + else: ["kinds must be some of #{Enum.join(@allowable, ", ")}"] + ), + if(is_binary(a["reason"]) and String.trim(a["reason"]) != "", + do: [], + else: ["needs a reason"] + ), + private_key_problems(a, kinds) + ]) + end + + defp private_key_problems(a, kinds) do + cond do + "private_key" not in kinds -> + [] + + String.ends_with?(to_string(a["path"]), "/") -> + ["a private_key allowance must name one file, not a directory"] + + not (a["sha256"] |> List.wrap() |> Enum.all?(&(to_string(&1) =~ ~r/^[0-9a-f]{64}$/))) or + a["sha256"] in [nil, []] -> + ["a private_key allowance must give the SHA-256 of each PEM block it allows"] + + true -> + [] + end + end + + defp inherited(layer) do + for %{type: :regular} = entry <- layer.entries, + what = inherited_kind(entry, Path.join(layer.dir, entry.path)), + what != nil, + do: "/#{entry.path}: #{what}" + end + + defp inherited_kind(entry, file) do + cond do + entry.path =~ ~r{(^|/)usr/share/(doc|man|info)/} -> "documentation" + Path.extname(entry.path) in @certificate_extensions -> "certificate material" + entry.path =~ ~r{^etc/pki/rpm-gpg/} -> "an RPM signing public key" + contains?(file, @certificate) -> "certificate material" + true -> nil + end + end + + # Layers -------------------------------------------------------------------------------------- + + @doc """ + Unpacks `image` and `base` from `docker save` into `work`: the image's config, its base layers + and the layers it adds. Fails unless the base's layers are a prefix of the image's. + """ + @spec unpack(String.t(), String.t(), Path.t()) :: {map(), [layer()], [layer()]} + def unpack(image, base, work) do + File.mkdir_p!(work) + {image_config, image_layers} = save_and_read(image, Path.join(work, "image")) + {_base_config, base_layers} = save_and_read(base, Path.join(work, "base")) + base_ids = Enum.map(base_layers, &elem(&1, 0)) + image_ids = Enum.map(image_layers, &elem(&1, 0)) + + unless Enum.take(image_ids, length(base_ids)) == base_ids do + Mix.raise( + "#{image} is not built on #{base}: the base's layers are not a prefix of the image's" + ) + end + + to_layer = fn {_id, tar, dir} -> read_layer(tar, dir) end + added = Enum.drop(image_layers, length(base_ids)) + {image_config, Enum.map(base_layers, to_layer), Enum.map(added, to_layer)} + end + + defp save_and_read(ref, dir) do + File.mkdir_p!(dir) + archive = Path.join(dir, "image.tar") + {out, status} = System.cmd("docker", ["save", ref, "-o", archive], stderr_to_stdout: true) + if status != 0, do: Mix.raise("docker save #{ref} failed (#{status}): #{out}") + + :ok = :erl_tar.extract(String.to_charlist(archive), cwd: String.to_charlist(dir)) + [manifest] = dir |> Path.join("manifest.json") |> File.read!() |> Jason.decode!() + config = dir |> Path.join(manifest["Config"]) |> File.read!() |> Jason.decode!() + + layers = + manifest["Layers"] + |> Enum.zip(config["rootfs"]["diff_ids"]) + |> Enum.with_index() + |> Enum.map(fn {{tar, id}, i} -> + {id, Path.join(dir, tar), Path.join(dir, "layer-#{i}")} + end) + + {config, layers} + end + + @doc """ + A layer tarball's entries, with its contents written under `dir` for reading. The entries + (modes, owners) come from the tarball itself; the unpacked copy is only read for content, so it + is unpacked with permissions that let a non-root user read it and remove it afterwards. + """ + @spec read_layer(Path.t(), Path.t()) :: layer() + def read_layer(tar, dir) do + {:ok, table} = :erl_tar.table(String.to_charlist(tar), [:verbose]) + File.mkdir_p!(dir) + + {out, status} = + System.cmd( + "tar", + [ + "-x", + "--no-same-owner", + "--no-same-permissions", + "--exclude=dev/*", + "-f", + tar, + "-C", + dir + ], + stderr_to_stdout: true + ) + + if status != 0, do: Mix.raise("unpacking #{tar} failed (#{status}): #{out}") + # Directories a layer ships read-only are made writable for the owner, so the copy can be + # read through and removed afterwards. + {_, 0} = System.cmd("chmod", ["-R", "u+rwX", dir]) + + entries = + for {name, type, _size, _mtime, mode, uid, gid} <- table do + path = name |> to_string() |> String.trim_leading("./") |> String.trim_trailing("/") + %{path: path, type: type, mode: mode, uid: uid, gid: gid} + end + + %{entries: entries, dir: dir} + end + + defp files_by_path(layers) do + for layer <- layers, %{type: :regular} = e <- layer.entries, into: %{} do + {e.path, Path.join(layer.dir, e.path)} + end + end + + defp final_entry(layers, path) do + layers + |> Enum.flat_map(fn layer -> + for e <- layer.entries, e.path == path, do: Map.put(e, :file, Path.join(layer.dir, e.path)) + end) + |> List.last() + end + + defp whiteout?(path), do: path |> Path.basename() |> String.starts_with?(".wh.") + + defp same_as_base?(_file, nil), do: false + + defp same_as_base?(file, base_file) do + digest = digest(file) + digest != nil and digest == digest(base_file) + end + + # nil for anything not unpacked as a regular file (device nodes under dev/ are not unpacked). + defp digest(file) do + if File.regular?(file) do + file + |> File.stream!(@chunk) + |> Enum.reduce(:crypto.hash_init(:sha256), &:crypto.hash_update(&2, &1)) + |> :crypto.hash_final() + end + end + + # True when the file's bytes match `pattern`, read in overlapping chunks so a block that + # straddles a chunk boundary is still found; a file that cannot be read is not a match. + defp contains?(file, pattern) do + case File.open(file, [:read, :binary]) do + {:ok, io} -> + try do + scan(io, pattern, "") + after + File.close(io) + end + + _ -> + false + end + end + + defp scan(io, pattern, carry) do + case IO.binread(io, @chunk) do + data when is_binary(data) -> + window = carry <> data + keep = min(@carry, byte_size(window)) + + Regex.match?(pattern, window) or + scan(io, pattern, binary_part(window, byte_size(window) - keep, keep)) + + _ -> + false + end + end + + defp octal(mode), do: "0" <> Integer.to_string(mode, 8) + + defp read_allowances(path) do + case YamlElixir.read_from_file(path) do + {:ok, data} -> (data || %{}) |> Map.get("allowances", []) |> List.wrap() + {:error, _} -> Mix.raise("cannot read #{path}") + end + end + + defp pinned_base do + env = "ci/headless/bases.env" |> File.read!() |> Mix.Tasks.Trinity.Ironbank.Lint.env_pairs() + "#{env["BASE_REGISTRY"]}/#{env["BASE_IMAGE"]}:#{env["BASE_TAG"]}" + end + + defp report(%{violations: violations, allowed: allowed, inherited: inherited}, image, base) do + Enum.each(inherited, &Mix.shell().info("inherited from the base: #{&1}")) + Enum.each(allowed, &Mix.shell().info("allowed: #{&1}")) + + Mix.shell().info( + "trinity.image.inspect: #{image} on #{base}: #{length(inherited)} inherited, " <> + "#{length(allowed)} allowed, #{length(violations)} violation(s)" + ) + + case violations do + [] -> + Mix.shell().info("trinity.image.inspect: OK") + + found -> + Enum.each(found, &Mix.shell().error("FAIL #{&1}")) + Mix.raise("trinity.image.inspect: #{length(found)} violation(s) in #{image}") + end + end +end diff --git a/lib/mix/tasks/trinity.image.stig.ex b/lib/mix/tasks/trinity.image.stig.ex new file mode 100644 index 0000000..0bcf026 --- /dev/null +++ b/lib/mix/tasks/trinity.image.stig.ex @@ -0,0 +1,405 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Mix.Tasks.Trinity.Image.Stig do + @shortdoc "Derives the headless image's STIG applicability statement (slice 130, AC5)" + + @moduledoc """ + Turns OpenSCAP's evaluation of the headless image against the DISA STIG profile for RHEL 9 + (`scripts/stig_scan.sh`) into the image's STIG applicability statement, and fails when a rule has + no disposition (slice 130, AC5; `docs/regulated/headless-image.md`). + + mix trinity.image.stig --results stig-results.xml [--dispositions PATH] [--out PATH] + [--image REF] [--digest IMAGE_ID] + + The population is every rule the profile selects, read from the results, never a list typed by + hand. Each gets one disposition: + + * **met**: OpenSCAP's result is `pass`, or a row in `ci/headless/stig_dispositions.yaml` says + how the image meets it otherwise; + * **not applicable**: OpenSCAP's result is `notapplicable`, and the reason is the rule's own + applicability condition (its platforms, from the guide), or a row gives one; + * **the deployment's**: a row says what the deployment does and why the image cannot. + + A rule OpenSCAP reports any other way (`fail`, `notchecked`, `error`, ...) with no row fails the + check; so does a row for a rule that no longer needs one, so the file cannot keep a disposition + for something that changed. This is a statement about one image; it is not a STIG compliance + determination for any host, and the statement says so. + """ + + use Boundary, classify_to: Trinity + use Mix.Task + + @compile {:no_warn_undefined, :xmerl_sax_parser} + + @dispositions "ci/headless/stig_dispositions.yaml" + @values %{ + "met" => "met", + "not_applicable" => "not applicable", + "deployment" => "the deployment's" + } + + @typedoc "One selected rule, as the results describe it." + @type rule :: %{ + id: String.t(), + title: String.t(), + stig: [String.t()], + platforms: [String.t()], + severity: String.t(), + result: String.t() + } + + @typedoc "What the results file says about the scan itself." + @type scan :: %{ + rules: [rule()], + profile: String.t(), + version: String.t(), + scanner: String.t(), + time: String.t() + } + + @typedoc "A rule with its disposition and reason." + @type disposed :: {rule(), String.t(), String.t()} + + @impl Mix.Task + def run(argv) do + {opts, _, _} = + OptionParser.parse(argv, + strict: [ + results: :string, + dispositions: :string, + out: :string, + image: :string, + digest: :string + ] + ) + + path = opts[:results] || Mix.raise("usage: mix trinity.image.stig --results stig-results.xml") + Mix.ensure_application!(:xmerl) + scan = read_results(path) + rows = read_rows(opts[:dispositions] || @dispositions) + {disposed, violations} = dispose(scan.rules, rows) + + if out = opts[:out] do + File.write!( + out, + statement(scan, disposed, violations, Keyword.take(opts, [:image, :digest])) + ) + + Mix.shell().info("trinity.image.stig: wrote #{out}") + end + + counts = + disposed + |> Enum.frequencies_by(&elem(&1, 1)) + |> Enum.sort() + |> Enum.map_join(", ", fn {d, n} -> "#{n} #{d}" end) + + Mix.shell().info( + "trinity.image.stig: #{length(scan.rules)} rule(s) in #{scan.profile}: #{counts}" + ) + + case violations do + [] -> + Mix.shell().info("trinity.image.stig: OK, every rule has a disposition") + + found -> + Enum.each(found, &Mix.shell().error("FAIL #{&1}")) + Mix.raise("trinity.image.stig: #{length(found)} violation(s)") + end + end + + @doc """ + Gives every rule its disposition: automatic for `pass` and `notapplicable`, from `rows` + otherwise. Returns the disposed rules and the violations: rules with none, rows for rules that + need none or are not in the profile, and malformed rows. + """ + @spec dispose([rule()], [map()]) :: {[disposed()], [String.t()]} + def dispose(rules, rows) do + {by_rule, row_problems} = index_rows(rows) + results = Enum.map(rules, &dispose_rule(&1, by_rule[&1.id])) + unknown = Map.keys(by_rule) -- Enum.map(rules, & &1.id) + + violations = + Enum.concat([ + for({:error, v} <- results, do: v), + for({:stale, rule} <- results, do: stale_message(rule)), + for(id <- Enum.sort(unknown), do: unknown_message(id)), + row_problems + ]) + + {for({:ok, d} <- results, do: d), violations} + end + + defp dispose_rule(%{result: "pass"} = rule, nil), do: {:ok, {rule, "met", "OpenSCAP: pass"}} + + defp dispose_rule(%{result: "notapplicable"} = rule, nil), + do: {:ok, {rule, "not applicable", applicability_reason(rule)}} + + defp dispose_rule(rule, nil), + do: + {:error, "AC5: #{label(rule)} is #{rule.result} and has no disposition in #{@dispositions}"} + + defp dispose_rule(%{result: result} = rule, _row) when result in ["pass", "notapplicable"], + do: {:stale, rule} + + defp dispose_rule(rule, row), + do: {:ok, {rule, @values[row["disposition"]], String.trim(row["reason"])}} + + defp stale_message(rule), + do: "AC5: #{label(rule)} is #{rule.result} now; remove its row from #{@dispositions}" + + defp unknown_message(id), + do: "AC5: #{@dispositions} disposes of #{id}, which the profile does not select" + + defp index_rows(rows), do: Enum.reduce(rows, {%{}, []}, &index_row/2) + + defp index_row(row, {acc, problems}) do + case row_problems(row) do + [] -> + dupes = + for id <- row["rules"], + Map.has_key?(acc, id), + do: "AC5: #{id} has more than one disposition" + + {Enum.reduce(row["rules"], acc, &Map.put(&2, &1, row)), problems ++ dupes} + + found -> + {acc, + problems ++ + Enum.map(found, &"AC5: malformed disposition row #{inspect(row["rules"])}: #{&1}")} + end + end + + defp row_problems(row) when is_map(row) do + Enum.concat([ + if(is_list(row["rules"]) and row["rules"] != [] and Enum.all?(row["rules"], &is_binary/1), + do: [], + else: ["needs a list of rule ids"] + ), + if(Map.has_key?(@values, row["disposition"]), + do: [], + else: ["disposition must be met, not_applicable or deployment"] + ), + if(is_binary(row["reason"]) and String.trim(row["reason"]) != "", + do: [], + else: ["needs a reason"] + ) + ]) + end + + defp row_problems(_), do: ["is not a mapping"] + + @doc "The reason OpenSCAP's own applicability check gives for a rule it found not applicable." + @spec applicability_reason(rule()) :: String.t() + def applicability_reason(%{platforms: []}), do: "OpenSCAP: notapplicable" + + def applicability_reason(%{platforms: platforms}) do + verb = if length(platforms) == 1, do: "holds", else: "hold" + + "OpenSCAP: notapplicable; the rule applies only where " <> + Enum.map_join(platforms, " and ", &String.trim_leading(&1, "#")) <> " " <> verb + end + + defp label(rule) do + case rule.stig do + [] -> rule.id + ids -> "#{Enum.join(ids, ", ")} (#{rule.id})" + end + end + + # Results ------------------------------------------------------------------------------------- + + @doc """ + The selected rules of an XCCDF results file, with their titles, DISA STIG identifiers, + applicability platforms (their own and their groups') and results, read as a stream. + """ + @spec read_results(Path.t()) :: scan() + def read_results(path) do + {:ok, state, _rest} = + :xmerl_sax_parser.file(String.to_charlist(path), + event_fun: &event/3, + event_state: %{ + stack: [], + holders: [], + rules: %{}, + text: nil, + result_id: nil, + results: [], + test: %{}, + in_test: false + } + ) + + rules = + for {id, result} <- Enum.reverse(state.results), result != "notselected" do + info = Map.get(state.rules, id, %{title: "", stig: [], platforms: [], severity: ""}) + Map.merge(info, %{id: id, result: result}) + end + + Map.merge(%{rules: rules, profile: "", version: "", scanner: "", time: ""}, state.test) + end + + defp event({:startElement, _, name, _, attrs}, _loc, state), + do: start(to_string(name), attributes(attrs), state) + + defp event({:endElement, _, name, _}, _loc, state), do: finish(to_string(name), state) + + defp event({:characters, chars}, _loc, %{text: text} = state) when is_binary(text), + do: %{state | text: text <> to_string(chars)} + + defp event(_event, _loc, state), do: state + + defp attributes(attrs), + do: Map.new(attrs, fn {_, _, name, value} -> {to_string(name), to_string(value)} end) + + defp start("TestResult", attrs, state) do + test = + Map.merge(state.test, %{version: attrs["version"] || "", time: attrs["end-time"] || ""}) + + push(%{state | in_test: true, test: test}, "TestResult") + end + + defp start("profile", attrs, %{in_test: true} = state), + do: push(put_in(state.test[:profile], attrs["idref"] || ""), "profile") + + defp start("fact", %{"name" => "urn:xccdf:fact:scanner:version"}, %{in_test: true} = state), + do: push(%{state | text: ""}, "fact-version") + + defp start("rule-result", attrs, %{in_test: true} = state), + do: push(%{state | result_id: attrs["idref"]}, "rule-result") + + defp start("result", _attrs, %{in_test: true} = state), do: push(%{state | text: ""}, "result") + + defp start(kind, attrs, %{in_test: false} = state) when kind in ["Group", "Rule"] do + holder = %{ + kind: kind, + id: attrs["id"], + platforms: [], + title: "", + stig: [], + severity: attrs["severity"] || "" + } + + push(%{state | holders: [holder | state.holders]}, kind) + end + + defp start( + "platform", + %{"idref" => idref}, + %{in_test: false, holders: [holder | rest]} = state + ), + do: + push( + %{state | holders: [%{holder | platforms: holder.platforms ++ [idref]} | rest]}, + "platform" + ) + + defp start( + name, + _attrs, + %{in_test: false, holders: [%{kind: "Rule"} | _], stack: [parent | _]} = state + ) + when name in ["title", "reference"] and parent == "Rule", + do: push(%{state | text: ""}, name) + + defp start(name, _attrs, state), do: push(state, name) + + defp push(state, name), do: %{state | stack: [name | state.stack]} + + defp finish(_name, %{stack: [top | rest]} = state), do: close(top, %{state | stack: rest}) + + defp close("TestResult", state), do: %{state | in_test: false} + + defp close("fact-version", state), + do: %{state | text: nil, test: Map.put(state.test, :scanner, state.text)} + + defp close("result", state), + do: %{ + state + | text: nil, + results: [{state.result_id, String.trim(state.text)} | state.results] + } + + defp close("title", %{holders: [rule | rest], text: text} = state) when is_binary(text), + do: %{state | text: nil, holders: [%{rule | title: String.trim(text)} | rest]} + + defp close("reference", %{holders: [rule | rest], text: text} = state) when is_binary(text) do + id = String.trim(text) + stig = if id =~ ~r/^RHEL-09-\d+$/, do: rule.stig ++ [id], else: rule.stig + %{state | text: nil, holders: [%{rule | stig: stig} | rest]} + end + + defp close("Rule", %{holders: [rule | groups]} = state) do + inherited = groups |> Enum.reverse() |> Enum.flat_map(& &1.platforms) + platforms = Enum.uniq(inherited ++ rule.platforms) + info = %{title: rule.title, stig: rule.stig, platforms: platforms, severity: rule.severity} + %{state | holders: groups, rules: Map.put(state.rules, rule.id, info)} + end + + defp close("Group", %{holders: [_ | groups]} = state), do: %{state | holders: groups} + defp close(_name, state), do: state + + # The statement ------------------------------------------------------------------------------- + + @doc "The applicability statement as Markdown, one row per selected rule." + @spec statement(scan(), [disposed()], [String.t()], keyword()) :: String.t() + def statement(scan, disposed, violations, opts \\ []) do + counts = disposed |> Enum.frequencies_by(&elem(&1, 1)) + + results = + scan.rules + |> Enum.frequencies_by(& &1.result) + |> Enum.sort() + |> Enum.map_join(", ", fn {r, n} -> "#{n} #{r}" end) + + rows = + disposed + |> Enum.sort_by(fn {rule, _, _} -> {List.first(rule.stig) || "~", rule.id} end) + |> Enum.map(fn {rule, disposition, reason} -> + "| #{Enum.join(rule.stig, ", ")} | #{cell(rule.title)} | #{rule.severity} | #{rule.result} | #{disposition} | #{cell(reason)} |" + end) + + """ + + + # STIG applicability statement: the Trinity headless image + + **Generated, not written.** `mix trinity.image.stig` derived this file from OpenSCAP's evaluation of + one built image; edit `ci/headless/stig_dispositions.yaml` and regenerate, never this file. + + **This is a statement about an image, not a compliance determination.** It says, for each rule of + the DISA STIG profile for RHEL 9 that the SCAP Security Guide selects, whether this container image + meets it, why it does not apply to a container image, or why it belongs to the deployment that runs + the image. It is not a STIG compliance determination for any host, and it is not an authorization. + + | | | + |---|---| + | Image | #{opts[:image] || "(not given)"} | + | Image ID | #{opts[:digest] || "(not given)"} | + | Profile | `#{scan.profile}` | + | SCAP Security Guide | #{scan.version} (`ssg-rhel9-ds.xml`) | + | Scanner | OpenSCAP #{scan.scanner} | + | Evaluated | #{scan.time} | + | Deriving commands | `scripts/stig_scan.sh IMAGE OUT`, then `mix trinity.image.stig --results OUT/stig-results.xml --out PATH --image IMAGE --digest IMAGE_ID` | + + #{length(scan.rules)} rules selected. OpenSCAP: #{results}. Dispositions: #{counts["met"] || 0} met, + #{counts["not applicable"] || 0} not applicable, #{counts["the deployment's"] || 0} the deployment's, + #{length(violations)} without one. + + #{if violations == [], do: "Every rule has a disposition.", else: "**Rules without a disposition, or rows that need attention:**\n\n" <> Enum.map_join(violations, "\n", &"* #{&1}")} + + | STIG ID | Rule | Severity | OpenSCAP | Disposition | Reason | + |---|---|---|---|---|---| + #{Enum.join(rows, "\n")} + """ + end + + defp cell(text), do: text |> String.replace("|", "\\|") |> String.replace(~r/\s+/, " ") + + defp read_rows(path) do + case YamlElixir.read_from_file(path) do + {:ok, data} -> (data || %{}) |> Map.get("dispositions", []) |> List.wrap() + {:error, _} -> Mix.raise("cannot read #{path}") + end + end +end diff --git a/lib/mix/tasks/trinity.ironbank.lint.ex b/lib/mix/tasks/trinity.ironbank.lint.ex new file mode 100644 index 0000000..8ab0470 --- /dev/null +++ b/lib/mix/tasks/trinity.ironbank.lint.ex @@ -0,0 +1,698 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Mix.Tasks.Trinity.Ironbank.Lint do + @shortdoc "Checks the Iron Bank submission tree and the published image's base pins" + + @moduledoc """ + Checks `ci/ironbank/`, the Iron Bank submission tree, against the rules that can be checked + without Iron Bank's pipeline, and `ci/headless/bases.env`, the published image's base pins + (slice 130, AC2, AC2a, AC6 and AC6a; `docs/regulated/headless-image.md`). + + mix trinity.ironbank.lint the tree as it stands; holds are allowed + mix trinity.ironbank.lint --submission also refuses every open hold (AC7's review) + + Every rule is checked on the files, never on the image: the image's own assertions are + `mix trinity.image.inspect`. Each violation names the criterion it belongs to. + + * **AC2.** Every `FROM` takes its registry from `${BASE_REGISTRY}` or names an earlier stage; + none names a registry host. Resolved against `ci/headless/bases.env`, every `FROM` of the + published build carries an `@sha256:` digest. The manifest supplies every other variable a + `FROM` uses and never `BASE_REGISTRY`, which is the pipeline's. + * **AC2a.** No `ADD`; no `curl`, `wget` or URL anywhere in the Dockerfile or the submission's + scripts, comments included, because Iron Bank rejects them "even if the code path never + runs". Every file the Dockerfile copies from the context is a declared resource, a held one + (`ci/headless/submission_holds.yaml`), or a file of the submission; every declared resource + is used; each resource has a SHA-256 or SHA-512 digest. + * **AC6.** No world-writable or SUID/SGID `chmod`, no `--nogpgcheck` or `gpgcheck=0`, no + `rpm --import` from anywhere but a `gpg/` folder, nothing that writes `/etc/passwd`, and the + final `USER` is a numeric UID of 1000 or more. + * **AC6a.** The submission holds only the entries Iron Bank's repository-structure page + names, the four it requires among them; no `conf/` (`config/` is the spelling, slice 130 + NOTES D-130-1); no `LABEL` in the Dockerfile; the manifest has the fields and labels Iron + Bank's schema requires; its version and its OTP and Elixir resources agree with `mix.exs` + and `.tool-versions`. + """ + + use Boundary, classify_to: Trinity + use Mix.Task + + @submission "ci/ironbank" + @bases "ci/headless/bases.env" + @holds "ci/headless/submission_holds.yaml" + + # Iron Bank's repository-structure page (docs-ironbank.dso.mil/hardening/repository-structure/, + # read 2026-10-08), plus the `gpg` folder its Dockerfile requirements name for imported keys. + @allowed_entries ~w(Dockerfile Dockerfile.arm64 hardening_manifest.yaml testing_manifest.yaml + LICENSE README.md renovate.json trufflehog-config.yaml config documentation + scripts gpg) + @required_entries ~w(Dockerfile hardening_manifest.yaml LICENSE README.md) + + # Iron Bank's schema, schema/hardening_manifest.schema.json, `labels`. + @required_labels ~w(org.opencontainers.image.title org.opencontainers.image.description + org.opencontainers.image.licenses org.opencontainers.image.vendor + org.opencontainers.image.version) + @allowed_labels @required_labels ++ + ~w(org.opencontainers.image.url mil.dso.ironbank.image.keywords + mil.dso.ironbank.image.type mil.dso.ironbank.product.name) + + @from_variables ~r/^\$\{BASE_REGISTRY\}\/\$\{([A-Z0-9_]+)\}:\$\{([A-Z0-9_]+)\}$/ + @network_words ~r/\b(curl|wget)\b/i + @url ~r{\b(?:https?|ftp)://}i + @digest_lengths %{"sha256" => 64, "sha512" => 128} + @pending "PENDING" + + @typedoc "Everything the rules read, gathered once so a test can plant a violation in it." + @type tree :: %{ + dockerfile: String.t(), + manifest: map(), + scripts: [{Path.t(), String.t()}], + entries: [String.t()], + symlinks: [String.t()], + bases_env: String.t(), + holds: map(), + tool_versions: String.t(), + version: String.t() + } + + @typedoc "One Dockerfile instruction: its first line number, keyword (upper case) and text." + @type instruction :: {pos_integer(), String.t(), String.t()} + + @impl Mix.Task + def run(argv) do + {opts, _, _} = OptionParser.parse(argv, strict: [submission: :boolean]) + tree = read_tree(".") + + case violations(tree, submission: Keyword.get(opts, :submission, false)) do + [] -> + Mix.shell().info("trinity.ironbank.lint: OK (#{@submission}, #{@bases})") + + found -> + Enum.each(found, &Mix.shell().error("FAIL #{&1}")) + Mix.raise("trinity.ironbank.lint: #{length(found)} violation(s)") + end + end + + @doc "Reads the submission tree, the base pins, the holds and the versions they must agree with." + @spec read_tree(Path.t()) :: tree() + def read_tree(root) do + sub = Path.join(root, @submission) + entries = sub |> File.ls!() |> Enum.sort() + + %{ + dockerfile: File.read!(Path.join(sub, "Dockerfile")), + manifest: yaml!(Path.join(sub, "hardening_manifest.yaml")), + scripts: read_scripts(Path.join(sub, "scripts")), + entries: entries, + symlinks: Enum.filter(entries, &symlink?(Path.join(sub, &1))), + bases_env: File.read!(Path.join(root, @bases)), + holds: yaml!(Path.join(root, @holds)), + tool_versions: File.read!(Path.join(root, ".tool-versions")), + version: Mix.Project.config()[:version] + } + end + + @doc """ + Every violation in `tree`, as one line each naming its criterion. Empty when the tree passes. + With `submission: true`, every open hold is a violation too. + """ + @spec violations(tree(), keyword()) :: [String.t()] + def violations(tree, opts \\ []) do + instructions = instructions(tree.dockerfile) + + Enum.concat([ + from_violations(instructions, tree), + network_violations(instructions, tree), + resource_violations(instructions, tree), + hardening_violations(instructions), + layout_violations(instructions, tree), + manifest_violations(tree), + hold_violations(tree, Keyword.get(opts, :submission, false)) + ]) + end + + @doc """ + The Dockerfile's instructions, continuation lines joined and comment lines dropped, as + `{first_line, KEYWORD, rest}`. + """ + @spec instructions(String.t()) :: [instruction()] + def instructions(text) do + text + |> String.split("\n") + |> Enum.with_index(1) + |> Enum.reduce({[], nil}, &collect_line/2) + |> then(fn {acc, open} -> if open, do: [open | acc], else: acc end) + |> Enum.reverse() + |> Enum.map(fn {n, body} -> + [keyword | rest] = String.split(String.trim(body), ~r/\s+/, parts: 2) + {n, String.upcase(keyword), Enum.join(rest)} + end) + end + + defp collect_line({line, n}, {acc, open}) do + trimmed = String.trim(line) + + if trimmed == "" or String.starts_with?(trimmed, "#"), + do: {acc, open}, + else: continue_or_close(String.trim_trailing(line), n, acc, open) + end + + defp continue_or_close(line, n, acc, open) do + {start, body} = open || {n, ""} + + if String.ends_with?(line, "\\") do + {acc, {start, body <> " " <> String.trim_trailing(line, "\\")}} + else + {[{start, body <> " " <> line} | acc], nil} + end + end + + @doc """ + The `FROM` instructions as `{line, reference, stage_name_or_nil}`, with `--platform` dropped. + """ + @spec froms([instruction()]) :: [{pos_integer(), String.t(), String.t() | nil}] + def froms(instructions) do + for {n, "FROM", rest} <- instructions do + words = rest |> String.split() |> Enum.reject(&String.starts_with?(&1, "--")) + + case words do + [ref, as, name] when as in ["AS", "as", "As"] -> {n, ref, name} + [ref | _] -> {n, ref, nil} + end + end + end + + @doc "`KEY=VALUE` lines of an env file, comments and blank lines skipped." + @spec env_pairs(String.t()) :: %{String.t() => String.t()} + def env_pairs(text) do + for line <- String.split(text, "\n"), + line = String.trim(line), + line != "" and not String.starts_with?(line, "#"), + [key, value] <- [String.split(line, "=", parts: 2)], + into: %{}, + do: {key, value} + end + + # AC2 ----------------------------------------------------------------------------------------- + + defp from_violations(instructions, tree) do + bases = env_pairs(tree.bases_env) + args = Map.get(tree.manifest, "args", %{}) + + instructions + |> froms() + |> Enum.reduce({[], MapSet.new()}, fn {n, ref, name}, {found, stages} -> + stages_after = if name, do: MapSet.put(stages, name), else: stages + + if MapSet.member?(stages, ref) do + {found, stages_after} + else + {found ++ base_from_violations(n, ref, bases, args), stages_after} + end + end) + |> elem(0) + end + + defp base_from_violations(n, ref, bases, args) do + case Regex.run(@from_variables, ref) do + [_, image_var, tag_var] -> + manifest_args_violations(n, [image_var, tag_var], args) ++ + published_pin_violations(n, ref, bases) + + nil -> + [ + "AC2: Dockerfile line #{n}: FROM #{ref} does not take its registry from " <> + "${BASE_REGISTRY}/${IMAGE}:${TAG}; a registry named in the file is prohibited " <> + "in an Iron Bank submission" + ] + end + end + + defp manifest_args_violations(n, vars, args) do + for var <- vars, not Map.has_key?(args, var) do + "AC2: Dockerfile line #{n}: ${#{var}} is not set by hardening_manifest.yaml `args`, " <> + "so Iron Bank's build would fall back to the Dockerfile default" + end + end + + defp published_pin_violations(n, ref, bases) do + missing = for var <- ~w(BASE_REGISTRY) ++ vars_in(ref), not Map.has_key?(bases, var), do: var + + if missing != [] do + ["AC2: Dockerfile line #{n}: #{@bases} does not set #{Enum.join(missing, ", ")}"] + else + resolved = Regex.replace(~r/\$\{([A-Z0-9_]+)\}/, ref, fn _, var -> bases[var] end) + + if resolved =~ ~r/@sha256:[0-9a-f]{64}$/ do + [] + else + [ + "AC2: Dockerfile line #{n}: the published build resolves FROM to #{resolved}, " <> + "which is not pinned by digest (#{@bases})" + ] + end + end + end + + defp vars_in(ref), do: for([_, var] <- Regex.scan(~r/\$\{([A-Z0-9_]+)\}/, ref), do: var) + + # AC2a ---------------------------------------------------------------------------------------- + + defp network_violations(instructions, tree) do + adds = + for {n, "ADD", _} <- instructions, + do: "AC2a: Dockerfile line #{n}: ADD is prohibited; use COPY from the build context" + + texts = [{"Dockerfile", tree.dockerfile} | tree.scripts] + + words = + for {path, text} <- texts, + {line, n} <- text |> String.split("\n") |> Enum.with_index(1), + reason <- network_reasons(line), + do: "AC2a: #{path} line #{n}: #{reason}" + + adds ++ words + end + + defp network_reasons(line) do + words = + for [_, word] <- Regex.scan(@network_words, line), + do: "#{word} reaches the network; Iron Bank rejects it even in a branch never taken" + + urls = if line =~ @url, do: ["a URL; the build may not reference the internet"], else: [] + words ++ urls + end + + defp resource_violations(instructions, tree) do + declared = tree.manifest |> Map.get("resources", []) |> Enum.map(& &1["filename"]) + held = tree.holds |> Map.get("resources", []) |> Enum.map(& &1["filename"]) + copied = copy_sources(instructions) + copied_names = Enum.map(copied, &elem(&1, 1)) + + unknown = + for {n, source} <- copied, + source not in declared and source not in held and not submission_file?(source, tree) do + "AC2a: Dockerfile line #{n}: COPY #{source} is not a resource declared in " <> + "hardening_manifest.yaml, a held one, or a file of the submission" + end + + unused = + for name <- declared, name not in copied_names do + "AC2a: resource #{name} is declared in hardening_manifest.yaml but never copied" + end + + unknown ++ unused ++ digest_violations(tree.manifest) + end + + defp submission_file?(source, tree) do + case String.split(source, "/", parts: 2) do + [dir, _] when dir in ["scripts", "config", "gpg"] -> dir in tree.entries + _ -> false + end + end + + @doc "The build-context sources of every `COPY` without `--from`, as `{line, source}`." + @spec copy_sources([instruction()]) :: [{pos_integer(), String.t()}] + def copy_sources(instructions) do + for {n, "COPY", rest} <- instructions, + not String.contains?(rest, "--from="), + words = rest |> String.split() |> Enum.reject(&String.starts_with?(&1, "--")), + source <- Enum.drop(words, -1), + do: {n, String.trim_trailing(source, "/")} + end + + defp digest_violations(manifest) do + for resource <- Map.get(manifest, "resources", []), + reason <- resource_reasons(resource), + do: "AC2a: resource #{resource["filename"] || inspect(resource)}: #{reason}" + end + + defp resource_reasons(resource) do + cond do + not url?(resource["url"]) -> ["needs an http(s) url"] + not bare_filename?(resource["filename"]) -> ["needs a filename with no directory"] + true -> validation_reasons(resource["validation"] || %{}) + end + end + + defp url?(url), do: is_binary(url) and url =~ ~r{^https?://.+} + defp bare_filename?(name), do: is_binary(name) and not String.contains?(name, "/") + + defp validation_reasons(%{"type" => type} = validation) + when is_map_key(@digest_lengths, type) do + value = to_string(validation["value"]) + length = @digest_lengths[type] + + if value =~ ~r/^[0-9a-f]+$/ and byte_size(value) == length, + do: [], + else: ["its #{type} value is not #{length} lowercase hex digits"] + end + + defp validation_reasons(_), do: ["needs a sha256 or sha512 validation"] + + # AC6 ----------------------------------------------------------------------------------------- + + defp hardening_violations(instructions) do + runs = for {n, "RUN", rest} <- instructions, do: {n, rest} + + run_violations = + for {n, text} <- runs, + reason <- run_reasons(text), + do: "AC6: Dockerfile line #{n}: #{reason}" + + run_violations ++ final_user_violations(instructions) + end + + defp run_reasons(text) do + chmod_reasons(text) ++ + gpg_reasons(text) ++ + if(String.contains?(text, "/etc/passwd"), + do: ["writes or changes /etc/passwd; add the user with useradd and leave its mode alone"], + else: [] + ) + end + + defp chmod_reasons(text) do + for args <- command_args(text, "chmod"), + mode <- args, + not String.starts_with?(mode, "-"), + not String.contains?(mode, "/"), + reason <- mode_reasons(mode), + do: reason + end + + @doc false + @spec mode_reasons(String.t()) :: [String.t()] + def mode_reasons(mode) do + cond do + mode =~ ~r/^[0-7]{3,4}$/ -> + digits = + mode + |> String.pad_leading(4, "0") + |> String.graphemes() + |> Enum.map(&String.to_integer/1) + + numeric_mode_reasons(mode, digits) + + mode =~ ~r/^[ugoa]*[+=][rwxXst]+(,[ugoa]*[+=-][rwxXst]+)*$/ -> + symbolic_mode_reasons(mode) + + true -> + [] + end + end + + defp numeric_mode_reasons(mode, [special, _user, _group, other]) do + world = + if Bitwise.band(other, 2) != 0, do: ["chmod #{mode} makes a file world-writable"], else: [] + + setid = + if Bitwise.band(special, 6) != 0, do: ["chmod #{mode} sets a SUID or SGID bit"], else: [] + + world ++ setid + end + + defp symbolic_mode_reasons(mode) do + for clause <- String.split(mode, ","), + [_, who, op, perms] <- [Regex.run(~r/^([ugoa]*)([+=-])(.*)$/, clause)], + op in ["+", "="], + reason <- symbolic_reason(mode, who, perms), + do: reason + end + + defp symbolic_reason(mode, who, perms) do + world = + if String.contains?(perms, "w") and (who == "" or who =~ ~r/[oa]/), + do: ["chmod #{mode} makes a file world-writable"], + else: [] + + setid = + if String.contains?(perms, "s"), do: ["chmod #{mode} sets a SUID or SGID bit"], else: [] + + world ++ setid + end + + defp gpg_reasons(text) do + nogpg = + if text =~ ~r/--nogpgcheck|gpgcheck\s*=\s*(0|false|no)\b/i, + do: ["disables package signature checks; import the key from a gpg/ folder instead"], + else: [] + + imports = + for args <- command_args(text, "rpm"), + "--import" in args, + path <- Enum.drop_while(args, &(&1 != "--import")) |> Enum.drop(1) |> Enum.take(1), + not (path =~ ~r{(^|/)gpg/}), + do: "rpm --import #{path} is not from a gpg/ folder" + + nogpg ++ imports + end + + # The arguments of every invocation of `command` in a shell text, split at && || ; and |. + defp command_args(text, command) do + for segment <- String.split(text, ~r/&&|\|\||;|\|/), + [cmd | args] <- [String.split(segment)], + Path.basename(cmd) == command, + do: args + end + + defp final_user_violations(instructions) do + final_stage = + instructions + |> Enum.reverse() + |> Enum.take_while(fn {_, keyword, _} -> keyword != "FROM" end) + + case for({n, "USER", user} <- final_stage, do: {n, user}) do + [] -> + ["AC6: the final stage sets no USER, so the image runs as root"] + + [{n, user} | _] -> + uid = user |> String.split(":") |> hd() + + if uid =~ ~r/^\d+$/ and String.to_integer(uid) >= 1000, + do: [], + else: [ + "AC6: Dockerfile line #{n}: the final USER #{user} is not a numeric UID of 1000 or more" + ] + end + end + + # AC6a ---------------------------------------------------------------------------------------- + + defp layout_violations(instructions, tree) do + missing = + for e <- @required_entries, + e not in tree.entries, + do: "AC6a: #{@submission}/#{e} is required" + + stray = + for e <- tree.entries, e not in @allowed_entries do + hint = if e == "conf", do: " (the configuration directory is config/)", else: "" + "AC6a: #{@submission}/#{e} is not part of Iron Bank's repository structure#{hint}" + end + + links = + for e <- tree.symlinks, do: "AC6a: #{@submission}/#{e} is a symlink; their lint refuses one" + + labels = + for {n, "LABEL", _} <- instructions, + do: "AC6a: Dockerfile line #{n}: LABEL belongs in hardening_manifest.yaml" + + missing ++ stray ++ links ++ labels + end + + defp manifest_violations(tree) do + m = tree.manifest + + Enum.concat([ + if(m["apiVersion"] == "v1", do: [], else: ["AC6a: manifest apiVersion must be v1"]), + name_violations(m["name"]), + tag_violations(m["tags"], tree.version), + args_violations(m["args"]), + label_violations(m["labels"], tree.version), + maintainer_violations(m["maintainers"]), + toolchain_violations(m, tree.tool_versions) + ]) + end + + defp name_violations(name) when is_binary(name) do + if name =~ ~r{^[a-z0-9]+(?:[._-][a-z0-9]+)*(?:/[a-z0-9]+(?:[._-][a-z0-9]+)*)*$} and + not String.starts_with?(name, "ironbank/"), + do: [], + else: [ + "AC6a: manifest name #{inspect(name)} is not a registry path without its domain or ironbank/" + ] + end + + defp name_violations(_), do: ["AC6a: manifest name is missing"] + + defp tag_violations([first | _] = tags, version) when length(tags) <= 10 do + cond do + first == "latest" -> + ["AC6a: the first manifest tag may not be latest"] + + first != version -> + ["AC6a: the first manifest tag #{first} is not mix.exs's version #{version}"] + + true -> + [] + end + end + + defp tag_violations(_, _), do: ["AC6a: manifest tags must be a list of one to ten"] + + defp args_violations(args) when is_map(args) do + required = + for a <- ~w(BASE_IMAGE BASE_TAG), + not Map.has_key?(args, a), + do: "AC6a: manifest args lack #{a}" + + registry = + if Map.has_key?(args, "BASE_REGISTRY"), + do: ["AC6a: manifest args may not set BASE_REGISTRY; the pipeline does"], + else: [] + + required ++ registry + end + + defp args_violations(_), do: ["AC6a: manifest args are missing"] + + defp label_violations(labels, version) when is_map(labels) do + missing = + for l <- @required_labels, + not Map.has_key?(labels, l), + do: "AC6a: manifest label #{l} is required" + + unknown = + for {l, _} <- labels, + l not in @allowed_labels, + do: "AC6a: manifest label #{l} is not in Iron Bank's schema" + + type = + if labels["mil.dso.ironbank.image.type"] in [nil, "opensource", "commercial"], + do: [], + else: ["AC6a: mil.dso.ironbank.image.type must be opensource or commercial"] + + stale = + if labels["org.opencontainers.image.version"] == version, + do: [], + else: ["AC6a: label org.opencontainers.image.version is not mix.exs's version #{version}"] + + missing ++ unknown ++ type ++ stale + end + + defp label_violations(_, _), do: ["AC6a: manifest labels are missing"] + + defp maintainer_violations([_ | _] = maintainers) do + for m <- maintainers, + key <- ~w(name username), + not (is_binary(m[key]) and m[key] != ""), + do: "AC6a: a manifest maintainer lacks #{key}" + end + + defp maintainer_violations(_), do: ["AC6a: the manifest names no maintainer"] + + defp toolchain_violations(manifest, tool_versions) do + files = manifest |> Map.get("resources", []) |> Enum.map(& &1["filename"]) + tools = env_pairs(String.replace(tool_versions, " ", "=")) + otp = tools["erlang"] + elixir = tools["elixir"] |> to_string() |> String.replace(~r/-otp-\d+$/, "") + major = otp |> to_string() |> String.split(".") |> hd() + + for {expected, what} <- [ + {"otp_src_#{otp}.tar.gz", "OTP #{otp}"}, + {"elixir-#{elixir}-otp-#{major}.zip", "Elixir #{elixir}"} + ], + expected not in files, + do: "AC6a: no resource #{expected}, the #{what} that .tool-versions names" + end + + # Holds --------------------------------------------------------------------------------------- + + defp hold_violations(tree, submission?) do + resources = Map.get(tree.holds, "resources") || [] + fields = Map.get(tree.holds, "fields") || [] + + Enum.concat([ + malformed_holds(resources ++ fields), + stale_resource_holds(resources, tree), + field_hold_violations(fields, tree.manifest), + if(submission?, do: open_holds(resources ++ fields), else: []) + ]) + end + + defp malformed_holds(holds) do + for hold <- holds, + key <- ~w(owner opened lift), + not well_formed?(key, hold[key]), + do: "holds: #{hold["filename"] || hold["path"]} lacks a valid #{key}" + end + + defp stale_resource_holds(resources, tree) do + declared = tree.manifest |> Map.get("resources", []) |> Enum.map(& &1["filename"]) + copied = tree.dockerfile |> instructions() |> copy_sources() |> Enum.map(&elem(&1, 1)) + + for %{"filename" => f} <- resources, f in declared or f not in copied do + state = if f in declared, do: "now declared", else: "not copied" + "holds: #{f} is held but is #{state}; remove the hold" + end + end + + defp field_hold_violations(fields, manifest) do + pending = pending_paths(manifest, []) + held = Enum.map(fields, & &1["path"]) + + for(p <- pending, p not in held, do: "holds: manifest #{p} is #{@pending} with no hold") ++ + for p <- held, p not in pending, do: "holds: #{p} is held but no longer #{@pending}" + end + + defp open_holds(holds) do + for h <- holds, do: "AC7: open hold #{h["filename"] || h["path"]}: #{h["lift"]}" + end + + defp well_formed?("opened", value), + do: is_binary(value) and match?({:ok, _}, Date.from_iso8601(value)) + + defp well_formed?(_key, value), do: is_binary(value) and String.trim(value) != "" + + # The paths of every manifest value containing PENDING, as `maintainers[0].username`. + defp pending_paths(value, path) when is_map(value) do + Enum.flat_map(value, fn {k, v} -> pending_paths(v, path ++ [k]) end) + end + + defp pending_paths(value, path) when is_list(value) do + value |> Enum.with_index() |> Enum.flat_map(fn {v, i} -> pending_paths(v, path ++ [i]) end) + end + + defp pending_paths(value, path) when is_binary(value) do + if String.contains?(value, @pending), do: [render_path(path)], else: [] + end + + defp pending_paths(_, _), do: [] + + defp render_path(path) do + Enum.reduce(path, "", fn + i, acc when is_integer(i) -> acc <> "[#{i}]" + k, "" -> k + k, acc -> acc <> "." <> k + end) + end + + # Files --------------------------------------------------------------------------------------- + + defp read_scripts(dir) do + if File.dir?(dir) do + for path <- Path.wildcard(Path.join(dir, "**/*")), + File.regular?(path), + do: {path, File.read!(path)} + else + [] + end + end + + defp symlink?(path), do: match?({:ok, %File.Stat{type: :symlink}}, File.lstat(path)) + + defp yaml!(path) do + {:ok, data} = YamlElixir.read_from_file(path) + data + end +end diff --git a/lib/trinity/versions.ex b/lib/trinity/versions.ex index 5b984be..64813bd 100644 --- a/lib/trinity/versions.ex +++ b/lib/trinity/versions.ex @@ -112,6 +112,44 @@ defmodule Trinity.Versions do "registry.access.redhat.com/ubi9/ubi@sha256:9295c5c688f487fa5cf27a734fa55ecd57aeb7dc0904ba537da4f42dfa1d0acb"}, note: "Added at Slice 003: the base of the FIPS build leg's image (docs/fips-leg.md), `registry.access.redhat.com/ubi9/ubi:latest` resolved by digest on 2026-09-20 (Red Hat Enterprise Linux release 9.8, `openssl-libs 3.5.8-1.el9_8`, `openssl-fips-provider-so 3.0.7-11.el9_8`). The image itself is OTP 28.5.0.5 built from source with `--enable-fips` against that OpenSSL, plus Elixir 1.20.4; the two archives are pinned by SHA-256 in the Containerfile and the image tag is `scripts/fips_image_tag.sh` over `.tool-versions` and the Containerfile. The FIPS provider the image runs is what the distribution ships and names; docs/fips-leg.md states what Trinity does and does not claim about it." + }, + %{ + name: "Headless image runtime base (UBI9 micro)", + pin: "**sha256:932aec77f5b86a5dba854a18b14a38725b296967e7dc9c7a1f4d7f0bf82e1ce5**", + lock: nil, + from: + {:file, "ci/headless/bases.env", + "BASE_TAG=9.8@sha256:932aec77f5b86a5dba854a18b14a38725b296967e7dc9c7a1f4d7f0bf82e1ce5"}, + note: + "Added at Slice 130: the final stage of the hardened headless image as this project publishes it (`ci/ironbank/Dockerfile`, docs/regulated/headless-image.md), `registry.access.redhat.com/ubi9/ubi-micro` 9.8. The digest is the one Iron Bank's own ubi9-micro image declares as its source, so the published build and an Iron Bank build start from the same bytes. Iron Bank builds the same Dockerfile from its own registry with the tags in `ci/ironbank/hardening_manifest.yaml`." + }, + %{ + name: "Headless image builder base (UBI9)", + pin: "**sha256:c26665a762876d5de27d1ed50e029ab3bc80ef3a3e155824ee7ab43e70055b63**", + lock: nil, + from: + {:file, "ci/headless/bases.env", + "BUILDER_TAG=9.8@sha256:c26665a762876d5de27d1ed50e029ab3bc80ef3a3e155824ee7ab43e70055b63"}, + note: + "Added at Slice 130: the builder stages of the hardened headless image (OTP from source, the release, the runtime root filesystem), `registry.access.redhat.com/ubi9` 9.8, the digest Iron Bank's own ubi9 image declares as its source. Nothing from these stages reaches the final image except the release and the root filesystem `dnf --installroot` assembles." + }, + %{ + name: "Image vulnerability scanner (grype)", + pin: "**v0.119.0**", + lock: nil, + from: + {:file, ".github/workflows/headless-image.yml", + "anchore/grype:v0.119.0@sha256:8c2c9234a345577a6d321a4753aa3ee1276d8975c8452d2344a56b57733ecad3"}, + note: + "Added at Slice 130 and chosen by measurement over Trivy 0.74.0: grype identifies the Erlang/OTP runtime inside a release from `beam.smp` and matches its advisories, which Trivy does not see at all (slice 130 NOTES, D-130-6). Pinned by image digest in the workflow, and to a release at least two weeks old when chosen." + }, + %{ + name: "SCAP Security Guide", + pin: "**0.1.82**", + lock: nil, + from: {:file, "scripts/stig_scan.sh", "ssg_version=0.1.82"}, + note: + "Added at Slice 130: the datastream (`ssg-rhel9-ds.xml`) and DISA STIG profile OpenSCAP evaluates the headless image against, the profile Iron Bank's pipeline uses for a UBI9 image. The release zip is checked against its published SHA-512, pinned beside the version in the script." } ] diff --git a/mix.exs b/mix.exs index f68e239..69b3827 100644 --- a/mix.exs +++ b/mix.exs @@ -51,7 +51,7 @@ defmodule Trinity.MixProject do # Slice 061: the headless release. The same tree assembled as an ordinary OTP release # (no Burrito, no desktop shell), for a server that runs Trinity as an MCP server and # the web pages on a bind address it is told (`TRINITY_MODE=headless`, `TRINITY_BIND`, - # `PORT`; config/runtime.exs). `ci/headless/Containerfile` builds and runs it. + # `PORT`; config/runtime.exs). `ci/ironbank/Dockerfile` builds and runs it (slice 130). headless: [ steps: [:assemble], include_executables_for: [:unix], diff --git a/scripts/headless_image.sh b/scripts/headless_image.sh new file mode 100755 index 0000000..f8cdee2 --- /dev/null +++ b/scripts/headless_image.sh @@ -0,0 +1,129 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +# +# Builds the hardened headless image (slice 130) the way Iron Bank's pipeline would, so the image +# this project publishes and the one it would submit come from the same Dockerfile and the same +# inputs (docs/regulated/headless-image.md). +# +# scripts/headless_image.sh stage DIR assemble a build context in DIR +# scripts/headless_image.sh build TAG [DIR] stage into DIR (default: a temporary directory), +# then build TAG with ci/headless/bases.env +# +# `stage` does what Iron Bank's prebuild does: for each resource in +# ci/ironbank/hardening_manifest.yaml it downloads the URL, checks the digest, and places the file +# in the context under its filename. A download is cached (TRINITY_RESOURCE_CACHE, default +# ~/.cache/trinity/ironbank-resources) and re-checked on every use, so a corrupted cache fails +# rather than building. The two held resources (ci/headless/submission_holds.yaml) are produced +# from this checkout: its tracked files, and its prod Hex tree. +# +# This script reaches the network on purpose; it is the pipeline's part, not the build's. The +# Dockerfile and the submission's own scripts never do, and `mix trinity.ironbank.lint` holds +# that. +set -euo pipefail +cd "$(dirname "$0")/.." + +cache="${TRINITY_RESOURCE_CACHE:-${XDG_CACHE_HOME:-$HOME/.cache}/trinity/ironbank-resources}" + +die() { + echo "headless_image: $*" >&2 + exit 1 +} + +# One line per manifest resource: filename, url, digest type, digest value, tab separated. +resources() { + python3 -c ' +import sys, yaml +manifest = yaml.safe_load(open("ci/ironbank/hardening_manifest.yaml")) +for r in manifest.get("resources", []): + v = r.get("validation") or {} + print("\t".join([r["filename"], r["url"], v.get("type", ""), v.get("value", "")])) +' +} + +verify() { # file type value + local file=$1 type=$2 value=$3 actual + case "$type" in + sha256) actual=$(sha256sum "$file" | cut -d' ' -f1) ;; + sha512) actual=$(sha512sum "$file" | cut -d' ' -f1) ;; + *) die "$file: the manifest gives no sha256 or sha512 digest" ;; + esac + [ "$actual" = "$value" ] +} + +fetch() { # filename url type value + local filename=$1 url=$2 type=$3 value=$4 + if [ -f "$cache/$filename" ] && verify "$cache/$filename" "$type" "$value"; then + return 0 + fi + echo "headless_image: fetching $filename" >&2 + curl -fsSL --retry 3 -o "$cache/$filename.part" "$url" + verify "$cache/$filename.part" "$type" "$value" || + die "$filename from $url does not match its $type digest in the manifest" + mv "$cache/$filename.part" "$cache/$filename" +} + +# A tar.gz that depends only on its contents: names sorted, owners and times fixed, gzip without +# a timestamp. Two stages of the same tree give the same bytes. The fixed time is 2000-01-01, not +# the epoch: Mix decides what to compile by comparing a source's mtime with its target's, and +# reads a missing target as mtime 0, so sources stamped 0 look up to date and are never compiled +# (measured: `expo`'s parser was skipped and its compile failed). +deterministic_tgz() { # out dir paths-from-stdin (NUL separated) + local out=$1 dir=$2 + tar -C "$dir" --null --no-recursion -T - --sort=name --owner=0 --group=0 --numeric-owner \ + --mtime='@946684800' --mode='go-w' -cf - | gzip -n >"$out" +} + +stage() { + local dir=$1 + [ -n "$dir" ] || die "stage needs a directory" + rm -rf "$dir" + mkdir -p "$dir" "$cache" + + cp ci/ironbank/Dockerfile "$dir/" + if [ -d ci/ironbank/scripts ]; then cp -R ci/ironbank/scripts "$dir/"; fi + + while IFS=$'\t' read -r filename url type value; do + fetch "$filename" "$url" "$type" "$value" + cp "$cache/$filename" "$dir/$filename" + done < <(resources) + + # Held resource 1: the tracked files of this checkout, as they are on disk. + git ls-files -z | deterministic_tgz "$dir/trinity-src.tar.gz" . + + # Held resource 2: the prod Hex tree, fetched into a scratch project with this checkout's mix + # files, so nothing in the working tree's own deps/ leaks in. + local hex + hex=$(mktemp -d) + cp mix.exs mix.lock "$hex/" + cp -R config "$hex/" + (cd "$hex" && MIX_ENV=prod mix deps.get --only prod >&2) + (cd "$hex" && find deps -print0 | sort -z) | deterministic_tgz "$dir/trinity-hex-deps.tar.gz" "$hex" + rm -rf "$hex" + + echo "headless_image: staged $(find "$dir" -maxdepth 1 -type f | wc -l) files in $dir" >&2 +} + +build() { + local tag=$1 dir=${2:-} + [ -n "$tag" ] || die "build needs an image tag" + if [ -z "$dir" ]; then + dir=$(mktemp -d) + trap 'rm -rf "$dir"' EXIT + fi + stage "$dir" + + local args=() + while IFS= read -r line; do + case "$line" in '' | \#*) continue ;; esac + args+=(--build-arg "$line") + done &2; exit 1; } + mv "$zip.part" "$zip" +fi + +ssg=$(mktemp -d) +cid="" +trap 'rm -rf "$ssg"; if [ -n "$cid" ]; then docker rm "$cid" >/dev/null; fi' EXIT +unzip -q -j "$zip" "scap-security-guide-$ssg_version/ssg-rhel9-ds.xml" -d "$ssg" + +builder=$(awk -F= '$1 == "BASE_REGISTRY" { r = $2 } $1 == "BUILDER_IMAGE" { i = $2 } $1 == "BUILDER_TAG" { t = $2 } + END { print r "/" i ":" t }' ci/headless/bases.env) + +cid=$(docker create "$image") +docker export "$cid" | + docker run --rm -i \ + -v "$ssg:/ssg:ro" -v "$out:/out" \ + -e HOST_UID="$(id -u)" -e HOST_GID="$(id -g)" \ + "$builder" bash -c ' + set -euo pipefail + dnf -q -y install --setopt=install_weak_deps=False openscap-scanner >/dev/null + oscap --version | head -1 >&2 + mkdir /rootfs + tar -x -C /rootfs + set +e + OSCAP_PROBE_ROOT=/rootfs oscap xccdf eval \ + --profile xccdf_org.ssgproject.content_profile_stig \ + --results /out/stig-results.xml --report /out/stig-report.html \ + /ssg/ssg-rhel9-ds.xml >/out/stig-oscap.log 2>&1 + status=$? + set -e + chown "$HOST_UID:$HOST_GID" /out/stig-results.xml /out/stig-report.html /out/stig-oscap.log + # 0: every rule passed; 2: at least one did not. Anything else is an evaluation error. + if [ "$status" -ne 0 ] && [ "$status" -ne 2 ]; then + echo "stig_scan: oscap exited $status" >&2 + tail -20 /out/stig-oscap.log >&2 + exit "$status" + fi + ' +echo "stig_scan: wrote $out/stig-results.xml" >&2 diff --git a/test/ironbank_submission_test.exs b/test/ironbank_submission_test.exs new file mode 100644 index 0000000..c13ce03 --- /dev/null +++ b/test/ironbank_submission_test.exs @@ -0,0 +1,358 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule IronbankSubmissionTest do + @moduledoc """ + Slice 130, AC2, AC2a, AC6 (the Dockerfile half) and AC6a: `ci/ironbank/` is a submission Iron + Bank's rules accept, and the published build pins every base by digest. + + The first test reads the tree as it is. Every other test plants one violation into that same + tree, read once, and asserts that the lint reports it **for its own reason**: the assertion + names the message, and the unplanted tree is asserted clean first, so a plant that tripped some + other rule would not pass. The planted reds the slice's proof requires are here by name: a + literal registry host (AC2), a tag-not-digest base (AC2), and `curl` in the build (AC2a). + """ + use ExUnit.Case, async: true + + alias Mix.Tasks.Trinity.Ironbank.Lint + + @tree Lint.read_tree(".") + + defp violations(tree, opts \\ []), do: Lint.violations(tree, opts) + + defp plant(field, from, to) do + text = Map.fetch!(@tree, field) + assert String.contains?(text, from), "the plant's anchor #{inspect(from)} is not in #{field}" + Map.put(@tree, field, String.replace(text, from, to, global: false)) + end + + defp assert_only(tree, pattern) do + found = violations(tree) + assert found != [], "the plant produced no violation" + + assert Enum.all?(found, &(&1 =~ pattern)), + "expected only #{inspect(pattern)}, got:\n" <> Enum.join(found, "\n") + + found + end + + test "the tree as it stands has no violation" do + assert violations(@tree) == [] + end + + test "the submission's LICENSE is the project's, byte for byte" do + assert File.read!("ci/ironbank/LICENSE") == File.read!("LICENSE") + end + + describe "AC2: bases" do + test "a FROM naming a registry host is refused (planted red)" do + tree = + plant( + :dockerfile, + "FROM ${BASE_REGISTRY}/${BASE_IMAGE}:${BASE_TAG} AS runtime-base", + "FROM registry.access.redhat.com/ubi9/ubi-micro:9.8 AS runtime-base" + ) + + assert_only( + tree, + ~r/^AC2: .*FROM registry\.access\.redhat\.com\/ubi9\/ubi-micro:9\.8 does not take its registry/ + ) + end + + test "a published base pinned by tag and not by digest is refused (planted red)" do + tree = + plant( + :bases_env, + "BASE_TAG=9.8@sha256:932aec77f5b86a5dba854a18b14a38725b296967e7dc9c7a1f4d7f0bf82e1ce5", + "BASE_TAG=9.8" + ) + + [_ | _] = + assert_only( + tree, + ~r/^AC2: .*resolves FROM to registry\.access\.redhat\.com\/ubi9\/ubi-micro:9\.8, which is not pinned by digest/ + ) + end + + test "a variable the published build does not set is refused" do + tree = plant(:bases_env, "BUILDER_TAG=", "UNUSED_TAG=") + assert_only(tree, ~r/^AC2: .*bases\.env does not set BUILDER_TAG/) + end + + test "a FROM variable the manifest does not set is refused" do + tree = + Map.update!( + @tree, + :manifest, + &update_in(&1, ["args"], fn a -> Map.delete(a, "BUILDER_TAG") end) + ) + + assert_only(tree, ~r/^AC2: .*\$\{BUILDER_TAG\} is not set by hardening_manifest\.yaml/) + end + end + + describe "AC2a: no network in the build" do + test "curl in a RUN is refused (planted red)" do + tree = + plant( + :dockerfile, + "&& make install \\", + "&& curl -fsSL -o /tmp/x example.invalid \\\n && make install \\" + ) + + assert_only(tree, ~r/^AC2a: Dockerfile line \d+: curl reaches the network/) + end + + test "wget in a submission script is refused, even in a comment" do + tree = %{ + @tree + | scripts: [ + {"ci/ironbank/scripts/entrypoint.sh", + "#!/bin/sh\n# wget is never called\nexec \"$@\"\n"} + ] + } + + assert_only( + tree, + ~r/^AC2a: ci\/ironbank\/scripts\/entrypoint\.sh line 2: wget reaches the network/ + ) + end + + test "a URL anywhere in the Dockerfile is refused" do + tree = + plant(:dockerfile, "# The toolchain:", "# See https://example.invalid. The toolchain:") + + assert_only(tree, ~r/^AC2a: Dockerfile line \d+: a URL/) + end + + test "ADD is refused" do + tree = + plant( + :dockerfile, + "COPY --from=rootfs /rootfs/ /", + "COPY --from=rootfs /rootfs/ /\nADD trinity-src.tar.gz /opt/" + ) + + assert_only(tree, ~r/^AC2a: Dockerfile line \d+: ADD is prohibited/) + end + + test "copying a file no resource declares is refused" do + tree = + plant( + :dockerfile, + "trinity-src.tar.gz trinity-hex-deps.tar.gz", + "trinity-src.tar.gz trinity-hex-deps.tar.gz undeclared.bin" + ) + + assert_only( + tree, + ~r/^AC2a: Dockerfile line \d+: COPY undeclared\.bin is not a resource declared/ + ) + end + + test "a declared resource the Dockerfile never copies is refused" do + tree = plant(:dockerfile, " rebar3-3.25.1-otp-28 /build/resources/", " /build/resources/") + found = violations(tree) + + assert "AC2a: resource rebar3-3.25.1-otp-28 is declared in hardening_manifest.yaml but never copied" in found + end + + test "a resource whose digest is not lowercase hex of the right length is refused" do + tree = + Map.update!(@tree, :manifest, fn m -> + update_in(m, ["resources", Access.at(0), "validation", "value"], &String.upcase/1) + end) + + assert_only( + tree, + ~r/^AC2a: resource otp_src_.*: its sha256 value is not 64 lowercase hex digits/ + ) + end + end + + describe "AC6: what the Dockerfile may not do" do + for {label, command, expected} <- [ + {"chmod 777", "chmod 777 /opt/trinity", "chmod 777 makes a file world-writable"}, + {"chmod 4755", "chmod 4755 /opt/trinity/bin/headless", + "chmod 4755 sets a SUID or SGID bit"}, + {"chmod u+s", "chmod u+s /opt/trinity/bin/headless", + "chmod u+s sets a SUID or SGID bit"}, + {"chmod -R o+w", "chmod -R o+w /data", "chmod o+w makes a file world-writable"}, + {"--nogpgcheck", "dnf -y --nogpgcheck install zlib", + "disables package signature checks"}, + {"rpm --import elsewhere", "rpm --import /tmp/key.asc", + "rpm --import /tmp/key.asc is not from a gpg/ folder"}, + {"a write to /etc/passwd", + "echo trinity:x:10001:10001::/data:/sbin/nologin >> /rootfs/etc/passwd", + "writes or changes /etc/passwd"} + ] do + test "#{label} is refused" do + tree = + plant( + :dockerfile, + " && install -d -o 10001", + " && #{unquote(command)} \\\n && install -d -o 10001" + ) + + found = assert_only(tree, ~r/^AC6: Dockerfile line \d+: /) + assert Enum.any?(found, &String.contains?(&1, unquote(expected))), Enum.join(found, "\n") + end + end + + test "rpm --import from a gpg/ folder is accepted" do + tree = + plant( + :dockerfile, + " && install -d -o 10001", + " && rpm --import /tmp/gpg/vendor.asc \\\n && install -d -o 10001" + ) + + assert violations(tree) == [] + end + + test "a final USER of root is refused" do + tree = plant(:dockerfile, "USER 10001:10001", "USER root") + assert_only(tree, ~r/^AC6: Dockerfile line \d+: the final USER root is not a numeric UID/) + end + + test "no USER in the final stage is refused" do + tree = plant(:dockerfile, "USER 10001:10001\n", "") + assert_only(tree, ~r/^AC6: the final stage sets no USER/) + end + end + + describe "AC6a: Iron Bank's layout" do + test "a conf/ directory is refused and named" do + tree = %{@tree | entries: Enum.sort(["conf" | @tree.entries])} + + assert_only( + tree, + ~r/^AC6a: ci\/ironbank\/conf is not part of .*the configuration directory is config\// + ) + end + + test "a missing README.md is refused" do + tree = %{@tree | entries: @tree.entries -- ["README.md"]} + assert_only(tree, ~r/^AC6a: ci\/ironbank\/README\.md is required/) + end + + test "a second recipe beside the Dockerfile is refused" do + tree = %{@tree | entries: Enum.sort(["Containerfile" | @tree.entries])} + assert_only(tree, ~r/^AC6a: ci\/ironbank\/Containerfile is not part of/) + end + + test "a LABEL in the Dockerfile is refused" do + tree = + plant( + :dockerfile, + "WORKDIR /opt/trinity\n", + "WORKDIR /opt/trinity\nLABEL org.opencontainers.image.title=trinity\n" + ) + + assert_only(tree, ~r/^AC6a: Dockerfile line \d+: LABEL belongs in hardening_manifest\.yaml/) + end + + test "a first tag of latest, BASE_REGISTRY in args, and a missing label are refused" do + tree = + Map.update!(@tree, :manifest, fn m -> + m + |> Map.put("tags", ["latest", "0.1.0"]) + |> put_in(["args", "BASE_REGISTRY"], "registry1.dso.mil") + |> update_in(["labels"], &Map.delete(&1, "org.opencontainers.image.vendor")) + end) + + found = violations(tree) + assert "AC6a: the first manifest tag may not be latest" in found + assert "AC6a: manifest args may not set BASE_REGISTRY; the pipeline does" in found + assert "AC6a: manifest label org.opencontainers.image.vendor is required" in found + assert length(found) == 3 + end + + test "a manifest version that disagrees with mix.exs is refused" do + tree = %{@tree | version: "9.9.9"} + found = violations(tree) + assert "AC6a: the first manifest tag 0.1.0 is not mix.exs's version 9.9.9" in found + + assert "AC6a: label org.opencontainers.image.version is not mix.exs's version 9.9.9" in found + end + + test "an OTP resource that disagrees with .tool-versions is refused" do + tree = %{ + @tree + | tool_versions: String.replace(@tree.tool_versions, "erlang 28.5.0.5", "erlang 28.5.0.7") + } + + assert_only( + tree, + ~r/^AC6a: no resource otp_src_28\.5\.0\.7\.tar\.gz, the OTP 28\.5\.0\.7 that \.tool-versions names/ + ) + end + end + + describe "holds" do + test "a held resource that is now declared is stale" do + tree = + Map.update!(@tree, :manifest, fn m -> + resource = %{ + "url" => "https://example.invalid/trinity-src.tar.gz", + "filename" => "trinity-src.tar.gz", + "validation" => %{"type" => "sha256", "value" => String.duplicate("a", 64)} + } + + Map.update!(m, "resources", &(&1 ++ [resource])) + end) + + assert "holds: trinity-src.tar.gz is held but is now declared; remove the hold" in violations( + tree + ) + end + + test "a PENDING value with no hold is refused, and a hold with no PENDING value is stale" do + tree = + Map.update!( + @tree, + :manifest, + &put_in(&1, ["maintainers", Access.at(0), "username"], "ayla") + ) + + assert "holds: maintainers[0].username is held but no longer PENDING" in violations(tree) + + tree = + Map.update!( + @tree, + :manifest, + &put_in(&1, ["labels", "org.opencontainers.image.url"], "PENDING") + ) + + assert "holds: manifest labels.org.opencontainers.image.url is PENDING with no hold" in violations( + tree + ) + end + + test "--submission refuses every open hold, and today there are three" do + found = violations(@tree, submission: true) + assert length(found) == 3 + assert Enum.all?(found, &String.starts_with?(&1, "AC7: open hold ")) + end + end + + describe "mode_reasons/1" do + test "reads numeric and symbolic modes" do + assert Lint.mode_reasons("0755") == [] + assert Lint.mode_reasons("644") == [] + assert Lint.mode_reasons("1777") == ["chmod 1777 makes a file world-writable"] + assert Lint.mode_reasons("2755") == ["chmod 2755 sets a SUID or SGID bit"] + assert Lint.mode_reasons("go-w") == [] + assert Lint.mode_reasons("u+x") == [] + assert Lint.mode_reasons("+w") == ["chmod +w makes a file world-writable"] + assert Lint.mode_reasons("a+rwx") == ["chmod a+rwx makes a file world-writable"] + assert Lint.mode_reasons("g+s") == ["chmod g+s sets a SUID or SGID bit"] + end + end + + describe "the task" do + test "passes on the tree, and --submission refuses the open holds" do + assert :ok == Lint.run([]) + assert_raise Mix.Error, ~r/3 violation/, fn -> Lint.run(["--submission"]) end + end + end +end diff --git a/test/mix/tasks/trinity_image_findings_test.exs b/test/mix/tasks/trinity_image_findings_test.exs new file mode 100644 index 0000000..73a708a --- /dev/null +++ b/test/mix/tasks/trinity_image_findings_test.exs @@ -0,0 +1,209 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Mix.Tasks.Trinity.Image.FindingsTest do + @moduledoc """ + Slice 130, AC3 and AC4: the scan gate fails on a High or Critical finding with no justification + row, passes once the row exists, and fails on a row whose finding the scan no longer reports. + The reports are grype's JSON shape, cut down to the fields the gate reads; the live run on the + built image is in the slice's PROOF.md. + """ + use ExUnit.Case, async: true + + alias Mix.Tasks.Trinity.Image.Findings + + defp rpm(id, severity, name, version, state \\ "not-fixed") do + %{ + "vulnerability" => %{ + "id" => id, + "severity" => severity, + "fix" => %{"state" => state, "versions" => []} + }, + "artifact" => %{ + "name" => name, + "version" => version, + "type" => "rpm", + "locations" => [%{"path" => "/var/lib/rpm/rpmdb.sqlite"}] + } + } + end + + defp binary(id, severity, fixed) do + %{ + "vulnerability" => %{ + "id" => id, + "severity" => severity, + "fix" => %{"state" => "fixed", "versions" => fixed} + }, + "artifact" => %{ + "name" => "erlang", + "version" => "28.5.0.5", + "type" => "binary", + "locations" => [%{"path" => "/opt/trinity/erts-16.4.0.5/bin/beam.smp"}] + } + } + end + + defp row(finding, package, path \\ nil) do + %{ + "finding" => finding, + "package" => package, + "packagePath" => path, + "severity" => "high", + "scanSource" => "grype", + "justification" => "Inherited from the base; the vendor lists no fix." + } + end + + defp gate(matches, rows), + do: + %{"matches" => matches} + |> Findings.findings() + |> Findings.evaluate(rows) + |> Findings.violations() + + test "an OS package finding is keyed with no path, a binary's by its path" do + [os, bin] = + Findings.findings(%{ + "matches" => [ + rpm("CVE-1", "High", "pcre2", "10.40-6.el9"), + binary("CVE-2", "Low", ["28.5.0.7"]) + ] + }) + + assert {os.finding, os.package, os.packagePath} == {"CVE-1", "pcre2-10.40-6.el9", nil} + + assert {bin.package, bin.packagePath} == + {"erlang-28.5.0.5", "/opt/trinity/erts-16.4.0.5/bin/beam.smp"} + end + + test "AC3: a planted High finding with no row fails, and passes once justified" do + planted = rpm("CVE-2099-0001", "High", "openssl-libs", "1:3.5.8-1.el9_8") + + assert [violation] = gate([planted], []) + + assert violation =~ + ~r/^AC3: CVE-2099-0001 \(high\) in openssl-libs-1:3\.5\.8-1\.el9_8 at pkgdb has no justification row/ + + assert gate([planted], [row("CVE-2099-0001", "openssl-libs-1:3.5.8-1.el9_8")]) == [] + end + + test "AC3: a Critical finding with a fix available says what fixes it" do + assert [violation] = gate([binary("CVE-2099-0002", "Critical", ["28.5.0.7"])], []) + assert violation =~ "(critical) in erlang-28.5.0.5 at /opt/trinity/erts-16.4.0.5/bin/beam.smp" + assert violation =~ "(fix: fixed 28.5.0.7)" + end + + test "AC3: Medium and below are reported, not gated" do + assert gate( + [ + rpm("CVE-2099-0003", "Medium", "zlib", "1.2.11"), + rpm("CVE-2099-0004", "Negligible", "zlib", "1.2.11") + ], + [] + ) == [] + end + + test "AC4: a planted row whose finding the scan no longer reports fails as stale" do + current = rpm("CVE-2099-0001", "High", "openssl-libs", "1:3.5.8-1.el9_8") + + rows = [ + row("CVE-2099-0001", "openssl-libs-1:3.5.8-1.el9_8"), + row("CVE-2099-0009", "pcre2-10.40-6.el9") + ] + + assert [violation] = gate([current], rows) + + assert violation =~ + ~r/^AC4: .* justifies CVE-2099-0009 in pcre2-10\.40-6\.el9 at pkgdb, which the scan no longer reports/ + end + + test "AC4: upgrading the package retires the row, because the key includes the version" do + upgraded = rpm("CVE-2099-0001", "High", "openssl-libs", "1:3.5.9-1.el9_8") + + [unjustified, stale] = + gate([upgraded], [row("CVE-2099-0001", "openssl-libs-1:3.5.8-1.el9_8")]) + + assert unjustified =~ "AC3: CVE-2099-0001 (high) in openssl-libs-1:3.5.9-1.el9_8" + assert stale =~ "AC4: " + end + + test "a row with no justification text, or missing a key, is refused" do + finding = rpm("CVE-2099-0001", "High", "openssl-libs", "1:3.5.8-1.el9_8") + blank = Map.put(row("CVE-2099-0001", "openssl-libs-1:3.5.8-1.el9_8"), "justification", " ") + keyless = Map.delete(row("CVE-2099-0001", "openssl-libs-1:3.5.8-1.el9_8"), "scanSource") + + for bad <- [blank, keyless] do + found = gate([finding], [bad]) + assert Enum.any?(found, &(&1 =~ "AC3: malformed justification row")), inspect(found) + + assert Enum.any?(found, &(&1 =~ "AC3: CVE-2099-0001 (high)")), + "a malformed row must not justify" + end + end + + test "the same finding reported twice is one finding" do + match = rpm("CVE-2099-0001", "High", "openssl-libs", "1:3.5.8-1.el9_8") + assert length(Findings.findings(%{"matches" => [match, match]})) == 1 + end + + test "the CSV carries every finding with its justification, quoting commas" do + findings = + Findings.findings(%{"matches" => [rpm("CVE-2099-0001", "High", "pcre2", "10.40-6.el9")]}) + + row = Map.put(row("CVE-2099-0001", "pcre2-10.40-6.el9"), "justification", "inherited, no fix") + csv = findings |> Findings.evaluate([row]) |> Findings.to_csv() + + assert csv == + "finding,severity,scanSource,package,packagePath,fix,justification\n" <> + "CVE-2099-0001,high,grype,pcre2-10.40-6.el9,,not-fixed,\"inherited, no fix\"\n" + end + + test "the justification file in the tree is well formed" do + {:ok, data} = YamlElixir.read_from_file("ci/headless/justifications.yaml") + rows = data["justifications"] + assert rows != [] + assert Findings.evaluate([], rows).invalid == [] + end + + describe "the task" do + @describetag :tmp_dir + + test "fails on an unjustified finding and passes once justified, writing the CSV", %{ + tmp_dir: tmp + } do + scan = Path.join(tmp, "grype.json") + rows = Path.join(tmp, "justifications.yaml") + out = Path.join(tmp, "findings.csv") + + File.write!( + scan, + Jason.encode!(%{"matches" => [rpm("CVE-2099-0001", "High", "zlib", "1.2.11")]}) + ) + + File.write!(rows, "justifications: []\n") + + assert_raise Mix.Error, ~r/1 violation/, fn -> + Findings.run(["--scan", scan, "--justifications", rows, "--out", out]) + end + + File.write!(rows, """ + justifications: + - finding: "CVE-2099-0001" + package: "zlib-1.2.11" + packagePath: null + severity: "high" + scanSource: "grype" + justification: "Planted for this test." + """) + + assert :ok == Findings.run(["--scan", scan, "--justifications", rows, "--out", out]) + + assert File.read!(out) =~ + "CVE-2099-0001,high,grype,zlib-1.2.11,,not-fixed,Planted for this test." + end + + test "says how to call it without --scan" do + assert_raise Mix.Error, ~r/usage/, fn -> Findings.run([]) end + end + end +end diff --git a/test/mix/tasks/trinity_image_inspect_test.exs b/test/mix/tasks/trinity_image_inspect_test.exs new file mode 100644 index 0000000..0881d38 --- /dev/null +++ b/test/mix/tasks/trinity_image_inspect_test.exs @@ -0,0 +1,302 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Mix.Tasks.Trinity.Image.InspectTest do + @moduledoc """ + Slice 130, AC1 and AC6, the decisions `mix trinity.image.inspect` makes about an image's layers, + on real layer tarballs built here. The task's run on built images (the hardened one passing, the + slice 061 one and two images that revert a property on top of the hardened one failing) needs + Docker and is in the slice's PROOF.md; this file proves each rule discriminates. + + A clean pair of layers is asserted to pass first, and each test then plants one thing and + asserts the violation names it, so a plant cannot pass by tripping some other rule. + """ + use ExUnit.Case, async: true + + alias Mix.Tasks.Trinity.Image.Inspect + + @moduletag :tmp_dir + + # Assembled so the tree's own secrets scan does not read these source lines as keys. + @key "-----BEGIN " <> + "PRIVATE KEY-----\n" <> + String.duplicate("QUJD", 16) <> "\n-----END " <> "PRIVATE KEY-----\n" + @other_key "-----BEGIN " <> + "PRIVATE KEY-----\n" <> + String.duplicate("WFla", 16) <> "\n-----END " <> "PRIVATE KEY-----\n" + @cert "-----BEGIN CERTIFICATE-----\n" <> + String.duplicate("TUlJ", 16) <> "\n-----END CERTIFICATE-----\n" + @config %{"config" => %{"User" => "10001:10001", "Env" => ["RELEASE_DISTRIBUTION=none"]}} + + # A layer tarball from `files` ({path, content | :dir, mode}), unpacked the way the task does. + defp layer(tmp, name, files) do + src = Path.join(tmp, name) + + for {path, content, mode} <- files do + full = Path.join(src, path) + + if content == :dir do + File.mkdir_p!(full) + else + File.mkdir_p!(Path.dirname(full)) + File.write!(full, content) + end + + # The system chmod, not File.chmod!/2: measured, File.chmod!/2 drops the sticky bit on a + # directory (1777 becomes 0777), which made every fixture carry an unsticky /tmp. + {_, 0} = System.cmd("chmod", [Integer.to_string(mode, 8), full]) + end + + tar = Path.join(tmp, name <> ".tar") + + {_, 0} = + System.cmd("tar", ["-C", src, "--owner=0", "--group=0", "--numeric-owner", "-cf", tar, "."]) + + Inspect.read_layer(tar, Path.join(tmp, name <> "-unpacked")) + end + + defp base(tmp, extra \\ []) do + layer(tmp, "base", [ + {"etc/passwd", "root:x:0:0:root:/root:/bin/bash\n", 0o644}, + {"usr/bin/bash", "#!", 0o755}, + {"etc/pki/product-default/479.pem", @cert, 0o644} | extra + ]) + end + + defp added(tmp, extra \\ [], name \\ "added") do + layer(tmp, name, [ + {"etc/passwd", + "root:x:0:0:root:/root:/bin/bash\ntrinity:x:10001:10001::/data:/sbin/nologin\n", 0o644}, + {"opt/trinity/bin/headless", "#!/bin/sh\n", 0o755}, + {"opt/trinity/lib/trinity-0.1.0/priv/skills/web/SKILL.md", "# a skill\n", 0o644}, + {"usr/share/licenses/zlib/README", "licence\n", 0o644}, + {"tmp", :dir, 0o1777} | extra + ]) + end + + defp check(tmp, opts \\ []) do + Inspect.check(%{ + config: Keyword.get(opts, :config, @config), + base: [Keyword.get_lazy(opts, :base, fn -> base(tmp) end)], + added: Keyword.get_lazy(opts, :added, fn -> [added(tmp)] end), + allowances: Keyword.get(opts, :allowances, []) + }) + end + + defp only(result, expected) do + assert result.violations == [expected], + "expected only #{inspect(expected)}, got #{inspect(result.violations)}" + end + + test "a clean image passes, and what the base carries is listed as inherited", %{tmp_dir: tmp} do + result = check(tmp) + assert result.violations == [] + assert result.inherited == ["/etc/pki/product-default/479.pem: certificate material"] + end + + describe "AC1" do + test "no user, root, UID 0 and a UID below 1000 are refused", %{tmp_dir: tmp} do + for {user, expected} <- [ + {"", "AC1: the image sets no user, so it runs as root"}, + {"root", "AC1: the image's user root is root"}, + {"0:0", "AC1: the image's user 0:0 is root"}, + {"999", "AC1: the image's user 999 has UID 999, below 1000"} + ] do + only(check(tmp, config: put_in(@config, ["config", "User"], user)), expected) + end + end + + test "a user name resolves through the image's own /etc/passwd", %{tmp_dir: tmp} do + assert check(tmp, config: put_in(@config, ["config", "User"], "trinity")).violations == [] + + only( + check(tmp, config: put_in(@config, ["config", "User"], "nobody")), + "AC1: the image's user nobody is not in its /etc/passwd" + ) + end + end + + describe "AC6, in the layers the image adds" do + for {label, file, expected} <- [ + {"a package manager", {"usr/bin/dnf", "#!", 0o755}, + "AC6: /usr/bin/dnf: a package manager"}, + {"a build tool", {"usr/bin/gcc", "#!", 0o755}, "AC6: /usr/bin/gcc: a build tool"}, + {"Mix in the release", {"opt/trinity/lib/mix-1.20.4", :dir, 0o755}, + "AC6: /opt/trinity/lib/mix-1.20.4: mix, a build-time library, carried as a release application"}, + {"shell history", {"data/.bash_history", "ls\n", 0o600}, + "AC6: /data/.bash_history: shell history"}, + {"documentation", {"usr/share/doc/zlib/NEWS", "x", 0o644}, + "AC6: /usr/share/doc/zlib/NEWS: documentation"}, + {"a README", {"opt/trinity/README.md", "x", 0o644}, + "AC6: /opt/trinity/README.md: documentation"}, + {"a SUID bit", {"opt/trinity/bin/tool", "x", 0o4755}, + "AC6: /opt/trinity/bin/tool: SUID or SGID bit set (mode 04755)"}, + {"a world-writable file", {"opt/trinity/env", "x", 0o666}, + "AC6: /opt/trinity/env: world-writable (mode 0666)"}, + {"an unsticky world-writable directory", {"opt/trinity/drop", :dir, 0o777}, + "AC6: /opt/trinity/drop: world-writable (mode 0777)"}, + {"a certificate", {"opt/trinity/ca.pem", @cert, 0o644}, + "AC6: /opt/trinity/ca.pem: certificate material"} + ] do + test "#{label} is refused", %{tmp_dir: tmp} do + only(check(tmp, added: [added(tmp, [unquote(Macro.escape(file))])]), unquote(expected)) + end + end + + test "a file deleted by a later layer is still found", %{tmp_dir: tmp} do + planted = added(tmp, [{"usr/bin/dnf", "#!", 0o755}], "planted") + removed = layer(tmp, "removed", [{"usr/bin/.wh.dnf", "", 0o644}]) + only(check(tmp, added: [planted, removed]), "AC6: /usr/bin/dnf: a package manager") + end + + test "a certificate identical to the base's at the same path is not added material", %{ + tmp_dir: tmp + } do + copy = added(tmp, [{"etc/pki/product-default/479.pem", @cert, 0o644}]) + assert check(tmp, added: [copy]).violations == [] + end + + test "/etc/passwd made writable is refused twice: its mode, and world-writable", %{ + tmp_dir: tmp + } do + result = + check(tmp, + added: [ + layer(tmp, "added", [ + {"etc/passwd", "root:x:0:0::/:/bin/sh\ntrinity:x:10001:10001::/data:/x\n", 0o666} + ]) + ] + ) + + assert "AC6: /etc/passwd: world-writable (mode 0666)" in result.violations + + assert Enum.any?( + result.violations, + &(&1 =~ + ~r/^AC6: \/etc\/passwd is mode, owner, group \{"0666", 0, 0\}; the base's is \{"0644", 0, 0\}/) + ) + end + end + + describe "AC6, anywhere in the image" do + test "a private key in the base is refused too", %{tmp_dir: tmp} do + only( + check(tmp, base: base(tmp, [{"etc/ssh/key.pem", @key, 0o600}])), + "AC6: /etc/ssh/key.pem: a private key" + ) + end + + test "a key header with no key after it is not a key", %{tmp_dir: tmp} do + header = "constant: -----BEGIN " <> "RSA PRIVATE KEY----- used by a parser" + + assert check(tmp, added: [added(tmp, [{"usr/lib64/libtls.so", header, 0o755}])]).violations == + [] + end + + test "a distribution cookie and key files by name are refused", %{tmp_dir: tmp} do + result = + check(tmp, + added: [ + added(tmp, [ + {"opt/trinity/releases/COOKIE", "abc", 0o400}, + {"root/.ssh/id_rsa", "x", 0o600} + ]) + ] + ) + + assert Enum.sort(result.violations) == [ + "AC6: /opt/trinity/releases/COOKIE: an Erlang distribution cookie, a credential everyone who pulls the image would hold", + "AC6: /root/.ssh/id_rsa: key material by its name" + ] + end + + test "distribution not turned off is refused", %{tmp_dir: tmp} do + only( + check(tmp, config: put_in(@config, ["config", "Env"], [])), + "hardening: RELEASE_DISTRIBUTION is not none, so the release starts a distribution listener" + ) + end + end + + describe "allowances" do + test "a certificate under an allowed prefix is allowed, with its reason", %{tmp_dir: tmp} do + allowance = %{ + "path" => "/etc/pki/ca-trust/", + "kinds" => ["certificate"], + "reason" => "the OS trust store" + } + + result = + check(tmp, + added: [added(tmp, [{"etc/pki/ca-trust/bundle.pem", @cert, 0o644}])], + allowances: [allowance] + ) + + assert result.violations == [] + + assert result.allowed == [ + "/etc/pki/ca-trust/bundle.pem: certificate material (the OS trust store)" + ] + end + + test "an allowance that allows nothing fails as stale", %{tmp_dir: tmp} do + allowance = %{ + "path" => "/etc/pki/ca-trust/", + "kinds" => ["certificate"], + "reason" => "the OS trust store" + } + + only( + check(tmp, allowances: [allowance]), + "AC6: allowance /etc/pki/ca-trust/ allows nothing in this image; remove it from ci/headless/image_allowances.yaml" + ) + end + + test "a private key is allowed only by the SHA-256 of its PEM block", %{tmp_dir: tmp} do + sha = :crypto.hash(:sha256, String.trim_trailing(@key, "\n")) |> Base.encode16(case: :lower) + file = {"opt/trinity/lib/jose/ebin/jose_server.beam", "code" <> @key <> "code", 0o644} + + allowance = %{ + "path" => "/opt/trinity/lib/jose/ebin/jose_server.beam", + "kinds" => ["private_key"], + "sha256" => [sha], + "reason" => "a published test key" + } + + assert check(tmp, added: [added(tmp, [file])], allowances: [allowance]).violations == [] + + second = {"opt/trinity/lib/jose/ebin/jose_server.beam", "code" <> @key <> @other_key, 0o644} + result = check(tmp, added: [added(tmp, [second])], allowances: [allowance]) + + assert "AC6: /opt/trinity/lib/jose/ebin/jose_server.beam: a private key" in result.violations + end + + test "a kind that cannot be allowed is refused as an allowance", %{tmp_dir: tmp} do + allowance = %{ + "path" => "/usr/bin/dnf", + "kinds" => ["package_manager"], + "reason" => "convenient" + } + + result = + check(tmp, added: [added(tmp, [{"usr/bin/dnf", "#!", 0o755}])], allowances: [allowance]) + + assert "AC6: /usr/bin/dnf: a package manager" in result.violations + + assert Enum.any?( + result.violations, + &(&1 =~ ~r/^AC6: allowance "\/usr\/bin\/dnf": kinds must be some of/) + ) + end + + test "the allowances file in the tree is well formed", %{tmp_dir: tmp} do + {:ok, %{"allowances" => allowances}} = + YamlElixir.read_from_file("ci/headless/image_allowances.yaml") + + malformed = + check(tmp, allowances: allowances).violations + |> Enum.reject(&(&1 =~ "allows nothing in this image")) + + assert malformed == [] + end + end +end diff --git a/test/mix/tasks/trinity_image_stig_test.exs b/test/mix/tasks/trinity_image_stig_test.exs new file mode 100644 index 0000000..c4bac89 --- /dev/null +++ b/test/mix/tasks/trinity_image_stig_test.exs @@ -0,0 +1,209 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Mix.Tasks.Trinity.Image.StigTest do + @moduledoc """ + Slice 130, AC5: the STIG applicability statement is derived from OpenSCAP's results, every + selected rule gets a disposition, and a rule with none fails the check. The results here are a + small XCCDF 1.2 document in OpenSCAP's shape; the run on the built image's real results (477 + rules) is in the slice's PROOF.md. + """ + use ExUnit.Case, async: true + + alias Mix.Tasks.Trinity.Image.Stig + + @moduletag :tmp_dir + + @results """ + + + + + + Enable auditd + RHEL-09-653010 + SV-1_rule + + + + + Ensure the Default Bash Umask is Set Correctly + umask + + RHEL-09-412055 + + + Configure Multiple DNS Servers + RHEL-09-252035 + + Not in the profile + + + + 1.3.14 + + notapplicable + pass + fail + notselected + + + """ + + @deployment %{ + "rules" => ["rule_resolv"], + "disposition" => "deployment", + "reason" => "the runtime writes resolv.conf" + } + + setup_all do + Mix.ensure_application!(:xmerl) + :ok + end + + setup %{tmp_dir: tmp} do + path = Path.join(tmp, "results.xml") + File.write!(path, @results) + %{scan: Stig.read_results(path)} + end + + test "reads the selected rules, their STIG identifiers and their groups' platforms", %{ + scan: scan + } do + assert scan.profile == "xccdf_org.ssgproject.content_profile_stig" + assert {scan.version, scan.scanner} == {"0.1.82", "1.3.14"} + assert Enum.map(scan.rules, & &1.id) == ["rule_audit", "rule_umask", "rule_resolv"] + + [audit, umask, _] = scan.rules + + assert {audit.title, audit.stig, audit.platforms, audit.result} == + {"Enable auditd", ["RHEL-09-653010"], ["#machine"], "notapplicable"} + + assert {umask.platforms, umask.result} == {["#package_bash"], "pass"} + end + + test "pass is met and notapplicable is not applicable, with OpenSCAP's own reason", %{ + scan: scan + } do + {disposed, []} = Stig.dispose(scan.rules, [@deployment]) + + assert Enum.map(disposed, fn {rule, d, reason} -> {rule.id, d, reason} end) == [ + {"rule_audit", "not applicable", + "OpenSCAP: notapplicable; the rule applies only where machine holds"}, + {"rule_umask", "met", "OpenSCAP: pass"}, + {"rule_resolv", "the deployment's", "the runtime writes resolv.conf"} + ] + end + + test "a rule with no disposition fails the check (planted red), and passes once it has one", %{ + scan: scan + } do + assert {_, [violation]} = Stig.dispose(scan.rules, []) + + assert violation == + "AC5: RHEL-09-252035 (rule_resolv) is fail and has no disposition in ci/headless/stig_dispositions.yaml" + + assert {_, []} = Stig.dispose(scan.rules, [@deployment]) + end + + test "a row for a rule that now passes, or that the profile does not select, is stale", %{ + scan: scan + } do + stale = %{"rules" => ["rule_umask"], "disposition" => "met", "reason" => "was fixed by hand"} + + unknown = %{ + "rules" => ["rule_gone"], + "disposition" => "deployment", + "reason" => "no longer in the guide" + } + + {_, violations} = Stig.dispose(scan.rules, [@deployment, stale, unknown]) + + assert violations == [ + "AC5: RHEL-09-412055 (rule_umask) is pass now; remove its row from ci/headless/stig_dispositions.yaml", + "AC5: ci/headless/stig_dispositions.yaml disposes of rule_gone, which the profile does not select" + ] + end + + test "a malformed or duplicated row is refused, and a malformed row disposes of nothing", %{ + scan: scan + } do + blank = %{"rules" => ["rule_resolv"], "disposition" => "waived", "reason" => " "} + {_, violations} = Stig.dispose(scan.rules, [blank]) + + assert "AC5: RHEL-09-252035 (rule_resolv) is fail and has no disposition in ci/headless/stig_dispositions.yaml" in violations + + assert Enum.any?(violations, &(&1 =~ "disposition must be met, not_applicable or deployment")) + assert Enum.any?(violations, &(&1 =~ "needs a reason")) + + {_, violations} = Stig.dispose(scan.rules, [@deployment, @deployment]) + assert violations == ["AC5: rule_resolv has more than one disposition"] + end + + test "the statement says what it is and is not, and carries every rule", %{scan: scan} do + {disposed, violations} = Stig.dispose(scan.rules, [@deployment]) + + text = + Stig.statement(scan, disposed, violations, + image: "trinity-headless:test", + digest: "sha256:abc" + ) + + assert text =~ "This is a statement about an image, not a compliance determination." + assert text =~ "| Image ID | sha256:abc |" + assert text =~ "3 rules selected." + + assert text =~ + "| RHEL-09-252035 | Configure Multiple DNS Servers | medium | fail | the deployment's | the runtime writes resolv.conf |" + + assert text =~ "Every rule has a disposition." + end + + test "the dispositions file in the tree is well formed" do + {:ok, %{"dispositions" => rows}} = + YamlElixir.read_from_file("ci/headless/stig_dispositions.yaml") + + rules = + for row <- rows, + id <- row["rules"], + do: %{id: id, title: "", stig: [], platforms: [], severity: "", result: "fail"} + + assert {_, []} = Stig.dispose(rules, rows) + end + + test "the task writes the statement and fails while a rule has no disposition", %{tmp_dir: tmp} do + results = Path.join(tmp, "results.xml") + rows = Path.join(tmp, "dispositions.yaml") + out = Path.join(tmp, "statement.md") + File.write!(results, @results) + File.write!(rows, "dispositions: []\n") + + assert_raise Mix.Error, ~r/1 violation/, fn -> + Stig.run(["--results", results, "--dispositions", rows, "--out", out]) + end + + assert File.read!(out) =~ "**Rules without a disposition, or rows that need attention:**" + + File.write!(rows, """ + dispositions: + - rules: ["rule_resolv"] + disposition: "deployment" + reason: "the runtime writes resolv.conf" + """) + + assert :ok == + Stig.run([ + "--results", + results, + "--dispositions", + rows, + "--out", + out, + "--image", + "img", + "--digest", + "sha256:1" + ]) + + assert File.read!(out) =~ "Every rule has a disposition." + end +end From e8a3c6c349bc212f0f65f078530b09660c2150ab Mon Sep 17 00:00:00 2001 From: Ayla Croft Date: Thu, 8 Oct 2026 06:39:50 -0400 Subject: [PATCH 2/4] fix(s130): the image build no longer fails after it succeeds scripts/headless_image.sh build TAG, with no directory, set an EXIT trap that read the function's local `dir`. By exit the local was gone, so under set -u the trap failed with "dir: unbound variable", the script exited 1 after a successful build, and the 453 MB context stayed in /tmp. The headless-image workflow calls it exactly that way, so its image job would have failed on every run. The path is now expanded into the trap when it is set. Found by slice 131 (F-131-2) and reproduced here in isolation before the fix. Signed-off-by: Ayla Croft --- scripts/headless_image.sh | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/scripts/headless_image.sh b/scripts/headless_image.sh index f8cdee2..136e469 100755 --- a/scripts/headless_image.sh +++ b/scripts/headless_image.sh @@ -109,7 +109,12 @@ build() { [ -n "$tag" ] || die "build needs an image tag" if [ -z "$dir" ]; then dir=$(mktemp -d) - trap 'rm -rf "$dir"' EXIT + # Expanded now, not at exit: `dir` is local to this function and is gone by the time the + # EXIT trap runs, so a trap that read it then failed under `set -u` ("dir: unbound + # variable"), exited 1 after a successful build and left the context behind (slice 131 + # NOTES, F-131-2). + # shellcheck disable=SC2064 + trap "rm -rf '$dir'" EXIT fi stage "$dir" From d133e8e15ec47da854dcc7f1f9a7f8a050207592 Mon Sep 17 00:00:00 2001 From: Ayla Croft Date: Thu, 8 Oct 2026 07:46:03 -0400 Subject: [PATCH 3/4] fix(s130): the image job's inspect and scan steps run on a runner Run 37764956008 built the image and then failed two checks, neither in the image: - `mix trinity.image.inspect` ran `docker save` on the base, which BuildKit keeps in its own cache and not in the image store ("reference does not exist"). The task now pulls the base when it is missing. The ref is pinned by digest, so the pull gets the bytes the build used. - grype's `db update` ran as a non-root user with no writable TMPDIR. The scan run already set one; the update now sets the same one. Both were reproduced here first: with the base untagged, `docker save` gives the runner's error and the task pulls the same image id and passes; the old update command fails with the runner's "permission denied" and the new one exits 0. Signed-off-by: Ayla Croft --- .github/workflows/headless-image.yml | 5 ++++- lib/mix/tasks/trinity.image.inspect.ex | 16 ++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/.github/workflows/headless-image.yml b/.github/workflows/headless-image.yml index 44acefd..8ca32d1 100644 --- a/.github/workflows/headless-image.yml +++ b/.github/workflows/headless-image.yml @@ -114,7 +114,10 @@ jobs: run: | mkdir -p out/grype-db out/tmp docker save "$IMAGE" -o out/image.tar - docker run --rm --user "$(id -u):$(id -g)" -v "$PWD/out:/s" -e GRYPE_DB_CACHE_DIR=/s/grype-db "$GRYPE" db update + # TMPDIR on both runs: as a non-root user grype cannot write the image's /tmp, and the + # update failed on its listing file without it (run 37764956008). + docker run --rm --user "$(id -u):$(id -g)" -v "$PWD/out:/s" \ + -e TMPDIR=/s/tmp -e GRYPE_DB_CACHE_DIR=/s/grype-db "$GRYPE" db update docker run --rm --network none --user "$(id -u):$(id -g)" -v "$PWD/out:/s" \ -e TMPDIR=/s/tmp -e GRYPE_DB_CACHE_DIR=/s/grype-db \ -e GRYPE_DB_AUTO_UPDATE=false -e GRYPE_CHECK_FOR_APP_UPDATE=false \ diff --git a/lib/mix/tasks/trinity.image.inspect.ex b/lib/mix/tasks/trinity.image.inspect.ex index af3b41e..059a38c 100644 --- a/lib/mix/tasks/trinity.image.inspect.ex +++ b/lib/mix/tasks/trinity.image.inspect.ex @@ -467,6 +467,7 @@ defmodule Mix.Tasks.Trinity.Image.Inspect do def unpack(image, base, work) do File.mkdir_p!(work) {image_config, image_layers} = save_and_read(image, Path.join(work, "image")) + :ok = ensure_local(base) {_base_config, base_layers} = save_and_read(base, Path.join(work, "base")) base_ids = Enum.map(base_layers, &elem(&1, 0)) image_ids = Enum.map(image_layers, &elem(&1, 0)) @@ -482,6 +483,21 @@ defmodule Mix.Tasks.Trinity.Image.Inspect do {image_config, Enum.map(base_layers, to_layer), Enum.map(added, to_layer)} end + # BuildKit, the builder on a GitHub runner, keeps the base in its own cache and never in the + # image store, so `docker save` of the base failed there (run 37764956008). The base is pinned + # by digest, so a pull fetches the same bytes the build used. + defp ensure_local(ref) do + case System.cmd("docker", ["image", "inspect", ref], stderr_to_stdout: true) do + {_, 0} -> + :ok + + _ -> + {out, status} = System.cmd("docker", ["pull", ref], stderr_to_stdout: true) + if status != 0, do: Mix.raise("docker pull #{ref} failed (#{status}): #{out}") + :ok + end + end + defp save_and_read(ref, dir) do File.mkdir_p!(dir) archive = Path.join(dir, "image.tar") From f8acab6198a33c6f186bde910d76c1cb98d50182 Mon Sep 17 00:00:00 2001 From: Ayla Croft Date: Thu, 8 Oct 2026 10:19:23 -0400 Subject: [PATCH 4/4] feat(s130): the OTP pins split, container 28.5.0.7 and desktop 28.5.0.6 with a TLS 1.2 clamp Owner decision D2 (2026-10-08). The container images build OTP 28.5.0.7 from source (ci/container.tool-versions; the Iron Bank manifest, the Dockerfile and the FIPS Containerfile read it; the source sha256 matches the release's SHA256.txt). The desktop stays on 28.5.0.6, because Burrito's CDN has no 28.5.0.7 ERTS for Linux or macOS, and holds every outbound TLS client to TLS 1.2 while the loaded ssl carries CVE-2026-89422 (Trinity.TLS: the ssl application default, Req's default Finch pool, ReqLLM's pools). The clamp lifts itself when ssl reaches 11.6.0.6. ADR-0014 records the window; a daily canary opens a pull request when Burrito can fetch the container's OTP for every desktop target. The ironbank lint holds the manifest to the container pin, both pins to one major, and the container pin no older than the desktop's. THIRD_PARTY_LICENSES --check accepts OTP application versions differing, and only on the container's runtime. Built and scanned locally: the image on 28.5.0.7 has 0 of the 14 findings; inspect 0 violations; STIG every rule disposed. Signed-off-by: Ayla Croft --- .github/workflows/fips-image.yml | 7 +- .github/workflows/gate.yml | 3 +- .github/workflows/headless-image.yml | 1 + .github/workflows/otp-canary.yml | 124 +++++ .tool-versions | 2 +- CHANGELOG.md | 10 + README.md | 6 +- THIRD_PARTY_LICENSES.md | 14 +- VERSIONS.md | 5 +- ci/container.tool-versions | 10 + ci/fips/Containerfile | 11 +- ci/headless/justifications.yaml | 12 +- ci/ironbank/Dockerfile | 4 +- ci/ironbank/hardening_manifest.yaml | 10 +- config/runtime.exs | 12 + docs/01-architecture.md | 3 + docs/adr/0004-desktop-shell-ex-tauri.md | 2 +- docs/adr/0005-otp-pin-driven-by-packaging.md | 22 + docs/adr/0014-otp-pins-split.md | 125 +++++ docs/fips-leg.md | 8 +- docs/fips-leg/supports-default.txt | 2 +- docs/fips-leg/supports-fips.txt | 2 +- docs/packaging.md | 2 +- docs/regulated/headless-image.md | 13 +- docs/regulated/stig-applicability.md | 6 +- lib/mix/tasks/trinity.ironbank.lint.ex | 84 +++- lib/mix/tasks/trinity.third_party_licenses.ex | 93 +++- lib/trinity.ex | 3 + lib/trinity/application.ex | 6 + lib/trinity/tls.ex | 173 +++++++ lib/trinity/versions.ex | 16 +- scripts/fips_image_tag.sh | 9 +- scripts/otp_canary.sh | 96 ++++ test/ironbank_submission_test.exs | 48 +- test/otp_canary_test.exs | 106 +++++ test/trinity/authority/selection_test.exs | 11 +- test/trinity/third_party_licenses_test.exs | 49 ++ test/trinity/tls_clamp_test.exs | 440 ++++++++++++++++++ test/trinity/tls_test.exs | 77 +++ 39 files changed, 1546 insertions(+), 81 deletions(-) create mode 100644 .github/workflows/otp-canary.yml create mode 100644 ci/container.tool-versions create mode 100644 docs/adr/0014-otp-pins-split.md create mode 100644 lib/trinity/tls.ex create mode 100755 scripts/otp_canary.sh create mode 100644 test/otp_canary_test.exs create mode 100644 test/trinity/tls_clamp_test.exs create mode 100644 test/trinity/tls_test.exs diff --git a/.github/workflows/fips-image.yml b/.github/workflows/fips-image.yml index a606c74..d4ac9d7 100644 --- a/.github/workflows/fips-image.yml +++ b/.github/workflows/fips-image.yml @@ -12,6 +12,7 @@ on: push: paths: - .tool-versions + - ci/container.tool-versions - ci/fips/Containerfile - scripts/fips_image_tag.sh - .github/workflows/fips-image.yml @@ -34,11 +35,13 @@ jobs: steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - - name: The tag, and the versions .tool-versions names + # OTP is the container's (ci/container.tool-versions), which may lead the desktop's pin in + # .tool-versions (ADR-0014); Elixir is the one Elixir pin. + - name: The tag, the container's OTP and the Elixir .tool-versions names id: meta run: | echo "ref=$(scripts/fips_image_tag.sh --ref)" >> "$GITHUB_OUTPUT" - echo "otp=$(awk '$1 == "erlang" { print $2 }' .tool-versions)" >> "$GITHUB_OUTPUT" + echo "otp=$(awk '$1 == "erlang" { print $2 }' ci/container.tool-versions)" >> "$GITHUB_OUTPUT" echo "elixir=$(awk '$1 == "elixir" { print $2 }' .tool-versions | cut -d- -f1)" >> "$GITHUB_OUTPUT" - name: Log in to the registry diff --git a/.github/workflows/gate.yml b/.github/workflows/gate.yml index 910c69f..912ef29 100644 --- a/.github/workflows/gate.yml +++ b/.github/workflows/gate.yml @@ -212,7 +212,8 @@ jobs: sleep 30 done echo "::error::$ref was not published within 30 minutes. It is built by the" \ - "fips-image workflow, which runs when ci/fips/Containerfile, .tool-versions or" \ + "fips-image workflow, which runs when ci/fips/Containerfile, .tool-versions," \ + "ci/container.tool-versions or" \ "scripts/fips_image_tag.sh changes. Check that workflow's run for this commit." exit 1 diff --git a/.github/workflows/headless-image.yml b/.github/workflows/headless-image.yml index 8ca32d1..6f15c3f 100644 --- a/.github/workflows/headless-image.yml +++ b/.github/workflows/headless-image.yml @@ -32,6 +32,7 @@ on: - mix.lock - config/** - .tool-versions + - ci/container.tool-versions - .github/workflows/headless-image.yml schedule: - cron: "17 6 * * *" diff --git a/.github/workflows/otp-canary.yml b/.github/workflows/otp-canary.yml new file mode 100644 index 0000000..999bb8c --- /dev/null +++ b/.github/workflows/otp-canary.yml @@ -0,0 +1,124 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +# +# The OTP canary (owner decision D2, ADR-0014). The desktop pin in .tool-versions waits for +# Burrito's ERTS builds; the container pin in ci/container.tool-versions does not. Every day this +# asks whether Burrito can now fetch the container's OTP for all three desktop targets, and the +# first day it can, it opens a pull request that moves the desktop pin there. Merging that pull +# request is how the desktop gets the CVE-2026-89422 fix, and with it `Trinity.TLS` stops holding +# the desktop's TLS clients to TLS 1.2 (it decides from the running runtime, not from a flag). +# +# Two things a reader of the pull request needs to know: +# * a pull request opened with GITHUB_TOKEN starts no workflow, so its gate runs when a person +# pushes to the branch (or closes and reopens it); +# * the repository setting "Allow GitHub Actions to create and approve pull requests" must be on, +# or the last step fails with a permissions error that names it. +# It never opens a second pull request for the same version: a closed one is a person's answer. +name: otp-canary + +on: + schedule: + - cron: "41 7 * * *" + workflow_dispatch: + +permissions: + contents: read + +jobs: + probe: + runs-on: ubuntu-latest + outputs: + result: ${{ steps.probe.outputs.result }} + version: ${{ steps.probe.outputs.version }} + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + + - name: Can Burrito fetch the container's OTP for every desktop target? + id: probe + run: scripts/otp_canary.sh probe | tee -a "$GITHUB_OUTPUT" + + flip: + needs: probe + if: needs.probe.outputs.result == 'ready' + runs-on: ubuntu-latest + # The branch push and the pull request, and nothing else in this workflow. + permissions: + contents: write + pull-requests: write + env: + VERSION: ${{ needs.probe.outputs.version }} + BRANCH: canary/otp-${{ needs.probe.outputs.version }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + + - name: Has a pull request for this version been opened before? + id: seen + run: | + count=$(gh pr list --head "$BRANCH" --state all --json number --jq length) + if [ "$count" != 0 ] || git ls-remote --exit-code --heads origin "$BRANCH" >/dev/null; then + echo "::notice::$BRANCH already has a pull request or a branch; nothing to do" + echo "seen=true" >> "$GITHUB_OUTPUT" + else + echo "seen=false" >> "$GITHUB_OUTPUT" + fi + + # The flip first, so setup-beam installs the OTP the pull request moves to: the licence list + # names the OTP applications of the runtime that generates it, and VERSIONS.md is generated + # from the pin list the flip edits. The gate checks both. + - name: Move the desktop pin + if: steps.seen.outputs.seen == 'false' + run: scripts/otp_canary.sh flip + + - uses: erlef/setup-beam@54075bcc5e249e4758d363f27d099f55d843f124 # v1.24.1 + if: steps.seen.outputs.seen == 'false' + with: + version-file: .tool-versions + version-type: strict + + - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + if: steps.seen.outputs.seen == 'false' + with: + path: | + deps + _build + key: ${{ runner.os }}-mix-canary-${{ hashFiles('mix.lock') }} + restore-keys: ${{ runner.os }}-mix- + + - name: Regenerate VERSIONS.md and THIRD_PARTY_LICENSES.md on the new OTP + if: steps.seen.outputs.seen == 'false' + env: + MIX_ENV: test + run: | + mix deps.get + mix versions.gen + mix trinity.sbom + mix trinity.third_party_licenses --write + + - name: Open the pull request + if: steps.seen.outputs.seen == 'false' + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git switch -c "$BRANCH" + git commit -s -a -m "build(otp): move the desktop pin to OTP $VERSION (ADR-0014)" + git push origin "$BRANCH" + cat > "$RUNNER_TEMP/body.md" < 27.0"`, which read like a refusal. It is not one. That key is declarative metadata — Mix enforces `:elixir` as a version requirement and has no built-in `:otp_release` enforcement — and the behaviour lives in a runtime check on diff --git a/docs/adr/0005-otp-pin-driven-by-packaging.md b/docs/adr/0005-otp-pin-driven-by-packaging.md index 27af242..568268a 100644 --- a/docs/adr/0005-otp-pin-driven-by-packaging.md +++ b/docs/adr/0005-otp-pin-driven-by-packaging.md @@ -71,3 +71,25 @@ declared constraint resting on a refuted fact does not outrank a measurement. **Standing risk.** Windows tracks OTP releases immediately while the other three targets depend on a third-party CDN's build queue, so the newest packageable OTP is whatever that CDN last built, and it lags. Re-run the probe at every phase boundary, per `VERSIONS.md`'s re-verification procedure. + +## Third correction, appended 2026-10-08 + +**Narrows the Decision above to the desktop, and moves its pin.** This ADR pinned "OTP" for the whole +tree. From 2026-10-08 it governs only the desktop build and the gate (`.tool-versions`); the container +images build the OTP `ci/container.tool-versions` names, from source, which does not depend on +Burrito (ADR-0014, owner decision D2). + +**Re-probed 2026-10-08**, the same URLs as the second correction, for the three targets `mix.exs` now +builds plus Linux aarch64: + +| OTP | macOS universal | Linux x86_64 | Linux aarch64 | Windows | +|---|---|---|---|---| +| 28.5.0.6 | **200** | **200** | **200** | **200** | +| 28.5.0.7 | 404 | 404 | 404 | 200 | + +1. **The desktop pin moves to 28.5.0.6**, now the newest OTP present on every target, by this ADR's own + rule. The second correction's row for 28.5.0.6 (404 on three targets on 2026-09-06) was true that + day and is superseded by this measurement. +2. **28.5.0.7 fixes CVE-2026-89422** (`ssl`, Critical), which this rule cannot reach yet. ADR-0014 + records the window, the TLS 1.2 clamp that covers it, and the daily canary that moves this pin to + the container's when the three URLs answer 200. diff --git a/docs/adr/0014-otp-pins-split.md b/docs/adr/0014-otp-pins-split.md new file mode 100644 index 0000000..862b81d --- /dev/null +++ b/docs/adr/0014-otp-pins-split.md @@ -0,0 +1,125 @@ +# ADR-0014: Two OTP pins, the container's and the desktop's, and a TLS 1.2 clamp between them +Status: accepted · Date: 2026-10-08 + +## Context + +OTP 28.5.0.7 (released 2026-09-22) fixes CVE-2026-89422 (GHSA-rgxr-4g4w-j875, `ssl`, CVSS 4.0 +9.3 Critical): OTP's TLS 1.3 client completes a handshake without validating the server's +certificate when the ServerHello carries a `pre_shared_key` extension the client never offered. Every +outbound HTTPS call Trinity makes (model providers, MCP servers, web fetch, model downloads) is a TLS +client, so anyone on the network path can impersonate any of them. The advisory's workaround is +"Restrict affected clients to TLS 1.2 ({versions, ['tlsv1.2']}) until the patch is applied. No +configuration both keeps TLS 1.3 and mitigates the issue." The same release fixes CVE-2026-65634 +(`asn1`, 8.2 High, a crafted OID costs CPU during a TLS handshake) and CVE-2026-68956 (the `ssh` +daemon, which Trinity does not run). + +Until this ADR one file, `.tool-versions`, pinned OTP for everything, and ADR-0005 set that pin to the +newest OTP whose ERTS Burrito can download for every desktop target. Burrito 1.6.0 fetches Linux and +macOS ERTS from a third-party CDN and Windows from the OTP release page +(`deps/burrito/lib/util/erts_universal_machine_fetcher.ex`). Probed 2026-10-08: + +| OTP | Linux x86_64 | Linux aarch64 | macOS universal | Windows | +|---|---|---|---|---| +| 28.5.0.6 | 200 | 200 | 200 | 200 | +| 28.5.0.7 | 404 | 404 | 404 | 200 | + +So the desktop cannot have 28.5.0.7 yet. The container images can: the hardened headless image +(`ci/ironbank/`) and the FIPS leg's image (`ci/fips/Containerfile`) build OTP from source and never +touch Burrito. The hardened image's vulnerability check was failing on 14 findings, every one in +`erlang-28.5.0.5`, CVE-2026-89422 among them, and tying the container to the desktop's pin meant +the container waited on a CDN it does not use. + +The owner decided this on 2026-10-08 (decision D2): the container to 28.5.0.7 now; the desktop to +28.5.0.6 on all three targets with every outbound BEAM TLS client clamped to TLS 1.2; a test that +enumerates the clients and asserts the clamp; a daily canary that opens the pull request moving the +desktop pin when Burrito has the ERTS. A custom ERTS build was considered and rejected: macOS is +feasible, Windows is unnecessary, and Linux is blocked by Burrito issue #237 (custom ERTS fails to +compile its wrapper in 1.6.0). + +## Decision + +**Two pins.** `ci/container.tool-versions` names the OTP the container images build (28.5.0.7); +`.tool-versions` names the desktop's and the gate's (28.5.0.6). `fips-image.yml` reads the container +pin, and `scripts/fips_image_tag.sh` hashes it. ADR-0005 still governs the desktop pin. + +**The lint holds the pins together rather than equal.** `mix trinity.ironbank.lint` used to require +the submission's OTP resource to be the OTP `.tool-versions` names. That rule is what tied the +container to Burrito, so it now requires the resource to be the OTP `ci/container.tool-versions` +names, and adds two rules of its own: both pins are the same OTP major, because one compiled tree and +one Elixir build run on both; and the container's is never older than the desktop's, because the +container is the pin that can move the day OTP publishes a fix. Dropping the check instead would have +let the manifest and the declared pin drift apart unnoticed. + +**The clamp is keyed on the running `ssl` application.** `Trinity.TLS.affected?/0` compares the +loaded `ssl` version with the first fixed one for the runtime's OTP major (`ssl` 11.6.0.6 on OTP 28, +from OTP's `otp_versions.table`: 28.5.0.6 ships 11.6.0.5, 28.5.0.7 ships 11.6.0.6); a major with no +recorded fix clamps. `OTP_VERSION` was the first choice and is not used, because an assembled release +does not carry that file. The container on 28.5.0.7 is configured exactly as before, the desktop on +28.5.0.6 clamps, and the clamp lifts itself the day the desktop pin moves, with nothing to remember. + +**The clamp has three layers**, applied by `config/runtime.exs` before any application starts: + +1. the `ssl` application's `protocol_version`, which every client that passes no `versions` option + inherits: `:httpc` (and with it Bumblebee's and Tokenizers' downloads), Postgrex, WebSockex, a + bare `:ssl.connect/3`; +2. Req's default options, which name `Trinity.TLS.Finch`, a Finch instance whose pool gives Mint + `transport_opts: [versions: [:"tlsv1.2"]]`. This layer exists because Mint passes `versions` + to `ssl` itself, from `ssl:versions()`'s `available` list, which ignores the first layer; +3. ReqLLM's own Finch pools, which its streaming requests use, with the same transport options. + +A caller that needs transport options of its own (a private CA, for example) takes them from +`Trinity.TLS.req_options/1`, which keeps the clamp. A caller that passes `connect_options` directly is +refused by Req while the clamp is in force, because Req will not combine a named pool with connect +options: a loud failure rather than a quiet TLS 1.3 connection. + +**The proof derives the clients from the release.** `test/trinity/tls_clamp_test.exs` reads the BEAM +import tables of every application in the release and lists every module that calls `ssl:connect` +and every module outside Mint that opens a Mint connection; each must be named in the test with the +layer that clamps it, and a named one no longer found fails as stale. It then reads the ClientHello +that each client path actually sends to a loopback listener (Req, ReqLLM with and without streaming, +`:httpc`, a bare `ssl:connect`, WebSockex, Postgrex) and fails on an offer of TLS 1.3 while the +runtime is affected. Its oracle for "affected" is the test runtime's own `OTP_VERSION`, read +independently of `Trinity.TLS`. On the FIPS leg, which runs the container's OTP, the same test +asserts the clamp is lifted. + +**The desktop is green while the clamp is provably global, and red otherwise.** A dependency that +starts opening TLS connections the census does not name fails the gate until it is clamped or named. + +**The canary.** `.github/workflows/otp-canary.yml` runs `scripts/otp_canary.sh probe` daily; when +Burrito serves the container's OTP for all three desktop targets it runs `flip` (`.tool-versions` and +the pin list), regenerates `VERSIONS.md` and `THIRD_PARTY_LICENSES.md` on the new OTP, and opens one +pull request per version. + +## Consequences + +- **The window.** Opened 2026-10-08. The desktop runs OTP 28.5.0.6, which carries CVE-2026-89422 + (mitigated by the clamp as the advisory prescribes) and CVE-2026-65634 (`asn1`, not mitigated: the + clamp does not change how a certificate's OIDs are decoded). It closes when the canary's pull + request is merged; the lift condition a stranger can check is the three URLs in + `scripts/otp_canary.sh` returning 200 for 28.5.0.7. The owner merges it. The desktop is + unreleased until slice 101, so during the window the exposure is development machines. +- On an affected runtime the `ssl` setting also limits TLS servers to TLS 1.2. The desktop serves + plain HTTP on loopback, and the container is not affected, so no server Trinity runs today is + changed by it. +- A server that accepts only TLS 1.3 fails to connect from the desktop during the window. That is a + visible error, not a silent downgrade. +- Two residual paths are named rather than clamped. `Req.Finch`, Req's own pool, runs unclamped + and is reached only by a request that names it or sets `finch: nil` without connect options; no + code in the tree does. `ReqLLM.Streaming.HTTP2DuplexSession` opens Mint with no transport options + (it is the transport of `ReqLLM.Bedrock.NovaSonic`); the census holds that no Trinity module + reaches it. +- The gateways in slices 071 and 072 make TLS calls of their own. 072's Mattermost client passes + `connect_options` for its CA file, which Req refuses beside the default pool; it must take its + options from `Trinity.TLS.req_options/1` before it merges. Its WebSockex connection inherits the + `ssl` setting. +- The pull request the canary opens starts no workflow (it is opened with `GITHUB_TOKEN`), and the + repository must allow Actions to create pull requests. Both are stated in the workflow. +- The FIPS leg now runs the gate on the container's OTP, so the gate's two legs run two OTP patches + of one major. Two checks had to learn that. `THIRD_PARTY_LICENSES.md` lists the OTP applications + of the runtime that generated the bill, so on the container's OTP six of them (`asn1`, `compiler`, + `public_key`, `ssh`, `ssl`, `stdlib`) carry newer versions: `mix trinity.third_party_licenses + --check` accepts exactly that difference, and only when the runtime is the container's pin and not + the desktop's. The census in `test/trinity/authority/selection_test.exs` counted outbound + connections by application, and Req's pooled connections now belong to Trinity's supervisor; it + leaves `Trinity.TLS.Finch` out, as it left `Req.Finch` out before. The desktop pin moving to the + container's ends the first of these. diff --git a/docs/fips-leg.md b/docs/fips-leg.md index 39df047..6355ff9 100644 --- a/docs/fips-leg.md +++ b/docs/fips-leg.md @@ -12,9 +12,9 @@ name the command that produced them. | Piece | Where | What it does | |---|---|---| -| `ci/fips/Containerfile` | the image | UBI9 by digest; OTP 28.5.0.5 from the release tarball with `--enable-fips --with-ssl=/usr`; Elixir 1.20.4 from the release zip; both archives checked against SHA-256 digests pinned in the file; git, python3 with PyYAML, gcc and procps for the gate's own steps | -| `.github/workflows/fips-image.yml` | builds the image | On a change to `.tool-versions`, the Containerfile or the tag script, and on demand; pushes to `ghcr.io/scriptkittyos/trinity-fips` under the tag below; a tag already published is not rebuilt | -| `scripts/fips_image_tag.sh` | both workflows | The tag: sixteen hex digits of SHA-256 over `.tool-versions` and the Containerfile, so the builder and the puller cannot disagree | +| `ci/fips/Containerfile` | the image | UBI9 by digest; OTP (the version `ci/container.tool-versions` names, 28.5.0.7; ADR-0014) from the release tarball with `--enable-fips --with-ssl=/usr`; Elixir 1.20.4 from the release zip; both archives checked against SHA-256 digests pinned in the file; git, python3 with PyYAML, gcc and procps for the gate's own steps | +| `.github/workflows/fips-image.yml` | builds the image | On a change to `.tool-versions` (Elixir), `ci/container.tool-versions` (OTP), the Containerfile or the tag script, and on demand; pushes to `ghcr.io/scriptkittyos/trinity-fips` under the tag below; a tag already published is not rebuilt | +| `scripts/fips_image_tag.sh` | both workflows | The tag: sixteen hex digits of SHA-256 over `.tool-versions`, `ci/container.tool-versions` and the Containerfile, so the builder and the puller cannot disagree | | `.github/workflows/gate.yml`, jobs `fips-tag` and `fips` | the leg | Pulls the image, fetches and compiles dependencies with the mode off, asserts the mode, checks the mode-off listing, then runs `plan_check`, the DCO check and `mix gate`, all in the mode | | `test/fips/mode_test.exs` | the suite | One untagged test binds `TRINITY_FIPS_LEG` to `crypto:info_fips/0` on every leg; three `:fips` tests (AC1 to AC3) run only where the leg declares itself | | `scripts/crypto_supports.exs` | the listing | Prints `crypto:supports/0` one item per line, sorted, with the mode and library named first | @@ -118,7 +118,7 @@ owner. No skip tag exists for this: `git grep -n '@tag :skip\|@moduletag :skip' ## Reproducing the image and the leg on a machine ``` -docker build --build-arg OTP_VERSION=28.5.0.5 --build-arg ELIXIR_VERSION=1.20.4 \ +docker build --build-arg OTP_VERSION=28.5.0.7 --build-arg ELIXIR_VERSION=1.20.4 \ -t trinity-fips:local -f ci/fips/Containerfile ci/fips docker run --rm -v "$PWD:/work" -w /work \ -e MIX_ENV=test -e TRINITY_DB=sqlite -e TRINITY_FIPS_LEG=1 trinity-fips:local bash -c ' diff --git a/docs/fips-leg/supports-default.txt b/docs/fips-leg/supports-default.txt index 6625421..8524703 100644 --- a/docs/fips-leg/supports-default.txt +++ b/docs/fips-leg/supports-default.txt @@ -1,7 +1,7 @@ fips_mode: :not_enabled fips_provider_available: true cryptolib_version_linked: OpenSSL 3.5.8 25 Aug 2026 -otp_crypto_version: 5.8.3.2 +otp_crypto_version: 5.8.3.3 ciphers: :aes_128_cbc ciphers: :aes_128_ccm ciphers: :aes_128_cfb128 diff --git a/docs/fips-leg/supports-fips.txt b/docs/fips-leg/supports-fips.txt index b01f06e..13246f1 100644 --- a/docs/fips-leg/supports-fips.txt +++ b/docs/fips-leg/supports-fips.txt @@ -1,7 +1,7 @@ fips_mode: :enabled fips_provider_available: true cryptolib_version_linked: OpenSSL 3.5.8 25 Aug 2026 -otp_crypto_version: 5.8.3.2 +otp_crypto_version: 5.8.3.3 ciphers: :aes_128_cbc ciphers: :aes_128_ccm ciphers: :aes_128_cfb128 diff --git a/docs/packaging.md b/docs/packaging.md index 514d6fe..af96d6e 100644 --- a/docs/packaging.md +++ b/docs/packaging.md @@ -16,7 +16,7 @@ covers is the half a command can answer: a single binary that boots, serves and | Tool | Version | Pinned in | Why exactly this | |---|---|---|---| -| Erlang/OTP | 28.5.0.5 | `.tool-versions` | The newest OTP whose ERTS Burrito can fetch for all four targets. See `docs/adr/0005-otp-pin-driven-by-packaging.md`. | +| Erlang/OTP | 28.5.0.6 | `.tool-versions` | The newest OTP whose ERTS Burrito can fetch for every target (`docs/adr/0005-otp-pin-driven-by-packaging.md`). It carries CVE-2026-89422, so every outbound TLS client is held to TLS 1.2 until a daily canary moves the pin to the container images' OTP, 28.5.0.7 (`docs/adr/0014-otp-pins-split.md`). | | Elixir | 1.20.4-otp-28 | `.tool-versions` | | | Zig | **exactly** 0.16.0 | `.tool-versions` | Burrito 1.6.0 compares for equality, not a range, and exits 1 on anything else. Installed with the asdf zig plugin. | | Rust | 1.92.0 | `rust-toolchain.toml` | Only for the Tauri shell. **Not** `.tool-versions`: asdf here has no rust plugin and ignores such a line silently, so it would be a pin that pins nothing. `rustup` honours this file. | diff --git a/docs/regulated/headless-image.md b/docs/regulated/headless-image.md index 8ca6904..8a1d711 100644 --- a/docs/regulated/headless-image.md +++ b/docs/regulated/headless-image.md @@ -44,8 +44,9 @@ network, playing the part of Iron Bank's prebuild step; it is not part of the su `openssl-libs`, which brings the CA trust store with it as `ca-certificates`), installed with `dnf --installroot` so the image's rpm database stays true to what is on disk. No package manager, compiler or shell history. -* **Erlang/OTP 28**, built from source with FIPS support against that OpenSSL - (`docs/fips-leg.md`). +* **Erlang/OTP 28**, the version `ci/container.tool-versions` names (28.5.0.7), built from source + with FIPS support against that OpenSSL (`docs/fips-leg.md`). The container's OTP is pinned apart + from the desktop's, which waits for Burrito's builds; `docs/adr/0014-otp-pins-split.md` says why. * **The Trinity release**, under `/opt/trinity`, owned by root and not writable at run time. It runs as UID 10001. The only path it writes is `/data` (the data directory, a volume) and `/tmp`; @@ -121,8 +122,12 @@ of it against repo1's intake requirements, are the maintainer's. ## Known gaps, stated -* **The OTP runtime has open advisories** fixed in OTP 28.5.0.7, including a Critical one in `ssl`. - They are not justified, so the vulnerability check fails on the image until the toolchain moves. +* **The OTP runtime's advisories are fixed, not justified.** The image builds OTP 28.5.0.7, and + grype, which catalogs `erlang 28.5.0.7` from the runtime in the image, reports no advisory against + it. Measured on 2026-10-08 with one vulnerability database (built 2026-10-08T06:33:47Z) against both + images: the previous build, on 28.5.0.5, had 14 High or Critical findings in the runtime, among + them CVE-2026-89422 in `ssl`; this one has none. The desktop build runs an older OTP for now + (`docs/adr/0014-otp-pins-split.md`); this image does not depend on it. * **The release still carries the local embedder** (`exla` and the XLA library, `tokenizers`) and some build-time libraries. Removing them from the regulated image is a change to what the release contains, made separately; it removes resources from the manifest and rows from the allowances, diff --git a/docs/regulated/stig-applicability.md b/docs/regulated/stig-applicability.md index c41b875..458ac90 100644 --- a/docs/regulated/stig-applicability.md +++ b/docs/regulated/stig-applicability.md @@ -12,12 +12,12 @@ the image. It is not a STIG compliance determination for any host, and it is not | | | |---|---| -| Image | trinity-headless:hardened (scripts/headless_image.sh build, 2026-10-08) | -| Image ID | sha256:13a03661dc98472b9cbc27c653ef01f87b21ebf30aa6691334a011b88d6b8bba | +| Image | trinity-headless:otp7 (scripts/headless_image.sh build, 2026-10-08) | +| Image ID | sha256:1d2dcfe0fc9a62eb6b29e84ec65cd98fa4983aef771ca7a1b5616b536e298f58 | | Profile | `xccdf_org.ssgproject.content_profile_stig` | | SCAP Security Guide | 0.1.82 (`ssg-rhel9-ds.xml`) | | Scanner | OpenSCAP 1.3.14 | -| Evaluated | 2026-10-08T06:49:27+00:00 | +| Evaluated | 2026-10-08T13:42:22+00:00 | | Deriving commands | `scripts/stig_scan.sh IMAGE OUT`, then `mix trinity.image.stig --results OUT/stig-results.xml --out PATH --image IMAGE --digest IMAGE_ID` | 477 rules selected. OpenSCAP: 2 fail, 410 notapplicable, 1 notchecked, 64 pass. Dispositions: 65 met, diff --git a/lib/mix/tasks/trinity.ironbank.lint.ex b/lib/mix/tasks/trinity.ironbank.lint.ex index 8ab0470..2b10246 100644 --- a/lib/mix/tasks/trinity.ironbank.lint.ex +++ b/lib/mix/tasks/trinity.ironbank.lint.ex @@ -29,8 +29,13 @@ defmodule Mix.Tasks.Trinity.Ironbank.Lint do * **AC6a.** The submission holds only the entries Iron Bank's repository-structure page names, the four it requires among them; no `conf/` (`config/` is the spelling, slice 130 NOTES D-130-1); no `LABEL` in the Dockerfile; the manifest has the fields and labels Iron - Bank's schema requires; its version and its OTP and Elixir resources agree with `mix.exs` - and `.tool-versions`. + Bank's schema requires; its version agrees with `mix.exs`; its OTP resource is the OTP + `ci/container.tool-versions` declares and its Elixir resource the Elixir `.tool-versions` + pins, built for that OTP's major. + * **The two OTP pins (ADR-0014).** The container's OTP and the desktop's (`.tool-versions`) + share one major, because both run the same compiled tree and the same Elixir build, and the + container's is never the older: it is the pin that can move as soon as OTP publishes a + patch, while the desktop's waits for Burrito's ERTS builds. """ use Boundary, classify_to: Trinity @@ -39,6 +44,7 @@ defmodule Mix.Tasks.Trinity.Ironbank.Lint do @submission "ci/ironbank" @bases "ci/headless/bases.env" @holds "ci/headless/submission_holds.yaml" + @container_tools "ci/container.tool-versions" # Iron Bank's repository-structure page (docs-ironbank.dso.mil/hardening/repository-structure/, # read 2026-10-08), plus the `gpg` folder its Dockerfile requirements name for imported keys. @@ -71,6 +77,7 @@ defmodule Mix.Tasks.Trinity.Ironbank.Lint do bases_env: String.t(), holds: map(), tool_versions: String.t(), + container_tool_versions: String.t(), version: String.t() } @@ -84,7 +91,9 @@ defmodule Mix.Tasks.Trinity.Ironbank.Lint do case violations(tree, submission: Keyword.get(opts, :submission, false)) do [] -> - Mix.shell().info("trinity.ironbank.lint: OK (#{@submission}, #{@bases})") + Mix.shell().info( + "trinity.ironbank.lint: OK (#{@submission}, #{@bases}, #{@container_tools})" + ) found -> Enum.each(found, &Mix.shell().error("FAIL #{&1}")) @@ -107,6 +116,7 @@ defmodule Mix.Tasks.Trinity.Ironbank.Lint do bases_env: File.read!(Path.join(root, @bases)), holds: yaml!(Path.join(root, @holds)), tool_versions: File.read!(Path.join(root, ".tool-versions")), + container_tool_versions: File.read!(Path.join(root, @container_tools)), version: Mix.Project.config()[:version] } end @@ -510,7 +520,7 @@ defmodule Mix.Tasks.Trinity.Ironbank.Lint do args_violations(m["args"]), label_violations(m["labels"], tree.version), maintainer_violations(m["maintainers"]), - toolchain_violations(m, tree.tool_versions) + toolchain_violations(m, tree.tool_versions, tree.container_tool_versions) ]) end @@ -591,19 +601,61 @@ defmodule Mix.Tasks.Trinity.Ironbank.Lint do defp maintainer_violations(_), do: ["AC6a: the manifest names no maintainer"] - defp toolchain_violations(manifest, tool_versions) do + # The container builds the OTP `ci/container.tool-versions` names and the Elixir `.tool-versions` + # names; the desktop runs the OTP `.tool-versions` names. Until 2026-10-08 one file named both + # OTPs, and this rule required the manifest's OTP to be the desktop's, which made a container + # fix wait for Burrito's CDN (ADR-0014). + defp toolchain_violations(manifest, tool_versions, container_tool_versions) do files = manifest |> Map.get("resources", []) |> Enum.map(& &1["filename"]) - tools = env_pairs(String.replace(tool_versions, " ", "=")) - otp = tools["erlang"] - elixir = tools["elixir"] |> to_string() |> String.replace(~r/-otp-\d+$/, "") - major = otp |> to_string() |> String.split(".") |> hd() - - for {expected, what} <- [ - {"otp_src_#{otp}.tar.gz", "OTP #{otp}"}, - {"elixir-#{elixir}-otp-#{major}.zip", "Elixir #{elixir}"} - ], - expected not in files, - do: "AC6a: no resource #{expected}, the #{what} that .tool-versions names" + desktop = tool(tool_versions, "erlang") + otp = tool(container_tool_versions, "erlang") + elixir = tool_versions |> tool("elixir") |> String.replace(~r/-otp-\d+$/, "") + major = otp |> String.split(".") |> hd() + + missing = + for {expected, what} <- [ + {"otp_src_#{otp}.tar.gz", "OTP #{otp} that #{@container_tools}"}, + {"elixir-#{elixir}-otp-#{major}.zip", "Elixir #{elixir} that .tool-versions"} + ], + expected not in files, + do: "AC6a: no resource #{expected}, the #{what} names" + + missing ++ pin_violations(otp, desktop) + end + + defp tool(text, name), do: text |> String.replace(" ", "=") |> env_pairs() |> Map.get(name, "") + + defp pin_violations(container, desktop) do + with {:ok, c} <- otp_parts(container), + {:ok, d} <- otp_parts(desktop) do + cond do + hd(c) != hd(d) -> + [ + "ADR-0014: the container's OTP #{container} (#{@container_tools}) and the desktop's " <> + "#{desktop} (.tool-versions) are different majors; one compiled tree runs on both" + ] + + c < d -> + [ + "ADR-0014: the container's OTP #{container} (#{@container_tools}) is older than the " <> + "desktop's #{desktop} (.tool-versions); the container pin is the one that leads" + ] + + true -> + [] + end + else + _ -> + ["ADR-0014: #{@container_tools} and .tool-versions must each pin erlang to a version"] + end + end + + defp otp_parts(version) do + parts = String.split(version, ".") + + if parts != [] and Enum.all?(parts, &(&1 =~ ~r/^\d+$/)), + do: {:ok, Enum.map(parts, &String.to_integer/1)}, + else: :error end # Holds --------------------------------------------------------------------------------------- diff --git a/lib/mix/tasks/trinity.third_party_licenses.ex b/lib/mix/tasks/trinity.third_party_licenses.ex index d5757e3..4a706ae 100644 --- a/lib/mix/tasks/trinity.third_party_licenses.ex +++ b/lib/mix/tasks/trinity.third_party_licenses.ex @@ -35,6 +35,16 @@ defmodule Mix.Tasks.Trinity.ThirdPartyLicenses do `--bom` exists so a test can hand this task a planted bill instead of writing over the real one. The first version of that test did write over it, which is the kind of thing that works until two tests run at once. + + ## The one difference `--check` accepts + + The bill lists the Erlang/OTP applications of the runtime that generates it, and the committed file + is generated on the OTP `.tool-versions` pins. Since ADR-0014 the FIPS leg runs the container + images' OTP (`ci/container.tool-versions`), a newer patch of the same major, so its bill carries + newer versions of some OTP applications and nothing else changes. `--check` accepts exactly that: + the runtime's OTP is the container's pin and not the desktop's, and the file matches the bill once + the versions of the bill's `erlang.otp` components are read from the file. Any other difference + still fails. """ use Boundary, classify_to: Trinity @@ -79,7 +89,7 @@ defmodule Mix.Tasks.Trinity.ThirdPartyLicenses do rendered = render(rows) cond do - "--check" in argv -> check(rendered) + "--check" in argv -> check(rendered, rows) "--write" in argv -> write(rendered) true -> Mix.shell().info(rendered) end @@ -124,6 +134,7 @@ defmodule Mix.Tasks.Trinity.ThirdPartyLicenses do licence -> %{name: name, version: version(component), licence: licence, source: :bom, holder: nil} end + |> Map.put(:otp?, component["group"] == "erlang.otp") end # CycloneDX puts a resolvable identifier in `license.id` and anything else in `license.name` @@ -167,7 +178,9 @@ defmodule Mix.Tasks.Trinity.ThirdPartyLicenses do """ end - defp render(rows) do + @doc "The file `rows` render to: what `--write` writes and `--check` compares." + @spec render([map()]) :: String.t() + def render(rows) do declared = Enum.filter(rows, &(&1.source == :declared)) """ @@ -248,21 +261,85 @@ defmodule Mix.Tasks.Trinity.ThirdPartyLicenses do end end + @doc """ + `{:ok, names}` when `committed` differs from what `rows` render only in the versions of OTP + applications, and `pins` (`{runtime, desktop, container}` OTP versions) says the runtime is the + container's pin and not the desktop's; `names` are the applications whose version differs. + `:error` otherwise. + """ + @spec otp_drift(String.t(), [map()], {String.t() | nil, String.t() | nil, String.t() | nil}) :: + {:ok, [String.t()]} | :error + def otp_drift(committed, rows, pins) do + versions = + for [_, name, version] <- Regex.scan(~r/^\| `([^`]+)` \| `([^`]*)` \|/m, committed), + into: %{}, + do: {name, version} + + as_committed = Enum.map(rows, &committed_version(&1, versions)) + drifted = for r <- rows, r.otp?, versions[r.name] != r.version, do: r.name + + if container_runtime?(pins) and Enum.all?(as_committed, & &1.version) and + render(as_committed) == committed, + do: {:ok, drifted}, + else: :error + end + + defp committed_version(%{otp?: true} = row, versions), + do: %{row | version: Map.get(versions, row.name)} + + defp committed_version(row, _versions), do: row + + defp container_runtime?({runtime, desktop, container}), + do: runtime != nil and runtime == container and runtime != desktop + + # The running OTP (an OTP install carries `releases//OTP_VERSION`; the gate never runs in + # a release) and the two pins. + defp pins do + runtime = + case File.read( + Path.join([:code.root_dir(), "releases", System.otp_release(), "OTP_VERSION"]) + ) do + {:ok, text} -> String.trim(text) + {:error, _} -> nil + end + + {runtime, erlang_pin(".tool-versions"), erlang_pin("ci/container.tool-versions")} + end + + defp erlang_pin(path) do + with {:ok, text} <- File.read(path), + [_, version] <- Regex.run(~r/^erlang\s+(\S+)\s*$/m, text) do + version + else + _ -> nil + end + end + defp write(rendered) do File.write!(@artifact, rendered) Mix.shell().info("trinity.third_party_licenses: wrote #{@artifact}") end - defp check(rendered) do + defp check(rendered, rows) do case File.read(@artifact) do {:ok, ^rendered} -> Mix.shell().info("trinity.third_party_licenses: OK. #{@artifact} matches the bill") - {:ok, _other} -> - Mix.raise( - "#{@artifact} does not match the bill of materials. Run " <> - "`mix trinity.third_party_licenses --write` and commit the result." - ) + {:ok, committed} -> + case otp_drift(committed, rows, pins()) do + {:ok, names} -> + Mix.shell().info( + "trinity.third_party_licenses: OK. #{@artifact} matches the bill apart from the " <> + "versions of #{length(names)} OTP application(s) (#{Enum.join(names, ", ")}): " <> + "this runtime is the container images' OTP, the file is the desktop pin's (ADR-0014)" + ) + + :error -> + Mix.raise( + "#{@artifact} does not match the bill of materials. Run " <> + "`mix trinity.third_party_licenses --write` and commit the result." + ) + end {:error, :enoent} -> Mix.raise("#{@artifact} is missing. Run `mix trinity.third_party_licenses --write`.") diff --git a/lib/trinity.ex b/lib/trinity.ex index 71e9864..b0500b9 100644 --- a/lib/trinity.ex +++ b/lib/trinity.ex @@ -21,6 +21,9 @@ defmodule Trinity do exports: [ Paths, + # Owner decision D2 (ADR-0014): a client that needs transport options of its own takes + # them from here so the TLS 1.2 clamp survives (slices 071 and 072's gateways). + TLS, # Slice 025: the skills staging and the archive export seal through it. Vault, # Slice 080: the delegate tool and the session view's subagent panel call it. diff --git a/lib/trinity/application.ex b/lib/trinity/application.ex index 42a01dc..b331275 100644 --- a/lib/trinity/application.ex +++ b/lib/trinity/application.ex @@ -34,9 +34,15 @@ defmodule Trinity.Application do # without reading anything. verify_regulated_configuration!() + # ADR-0014: say once whether this runtime holds its TLS clients to TLS 1.2. + Trinity.TLS.log_decision() + children = desktop_children() ++ [ + # ADR-0014: the Finch instance Req's default options name while the runtime carries + # CVE-2026-89422. First, so it is running before anything can make a request. + Trinity.TLS.finch_child_spec(), TrinityWeb.Telemetry, # Slice 090: the Activity page's bounded buffer. Early, so it is listening before # anything it would want to have recorded has happened. diff --git a/lib/trinity/tls.ex b/lib/trinity/tls.ex new file mode 100644 index 0000000..5ad55fd --- /dev/null +++ b/lib/trinity/tls.ex @@ -0,0 +1,173 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.TLS do + @moduledoc """ + Keeps every outbound TLS client off TLS 1.3 on an OTP that carries CVE-2026-89422 + (ADR-0014). + + The advisory (GHSA-rgxr-4g4w-j875): OTP's TLS 1.3 client completes a handshake without + checking the server's certificate when the ServerHello carries a `pre_shared_key` the client + never offered, so anyone on the path can impersonate a model provider or an MCP server. Its + workaround is `{versions, ['tlsv1.2']}`, and it says no configuration keeps TLS 1.3 and + mitigates the issue. The fix is in OTP 28.5.0.7. The container images build that from + source; the desktop runs the ERTS Burrito downloads, which did not exist for 28.5.0.7 on Linux + or macOS when this was written, so the desktop runs 28.5.0.6 with this clamp. + + ## The decision is the running runtime's + + `affected?/0` asks the `ssl` application that is loaded, which is the component the CVE is in + and the one fact every runtime carries: an assembled release has no `OTP_VERSION` file + (slice 130 NOTES, D-130-11). On OTP 28 the fix is `ssl` 11.6.0.6 (`otp_versions.table`, + OTP-28.5.0.7). On a major this module has no fixed version for, it clamps. So the container on + 28.5.0.7 keeps TLS 1.3, the desktop on 28.5.0.6 clamps, and the clamp lifts itself when the + desktop's pin moves. + + ## Three layers, because one is not enough + + `client_config/0` is what `config/runtime.exs` applies before any application starts: + + * the `ssl` application's `protocol_version`, which every client that passes no `versions` + option inherits: `:httpc` (and so Bumblebee and Tokenizers), Postgrex, WebSockex, a bare + `:ssl.connect/3`; + * Req's default options, which send every Req request to `Trinity.TLS.Finch`, a Finch + instance whose pool passes the clamp to Mint. Mint passes `versions` to `ssl` itself, read + from `ssl:versions()`'s `available` list, which ignores the setting above, so for Req and + Finch only the transport options work; + * ReqLLM's Finch pool, which its streaming requests use, with the same transport options. + + `test/trinity/tls_clamp_test.exs` derives the clients from the release and checks each on the + wire. A caller that needs transport options of its own (a private CA, say) takes them from + `req_options/1`, which keeps the clamp; a caller that passes `connect_options` past it is + refused by Req, which will not combine a named pool with connect options. + """ + + require Logger + + @clamp [:"tlsv1.2"] + + # The first `ssl` version with the fix, per OTP major (otp_versions.table: OTP-28.5.0.7 ships + # ssl-11.6.0.6, OTP-28.5.0.6 ships ssl-11.6.0.5). The advisory also names 27.3.4.18 and 29.1.1; + # their `ssl` versions are not read here, so those majors clamp. + @fixed_ssl %{"28" => [11, 6, 0, 6]} + + @finch Trinity.TLS.Finch + + @doc "The `versions` every client is held to while `affected?/0`." + @spec clamp() :: [:ssl.tls_version()] + def clamp, do: @clamp + + @doc "The Finch instance Req's default options name while `affected?/0`." + @spec finch() :: atom() + def finch, do: @finch + + @doc """ + Whether the running `ssl` application carries CVE-2026-89422. + The two-argument form takes the OTP major and the `ssl` version so every branch is testable. + """ + @spec affected?() :: boolean() + def affected?, do: affected?(System.otp_release(), ssl_version()) + + @spec affected?(String.t(), String.t()) :: boolean() + def affected?(otp_major, ssl_version) do + with {:ok, fixed} <- Map.fetch(@fixed_ssl, otp_major), + {:ok, running} <- parse(ssl_version) do + running < fixed + else + _ -> true + end + end + + @doc "The loaded `ssl` application's version, as a string." + @spec ssl_version() :: String.t() + def ssl_version do + _ = Application.load(:ssl) + :ssl |> Application.spec(:vsn) |> to_string() + end + + @doc "Mint transport options: the clamp while `affected?/0`, nothing otherwise." + @spec transport_opts() :: keyword() + def transport_opts, do: if(affected?(), do: [versions: @clamp], else: []) + + @doc """ + The application configuration `config/runtime.exs` applies, as `{app, key, value}`. Empty + when the runtime is not affected, so a fixed runtime is configured exactly as before. + """ + @spec client_config() :: [{atom(), atom(), term()}] + def client_config, do: client_config(affected?()) + + @spec client_config(boolean()) :: [{atom(), atom(), term()}] + def client_config(false), do: [] + + def client_config(true) do + [ + {:ssl, :protocol_version, @clamp}, + {:req, :default_options, [finch: [name: @finch]]}, + {:req_llm, :finch, req_llm_finch()} + ] + end + + # ReqLLM's own pool configuration, read from ReqLLM so its sizes stay its own, with the + # transport options added to every pool. + defp req_llm_finch do + config = ReqLLM.Application.get_finch_config() + + Keyword.update!(config, :pools, fn pools -> + Map.new(pools, fn {key, pool} -> {key, with_clamp(pool)} end) + end) + end + + defp with_clamp(pool) do + Keyword.update(pool, :conn_opts, [transport_opts: [versions: @clamp]], fn conn -> + Keyword.update( + conn, + :transport_opts, + [versions: @clamp], + &Keyword.put(&1, :versions, @clamp) + ) + end) + end + + @doc """ + The child spec of `Trinity.TLS.Finch`: Req's own default pool options, plus the clamp while + `affected?/0`. Started by `Trinity.Application` before anything that makes a request. + """ + @spec finch_child_spec() :: Supervisor.child_spec() + def finch_child_spec do + pool = Req.Finch.pool_options(%{connect_options: [transport_opts: transport_opts()]}) + Finch.child_spec(name: @finch, pools: %{default: pool}) + end + + @doc """ + Req options for a caller that needs transport options of its own, such as `cacertfile`. + The clamp is merged last so it cannot be overridden; `finch: nil` lifts the default pool, which + Req will not combine with `connect_options`. + """ + @spec req_options(keyword()) :: keyword() + def req_options(transport_opts) do + [ + finch: nil, + connect_options: [transport_opts: Keyword.merge(transport_opts, transport_opts())] + ] + end + + @doc "Logs, once at boot, which way the decision went and why." + @spec log_decision() :: :ok + def log_decision do + if affected?() do + Logger.notice( + "TLS clients held to TLS 1.2: ssl #{ssl_version()} on OTP #{System.otp_release()} " <> + "carries CVE-2026-89422 (ADR-0014)" + ) + end + + :ok + end + + defp parse(version) do + parts = String.split(version, ".") + + if parts != [] and Enum.all?(parts, &(&1 =~ ~r/^\d+$/)), + do: {:ok, Enum.map(parts, &String.to_integer/1)}, + else: :error + end +end diff --git a/lib/trinity/versions.ex b/lib/trinity/versions.ex index 64813bd..33ce00d 100644 --- a/lib/trinity/versions.ex +++ b/lib/trinity/versions.ex @@ -57,11 +57,19 @@ defmodule Trinity.Versions do @toolchain [ %{ name: "Erlang/OTP", - pin: "**28.5.0.5**", + pin: "**28.5.0.6**", lock: nil, - from: {:file, ".tool-versions", "erlang 28.5.0.5"}, + from: {:file, ".tool-versions", "erlang 28.5.0.6"}, note: - "Measured at Slice 000, not read from a README: Burrito 1.6.0's ERTS resolver names one artifact source per target, and 28.5.0.5 is the newest OTP returning 200 on all four (macOS universal, Linux x86_64, Linux aarch64, Windows). 28.5.0.6 is released but its macOS and Linux artifacts are unbuilt (404). OTP 29 is 404 on macOS and both Linux arches. ⚠️ Windows tracks OTP releases immediately while the other three lag a third-party CDN's build queue, so re-probe at every phase boundary. See ADR-0005's second correction." + "The desktop build and the gate. Pinned to the newest OTP whose ERTS Burrito 1.6.0 can fetch for all three targets (macOS universal, Linux x86_64, Windows), measured by probing Burrito's artifact sources, not read from a README (ADR-0005). Was 28.5.0.5 from Slice 000. 28.5.0.6 since 2026-10-08 (owner decision D2, ADR-0014): 200 on all three; 28.5.0.7, which fixes CVE-2026-89422, is 404 on Burrito's CDN for Linux and macOS, so this runtime carries that CVE and `Trinity.TLS` holds every outbound TLS client to TLS 1.2 while it runs. `.github/workflows/otp-canary.yml` probes the CDN daily and opens the pull request that moves this pin to the container's. ⚠️ Windows tracks OTP releases immediately while the other two lag a third-party CDN's build queue." + }, + %{ + name: "Erlang/OTP (container images)", + pin: "**28.5.0.7**", + lock: nil, + from: {:file, "ci/container.tool-versions", "erlang 28.5.0.7"}, + note: + "Added 2026-10-08 (owner decision D2, ADR-0014): the OTP the hardened headless image (`ci/ironbank/`) and the FIPS leg's image (`ci/fips/Containerfile`) build from source, so it does not wait for Burrito. 28.5.0.7 (released 2026-09-22) fixes CVE-2026-89422 (ssl, Critical) and CVE-2026-65634 (asn1, High). The source archive's SHA-256, `ddf17db6d3e9b7a7cfac0d72238ddc8ea040fedc5e3dfad82fc90675319d6c93`, was read from the release's SHA256.txt and its asset digest and matched a download. `mix trinity.ironbank.lint` holds the manifest to this file, and this pin to the desktop's major and to no older than it." }, %{ name: "Elixir", @@ -111,7 +119,7 @@ defmodule Trinity.Versions do {:file, "ci/fips/Containerfile", "registry.access.redhat.com/ubi9/ubi@sha256:9295c5c688f487fa5cf27a734fa55ecd57aeb7dc0904ba537da4f42dfa1d0acb"}, note: - "Added at Slice 003: the base of the FIPS build leg's image (docs/fips-leg.md), `registry.access.redhat.com/ubi9/ubi:latest` resolved by digest on 2026-09-20 (Red Hat Enterprise Linux release 9.8, `openssl-libs 3.5.8-1.el9_8`, `openssl-fips-provider-so 3.0.7-11.el9_8`). The image itself is OTP 28.5.0.5 built from source with `--enable-fips` against that OpenSSL, plus Elixir 1.20.4; the two archives are pinned by SHA-256 in the Containerfile and the image tag is `scripts/fips_image_tag.sh` over `.tool-versions` and the Containerfile. The FIPS provider the image runs is what the distribution ships and names; docs/fips-leg.md states what Trinity does and does not claim about it." + "Added at Slice 003: the base of the FIPS build leg's image (docs/fips-leg.md), `registry.access.redhat.com/ubi9/ubi:latest` resolved by digest on 2026-09-20 (Red Hat Enterprise Linux release 9.8, `openssl-libs 3.5.8-1.el9_8`, `openssl-fips-provider-so 3.0.7-11.el9_8`). The image itself is OTP built from source with `--enable-fips` against that OpenSSL (the container pin, `ci/container.tool-versions`: 28.5.0.5 until 2026-10-08, 28.5.0.7 since), plus Elixir 1.20.4; the two archives are pinned by SHA-256 in the Containerfile and the image tag is `scripts/fips_image_tag.sh` over `.tool-versions`, `ci/container.tool-versions` and the Containerfile. The FIPS provider the image runs is what the distribution ships and names; docs/fips-leg.md states what Trinity does and does not claim about it." }, %{ name: "Headless image runtime base (UBI9 micro)", diff --git a/scripts/fips_image_tag.sh b/scripts/fips_image_tag.sh index 2f569e4..99f6fc2 100755 --- a/scripts/fips_image_tag.sh +++ b/scripts/fips_image_tag.sh @@ -2,16 +2,17 @@ # SPDX-FileCopyrightText: Sudo Apt Holdings LLC # SPDX-License-Identifier: Apache-2.0 # -# The FIPS leg image's tag: the first sixteen hex digits of the SHA-256 over the two files -# that define the image (slice 003, docs/fips-leg.md). The image workflow pushes under it and +# The FIPS leg image's tag: the first sixteen hex digits of the SHA-256 over the files that +# define the image (slice 003, docs/fips-leg.md): .tool-versions (Elixir), ci/container.tool-versions +# (OTP, ADR-0014) and the Containerfile. The image workflow pushes under it and # the gate leg pulls by it, so the two can never disagree about which image a tree wants, and -# a change to either file is a new tag, never a rebuild under an old one. +# a change to any of them is a new tag, never a rebuild under an old one. # # scripts/fips_image_tag.sh prints the tag # scripts/fips_image_tag.sh --ref prints the full image reference set -eu cd "$(dirname "$0")/.." -tag=$(cat .tool-versions ci/fips/Containerfile | sha256sum | cut -c1-16) +tag=$(cat .tool-versions ci/container.tool-versions ci/fips/Containerfile | sha256sum | cut -c1-16) case "${1:-}" in --ref) printf 'ghcr.io/scriptkittyos/trinity-fips:%s\n' "$tag" ;; "") printf '%s\n' "$tag" ;; diff --git a/scripts/otp_canary.sh b/scripts/otp_canary.sh new file mode 100755 index 0000000..14ef396 --- /dev/null +++ b/scripts/otp_canary.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +# +# The OTP canary (owner decision D2, ADR-0014). The container images run the OTP that +# ci/container.tool-versions names; the desktop runs the one .tool-versions names, which can only +# be a version whose ERTS Burrito can download for every desktop target. This asks whether that +# is true yet of the container's OTP, and when it is, moves the desktop pin to it. +# +# scripts/otp_canary.sh probe one line per target URL, then `result=agree|missing|ready` and +# `version=...`, in the key=value form $GITHUB_OUTPUT takes +# scripts/otp_canary.sh flip writes the container's OTP into .tool-versions and the +# Erlang/OTP row of lib/trinity/versions.ex (then run +# `mix versions.gen`) +# +# The URL templates are Burrito 1.6.0's (deps/burrito/lib/util/erts_universal_machine_fetcher.ex); +# test/otp_canary_test.exs fails if they drift from that file. The targets are mix.exs's three: +# linux_x86_64 and macos_aarch64 from Burrito's CDN (macOS takes the universal build), and +# windows_x86_64 from the OTP release page. +# +# `probe` reaches the network; `flip` does not. TRINITY_REPO names another checkout to act on. +set -euo pipefail +cd "${TRINITY_REPO:-$(dirname "$0")/..}" + +linux_url='https://beam-machine-universal.b-cdn.net/OTP-{OTP_VERSION}/linux/{ARCH}/any/otp_{OTP_VERSION}_linux_any_{ARCH}.tar.gz' +mac_url='https://beam-machine-universal.b-cdn.net/OTP-{OTP_VERSION}/macos/universal/otp_{OTP_VERSION}_macos_universal.tar.gz' +windows_url='https://github.com/erlang/otp/releases/download/OTP-{OTP_VERSION}/otp_win64_{OTP_VERSION}.exe' + +erlang_pin() { awk '$1 == "erlang" { print $2 }' "$1"; } + +container=$(erlang_pin ci/container.tool-versions) +desktop=$(erlang_pin .tool-versions) +[ -n "$container" ] && [ -n "$desktop" ] || { + echo "otp_canary: ci/container.tool-versions and .tool-versions must each pin erlang" >&2 + exit 2 +} + +url() { # template arch + local u=${1//\{OTP_VERSION\}/$container} + printf '%s\n' "${u//\{ARCH\}/$2}" +} + +probe() { + if [ "$container" = "$desktop" ]; then + echo "result=agree" + echo "version=$container" + return 0 + fi + local missing=0 status u + for u in "$(url "$linux_url" x86_64)" "$(url "$mac_url" -)" "$(url "$windows_url" -)"; do + status=$(curl -s -o /dev/null -I -L --retry 2 --max-time 30 -w '%{http_code}' "$u" || true) + echo "probe $status $u" >&2 + [ "$status" = 200 ] || missing=1 + done + if [ "$missing" = 0 ]; then echo "result=ready"; else echo "result=missing"; fi + echo "version=$container" +} + +# Exact replacements, each refused unless its old text occurs exactly once, and all checked +# before any file is written, so a tree that has drifted from what this script expects fails +# here rather than being half-edited. Arguments: file old new, repeated. +replace_all_once() { + python3 -I - "$@" <<'EOF' +import sys +args = sys.argv[1:] +edits = [args[i:i + 3] for i in range(0, len(args), 3)] +texts = {} +for path, old, new in edits: + text = texts.setdefault(path, open(path, encoding="utf-8").read()) + count = text.count(old) + if count != 1: + sys.exit(f"otp_canary: {path}: expected {old!r} once, found it {count} times; nothing written") + texts[path] = text.replace(old, new) +for path, text in texts.items(): + open(path, "w", encoding="utf-8").write(text) +EOF +} + +flip() { + if [ "$container" = "$desktop" ]; then + echo "otp_canary: the pins already agree at $container; nothing to flip" >&2 + exit 0 + fi + replace_all_once \ + .tool-versions "erlang $desktop" "erlang $container" \ + lib/trinity/versions.ex "pin: \"**$desktop**\"" "pin: \"**$container**\"" \ + lib/trinity/versions.ex "{:file, \".tool-versions\", \"erlang $desktop\"}" \ + "{:file, \".tool-versions\", \"erlang $container\"}" + echo "otp_canary: desktop pin $desktop -> $container" +} + +case "${1:-}" in + probe) probe ;; + flip) flip ;; + *) echo "usage: $0 probe|flip" >&2; exit 2 ;; +esac diff --git a/test/ironbank_submission_test.exs b/test/ironbank_submission_test.exs index c13ce03..c6454fa 100644 --- a/test/ironbank_submission_test.exs +++ b/test/ironbank_submission_test.exs @@ -275,17 +275,59 @@ defmodule IronbankSubmissionTest do assert "AC6a: label org.opencontainers.image.version is not mix.exs's version 9.9.9" in found end - test "an OTP resource that disagrees with .tool-versions is refused" do + test "an OTP resource that disagrees with the container's declared OTP is refused" do tree = %{ @tree - | tool_versions: String.replace(@tree.tool_versions, "erlang 28.5.0.5", "erlang 28.5.0.7") + | container_tool_versions: + String.replace(@tree.container_tool_versions, "erlang 28.5.0.7", "erlang 28.5.0.8") } assert_only( tree, - ~r/^AC6a: no resource otp_src_28\.5\.0\.7\.tar\.gz, the OTP 28\.5\.0\.7 that \.tool-versions names/ + ~r/^AC6a: no resource otp_src_28\.5\.0\.8\.tar\.gz, the OTP 28\.5\.0\.8 that ci\/container\.tool-versions names/ ) end + + # ADR-0014: the desktop pin moves when Burrito's CDN has an ERTS, and the submission does not + # follow it. Before the split, this plant failed the tree. + test "the desktop pin moving alone leaves the submission as it is" do + tree = %{ + @tree + | tool_versions: String.replace(@tree.tool_versions, "erlang 28.5.0.6", "erlang 28.5.0.5") + } + + assert violations(tree) == [] + end + + test "a container OTP of another major than the desktop's is refused" do + tree = %{ + @tree + | tool_versions: + String.replace(@tree.tool_versions, "erlang 28.5.0.6", "erlang 27.3.4.18") + } + + assert_only(tree, ~r/^ADR-0014: the container's OTP 28\.5\.0\.7 .* are different majors/) + end + + test "a container OTP older than the desktop's is refused" do + tree = %{ + @tree + | tool_versions: + String.replace(@tree.tool_versions, "erlang 28.5.0.6", "erlang 28.5.0.10") + } + + assert_only( + tree, + ~r/^ADR-0014: the container's OTP 28\.5\.0\.7 .* is older than the desktop's 28\.5\.0\.10/ + ) + end + + test "a container pin that is not a version is refused" do + tree = %{@tree | container_tool_versions: "# nothing pinned\n"} + found = violations(tree) + + assert "ADR-0014: ci/container.tool-versions and .tool-versions must each pin erlang to a version" in found + end end describe "holds" do diff --git a/test/otp_canary_test.exs b/test/otp_canary_test.exs new file mode 100644 index 0000000..cfcaea8 --- /dev/null +++ b/test/otp_canary_test.exs @@ -0,0 +1,106 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.OtpCanaryTest do + @moduledoc """ + `scripts/otp_canary.sh` and `.github/workflows/otp-canary.yml` (owner decision D2, ADR-0014). + The probe's network half runs only in CI; what is checked here is what it would ask about and + what its flip writes. + """ + + use ExUnit.Case, async: true + + @script "scripts/otp_canary.sh" + @fetcher "deps/burrito/lib/util/erts_universal_machine_fetcher.ex" + + test "the probe asks for the URLs Burrito itself fetches" do + script = File.read!(@script) + fetcher = File.read!(@fetcher) + + for {var, attr} <- [linux_url: "@linux_url", mac_url: "@mac_url", windows_url: "@windows_url"] do + [_, ours] = Regex.run(~r/^#{var}='([^']+)'$/m, script) + [_, burritos] = Regex.run(~r/^\s*#{attr} "([^"]+)"$/m, fetcher) + assert ours == burritos, "#{var} in #{@script} is not #{attr} in #{@fetcher}" + end + end + + test "the probe covers every desktop target mix.exs names" do + targets = Mix.Project.config()[:releases][:desktop][:burrito][:targets] + assert Enum.sort(Keyword.keys(targets)) == [:linux_x86_64, :macos_aarch64, :windows_x86_64] + script = File.read!(@script) + + assert script =~ + ~s{"$(url "$linux_url" x86_64)" "$(url "$mac_url" -)" "$(url "$windows_url" -)"} + end + + # The copy's container pin is a version no file names, so the test holds whatever the two pins + # are today, including after a flip has made them agree. + @next "28.5.0.99" + + @tag :tmp_dir + test "flip moves the desktop pin to the container's, and only that", %{tmp_dir: dir} do + desktop = erlang(File.read!(".tool-versions")) + copy_tree(dir) + assert {out, 0} = run("flip", dir) + assert out =~ "desktop pin #{desktop} -> #{@next}" + + assert erlang(File.read!(Path.join(dir, ".tool-versions"))) == @next + versions = File.read!(Path.join(dir, "lib/trinity/versions.ex")) + assert versions =~ ~s|pin: "**#{@next}**"| + assert versions =~ ~s|from: {:file, ".tool-versions", "erlang #{@next}"}| + refute versions =~ ~s|"erlang #{desktop}"| + + # Everything else in the pin list is as it was. + assert String.replace(versions, @next, desktop) == File.read!("lib/trinity/versions.ex") + + assert {out, 0} = run("flip", dir) + assert out =~ "already agree" + end + + @tag :tmp_dir + test "flip writes nothing when the tree has drifted from what it expects", %{tmp_dir: dir} do + desktop = erlang(File.read!(".tool-versions")) + copy_tree(dir) + path = Path.join(dir, "lib/trinity/versions.ex") + + File.write!( + path, + String.replace(File.read!(path), ~s|pin: "**#{desktop}**"|, ~s|pin: "#{desktop}"|) + ) + + assert {out, status} = run("flip", dir) + assert status != 0 + assert out =~ "nothing written" + assert File.read!(Path.join(dir, ".tool-versions")) == File.read!(".tool-versions") + end + + test "the workflow runs daily and on demand, and opens at most one pull request per version" do + workflow = File.read!(".github/workflows/otp-canary.yml") + assert workflow =~ ~r/schedule:\n\s+- cron: "[^"]+"/ + assert workflow =~ "workflow_dispatch:" + assert workflow =~ "scripts/otp_canary.sh probe" + assert workflow =~ "scripts/otp_canary.sh flip" + assert workflow =~ ~s{gh pr list --head "$BRANCH" --state all} + end + + defp copy_tree(dir) do + for path <- [".tool-versions", "lib/trinity/versions.ex"] do + File.mkdir_p!(Path.dirname(Path.join(dir, path))) + File.cp!(path, Path.join(dir, path)) + end + + File.mkdir_p!(Path.join(dir, "ci")) + File.write!(Path.join(dir, "ci/container.tool-versions"), "erlang #{@next}\n") + end + + defp erlang(text) do + [_, version] = Regex.run(~r/^erlang (\S+)$/m, text) + version + end + + defp run(command, dir) do + System.cmd("bash", [@script, command], + env: [{"TRINITY_REPO", dir}], + stderr_to_stdout: true + ) + end +end diff --git a/test/trinity/authority/selection_test.exs b/test/trinity/authority/selection_test.exs index bb8b463..35706d5 100644 --- a/test/trinity/authority/selection_test.exs +++ b/test/trinity/authority/selection_test.exs @@ -106,9 +106,16 @@ defmodule Trinity.Authority.SelectionTest do # /mcp, and the client's pool keeps the connection alive past its test (found on run # 35676874669, one ordering of the postgres job). The census asks about connections this # application opened, and the endpoint opened none of those. + # + # ADR-0014 (2026-10-08): Req's requests now go through `Trinity.TLS.Finch`, a Finch instance + # under this application's supervisor, where until then they went through `Req.Finch`, under + # Req's. Its pools keep a connection alive past the test that made it (another suite's request + # to its own loopback server), and that connection used to fall outside this census by its + # application alone. It is the shared HTTP client, not an authority adapter, so it stays outside. trinity_peers = - Enum.filter(peers, fn {_, _, %{app: app, initial_call: call}} -> - app == {:ok, :trinity} and not match?({Bandit.DelegatingHandler, _, _}, call) + Enum.filter(peers, fn {_, _, %{app: app, initial_call: call} = who} -> + app == {:ok, :trinity} and not match?({Bandit.DelegatingHandler, _, _}, call) and + Trinity.TLS.Finch.Supervisor not in (who.ancestors || []) end) for {port, peer, %{initial_call: call} = who} <- trinity_peers do diff --git a/test/trinity/third_party_licenses_test.exs b/test/trinity/third_party_licenses_test.exs index b4ddf50..36e6a93 100644 --- a/test/trinity/third_party_licenses_test.exs +++ b/test/trinity/third_party_licenses_test.exs @@ -98,4 +98,53 @@ defmodule ThirdPartyLicensesTest do "a row's licence came from nowhere nameable: #{row}" end end + + describe "otp_drift/3, the one difference --check accepts (ADR-0014)" do + defp otp_component(name, version) do + hex_component(name, "Apache-2.0") + |> Map.merge(%{"version" => version, "group" => "erlang.otp"}) + end + + setup do + desktop = [otp_component("ssl", "11.6.0.5"), hex_component("jason", "Apache-2.0")] + container = [otp_component("ssl", "11.6.0.6"), hex_component("jason", "Apache-2.0")] + %{desktop: desktop, container: container} + end + + test "OTP application versions alone may differ, on the container's OTP", c do + text = render(c.desktop) + rows = Task.rows(bom(c.container)) + assert {:ok, ["ssl"]} = Task.otp_drift(text, rows, {"28.5.0.7", "28.5.0.6", "28.5.0.7"}) + end + + test "the same drift on the desktop's OTP fails: the file is stale there", c do + text = render(c.desktop) + rows = Task.rows(bom(c.container)) + assert :error = Task.otp_drift(text, rows, {"28.5.0.6", "28.5.0.6", "28.5.0.7"}) + assert :error = Task.otp_drift(text, rows, {nil, "28.5.0.6", "28.5.0.7"}) + end + + test "a difference in anything but an OTP application's version fails, on any OTP", c do + text = render(c.desktop) + + changed = [ + otp_component("ssl", "11.6.0.6"), + Map.put(hex_component("jason", "Apache-2.0"), "version", "9.9.9") + ] + + assert :error = + Task.otp_drift(text, Task.rows(bom(changed)), {"28.5.0.7", "28.5.0.6", "28.5.0.7"}) + + relicensed = [otp_component("ssl", "11.6.0.6"), hex_component("jason", "MIT")] + + assert :error = + Task.otp_drift( + text, + Task.rows(bom(relicensed)), + {"28.5.0.7", "28.5.0.6", "28.5.0.7"} + ) + end + + defp render(components), do: components |> bom() |> Task.rows() |> Task.render() + end end diff --git a/test/trinity/tls_clamp_test.exs b/test/trinity/tls_clamp_test.exs new file mode 100644 index 0000000..7b40fee --- /dev/null +++ b/test/trinity/tls_clamp_test.exs @@ -0,0 +1,440 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.TLSClampTest do + @moduledoc """ + The desktop's TLS 1.2 clamp (owner decision D2, ADR-0014): on an OTP that carries + CVE-2026-89422, a TLS 1.3 client that accepts a `pre_shared_key` it never offered skips the + server's certificate, so no BEAM client in the release may offer TLS 1.3 there. + + Three parts, so that "every client" is a population read from the release and not a list + someone remembered: + + 1. **The census.** Every module in the release's applications that calls `ssl:connect`, read + from the BEAM import tables, and every module outside Mint that opens a Mint connection. + Each is named here with the layer that clamps it. A new one fails until it is named; a + named one that is no longer found fails as stale. + 2. **The layers are in force.** The `ssl` application's `protocol_version`, every running + Finch instance's pools, and the Finch pool Req's defaults resolve to. + 3. **The probe.** A loopback listener reads the ClientHello each client path actually sends + and records the protocol versions it offers. That is the property itself, observed on the + wire, rather than a reading of configuration. + + The oracle for "this runtime carries the CVE" is the runtime's own `OTP_VERSION` file, read + here independently of `Trinity.TLS`, so the test does not grade the module by its own + answer. An assembled release has no such file (slice 130 NOTES, D-130-11); `mix test` runs on + an OTP install, which does. + """ + + use ExUnit.Case, async: false + + # The clients fail when the probe hangs up after their ClientHello, and some log it. + @moduletag :capture_log + + # The first OTP 28 patch with the fix (GHSA-rgxr-4g4w-j875; otp_versions.table, OTP-28.5.0.7). + @fixed_28 [28, 5, 0, 7] + @tls12 0x0303 + @tls13 0x0304 + @clamp [:"tlsv1.2"] + + # Every module outside the `ssl` application that calls ssl:connect/2,3,4, and the layer that + # keeps it off TLS 1.3. `:ssl_setting` is the `ssl` application's `protocol_version`, which an + # explicit `versions` option overrides; none of these passes one (D-130-12). `:finch_pool` is + # Mint, which always passes `versions` itself, so only a Finch pool's transport options reach it. + @openers %{ + :http_transport => :ssl_setting, + :httpc_handler => :ssl_setting, + Postgrex.Protocol => :ssl_setting, + WebSockex.Conn => :ssl_setting, + Mint.Core.Transport.SSL => :finch_pool + } + + # Every module outside Mint that opens a Mint connection. Finch's two take their transport + # options from the pool. ReqLLM's duplex session passes none and so offers TLS 1.3; it is the + # transport of `ReqLLM.Bedrock.NovaSonic`, and the reachability test below holds that no + # Trinity module reaches it. + @mint_callers %{ + Finch.HTTP1.Conn => :finch_pool, + Finch.HTTP2.Pool => :finch_pool, + ReqLLM.Streaming.HTTP2DuplexSession => :unreachable + } + + @mint_connect [{Mint.HTTP, :connect}, {Mint.HTTP1, :connect}, {Mint.HTTP2, :connect}] + + # Every module outside the `ssl` application whose code names TLS 1.3 (in its atom table or its + # literals). An explicit `versions` option overrides the `ssl` setting, so a client that names + # TLS 1.3 is a client the first layer does not reach; this is how one would show up. + @name_tls13 %{ + # Its default `versions`, which a Finch pool's transport options replace. + Mint.Core.Transport.SSL => :finch_pool, + # Configures TLS listeners (`Plug.SSL.configure/1`), a server; Trinity's endpoint has none. + Plug.SSL => :server, + # A Mix task that generates release files; a release carries no Mix to run it. + Mix.Tasks.Phx.Gen.Release => :mix_task + } + + # Req's own pool, started by Req with its default pool options. With the clamp in force no + # request reaches it: Req's defaults name the clamped pool, and a caller that passes + # `connect_options` gets a pool of its own (and must take them from `Trinity.TLS`). + @unclamped_finch [Req.Finch] + + describe "the census, from the release's BEAM files" do + test "every module that calls ssl:connect is named, with the layer that clamps it" do + found = + callers_of(fn {m, f, a} -> m == :ssl and f == :connect and a in 2..4 end, &(&1 != :ssl)) + + assert_census(found, @openers, "call ssl:connect") + end + + test "every module outside Mint that opens a Mint connection is named" do + found = callers_of(fn {m, f, _} -> {m, f} in @mint_connect end, &(&1 != :mint)) + assert_census(found, @mint_callers, "open a Mint connection") + end + + test "every module that names TLS 1.3 is named, with why it cannot offer it to a server" do + found = + for {app, mod, _imports} <- modules(), + app != :ssl, + String.contains?(code_names(mod), "tlsv1.3"), + uniq: true, + do: mod + + assert_census(found, @name_tls13, "name TLS 1.3") + end + + test "no Trinity module reaches the one Mint caller that takes no transport options" do + unreachable = for {mod, :unreachable} <- @mint_callers, do: mod + reaching = transitive_callers(MapSet.new(unreachable)) + ours = for {app, mod} <- reaching, app == :trinity, do: mod + + assert ours == [], + "Trinity reaches a client that offers TLS 1.3 regardless of the clamp: " <> + inspect(ours) + end + end + + describe "the layers, in this runtime" do + test "the ssl application's protocol_version is the clamp exactly when the runtime is affected" do + setting = Application.get_env(:ssl, :protocol_version) + + if affected?() do + assert setting == @clamp, + "OTP #{otp_version_string()} carries CVE-2026-89422 and ssl's protocol_version is " <> + inspect(setting) + else + refute setting == @clamp, "OTP #{otp_version_string()} is fixed and still clamped" + end + end + + test "every running Finch instance's pools carry the clamp when the runtime is affected" do + instances = finch_instances() + assert instances != [], "no running Finch instance found; the census found nothing" + + if affected?() do + unclamped = + for {name, config} <- instances, + name not in @unclamped_finch, + pool <- [config.default_pool_config | Map.values(config.pools)], + versions(pool) != @clamp, + do: {name, versions(pool)} + + assert unclamped == [], "Finch pools that can offer TLS 1.3: #{inspect(unclamped)}" + end + end + + test "Req's default request resolves to a clamped Finch instance when the runtime is affected" do + if affected?() do + name = Req.new() |> Map.fetch!(:options) |> Map.get(:finch, []) |> Keyword.get(:name) + assert name, "Req's default options name no Finch instance, so Req.Finch serves them" + assert {^name, config} = List.keyfind(finch_instances(), name, 0) + assert versions(config.default_pool_config) == @clamp + end + end + end + + describe "the probe: what each client offers on the wire" do + test "Req, the client every Trinity call site uses" do + assert_offer(fn port -> Req.get("https://127.0.0.1:#{port}/", retry: false) end) + end + + test "ReqLLM, a request without streaming" do + assert_offer(fn port -> + ReqLLM.generate_text("openai:gpt-4o-mini", "probe", + base_url: "https://127.0.0.1:#{port}/v1", + api_key: "probe", + max_retries: 0 + ) + end) + end + + test "ReqLLM, a streaming request (its own Finch pool)" do + assert_offer(fn port -> + with {:ok, response} <- + ReqLLM.stream_text("openai:gpt-4o-mini", "probe", + base_url: "https://127.0.0.1:#{port}/v1", + api_key: "probe", + max_retries: 0 + ) do + response |> ReqLLM.StreamResponse.tokens() |> Enum.take(1) + end + end) + end + + test ":httpc, the client Bumblebee and Tokenizers download through" do + {:ok, _} = Application.ensure_all_started(:inets) + + assert_offer(fn port -> + :httpc.request( + :get, + {~c"https://127.0.0.1:#{port}/", []}, + [ssl: [verify: :verify_none], timeout: 5_000], + [] + ) + end) + end + + test "a bare ssl:connect with no versions option" do + assert_offer(fn port -> + :ssl.connect(~c"127.0.0.1", port, [verify: :verify_none], 5_000) + end) + end + + test "WebSockex, the websocket client in the release" do + assert_offer(fn port -> + "wss://127.0.0.1:#{port}/" + |> WebSockex.Conn.new(ssl_options: [verify: :verify_none]) + |> WebSockex.Conn.open_socket() + end) + end + + test "Postgrex with TLS, after its SSLRequest" do + assert_offer( + fn port -> + {:ok, _} = + Postgrex.start_link( + hostname: "127.0.0.1", + port: port, + username: "probe", + password: "probe", + database: "probe", + ssl: [verify: :verify_none], + backoff_type: :stop, + pool_size: 1 + ) + + Process.sleep(:infinity) + end, + :postgres + ) + end + end + + ## The oracle + + defp otp_version_string do + [:code.root_dir(), "releases", System.otp_release(), "OTP_VERSION"] + |> Path.join() + |> File.read!() + |> String.trim() + end + + defp affected? do + version = otp_version_string() |> String.split(".") |> Enum.map(&String.to_integer/1) + hd(version) != 28 or version < @fixed_28 + end + + ## The census + + # The applications the release carries: the closure of :trinity's, kept to the ones this project + # builds or OTP provides. An optional dependency can resolve to an archive installed on the + # machine (igniter's `phx_new` did, here), which no release carries and CI does not have. + defp release_applications do + roots = [Mix.Project.build_path(), to_string(:code.root_dir())] + + [:trinity, :logger, :runtime_tools, :sasl] + |> walk(MapSet.new()) + |> Enum.filter(fn app -> + dir = :code.lib_dir(app) + is_list(dir) and String.starts_with?(to_string(dir), roots) + end) + end + + defp walk([], seen), do: seen + + defp walk([app | rest], seen) do + if MapSet.member?(seen, app) do + walk(rest, seen) + else + _ = Application.load(app) + + deps = + (Application.spec(app, :applications) || []) ++ + (Application.spec(app, :included_applications) || []) + + walk(deps ++ rest, MapSet.put(seen, app)) + end + end + + # {app, module, imports} for every module of every application in the release. + defp modules do + for app <- release_applications(), + mod <- Application.spec(app, :modules) || [], + path = :code.which(mod), + is_list(path), + {:ok, {^mod, [imports: imports]}} <- [:beam_lib.chunks(path, [:imports])], + do: {app, mod, imports} + end + + # The atom table and the literal table of a module's BEAM file, where a `versions: [...]` list + # lives; documentation chunks are left out, so a mention in a docstring does not count. + defp code_names(mod) do + {:ok, _, chunks} = mod |> :code.which() |> :beam_lib.all_chunks() + + literals = + for {~c"LitT", <>} <- chunks, + do: if(size == 0, do: data, else: :zlib.uncompress(data)) + + IO.iodata_to_binary(for({~c"AtU8", atoms} <- chunks, do: atoms) ++ literals) + end + + defp callers_of(match?, app_filter) do + for {app, mod, imports} <- modules(), + app_filter.(app), + Enum.any?(imports, match?), + uniq: true, + do: mod + end + + defp transitive_callers(targets) do + all = modules() + grow(all, targets, MapSet.new()) + end + + defp grow(all, frontier, seen) do + new = + for {app, mod, imports} <- all, + not MapSet.member?(seen, {app, mod}), + Enum.any?(imports, fn {m, _, _} -> MapSet.member?(frontier, m) end), + into: MapSet.new(), + do: {app, mod} + + if MapSet.size(new) == 0 do + MapSet.to_list(seen) + else + grow(all, MapSet.new(new, &elem(&1, 1)), MapSet.union(seen, new)) + end + end + + defp assert_census(found, named, what) do + unnamed = Enum.reject(found, &Map.has_key?(named, &1)) + stale = named |> Map.keys() |> Enum.reject(&(&1 in found)) + + assert unnamed == [], + "modules in the release that #{what} and are not in this census: #{inspect(unnamed)}. " <> + "Name each with the layer that keeps it off TLS 1.3, or clamp it (ADR-0014)." + + assert stale == [], + "named in this census but no longer found in the release: #{inspect(stale)}; remove them" + end + + ## Finch instances + + # A Finch instance is a Registry whose metadata carries its pool configuration + # (deps/finch/lib/finch.ex, init/1). Every registered name is tried; anything else is skipped. + defp finch_instances do + for name <- Process.registered(), + {:ok, %{default_pool_config: _} = config} <- [registry_config(name)], + do: {name, config} + end + + defp registry_config(name) do + Registry.meta(name, :config) + rescue + _ -> :error + end + + defp versions(pool) do + pool |> Map.get(:conn_opts, []) |> Keyword.get(:transport_opts, []) |> Keyword.get(:versions) + end + + ## The probe + + defp assert_offer(client, mode \\ :tls) do + offered = offer(client, mode) + + if affected?() do + refute @tls13 in offered, + "OTP #{otp_version_string()} carries CVE-2026-89422 and this client offered TLS 1.3 " <> + "(offered #{inspect(Enum.map(offered, &version_name/1))})" + + assert @tls12 in offered + else + assert @tls13 in offered == :"tlsv1.3" in :ssl.versions()[:available], + "OTP #{otp_version_string()} is fixed, and this client is still clamped" + end + end + + defp version_name(@tls13), do: :"tlsv1.3" + defp version_name(@tls12), do: :"tlsv1.2" + defp version_name(other), do: other + + # Starts a loopback listener, runs the client against it in a process of its own (the client + # fails when the listener hangs up, which is expected), and returns the versions the first + # ClientHello offered. + defp offer(client, mode) do + {:ok, listener} = + :gen_tcp.listen(0, [:binary, active: false, ip: {127, 0, 0, 1}, reuseaddr: true]) + + {:ok, port} = :inet.port(listener) + test = self() + {:ok, acceptor} = Task.start(fn -> accept(listener, test, mode) end) + :ok = :gen_tcp.controlling_process(listener, acceptor) + {:ok, runner} = Task.start(fn -> client.(port) end) + + receive do + {:client_hello, offered} -> + Process.exit(runner, :kill) + Process.exit(acceptor, :kill) + offered + after + 10_000 -> + Process.exit(runner, :kill) + Process.exit(acceptor, :kill) + flunk("no ClientHello reached the probe within 10 s") + end + end + + defp accept(listener, test, mode) do + {:ok, socket} = :gen_tcp.accept(listener) + if mode == :postgres, do: postgres_ssl_request(socket) + {:ok, <<22, _major, _minor, length::16>>} = :gen_tcp.recv(socket, 5, 5_000) + {:ok, record} = :gen_tcp.recv(socket, length, 5_000) + send(test, {:client_hello, client_hello_versions(record)}) + :gen_tcp.close(socket) + end + + # Postgres asks for TLS in the clear first: an 8-byte SSLRequest, answered with "S". + defp postgres_ssl_request(socket) do + {:ok, <<8::32, 80_877_103::32>>} = :gen_tcp.recv(socket, 8, 5_000) + :ok = :gen_tcp.send(socket, "S") + end + + # RFC 8446 section 4.1.2. A client that offers TLS 1.3 says so in supported_versions (43); + # without that extension, it offers only its legacy_version. + defp client_hello_versions( + <<1, _length::24, legacy::16, _random::binary-32, sid::8, _sid::binary-size(sid), + suites::16, _suites::binary-size(suites), comp::8, _comp::binary-size(comp), + ext_length::16, extensions::binary-size(ext_length), _::binary>> + ) do + case extension(extensions, 43) do + <> -> for <>, do: v + nil -> [legacy] + end + end + + defp extension(<>, type), do: data + + defp extension(<<_::16, n::16, _::binary-size(n), rest::binary>>, type), + do: extension(rest, type) + + defp extension(<<>>, _type), do: nil +end diff --git a/test/trinity/tls_test.exs b/test/trinity/tls_test.exs new file mode 100644 index 0000000..615227d --- /dev/null +++ b/test/trinity/tls_test.exs @@ -0,0 +1,77 @@ +# SPDX-FileCopyrightText: Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +defmodule Trinity.TLSTest do + @moduledoc """ + `Trinity.TLS`'s decision and the options it hands out (ADR-0014). What reaches the wire is + `test/trinity/tls_clamp_test.exs`'s; this is the module's own arithmetic. + """ + + use ExUnit.Case, async: true + + alias Trinity.TLS + + describe "affected?/2" do + test "OTP 28 is affected below ssl 11.6.0.6 and fixed from it" do + # otp_versions.table: OTP-28.5.0.5 ssl-11.6.0.4, OTP-28.5.0.6 ssl-11.6.0.5, + # OTP-28.5.0.7 ssl-11.6.0.6. + assert TLS.affected?("28", "11.6.0.4") + assert TLS.affected?("28", "11.6.0.5") + refute TLS.affected?("28", "11.6.0.6") + refute TLS.affected?("28", "11.6.0.10") + refute TLS.affected?("28", "11.7") + end + + test "the comparison is numeric, not a string comparison" do + # As strings, "11.6.0.10" sorts before "11.6.0.6". + refute TLS.affected?("28", "11.6.0.10") + assert TLS.affected?("28", "11.6.0") + end + + test "a major with no recorded fix, or an unreadable version, clamps" do + assert TLS.affected?("27", "11.2.12.9") + assert TLS.affected?("29", "11.9") + assert TLS.affected?("28", "") + assert TLS.affected?("28", "11.6.0.6-rc1") + end + end + + describe "client_config/1" do + test "a fixed runtime is configured exactly as before" do + assert TLS.client_config(false) == [] + end + + test "an affected runtime sets the ssl setting, Req's default pool and ReqLLM's pools" do + config = TLS.client_config(true) + assert {:ssl, :protocol_version, [:"tlsv1.2"]} in config + assert {:req, :default_options, [finch: [name: Trinity.TLS.Finch]]} in config + + {:req_llm, :finch, finch} = List.keyfind(config, :req_llm, 0) + assert finch[:name] == ReqLLM.Finch + + for {_key, pool} <- finch[:pools] do + assert pool[:conn_opts][:transport_opts][:versions] == [:"tlsv1.2"] + end + end + end + + describe "req_options/1" do + test "keeps a caller's transport options and the clamp, and lifts the named pool" do + options = TLS.req_options(cacertfile: "/tmp/ca.pem", versions: [:"tlsv1.3"]) + assert options[:finch] == nil + transport = options[:connect_options][:transport_opts] + assert transport[:cacertfile] == "/tmp/ca.pem" + + if TLS.affected?() do + assert transport[:versions] == [:"tlsv1.2"] + end + end + + test "Req refuses connect_options beside the default pool, so the clamp cannot be bypassed quietly" do + if TLS.affected?() do + assert_raise ArgumentError, ~r/cannot set both :finch and :connect_options/, fn -> + Req.get("https://127.0.0.1:1/", connect_options: [timeout: 100], retry: false) + end + end + end + end +end