chore: initialize from frontend template 4dc033c
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
# Browser security boundary
|
||||
|
||||
The browser bundle is public. Secrets, token lifecycle, raw HTML injection,
|
||||
dynamic code execution, untrusted script URLs, and public production source
|
||||
maps are prohibited defaults.
|
||||
|
||||
`config/hosting/security-headers.json` is the declared header set. Hosting
|
||||
verification compares that declaration with live responses. CSP deliberately
|
||||
omits `unsafe-inline` and `unsafe-eval`; production code and built assets must
|
||||
remain compatible with that baseline.
|
||||
|
||||
Route guards are UX hints and client validation does not replace backend
|
||||
authorization or validation.
|
||||
@@ -0,0 +1,292 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user