Skip to content

Container image composition ​

Two container images are built from this repository: the shipped CLI image (Dockerfile) and the FIPS variant (Dockerfile.fips), published beside it under -fips tags. Both freeze their npm tree to the committed lockfile, pin their base image by digest, and install one reviewed set of OS packages each. This document records what each pins, what holds the pin, what those installs bring into the image, and the four properties of a built image that are measured by running it rather than by reading its instructions. Why the npm dependencies themselves are exact-pinned, and the per-stack upgrade checklists, are in DEPENDENCY_PINS.md.

The Docker image's dependency freeze ​

The shipped CLI image resolves no npm dependency at image-build time and installs nothing at container runtime; one OS package, named below, is fetched from the Alpine mirror while the image is built. The Dockerfile's builder stage installs with npm ci against the committed package-lock.json -- which installs exactly the locked tree, verifying each registry package against the lockfile's integrity hash, and fails the build if a manifest and the lockfile disagree -- then, after building, empties node_modules and re-installs it as rm -rf node_modules && npm ci --omit=dev --omit=optional -w packages/core -w apps/cli, and the runtime stage copies that node_modules unchanged. Every runtime dependency and transitive in the image is therefore the exact version in the committed lockfile: a rebuild without a lockfile change cannot re-resolve a caret range, and the image ships the same tree CI tested. The release SBOM covers a wider scope than this install -- step 9 in RELEASES.md runs npm sbom --sbom-format cyclonedx --package-lock-only --omit=dev -w packages/core -w apps/cli -w apps/web -- because the console server build this image copies (apps/web/dist/console-server) bundles apps/web's runtime dependencies, and the console client build (apps/web/dist/console) its browser ones, which the install scope above does not reach.

Why this is correctness-critical rather than hygiene: re2js executes the agreed linkage transforms' regexes that standardize values before PSI key derivation, so the two parties' regex engines must behave byte-identically or the derived keys silently mismatch -- no error, just missed matches. The freeze covers it and every other external the core CJS build resolves from node_modules at runtime (yaml, zod, ...), which their caret manifest ranges would otherwise leave free to re-resolve at image build time, letting two images of nominally the same release diverge. The vendored @openmined/psi.js tarball participates as the committed bytes in lib/, under the committed sha256 sidecar that stands in for a lockfile integrity hash (see DEPENDENCY_PINS.md).

The structural invariants are enforced by scripts/dockerfile-freeze.test.mjs (run by npm run test:scripts, a CI static check), over both Dockerfile and the FIPS variant's Dockerfile.fips alike. Every install is npm ci, the lockfile and the root .npmrc are copied into the builder before the first install, the builder's last npm command empties node_modules and passes both --omit=dev and --omit=optional, and the runtime stage runs no npm at all. Every stage builds from the reviewed base digest or from another stage of the same file, each file's OS-package installs are exactly the reviewed set, and the copied layout keeps the workspace links and the PSI worker entry where the CLI resolves them.

Every one of those is a property of the instructions rather than of the tree a build resolves. The test reads what the build is told to do, so the package set that ends up in the image is outside it -- which is why the command's shape is held part by part below, each part standing for a way the resolved tree and the instruction can disagree.

Those invariants are read off COPY and RUN, so the test refuses every instruction class outside a reviewed list, in either stage, rather than modeling it. Beside COPY and RUN the list admits ARG, ENTRYPOINT, ENV, EXPOSE, FROM, LABEL, USER and WORKDIR. ADD is the one that names itself: it fetches a remote source and takes the same --chown/--chmod flags COPY does, so it can both pull in a build input the lockfile does not pin and land files with an ownership no assertion here reads. A build that needs another class extends the test's reviewed list in the same diff.

What makes the rebuilt tree production-only. Both halves of the rebuild command answer a measurement. Driving the builder's installs against the committed lockfile over only the files the builder copies in -- outside the image, on npm 11.19.0 and Node 26.8.1 (linux/arm64), so the figures stand for the resolution rather than for a built image's own tree:

InstallTop-level packagesdu -sh
npm ci -w packages/core -w packages/peerjs-broker -w apps/cli -w apps/web634517M
then npm ci --omit=dev -w packages/core -w apps/cli over that tree553449M
the same command into an emptied tree123146M
the same command into an emptied tree, plus --omit=optional10198M

The tree is emptied explicitly because npm ci empties node_modules only when it is unscoped. A marker directory placed in the tree survives npm ci --omit=dev -w packages/core -w apps/cli and does not survive the same command with the -w flags dropped: scoped, npm reifies in place and removes only what the scope reaches, leaving the build's own dependencies -- eslint among them -- where the first install put them.

npm omits a package the lockfile flags dev and keeps one it flags devOptional. The lockfile flags vite dev, so the emptied tree holds no vite, and no rolldown or esbuild beneath it. The table's figures were taken against an earlier lockfile. Driven the same way on npm 11.19.1 against the committed one, the emptied-tree install holds no vite with or without --omit=optional, at 69 and 66 top-level entries; the flag's only effect is the omission below.

What that omission costs is ssh2's two optional edges, the only ones inside the packages/core plus apps/cli scope: cpu-features, its native CPU-detection addon (with buildcheck and nan beneath it), and nan, which ssh2 also declares optional directly. The omitted install holds none of the three (npm explain nan names both edges). ssh2 runs without them -- driven on the omitted tree, ssh2 1.17.0 completes a handshake, authentication and remote exec over loopback with cpu-features unresolvable.

The built image's /app/node_modules is measured against that scope by the production node_modules scope below, which is the resolved-tree half the freeze test cannot reach: every package the image carries must be one the same install resolves from the committed lockfile on the runner. What it does not reach is a package the resolution holds and the image lacks, and the apps/web console builds the runtime stage copies alongside, which are vite's own output rather than an npm install.

The node:26-alpine base image is digest-pinned in both stages to its multi-arch index digest, so the Node runtime and Alpine userland beneath the frozen node_modules are fixed across rebuilds. The tradeoff is that a base patch (a Node or musl fix) does not arrive on a rebuild of its own: bumping the base is a deliberate digest update. Pin the multi-arch index digest, not a platform-specific one, or the multi-platform release build cannot resolve every architecture; obtain it with docker buildx imagetools inspect node:26-alpine.

What makes that update deliberate is the freeze test, which holds the digest as a literal listed once per stage: a stage re-pinned onto another digest, dropped back to the floating tag, or collapsed onto the other stage reddens until the literal moves in the same diff. The builder's FROM is held as tightly as the runtime stage's, because the npm that resolves the tree the runtime stage ships is the one the builder's base holds -- a property of the digest rather than of anything in this repository, recorded in DEPENDENCY_PINS.md.

The one OS package the build fetches. The runtime stage runs apk add --no-cache samba-client, which is a dependency resolved at image-build time and the single exception to the paragraphs above. It is in the image rather than fetched when it is used because of what uses it: the Windows file-drop setup scripts (support/windows-network-filedrop/) run their SMB probe inside this image, and the networks that probe exists to diagnose are the ones where a container cannot reach the Alpine mirror at all. HTTPS interception there fails the fetch with

error:0A000086:SSL routines:tls_post_process_server_certificate:certificate verify failed
...
samba-client (no such package): required by: world[samba-client]

so a probe that has to install the package stops before it has tested anything. Installing it while the image is built moves that fetch onto the machine that publishes the image.

Unverified as of 2026-09-29: the package counts, sizes, versions and the apk error text in this section and the next were measured on earlier image builds. A documentation audit on that date had no Docker and could not reach the image registries, and did not re-run them.

It floats: the instruction names no version, so a rebuild takes whatever the mirror holds for the pinned base's Alpine release. Measured on the base digest pinned here (Alpine 3.24.2): samba-client-4.23.8-r0 and 44 dependencies, 45 packages newly present and none removed. apk's trailing OK: line reports the post-install total rather than the increment, so it reads OK: 11.1 MiB in 18 packages on the bare base and OK: 63.1 MiB in 63 packages after, on aarch64, where the install's image layer is 54,625,614 bytes. The same 63 packages resolve on x86_64, where the post-install total is 54.2 MiB, so the multi-arch release build is not left short a package. An exact version pin was rejected because Alpine has exactly one version of a package per release branch, so a pin hard-fails the build the moment the mirror supersedes it: apk add --no-cache --simulate samba-client=4.23.7-r0 on that base answers

ERROR: unable to select packages:
  samba-client-4.23.8-r0:
    breaks: world[samba-client=4.23.7-r0]

while =4.23.8-r0 resolves. A pin would therefore convert every upstream samba patch into a red build, costing more than the drift it prevents. What the float costs is that two rebuilds of the same commit can ship different samba-client versions, and that the package is outside the release SBOM, which npm sbom generates from the npm tree. That is acceptable here in a way it is not for re2js: nothing in an exchange reaches this package. It is used only by the setup-time probe, over a share the operator is testing, never by the CLI or the console during a run.

Two checks bound it. scripts/dockerfile-freeze.test.mjs holds every apk/apt/apt-get/dnf/microdnf/yum/pip/pip3 instruction in the file to a per-file list of literals, the way it holds the .npmrc COPY, so a second install or a wider spec on one of those lines reddens rather than shipping. It reads the whole file rather than the runtime stage alone, because both images build their runtime stage FROM an earlier stage of their own and a package installed there ships just as surely. A fetch by some other route -- curl and extract, rpm driven directly, or another language's package manager -- is outside what it sees. image_smoke.yaml asserts each built image provides smbclient (docker run --rm --entrypoint sh <image> -c 'command -v smbclient'), so a build that resolved the package away fails the pull request rather than an operator's setup run.

The FIPS variant image's pins ​

Dockerfile.fips builds a second image on Amazon Linux 2023 with the CMVP-validated OpenSSL FIPS provider AWS publishes for that distribution. It is published from the same release workflow under the default image's tags with -fips appended (RELEASES.md); why it exists, what may be claimed of it, and what stops working inside it are in fips-variant-image.md. Everything above about the npm freeze applies to it unchanged -- same lockfile, same npm ci, same production rebuild for the runtime tree, same freeze test. What follows is what it pins beyond that, and the second OS-package inventory that comes with it.

Six pins, five of them compared against the artifact inside the build.

PinValueHow it is held
Release snapshot--releasever=2023.12.20261028 on every dnf transactionShape-checked as a dated snapshot in scripts/dockerfile-freeze.test.mjs; compared against the base rootfs's own system-release version, asserted in the build
Provider package and versionopenssl-fips-provider-certified at 3.2.2-1.amzn2023rpm -qf on the installed fips.so, asserted in the build
Module version string3.2.2-799901ad7ab41d45openssl list -providers read back, asserted in the build
Base imagepublic.ecr.aws/amazonlinux/amazonlinux@sha256:d9c9967b3d895c4c423f54b6562fbf5d18a5b68f5df54908c29e88cba4efcc04, the multi-arch index digestNamed in the FROM instead of the tag; the literal held in scripts/dockerfile-freeze.test.mjs
Node runtime tarball, x64d28a96af6c38596f6d9d97c0ee531c13d238d757742387d1928de822d959e997sha256sum -c against the literal committed in the fetching RUN; the literal held in scripts/dockerfile-freeze.test.mjs
Node runtime tarball, arm64406fe3fbde1c054adff8b297c16fdb5af6659db327c1ac8acd46b56ef1af9a92as above

All three certificate pins are build ARGs, so what the assertions catch is a package layer that drifts under the committed values rather than an operator who changes those values. The three do not move together, and that is what keeps a partial override from passing. The dnf swap and the rpm -qf assertion both read FIPS_PROVIDER_PACKAGE and FIPS_PROVIDER_VERSION, so overriding those moves the install and its own check in step; FIPS_MODULE_VERSION appears in no dnf line and is read by the read-back assertion and by the ENV of the same name the runtime stage exports from it. That ENV is the value the entrypoint's per-run report names, and it is trustworthy at run time for exactly one reason: the assertion that compares it against the module the loader activates has already run in the same stage, so no green build can have an ENV naming a module other than the one installed. A build driven by hand with --build-arg over the install pins alone therefore installs a different NVR, satisfies the rpm -qf half, and fails on the module version the loader reports -- it goes red, not green. A green build with a different module takes an override of the module version as well, which is a statement of which module was intended. What holds the committed defaults themselves is scripts/dockerfile-freeze.test.mjs, which pins all three as literals, and no CI path passes a build-arg -- neither image_smoke.yaml nor the release workflow's builds of this image pass one.

The two tarball hashes are not ARGs. Each is a literal in the RUN that fetches the tarball, selected by the same case arm that selects the architecture's tarball name. An ARG is overridable at docker build --build-arg, which is the limit the paragraph above records for the certificate pins: tolerable there, because a second assertion reads the installed module back and a partial override goes red, and not tolerable here, because nothing else in the build looks at the tarball. NODE_VERSION stays an ARG, so overriding it alone fetches bytes the committed hash does not cover and fails at the checksum -- a Node bump means moving the version and both hashes together. That the check fails closed, on bytes that do not match and on an arch arm that paired a tarball with the other architecture's hash, is driven against the real sha256sum in scripts/docker-entrypoint-fips.test.mjs.

The release-snapshot pin is for reproducibility rather than for the certificate: AWS retains superseded NVRs, and a dated snapshot is immutable while --releasever=latest accumulates. That is why the freeze test holds its shape rather than its value: the property worth guarding is that the build names a dated snapshot at all, and a deliberate bump to a newer one should not have to edit the test. Which snapshot it names is held by review, but not independently of the base: the build asserts the two are the same release, so review decides the pair and a check keeps them from parting company. Sampled across AWS's published snapshots, every snapshot from the packages' first appearance (between 2023.6.20250107 and 2023.7.20250428) onward still resolves and still serves both certified NVRs, from a content-addressed blobstore.

Unverified as of 2026-09-29: the snapshot sampling above, the ten NVRs below whose modules were measured, their module hashes, the two certificate numbers and the per-architecture image labels were read from AWS's published packages and registry in earlier sessions. The 2026-09-29 audit could not reach those services and did not repeat the readings.

The module-version pin is the one that cannot be skipped. At least eleven NVRs share the openssl-fips-provider-latest package name. Ten of them hold ten different modules with ten different fips.so hashes, exactly one of them certified; the eleventh, below, is the stock provider of the pinned rootfs, and its module is unmeasured. The package name determines nothing:

3.2.2-1.amzn2023.0.1 -> 3.2.2-799901ad7ab41d45   <- the one certificate 5438 names
3.2.2-1.amzn2023.0.2 -> 3.2.2-6a2d04a6952ab14a
3.5.5-1.amzn2023.0.5 -> 3.5.5-f06cf76f53649b34
3.5.8-1.amzn2023.0.3 -> module version unmeasured as of 2026-10-19   <- stock in the Amazon Linux 20261016, 20261026 and 20261028 rootfs

The -certified name this image uses is a different package, which is what leaves openssl-libs free to float beside the pin, and the snapshot above serves it at two NVRs holding two certified modules:

3.0.8-1.amzn2023.0.1 -> 3.0.8-d694bfa693b76001   <- the module certificate 5021 names
3.2.2-1.amzn2023     -> 3.2.2-799901ad7ab41d45   <- what this image pins, certificate 5438

The second line was read back from a build of Dockerfile.fips (reading): rpm names openssl-fips-provider-certified-so3.2.2-1.amzn2023 as the owner of the installed fips.so, and openssl list -providers reports module 3.2.2-799901ad7ab41d45 active beside an openssl library at 3.5.8. The first line is not measured at 3.2.2, and the x86_64 build is not measured.

Both NVRs hold a certified module under a certificate of its own, so within this package name the pin decides which certificate the image stands on rather than whether the module is certified at all. Nothing in the build decides it by version order -- the swap names an NVR and the assertions read the installed module back -- and both halves of that read are what catch a module the pins do not name, certified or not.

The security policy is the other reason that pin does the work, because it names no package this image installs. Its installation, administrator-guidance and end-of-life sections (11.1, 11.2 and 11.4, p. 65) all name openssl-3.2.2-1.amzn2023.0.1, an NVR of the distribution's openssl package rather than of either -certified package -- and one this image does not carry under that name either, its openssl being 3.5.8-1.amzn2023.0.3. What the policy is consistent about is the module version string, given in section 11.2 (p. 65) as the value the Crypto Officer reads back and in all six rows of Tables 2 and 3 (p. 8), which is the value the build's second assertion compares against.

The base image digest and the snapshot pin name one release, not two. The snapshot pin covers the package layer and not the base rootfs, and the two are coupled: a base far newer than the pinned snapshot can put that snapshot's packages in conflict with what the base already holds. The digest in the table above closes that. It is the multi-arch index digest, which is what a multi-platform build can resolve -- a platform-specific manifest digest names one architecture and fails on the other -- and it was resolved on 2026-10-29 (UTC) with docker buildx imagetools inspect public.ecr.aws/amazonlinux/amazonlinux:2023, the digest ECR Public's 2023.12.20261028.0 tag also names. 2023.12.20261028 is the release AL2023_RELEASEVER names, so the base and the packages are the same snapshot rather than two compatible ones. The built image's system-release package reads 2023.12.20261028 on amd64 and arm64 alike, read on 2026-10-29 in native builds of each architecture.

The digest names Amazon's own registry, ECR Public, rather than Docker Hub's amazonlinux:2023. The two registries publish the same releases under different digests, so a digest read from one cannot be carried to the other.

That equality is asserted in the build rather than left to the bumper: the nodebase stage reads system-release's version out of the rootfs and fails unless it is what AL2023_RELEASEVER names, so a digest moved on its own reddens the image build instead of producing a green one whose userland and package layer came from different releases. Its reach is the architecture being built, each having a rootfs of its own, and image_smoke.yaml builds amd64 alone. So bumping the base still means re-resolving the digest and reading that release out of each architecture's rootfs -- docker create plus docker cp of /etc/os-release reads the foreign architecture without emulating or executing it -- with the assertion holding the value that reading produces. The rest of that procedure, and what a green smoke run does and does not prove about a bump, are in DEPENDENCY_PINS.md.

What the build fetches, beyond the npm tree. Two mirrors rather than the default image's one:

  • dnf from the pinned Amazon Linux snapshot, for tar, gzip, xz, findutils, libatomic, samba-client, openssl, and the provider swap. RPM signatures are verified against the key the base image holds.
  • nodejs.org, for the official Node 26 tarball, whose bytes are checked against the per-architecture hash committed in the fetching RUN rather than against a checksum file fetched beside them. Amazon Linux 2023 packages nodejs20, nodejs22 and nodejs24 only and the root package.json requires >=26, and libatomic is the one shared library the stock image lacks for that binary (without it node exits 127). What that pin does and does not establish about the tarball's provenance, and the one-time verification behind the two values, are in fips-variant-image.md.

smbclient comes from samba-client here rather than from Alpine's package of the same name, at 4.17.12, so image_smoke.yaml's command -v smbclient assertion holds against both images unchanged. It costs 53 packages on this base against Alpine's 45, including systemd, dbus, pam, cryptsetup-libs, device-mapper and util-linux -- a heavier closure, and one that includes an init system. The 4.17.12 version and the package counts are unverified as of 2026-09-29: they come from an earlier build and were not re-run.

The FIPS image's measured reading ​

Every statement in the docs that the FIPS image's module does not offer X25519 or Ed25519, and that Node's key generation for them throws ERR_OSSL_EVP_UNSUPPORTED, was read once, here: on 2026-10-08, in a linux/arm64 build of Dockerfile.fips at module 3.2.2-799901ad7ab41d45 (package 3.2.2-1.amzn2023). In that build Node's linked OpenSSL is 3.5.7 (process.versions.openssl) and the image's own openssl library and command are 3.5.8, two components that are both right for what each names. The x86_64 build was not measured. That build ran Node v26.7.0. The v26.13.0 the image pins reports linked OpenSSL 3.5.9 (process.versions.openssl) beside an openssl library of 3.5.8, read on 2026-10-29 in linux/amd64 and linux/arm64 builds at the pins above. The X25519 and Ed25519 reading has not been re-taken on that build.

What certificate 5438 attests ​

The provider pins above name CMVP certificate 5438, "Amazon Linux 2023 OpenSSL FIPS Provider". Its two sources are the certificate page, which holds FIPS 140-3, Overall Level 1, Status: Active, a 7/26/2031 sunset date, and an initial validation on 7/27/2026 by atsec information security corporation; and the module's own non-proprietary security policy, document version 1.6 of 2026-07-20, which is the authority for every table below. The page renders no module-version, tested-configuration or approved-algorithm section, so the rows here are single-sourced on the policy. Why the image pairs with this certificate, what a claim about the image may say, and the conditions the policy attaches to two of the algorithm rows are in fips-variant-image.md.

The Caveat ​

The certificate states one Caveat, a single field of three sentences. Quoted verbatim as one string, read from the certificate page:

When operated in approved mode. When installed, initialized and configured as specified in Section 11.1 of the Security Policy. No assurance of minimum security of SSPs (e.g., keys, bit strings) that are externally loaded, or of SSPs established with externally loaded SSPs.

The first and third sentences are the two that certificate 5021's Caveat also states, as 5021's Caveat was read at the 3.0.8 pin; it is not read at 3.2.2. The second, the installation clause, is 5438's alone.

Provenance differs from every other row in this section. The Caveat is a field of the certificate detail page itself, an HTML page with no page numbers, rather than of the 68-page security policy that sources the tables below; there is accordingly no page citation for it and none is fabricated.

SSP is the FIPS 140-3 term for a sensitive security parameter: a key, a seed, or any other value whose disclosure or modification compromises the module. The Caveat's third sentence reaches this project's composition directly -- the key schedule mixes a pre-shared secret the module did not generate -- and COMPLIANCE.md is where what follows from that is stated.

The installation clause points at a procedure this image does not follow to the letter and at a host state it cannot supply for itself. Section 11.1 (p. 65) names the same openssl RPM the pin section above records, and requires the Amazon Linux 2023 system to be in the FIPS validated configuration, verified by fips-mode-setup --check reporting "FIPS mode is enabled" -- which is the host's business rather than the image's, per host tier in fips-variant-image.md.

The tested and vendor-affirmed operational environments ​

Section 2.2, Table 3 (p. 8), complete -- two hardware platforms, each tested with the processor's cryptographic acceleration on and off:

Operating systemHardware platformProcessorsPAA/PAIHypervisor or host OS
Amazon Linux 2023EC2 c7g.metalAWS Graviton3 (ARMv8-A)YesN/A
Amazon Linux 2023EC2 c6i.metalIntel Xeon Platinum 8375C (Sunny Cove)YesN/A
Amazon Linux 2023EC2 c7g.metalAWS Graviton3 (ARMv8-A)NoN/A
Amazon Linux 2023EC2 c6i.metalIntel Xeon Platinum 8375C (Sunny Cove)NoN/A

Every row states module version 3.2.2-799901ad7ab41d45, the string the pin table above holds. Table 2 (Tested Module Identification, p. 8) names the same two platforms with fips.so as the file and HMAC-SHA-256 as its integrity test. Both architectures Dockerfile.fips builds for are among them at both acceleration settings: the Graviton3 rows are ARMv8-A and the Xeon rows x86-64.

Three vendor-affirmed environments, and the sentence that bounds them. Table 4 (p. 9) states Amazon Linux 2023 on AWS Snowball with AMD EPYC 9R14, on AWS Snowball with AMD EPYC 7702, and on AWS Snowcone with Intel Denverton Atom C3558. What the policy prints directly beneath that table, verbatim:

CMVP makes no statement as to the correct operation of the module or the security strengths of the generated keys when so ported if the specific operational environment is not listed on the validation certificate.

A vendor-affirmed row is the vendor's affirmation rather than a tested result, so a claim resting on one says less than a claim resting on Table 3.

None of the seven names a container, and none names a virtual machine. Every tested row is bare metal with Hypervisor or Host OS: N/A, and Table 4 states an operating system and a hardware platform per row with no hypervisor column at all. The variant image is therefore not running in a tested operational environment on any host, which is what fips-variant-image.md reasons from rather than around.

The approved-algorithm rows behind the measured call shapes ​

The variant's entrypoint probe makes five call shapes beside the rotation's two below, every one of them gating its verdict, and those five are what a "dispatches into the validated module" claim may name (fips-variant-image.md). Each is on Table 6 (Approved Algorithms, pp. 9-13), under the row name and CAVP certificate ids the policy records:

Call shapeTable 6 rowCAVP certificatesPropertiesReference
AES-256-GCM, 12-byte IVAES-GCMA7447, A7448, A7449, A7450, A7499, A7500, A7501, A7502, A7503, A7504, A7505, A7506, A7507Direction - Decrypt, Encrypt; IV Generation - External, Internal; Key Length - 128, 192, 256; IV Generation Mode - 8.2.2SP 800-38D
HKDF-SHA-256KDA HKDF Sp800-56Cr1A7440Derived Key Length - 2048; Shared Secret Length: 224-2048 Increment 8; HMAC Algorithm including SHA2-256SP 800-56C Rev. 2
HMAC-SHA-256HMAC-SHA2-256A7444, A7445, A7451, A7495, A7496, A7497, A7498Key Length: 112-524288 Increment 8FIPS 198-1
SHA-256SHA2-256A7444, A7445, A7451, A7495, A7496, A7497, A7498Message Length: 0-65536 Increment 8; Large Message Sizes - 1, 2, 4, 8FIPS 180-4
P-256 ECDHKAS-ECC-SSC Sp800-56Ar3A7445, A7451, A7495, A7496, A7497, A7498Domain Parameter Generation Methods - P-224, P-256, P-384, P-521; Scheme - ephemeralUnified; KAS Role - initiator, responderSP 800-56A Rev. 3

Three of those properties bound a value packages/core/src chooses, and all three are satisfied: the HKDF row's shared-secret window is 224-2048 bits against a 256-bit shared secret, its tested derived-key length 2048 bits against a 256-bit output, and the HMAC row's key-length floor 112 bits against a 256-bit key.

The HKDF row's name and its reference column disagree, and the policy settles it by repetition rather than in prose. The row is named KDA HKDF Sp800-56Cr1 while its reference column reads SP 800-56C Rev. 2, and section 2.10 (p. 22) adds nothing, saying only that key establishment methods are specified in the Security Function Implementations table. That table (Table 9, p. 18) carries the Cr1 row name again, against CAVP certificate A7440. A citation that names this module's HKDF therefore names Cr1 and A7440, not the Cr2 row and cert A3548 that certificate 4985 holds.

Table 6 membership is not the whole answer for two of the five. The policy attaches a condition to AES-GCM (section 2.7.1, p. 19) and to the SP 800-56Ar3 assurances behind the ECDH row (section 2.7.4, pp. 20-21), each stated outside Table 6. Both are recorded in fips-variant-image.md, which is where what may be claimed of the image is reasoned about. HKDF carries no use-context restriction on this policy: section 2.10 states none, and what bounds the row instead is Table 8, which lists HKDF among the key-derivation algorithms non-approved below a 112-bit key -- a threshold a 256-bit key-derivation key is far above.

The AES-GCM condition decides Alcove's AEAD, and the row's IV generation mode does not. The row above lists IV Generation Mode - 8.2.2 alone, while Alcove's IV is a deterministic construction conformant to SP 800-38D section 8.2.1 (CHANNEL_SECURITY.md). Neither reading reaches the calls, because every crypto.subtle AES-GCM call supplies the IV from outside the module, which Table 8 (Non-Approved, Not Allowed Algorithms, pp. 13-14) names as AES GCM (external IV) and Table 15 (Non-Approved Services, p. 33) as the encryption service built on it. Where the IV comes from is what decides it.

X25519 and Ed25519 appear in no table of this policy. Neither string occurs anywhere in its 68 pages, and the policy's non-approved-but-allowed categories are empty by explicit statement ("N/A for this module", stated twice -- with and without security claimed), so there is no status either algorithm could hold under this certificate. That is a different statement from the one certificate 4985 supports, whose Non-Approved, Not Allowed table names both.

The AES-KW row behind the rotation's wrap ​

The shared-secret rotation draws each party's value as a 256-bit HMAC key with crypto.subtle.generateKey and wraps it with crypto.subtle AES-KW under a 256-bit key HKDF derives (PROTOCOL.md). The entrypoint probe makes both calls, in its rotation_value_draw and aes_kw_wrap_round_trip legs, and either leg failing fails its verdict. Image Smoke run 40777437348, at commit 06b94e091 under the image's shipped fips-only.cnf, recorded both legs completing in an ENGAGED transcript, with fips.so mapped and the MD5 digest refused. On that measurement the module performs the wrap and the unwrap at the call shape the row below lists. The draw's leg shows that the call completes with the default provider unreachable, not which generator produced the bytes, so no document says the values come from the module's generator. The AES-KW row sits on Table 6 (p. 10):

Call shapeTable 6 rowCAVP certificatesPropertiesReference
AES-KW, 256-bit keyAES-KWA7438, A7442, A7443, A7492, A7493, A7494Direction - Decrypt, Encrypt; Key Length - 128, 192, 256SP 800-38F

That crypto.subtle's AES-KW, the RFC 3394 algorithm, is the KW this row names rests on the policy's labeling of the row: SP 800-38F itself was not read.

The ECDSA rows, which no probe leg covers ​

Receipt signing calls crypto.subtle ECDSA over P-256 with SHA-256, and the entrypoint probe makes no ECDSA call, so the rows below are a table placement and never a measured dispatch (fips-variant-image.md). They sit on the same Table 6 (p. 10):

Table 6 rowCAVP certificatesPropertiesReference
ECDSA KeyGen (FIPS186-5)A7445, A7451, A7495, A7496, A7497, A7498Curve - P-224, P-256, P-384, P-521; Secret Generation Mode - testing candidatesFIPS 186-5
ECDSA KeyVer (FIPS186-5)A7445, A7451, A7495, A7496, A7497, A7498Curve - P-224, P-256, P-384, P-521FIPS 186-5
ECDSA SigGen (FIPS186-5)A7445, A7451, A7495, A7496, A7497, A7498Curve - P-224, P-256, P-384, P-521; Hash Algorithm - SHA2-224, SHA2-256, SHA2-384, SHA2-512, SHA2-512/224, SHA2-512/256; Component - NoFIPS 186-5
ECDSA SigVer (FIPS186-5)A7445, A7451, A7495, A7496, A7497, A7498Curve - P-224, P-256, P-384, P-521; Hash Algorithm - SHA2-224, SHA2-256, SHA2-384, SHA2-512, SHA2-512/224, SHA2-512/256FIPS 186-5

A second pair of SigGen and SigVer rows, at CAVP certificates A7446 and A7452, lists the SHA-3 hash algorithms in place of the SHA-2 ones over the same four curves.

Table 14 (Approved Services, pp. 26-33) lists the services above those rows: signature generation and signature verification with ECDSA, each with the approved indicator OSSL_RH_FIPSINDICATOR_APPROVED (p. 28), beside key pair generation with ECDSA and public key verification with ECDSA.

The pre-hashed-message variant is a non-approved service. Table 8 lists RSA and ECDSA (pre-hashed message) for signature generation and verification, and Table 15 (Non-Approved Services, pp. 33-34) lists the two matching service rows. The module therefore separates two signature services over one approved algorithm, and which of them a caller reaches is decided by the indicator the module sets rather than by Table 6 membership. The AES-GCM condition above separates two services the same way, and there the call surface determines which one is reached: every crypto.subtle AES-GCM call supplies an external IV (fips-variant-image.md).

The runtime posture measured on each built image ​

Four properties are settled by running the image image_smoke.yaml just built, not by reading the Dockerfile that produced it: which trees the container can write, which files have a setuid or setgid bit, where every symlink under /app resolves, and which packages /app/node_modules carries. All four are outcomes rather than instructions -- each is decided by base-image state, by the file modes and targets an OS package arrives with, or by what a lockfile resolves, as much as by anything this repository writes -- so a static reading of the build cannot reach any of them, however tightly scripts/dockerfile-freeze.test.mjs holds the instructions themselves.

The writable set ​

Either image's container writes /work and /run/alcove/sftp-credentials, and no path under /app. The measurement creates a file in each of the two writable directories under the account the image runs as and requires the same write under /app to be refused; a write is what determines it, because a mode that is treated as writable over a layer that refuses the write is the case a stat cannot tell apart.

Refusing the write into /app is only half the claim, since a file the account can rewrite in place needs no writable directory around it. The other half is a walk of /app for any path owned by that account, owned by any group the account belongs to, or other-writable; the expected set is empty. The walk runs as uid 0, so no directory mode can hide a path from it -- ownership and mode read the same whoever asks.

Symlinks are outside that walk. Linux has no chmod for one, so a symlink's own mode is always 0777 and an other-writable test matches every link in the image while none of them is rewritable through that mode. What re-points a link is write permission on the directory holding it, which the walk already reaches through the directory itself.

Both halves run on both images. They differ in where the account comes from -- the default image inherits node from node:26-alpine, the variant creates it at the same uid and gid, Amazon Linux 2023 having no such account -- and in nothing this measurement reads.

The writable-set walk above skips symlinks, correctly: a symlink's own mode is always 0777 and grants nothing, so testing it proves nothing about rewriting. That leaves one shape the walk cannot see: a symlink under /app whose target resolves into a tree the runtime account can write, /work for instance, is code that account can effectively rewrite without owning, or being granted write on, anything the walk measures. This pass closes that gap directly, measuring every symlink found under /app in the built image.

Each link is resolved and sorted into one of three buckets: contained (the resolved path is /app or falls under it), escaping (the resolved path exists but falls outside /app), or dangling (the target does not resolve to a path that exists at all).

The resolution runs readlink -f on each link and then checks the result with [ -e ] rather than trusting it alone -- the guard is what actually determines the classification. The step never changes directory, so every command runs at the image's WORKDIR, /work, which is empty in the ephemeral container the step runs in; a target that does not resolve to a real path therefore fails the [ -e ] guard regardless of what string readlink -f produced for it, and is bucketed as dangling rather than misread as an escape.

Any escaping or dangling link fails the step, and the offenders -- each with its raw target and, where one resolved, the path it resolved to -- are printed so the failure is diagnosable from the job log alone.

Run on both images, as the writable-set measurement above is.

The production node_modules scope ​

Every package the built image carries under /app/node_modules is one the production install resolves. The measured side is a walk of that directory in the image; the expected side is the tree npm ci --omit=dev --omit=optional -w packages/core -w apps/cli resolves from the committed lockfile on the runner, in the checkout the job already has. scripts/list-node-modules-packages.mjs reads both, emitting one name@version per package, and the step fails on any the image holds and the resolution does not, printing them.

The expected side is resolved rather than listed. A committed list of packages would be a second freeze, drifting against the one scripts/dockerfile-freeze.test.mjs already holds, and reading the flags off the Dockerfile would move the expectation with any regression in the instruction it is meant to catch. The command is therefore written out in the step, and the freeze test is what holds the Dockerfile to the same one.

The lister walks directories rather than reading npm's own .package-lock.json, which records what npm installed and not what the tree holds: a later build step that adds to the tree is invisible to that file. A directory carrying no readable manifest is reported by its path, that being the shape something other than npm arrives in, and an empty tree fails outright rather than passing for want of anything to compare. A symlinked entry is read from the manifest it resolves to and not descended into.

The walk fails closed. A directory it cannot list fails the step, naming that path and the error code, rather than reporting an empty level and dropping the packages under it from the comparison; an absent directory is not that, most packages having no nested node_modules of their own. The walk of the image runs as uid 0. Which packages the tree holds is a property of the image's filesystem rather than of what the runtime account can see, so no directory mode under /app/node_modules decides what is measured or refuses the step.

The comparison runs one way: a package in the image and not in the resolution fails, and one in the resolution and not in the image does not. The two trees resolve from the same lockfile but are seeded from different manifest sets -- the builder copies five workspace manifests, a checkout has all six -- so the runner's tree carries a workspace link the image has no manifest for. What the opposite direction would catch is covered by the exchange smokes, which do not complete without the runtime dependencies, and by the symlink containment measurement, which refuses a workspace link that dangles.

Two things stay outside it. The apps/web console builds the runtime stage copies, dist/console and dist/console-server, bundle their own dependencies, which are vite's output rather than an npm install and are not walked. And where a package sits in the tree is not compared, only which packages are there; the layout is the lockfile's, and the copied layout is the freeze test's.

Run on both images, as the measurements above are.

The setuid and setgid inventory ​

Measured on both images with

find / -xdev -type f \( -perm -2000 -o -perm -4000 \) -exec ls -l {} +

run as uid 0, for the same reason the /app walk is: a traversal under an unprivileged account cannot enter a root-only directory, so an empty result from one would report an absence it never measured. The step compares the paths it finds against the inventory recorded here for that image and fails on any difference in either direction.

ImageRecorded inventory
Dockerfileempty -- no setuid or setgid file
Dockerfile.fipsempty -- no setuid or setgid file

For the FIPS variant the inventory was also measured on 2026-10-29, on native linux/amd64 and linux/arm64 builds without a layer cache, with find / -xdev -type f \( -perm -2000 -o -perm -4000 \) run as root: both results were empty.

Both inventories are empty because each runtime stage takes off every bit its own OS install brings in, and neither stage's base has another. Both images declare USER node, so a bit left in place would be a boundary an unprivileged process could push against rather than a formality, and neither role authenticates a Unix account or mounts a filesystem.

The default image strips one file. samba-client pulls in linux-pam, whose /usr/sbin/unix_chkpwd lands setgid shadow, and chmod g-s in the runtime stage removes it.

The variant strips ten, its Amazon Linux 2023 base and the samba-client closure recorded above with between them eight setuid root -- account, mount and PAM helpers -- plus write setgid tty and utempter setgid utmp. That closure is larger than Alpine's, bringing systemd, pam, cryptsetup-libs, device-mapper and util-linux with it. The ten paths are named as literals in one chmod u-s,g-s, so a path the closure stops holding fails the build rather than passing unnoticed, and one it gains reddens this measurement. Per-file detail on what each was for is in the FIPS variant's setuid and setgid files.

scripts/dockerfile-freeze.test.mjs holds each image's stripping instruction as one of the mode changes it permits outside the writable trees, and the measurement holds the outcome, which is the half that also sees the base image's own files.

An image added to the matrix records its own row the same way rather than starting unmeasured: its arm in the step is set to the @unrecorded sentinel, and the first run fails that leg and prints the block to paste into the arm and the list to paste here. The step runs after the variant's provider assertions and its end-to-end exchange, so a failure of either kind -- a sentinel awaiting its first measurement, or an inventory that has drifted -- skips none of them. Of the steps after it, the OS-package attribution comparison is skipped by such a failure; the image vulnerability scan and its report run regardless.

What the shipped setup scripts ask of the image ​

The Windows and POSIX file-drop scripts in support/windows-network-filedrop/ delegate every check they make to a capability of the image: they hand a container an Alcove argument vector, or they pipe one of their helper scripts into a shell in it and depend on the tools that shell resolves. That is a contract between two things nothing else in the repository connects, so image_smoke.yaml exercises it on both sides of publication.

The set is derived, not listed. scripts/derive-image-dependencies.mjs reads it out of the scripts. An argument vector is a run of literal tokens beginning with a word the image's own dispatchers answer to -- the words docker-entrypoint.sh routes on, and the commands apps/cli/src/cliParser.ts registers -- on a line that also names the image or an argument-vector parameter. A helper script is one cmd_Setup-AlcoveFileDrop.cmd redirects into a shell in the image, with the environment and mounts that call site gives it. A call site added to a script changes the derived set, and a derived dependency with nothing to exercise it fails npm run check:image-capabilities, which static_checks.yaml runs on every pull request whatever it touched.

Each is exercised rather than matched. scripts/assert-image-capabilities.mjs runs the argument vector and reads back the machine-readable verdict, and pipes each helper script in as the .cmd pipes it -- so a helper's in-image tools are resolved by the run and are enumerated nowhere. Two fixtures decide whether a red result is about the image at all, and the script sets up both. The first is a rendezvous directory the account the image runs as can write, because a fresh named volume belongs to root and the default image runs as uid 1000. The second is a stub peer that accepts and drops each connection on port 445, because both probe paths stop at their reachability check when nothing answers and leave smbclient unreached.

What a run may claim is bounded by what it treats as evidence. A doctor run that refuses its input exits 64 having run no check, and an image whose CLI has no doctor command answers the same way, so 64 proves nothing; 69 is a dependency the run could not reach and fails the gate. The evidence is the verdict document, whose version must be the one the shipped launchers read and stop past.

Two legs, asserting different things. The build-time leg runs against the image the job just built. That is the right subject for Start-Alcove.ps1 and start-alcove.sh, which ship stamped with a release's manifest digest and are therefore locked to the commit they were built from, and it catches a support script that outran the source tree in the same pull request. It is the wrong subject for Setup-AlcoveFileDrop.ps1, which is fetched on its own and runs the floating tag, so a second leg runs against ghcr.io/georgetown-mdi/alcove:latest on the weekly schedule and on demand. That leg is not a merge gate: the commonest reading of a gap there is that the capability is on the default branch and no release has been cut since, whose remedy is a release rather than a held merge. Both legs print the digest the reference resolved to rather than the reference they asked for, because the floating tag moves.

Both legs are scoped to the default image. It is the one published as ghcr.io/georgetown-mdi/alcove, the only one the scripts name, and the only one whose package closure their helper scripts were written against.

Measured inventories ​

Non-normative. What follows measures images that were built, kept as the evidence behind the rows above. Nothing here binds a build: a rebuild that moves a figure moves the figure, not the spec.

Unverified as of 2026-09-29, except the FIPS variant's closure figures. Every inventory in this section (package counts, byte sizes, the find results for setuid and setgid files, the PAM helper list, the per-component sizes) was measured on an earlier image build. The 2026-09-29 documentation audit had no Docker and could not reach the image registries, so none of it was re-run; a figure here may be out of date. The FIPS variant's attribution list, package count, GPL-3.0/LGPL-3.0 count, comparison between architectures and setuid and setgid inventory were measured on 2026-10-29 (UTC) at the base pinned above, on native linux/amd64 and linux/arm64 builds without a layer cache on GitHub-hosted runners, and its aarch64 image size on that linux/arm64 build. Its x86_64 image size was measured on 2026-10-26 (UTC) at the 2023.12.20261026 base, on a build without a layer cache emulated on Docker Desktop on an Apple Silicon Mac.

The OS-layer attribution lists ​

One generated list per image sits beside NOTICE: NOTICE-os-packages-default.tsv and NOTICE-os-packages-fips.tsv. Each holds one tab-separated row per installed package -- package, version, declared license -- under a commented header stating the image, the base pin in force at generation, the query the rows came from, and the architectures compared. NOTICE covers this repository's npm tree by construction and npm sbom reaches no OS package, so these two files are where an image's OS layer is attributed.

The relay image infra/relay/Dockerfile pins has a third list, NOTICE-os-packages-relay.tsv: package, version, source package, source version and homepage, with no licence column. It came from a host run of dpkg-query against the pinned digest on 2026-10-04 at linux/arm64, not from the generator, and check:all neither regenerates nor checks it. The generator does not cover dpkg images: it fails a package that declares no licence, and dpkg declares none (each package ships a /usr/share/doc/<package>/copyright file instead). How the generator should treat that is an open design question and is not answered here.

Beside them, NOTICE-os-packages.md states where the corresponding source for the listed binaries is published -- Alpine's aports for the default image, Amazon Linux source RPMs for the variant -- with the retrieval path for a row's own version, the reading of both Dockerfiles that makes the packages unmodified distribution packages, and the position that the project makes no separate offer of source.

How they are derived. scripts/generate-os-package-attribution.mjs reads each image's own package-manager metadata, queried against the built image by tag.

  • Default image: apk list --installed, apk-tools 3.0.8-r0 on Alpine 3.24.2. /lib/apk/db/installed agrees with that listing on every package's name, version and license on both architectures; the listing is what the generator parses.
  • FIPS variant: rpm -qa --qf "%{NAME}\t...\t%{LICENSE}\n", RPM 4.16.1.3 with dbpath /var/lib/rpm, on Amazon Linux 2023.12.20261028. The variant's fips-only OpenSSL configuration does not reach rpm, which reads its database in C; what it breaks is dnf's own Python, which hashes with blake2s.
  • Neither Dockerfile names its final stage, so a --target query cannot reach the runtime layer and both images are queried by tag. On the default image that is the difference between measuring the image and measuring the builder: the runtime stage is where apk add --no-cache samba-client runs.
  • The rpm version column is epoch:version-release, the epoch written even where it is the 0 the format string substitutes for a package that sets none. The architecture column is read and dropped: it is the one field the two architectures of an image disagree on.

What was measured. Both images built without a layer cache at both architectures, the FIPS variant on 2026-10-29 and the default image on 2026-09-24. A first attempt with a warm cache reported an architecture difference that was a stale layer rather than a property of the image, so a re-measurement builds with --no-cache or it measures the cache.

  • Default image: 63 packages, 6 of which declare a disjunction and 18 of which hold a GPL-3.0 or LGPL-3.0 term.
  • FIPS variant: 170 rows, 15 of which declare a disjunction and 37 of which hold a GPL-3.0 or LGPL-3.0 term. One row is the gpg-pubkey pseudo-package the Amazon Linux signing key is stored as rather than an installed package; it is listed as the rpm database reports it, so 170 is the row count and 169 the count of real packages.
  • The two architectures of each image produce byte-identical lists: every package agrees on name, version and license, the FIPS variant's 170 included, and the only per-architecture difference in either capture is the architecture column the lists drop.

The limits.

  • Neither list holds the Node.js runtime or the npm bundled with it. The default image's base installs both outside apk, and the FIPS variant unpacks them from the nodejs.org tarball ARG NODE_VERSION names, so neither package manager records either and no generated list names one. What holds each is the base digest and that tarball's committed hash above; a pin is not an attribution. A row naming either -- planted in a committed list, or measured off an image whose package set moved -- fails the generator and the scripts test beside it, naming the Section 889 claim in COMPLIANCE.md that rests on this limit.
  • The license field is the string the package manager declares, not an audit of the package's contents. Nothing here verifies that a package's files match what its own metadata states.
  • A disjunctive expression (LGPL-3.0-or-later OR GPL-2.0-or-later) is recorded as declared. Choosing an arm is a licensing call, and no part of this derivation makes one.
  • The two distributions mix notations, and no string is rewritten. The FIPS variant's field holds legacy Fedora shorthand (GPLv2+, ASL 2.0, Public Domain) beside modern SPDX expressions, bare project names used as a license (curl, Python, OpenLDAP, pubkey), a LicenseRef-, and one 744-character expression shared by libgcc, libstdc++, libgomp and libatomic; operator case differs between packages declaring the same disjunction. A reader gets what the distribution states rather than a normalization this project invented.
  • A package recording no license fails the generator, naming the package, rather than reaching a list with an empty cell. No package on either image records one. rpm renders an absent header tag as the literal (none), which the generator treats as the same absence.
  • The drift check on .github/workflows/image_smoke.yaml compares linux/amd64 only, that job building no other architecture. The arm64 half of each list rests on the comparison made when the list was generated. Moving the comparison to .github/workflows/release.yaml, where both architectures are already built, is the upgrade path; it is not planned work.
  • The default image's package set floats. apk add --no-cache samba-client resolves against whatever the Alpine mirror holds at build time, so its list can go stale with nothing in this repository changing, and the remedy is to regenerate it. The FIPS variant has no such exposure: its dnf transactions pin --releasever=2023.12.20261028.
  • The drift check fails on a package added or removed and on a license string that moved; a version that moved it reports without failing. That install resolves its dependency versions against the live index, which moves patch versions within days, so two builds of the same digest-pinned Dockerfile hold one package set at different versions. Each version a committed list states is therefore as of that list's generation, refreshed when the list is regenerated.

What the 45 packages add beyond smbclient ​

Invocation is not the only cost: these libraries sit in the image that runs every exchange, so an advisory against any of them applies to that image whether or not an exchange reaches the code, and none of them is in the release SBOM either. Measured on the pinned base at aarch64 (apk list -I before and after), they fall into five groups. The samba client libraries and their record stores are samba-client-libs, samba-common, samba-libs, samba-util-libs, libsmbclient, libwbclient, libauth-samba, ldb, talloc, tdb-libs, tevent, lmdb, and gdbm. The authentication and directory stack is linux-pam, libldap, libsasl, utmps-libs, and skalibs-libs. The TLS/crypto stack is gnutls, nettle, gmp, libtasn1, p11-kit, and libffi. The compression and archive libraries are libarchive, xz-libs, zstd-libs, lz4-libs, libbz2, and brotli-libs. The tail of support libraries is readline, the ncurses set, popt, icu-libs, icu-data-en, libexpat, jansson, libidn2, libunistring, acl-libs, and libcap2. No smbd, nmbd or winbindd is installed, so nothing added listens.

linux-pam is where the setgid bit the runtime stage strips comes from. Measured with

find / -xdev -type f \( -perm -2000 -o -perm -4000 \) -exec ls -l {} +

which reports nothing on the pinned base digest at either architecture, and after the install reports exactly -rwxr-sr-x 1 root shadow /usr/sbin/unix_chkpwd and no setuid file at all -- on the built arm64 image, and at x86_64 on the pinned base plus that one instruction. It sits beside the PAM helpers faillock, mkhomedir_helper, pam_namespace_helper, pam_timestamp_check and pwhistory_helper, none of them setgid. Because the runtime stage declares USER node, the process that bit would elevate is unprivileged, so it would have to be read as a boundary rather than as the formality it is for uid 0 -- which is why the stage removes it instead, leaving nothing to reason about. The enforced form of that, and the same find run against both built images on every pull request, are in the runtime posture measured on each built image.

The helper image the setup scripts run the probe in is a mutable tag ​

It is ghcr.io/georgetown-mdi/alcove:latest in both scripts, and floating it is deliberate: the scripts are downloaded on their own rather than shipped with a release, so they cannot name the digest of a release they do not know they belong to, and a diagnostic pinned tighter than the thing it diagnoses would test an image the exchange will not run. The limit that comes with it is recorded rather than closed. The helper container receives the share password in its environment (--env SMB_PASS) and, for the volume check, a bind of the CIFS volume, so the plaintext credential and read/write access to the partner's drop folder go to whatever the tag resolves to at pull time -- and that pull happens on exactly the HTTPS-intercepting networks the probe exists to diagnose. A digest, or a released version tag, is what would make a substituted image detectable there; how a separately downloaded script would learn either is the open part.

The FIPS variant's setuid and setgid files ​

The ten files the variant's runtime stage strips, with the bits and modes they arrive with, as image_smoke.yaml measured them on a build at the base digest pinned above before the stripping instruction existed (run 31235317792, amd64). This is the evidence behind that instruction's literal path list, and the record of what an unstripped build of this closure holds:

-rwsr-xr-x 1 root root 74360 Nov 20  2023 /usr/bin/chage
-rwsr-xr-x 1 root root 78680 Nov 20  2023 /usr/bin/gpasswd
-rwsr-xr-x 1 root root 48760 Jul 10 19:10 /usr/bin/mount
-rwsr-xr-x 1 root root 42392 Nov 20  2023 /usr/bin/newgrp
-rwsr-xr-x 1 root root 57232 Jul 10 19:10 /usr/bin/su
-rwsr-xr-x 1 root root 36400 Jul 10 19:10 /usr/bin/umount
-rwxr-sr-x 1 root tty  24064 Jul 10 19:10 /usr/bin/write
-rwx--s--x 1 root utmp 16176 Jan 29  2023 /usr/libexec/utempter/utempter
-rwsr-xr-x 1 root root 15768 Dec 22  2025 /usr/sbin/pam_timestamp_check
-rwsr-xr-x 1 root root 28208 Dec 22  2025 /usr/sbin/unix_chkpwd

Eight are setuid root: the account helpers chage, gpasswd, newgrp and su, the mount helpers mount and umount, and the PAM helpers pam_timestamp_check and unix_chkpwd. Two are setgid: write to tty and utempter to utmp. The measurement reads the built image and does not separate what the base rootfs holds from what the package closure adds; no row above turns on that split.

Two of these names also appear in the default image's closure, with a different bit there: unix_chkpwd lands setgid shadow on Alpine, while here it is setuid root; and pam_timestamp_check, which Alpine's install leaves with neither bit, is setuid root here. The same package name on two distributions is not the same file modes, which is why each image's list is measured rather than derived from the other's.

The FIPS reference build's inventory ​

The shipped Dockerfile.fips build at the pins above installs 170 OS packages, counted from the rows of NOTICE-os-packages-fips.tsv. Of those 170, 37 hold a GPL-3.0 or LGPL-3.0 term -- the reference build's 39 (enumerated below) minus binutils and its elfutils-debuginfod-client dependency, both of which hold a v3 term and neither of which the shipped build installs. At this pin the 37 is counted from the rows of that list whose license holds a GPL-3.0 or LGPL-3.0 term. Five of the 170 -- libselinux-utils, policycoreutils, rpm-plugin-selinux, selinux-policy and selinux-policy-targeted -- are dependencies the build's samba-client and openssl install resolves at snapshot 2023.12.20261028; none holds a v3 term, and none adds a setuid or setgid file.

The image weighs 809,923,294 bytes (810 MB) on aarch64, as docker image inspect --format '{{.Size}}' reports it, measured on 2026-10-29 on a native build without a layer cache at the pins above. On x86_64 it weighed 658,606,218 bytes (659 MB) at the 2023.12.20261026 base, measured on 2026-10-26 on a build without a layer cache emulated on Docker Desktop on an Apple Silicon Mac; it has not been re-taken at the pins above. A runner using Docker's containerd image store reads a different {{.Size}} (218,186,826 bytes at these pins), which is not the quantity the figures here record.

The table below is the older one-off reference build Dockerfile.fips was derived from, measured on aarch64 against the Alpine image built the same day. That reference installed binutils (29,160,927 bytes installed) to read the module version out of fips.so with strings, which the shipped build does not need because it reads the version back through openssl list instead. binutils and its elfutils-debuginfod-client dependency are the only two packages the reference installs that the shipped build does not, and the five SELinux packages above are the only ones the shipped build installs that the reference, resolved at an older snapshot, does not: hence its 167 against the shipped 170. Its absolute size is a separate earlier measurement, not the shipped build's size plus binutils.

Alpine imageFIPS variant (reference build, with binutils)
Image size575,506,781 bytes (576 MB)1,055,721,059 bytes (1056 MB)
OS packages63167
Packages with a GPL-3.0 or LGPL-3.0 term1839

Where the 480 MB goes: the two base rootfs are almost the same weight (amazonlinux:2023 183 MB, node:26-alpine 178 MB), but Amazon Linux bundles no Node, so the 222 MB tarball is additive. Its dependency closures are fatter too -- glibc/libgcc/libstdc++ 67 MB, python3 (in the base image, for dnf) 55 MB, the samba client stack 44 MB, systemd (a samba-client dependency) 31 MB, dnf/rpm/libsolv/librepo 11 MB. The certified fips.so itself is 1.2 MB.

The licence consequence is wider than the default image's, and is open. The Alpine image's 18, read out of NOTICE-os-packages-default.tsv, are eight samba-family packages, the four record stores talloc, tdb-libs, tevent and ldb, gdbm and readline on an unconditional v3 term, and gmp, libidn2, libunistring and nettle offering a v3 arm beside a GPLv2-or-later one. The term reappears here on samba-client, samba-client-libs, samba-common, samba-common-libs, libsmbclient and libwbclient. It is joined by a GPLv3 base userland Alpine's busybox and musl do not have: bash, coreutils-single, diffutils, findutils, gawk, grep, gzip, sed, tar, readline, gdbm-libs, gnupg2-minimal, gnutls, libtasn1, libassuan, plus the LGPL-3.0 samba record stores (libtalloc, libtdb, libtevent, libldb). Four more have an unconditional v3 term from elsewhere in the closure: binutils, elfutils-debuginfod-client, libidn2 and mpfr. libgcc, libstdc++, libatomic and libgomp have "GPL-3.0-or-later with GCC-exception-3.1", the runtime exception, which is the normal case for a linked C++ runtime. The remaining six of the 39 -- elfutils-libelf, elfutils-libs, elfutils-default-yama-scope, gmp, libunistring and nettle -- offer a GPLv2-or-later arm beside the LGPLv3 one, so they have a v3 term only under the arm taken. The shipped build installs neither binutils nor elfutils-debuginfod-client, so its own count is the 37 named above rather than this reference build's 39. Whether that breadth changes this project's distribution posture is a licensing call rather than a measurement, and it is not settled here.