Security
Sparkwing authorizes controller operations, execution claims and stored data separately. This guide describes the rewrite's security contract. Native controller, host, storage and deployment bindings still need acceptance evidence. A fake-backed client test proves transport behavior, rather than a deployed security boundary. See rewrite integration.
Report suspected vulnerabilities through GitHub's private vulnerability form. The repository's security policy states what to include.
Trust model
The team is the tenant boundary. Within a team, repository authority, secret scope, declared read edges, reserved pools and claim bindings narrow access. A name, label or uploaded digest grants no authority by itself.
The controller derives the caller, team, granted runner identity, repository access, billing provenance and claim fence. It rechecks those facts in the same transaction as a mutation. The store clock decides whether a holder is live. A client cannot replace those facts with headers, environment variables or JSON.
Scheduled launches pass only a tenant, stored schedule ID and due instant to the scheduled-intake transaction. That transaction must recheck current arming consent and derive the actor, repository, source and replay identity from the stored row. The controller refuses a missing or wrong-team transaction adapter. Its schedule tests substitute consent and use real sealed intake. They do not establish the store's schedule-consent implementation or a working native timer.
Pipeline authors execute code with the permissions of the runner's OS account. Same-account code may read the runner's private credential file, other files or process memory. Use a dedicated account, container or VM and enroll only code that may use those permissions. Private files and filtered process environments reduce disclosure. They do not isolate hostile processes sharing an account.
Cloud execution runs every Job container as root of its own Kubernetes user namespace on nodes permanently bound to one team and run; that root maps to an unprivileged host UID. No Job container is privileged or holds node root. A launcher credential stays outside the Job. See Runner Job placement.
An authorized pipeline can use and disclose its secrets. Masking covers values
registered with the host and their supported encodings. Arbitrary transformations,
network exfiltration and files outside the host's output path are outside that
protection. Plan acceptance refuses known registered secrets in job, step, group
and approval IDs because stored identifiers cannot be masked safely. A lazily
issued secret is registered before release without invalidating exact replay of
accepted topology. Every fresh identifier, including generated job IDs, is checked
against issued values as well as declared secrets. One Go host module masks every presentation payload. Stdout and stderr
are masked as byte streams before line splitting. Protocol frames are decoded
first, then their payloads are sanitized before trusted envelopes are built.
The accepted run seals one registration projection containing raw arguments,
secret defaults, effective values and every normalized list item. Stored secrets
and OIDC tokens register with the shared host masker before release. Explicit
Mask registrations affect only the registering job and end with its last
process. Child pipeline names, arguments, repository and branch pass the output
secret check before leaving the host.
See secret values for the masking
length rule and save-time warning. Empty supplied arguments and defaults carry no value.
Defaults of unselected pipelines still register when they are long enough. Secrets containing *** are accepted. Replacement markers can
form a registered value containing ***, or a value containing * beside a marker,
across published stream emissions. This rare marker-edge case is accepted.
The old twelve-byte line/list fragment policy
is removed. Remote environment values still use their separate twelve-byte
registration threshold. Plain submitted config is preserved when it is too short
to register a mask. Planning output waits until declarations are known.
Failed discovery withholds output. A healthy selected pipeline can still plan
when a sibling fails discovery, with planning output withheld. The withheld
notice names how many pipelines failed and their error codes, never their names,
and gives the isolated-home retry when that refusal is the only failure. A
missing risk allowance is still named with its labels. Private
declarations drive lookup, flag parsing and routing. Help, display and the
describe cache use a separate presentation type. Incomplete discovery withholds
all unclassified presentation text, including names and job IDs. Help reports the
incomplete-discovery diagnostic. Structured
attributes are masked as JSON text and checked for valid JSON, duplicate keys
and object shape. A failed check
replaces the whole attribute payload. Pipeline code run without a Sparkwing host
is unmasked. See the masking contract for
encodings and overlap rules.
Authentication and authorization
Controller capabilities use swk_. Node-host capabilities use swn_. Each
contains 256 random bits. Prefixes select an audience and a public metadata
selector. They do not grant a role. Lookup hashes the complete bearer with
SHA-256. Lists and receipts contain metadata, rather than reusable credentials.
Each route declares accepted credential classes, exact grants, binding,
liveness and retry policy in one registration table. admin is not an implied
wildcard. Deployment operational credentials cannot replace an operator-console
session. A live caller missing authority gets 403. A dead credential gets
401. Canonical errors carry error.code, message, optional hint and details.
See authentication for the grant and issuance contracts.
Team boundary route table
HTTP and api.sock share route registration and authorization. A team-owned
resource is resolved through the authenticated team. Cross-team access requires
the route's specific service authority. An unregistered route fails closed.
Local peer authentication supplies an account identity and still uses the same
route policy.
Delegation narrows its parent's authority. Award creates one credential for a selected unit under the claim transaction. Exchange consumes a bootstrap once. Revocation checks capability and session ancestry, including the requesting ancestor of a here award. A root that may claim work has no arbitrary execution write authority.
Sensitive reads need a live bound claim. A terminal job's bounded final log flush does not reopen general data, source or secret access. A job-process credential dies with its process. Plan, eval, owner and job bindings retain their separate permissions.
Run intake and child calls
A supplied Git SHA must be empty or a full 40- or 64-character hexadecimal object ID. Revision names and option-shaped values are refused before admission. API input cannot choose the submitting account or attach verified App facts.
A child call names its live parent job. Only that job can read the child through
its parent relation. The private job snapshot supplies execution inputs, while
public run details keep secret arguments redacted. Admission checks ancestor
pipeline and repository identities within the same team. Re-entering one returns
409; a missing ancestor returns 404 without disclosing foreign run facts.
Sealed run identity
Intake seals each run with the identity it speaks for, derived from facts the
admission transaction verified and never from the request body: a trust class,
the ref, commit, event and pipeline, and GitHub's repository ID for every class
but asserted. The classes are verified-default (a signed App push to the
default branch), verified (any other signed App delivery), actions (a run
bound to a GitHub Actions push), schedule (a fire of a schedule a team admin
armed, following the default branch GitHub reports at fire time, unlocked and
unmodified, at the commit the App resolves) and asserted (every run whose ref,
commit or inputs a principal chose, including schedules a non-admin armed,
locked, overridden or non-default-branch schedules and "run now"). OIDC claims, data and
cache scopes, pool access rules and pipeline-scoped secret release read only
this value.
A job may start only the child pipelines its plan declares with
StartsChildren; an undeclared child is refused with 403. A child either runs
its parent's exact revision (same repository, ref, commit and code commit) and
inherits the parent's identity whole, including its repository and pipeline
for OIDC and pipeline secrets, or names a branch that the controller resolves and is
sealed asserted under its own pipeline name. An asserted child reads only
team-wide shared secrets, like a GitHub reusable workflow called without
secrets: inherit. A request that pairs another ref or code commit with its
own commit in the inherited repository is refused with 403. An explicit
child repository requires authorization from the parent's live claim and always
runs as asserted. Cycle checks compare repository and pipeline together. A
retry keeps its source run's sealed identity, including asserted provenance,
in full and failed modes. Cache, memo and personal placement use that same
writer because that principal chose the code. The retrier is recorded separately
as the actor requesting the retry.
Only the sealed writer's principal can retry a --runner here or other local
run. Another member must start a new run because a retry keeps the source
writer's code and data scope.
An Actions push-bound runner takes work only when its sealed provenance proves the binding's exact repository, ref and commit through Actions or a verified App delivery. An asserted run cannot use that runner by naming the same ref and commit.
A source: code pin that is a full commit ID keeps the run's class. A branch or
tag pin that does not resolve to the execution commit demotes the run to
asserted, because whoever can push that ref chooses the code. The demoted run
still reads its repository's pipeline secrets, because the controller fetches its
code from the repository; see secrets.
Bare names must identify a branch or tag. If both exist at different commits,
resolution fails. Use refs/heads/main or refs/tags/main to choose one.
Commit pins require a full commit ID. Scheduled execution resolves the named
branch in refs/heads/, so a tag with the same name cannot select its code.
Reserved pools are granted only to verified App runs, the children and retries that inherit them, and admin-armed default-branch schedule fires, which receive only pools whose rules allow the default branch. A typed repository grants no pool. Access is checked against the rules as they stand at each use: placement, reserved secret release and OIDC tokens. Removing a rule cuts off every run of that repository at its next use, including retries of older runs. Owner-approved extra repositories join the source token only for a verified push to the default branch.
On Kubernetes, verified default-branch work runs only on fresh, single-use pods. This includes admin-armed default-branch schedules, plus children and retries that inherit verified default-branch provenance. Persistent Kubernetes runners serve other work. With only persistent Kubernetes runners, verified work waits; add a launcher pool or use Sparkwing Cloud. The controller reads the platform from the admin-issued enrollment, never from job claims. Plain-machine runners (VMs and bare metal) keep serving verified work under the same OS user, so job code can read runner credentials and leave host state for later jobs.
A long-lived runner (a plain machine or a persistent Kubernetes runner) runs
every job with the runner's own credential within reach. When it serves several
repositories, a job from one repository can act as the runner and claim another
repository's jobs in the same team, and with them that repository's pipeline
secrets, source token and OIDC identity. The isolation unit is the runner, not
the repository: enroll a shared runner with --repos limited to one repository, or to
repositories that trust each other, and use launcher pools or Sparkwing Cloud,
which give each claim a fresh pod, where repositories must stay apart. Pools
that hold reserved secrets already require a launcher.
Personal enrollment may omit --repos because personal machines take only
their owner's asserted runs. They never claim repository-proven work. Shared
runner enrollment requires repository scopes, which accept only proven work.
A pass that sw.FromProvenRunAtCommit() counts must come from trusted hosts
only. When a job's attempt starts, the controller records from the claimant's
authority chain, never from names the runner reports, whether its host is
trusted: a fresh host (a launcher pod, a Sparkwing Cloud node or a
GitHub-hosted Actions runner), or a machine whose team admin registered it with
--trust verified-default or --trust verified, which never take asserted
work. A run counts only if every job that did not skip ran on a trusted host, so
a runner that takes asserted work, which could leave HOME, GOFLAGS or the
build cache poisoned for later jobs, never produces a counted pass. Runners
registered with --trust any or no trust never do.
Two gaps remain. A team's own launcher credential counts as a fresh host
whatever trust class it was registered with, because each claim gets a pod no
other run has used. The host that evaluates the plan is not covered by the
trusted-host check, because only jobs record it; record-tree reads the other
jobs' outputs itself rather than trusting the plan to say they ran.
A verified run on a host that outlives its jobs (plain-machine runners,
persistent Kubernetes runners and self-hosted GitHub Actions runners) writes
memo, depcache and output records only to that host's own cache scope. Fresh
pods, Sparkwing Cloud and remote cache readers restore only the shared verified
scope, which launcher pods, Cloud pods and GitHub-hosted Actions runners write,
as GitHub's signed runner_environment claim reports, so state an earlier job left on a long-lived host never
reaches them through the cache. Runs on that host still read the verified
default branch. A team administrator can turn on the high_trust team setting
(console team settings, or sparkwing team settings set --high-trust true; off
by default and audited), meant for teams whose every job on those runners is
trusted, to let them write the shared verified scope. A verified default-branch
run reads only cache records that carry default-branch evidence, including in
its own ref scope.
Each Sparkwing Cloud node serves one team and one run. Later jobs of the same run may reuse a warm node; every other run, a child run included, gets a node no other run has used, so nothing one run's job leaves on a node reaches another run. A node's team and run are recorded in labels the node itself cannot change; a node whose run label does not name the run is never reused. Cloud nodes may pull only Sparkwing's own images.
Cloud jobs get the chart's NetworkPolicy: outbound internet on any port, as on other CI providers, and nothing in private, carrier-grade NAT or link-local ranges, which covers instance metadata and the Pod Identity agent. The cluster must enforce NetworkPolicy; Sparkwing does not probe enforcement at runtime. Verifying it is the operator's periodic check.
Child runs nest at most 10 levels deep counting the root, and one root run
uses the team-set descendant ceiling, from 1 to 1000, default 50 at every depth, retries excluded.
Team admins change the ceiling in Team settings or with
team settings set --max-children-per-root; changes are audited. Either bound
refuses with 409 and conflict detail limit set to max_child_depth or
max_children_per_root. A child spends the hourly run, flood and queue-depth
budgets of whoever created its root, not the runner holding the parent's
claim. See limits.
Approvals
An approval pauses a run until a team member with approvals.write (an editor
or owner, including the member who started the run) approves or denies
it. It is a workflow step, not a security control. It does not enforce a
separate reviewer, and it does not narrow what the run's code, secrets or OIDC
tokens can reach. Protect a deployment target with that target's own controls,
such as a protected branch, a reserved pool or the relying party's OIDC claim
checks. Each decision is audited as approval.decided.
Request audit log
Audit records identify the route, operation, team, subject and public credential
prefix. Revocation targets appear after the store commits. Logout and browser
session replacement record session_digest, revoked_account_id and
revoked_team for the session that ended. Identity unlink records
kept_session_digest and one audit line per ended session with its digest,
account and team. Every audit field replaces complete Sparkwing credential
shapes with a short SHA-256 fingerprint before truncation. Public 16-character
credential prefixes remain readable. The controller generates every request ID,
ignores client-supplied X-Request-ID values and echoes its ID in the response. Source and secret releases record their authorized subject. Raw bearers,
request bodies, captured environment values and secret-bearing hashes do not
belong in logs or receipts. Public run details are redacted display projections.
Approval decisions, run and job cancellations, token minting and rotation,
runner enrollment, and invitation acceptance commit their audit events in the
same transaction. These events contain IDs and closed decision values, without
approval comments, cancellation reasons, email addresses or other personal text.
Each OIDC token a run mints is audited as oidc.minted in the transaction that
rechecks the run's authority, naming the run, a digest of its job, the
audience, and the token's jti, subject and expiry, so a leaked token traces to
its mint. A token whose audit row does not commit is never returned.
Restricted claims and sealed execution inputs must never be rendered as details.
Run creation and retry return display inputs, not the execution seal. Schedule
declarations, overrides, effective arguments and fire history hide every argument
value because schedules have no describe-time secret classification. Execution
claims retain the original values.
Login and request budgets
Capability verification uses indexed SHA-256 lookup. Authentication-cache reuse requires current ancestry and membership generations and the shared five-second TTL. A cached allow is invalid after the relevant revocation or membership change. Browser sessions and CSRF checks retain their separate persistence and verification rules.
Login budgets
The sign-in routes (POST /login and the Google and GitHub flows) carry their
own budgets. One client gets 30 attempts a minute. The listener's existing
budget is 600 per configured hashing slot. Both answer 429 with
Retry-After once drained. The account access token posted to /login uses
SHA-256 capability lookup and fresh ancestry checks. It does not use the
legacy token table, argon2 verifier or principal cache.
Request limits protect a controller process. Persistent usage counters and quotas aggregate in the store. The supported deployment has one controller replica. Coordinated request limiters and cross-replica live-log fan-out are separate work. Flushed log segments remain the persistence fallback.
Run submission budgets
The configured hourly flood cap gives each API token its own bucket for
submissions and retries. App deliveries share their team's separate bucket.
Queue shedding measures that team's pending work.
Exhaustion returns 429; queue shedding returns 503. Both carry
Retry-After. These process budgets complement the store's persistent limits.
Explicit idempotency keys and GitHub delivery IDs identify replays. The store
owns the commit: the first response is 201, and replay returns 200 with
replayed: true and the same run ID. Replay never returns another here bearer
or spends the flood budget. A refused store transaction leaves no replay result.
Identical submissions without an idempotency key create separate runs.
Limits profiles
An operator profile supplies defaults for its declared guards. Explicit operator
values retain their presence, including zero where the setting allows it.
Inspect effective limits with sparkwing operator limits show. Operator ceilings
refuse excessive pipeline values and name both values. They never silently clamp
an author request. See limits for the setting table and budgets.
A 429 or 503 retains its structured error and any Retry-After. Renewal
backoff never counts as a successful renewal. Only operations marked safe by the
route's retry policy can be replayed. A lost claim response or here bearer is
never automatically replayed to recover one-time authority.
GitHub webhooks
The controller verifies the App webhook's HMAC and deduplicates its delivery key. A verified default-branch push carries the webhook's repository, ref and SHA. Acquisition clones exactly that SHA. A later branch-head lookup cannot change those trust facts or reject a valid earlier push. Retry preserves the original trust and records its new requester separately.
Source credentials require live authorized claim access. Team Git credentials also require Cloud work or a shared runner of that team, never a personal machine. Every job credential, including a host-wide credential or GitHub App token, requires a matching sealed repository proof and a successful intake GitHub compare check. Uploaded, working-tree and local-checkout code receives no team credential. Local owner credentials remain subject to the repository allow-list. Every credential release is audited. See Git credentials.
Secrets at rest
Secrets remain encrypted with authenticated row binding. The kind of row (secret, git credential, submitted run environment or controller setting), its team, name, pipeline/shared scope and masking policy participate in the binding. Moving a ciphertext to a row with different access, or to a row of another kind, cannot make it readable there. A value that fails to open is an error, never an empty success value.
Rotating the secrets key
One key seals every team's secrets, git credentials and submitted run environments, and the session signing key. To replace it:
- Generate a new key (
openssl rand -base64 32). - Put the current key in the
secrets-key.previouscredential and the new key insecrets-key, then restart every controller replica. Wait until no replica still runs with the old key alone: one that does keeps sealing new values under it. - Run
sparkwing operator secrets rotatewith a deployment credential holdingoperator.tokens, orPOST /api/v1/operator/secrets/rotatefrom a console session signed in within the last 10 minutes. It reseals every row under the new key, one team at a time, and reports counts,previous_key_only(rows that needed the previous key) and any row neither key opens. It is safe to repeat, including after an interruption. - Run
sparkwing operator secrets rotate --verify(?verify=1). It writes nothing and reports how many rows still open only under the previous key. Run the rotation again until it reportsprevious_key_only: 0. - When verification reports zero and nothing unreadable, remove
secrets-key.previousand restart every replica. A row reported unreadable was already unreadable under both keys; rewrite or delete it first. Back up the new key with the database.
Release integrity
GitHub Actions stores SPARKWING_UPDATE_SIGNING_KEY as a base64-encoded
32-byte Ed25519 seed or its canonical 64-byte private key. Release jobs sign
the final checksum manifest and every platform asset; the updater embeds only public keys. Rotate the key
through three releases: add the replacement key to the updater trust set and
ship that bridge release with the old signer; change the workflow secret to the
replacement signer; remove the old key from the trust set after supported
updaters trust the replacement. The release gate rejects a signer outside the
embedded trust set. Updaters without the replacement key fail closed rather
than accepting an unknown signer.
Container images follow the same rule. The release signs each image digest
with cosign, then moves vX.Y.Z onto it with
docker buildx imagetools create. bin/publish-image-tags.sh resolves every
tag before it moves any of them and fails when one already points at a
different digest, so a workflow_dispatch rerun cannot swap bytes under an
operator who pinned the tag and a refusal cannot leave the registry
half-moved; the force_retag dispatch input is the only override. A registry
lookup that fails for any other reason stops the step rather than reading as
an absent tag. After the moves it re-reads each tag and fails when it does not
resolve to the pushed digest. The floating latest tag is exempt because it is
meant to move. Each release publishes an image-digests.json asset naming
every image, its tag, and its digest, built from that re-read, so operators can
pin digests and diff them between releases. The asset carries an
image-digests.json.sig signed by the release key, so a swapped listing does
not verify; the cosign signature over the digest is still what proves the image
bytes.
Recovering from a publication-only failure is a choice between two dispatch
inputs. A rerun with publish_images: false keeps the images and tags that
already landed, republishes the GitHub release, and omits the
image-digests.json asset because no publish job ran. A rerun with
publish_images: true rebuilds the images, so the guard refuses the tag move
and the run fails; completing it takes force_retag: true, which moves the
version tag onto bytes nobody pinned. Prefer publish_images: false unless
the published images are known bad.
Signed data access
The controller authorizes /data signing and logical records. Bytes transfer
directly to filesystem or object storage using short-lived signed requests.
Signed-object clients receive only signed headers and never attach a reusable
controller credential, including to the controller's own origin.
Every record edge checks authority, integrity and existence. An unrelated same-team or same-run receipt cannot authorize another job's manifest. Each unverified writer keeps its own data scope, so a member's manual run cannot feed memo or cache entries to another member or to App, schedule or Actions builds; see caching. A remote cache profile permits only the user's authorized verified-default-branch memo, artifact and dependency-cache reads, and needs the editor role, because dependency-cache archives are not checked for secrets. It exposes no write or binary-fetch method. Publication is separate, labels provenance as local and creates no execution claim. A publication failure does not change the completed local verdict.
Downloads expire after 60 seconds and verify the digest at EOF. Revocation cannot recall a signed URL already issued, a GitHub credential or an OIDC token. Record reachability controls blob retention. Collection marks unreferenced bytes, waits 24 hours and rechecks before deletion. A live record keeps its bytes even when the originating run has expired. Missing or corrupt recoverable bytes fail as an error or cache miss. See data uploads.
Local daemon socket
The local controller owns its home's store, queue, admission and dashboard. Unix transports require an owner-only socket directory and kernel peer identity. The socket directory and Windows pipe names carry a random id kept in the home, so another account cannot claim them first. Windows uses the authenticated account SID. An unauthenticated or foreign peer is refused. A filesystem path alone is not peer authentication.
Same-account processes and root remain inside this trust boundary. Use distinct accounts and homes where separate users must be isolated. A second store opener is not part of that contract. The local dashboard's TCP listener answers only loopback Host names and its own loopback origin, so a rebound DNS name or another site's page is refused.
TCP carries no peer identity, so the listener also needs a browser session.
sparkwing dashboard open mints a sign-in link over the private socket. The
link carries a random 32-byte token, works once and expires after 60 seconds.
Visiting it redirects to /#s=<session>. The page moves the session from the
fragment into the tab's sessionStorage, clears the fragment, and sends it as
Authorization: Bearer on every API request and event stream. The local dashboard
never sets or authenticates with a cookie: browsers send one to every port on the
same loopback host, so
another account's server on 127.0.0.1 could replay it, while a header goes
only to the origin the page names. A TCP request without a live bearer gets
401, except that the static dashboard pages, which carry no run data, load
signed out. The browser then acts as the socket peer that minted its link.
After verifying the dashboard bearer, the gate consumes its Authorization header
and any controller session cookies, then forwards the verified peer to the shared
controller API. A stale cookie from another port cannot replace that peer.
The dashboard bearer is not a controller capability.
A session ends after 12 hours without a request or 24 hours after sign-in,
whichever comes first, and the dashboard's Log out button ends it at once with
a bearer-authenticated POST /logout. The controller holds tokens and sessions
in memory, so a restart also signs every browser out; run
sparkwing dashboard open again. The socket itself needs no session.
See local execution.
Container hardening
The controller and launcher hold service authority outside pipeline Jobs. Jobs disable service-account token automounting and service links. They receive a single-use bootstrap through a private file and exchange it for their awarded unit. No runner root or launcher credential enters a Job's argv or environment.
Runner Job placement
Every plan, eval and work Job uses the same builder, user-namespaced Pod and
DinD sidecar. The sidecar adds namespaced SYS_ADMIN, NET_ADMIN and
SYS_PTRACE and an unmasked /proc, under a Localhost seccomp profile that
every customer container inherits. The kernel's user-namespace surface,
nf_tables above all, is what an escape would use, so node kernel patch
latency is part of this boundary. A node is permanently bound to one team and run: later Jobs of
that run may reuse it warm, and no other run, of the same team or another,
ever uses it. Aggregate scratch,
including DinD, is fixed at 20 GiB.
Deployment acceptance must prove exclusive node reservation, enforced default-deny network policy, protected metadata/controller/storage access and complete teardown before the reservation is released. Labels and rendered YAML alone establish none of those facts. The launcher creates Jobs without executing pipeline code. Warm-pool dispatchers and pipeline-owned Kubernetes Job builders are removed from the final process model.
Verified self-update
sparkwing update proves the bytes it installs are the release's bytes
before and after it installs them. The release signs the SHA256SUMS
manifest with an ed25519 private key; the updater carries the matching
public key compiled into the binary and verifies the detached
SHA256SUMS.sig with pure-Go crypto/ed25519 -- no external tool and no
network beyond fetching the asset, its detached signature, SHA256SUMS,
and SHA256SUMS.sig. It
then checks the download against the signed digest. A signature covers
bytes, not a tag, so it also reads the staged binary's embedded build
identity without executing it and refuses a version other than the requested
release. It then installs atomically,
and re-hashes the installed file, requiring it to equal the verified
digest. macOS binaries are ad-hoc-codesigned by the release before the
manifest is hashed, so the verified bytes install unchanged -- nothing is
mutated after verification. A signature, digest, download, or install
failure is terminal: the updater never falls back to go install, and a
post-install mismatch restores the prior binary and fails loudly.
The command refreshes managed schedule units and the installed local-controller
service after installation. A success receipt confirms both steps. If the
refresh fails, the diagnostic names the installed CLI version and the retry
command. A build without managed-unit integration refuses before replacement.
sparkwing update --dry-run reads release metadata without installing software
or touching services.
The signing key is release machinery, not per-user configuration:
- Generate a base64-encoded 32-byte Ed25519 seed (
openssl rand -base64 32) and store it as theSPARKWING_UPDATE_SIGNING_KEYGitHub Actions secret. - Add its public key to
internal/releaseauth.TrustedPublicKeys. The release verifier refuses publication unless the secret-derived key is in the updater trust set. - Rotate through the three-release overlap above.
SPARKWING_RELEASE_SIGNING_KEY="$SPARKWING_UPDATE_SIGNING_KEY" go run ./cmd/verify-release --public-keyprints the secret's public key and enforces trust-set membership before release assets are signed.
Static analysis
The security-scan pipeline runs four local scanners. The Security GitHub
Actions workflow runs it on every pull request, on pushes to main, and
weekly. The release workflow calls none of it: a tag builds and publishes the
commit those runs already covered on main.
- gosec over the public module and the
.sparkwingpipeline module, with the rules that describe how a CI tool works (file inclusion and subprocess arguments named by its inputs, cache directory permissions) excluded. The pipeline writes a repository-relative SARIF file that the workflow uploads to GitHub code scanning. The gosec job fails on any high-severity, high-confidence finding, so a false positive goes quiet only through a source annotation. The scan runs with-nosec-require-rulesand-nosec-require-justification, so every suppression reads#nosec GNNN -- <reason>, naming the rules it silences and why, and no-nosec-tagalternative is configured, sogrep -rn '#nosec' --include='*.go' .lists every one for review. The comment gate keeps each annotation alone on one line, so no free prose rides behind a suppression. - govulncheck in source mode over
./..., in addition to the binary-mode scan thepre-releasegate runs against every shipped executable. - gitleaks over the available git history.
.gitleaks.tomlpairs exact documentation and fixture values with their paths..gitleaksignorenames historical fixture, generated-bundle and QA cache-identifier false positives by exact fingerprint. No path is excluded. npm auditover the dashboard's production dependencies at thehighthreshold. A registry that times out or answers 5xx is retried, and fails as its own error rather than as an advisory. A pass is recorded against a digest ofweb/package-lock.jsonandweb/package.jsonand reused for at most a day, so an unchanged dependency set is still re-asked daily and an advisory is never replayed from the record.
The hosted workflow also runs CodeQL for Go and TypeScript with the
security-extended query suite. CodeQL alerts remain report-only. The workflow
pins external actions to commit SHAs, and the three Go-based local scanners use
pinned module versions. The installed npm version and advisory database supply
npm audit; CodeQL has no local pipeline step. A pull request or a push to
main stops when a scanner cannot complete or when gosec, govulncheck,
gitleaks, or npm audit finds a failure. A tag stops for neither, so a
scanner failure on main is what holds a release back, before the tag exists.
Operator checklist
- Provision controller authority through the capability issuer and its private credential files. Keep deployment operational grants separate from team and operator-console authority. Do not use an unauthenticated bootstrap window.
- Terminate TLS at a trusted ingress and keep signing and source credentials outside pipeline Jobs.
- Dedicate each Cloud node to one run and enforce NetworkPolicy in the cluster. Sparkwing does not probe enforcement; verify it and node teardown periodically with your own tooling.
- Configure storage lifecycle rules for the declared log retention classes as a backstop. Startup warns about a class without one; Sparkwing still deletes log objects at their class age.
- Pin verified image digests, encrypt the deployment's secret store and rotate source and release credentials under their owning services' procedures.
Controller client redirects
Authenticated controller clients refuse redirects because both the credential and private execution inputs must remain at the selected controller. Supplying a custom HTTP client does not override that rule. Signed-object transfers use their own short-lived authority and never inherit the controller bearer.