chore: initialize from frontend template 4dc033c

This commit is contained in:
DongHyeonka
2026-08-13 18:23:26 +09:00
commit 40107eec84
897 changed files with 234824 additions and 0 deletions
+13
View File
@@ -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.
+292
View File
@@ -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.