Files
ModelForge-Public/docs/security/SUPPLY_CHAIN_RELEASE_GATE.md
T

20 KiB

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:

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:

{
  "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.