Security

How sparkwing protects code, credentials, and infrastructure.

Report suspected vulnerabilities through GitHub's private vulnerability form, not a public issue. The repository security policy defines supported versions and the information to include.

Trust modelSection anchor link

Read this before deciding who holds a token and which repositories share a deployment.

One controller is one trust domain. Every token authenticates against the same store, and every run, secret, and concurrency key lives in it. Scopes narrow what a token may do; they do not partition the deployment by repository, team, or environment. Two projects that must not read each other's runs need two controllers, not two tokens in one.

Pipeline authors run code on runners. A pipeline is Go that the runner compiles and executes. Enrolling a workstation or gateway authorizes that code to execute as the agent service's OS user. Assisted execution keeps the enrollment bearer and claim identity in the supervisor; the job-body child gets a process-lifetime loopback capability limited to its exact run and node, with execution start, finish, and logs additionally bound to its acknowledged attempt ordinal. The child does not inherit arbitrary agent service credentials, and its capability cannot claim or renew work, manage the fleet, or call administrative routes. This is not an OS sandbox: the pipeline keeps every file, network, and process permission of the agent OS user. Use a dedicated account whose reach every enrolled repository may have. Sparkwing does not join a tailnet or configure host networking. threat-model.md states what that boundary isolates, what it does not, and what an operator does about the rest when a teammate's branch runs on an enrolled desktop.

Native Windows helpers start the body suspended, assign it to a kill-on-close Job Object, and then resume it, so nothing the body spawns exists outside the job. The supervisor waits for the Job to report zero active processes after the body exits or is cancelled. Linux, macOS, and WSL helpers instead create a dedicated process session, send TERM and then KILL to its remaining members, and wait for that session to empty. Unix code can call setsid to leave that accounting boundary. Pipeline bodies must not daemonize out of their session; Sparkwing does not try to contain intentional evasion by code already trusted to run as the agent OS user.

Schema 30 supplies authenticated foreground enrollment, offer, award, source handoff, and coordinator fallback as an internal release dependency. It does not expose remote helper body completion. The containment boundary above must ship with schema 31, which adds the current-attempt fence and durable grants required by Memoize, Concurrency, ToolSlot, RunAndAwait, cross-pipeline references, and dynamic SpawnNode. Schema 30 is not a complete assisted-node compatibility boundary on its own.

A warm pool shares one OS account across repositories. Assisted job bodies run in separate child processes, but consecutive nodes still share the same filesystem, network identity, and OS permissions. A pipeline that writes a credential to disk can leave it where the next repository reads it. Run sparkwing cluster worker --runner k8s to give each node its own Job pod where repositories do not trust each other; see warm-pool.md.

runs.read is deployment-wide. GET /api/v1/runs filters on the query the caller supplies, never on the caller. One runs.read token lists every run of every pipeline and repository the controller holds, along with the plan, the arguments, the event stream, the trends aggregate, and the queue view. Argument values the pipeline declared secret:"true" are masked; nothing else is. Give runs.read to a principal you would show the whole deployment's history.

The laptop dashboard serves an unauthenticated controller. sparkwing serve start mounts the controller API and the dashboard on one listener with no token check, so anything that reaches the port can trigger pipelines and read secrets. The boundary is the bind address: the process refuses a non-loopback --addr unless the operator passes --allow-remote, and a browser request carrying a foreign Origin is refused unless the operator named that origin in --allow-origin. See local-execution.md. The cluster-mode controller is the authenticated deployment; laptop mode is a single-user tool on a single-user machine.

Authentication and authorizationSection anchor link

Controller and logs requests carry a bearer token; each route declares the scope it needs. Tokens are typed (swu_/swr_/sws_), stored as argon2id hashes, and never logged in full. The complete model -- token kinds, the scope set, per-endpoint enforcement, the unauthenticated endpoints, and first-visit admin bootstrap -- is in auth.md. Sparkwing does not have a "root token"; the admin scope is the superset.

Login and hashing budgetsSection anchor link

POST /api/v1/auth/login is the controller's only unauthenticated route that hashes a password, so it carries its own budgets. One client gets 30 attempts a minute; the listener as a whole gets 600 per concurrent argon2 slot, which bounds hashing work without throttling a fleet's real logins. Both answer 429 with Retry-After once drained.

A failed login also charges a budget keyed on the account and the client address: 5 failures, refilling one every three minutes. Keying it on both is deliberate. An account-only budget would let any stranger lock a named user out of the dashboard with 20 requests an hour, so a wrong guesser only ever slows itself down; the per-client and listener buckets remain the outer bound on how much guessing one source can do. The budget charges failures only, so a busy account is never locked out by its successes.

Bearer verification carries the same protection, keyed on the client address and the 12-character token prefix, which is public (sparkwing cluster tokens list prints it). Ten failed verifications for one prefix from one client in a minute and further attempts answer 429 without hashing. Keying on the pair matters for the same reason it does for login: a prefix-only budget would let a stranger who reads a prefix deny that runner its own token on any cold cache. Only a genuine hash mismatch spends the budget, so a prefix that matches no stored row costs an indexed SELECT and nothing more, and a valid token served from the principal cache spends nothing at all. The controller also remembers a rejected raw token for five seconds, so a client replaying one wrong guess pays for a single hash; that cache evicts its coldest entries when full rather than closing to new ones.

Every argon2id verification, login and bearer-token lookup alike, passes through a semaphore sized by --argon2-memory-budget-mb (chart: controller.argon2MemoryBudgetMB, default 256). One hash holds 64 MiB while it runs, so the default admits four at a time. A hash waits at most 250ms for a slot; past that the request is shed with 503 and a Retry-After rather than queued, so a flood cannot grow an unbounded backlog behind legitimate callers. Raise the budget only alongside the pod's memory limit. A runner whose token is in the 60-second principal cache never reaches the store or the semaphore at all, so heartbeats are unaffected by a login flood.

An unauthenticated caller never sees a store error verbatim. Anything that is not an authentication rejection answers 503 with a generic message and the detail goes to the controller log.

Login throttling keys on the TCP peer and ignores forwarded headers until you name the proxy networks in --trusted-proxy-cidrs (chart: controller.trustedProxyCIDRs). The dashboard forwards each browser's address to the controller, so that list must include the web pod's source or every dashboard login shares one client budget. Set the web pod's address where you pin it; where the pod IP is unknown, set the cluster pod CIDR (10.244.0.0/16 on kubeadm and kind, 10.42.0.0/16 on k3s) and accept that any pod in that range can then supply X-Forwarded-For. List the narrowest range that contains the web pod. Leaving it empty stays safe and turns coarse: every browser then shares the proxy's budget.

Trigger and list-query limitsSection anchor link

POST /api/v1/triggers validates git.repo_url with the same rules the Git cache routes use, so a submission cannot point a runner's clone at a local path, a loopback or private address, or a URL carrying embedded credentials. It also keeps only the trigger environment keys a run actually reads (GITHUB_REPOSITORY, the GitHub pull-request context, and the SPARKWING_START_AT / STOP_AT / ONLY / DRY_RUN / NO_CACHE switches). Everything else is dropped, including the retry-provenance keys the controller writes for itself, so a submission cannot forge the repository directory a later local retry trusts.

The clone-URL check canonicalizes the host before it decides: a trailing dot, an IPv6 zone id, and the decimal, hexadecimal, and octal spellings of an address (127.1, 2130706433, 0x7f000001, 017700000001) all resolve to the same place a resolver sends them, and loopback, private, link-local, carrier-grade NAT, and the cloud metadata names are rejected in every spelling.

An scp-like URL is read the way ssh reads it. git@a@127.0.0.1:repo.git is refused rather than checked as a host named a@127.0.0.1, which is what the guard used to do while ssh, splitting the destination at the last @, dialled the loopback address. A host in that form must read as a hostname, with no @, :, or other character in it that would let the host checked differ from the host dialled.

A host is also refused when it sits in a name space that only ever points inward: internal, local, localdomain, and home.arpa, whole or as a suffix, beside localhost, the cloud metadata names, and the ip6-localhost and ip6-loopback aliases every Debian and Ubuntu /etc/hosts ships.

This is a name check, not an address check, and it is not a complete SSRF guard. A name that resolves to a loopback or private address -- evil.example.com pointed at 127.0.0.1 -- passes it. Names are not resolved during validation on purpose: git resolves the name again when it connects, so an address checked here is not the address reached, and a Host alias in the runner's ~/.ssh/config can send ssh somewhere the name never resolved to at all. Bound clone targets where the connection is actually made: an egress network policy on the cache and runner pods that permits only the forges you clone from. Deployments that build sparkwing can also install a host allowlist through the clone validator's host-policy hook, which runs after every check above.

GITHUB_REPOSITORY is the one submitted key a runner reads as a clone target, and it wins over git.repo_url, so it is accepted only as an owner/name slug. A caller without admin also cannot submit trigger.source: github or the pull-request environment keys: those are what the commit-status reporter trusts when it spends the controller's GitHub token, and the HMAC-verified webhook is what writes them.

GET /api/v1/runs?limit=, GET /api/v1/triggers?limit=, and GET /api/v1/runs/{id}/events?limit= are capped at 1000 rows, in the handler and again in the store, so a read-only token cannot ask one request to materialize every row with its plan, args, and payload blobs.

GET /api/v1/services announces internal cache and logs URLs and needs a bearer; any valid token satisfies it, and every client that consumes it already holds one.

Flood controlSection anchor link

A push storm, a bot opening hundreds of pull requests, or a misconfigured hook delivers thousands of events in minutes, and each one costs a run, a log object, and a row. Three controller settings bound what one burst can create. All three default to off, so a controller that names none admits what it always did.

--max-runs-per-principal-hour N (chart controller.maxRunsPerPrincipalHour) caps the runs one principal may create in a rolling hour. An authenticated submission spends its own token's budget; a webhook delivery carries no principal, so it spends the budget of the repository it names. Past the cap the controller answers 429 with a Retry-After naming the real refill delay, which lengthens while a caller keeps knocking at an empty budget. The budget lives in controller memory, so a restart or a rollout refills every principal; it bounds a burst, not a month.

--shed-queue-depth N (chart controller.shedQueueDepth) answers 503 with a Retry-After once pending triggers reach N, which is the outer bound on how deep a backlog one burst can grow. The depth is read at most once a second, because a flood asks for it far faster than it changes.

--trigger-dedupe-window D (chart controller.triggerDedupeWindow) answers a content-identical POST /api/v1/triggers submission inside D with 409 and the run the first one started. The digest covers the submitting principal, so a 409 naming a run id only ever reaches the principal that owns that run and two tenants submitting the same body get a run each. A GitHub redelivery is deduped regardless: the store holds one trigger per delivery id and one per body digest, so a retried delivery answers 409 naming the original run whatever this window says.

Deduplication runs before the shed and the cap, so a redelivery is answered with its original run rather than a refusal, and retrying one never spends the submitter's budget.

Every refusal is a status a caller can act on and a line in the controller log at warn naming the principal and the reason. Nothing is dropped silently.

Per-runner request budgetsSection anchor link

The claim and heartbeat routes can carry a budget of their own, because a looping runner reaches them thousands of times a minute without ever failing authentication. --claims-per-runner-minute and --heartbeats-per-runner-minute (chart controller.claimsPerRunnerMinute, controller.heartbeatsPerRunnerMinute) bound what one runner spends per rolling minute. Both default to zero, which is unlimited: an operator opts in.

A claim that comes back with a node spends no claim budget. An award is work the controller chose to hand out, and the loop that gets one re-claims at once rather than waiting its poll interval, so charging it would bound how fast a runner may execute rather than how fast it may ask. What the budget bounds is empty polling, which a runner can do without limit: a pool runner polls every 500ms, or 120 a minute, so 480 allows four times that cadence. Heartbeats carry no such exemption; 1200 suits the 3s cadence the shipped runners keep.

The budget is keyed on the runner, not the token. The controller derives the runner from the route wherever it can -- the node, run, or agent the path names -- and falls back to the X-Sparkwing-Runner header only on POST /api/v1/nodes/claim, POST /api/v1/nodes/claim/prepare and POST /api/v1/triggers/claim, which name nothing. A runner sends one identity for the life of its process (a pool runner its holder prefix and process id, an enrolled agent its name), not one per poll: a value that changed per request would buy a fresh budget on every claim and grow the controller's bucket table at the fleet's poll rate.

What this bounds is a cooperating runner. On those three claim routes the identity is the runner's own word, so a holder of a valid token that varies it gets a fresh budget each time. The budget stops a runaway loop and keeps one misbehaving runner in a shared-token fleet from spending its peers' claim budget; it is not a defence against an authenticated caller who means harm. The per-token budget below is what bounds that caller, and the token itself is the control that ends it -- revoke it.

A runner too old to send an identity shares one bucket with its peers on those routes, so during a rolling upgrade a shared-token fleet is budgeted as one caller there. Size the budgets per runner and the older half of the fleet still clears them, or leave the budgets at zero until the rollout finishes.

--limits-profile turns the budgets on as a set, so a hosted controller carries one setting rather than one per guard. It is described under Limits profiles below.

The agent liveness heartbeat, POST /api/v1/agents/{name}/heartbeat, is never budgeted. An agent that loses it tears down its membership and every node under it, which is a far worse outcome than the load one heartbeat every few seconds represents.

Past a budget the route answers 429 with a Retry-After naming the real refill delay, and sparkwing_principal_throttled_total{route_class} counts it. A runner reads a 429 the way it reads a 503: it waits the header out, capped at 30 seconds, and keeps its claim and its node. A host's own admission daemon and the loopback controller budget nothing, because their callers are unauthenticated and would share one bucket.

Per-token request budgetSection anchor link

--requests-per-token-minute bounds every route one token can reach, keyed on the token prefix alone. It is the guard that binds a caller varying the runner it says it is: on the two claim routes the runner name is the caller's own word, so the per-runner budgets above bound a runaway loop rather than a holder of a valid token who means harm. The agent liveness heartbeat is spared here too. Past the budget a request answers 429 with a Retry-After, counted under sparkwing_principal_throttled_total{route_class="token"}.

--requests-per-minute-alarm refuses nothing. It is the rate, across every caller, past which the controller logs at warn and counts sparkwing_request_rate_alarm_total, once a minute: the notice that one pod is serving more than it was sized for.

Idle-poll enforcementSection anchor link

A controller told to enforce its idle-poll suggestion answers a claim poll that arrives sooner than the widest interval it suggests with 429 and a Retry-After naming the rest of the wait, instead of a claim. Enforcement starts only once that widest interval has been the standing suggestion for a whole interval, so a runner is never refused against an interval it was not yet told about, and it lifts the moment work is handed out, so a fleet is never held off a queue that has since filled. A runner that honors the suggestion waits at least that long by construction and is never refused; a runner that ignores the header pays the wait it was told about, which is never longer than one suggestion, so two refused polls and a runner's own spread still fit inside the placement hold. A controller that suggests nothing, which is any controller with --idle-claim-poll=0 and every host's own admission daemon, enforces nothing.

The gate guards the two routes that carry the suggestion, POST /api/v1/nodes/claim and POST /api/v1/triggers/claim. A route that names the trigger or the node it wants is no idle poll, and the preparation half of an offer round would charge the round twice. It is keyed the way the per-runner budgets are, on the token prefix together with the runner, and the runner a pool names itself carries its process id, so two runner processes on one host are two runners rather than one polling twice.

Before turning enforcement on, check what the fleet is running. A caller older than v0.50.1 sends no X-Sparkwing-Runner, so every such caller on one token shares a single gate slot and all but the first are refused every round. They hold their claims and their nodes, because a 429 is backpressure they already honor, but they pick work up no faster than one runner would. Roll the fleet forward first, or leave --idle-claim-poll at zero until it is.

Enforcement is off unless a limits profile turns it on; sparkwing_principal_throttled_total{route_class="idle_poll"} counts the refusals.

Limits profilesSection anchor link

--limits-profile (chart controller.limitsProfile) names a set of abuse guards a hosted controller runs with, so provisioning writes one setting rather than one per guard. Empty, the default, supplies none: a self-hosted controller keeps every budget unlimited and enforces no idle poll, which is what it served before profiles existed.

Guardcloudcloud-free
--claims-per-runner-minute480240
--heartbeats-per-runner-minute1200600
--requests-per-token-minute2000600
--requests-per-minute-alarm50005000
--egress-max-log-streams5010
--egress-max-downloads205
Idle-poll enforcementonon

The claim budgets are worked from the cadence the shipped claim loop keeps rather than from a round number. It polls once every 500ms while the queue is empty, which is 120 requests a minute, and claims once more for each node it starts: cloud allows four times that cadence and cloud-free twice, so a runner keeping its configured cadence is never refused and one stuck in a tight loop is held to about the work it was asked to do. The heartbeat budgets carry the shipped cadence with the same headroom the recommendation above uses, and the free tier halves the paid figure. The per-token budgets carry the rest of what a runner spends: a two-slot runner honoring its cadences spends roughly 300 requests a minute once its node heartbeats and state writes are counted, so the free tier carries one such runner and the paid tier several under one token. The alarm is what one controller pod is sized to serve. The egress caps are the concurrency one team is expected to read logs and artifacts at.

A profile fills a guard only where the command line and the environment named none, and a guard the operator named wins whatever its value, including an explicit zero that turns it off. Raising one guard on a hosted controller is one flag beside the profile rather than a fork of it. sparkwing cluster limits show prints the budgets in force beside the stored compute guards.

WebhooksSection anchor link

GitHub webhook deliveries are verified by the controller: it checks the X-Hub-Signature-256 HMAC with a constant-time compare before doing any work. The handler acts on push and on pull_request (opened / synchronize / reopened, against the PR head), and answers ping; other event types and other pull_request actions are accepted and ignored.

GITHUB_WEBHOOK_SECRET is one value every configured repository holds, so on its own it says only that some holder signed the body -- any holder could then drive any pipeline against any repository. Bind the intake with GITHUB_WEBHOOK_BINDINGS, a JSON document:

{
  "pipelines": {
    "sample-app-build": {"repos": ["acme/sample-app"], "secret": "..."}
  },
  "repo_secrets": {"acme/sample-app": "..."}
}

pipelines is keyed by the {pipeline} path segment and repo_secrets by repository slug. A slug is lowercased once, when the delivery is read, and that one value picks the secret and answers the binding, so no case fold can send the two decisions to different repositories; a repository.full_name that is not an ASCII owner/name slug is refused outright. A pipeline with a repos list refuses any delivery naming a repository outside it, so a repository owner reaches only the pipelines you bound to them. A repos list that is present but empty refuses every repository; omit the key, or the pipeline entry, to leave the delivery's repository unchecked. The controller logs the resolved counts at startup, so an installed document that parsed to nothing is visible in the log.

The signing secret resolves most specific first -- the pipeline's own secret, then the named repository's secret, then GITHUB_WEBHOOK_SECRET. Give every bound repository a secret of its own to isolate them completely: a repository left without one is verified with the shared secret its peers also hold. In the chart, pass the document through controller.extraEnv from a Kubernetes secret.

A refusal does not say which of these rules it failed. An unbound repository answers 404, the same as a pipeline that does not exist, and once any pipeline or repository carries a secret of its own, a delivery resolving to no secret answers 401 like a bad signature rather than 503. Otherwise the status code alone would enumerate the binding table and the repo_secrets key set, one guess per request. 503 remains the answer when no secret is configured anywhere.

Each delivery is recorded under two unique constraints: the X-GitHub-Delivery id, store-wide, and a digest of the material the signature covered -- the pipeline and the request body. The digest is what closes replay: X-GitHub-Delivery is a header the sender picks and the HMAC does not cover, so keying on it alone would let anyone who captured one delivery re-send it under an id of their own. Re-sending a body the controller already accepted answers 409 whatever header rides with it, and the response names the run the first delivery produced, so a redelivery from the GitHub side resolves to that run instead of a dead end. A delivery arriving without the header answers 400.

When GITHUB_TOKEN is set, the controller uses it only for outbound commit-status requests for pull_request webhook runs. Prefer a fine-grained token limited to the served repositories with Commit statuses: Read and write. The token never enters trigger environment, run state, logs, or the dashboard. An empty token disables outbound status reporting.

Secrets at restSection anchor link

Encryption at rest is opt-in and off by default. Configure a master key and secret values are encrypted with an XChaCha20-Poly1305 AEAD cipher (internal/secrets) before they hit the database. With no key configured the controller stores secret values as plaintext and logs a warning at startup. Provide the key via:

  • --secrets-key-file <path> -- a file holding the raw or base64 key, or
  • SPARKWING_SECRETS_KEY -- a base64-encoded 32-byte key.

Prefer the file. The chart mounts controller.secretsKey as a file and renders the flag, because an environment entry is readable through /proc and is inherited by anything the container execs. The controller clears either variable from its own environment as soon as it reads it, so a value supplied that way does not outlive startup.

Each envelope is bound to the fields of the row that decide who may read it: the secret name, the owning repository (empty for an unscoped secret), whether an unscoped row is shared with every run, and whether the value is masked in run output. Anyone with database write access who copies a ciphertext onto another name, into another repository, or onto the unscoped row, or who edits a row to widen its own access, gets a value that fails to open rather than one that answers there.

Values sealed before binding (enc:v1: envelopes) still open, and they are still substitutable until they are rebound. sparkwing secrets list reports BOUND false for them ("bound": false on the API), and the controller reseals such a row into a bound envelope the first time it is read, so rows migrate as they are used. Re-setting a secret rebinds it as well.

A stored envelope carries no key id, so the controller opens it by trying the keys it holds. Name the key values were sealed under before the current one and it becomes a read-only fallback:

  • --secrets-previous-key-file <path>, or
  • SPARKWING_SECRETS_PREVIOUS_KEY.

A value that does not open under the current key is tried against that one, which keeps every value readable across a key change. Close the window with sparkwing secrets rotate --profile <name> (POST /api/v1/secrets/rotate, admin): it opens every row with the keys the controller holds and writes it back sealed and bound under the current key, in one transaction, so the rotation lands for the whole table or for none of it. The same command turns encryption on for a database that already holds plaintext values, which come out encrypted with no re-set by hand.

A row that opens under neither key keeps the bytes it had and is named in the response, which is what a value written as plaintext before encryption was enabled looks like when it happens to start with an envelope prefix. The rest of the table still rotates. sparkwing secrets rotate lists those rows; re-set each one, or name the key it was sealed under, and rotate again. Drop the previous key once a rotation reports nothing skipped.

Configure the keys in this order for a key change: mount the new key as secretsKey and the outgoing one as secretsPreviousKey, restart, rotate, then clear secretsPreviousKey. A controller started with a previous key and no current key refuses to start, because it would have nothing to seal new values under.

Encrypted or not, values leave the server only through the authenticated secrets API; pipelines read them with sparkwing.Secret (see sdk.md).

Release integritySection anchor link

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. It no longer scans the image first; the CI/CD group is reintroducing that scan deliberately, with the rest of the release-side checks. 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.

Cache serviceSection anchor link

sparkwing-cache requires a bearer token (--api-token, falling back to $SPARKWING_API_TOKEN) on every route that touches repository content: git clone and registration, archives, single files, tree hashes, branch membership, the repo listing, artifacts, and the blob and sync endpoints. It refuses to start without one unless the operator passes --allow-unauthenticated ($SPARKWING_CACHE_ALLOW_UNAUTHENTICATED), which logs a startup warning. The guard has no network-location exemption: an in-cluster caller, a port-forward, and an ingress request are all rejected without the bearer, because a caller-controlled header cannot prove where a request came from. /health, /metrics, /stats, and the pull-through package proxy under /proxy/ stay open, because package managers fetch through the proxy without a credential and it serves upstream registry bytes rather than repository content.

Registering a repository name validates it against ^[A-Za-z0-9._-]{1,64}$, and repointing a name that already maps to a different repository requires the token even on an unauthenticated cache. Every response carries X-Content-Type-Options: nosniff, and artifact downloads are served as application/octet-stream attachments.

Off-cluster runners read Git through /api/v1/runs/<run>/gitcache/git/.... That route requires nodes.claim, a live claim on the named run, and the repository recorded on its trigger. The unscoped /api/v1/gitcache/git/... route remains admin-only. The controller drops the caller's bearer and presents its own cache credential upstream, and permits only registration and upload-pack reads. A login-enabled dashboard exposes those paths to machine bearers without accepting browser sessions: the mount rejects a request carrying no bearer before it extends the half-hour stream deadline or proxies anything, and caps concurrent Git streams. A direct cache uses the separately configured cache_token instead.

The runner-bundle chart ships a default-deny ingress NetworkPolicy for the cache pod (networkPolicy.enabled, on by default). It admits the release's runner, controller, and dashboard pods plus the Job pods the Kubernetes runner backend creates (app.kubernetes.io/name: sparkwing-runner), and refuses to render a non-ClusterIP cache Service unless a token Secret is configured. A controller or runner pool outside the cluster reaches the cache through networkPolicy.extraIngress, which is appended to the rule verbatim and takes an ipBlock for the caller's source range.

pipeline trigger --working-tree refuses to upload a snapshot whose manifest holds a secret-shaped file: by name, by a key or certificate block in any text file, or by the first 64 KiB of a settings or manifest file, using the credential vocabulary the detached-run environment filter uses. An operator sends such a file only by naming its path with --allow-secret-file, so the audit of what left the laptop is the command itself.

pipeline trigger --working-tree may seed uncommitted source; the cache retains up to 128 workspace refs per repository and expires them after WORKSPACE_SEED_MAX_AGE (24 hours by default). Expiry moves the ref into refs/sparkwing-workspace-archive/ rather than dropping it, so a retry of an older working-tree run still finds its snapshot; archived refs are dropped after seven times WORKSPACE_SEED_MAX_AGE, or once 128 of them accumulate.

The cache's unauthenticated /metrics carries no per-repository label, so scraping it does not enumerate or confirm the mirror set.

Local daemon socketSection anchor link

The admission daemon (wingd) is a per-user process on the developer's own machine. It serves a unix socket at /tmp/sparkwing-<uid>-<hash>/d.sock, where the hash covers SPARKWING_HOME. The path is a pure function of the home: no environment variable moves it, so a cron job, a privilege-elevated shell, and an interactive session all resolve the same socket for the same home. It sits under /tmp rather than under the home because a unix socket path is capped at 104 bytes on macOS. Windows uses the process temp directory instead. The trust boundary is the user account, not the machine: everyone logged into the same host as the same user shares one daemon and can queue, inspect, cancel, and drain its runs. The protocol carries no token, and adding one would not change that -- a token readable by the account is readable by anything running as the account.

Other accounts on the host are outside the boundary, and the checks below keep them out. The base directory must be a directory carrying the sticky bit, or else not be writable by other accounts, so no one can rename this user's socket directory away and substitute their own. The daemon then creates its socket directory with Mkdir and refuses to serve if the path already exists as anything but a real directory owned by the current uid with mode 0700, so another account cannot pre-create it and collect connections; a foreign directory at that path is a refusal that names it, never a redirect somewhere else. The bound socket is chmodded to 0600. Every accepted connection is checked against the kernel's peer credentials (SO_PEERCRED on Linux, LOCAL_PEERCRED on macOS and FreeBSD) and dropped when the caller's uid differs, which holds even where socket file modes are not enforced on connect. Clients apply the same base and directory tests before dialing, including the peer sweep behind sparkwing doctor, so a sparkwing command refuses to hand a handshake to a socket sitting in a directory this user does not own.

The ownership, mode, and peer-credential checks are unix-only. Windows reports no uid for a unix socket peer and has no sticky bit, so the per-user temp directory is the only separation there, and the daemon neither refuses a connection on credentials nor sweeps a stale socket directory away.

Root is not excluded by any of this; a root account on the host can read the daemon's memory whatever the socket says. On a shared host, give each user their own SPARKWING_HOME, which is the unit of daemon isolation.

Container hardeningSection anchor link

The Helm charts run the long-lived services as non-root with explicit securityContext settings (the controller as uid 65534, privilege escalation disabled, all Linux capabilities dropped). The one exception is the warm-pool warmer: when the pool is enabled the controller launches an ephemeral docker:27-dind pod with privileged: true so it can run dockerd and pre-pull images into a warm PVC. It is short-lived, single-container, and the only privileged workload sparkwing creates. See warm-pool.md.

Verified self-updateSection anchor link

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, 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 signing key is release machinery, not per-user configuration:

  • Generate a base64-encoded 32-byte Ed25519 seed and store it as the SPARKWING_UPDATE_SIGNING_KEY GitHub 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-key prints the secret's public key and enforces trust-set membership before release assets are signed.

Static analysisSection anchor link

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. The CI/CD group is reintroducing the release-side scanners deliberately.

  • gosec over the public module and the .sparkwing pipeline 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-rules and -nosec-require-justification, so every suppression reads #nosec GNNN -- <reason>, naming the rules it silences and why, and no -nosec-tag alternative is configured, so grep -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 the pre-release gate runs against every shipped executable.
  • gitleaks over the available git history. .gitleaks.toml allow-lists two exact documentation and test-fixture values, and .gitleaksignore names one historical generated-bundle false positive by fingerprint. No path is excluded.
  • npm audit over the dashboard's production dependencies at the high threshold. 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 of web/package-lock.json and web/package.json and 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 checklistSection anchor link

  • Set the auth tokens. With an empty tokens table the controller serves every endpoint unauthenticated. It logs a warning at startup, reports "auth": "disabled" on GET /api/v1/health, and sparkwing cluster status flags the controller probe as a warning -- fine for a laptop, not for a shared deployment. Set SPARKWING_REQUIRE_AUTH=1 (or --require-auth) so the pod refuses to start with an empty tokens table. See auth.md.

  • Provision the first admin token. Hand the controller the first admin credential and it never serves a request unauthenticated: SPARKWING_BOOTSTRAP_ADMIN_TOKEN carries the token itself, and --bootstrap-admin-token-file <path> reads it from a mounted file. When the tokens table is empty the controller stores that token's argon2 hash as an admin credential under the principal bootstrap:admin before it binds the listener, which satisfies --require-auth on a first start. A table that already holds a token is left alone, so restarting with the same secret mounted neither duplicates the row nor revives a revoked one. The value has to look like a minted token -- swu_ followed by at least 28 characters, for example printf 'swu_%s' "$(openssl rand -hex 24)" -- because a bearer lookup selects on that prefix. The chart mounts it from controller.bootstrapAdminToken.name as a file, renders the flag, and renders --require-auth from controller.requireAuth. Without it, minting the first token needs the controller open, so enable auth by creating an admin token through that window and restarting.

  • Know what the bootstrap flag treats as an empty table. It writes when no token authenticates: every row is revoked, expired, or the table is empty. That is what recovers a cluster whose only credential was revoked or ran out, and it is also why revoking the bootstrap token while its Secret stays mounted recreates the same credential on the next restart. Unmount the Secret (clear controller.bootstrapAdminToken.name) before revoking, or mint a replacement admin token first so the table still holds a live one.

  • Point the logs service at a controller. Without --controller (SPARKWING_CONTROLLER_URL) sparkwing-logs resolves no tokens, so anything that reaches its Service can read, forge, and delete every run's logs. It reports "auth": "disabled" on GET /api/v1/health and sparkwing cluster status flags the logs probe as a warning. Set SPARKWING_REQUIRE_AUTH=1 (or --require-auth) so the pod refuses to start without an absolute http(s) controller URL, which keeps a typo from advertising "auth": "enabled" on a service whose every token lookup fails. The runner-bundle chart wires the controller URL from controller.tokenSecret, and a logs-enabled install without that Secret fails at render time unless you set logs.allowUnauthenticated=true. cluster status warns rather than passing whenever it cannot read the logs service's auth state: no announced logs URL, a health body with no auth field (an image older than the report), or a degraded service.

  • Size the logs service's quotas for your volume. sparkwing-logs caps what one authenticated runner can spend. Each flag below reads an environment variable of the same meaning, and 0 turns that bound off.

    Flag (env)DefaultEffect
    --max-node-bytes (SPARKWING_LOGS_MAX_NODE_BYTES)64MiBStored-byte cap for one node's log. Appends past it store a [sparkwing-logs] truncated marker once and are then dropped with 204.
    --max-run-bytes (SPARKWING_LOGS_MAX_RUN_BYTES)1GiBSame cap across every node log in one run.
    --max-inflight-bytes (SPARKWING_LOGS_MAX_INFLIGHT_BYTES)32MiBRequest-body bytes all in-flight appends may hold in memory at once; further appends are refused with 503. Keep it well under the pod's memory limit.
    --min-free-bytes (SPARKWING_LOGS_MIN_FREE_BYTES)512MiBFree space on the volume below which appends are rejected with 507, leaving room to read and delete what is already stored. A volume the service cannot measure is treated as full.
    --retention (SPARKWING_LOGS_RETENTION)0 (off)Age after a run's last write at which the sweeper deletes its logs. Off by default so an upgrade deletes nothing; 168h is a common choice.
    --sweep-interval (SPARKWING_LOGS_SWEEP_INTERVAL)1hHow often the sweeper runs.
    --search-max-bytes (SPARKWING_LOGS_SEARCH_MAX_BYTES)256MiBBytes one GET /api/v1/logs/search may read.
    --search-timeout (SPARKWING_LOGS_SEARCH_TIMEOUT)10sHow long one search may scan.
    --max-line-bytes (SPARKWING_LOGS_MAX_LINE_BYTES)0 (off)Byte cap for one log line, marker included: every line past it is stored cut to the cap with a [sparkwing-logs] truncated: line byte cap reached marker in place of its tail. The cut lands on a UTF-8 rune boundary. A cap too small to hold the marker and a byte of output is refused at startup, and raised to that minimum when set through the Go API.
    --binary-ratio (SPARKWING_LOGS_BINARY_RATIO)0 (off)Share of bytes in one append that read as binary rather than text, above which the append is dropped and one [sparkwing-logs] dropped line is stored for that node log. Control bytes count, and so does any byte above 0x7f that is not part of a valid UTF-8 sequence, which is what catches a gzip or tar blob while leaving text in any language stored as sent; 0.3 is a workable threshold.
    --max-store-bytes (SPARKWING_LOGS_MAX_STORE_BYTES)0 (off)Stored bytes across the whole log store, not one node or run. At or above it every append is refused with 507 naming the ceiling, until a measurement finds the store back under it.
    --max-store-objects (SPARKWING_LOGS_MAX_STORE_OBJECTS)0 (off)Same ceiling counted in log files.
    --warn-store-bytes (SPARKWING_LOGS_WARN_STORE_BYTES)0 (off)Stored bytes at which /api/v1/health reports the store as warning, refusing nothing.
    --warn-store-objects (SPARKWING_LOGS_WARN_STORE_OBJECTS)0 (off)Same warning counted in log files.
    --store-reconcile (SPARKWING_LOGS_STORE_RECONCILE)1hHow often the service walks the store and replaces its running count with the measurement. 0 measures once at startup. Deleting a run measures it again straight away.

    A search that hits either budget, or whose caller disconnects, returns the matches it found with "truncated": true. Search also requires run_id; a query without one is refused with 400 rather than walking every stored run.

    The runner-bundle chart passes these through as logs.limits.* (maxNodeBytes, maxRunBytes, maxInflightBytes, minFreeBytes, retention, sweepInterval, searchMaxBytes, searchTimeout, maxLineBytes, binaryRatio); an empty value keeps the binary's default. Size them against logs.storage.size, because a volume left to fill answers 507 to every append until you turn on retention or delete runs. A malformed or negative value stops the service at startup rather than falling back to the default.

  • Terminate TLS at your ingress. Sparkwing speaks plain HTTP; put it behind an ingress/proxy that enforces HTTPS.

  • Pin image digests rather than floating tags. Each release lists them in its image-digests.json asset.

  • Encrypt etcd / your secret store. Kubernetes Secrets are base64, not encrypted, unless the cluster enables it.

  • Rotate the GitHub credentials and cache SSH key periodically.

  • Limit the status token. Give the controller's GITHUB_TOKEN commit-status write access only to repositories whose pull requests Sparkwing reports.