refactor: adapter 구현중..
This commit is contained in:
+175
-33
@@ -2,7 +2,17 @@
|
||||
|
||||
## Local blocking controls
|
||||
|
||||
- `pnpm install --frozen-lockfile` and a real manifest/lock mismatch fixture
|
||||
- `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
|
||||
@@ -39,7 +49,8 @@ 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`.
|
||||
`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,
|
||||
@@ -58,15 +69,21 @@ 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`. `verify:provider-evidence` and
|
||||
`verify:promotion` then exit non-zero. Promotion recomputes the candidate file
|
||||
set and digests, then read-only revalidates the archived executable schemas,
|
||||
raw lockfile, module inventory, build outputs, release coherence, SBOM,
|
||||
provenance, security scan and supply-chain coherence. It never rebuilds or
|
||||
rewrites candidate evidence. Promotion uploads the already verified archive
|
||||
itself with the two provider reports and verification records; it does not
|
||||
create a replacement archive from extracted files. Scanner or signing outages
|
||||
are not converted to an empty PASS.
|
||||
`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
|
||||
@@ -94,17 +111,107 @@ 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
|
||||
an executable `/usr/bin/bwrap`. Bubblewrap mounts the repository workspace
|
||||
read-only, hides `.git`, and read-only binds the trusted `process.execPath`
|
||||
inside the sandbox at `/tmp/node`. It exposes only the sibling `untrusted` raw-evidence
|
||||
directory as writable. Provider commands run non-interactively through
|
||||
`/bin/sh -eu -c`, receive a minimized environment plus only their own
|
||||
provider-prefixed credentials, and have a 30-minute limit. They must consume
|
||||
the supplied candidate paths and digests, write exactly the configured raw
|
||||
report, and must not depend on workspace mutation, host home/toolcache access,
|
||||
or direct access to the sealed evidence path. Missing sandbox support, stale or
|
||||
misplaced outputs, command failure/timeout, and post-command candidate drift
|
||||
all stop publication.
|
||||
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
|
||||
@@ -115,17 +222,31 @@ 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. Promotion
|
||||
revalidates it from captured archive members without reopening checkout policy
|
||||
or source paths. 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`. The exact five are the captured archive, captured vulnerability
|
||||
report, captured provenance attestation, generated provider-verification v3,
|
||||
and generated promotion-verification v3. 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, and canonical
|
||||
trust-policy hash. It never creates or reuses `.release/promoted-staging`.
|
||||
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
|
||||
@@ -137,6 +258,14 @@ 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
|
||||
@@ -144,6 +273,19 @@ 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
|
||||
|
||||
Reference in New Issue
Block a user