302 lines
20 KiB
Markdown
302 lines
20 KiB
Markdown
# Supply-chain release gate
|
|
|
|
## Release boundary
|
|
|
|
RC-DEL-02 is a managed, fail-closed evidence gate. Pull requests, default runs, explicit `full`
|
|
runs and explicit `security` runs all require the complete source phase. Only an explicit
|
|
`workflow_dispatch` with `profile=security` builds and scans the four shipping images; `full` does
|
|
not silently turn into the expensive image profile. Neither profile can pass because a required
|
|
scanner was absent, timed out, lost network access, returned malformed data or silently omitted a
|
|
profile-required component. The job does not deploy or contact a ModelForge production service.
|
|
|
|
The checkout uses the commit selected by the Gitea run, `fetch-depth: 0`, tag fetching and
|
|
`persist-credentials: false`. An explicit fetch uses full head and tag refspecs.
|
|
`scripts/security_gate.py history` separately proves that Git reports the checkout as non-shallow,
|
|
that `HEAD` equals `GITHUB_SHA`, and that every advertised remote head and tag has the same local
|
|
object. It also records the canonical origin, all-ref and reachable-commit counts, root commits and
|
|
object-reachability proof. A non-shallow single-branch clone is incomplete and fails; checkout
|
|
configuration alone is not proof.
|
|
|
|
The job has a 120-minute bound. The explicit worst-case step bounds for the image profile total 108
|
|
minutes, including five minutes each for the always-run evaluator and artifact upload, leaving a
|
|
12-minute job-level reserve. A bootstrap `security-gate-result.json` starts in the fail state before
|
|
checkout. Evidence and tools each use an exclusive `mktemp` directory whose prefix includes run ID,
|
|
run attempt and SHA and whose unpredictable suffix prevents stale-path reuse. Both roots must be
|
|
real, runner-owned mode-0700 directories outside the workspace. The evidence location survives
|
|
checkout cleaning and remains uploadable when checkout fails. The evaluator rejects evidence-root,
|
|
directory or file symlinks/reparse points, non-regular files, ownership/mode surprises and paths
|
|
outside the evidence root. On Linux it walks with descriptor-relative `openat`/`O_NOFOLLOW`; on
|
|
Windows it rejects reparse points and compares pre-open, open-handle and post-read identities. It
|
|
validates owner/mode/type on opened handles, reads and hashes the same descriptors, retains one byte
|
|
snapshot, then revalidates ancestry, identity, size and timestamps before every consumption. A
|
|
same-inode mutation or lstat/open swap therefore fails without consuming replacement content.
|
|
Missing status records, timeout exits and interrupted or failed downloads/builds/scans all remain
|
|
blocking evidence.
|
|
|
|
## Pinned scanner acquisition
|
|
|
|
No floating scanner action, `latest` download or `curl | sh` installer is used. The runner downloads
|
|
and verifies these exact artifacts before execution:
|
|
|
|
| Scanner | Exact version/artifact | SHA-256 |
|
|
| --- | --- | --- |
|
|
| pip-audit | `pip_audit-2.10.1-py3-none-any.whl` | `99ef3f600a317c1945f1e89e227ef26e1c2d618429b8bd3fa6f4f7c440c4611a` |
|
|
| Gitleaks | `gitleaks_8.30.1_linux_x64.tar.gz` | `551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb` |
|
|
| Trivy | `trivy_0.74.0_Linux-64bit.tar.gz` | `2ae6fe3ee734b7fdf11335663e18c75ea12dccc76062f09f164a3b0f8be4371a` |
|
|
|
|
The pip-audit wheel and every dependency in its scanner environment are fully resolved in
|
|
`config/security/pip-audit-toolchain.lock`. Each official PyPI wheel has an exact version and
|
|
SHA-256; acquisition uses `--require-hashes --only-binary=:all:` from `https://pypi.org/simple`, and
|
|
installation is offline from those verified downloads with `--no-index --require-hashes`. The
|
|
evaluator verifies reported runtime versions again. Trivy version/DB evidence is collected in the
|
|
mandatory source phase, so `full` does not depend on image execution.
|
|
|
|
Both Trivy DB `UpdatedAt` and `DownloadedAt` must be well-formed explicit UTC timestamps, must not
|
|
be future-dated, and `DownloadedAt` cannot predate `UpdatedAt`. The DB update may be at most 48
|
|
hours old and the local download at most 24 hours old at evaluation time. Missing, null, malformed,
|
|
non-UTC, future or stale metadata fails. npm is runner-supplied and its exact observed version is
|
|
retained rather than claimed in advance.
|
|
|
|
## Required scan matrix
|
|
|
|
The source phase collects findings without ignore flags or finding-based early exit:
|
|
|
|
| Target | Evidence |
|
|
| --- | --- |
|
|
| Complete Git history | Gitleaks `git`, `--log-opts=--all`, `--redact=100` |
|
|
| API source | pip-audit of every `[project].dependencies`, optional group and locked build tool |
|
|
| Node Agent source | same, including its exact local API dependency |
|
|
| Runtime Worker source | same, including both `dev` and `inference` groups |
|
|
| Console lock | `npm audit --json` for all dependencies |
|
|
| Console production lock | `npm audit --omit=dev --json` |
|
|
| Repository filesystem | Trivy `vuln,misconfig`, retaining UNKNOWN through CRITICAL |
|
|
|
|
Python declarations are read with `tomllib`; a hand-maintained requirement subset is not accepted.
|
|
Each PEP 517 backend requirement must have one exact version that matches both gate policy and
|
|
`config/security/build-toolchain.lock`. The lock pins `pip==26.2.1`, `setuptools==84.0.0`,
|
|
`wheel==0.48.0`, `msgpack==1.2.2`, `hatchling==1.32.4`, `editables==0.5` and Hatchling's complete build-time
|
|
dependency closure. Every artifact is installed from PyPI with `--require-hashes --only-binary=:all:`.
|
|
The msgpack hashes cover CPython 3.11-3.14 glibc x86_64/aarch64 wheels and
|
|
CPython 3.12-3.14 Alpine musl x86_64/aarch64 wheels used by managed validation and release images.
|
|
The managed source-validation environment and all Python image builds install this lock before
|
|
packaging projects, then use `--no-build-isolation` so pip cannot resolve a second build
|
|
environment. The gate audits every locked tool as an exact direct package and compares the PEP 517
|
|
requirements against the lock.
|
|
The three first-party distributions are injected into their source inventories, and local
|
|
cross-component pins must resolve to the matching project version. Source reports therefore show
|
|
explicit coverage of `modelforge-api`, `modelforge-node-agent` and
|
|
`modelforge-runtime-worker`, even though a public advisory service may correctly report that a
|
|
private distribution is not present in its index. Those exact locally proven names are separated
|
|
before the public advisory lookup because `pip-audit --strict` correctly rejects a distribution
|
|
that PyPI cannot identify; every other declared direct package remains in the strict audit input.
|
|
Source pip-audit is intentionally allowed to resolve dependencies: every declared direct package
|
|
must appear at its exact manifest version, while every additional resolved transitive package and
|
|
version is retained in `resolved_transitive_packages`. Missing or version-drifted direct packages
|
|
fail. The resolved source report can omit the build-environment tools `packaging`, `pip`,
|
|
`setuptools` and `wheel`. The gate accepts those omissions only for source resolution: all four
|
|
remain hash-locked, and shipping-image inventory still requires `packaging` when present while
|
|
forbidding retained installers. Trivy scans the repository and immutable images. No other runtime
|
|
package receives this exception. The retained pip inputs
|
|
must preserve the original PEP 508 declarations, including extras such as `uvicorn[standard]` and
|
|
`psycopg[binary]`, plus every locked build-tool entry; reconstructing a bare `name==version` line
|
|
for an extra or omitting a lock entry fails. Duplicate project declarations are rejected before
|
|
aggregation and name the exact manifest, TOML source and line for both declarations. Identical as
|
|
well as conflicting duplicate pip result rows are malformed. Source audits never use `--no-deps`
|
|
or `--disable-pip`.
|
|
|
|
The shipping-image phase builds exactly four non-pushed local tags:
|
|
|
|
```text
|
|
modelforge-security-api:${GITHUB_SHA}
|
|
modelforge-security-web:${GITHUB_SHA}
|
|
modelforge-security-node-agent:${GITHUB_SHA}
|
|
modelforge-security-runtime-worker:${GITHUB_SHA}
|
|
```
|
|
|
|
Every build uses `--pull`, an exact SHA tag and exact `org.opencontainers.image.source`, `revision`
|
|
and release `version` labels. Sanitized Docker identity evidence records those labels, the unique
|
|
tag and immutable `sha256:` image ID for all four images. The retained set must contain exactly four
|
|
release identities, exactly one approved SHA tag per image, no mutable or attacker aliases, no
|
|
unexpected digest aliases and no duplicate image IDs. Trivy scans the image ID, not a mutable tag;
|
|
a gate-owned normalizer preserves Trivy's component target separately while binding every retained
|
|
`Results[].Target` and its own `ScannedImageID` to that exact ID. The evaluator cross-checks
|
|
`ArtifactType`, `ArtifactName`, every nested target, `Metadata.ImageID`, repo tags/digests and the
|
|
reference against Docker identity evidence; Trivy's distinct config `ArtifactID` must remain an
|
|
immutable SHA-256 value.
|
|
|
|
All four Dockerfiles declare the canonical HTTPS source URL
|
|
`https://git.example.com/example/modelforge`. The workflow validates both repository identity
|
|
and server URL before building, and the evaluator requires the same canonical URL in each inspected
|
|
image. Python images take a repository-root build context so they can consume the shared hash lock.
|
|
The curated public-source exporter intentionally rewrites that private static label to its existing
|
|
sanitized URL and records it as `sourceRepository` in `PUBLIC_SOURCE_MANIFEST.json`. The manifest
|
|
hashes all four transformed Dockerfiles; both the release builder and source gate require their
|
|
labels to match that manifest identity. Public release provenance records the same sanitized source
|
|
URL alongside the canonical source SHA and manifest digest. Private managed image builds stamp and
|
|
evaluate the exact trusted repository URL resolved by their workflow.
|
|
|
|
Python executes inside the exact API, Node Agent and Runtime Worker image IDs to enumerate installed
|
|
distributions through `importlib.metadata`. Each required first-party distribution must equal the
|
|
corresponding source project version. Those complete image inventories are audited exactly with
|
|
`pip-audit --strict --no-deps --disable-pip`; unexpected or missing image packages fail. The final
|
|
Runtime Worker inventory must not contain pip, setuptools, wheel or editables; retaining any
|
|
installer/build backend blocks the gate. The image audit permits only the established
|
|
`setuptools`/`wheel` report omissions, while its exact image inventory and Trivy evidence remain
|
|
mandatory.
|
|
The digest-pinned PyTorch base also exposes exact Conda-management distributions and CUDA-local
|
|
PyTorch wheels that do not exist at those versions on PyPI. Only the enumerated Runtime Worker
|
|
target/name/version tuples are excluded from the PyPI requirements input; every tuple remains
|
|
mandatory in the retained image inventory and the whole immutable image remains subject to Trivy.
|
|
The allowlist is `conda==25.5.0`, `conda-build==25.5.0`,
|
|
`conda-libmamba-solver==25.3.0`, `libmambapy==2.1.1`, `menuinst==2.2.0`,
|
|
`torch==2.7.1+cu128`, `torchaudio==2.7.1+cu128`, and `torchvision==0.22.1+cu128`.
|
|
Absence or version drift fails before vulnerability evaluation.
|
|
All four release images are rebuilt with `--pull --no-cache` for the explicit security profile.
|
|
This prevents a previously cached package-manager upgrade layer from masquerading as a current
|
|
base-OS scan. The release builder uses the same cache-free policy. Runtime images also remove
|
|
installers after their final dependency check.
|
|
The Web build sets `VITE_API_BASE_URL=https://security-scan.invalid`; exact image
|
|
identity records its canonical nginx bundle path. Evidence collection copies that actual path from
|
|
the exact immutable image ID, starts that image, and queries the canonical served build-provenance
|
|
asset generated by Vite. The served bytes must equal the hashed bundle asset. That exact-schema
|
|
asset binds the intentional API origin with SHA-256; the compiled runtime chunk must reference the
|
|
same origin and binding, and both digests are retained inside the whole-bundle digest. Every
|
|
JavaScript chunk, source map, HTML and CSS asset is scanned for direct or
|
|
split/concatenated/template HTTP(S) and WebSocket origins. The pinned React build's W3 namespace
|
|
constants and React error-document origin are separately recorded as exact non-API origins and are
|
|
valid only in their expected namespace/error contexts. Missing provenance, any other origin,
|
|
additional attacker coexistence, dead expected markers, IPv4/IPv6 loopback and localhost-family
|
|
origins all fail. The Web image is static nginx and has no invented Python inventory. Ordinary
|
|
validation uses three separate virtual environments so components cannot inherit one another's
|
|
resolution.
|
|
|
|
## Secret evidence and historical false positives
|
|
|
|
Gitleaks 8.30.1 has upstream false-negative regression #2170 and also drops newly added rules when
|
|
they are combined with `extend.useDefault`. The gate therefore scans the same complete history
|
|
twice: built-in default rules run on a temporary config-free worktree, then `.gitleaks.toml` runs
|
|
narrow no-entropy rules for GitHub's documented `ghp_`, `gho_`, `ghu_`, `ghr_`, `github_pat_` and
|
|
both classic and 2026 stateless `ghs_` token formats. The temporary raw reports are merged outside
|
|
retained evidence. Neither configuration path contains a path, commit, or global allowlist.
|
|
|
|
Before the repository scan, the exact pinned Linux binary must independently find canonical
|
|
synthetic `ghp_`, `gho_`, `ghu_`, `ghs_`, `ghr_` and `github_pat_` histories through the applicable
|
|
custom rules, plus a canonical AWS key through a built-in/default rule. Both configurations must
|
|
leave a benign control clean. Trigger values are assembled only at test runtime. Traps remove the
|
|
temporary histories and raw reports; scanner, assertion, worktree/prune or `rm` cleanup failure
|
|
blocks the gate.
|
|
|
|
The repository's raw Gitleaks JSON is temporary and is never uploaded. Before retention, the
|
|
normalizer emits an exact version-1 object and exact finding keys: a constrained rule ID and a
|
|
SHA-256 of Gitleaks' exact fingerprint. Unknown keys and `Secret`, `Match`, `Line`, author, email,
|
|
the un-hashed fingerprint, source path or commit metadata fail or are absent from the artifact.
|
|
|
|
A real pinned Gitleaks 8.30.1 scan on 2026-08-30 proved that the full non-shallow history contains
|
|
two findings for one old M15 AES unit-test fixture. Rewriting canonical history is not an acceptable
|
|
release operation. The fixture bytes were synthetic test material and never a credential or a
|
|
production backup key, so the two observations are classified `FALSE_POSITIVE` in
|
|
`config/security/gitleaks-false-positives.json` by exact SHA-256 of the Gitleaks fingerprint and
|
|
exact rule ID. The registry contains no path, glob, commit or secret value.
|
|
|
|
This is deliberately narrower than a scanner allowlist:
|
|
|
|
- each classification must match exactly one sanitized finding;
|
|
- a missing, changed, duplicated or additional fingerprint fails;
|
|
- duplicate registry entries and wildcard rule IDs fail;
|
|
- owner, classification date, review date, tracking reference and reason are mandatory;
|
|
- the review window is at most 30 days and a stale review fails;
|
|
- no Gitleaks finding can use the vulnerability accepted-risk mechanism.
|
|
|
|
Any new leak therefore remains blocking, including another finding from the same rule or file.
|
|
|
|
## Central decision and accepted risk
|
|
|
|
Scanners collect facts; only `scripts/security_gate.py evaluate` decides. It rejects missing, empty
|
|
or malformed reports, unexpected scanner exits, network/tool failures, timeouts, shallow history,
|
|
version drift, source/inventory mismatches and every expected-versus-actual target gap. It then
|
|
blocks:
|
|
|
|
- every unclassified Gitleaks finding;
|
|
- every pip-audit vulnerability;
|
|
- every npm vulnerability;
|
|
- every Trivy UNKNOWN, HIGH or CRITICAL vulnerability or misconfiguration.
|
|
|
|
LOW and MEDIUM Trivy observations remain in the raw machine-readable reports but are not RC-DEL-02
|
|
blocking severities. No scanner is instructed to omit them.
|
|
|
|
`config/security/accepted-risks.json` is a versioned object. It starts empty and contains only
|
|
separately reviewed, temporary exceptions. An entry has exactly these fields:
|
|
|
|
```json
|
|
{
|
|
"scanner": "trivy",
|
|
"target": "node-agent",
|
|
"advisory_id": "CVE-YYYY-NNNN",
|
|
"package": "package-name",
|
|
"installed_version": "1.2.3",
|
|
"classification": "ACCEPTED_TEMPORARY",
|
|
"owner": "named owner",
|
|
"accepted_on": "2026-08-30",
|
|
"expires_on": "2026-09-29",
|
|
"tracking": "https://issue.example/RC-DEL-02",
|
|
"reason": "Evidence-backed reason that the no-fix risk is temporarily acceptable."
|
|
}
|
|
```
|
|
|
|
The identity is the exact five-value tuple `(scanner, target, advisory_id, package,
|
|
installed_version)`. Classification must be exactly `ACCEPTED_TEMPORARY`; wildcards and extra
|
|
fields fail. Only pip-audit, npm-audit and Trivy
|
|
vulnerabilities with no available fix are eligible. The window is at most 30 calendar dates;
|
|
`expires_on` is the first invalid date. Future,
|
|
expired, duplicate, ambiguous, unused/stale, malformed and fix-available entries all fail the gate.
|
|
An accepted entry that no longer matches a finding is not harmless configuration; it is stale
|
|
policy and blocks the release.
|
|
|
|
For the v1.2.2 candidate, the registry contains 23 exact Trivy tuples for the Node Agent Debian
|
|
Trixie base. Every tuple was reported without an upstream fix by the exact-image run, is owned by
|
|
the ModelForge security owner, expires on 2026-10-02, and is tracked in
|
|
`https://git.example.com/example/modelforge/issues/24`. The current Bookworm alternative had
|
|
additional critical no-fix findings; the tested distroless Debian 13 alternative changes Python,
|
|
UID and runtime compatibility. No fix-available finding is accepted. The next scan must consume
|
|
every tuple exactly; a fixed, changed, missing or newly introduced tuple fails closed.
|
|
The source-only `full` profile validates the registry schema and active dates but defers the
|
|
unused/stale decision for image-targeted Trivy entries, because it produces no image evidence.
|
|
Only the explicit `security` profile may consume those entries, and it fails when any such tuple is
|
|
unused. A passing `full` run therefore never substitutes for the mandatory exact-image run.
|
|
|
|
Trivy report parsing is strict before risk matching. JSON evidence rejects duplicate object keys;
|
|
the pinned Trivy 0.74.0 filesystem scan must report `ArtifactType=repository`, and its
|
|
`ArtifactName` must equal the resolved checkout path exactly. Every result target must remain
|
|
inside that checkout. Image `ArtifactType`, `ArtifactName` and every result target are likewise
|
|
required and bound to the immutable image identity. Each vulnerability must contain
|
|
non-empty `VulnerabilityID`, `PkgName`, `InstalledVersion` and a valid `Severity`; malformed values
|
|
become schema-policy failures, never fallback identities, and therefore can never be waived.
|
|
|
|
## Retained result
|
|
|
|
The evaluator always replaces the bootstrap result when it runs and writes:
|
|
|
|
- commit, ref, run ID, profile and evaluation time;
|
|
- exact scanner versions and Trivy database time;
|
|
- every expected operation's exit code;
|
|
- SHA-256 for every retained, manifest-approved report (unknown evidence paths fail before bytes are
|
|
read or hashed);
|
|
- expected and actual targets by scan class;
|
|
- resolved source transitive packages and sanitized image identities when applicable;
|
|
- blocked findings;
|
|
- applied and unused/invalid false-positive classifications;
|
|
- applied and unused/invalid accepted risks;
|
|
- the final `PASS` or `FAIL` decision.
|
|
|
|
The security job uploads its unique retained
|
|
`/tmp/modelforge-security-evidence-${run_id}-${run_attempt}-${sha}.${random}` directory with
|
|
`gitea-upload-artifact@81f940d004763f986ba3582c007fd842dd5cb0d7`, `if: always()`,
|
|
`if-no-files-found: error` and 90-day retention. Its artifact name includes run ID, run attempt and
|
|
source SHA. Upload failure is a failed gate, not an evidence-retention warning.
|
|
|
|
Implementation and synthetic local tests are not release verification. RC-DEL-02 remains
|
|
unverified until both an exact-head managed `security` run (including its retained artifact) and an
|
|
exact-head managed `full` run succeed. A first real runner execution may legitimately be red because
|
|
it sees current dependency/image advisories or runner/network deficiencies; no historical
|
|
vulnerability risk has been seeded to manufacture a green result.
|