293 lines
18 KiB
Markdown
293 lines
18 KiB
Markdown
# Build and supply-chain gate
|
|
|
|
## Local blocking controls
|
|
|
|
- `pnpm install --frozen-lockfile --ignore-scripts` and a real manifest/lock
|
|
mismatch fixture; contract loading applies the root-only graph and lifecycle
|
|
policy to every registered command before the gate runner can spawn one, and
|
|
rejects nested installs without effective `--ignore-scripts`; npm script
|
|
traversal includes existing pre/post hooks unless an ordered bare or explicit
|
|
true `--ignore-scripts` suppresses them. Workspace/prefix and indirect
|
|
user/global config authority are rejected in npm options, direct or dynamic
|
|
assignments, exact `command`/`exec`/`env` prefix chains, cross-segment shell
|
|
state, and the inherited runner environment. Unknown or cwd-changing `env`
|
|
options and unquoted pre-delimiter pathname expansion fail closed, while the
|
|
explicitly modeled non-scope `env` options remain usable
|
|
- all direct and transitive lockfile rows with package SHA-512 integrity
|
|
- production/development, direct/transitive and platform-optional classification
|
|
- package-manifest license allow/deny policy
|
|
- approved inventory baseline digest and actual add/remove/change/upgrade diff
|
|
- independent review for new direct production dependencies
|
|
- CycloneDX 1.6 SBOM and inventory component/edge coherence
|
|
- source/lock/SBOM/dist-linked local provenance statement
|
|
- source, opt-in recipes, scripts, tests, tracked config/schema, public, built asset and generated
|
|
release metadata secret scan
|
|
- two-build `SOURCE_DATE_EPOCH` reproducibility check
|
|
|
|
The canonical commands are:
|
|
|
|
```bash
|
|
corepack pnpm verify:lockfile
|
|
corepack pnpm verify:reproducible-build
|
|
corepack pnpm build:release-candidate
|
|
corepack pnpm verify:local-evidence
|
|
corepack pnpm check:supply-chain:fixtures
|
|
```
|
|
|
|
`config/security/dependency-baseline.json` is the approved local baseline.
|
|
Changing it requires `DEPENDENCY_BASELINE_OWNER` and
|
|
`DEPENDENCY_BASELINE_REASON`; editing the digest or hardcoding an empty diff is
|
|
rejected.
|
|
|
|
## External promotion controls
|
|
|
|
Promotion reads the provider files named by `VULNERABILITY_REPORT_PATH` and
|
|
`PROVENANCE_ATTESTATION_PATH`. The vulnerability report must bind both the
|
|
exact lockfile digest and candidate `distSha256`; the provenance attestation
|
|
must name `dist` with that same digest. Both documents use strict schemas and
|
|
Ed25519 signatures verified with separately configured trusted public keys and
|
|
key IDs (`VULNERABILITY_PUBLIC_KEY_PATH`, `VULNERABILITY_KEY_ID`,
|
|
`PROVENANCE_PUBLIC_KEY_PATH`, and `PROVENANCE_KEY_ID`). Keys of another curve,
|
|
including Ed448, are rejected even if a document labels its algorithm
|
|
`Ed25519`. The two roles must use different key IDs and different canonical
|
|
DER-SPKI key bytes; giving the same key two IDs is rejected.
|
|
|
|
`immutable_build` archives the raw `pnpm-lock.yaml`, `dist` (including hidden
|
|
`.vite` files), the build manifest, module inventory, release verification,
|
|
secret-scan result, and local supply-chain evidence once. The candidate
|
|
manifest hashes the raw lockfile bytes and requires that digest to equal the
|
|
dependency inventory's `lockfileSha256`. Before upload, the producer validates
|
|
the manifest-bound exact archive member set and every member digest, then
|
|
publishes the archive SHA-256 as an immutable job output. The two provider jobs
|
|
download this same archive separately, compare that output digest, validate the
|
|
exact member set before extracting only into isolated roots, and receive
|
|
`CANDIDATE_LOCKFILE_PATH` and `CANDIDATE_DIST_SHA256`; configured
|
|
`VULNERABILITY_PROVIDER_COMMAND` and `PROVENANCE_PROVIDER_COMMAND` must emit the
|
|
signed reports. After each external command returns, provider upload validation
|
|
rechecks the unchanged archive and extracted candidate, parses the provider JSON
|
|
with its strict schema, and binds its dist and lockfile digests before upload.
|
|
|
|
If either provider input is absent, local verification remains meaningful but
|
|
`artifacts/security/supply-chain-verification.json` records
|
|
`promotionStatus: FAIL_UNVERIFIED`. The finalizer and downstream
|
|
`verify:promotion` exact-five validator then exit non-zero. Promotion derives
|
|
the candidate file set and digests only from the captured tar bytes, then
|
|
read-only revalidates archived executable schemas, archived policy/verifier
|
|
source bytes, raw lockfile, module inventory, build outputs, release coherence,
|
|
SBOM, provenance and supply-chain coherence. Candidate-internal checks are
|
|
recomputed; for the checkout-dependent secret scan, promotion independently
|
|
checks the archived policy, exact rule set, strict SARIF, zero findings and all
|
|
manifest/assessment digest bindings. It does not claim to rescan source bytes
|
|
that are not candidate members. It never rebuilds or rewrites candidate
|
|
evidence and never falls back to the checkout tree.
|
|
Promotion uploads the already verified archive itself with the two provider
|
|
reports and generated verification records; it does not create a replacement
|
|
archive from extracted files. Scanner or signing outages are not converted to
|
|
an empty PASS.
|
|
|
|
The generated workflow is also a supply-chain control. `config/ci/gates.json`
|
|
is its sole typed authority. Run `corepack pnpm generate:ci-workflow` after a
|
|
contract change and `corepack pnpm check:ci-workflow` (or the encompassing
|
|
`corepack pnpm check:ci`) to reject byte drift in the checked-in Gitea adapter.
|
|
|
|
Action resolution is separately closed over one typed, runtime-frozen registry
|
|
in `scripts/contracts/ci-gates.ts`. Every generated `uses:` value is an absolute
|
|
upstream URL pinned to a full commit SHA:
|
|
|
|
- `https://github.com/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5`
|
|
(`v4.3.1`)
|
|
- `https://github.com/actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020`
|
|
(`v4.4.0`)
|
|
- `https://github.com/ChristopherHX/gitea-upload-artifact@81f940d004763f986ba3582c007fd842dd5cb0d7`
|
|
(patched `v4` branch)
|
|
- `https://github.com/ChristopherHX/gitea-download-artifact@75635f32b4c1c41c4b3d64e8f85210112ed4c9c7`
|
|
(patched `v4` branch)
|
|
|
|
Unknown actions, relative repositories, tags/branches and short SHAs are
|
|
rejected. Gitea 1.22's official Actions documentation recommends the
|
|
`ChristopherHX` patched artifact forks for v4 compatibility; the supported
|
|
deployment baseline is nevertheless Gitea 1.26.4+ with Gitea Runner 1.0.0+.
|
|
A real end-to-end provider smoke on the staging Gitea instance remains
|
|
mandatory before any generated job becomes a required check.
|
|
|
|
External provider supervision is fail-closed and requires a Linux runner with
|
|
executable `/usr/bin/bwrap`, `/usr/bin/prlimit`, `/usr/bin/systemd-run`, and
|
|
`/usr/bin/systemctl`, bubblewrap support for `--size`, an active user bus, a
|
|
systemd user manager version 254 or newer, unified cgroup v2, and delegated
|
|
memory, pids, and CPU controllers. Executable access, user-manager/version,
|
|
trust, archive, and path failures are rejected before raw creation. Bubblewrap
|
|
`--size` acceptance and effective delegated controller values can only be
|
|
verified after the owned raw inode exists inside a new scope; failures there
|
|
remove that inode by identity and fail closed. Each invocation runs in a unique collected user scope and verifies
|
|
its effective cgroup membership and limits before bubblewrap starts: memory is
|
|
exactly 1 GiB, swap is zero, `TasksMax` is 64, and CPU quota is 100% per 100 ms.
|
|
The inner process also has zero core size, a 8,388,607-byte file-size limit,
|
|
64 open files and at most 1,200 CPU seconds. `TasksMax=64` is the authoritative
|
|
aggregate PID boundary; no per-provider `RLIMIT_NPROC=32` claim is made because
|
|
that limit is counted across the runner's same-UID process population.
|
|
|
|
Bubblewrap uses a private network namespace (`--unshare-net`), mounts the
|
|
workspace and verified candidate read-only, hides `.git`, and read-only binds
|
|
the trusted `process.execPath` at `/tmp/node`. The supervisor creates and pins
|
|
the exact configured raw-report inode only after sandbox/trust/archive
|
|
preflight; that inode is the only provider evidence path mounted read-write.
|
|
The provider cannot write the surrounding `untrusted` directory, workspace,
|
|
candidate, host home/toolcache, sealed evidence path, or general temporary
|
|
filesystem. Pre-execution and provider failures remove only the supervisor-
|
|
owned raw inode so the same job can retry without a stale empty report.
|
|
|
|
The complete bwrap argument/environment vector, including the provider command
|
|
and provider-prefixed environment, is carried in a bounded length-prefixed frame over
|
|
`systemd-run` stdin rather
|
|
than placed in the supervisor, systemd, or bwrap wrapper argv. This prevents
|
|
credentials from entering unit metadata and wrapper command lines. The final
|
|
provider executable and its ordinary arguments remain visible to same-UID
|
|
process inspection, so commands must never contain tokens or secrets. The same
|
|
stdin remains open as a parent-liveness channel until normal provider exit; EOF
|
|
caused by supervisor death makes the in-scope wrapper kill the provider process
|
|
group and remove only the dev/inode-matched raw report. Supply
|
|
credentials only through the provider-kind prefix
|
|
(`VULNERABILITY_PROVIDER_*` or `PROVENANCE_PROVIDER_*`, excluding `*_COMMAND`).
|
|
|
|
A separate trusted guardian starts outside the provider scope and owns the
|
|
filesystem transaction. Before spawn, the client opens and identity-checks the
|
|
canonical raw and evidence directories with `O_DIRECTORY|O_NOFOLLOW`, derives
|
|
the exact canonical and nonce-private leaves, and exclusively allocates empty
|
|
mode-`0600` raw-staging and sealed-temp files. It records both dev/inode pairs
|
|
before spawn and inherits the directory descriptors as guardian fd 3/fd 4 and
|
|
the private file descriptors as fd 5/fd 6. Its argv contains only the trusted
|
|
Node and helper paths. A bounded canonical v2 request carries only provider
|
|
kind, an absolute deadline, and a random 32-byte control nonce.
|
|
|
|
At bootstrap the guardian fstats fd 5/fd 6 before reading their procfs links.
|
|
Each procfs target is accepted only as a direct-child alias whose exact grammar,
|
|
descriptor-relative lstat, type, mode, size, link count, and dev/inode match the
|
|
already-recorded descriptor identity. The guardian publishes raw staging to the
|
|
fixed raw leaf with a no-overwrite hard link, verifies both aliases at link
|
|
count two, removes the private raw alias, syncs the raw directory, and verifies
|
|
the canonical raw alias at link count one before authenticated READY. If startup
|
|
ends before READY is accepted, the client cleans raw staging/canonical only when
|
|
they match its pre-spawn raw identity and sealed temp/final only when they match
|
|
its pre-spawn sealed identity. It never derives cleanup ownership by opening a
|
|
current canonical pathname, so an external file or a concurrent same-kind
|
|
winner is preserved. Provider execution starts only after the supervisor
|
|
confirms that the returned identities and canonical targets match exactly.
|
|
|
|
After evidence validation, the supervisor writes schema-validated bytes to the
|
|
pinned temp inode, changes it to `0400`, fsyncs it, and sends authenticated
|
|
size/hash/identity metadata. The guardian verifies the held descriptor and
|
|
pathname, publishes without replacement using same-directory `link`, removes
|
|
the temp name, fsyncs the directory, and returns authenticated PUBLISHED. Only
|
|
after successful `GITHUB_OUTPUT` append does the supervisor send commit. Commit
|
|
removes the raw inode and enters `commitPending`; clean control EOF is the sole
|
|
success terminal and preserves the sealed final. EOF without that terminal,
|
|
deadline expiry, malformed/trailing control data, a wrong nonce, or premature
|
|
guardian exit cleans every matching raw/temp/final identity and fails closed.
|
|
Guardian loss while the provider scope is active also triggers whole-scope kill
|
|
and collection.
|
|
|
|
`GITHUB_OUTPUT` is assumed to be a runner-owned regular file. This protocol
|
|
does not claim OS-level cancellation of sealing or output I/O. If the guardian
|
|
exits after scope collection, the scope-active latch records the lifecycle error
|
|
without starting a late kill. Publication or terminal commit observes the
|
|
nonzero exit and the client removes every identity-pinned raw/temp/final
|
|
fallback before failing closed. The lease is bounded by the 30-minute provider
|
|
wall limit plus a fixed ten-minute post-processing allowance.
|
|
|
|
A client hard stop after private allocation but before guardian spawn can leave
|
|
only empty mode-`0600` nonce-private leaves. Automatic sweeping is intentionally
|
|
omitted because an unrecorded pathname does not prove ownership; these private
|
|
leaves cannot occupy the fixed raw or final names and do not block a retry.
|
|
|
|
Provider stdout and stderr are untrusted secret-bearing bytes. The supervisor
|
|
does not retain or forward them to CI logs; it counts them only to enforce one
|
|
1 MiB aggregate limit. Guardian diagnostics are best effort, so closed stderr
|
|
or control descriptors cannot bypass cleanup or the required nonzero exit. The
|
|
provider wall-clock limit is 30 minutes. Wall timeout, output overflow, parent-liveness loss,
|
|
and active-scope guardian loss explicitly SIGKILL the whole provider process group. Ordinary command and
|
|
RLIMIT failures complete through systemd with their concrete exit/signal; every
|
|
path still waits for wrapper closure and requires the systemd unit/cgroup to be
|
|
collected before returning. Provider adapters must therefore operate entirely from pre-populated
|
|
offline data, consume the supplied candidate bindings, and write exactly the
|
|
pinned report inode. Missing prerequisites, cgroup drift, stale or misplaced
|
|
outputs, post-command candidate drift, and residual scope cleanup all stop
|
|
publication.
|
|
|
|
Provider documents are strict schema v2. Their Ed25519 signature covers the
|
|
supervisor-supplied evidence type, validity window, run ID/attempt, independent
|
|
32-byte invocation nonce, archived source identity, and all four candidate
|
|
digests. Each provider job exposes its supervisor-generated nonce as a job
|
|
output; promotion treats those outputs as the independent expected values and
|
|
never lets a report define its own expected nonce. A report from another
|
|
attempt, source, archive, nonce, or key fingerprint is fail-closed even when it
|
|
has been correctly re-signed.
|
|
|
|
The immutable archive contains a strict producer-local assessment plus the
|
|
policy and verifier source bytes needed to validate its archived relationships.
|
|
Promotion recomputes candidate-internal checks and validates the captured
|
|
secret-scan policy/rules/SARIF/digest relationships from an isolated extraction
|
|
root; it does not reopen checkout policy or source paths or claim to rescan
|
|
unarchived checkout source. The finalizer captures the archive,
|
|
both reports, and both public keys once, generates both verification v3 records
|
|
in memory, and writes exactly five mode-`0400` files beneath a random
|
|
mode-`0700` directory in `RUNNER_TEMP`, independently of a restrictive runner
|
|
umask. The exact five are the captured archive, captured vulnerability report,
|
|
captured provenance attestation, generated provider-verification v3, and
|
|
generated promotion-verification v3. Before returning, the finalizer validates
|
|
those exact bytes again with live-time provider signature/freshness checks. The
|
|
promotion record binds the exact provider-record hash, local-assessment hash,
|
|
both report hashes, run/source/candidate identities, both nonces, both key
|
|
IDs/fingerprints, canonical trust-policy hash, and the vulnerability provider's
|
|
signed `secretScanAttestation`. That strict attestation says `PASS` and binds
|
|
the captured local-assessment, source-set, secret-scan policy, SARIF, and actual
|
|
scan-input digests. The supervisor derives the expected tuple from the captured
|
|
archive and exact equality is rechecked at upload and finalization. The trusted
|
|
vulnerability provider remains responsible for independently scanning that
|
|
source set and refusing to sign a forged empty SARIF or incomplete scan input;
|
|
the signature proves the provider made the claim, not that an untrusted
|
|
provider performed the scan honestly. It never creates or reuses
|
|
`.release/promoted-staging`.
|
|
|
|
The final promotion verification/staging step must be immediately adjacent to
|
|
the promoted-release upload, and that upload must not use `always()`. This
|
|
reduces the post-verification mutation window but does not seal a pathname
|
|
across two action steps. The runner is therefore required to be trusted,
|
|
exclusive and single-tenant, with no provider command or other same-UID process
|
|
surviving from staging into the immediately following upload. The artifact
|
|
service and transfer actions also remain outside the candidate's cryptographic
|
|
identity: every downstream consumer must revalidate the downloaded archive,
|
|
manifest member digests and signed provider evidence. Producer-side adjacency
|
|
does not provide consumer-side digest revalidation.
|
|
The promotion job has no job-level `if`: ordinary `needs` success semantics
|
|
require immutable build and both provider jobs to succeed, and cancellation is
|
|
not overridden with `always()` or `cancelled()`. Cleanup alone uses bare `always()`
|
|
and is guarded by all six finalizer outputs: staging path, token, parent
|
|
device/inode, and staging-leaf device/inode. Cleanup opens the pinned leaf,
|
|
requires the exact five names, unlinks only those known files through the
|
|
descriptor, and uses a non-recursive `rmdir`; an exchanged directory or canary
|
|
is never recursively removed.
|
|
The immediately following upload action still reopens pathnames. The
|
|
descriptor-relative staging and cleanup code does not claim an atomic
|
|
`renameat2` handoff or close a malicious same-UID Gitea upload adapter; the
|
|
staging Gitea smoke/native platform adapter remains the required closure for
|
|
that boundary. That smoke must exercise exact-five upload and download plus
|
|
cleanup on success, validation failure, upload failure, and cancellation. No
|
|
native uploader or `renameat2` guarantee exists in this repository today.
|
|
Portable Node also cannot make `mkdir` plus the first pathname `lstat` atomic.
|
|
The implementation compares the immediate post-`mkdir` identity with the
|
|
subsequent `O_DIRECTORY|O_NOFOLLOW` descriptor before changing permissions, but
|
|
a malicious same-UID actor that wins before that first `lstat` remains part of
|
|
the native/privilege boundary. The private `0700` runner-temp ancestor and
|
|
single-tenant runner requirement are therefore security controls, not merely
|
|
hardening.
|
|
Failure cleanup is armed only after that created device/inode matches the opened
|
|
descriptor. If the opened descriptor is a replacement, it is closed without
|
|
unlinking or removing anything; the visible replacement is likewise untouched.
|
|
Portable Node cannot safely rediscover an attacker-moved original directory by
|
|
scanning the parent, so that residual must be removed by the isolated test
|
|
fixture or trusted runner/native cleanup after the attacker is excluded.
|
|
|
|
Approved vulnerability exceptions require vulnerability/package identity,
|
|
owner, a different reviewer, reason and expiry. Expired or self-approved
|
|
exceptions are blocking.
|