← All docs

Configuration reference

From docs/CONFIGURATION.md at v0.1.0 ·View the source on GitHub

Every setting Yellow Jack reads, what it does, and what it defaults to.

Where settings can come from — and can never come from — is a separate, asserted invariant: see CONFIG_SURFACE.md. Configuration is read from the process environment only; nothing is discovered from a working directory, a request path, or inspected package content. That document is the deliverable of issue #38 and is kept honest by tests.

This file is the deliverable of issue #51 acceptance item 1: the current FW_* surface enumerated with a one-line justification per knob, and the count stated as a defended budget. It now covers every service, not only the firewall — the approval service, console, scheduler, scanner and cache each have their own section below, and all of them are drift-checked by configreference_test.go.


The budget, stated

47 environment variables. 0 of them are required. — the FIREWALL’s surface, which is what issue #51 asked for and what the budget argument below defends.

The whole stack: 104 environment variables across six services. Still 0 required to start. Every service comes up with nothing set — the approval service falls back to an in-memory store, the console to loopback with no auth, the scheduler to the local docker CLI. The only genuinely required variables in the surface are SCANNER_REPO and SCANNER_SINK_URL, and those are set by the scheduler on a container it launches, never by an operator.

⚠️ One exception, added by #22: the shipped docker-compose.yml requires POSTGRES_PASSWORD. No service requires it — the sentence above still holds for every binary — but the compose file interpolates it rather than carrying a literal, because a default there is one identical database credential on every installation. It stays a zero-friction start for the documented path: sh scripts/dev.sh up generates a per-machine value into a git-ignored .env first. A bare docker compose up on a fresh clone fails, by design, with an error naming that command. Quote “0 required” of the SERVICES; do not quote it of a raw docker compose up.

⚠️ “0 required” is a statement about STARTING, not about running well. A real deployment sets APPROVAL_DATABASE_URL — without it the approval queue and the audit trail live in memory and are lost on restart, which is wrong for anything holding a compliance record. And a console reachable beyond loopback needs CONSOLE_AUTH_USER / CONSOLE_AUTH_PASS (or the CONSOLE_OIDC_* set, which supersedes them), because those are its only access control. Quote “0 required” as the friction claim it is; do not quote it as “no configuration needed”.

The second number is the one that matters. Issue #51 exists because “adopting Yellow Jack must cost less than routing around it — one .npmrc line, no new client tooling, no workflow change”, and for a cooperative-client gate friction is not a UX problem but a coverage problem: a developer who finds the gate annoying routes around it, and our dashboard stays green while the traffic leaves.

A container started with no environment set at all comes up as a working npm firewall. FW_ECOSYSTEM defaults to npm, FW_UPSTREAM derives from the chosen ecosystem so the two cannot be mismatched, and FW_LISTEN_ADDR defaults to :8080. Every other knob has a default chosen to be safe rather than permissive.

An honest note on the count. Issue #51 was written when the surface was about five knobs, and named sprawl as the thing to avoid. We are at 31. That is not undefended sprawl — the argument for each addition sits in config.go itself, and FW_SCORE_CACHE_MAX_ENTRIES carries a whole paragraph on why it is one knob covering two caches rather than two. What had never happened is the enumeration and the total, which is what this file is. Read 31 as a number to defend from here, not as a clean bill of health.

The rule for adding one: justify it against this budget in its own doc comment, and update the count above. TestConfigReferenceIsComplete fails if a FW_* variable exists in config.go and is not listed here, so the two cannot drift silently.


Deployment

Variable Default What it does
FW_LISTEN_ADDR :8080 The address:port this service listens on.
FW_METRICS_LISTEN (empty — off) Prometheus-format metrics (#172, D419). Set to an address (:9090) to serve GET /metrics in the Prometheus text exposition format on a separate listener; empty binds nothing. The OpenTelemetry Collector’s prometheus receiver scrapes the same endpoint, so one setting serves both. It must differ from FW_LISTEN_ADDR (refused at startup otherwise): refusal counts by kind describe the policy in force, so developers should not be able to read them, and where you bind it is the access control. Unauthenticated, like /healthz, so a standard scrape needs no credential. Series: requests by status class and a latency histogram (health probes not counted), terminal verdicts by verdict, action taken and denial kind, upstream failures by class and retries, dropped audit events, readiness and draining, whether each policy source is degraded, and the malware feed’s advisory count and generation time. No series carries a package name or a client address — per-package traffic is the console’s capacity view. Counters reset when the process restarts.
FW_ECOSYSTEM npm Which ecosystem this instance gates: npm, pypi, oci, maven, go. One process serves one ecosystem. go gates the Go module proxy protocol (GOPROXY, #42); its default upstream is https://proxy.golang.org, and it has no release window yet (see docs/SETUP.md).
FW_UPSTREAM derived from ecosystem The real registry allowed requests are proxied to. The default tracks FW_ECOSYSTEM so the two cannot be mismatched. 🛡️ It holds exactly ONE registry, and that is load-bearing. With a single upstream there is no “not found here, try the next one” step — so the gate cannot resolve an internal package name to a public registry, which is the dependency-confusion substitution described in the FW_DENY_LIST row below. That protection is a side effect of this field being single-valued rather than something anyone designed, which is precisely why it is written down here: an ordered list of upstreams with not-found fallback would make this gate the resolver that performs the attack. If multiple upstreams are ever added, internal-first ordering and a rule that a claimed-internal name never falls through are requirements, not refinements.
FW_PUBLIC_URL derived from the Host header Externally reachable base URL of this proxy, used when rewriting artifact URLs so those fetches come back through us. TLS-fronted deployments must set it. It must be reachable from where the client runs, not from where the proxy runs — see the note below.
FW_FILES_UPSTREAM https://files.pythonhosted.org PyPI only: the host serving artifact bytes, which is not the index host.
FW_TRUSTED_PROXIES (empty — trust none) CIDRs or IPs whose X-Forwarded-For is believed when recording a pull’s source IP. Empty is correct unless you genuinely sit behind a proxy you control.
FW_INTERCEPT_LISTEN (empty — off) Not part of the open-source edition: the open-source build refuses to start while this is set. TLS-interception mode (D51: optional, sysadmin-deployed). A second listener that accepts CONNECT, terminates the client’s TLS with a certificate minted from FW_INTERCEPT_CA_FILE, and hands every request to the same gate the cooperative path uses — one policy, one refusal path. It terminates TLS only for the hostname of FW_UPSTREAM and refuses CONNECT to any other host without dialling it, so the mode adds no egress destination (see docs/EGRESS.md). Under interception the packument is relayed verbatim (no dist.tarball rewrite), so lockfiles keep the registry’s own URLs. All four ecosystems. PyPI: it also terminates TLS for the hostname of FW_FILES_UPSTREAM, and binds each wheel to the package whose relayed index listed it — a wheel URL no relayed index listed is refused. OCI: the token realm the registry names in its 401 is rewritten to /_token on the registry host and relayed from there, and blob CDNs are followed by the gate, so the client never needs a second host. Maven: the repository path in FW_UPSTREAM (/maven2) is stripped from intercepted requests so the gate sees the same paths it gates as a mirror, and a request on the registry host outside that path is refused without a dial. The Maven default is https://repo.maven.apache.org/maven2 — the name Maven’s own super-POM and Gradle’s mavenCentral() carry — so an unconfigured client’s CONNECT is accepted without an operator step. Central also answers to repo1.maven.org; point FW_UPSTREAM at that name and every CONNECT is refused with both hosts in the log, because the listener terminates for the name you configured and not for the one the client says. The same is true of any registry reachable under two names. A registry served under a path gets the same stripping whatever the ecosystem. Point clients at it as their HTTPS proxy (HTTPS_PROXY / npm_config_https_proxy) and distribute the root they must trust yourself (NODE_EXTRA_CA_CERTS for Node, SSL_CERT_FILE for pip, uv and Go clients such as crane, certs.d/<registry>/ca.crt for the Docker daemon, keytool -importcert -cacerts for Maven and anything on the JVM, which reads neither the OS store nor SSL_CERT_FILE; Maven takes its proxy from settings.xml <proxies>, not HTTPS_PROXY — each measured per client); a client that does not trust it is logged as “does not trust our CA” with its address. Must be set together with FW_INTERCEPT_CA_FILE.
FW_INTERCEPT_CA_FILE (empty) Not part of the open-source edition: the open-source build refuses to start while this is set. Absolute path to a PEM file holding the CA certificate the listener signs per-host certificates with — in practice an intermediate issued by your own PKI, never the root (D105: we never hold the root key) — plus its private key, plus any chain to present. How to produce this file from a CA you run, with a verified recipe: the CA integration guide that ships with the interception build. Must be a CA (basicConstraints CA=TRUE, keyCertSign) and in validity; anything else is refused at startup with the reason, before any listener binds. Per-host leaves are P-256 and reused for 24h, so a rotated intermediate takes effect within a day. Read once at startup. Trust preflight: at startup the listener also mints a certificate for every host it will terminate for and verifies it as a client holding only the file’s last certificate would; a chain no client could build (the wrong root behind the intermediate, an issuing CA name-constrained to your corporate domain or without serverAuth) is refused at startup with the verifier’s reason, and the startup line names the certificate clients must hold with its SHA-256 fingerprint (openssl x509 -fingerprint -sha256 form) to compare with what you distributed. It does not claim any client trusts it — that is each client’s own trust store).

Behind a TLS terminator FW_PUBLIC_URL is REQUIRED. Unset, artifact links are minted as http:// + Host, which behind a terminator is the right host with the wrong scheme; the gate does not read X-Forwarded-Proto. Measured with a real npm install, which fails on the artifact fetch. See “Serving the gate over HTTPS” in docs/SETUP.md.

FW_PUBLIC_URL is an address for the CLIENT, and the failure is confusing. The proxy mints artifact URLs from this value and hands them to the package manager, which then fetches them itself. So the value has to resolve from wherever that client runs.

Measured while writing the reference deployment: with FW_PUBLIC_URL=http://localhost:8080 and a containerised npm, metadata fetches succeed and the install then dies with ECONNREFUSED on http://localhost:8080/_tarball/... — a URL that reads as perfectly correct in the error message. The container resolved localhost to itself. Pointing it at the address the client uses (http://firewall:8080 on that network) installs cleanly.

The rule: localhost is right when the client is on the same host as the proxy, and wrong the moment it is not. Leaving it unset is worse in a different way — URLs are then minted per request from the Host header, so one dependency resolves differently per environment and that difference is recorded in developers’ lockfiles. The proxy warns at startup when it is unset.

Scoring

Variable Default What it does
FW_SCORECARD_MODE stub How a score is obtained: off (no scoring at all), stub (fixed, offline dev), api (deps.dev), local (scan on demand via the scheduler).

off is the mode for running the gate on the operator lists, the known-malware feed and the release-age window alone (D273/D280: OSSF scanning is optional for the open-source launch). It short-circuits above the repo lookup, so it makes no network call for scoring and works in an air-gap; a package nothing else refuses is served, with the reason saying so rather than claiming a score it never took. It is a supported, ready configuration, and the console treats it as one: with every replica reporting off, /lookup drops its Score section and /audit its Score/Threshold columns rather than showing a scanner that was never asked (D273, #151). A mixed fleet, or a replica that does not report its mode, keeps them.

⚠️ stub is a development fixture and now reports NOT READY (/readyz 503, D273/#141). It does not decline to score — it asserts a flat 7.5 for every repository, which is worse than not scoring, because the verdict looks like a measurement. It remains the default only so existing dev setups do not change behaviour silently; use off for a deployment that has no scanner.
FW_SCORE_THRESHOLD 5.0 Minimum OpenSSF Scorecard score (0.0–10.0) to allow. Below it, blocked. On Maven an artifact’s own POM is not scored (D417): it is held to the known-malware feed, the deny list, the release window and recorded rulings, and its JAR gets the score. To stop a parent POM, deny it.
FW_SCANNER_URL (empty) Base URL of the scheduler service. Required only when FW_SCORECARD_MODE=local.
FW_SCORECARD_REQUIRED_CHECKS (empty = default set) The coverage floor for a partial Scorecard report (#133, D271), local mode only. Scorecard exits non-zero when one check hits a runtime error and still writes a complete report whose aggregate excludes the errored checks; the gate salvages it. A partial report is compared to FW_SCORE_THRESHOLD only if every check named here scored; if a required check errored, the package is unscorable and the refusal names the check. Comma-separated Scorecard check names. The default is every check Scorecard’s own documentation marks Critical or High: Dangerous-Workflow, Webhooks, Binary-Artifacts, Branch-Protection, Code-Review, Dependency-Update-Tool, Maintained, Signed-Releases, Token-Permissions, Vulnerabilities. none disables the floor, so any partial report is accepted on its aggregate. A count is deliberately not offered: losing Dangerous-Workflow and losing License are not the same event.
FW_DEPSDEV_BASE deps.dev Base URL of the deps.dev API, used for both api-mode scoring and the repo cross-check. Configurable for mirrors and air-gapped installs.
FW_VERIFY_REPO true The deps.dev package-to-source-repo cross-check, so a package cannot borrow a high-reputation repo’s score.
FW_UPSTREAM_AUTH (empty) Authorization value sent on the firewall’s own probe requests, scoped to the upstream host so it cannot leak to deps.dev or the approval service.
FW_UPSTREAM_AUTH_FILE (empty) The same Authorization value, read from a file and re-read as it changes, so a short-lived upstream token (AWS CodeArtifact, Chainguard) is picked up without restarting. Mutually exclusive with FW_UPSTREAM_AUTH — setting both is refused at startup. A failed or empty read keeps the last good value rather than silently going anonymous.

⚠️ Bind-mount this file’s DIRECTORY into the gate — never the file. The whole point of this variable is rotation, and a file bind mount defeats it: the mount pins that file’s inode, and every credential helper that writes safely (temp file, then rename — AWS CLI, Vault agent, Kubernetes secret projection) replaces the inode, so the container goes on reading the old token forever. Note that the “keeps the last good value” property above does not save you here: the read does not fail, it succeeds and returns stale bytes. The symptom is an expired upstream credential with no error anywhere, and it is platform-masked — Docker Desktop resolves file mounts by path, so it passes on a Mac or Windows laptop and fails on Linux, which is where it deploys. Identical hazard, and identical fix, to the console-edited list files below. Both halves are asserted rather than trusted: TestUpstreamCredentialRotation (e2e) rotates a real credential by rename against a running container, and TestReReadFilesAreMountedAsDirectories fails any compose file that mounts a re-read config file rather than its directory.
FW_UPSTREAM_CA_BUNDLE (empty) Absolute path to a PEM file of CA certificates, appended to the system roots for every TLS connection the firewall makes (registry, artifact CDN, deps.dev, scanner, approval). Set it when an enterprise TLS-inspecting proxy sits between the gate and the registry: without it every upstream handshake fails x509: certificate signed by unknown authority and nothing resolves. Appended, never substituted — the public roots stay, so a host reached directly (NO_PROXY, split-horizon DNS, a partially-inspecting proxy) keeps verifying normally; a firewall that replaced them would fail intermittently and per host, which is the worst shape a trust bug has. “Bundle” means the full chain — the root and every intermediate: a corporate proxy that withholds its intermediate is the “I added the root and it still fails” ticket, reproduced in the end-to-end suite (e2e/tls_class7b_test.go). Refused at startup, before the listener binds, if the path is relative, the file cannot be read, holds no PEM CERTIFICATE block, or holds no CA certificate (usually the proxy’s leaf instead of its issuer). On success the startup log names every anchor with its SHA-256 fingerprint in openssl x509 -fingerprint -sha256 form, so you can compare it with what your estate distributed. Read once; rotating a corporate root is a restart.
⚠️ The proxy ADDRESS is not a FW_ variable and deliberately never will be. Use the standard HTTPS_PROXY / HTTP_PROXY / NO_PROXY environment variables — Go’s HTTP client honours them, and that is the whole of our support for routing through a corporate proxy. A credential can ride in the URL (http://user:pass@proxy:3128), which works with no configuration of ours, but note it then reaches logs, ps and every error message; a first-class credential value is a separate decision, not yet taken. NO_PROXY is what keeps an internal registry or the scanner direct. Pinned by TestPooledTransportHonoursTheProxyEnvironment so a future change to the transport cannot quietly cost you the ability to chain.
📏 Why this exists when SSL_CERT_FILE appears to work. Go also reads SSL_CERT_FILE, and measured on this image (TestTheProductChainsThroughACorporateProxy, legs P15-4/P15-5) it keeps the public roots and does get through a corporate proxy — not by contract, but because crypto/x509 reads that file and then still scans certDirectories, where distroless/static:nonroot keeps its only bundle. That is an accident of the base image’s layout: the day /etc/ssl/certs stops holding the public bundle it becomes a replace, every host reached directly stops verifying, and you find out from a developer’s failed install. FW_UPSTREAM_CA_BUNDLE is the contract — validated at startup, anchors named in the log, appended by construction. The measurement is kept as a standing test so that day is a red pipeline rather than an incident.

Policy — what happens when the gate cannot simply allow

Every one of these defaults to the safe value, and an unrecognised value reads as the safe value too, never the permissive one: a typo must not silently reopen a hole.

Variable Default What it does
FW_MODE enforce Whether verdicts are enforced at all. enforce refuses; report computes and logs every verdict and then refuses nothing — the request is relayed and the counterfactual is logged as REPORT MODE: WOULD HAVE REFUSED …. This exists because nobody switches a blocking gate on in front of their build system cold: run report against real traffic for a week and read what it would have done, instead of taking our false-positive rate on trust. It changes only the response — the verdict, reason, audit record and queue entry are identical in both modes, which is what makes the numbers you collect predictive.

⚠️ report suppresses policy verdicts, not request-integrity refusals. An artifact whose path or filename does not address the package being judged, or whose URL signature is invalid, is still refused — those are not judgements about a package, they are the gate declining to serve a request whose two halves disagree, and relaying them would make report less safe than enforce. An operator evaluating the product must never be more exposed than one enforcing it.

The startup banner announces report in capitals and the console policy view reports it first, because a gate that is not enforcing while every dashboard is green is the worst state this product can be in.
FW_UNSCORABLE_POLICY block When no score can be obtained at all — no repo link, repo missing, scan failed. The common messy-metadata case.
FW_UNVERIFIED_POLICY closed When the deps.dev cross-check cannot durably confirm the package-to-repo link. OCI: deps.dev has no container index, so under closed every image that declares a source repo is refused as unverifiable (D48, #33) — set open-with-visibility to proceed on the self-declared repo with a log. Per ecosystem by construction: each instance fronts one ecosystem, so a Maven instance can run open-with-visibility while npm and PyPI instances stay closed.
FW_BYTE_GATE allow-but-log Whether artifact bytes are re-evaluated before being served, and what happens when that evaluation denies. off, allow-but-log, enforce. OCI under enforce: a layer requested by a digest that no manifest relayed through the gate lists is refused, because the gate cannot tell which version it belongs to (D432). A pull that fetches its manifest through the gate is unaffected, warm or cold. The record of which manifest listed which layer lives inside each gate process, so a pull must stay on one copy. The chart sets sessionAffinity: ClientIP on the Service of an OCI gate that runs enforce with more than one replica. Behind an Ingress, set it on the Ingress: a controller that routes to pod endpoints never consults the Service’s affinity, and ingress-nginx does so by default. The ingress-nginx setting is nginx.ingress.kubernetes.io/upstream-hash-by: "$binary_remote_addr". Clients that reach the gate through one NAT address then share one copy. kube-proxy’s iptables mode builds ClientIP affinity on the kernel’s recent match. A node kernel without it, such as Docker Desktop’s WSL2 kernel (measured 2026-10-16), makes every kube-proxy sync fail, and the affinity Service never answers. Standard Linux node kernels have it.
FW_UNKNOWN_PATH_POLICY block A request the gate cannot name a package for and cannot justify as registry infrastructure.
FW_WRITE_POLICY block A request that would write upstream — push, publish, upload, delete. Yellow Jack is a pull-through gate. Scoped to OCI today, deliberately: npm mixes reads and writes on one surface.
FW_MIN_RELEASE_AGE_DAYS 14 on npm, PyPI, Maven and Go; 0 on OCI Release cooldown, ON by default (D335; 0 turns it off). A release with no readable publish date is held while it is on, so an upstream that publishes no dates has EVERY package refused; on npm the gate says so once in its log, naming the upstream (D337). Measured cost at 14 days over benign npm, weighted by downloads: 7.39% of fresh resolves get an older release than upstream’s newest, and 0.036% are refused outright because every version is held (E108). A release whose version is already decided is NOT held — measured with real clients (E121): npm ci, and npm install with a lockfile present, install the version the lockfile records without re-reading the packument; on PyPI an exact pin installs a held release (pkg==1.2.3, including a hash-pinned requirements file, with pip or uv), because PEP 592 lets an exact pin select a yanked file — the client prints the cooldown as its yank reason, and the gate’s log records the fetch as an ordinary allow. (The index request that yanked it is logged: one line per held release, [release-window] <pkg> -> version <v> yanked in the index (<reason>), PyPI’s counterpart of npm’s removed from the packument line; an advisory’s or an operator’s version deny logs the same way under [known-malware] or [operator-denied].) An operator who needs a held release sooner names it in FW_ALLOW_LIST, which lifts the window for that release only. npm, by contrast, refuses an explicit npm install pkg@1.2.3 of a held version, and says why: the packument carries an npm-notice header naming each held release and the UTC time it clears, which npm prints above its bare ETARGET (#164; the response is no-store, because npm prints no notice on a response it caches; pnpm 9 and yarn 1 print no notice at all). Maven refuses every held request, and so does Go for a request naming a version (.info, .mod, .zip: go.mod pins exact versions, so most Go builds take this path), saying the day it clears. So the cooldown holds fresh resolves: a just-published release that reaches a lockfile or an exact pin by another route (a machine outside the gate, a dependency-update bot) installs through it. Measured benefit: 58.89% of single-poisoned-release attacks are held past their takedown (E30). releases newer than this are held back, so a resolver cannot land on a just-published version. Judged from the registry’s own publish dates; a release with no readable date is held too. How it is applied differs by ecosystem, and the difference is visible to developers — see the note below. npm/PyPI: the release is filtered out of the metadata document (yanked-with-reason on PyPI; removed from the packument with dist-tags repointed on npm), so resolution walks to the next compliant version. Maven: the request is refused with the reason, because Maven has no per-version index to filter. Go: a version-named request is refused with the reason and the day it clears; @v/list loses the versions held, so go get resolves to an older one; a held @latest is refused. Go’s date is the Last-Modified the module proxy serves, which is when it first stored the version (measured equal to index.golang.org’s first-seen time), never .info’s publisher-set commit time. OCI: not applied, deliberately — see the note.
FW_MAX_RELEASE_AGE_DAYS 0 (off) Release-age floor: the same window’s other edge — releases older than this are held back, stopping a resolver silently downgrading to an ancient CVE-laden release. Same per-ecosystem coverage and behaviour as the cooldown above.
⚠️ The release window means different things to a developer in different ecosystems, and you should know which one they will hit. On npm and PyPI a held-back release is filtered out of the metadata document, so the resolver quietly picks the next compliant version and the build succeeds — the window steers resolution. On Maven there is no per-version index to filter (maven-metadata.xml carries one <lastUpdated> for the whole artifact, not one per version), so the request is refused and the build fails with a reason saying when it will clear. Same policy, different developer experience: budget for the Maven one. Cost: while a bound is active, Maven spends one extra HEAD per artifact URL to read the repository’s Last-Modified, memoised per URL. Both bounds default to 0, and at the default there is no probe at all.
🚫 OCI is deliberately NOT covered, and this is a measurement, not a gap waiting to be filled. The console’s /policy page says so too: on a gate that cannot enforce the window, a non-zero value is shown as NOT ENFORCED on oci rather than as a bare number that reads like protection (#150). A container registry gives us nothing trustworthy to date an image by: the manifest response carries no Last-Modified (measured against Docker Hub), and the only timestamp in the pull path is created inside the image config blob — which is content the publisher uploads. Back-dating it and recomputing the digest yields a perfectly valid blob, so a cooldown keyed on it would be set by the party it restrains: any image could clear a 7-day cooldown the moment it was pushed. That is worse than having none, because you would believe your containers were covered. And this is not a hypothetical attacker: legitimate images already do it. Of 7 real images sampled on 2026-09-12, 4 report created: 1970-01-01T00:00:00Z — every gcr.io/distroless/* one, because reproducible builds zero the timestamp on purpose so the same source yields the same bytes. One of those four is gcr.io/distroless/static:nonroot, which this repository ships in four Dockerfiles: a cooldown keyed on that field would compute our own base image as 56 years old and wave it straight through. Where a date is present it can also disagree with itself — library/busybox:latest reads 2026-05-20 in the index annotation and 2026-05-13 in the config, seven days apart, which is the width of a typical cooldown. And where it is present and self-consistent, it still runs early: across 18 Docker official images on 2026-09-16, compared against the registry’s own push record, created was earlier than the real push in 18 of 18 — median 3.5 h, maximum 1,041 days (busybox:1.36) — with 4 of the 18 (22%) off by more than seven days (nginx:1.27 55.5 d, registry:2 501 d, traefik:v3.1 14.6 d). Those four clear a 7-day cooldown the instant they land, with every publisher behaving honestly. So the field fails loudly on reproducible builds and quietly on mainstream ones. Covering OCI needs a new source of truth, not a new parser. ⚠️ Docker Hub’s own non-spec API does return a registry-set push time (measured, anonymous) — but it is one registry’s private endpoint, registry:2 has no equivalent, and it lives on hub.docker.com, a different host from the registry, which would add an egress destination the interception mode deliberately avoids. See whyOCIHasNoReleaseWindow and issue #127.
🔊 Set one of these on an OCI gate and the gate tells you, twice, rather than staying quiet: a WARNING: line in the startup banner naming the setting, its value and the gate, and NOT ENFORCED beside the value on /policy. It does not refuse to start: one environment file is often shared by several gates, and the value is meaningful for the others. Leave it off the image gate, as both shipped deployments do.
FW_MALWARE_LIST (empty) Absolute path to a newline-delimited feed of packages already publicly reported malicious (OSV / OpenSSF Malicious Packages). Build it with sh scripts/dev.sh malwarefeed --src <osv-dir> --out <feed> — the feed is a FILE, so the verdict costs no egress. Re-read while the gate runs (#160): refresh the file on your own schedule and the gate picks it up within a minute, with no restart. A failed or half-written re-read keeps the last good feed and says so on /readyz; an unreadable feed at STARTUP is still fatal. Absolute paths only (#38 / CVE-2025-64726). Package-wide advisories are enforced in every ecosystem; version-pinned advisories (the hijack case) are enforced in every ecosystem — PyPI at the /simple/ index and /_files/ gate, OCI on the reference, Maven and Go on the request path, and npm by filtering the packument and judging the tarball filename (#103, #35). Go advisories come from OSV’s Go ecosystem; on 2026-10-06 it held 20, each naming a whole module. A Go module on github.com matches whatever the case of its owner, because the public proxy serves it under any case spelling. The startup log states the count for the running ecosystem.
FW_MALWARE_FEED_KEY (empty) Base64 Ed25519 public key the snapshot at FW_MALWARE_LIST must be signed with. Empty means no verification, which is what shipped before #157 and stays legal — refusing an unsigned feed would disarm layer 1 for every deployment that has not re-generated one. When it is set, the gate requires a detached signature beside the feed (<feed>.sig) and refuses to load a snapshot that does not verify — at every re-read, not only at startup, because the point of re-reading is that the bytes change afterwards. A refused snapshot never becomes the enforcing feed: the last good one stays in force and the failure is logged as a SECURITY line. A signed feed must also carry the header line # yellowjack-feed v1 serial=N generated=<RFC3339> rows=N, because a signature alone proves the key-holder produced those bytes once — which is exactly what a replayed snapshot also proves. The gate then refuses a re-read whose serial goes backwards, compared against the feed in force rather than a remembered maximum, so a deliberate rollback costs a restart and an attacker cannot do it quietly. A snapshot older than 72 hours is logged loudly and still enforced: an old advisory is still an advisory, and refusing to enforce what we have because it is old would turn a stale feed into no feed. Generate a key with go run ./scripts/feedsign -gen and sign with go run ./scripts/feedsign -key <private> -in <feed> -serial <n>. The key is yours — a deployment that builds its own feed signs it with its own key, and nothing here implies trusting us.
FW_MALWARE_FEED_URL (empty) Where the gate pulls the signed known-malware snapshot from, hourly, off the request path (#157, D294). Empty means the gate fetches nothing and you keep FW_MALWARE_LIST current yourself, which is what shipped before. There is no default: every address the gate dials is your configuration (docs/EGRESS.md). Requires FW_MALWARE_FEED_KEY (an unverifiable pull is refused at startup) and FW_MALWARE_LIST, which is where it installs. The signature, header and row count are checked in memory, before anything is written, and the snapshot then reaches enforcement through the ordinary re-read within a minute. A snapshot older than the one in force is refused; the same one is not rewritten. A failed pull keeps the feed in force, is logged once per change, and never makes a replica unready. ⚠️ Mount FW_MALWARE_LIST’s DIRECTORY writable (the gate replaces the file by rename), and seed it with a signed snapshot (in the image, or by an init container): a replica that starts with no file tries one pull and refuses to start if it fails, so an unseeded replica cannot start while the endpoint is down. The registry credential (FW_UPSTREAM_AUTH*) is never sent to this host.
FW_MALWARE_FEED_AUTH_FILE (empty) Absolute path to a file holding the Authorization header value the feed pull sends, for a feed that needs a subscription key (D366). Re-read every second like FW_UPSTREAM_AUTH_FILE, so a rotated key needs no restart, and a failed or empty read keeps the last good key. Sent only to the feed host: never to a registry, never logged, and not carried along a redirect to another host. A refusal is named, not reported as an outage: no key gives “NO access key was sent” (HTTP 401), and a wrong, expired or revoked key gives “the feed host REFUSED the access key” (HTTP 403). The feed in force stays enforcing either way. Refused at startup when the path is relative (#38 / CVE-2025-64726), when FW_MALWARE_FEED_URL is not set, or when that URL is plain http to a non-loopback host, because the key would travel in cleartext. The public route stays key-free: build the list yourself from OpenSSF data with scripts/build-malware-feed.py.
FW_ALLOW_LIST (empty) Absolute path to the operator’s own allow list: one package name per line, # comments. A listed package is served without scoring and without any upstream call, so it keeps working while deps.dev is down or throttling. When FW_ADMIN_OUTRANKS includes cooldown, an entry also lifts the release window (the cooldown and the age floor) for what it names: pkg@1.2.3 (npm), pkg==1.2.3 (PyPI) or group:artifact:1.2.3 (Maven) lifts it for that release, a range for every release inside it, and * for every release of the package. Each release served this way is logged with [window-lifted]. Your own deny list still wins. ⚠️ It overrides FW_MALWARE_LIST (D367): a listed package is served even when a published advisory names it, and every such pull is logged as [administrator-override] and recorded in the audit record’s override field. Your own deny list still wins over it.
An allow line may name ONE RELEASE, in the same spellings the deny list takes, plus image@sha256:… for OCI (a digest, never a tag — a tag is mutable, so “allow whatever this tag points at next” is not a decision anyone made). An allow entry outranks a known-malware advisory for every release it covers (D312, D367): a pinned line covers the release it names, and * covers every release, including ones published after you wrote it (lodash@*, requests *, group:artifact:*). ⚠️ A name with no version (lodash) is not valid on either list (D397, D415): the gate refuses to start on one, naming the line and what to write instead (lodash@*), and a list re-read while the gate runs keeps the last good list in force. v0.2.0 still accepted it, as every version, with a warning. The administrator decided, so the gate does what the file says. It is loud, not silent — the log line [administrator-override], the audit record’s override field and the console all carry it, so the organisation sees which allows were made over a finding. If you mean one release, pin it. An allow line may also name a version RANGE (#165), in npm’s package.json range syntax in every ecosystem: lodash@^4.17.0, requests>=2.31 <3 (or requests==2.*), group:artifact:>=1.0 <2.0. The operators are * < <= > >= = x - y ^ ~ 1.2.x; a space means AND and `
FW_ADMIN_OUTRANKS advisory,install-script Which checks an administrator’s decision outranks: an entry on FW_ALLOW_LIST, or a person’s approval of a release in the console’s Decisions (D409). A comma-separated list of advisory (a known-malware advisory), cooldown (the release window: the cooldown and the age floor) and install-script (FW_INSTALL_SCRIPT_POLICY=disallow), or none. The default, advisory,install-script, is the behaviour before this setting existed: an allow entry outranks an advisory (D312, D367) and nothing lifts the release window, so an upgrade changes nothing; install-script only matters once you turn FW_INSTALL_SCRIPT_POLICY to disallow. Add cooldown to let an allow entry, or a person’s approval, serve a release the window would hold (for example an urgent fix published yesterday). Each such release is logged [window-lifted] with who lifted it, and the audit record’s override field names the release window as the outranked check. none makes an allow entry outrank neither. Not configurable: your deny list still refuses first, and an every-version ban still refuses every release. An unknown word stops the gate at startup. A known-malware advisory refuses a release before it can reach the approval queue, so in practice advisory concerns allow-list entries.
FW_INSTALL_SCRIPT_POLICY off What the gate does with an npm release that runs code at install: a preinstall, install or postinstall script, or a native build npm runs on its own (binding.gyp) (D406). off does not act on it. Under every setting, off included, the console’s audit log marks a served release that runs code at install (“runs code at install”), as a fact rather than a finding. disallow refuses such a release. approval refuses it until a person approves that release in the console’s Decisions, and queues it there for that decision (needs FW_APPROVAL_URL). Judged per release: scripts appear and disappear between versions, so a plain install resolves to the newest release that does not run code at install, and a release that does is refused even when asked for exactly, including from a lockfile. When the gate cannot read a release’s metadata to check, it refuses that release (fail closed). Under approval, an entry on FW_ALLOW_LIST covering the release also counts as the approval. Under disallow, an allow entry or an approval serves the release while FW_ADMIN_OUTRANKS includes install-script, which it does by default; leave it out to make disallow absolute. Your deny list still refuses first. npm only. PyPI’s nearest equivalent, a release that ships only a source distribution (building it runs its code), is not covered by this setting; Maven and OCI have no install-time hook. An unknown word stops the gate at startup.
FW_ATTESTATION_POLICY input What the gate does with an npm release’s build provenance: the signed record, published through Sigstore, of which repository, commit and workflow built it (#34). input checks each tarball’s release off the request path and logs the result: valid, with the repository, commit and workflow the signing certificate names, or missing, invalid or unsupported, with why. It never blocks. require refuses a release whose provenance is not valid, before any byte is fetched, and names the status in the refusal; it fails closed when the attestation cannot be read. off fetches nothing. The check is the cryptography: a certificate chained to Sigstore’s Fulcio CA, an authentic and logged Rekor entry, a DSSE signature, and a statement naming this package and this tarball’s SHA-512. It does not judge whether the repository named is the right one for the package. The release document and its attestations are read from FW_UPSTREAM only, so no new destination is contacted. npm only so far: require on another ecosystem refuses to start.
FW_DENY_LIST (empty) Absolute path to the operator’s own deny list, same format. A listed package is refused with no upstream call and a reason that names your organisation as the decider rather than an advisory — a developer must be able to tell “we blocked this” from “this is publicly reported malware”, because they go to different people (D193). Enforced as a hard deny, so a lockfile-pinned tarball fetch cannot walk past it (#11).
🔄 Which file-valued settings are re-read, and which need a restart. Re-read while running: FW_MALWARE_LIST (within a minute), FW_ALLOW_LIST and FW_DENY_LIST (within 5 s), FW_UPSTREAM_AUTH_FILE (within 1 s). Read ONCE at startup, so a change needs a restart: FW_UPSTREAM_CA_BUNDLE and FW_INTERCEPT_CA_FILE — both are trust material verified at boot (the interception CA has a startup preflight), and swapping the chain under a running listener is a posture change rather than a refresh. In every re-reading case a failed read keeps the last good value: blanking a deny list or a feed would fail OPEN.
📋 What the console shows of a list is a REPORT, not the list. Each gate reports at most 200 names per list in its heartbeat, and if the whole report would not fit the heartbeat budget it sends each list’s digest and count without the names — a heartbeat the control plane refuses would make a healthy gate read as stale, and liveness is not worth trading for a display (#139). The /policy page then says how many entries are loaded and enforced, and why their names are not shown. Enforcement is unaffected either way: the gate always matches against the full file, and the digest that detects replicas disagreeing is computed over the full list.
⚠️ On OCI, an entry covers the WHOLE repository — every tag — even when it names one. library/alpine:3.19 and library/alpine are the same entry here: the reference is part of an image’s identity (so the line is well formed and IS enforced), but matching strips it, because a published advisory has to condemn a repository whichever tag was asked for. The trap is the accidental one — blocking the single bad tag of a base image your builds depend on takes out every build using any tag of it, and the refusal names the repository, so the log reads exactly as though you had written the repository. The gate now says so when it loads the file rather than leaving you to find out from a failed build; per-tag operator entries are not supported. Other ecosystems refuse a version-pinned line outright.
A line may name ONE RELEASE instead of the whole package (#155), in each ecosystem’s own spelling: lodash@4.17.20, @scope/name@1.2.3, requests==2.31.0, group:artifact:1.2.3, github.com/acme/widget@v1.2.3. That release is refused and its siblings stay installable — the hijack case, where one version of a package you depend on is bad and the package is fine. lodash@* refuses every version; a bare name is not valid (D397, D415). Enforced wherever the release is knowable: the npm packument and tarball, the PyPI index and file, the Maven and Go request paths. A Go release is written as the go command sends it: a leading v, and +incompatible for a v2-or-later release of a module path without /v2. A line no request can match (github.com/acme/widget@1.2.3, or @v2.0.0 on a path without /v2) stops the gate at startup with the spelling to write instead. A Go module’s version list is served even when a release of it is refused, so a refused release fails go get with this list’s reason rather than taking the whole module away. If a request’s version cannot be determined and the package carries a version-scoped entry, it is refused rather than guessed at. OCI is the exception: an entry there names a tag or digest and applies to the whole repository (#128), unchanged. The deny list takes no ranges. The allow list takes one release, a range or *; see the FW_ALLOW_LIST row above.
🛡️ The deny list is your dependency-confusion defence, and it is the only one we can give you. Put the names of your internal, never-published packages in FW_DENY_LIST. Then a public package that appears under one of those names is refused at the gate, with your organisation named as the decider — which is the whole attack: someone publishes your-internal-lib to the public index and a resolver prefers it. We cannot detect this for you from public data. A private name is, by construction, absent from every public index and popularity list, so nothing computable from the outside distinguishes “an internal name someone squatted” from “a new package” — but you know your namespace, and this is where you tell us. Keep the list in the same place you keep the list of what you publish internally, so the two cannot drift apart. Measured on PyPI as well as npm (2026-09-12): the strongest public-data instrument for this, a name-distance rule, reaches 6.5% of typosquats at 0.85% benign at its tightest and, of the packages squatting private names, 2 of 15 — the name it would have to be near is in no public list. On PyPI this list is not a fallback for a detector we lack; it is the mechanism.
⚠️ If the console edits these lists (CONSOLE_LIST_REPO), bind-mount the list files’ DIRECTORY into the gate — never the two files. A file bind mount pins that file’s inode; the console writes atomically (temp file, then rename), so an edit replaces the inode and a container holding a file-mount goes on reading the old one forever. The failure is silent and total in the worst direction: the console reports “committed and pushed”, git log is perfect, and the gate enforces none of it. It is also platform-masked — Docker Desktop resolves file mounts by path, so this passes on a Mac or Windows laptop and fails on Linux, which is where it gets deployed. e2e/async_local.sh leg 23 pins it by pulling a real package after the edit.

Caching and limits

Variable Default What it does
FW_SCORE_CACHE_TTL 1h How long a score is cached in memory — the L1 fan-out guard. 0 disables L1 caching, and is the single spelling for “off”. 🛡️ An OCI tag is not trusted from this cache by name. A tag is mutable, so on every pull the gate asks the registry what the tag points at now (one HEAD, no body) and uses the cached verdict only for that exact digest; a moved tag is re-evaluated on its first pull (#93, D164). Cost: one request per warm tag pull, on an anonymous registry and, after the first pull, on one that demands a bearer token (the token is kept per image for the lifetime the registry states); none for a pull by digest.
FW_SCORE_CACHE_MAX_ENTRIES built-in default Caps entries in each L1 cache. A TTL alone is not a bound: cardinality grows without limit in a long-lived service. 0 means “use the default”, never “unbounded”.
FW_SCORE_L2_TTL 0 (never stale) Freshness window for the durable L2 score cache. Without it a score is remembered forever — an unexpirable trust cache. Past this window the repo is treated as cold and re-scanned rather than served stale.
FW_MAX_CONNS_PER_HOST built-in default Bounds the TCP connections the firewall opens to any one host.
FW_RATELIMIT_BACKOFF_TTL 10s How long a package stays backed off after a scoring probe was rate-limited (HTTP 429). The same duration governs the deps.dev OUTAGE breaker: after a deps.dev lookup fails as unreachable or 5xx, verification answers “unavailable, retry” for this long without re-probing, so an outage costs one timeout rather than one per cold package (D342). 0 disables both.
FW_FLOW_FLUSH_INTERVAL 1m Capacity flush cadence, and the bucket width.

Integrations and operator experience

Variable Default What it does
FW_APPROVAL_URL (empty — disabled) Base URL of the optional approval service, consulted for human decisions on unscorable packages before FW_UNSCORABLE_POLICY applies. A person’s ruling is about one release (D385). For a container image that release is the digest the tag named when it was queued: when the tag moves to a different image, that image is refused and queued again, rather than served on the old approval. A denial recorded against a tag before this (2026-09-30) still refuses.
FW_AUDIT_ARTIFACTS true Records each npm tarball and PyPI file the gate serves in full as its own line in the decision log, naming the release, the file, and the SHA-256 of the bytes the client received, summed as they stream (nothing is buffered). With it, the export answers “which release, and which bytes, did we let in”, which the per-package verdict line cannot for npm and PyPI. Where the gate also checked the file against the digest the registry published (PyPI), the line says so (artifact_verified); a file that fails that check is aborted and recorded nowhere as delivered. These lines are tagged "record":"artifact", and the console’s totals, downloads by IP and last seen count installs, not lines: an artifact line counts only when no verdict for the same package and host is within ten minutes of it. So an npm install (a packument verdict, then the tarball) counts once, and a lockfile npm ci, which writes only artifact lines, counts once too. With false, a lockfile install leaves no line, so the console cannot count it. The cost is about one extra audit write per installed package (an install’s writes roughly double) and a SHA-256 pass over each artifact. false turns both off. No effect without FW_APPROVAL_URL. Maven and OCI are not covered: their verdict lines already name the release.
FW_PENDING_RETRY_AFTER 60 Seconds a developer is told to wait on a “verdict pending” refusal. A cold scan takes roughly 20–80s, so the figure is worth keeping accurate — it is read by a person, not acted on by a package manager.
FW_PENDING_HOLD 25 Seconds the first pull of a never-scanned package waits for the scan it started before answering “pending” (#185, D139). A scan that lands in time turns that pull into the real verdict, so the install succeeds (or is refused for a real reason) without the developer re-running it; past the bound the answer is the same 403 pending as with 0, which turns the hold off. No status code changes (D102). The default sits under yarn classic’s 30 s network timeout, the shortest default among npm clients; on PyPI the hold is capped at 12 s, under pip’s and Poetry’s 15 s read timeout, because a hold that outlasted the client would turn a readable refusal into a timeout. A cold scan takes roughly 20–80 s, so the hold converts the faster scans, not all of them. Local scoring mode only.
FW_URL_SIGNING_KEY (empty — opt-in) Enables signed artifact URLs, binding a URL’s package prefix to its object path. Empty is a first-class supported configuration: a default install must need no secret at all.
FW_URL_SIGNING_KEY_PREVIOUS (empty) Accepted for verification only, never for minting. Rotation needs it: signed URLs get recorded in lockfiles and replayed weeks later, so without a grace window rotating would break every in-flight lockfile.

The approval service’s own variables

The approval service holds the durable state: the approval queue, the L2 score cache, and the append-only decision log the #28 compliance export reads.

Variable Default What it does
APPROVAL_LISTEN_ADDR :8090 Address the service listens on. ⚠️ :8090 is every interface, and this service has no authentication — PUT /v1/decisions approves a quarantined package, PUT /v1/scores writes a score the firewall then serves as a verdict. Note the asymmetry with CONSOLE_LISTEN_ADDR, which defaults to loopback precisely so an unauthenticated operator surface is not published by accident: the service that merely displays the queue is bound more carefully than the one that can approve things in it. The shipped docker-compose.yml also publishes 8090 to the host. Authentication here is decided, and the decision is not to build it — D77 (2026-07-27) rules inter-service auth out of the application and onto the deployment’s service mesh or network policy, so #13 closed with its forgery half shipped (the scan-result capability token) and its mechanism half declined. This line previously read “deferred, not decided — the project’s call (#13 item 2, D31 Q3)”, which was true when written and stale from the day D77 landed. The practical instruction is unchanged and now load-bearing rather than provisional: network reachability IS the only control here, by design, so scope it accordingly — see docs/SETUP.md, “these services trust the network between them”. The exposure stays enumerated and pinned by approval/exposure_test.go, which is what keeps a delegated boundary visible instead of assumed.
YJ_CONTROL_PLANE_BIND 127.0.0.1 Compose-level, not read by any service — it is the host interface docker-compose.yml publishes the approval service (8090) and the scheduler (8096) on. Setting it to 0.0.0.0 publishes an unauthenticated, mutating control plane on every interface, which removes the single-host protection D77’s private-network assumption leans on. e2e/async_local.sh and e2e/verify_repo_local.sh export 0.0.0.0 deliberately, because their stacks live in a throwaway network the rig tears down; that is not a pattern to copy into a deployment. It is documented here rather than caught by the drift guard below because the guard reads Go source for FW_* lookups, and this variable is interpolated by compose and never reaches Go at all — so nothing but this row can surface it.
POSTGRES_PASSWORD (none — generated) Required, and deliberately not defaulted (#22). Consumed twice by docker-compose.yml: by the postgres service, and interpolated into APPROVAL_DATABASE_URL so the two cannot drift apart. It used to read postgres in both places — one identical database credential on every installation of the product, in the file a self-hoster runs unmodified. sh scripts/dev.sh up generates a per-machine value into a git-ignored .env (scripts/gen-env.sh), so “runs with nothing set” still holds for a developer; a fresh clone that skips that step gets compose’s own error naming the command, not a silent default. ⚠️ Postgres applies this only when it initialises an EMPTY data directory, so changing it does nothing to an existing pgdata volume — the generator detects one and keeps the legacy value rather than locking you out. Rotating means docker compose down -v, which destroys the local database and any audit trail in it. The e2e rigs export a fixture value instead, which docker-compose.e2e.yml documents as a fixture rather than a secret.
APPROVAL_DATABASE_URL (empty — in-memory) Postgres DSN. Empty is not a broken deployment: the service starts with an in-memory store and works, which is what keeps “runs with nothing set” true for the whole stack. It is lost on restart, so it is right for local development and wrong for anything holding an audit trail. This is the one variable a real deployment must set, and it is required for durability, not for startup.
APPROVAL_DB_CONNECT_TIMEOUT 60s How long startup keeps retrying the FIRST database connection before giving up; 0 means one attempt. Exists because an orchestrator does not promise start order: the first real helm install started this service before the postgres Service name resolved, and it exited and restarted five times. Bounded on purpose – after the window the last error is returned and the process exits with it, so a wrong DSN is still discovered at startup rather than becoming a pod that is Running and never Ready. Every failed attempt is logged with its error. The Helm chart sets 300s and a startupProbe (#163): approval opens its listener only once the database answers, so without the probe the liveness check killed it inside its own connect window. The first real-cluster install measured 6 restarts while the bundled postgres image was still pulling, and 0 after.
APPROVAL_UPSTREAM_ECOSYSTEM npm Which package-manager protocol the developer-lookup page speaks when harvesting the registry’s own metadata (D158). npm, pypi and maven are implemented; oci is not, and deliberately — a registry publishes no push time (the only date is created inside the publisher-uploaded config blob, demonstrated forgeable; see whyOCIHasNoReleaseWindow), so an OCI harvest would render a date the publisher chose. Any other value means no harvest. Selecting an ecosystem is not enough on its own — the matching URL variable below must also be set, or the page reports “not collected by this service”.
APPROVAL_UPSTREAM_NPM (empty — no harvest, no outbound call) Base URL of the npm registry to read package metadata from for the lookup page. Empty is the shipped default and makes no network call at all: the page reports the field as “not collected by this service”, exactly as before this feature existed. Defaulting it to registry.npmjs.org would give every existing deployment a new egress destination it never asked for, against the zero-egress posture (D155, D26). An air-gapped or mirrored deployment points this at the same internal mirror the firewall uses.
APPROVAL_UPSTREAM_PYPI (empty — no harvest, no outbound call) Base URL of the PyPI JSON API to read project metadata from for the lookup page — https://pypi.org/pypi for the public index, or the same path on a mirror (devpi and pypiserver both expose it). Not the /simple/ index: that is the installer protocol (PEP 503, a bare link list) and carries none of the fields this page renders, so a fetcher pointed at it would return an empty harvest that reads as a healthy package. Empty is the shipped default and makes no network call, for the same zero-egress reason as the npm variable above. Deliberately a separate variable: an operator running an internal npm mirror has not thereby authorised calls to a Python index.
APPROVAL_UPSTREAM_MAVEN (empty — no harvest, no outbound call) Base URL of a Maven repository root to read artifact metadata from for the lookup page — https://repo.maven.apache.org/maven2 for Central, or the same path on a Nexus/Artifactory proxy. Empty is the shipped default and makes no network call, for the same zero-egress reason as the two variables above, and it is separate for the same reason: an internal npm mirror is not authorisation to call a Java repository. Costs one extra request per lookup, and the reason is recorded rather than hidden: maven-metadata.xml carries one <lastUpdated> for the whole artifact (guava: 158 versions, one timestamp), so dating a version by it would stamp every version identically while looking correct. The honest date is the per-file Last-Modified the repository sets, which needs a HEAD on the release POM — the same source, and the same measurement, as the Maven release window (mavenage.go). A repository that forbids HEAD or serves no Last-Modified simply yields no date; the rest of the page still renders. Two fields Maven cannot answer at all: there is no deprecation signal (no equivalent of npm’s deprecated or PyPI’s PEP 592 yank), and no publisher list (<developers> is publisher-authored prose inside the artifact, not the repository’s record), so both render empty.
APPROVAL_FLOW_RETENTION 336h (14 days) How long per-package flow/usage buckets are kept before the sweeper deletes them (#32 Phase C). This is the telemetry dataset, deliberately separate from the audit log — the audit log is append-only and is not swept by this.
APPROVAL_FLOW_SWEEP_INTERVAL 1h How often the retention sweep above runs.
APPROVAL_ALERT_SWEEP_INTERVAL 1m How often the service checks for conditions worth emailing about.
APPROVAL_USAGE_URL (empty — usage data off, nothing sent) Where to send opt-in usage data (#173, D416): one JSON POST a day, with the report for the day before. Empty, the default, sends nothing and starts nothing, which TestAnUnconfiguredInstallSendsNoUsage asserts. No address of ours is built in: you name the destination, so docs/EGRESS.md’s claim holds. The console’s Usage data page and GET /v1/usage show the exact report each level would send, whether or not this is set. A value that is not an http(s) URL stops the service at startup. A failed send is logged once per change of outcome, and the next report covers the missed day.
APPROVAL_USAGE_LEVEL counts What the usage report carries, when APPROVAL_USAGE_URL is set. counts: the verdict tallies for the day, by deny kind, plus each package blocked as known malware (its name comes from the public advisory feed). No other package name is sent at this level, because the gate can front a private registry and an internal name can be refused as unscorable or put on the deny list. inventory: also every package and version the gates decided on that day, with the verdict, capped at 5,000 entries and marked when truncated. This is your dependency list, so it is its own choice (D418). Neither level sends source addresses, reasons, scores or rule text. Any other value stops the service at startup.
APPROVAL_FP_WEBHOOK (empty — reports stay local) Where to forward false-positive reports filed from the console (#142), as one JSON POST per report. Empty means a report is recorded on this control plane and nothing leaves the deployment — the shipped default, and asserted by a test with a negative control. Set it to an http(s) URL you run (an inbound webhook on your chat tool, a ticketing system, a collector inside your own boundary): there is no built-in destination and no vendor host. A destination that is down never costs the local record — the report is stored first, forwarding is best-effort, and failures are logged once per change of outcome.
APPROVAL_CHAT_WEBHOOK (empty — nothing is posted) An incoming-webhook URL for a chat channel (Slack, or Microsoft Teams via APPROVAL_CHAT_FORMAT) that is told when the firewall blocks a package (#87, D137). Off by default, and asserted so: empty means no notifier, no client and no outbound call. Only enforced verdict blocks are posted: not allows, and not report-mode “would have blocked” lines. Each package, version and reason is posted at most once an hour, so a CI fleet retrying a blocked package produces one message. The block is recorded first and the message is sent in the background; a webhook that is down never costs the record, and failures are logged once per change of outcome. ⚠️ This sends a package name, version and the block reason to the chat service you chose: operator-chosen third-party egress, never vendor egress (docs/EGRESS.md).
APPROVAL_CHAT_FORMAT slack The message shape APPROVAL_CHAT_WEBHOOK expects: slack sends {"text": ...}, which Slack’s incoming webhooks and most chat tools accept; teams sends a message carrying an Adaptive Card, which a Microsoft Teams Workflows webhook needs. Anything else refuses to start.

Alert email

Email is off unless configured, and configuring it is deliberately all-or-nothing: APPROVAL_SMTP_HOST and APPROVAL_ALERT_TO must both be set. Setting exactly one is refused at startup with a message naming both, because a half-configured alerter is the failure that stays silent — an operator believes they will be told and they will not.

Variable Default What it does
APPROVAL_SMTP_HOST (empty — email disabled) SMTP server hostname.
APPROVAL_SMTP_PORT (unset — the mailer’s default) SMTP port.
APPROVAL_SMTP_USER (empty — no auth) SMTP username, if the relay requires one.
APPROVAL_SMTP_PASSWORD (empty) SMTP password. ⚠️ A credential. It is read from the environment like everything else (CONFIG_SURFACE.md), so it should be injected by the orchestrator’s secret mechanism and never written into a compose file or an image.
APPROVAL_SMTP_TIMEOUT 20s Bound on the SMTP conversation, so a hung relay cannot wedge the alert loop.
APPROVAL_ALERT_TO (empty — email disabled) Comma-separated recipients. Half of the all-or-nothing pair above.
APPROVAL_ALERT_FROM (derived from the hostname) Sender address. The default names the sending host on purpose, so an operator running several deployments can tell which one is shouting without opening the message.

The console’s variables

The console is a stateless HTTP client of the approval service. It owns no data.

Variable Default What it does
CONSOLE_LISTEN_ADDR 127.0.0.1:8085 Address the console listens on. The default is loopback, not :8085, and that is the deliberate choice: the console has no authentication unless the two variables below are set, so a default that bound every interface would publish an unauthenticated operator surface to the network the moment someone ran it.
CONSOLE_APPROVAL_URL http://localhost:8090 Where the approval service is.
CONSOLE_SCHEDULER_URL (empty — no Scans page) Where the scorecard scheduler is, e.g. http://scheduler:8096. When set, the console’s Scans page shows the scans running and waiting for a launch slot (the scheduler’s GET /scans). Empty means no scanner: the page says so and the sidebar leaves it out. Reached on the internal network, like the gates’ /scan calls (D77).
CONSOLE_AUTH_USER (empty — no auth) Basic-auth username.
CONSOLE_OIDC_ISSUER (empty — OIDC off) Your identity provider’s issuer URL, e.g. https://login.microsoftonline.com/<tenant>/v2.0 or https://accounts.google.com. The console fetches <issuer>/.well-known/openid-configuration once at startup and refuses to start if the document’s own issuer does not match this value — that check is what stops a mistyped issuer silently federating your console to somebody else’s IdP.
CONSOLE_OIDC_CLIENT_ID (empty) The client id your IdP issued for the console.
CONSOLE_OIDC_CLIENT_SECRET (empty) ⚠️ A credential. The console is a confidential client and authenticates to the token endpoint with it (client_secret_basic).
CONSOLE_OIDC_REDIRECT_URL (empty) The full callback URL, ending /auth/callback, exactly as registered with the IdP. Its scheme also decides cookie security: https:// sets Secure on the session cookie automatically, http:// does not — so a localhost trial works and a real deployment is protected without a separate knob.
CONSOLE_OIDC_SESSION_TTL 12h How long a sign-in lasts before the IdP is consulted again. Ours, not the IdP’s: the console holds no refresh token, so re-authentication is a fresh redirect — which is also how a revoked account stops being able to act. An unparseable value falls back to the default rather than refusing to boot.
ℹ️ All four of issuer / client id / secret / redirect URL must be set for OIDC to engage; an incomplete set is treated as “not configured”, so a half-written deployment keeps the posture it already had. When OIDC is on it replaces basic auth as the identity source, and an override’s decidedBy becomes the signed-in person rather than a shared label. A configured-but-unreachable IdP is fatal at startup — falling back to the weaker posture an operator has just replaced would be a silent security downgrade. Step-by-step guides per identity provider, and which of them have been run against the real service: integrations/README.md (Keycloak is tested).
CONSOLE_AUTH_PASS (empty — no auth) Basic-auth password. ⚠️ A credential, and the console’s only access control. Empty means the console is open to anyone who can reach it — acceptable behind loopback (the default above), not acceptable once CONSOLE_LISTEN_ADDR is widened. The two go together: widening the bind address without setting these is the mistake this table exists to make visible. Fuller auth is deferred (D58); see #13.
CONSOLE_ADMINS (empty — nobody is an Admin) With OIDC: who is an Admin, as a comma-separated list of IdP subjects (sub) or email addresses (#170). Everyone else who signs in starts Read-only: they see everything, including the audit log and its export, and change nothing. An Admin can then change anyone’s role on the console’s Users page, and each change is recorded on the operator-action ledger with who made it. ⚠️ An email counts only when the IdP marks it verified (email_verified), because on an IdP where people can edit their own email, an unverified address would let anyone name themselves an Admin. Entra ID sends no email_verified, so name Entra admins by subject (the user’s object ID as the sub claim shows it). Empty means nobody is an Admin and nothing can be approved — the safe failure, and said in the startup log and on the Users page. People listed here cannot be demoted from the page. Ignored without OIDC.
CONSOLE_READONLY_USER (empty — no Read-only credential) A second shared basic-auth username, with the Read-only role (#170): for a console without an identity provider that should still have somebody who can look and not touch. CONSOLE_AUTH_USER is then the Admin credential. Must differ from CONSOLE_AUTH_USER, or the console refuses to start: two credentials with one name would make every log line by that name ambiguous about which role made it. Ignored when OIDC is on.
CONSOLE_READONLY_PASS (empty) Its password. ⚠️ A credential. Both halves must be set for the Read-only credential to exist.
CONSOLE_LIST_REPO (empty — editing off) Absolute path to a git working tree holding the operator allow/deny list files. Empty means /lists renders read-only. This is the one setting that turns on the console’s second write path (D193: “the UI edits the whitelist”), and it is off by default because the console has authored nothing until a deployer says where the authoring should be recorded — a default would have to invent that place. ⚠️ Must be absolute, for the reason FW_ALLOW_LIST must be: a relative path is chosen by whatever directory the process was launched from rather than by the operator (#38 / CVE-2025-64726), and this path is written to. 🚩 The image we ship by DEFAULT cannot use this setting. The default console is distroless/static, which has no git binary — D199 (2026-09-08) ruled git stays opt-in, because git plus a shell in the one service holding the operator credential is a footprint we decline by default. On that image the console refuses at startup and /lists names the real remedy — build or pull the git-capable target (docker build --target console-git) — rather than telling you to set a variable that cannot help.
CONSOLE_LIST_ALLOW_FILE allow.txt The allow list’s path inside CONSOLE_LIST_REPO. Relative by design — a path escaping the repo is refused, because a write outside the repository is a write outside the audit trail that is the whole reason this mechanism was chosen. Point FW_ALLOW_LIST at the same file as it appears on the firewall’s filesystem.
CONSOLE_LIST_DENY_FILE deny.txt The deny list’s path inside CONSOLE_LIST_REPO. Same rules as above; pairs with FW_DENY_LIST.
CONSOLE_LIST_ECOSYSTEM npm Which ecosystem’s naming rules apply when validating an entry an operator types. Must match the gated deployment’s FW_ECOSYSTEM: it decides whether library/nginx:1.25 is a well-formed name (it is, for oci — D164) or a version pin the firewall would refuse to start on.
CONSOLE_LIST_REMOTE origin Where a list change is pushed after it is committed. Empty means commit only — legitimate for a single-host deployment, and reported as such on the page rather than presented as a change that has reached the fleet. A failed push does not fail the edit: the commit stands, and the operator is told the difference.

The scheduler’s variables

The scheduler launches one run-once scanner container per scan (D16).

Variable Default What it does
SCHEDULER_LISTEN_ADDR :8096 Address the scheduler listens on.
SCHEDULER_SELF_URL http://scheduler:8096 The URL a launched container uses to reach the scheduler back — the result sink. It is not the same thing as the listen address: the container resolves this from inside the container network, so localhost is almost always wrong here.
SCHEDULER_LAUNCHER docker-run Which launcher implementation to use: docker-run (shells out to the docker CLI) or docker-api (talks to the daemon socket directly). Both are exercised by e2e (!21). A third, ECS-RunTask, is planned for AWS (D24).
SCHEDULER_SCANNER_IMAGE yellowjack-scanner:dev The image to launch per scan.
SCHEDULER_DOCKER_BIN docker Path to the docker CLI, for the docker-run launcher.
SCHEDULER_DOCKER_SOCKET /var/run/docker.sock The daemon socket, for the docker-api launcher. ⚠️ Mounting this into a container is root-equivalent on the host regardless of the user the container runs as — see the ROOT_BY_DESIGN note in e2e/hardening.sh.
SCHEDULER_DOCKER_NETWORK (empty) The docker network to attach launched containers to. Empty is usually wrong and the scheduler says so at startup: a container on the default bridge generally cannot reach SCHEDULER_SELF_URL, so scans run and their results go nowhere.
SCHEDULER_MAX_CONCURRENT_SCANS 8 Cap on simultaneous scans (D44/#13). A launch cap is what stops an unauthenticated or high-volume caller turning the scheduler into a container-spawning amplifier.
SCHEDULER_SCAN_TIMEOUT_SECS 240s How long the scheduler waits for a scan before giving up. Deliberately longer than the scanner’s own timeout below, so the scanner is the thing that times out first and can report why.

The scanner’s variables

The scanner is a run-once container: it scans one repo, POSTs the result, and exits. It serves nothing and has no port. These are set by the scheduler at docker run time rather than by an operator, but they are part of the surface and are listed for the same reason as everything else — someone debugging a scan needs to know what it read.

Variable Default What it does
SCANNER_REPO (none — required) The source repo to scan. The one genuinely required variable in the whole surface, and it is required of a container the scheduler starts, not of an operator.
SCANNER_SINK_URL (none — required) Where to POST the result. Same note as above.
SCANNER_SCORECARD_BIN scorecard Path to the OpenSSF Scorecard binary. The image sets this to an absolute path, because distroless has no PATH search to fall back on.
SCANNER_SCAN_TIMEOUT_SECS 180s Bound on the Scorecard run. Shorter than the scheduler’s timeout on purpose (see above).

The cache service’s variables

⚠️ The cache is out of scope permanently (D151). We integrate with an existing registry — ECR/CodeArtifact, Harbor, Nexus, Artifactory — rather than writing one. This service still exists in the tree and still has a settings surface, so it is enumerated here; it is not a component to build on.

Variable Default What it does
CACHE_LISTEN_ADDR :8070 Address the cache listens on.
CACHE_UPSTREAM http://localhost:8080 The firewall it sits in front of.
CACHE_TTL 5m How long a cached response stays fresh.
CACHE_MAX_BYTES 268435456 (256 MiB) Total body+header budget across all entries, so the cache cannot grow without bound.

The yj CLI’s variable

yj runs on a developer’s machine or in a build job, not in the stack (#88). It reads one setting.

Variable Default What it does
YJ_GATE (required) The firewall’s base URL, e.g. http://firewall.internal:8080. yj dry-run npm ... uses it as the npm registry; yj dry-run pip ... uses its /simple/ index. Unset, yj exits 2 and says so.

Variables that are not ours

Variable Read by What it does
GITHUB_TOKEN scanner, scheduler Raises the GitHub API rate ceiling for Scorecard. ⚠️ A credential. Unset works but hits anonymous rate limits quickly, which surfaces as scans that fail for a reason unrelated to the package. Named without a YJ_/FW_ prefix because it is the variable Scorecard and the GitHub tooling already expect.
GITLAB_TOKEN scanner, scheduler A gitlab.com token for Scorecard, so packages whose source lives on gitlab.com can be scored in local scanning mode (#14). ⚠️ A credential; read access is enough. Unset, a GitLab repository is still scanned, but four checks the scoring floor requires cannot run without it, so the package stays unscorable and the refusal names those checks. Other scoring modes never score GitLab: deps.dev publishes no Scorecard for it, and stub mode would give every GitLab package its fixed score. The scheduler passes it to each scan container only when it is set.

Reading this as an operator

  • You can run it with nothing set. Start there, then change one thing at a time.
  • The policy section is the one with teeth. Everything in it fails safe by default; each change is a deliberate loosening with a stated reason.
  • 0 means different things by design, and they are not interchangeable. For TTLs it means off. For FW_SCORE_CACHE_MAX_ENTRIES it means use the default, because a zero cap meaning “cache nothing” would silently disable caching in every hand-built config predating that field.
  • Nothing here is read from disk, from your working directory, or from a package we inspect. See CONFIG_SURFACE.md.