v0.22.0 - Configurable governance, worker fleet scaling, and API keys off bcrypt
A feature release built from two tracks that ran side by side. A configurability track turns decisions the portal used to make unilaterally into settings an organization writes: who a build gate answers to, who has to agree before a risk is accepted, who else hears about a finding, whether a component needs sign-off before it ships. A concurrency and scaling track makes the worker fleet, the database and the search paths behave under real load instead of a demo seed. A security review that ran alongside both closed an open redirect on the OAuth callback and moved API-key hashing off bcrypt onto HMAC-SHA256.
The full machine-readable changelog lives in
CHANGELOG.md.
Read this before upgrading
Helm installs must now set env.secret.apiKeyHmacSecret explicitly. A
chart bug let the render silently derive that secret from secretKey when it
was left blank, which reopened the exact exposure the HMAC migration below
was meant to close: a leaked SECRET_KEY would also expose API-key hashing.
The chart now fails the render rather than deriving a value. Generate one with
openssl rand -hex 32 before upgrading, or the Helm upgrade will fail
outright. See Upgrade.
Search behavior changed in two ways. The minimum query length is now 3 characters (was 2), because a 2-character query bypassed the trigram indexes search relies on. And component search - both the command palette and the full results page - now searches only a project's latest scan, matching how vulnerability and license search already worked; a component that only ever appeared in an older, superseded scan no longer surfaces.
Postgres connection pool defaults are lower across every deployment shape.
The old Helm defaults alone could put the backend tier at 240 connections
against Postgres' default 100-connection ceiling. If a deployment has not set
its own pool values, the new defaults apply automatically and a boot-time
warning now fires if the declared fleet shape still exceeds the database's
actual max_connections.
api_keys.last_used_at no longer means the exact instant. It is now
written at most once every 15 minutes per key, so its meaning is "used within
this interval" rather than "used at this exact instant." Nothing reads it at
finer grain today, but a monitoring integration built against exact
timestamps should account for the new interval.
Highlights
Governance moves from environment variables to policy an organization writes
The build gate could already bend, but only through environment variables -
changing it needed access to the deployment and left no record of who
decided it. gate_policies now scopes the way license policy already does:
one organization default with optional per-team overrides, every field
nullable, and NULL meaning "not decided here." The policy screen gained the
controls to edit thresholds directly, with an explicit override switch per
field so an empty threshold ("follow the organization") and a threshold of
zero ("block on any score") stay distinguishable. A deployment with no rows
evaluates exactly as it did before this release.
Two related governance features shipped alongside it. An organization can now require a second person to agree before a vulnerability is accepted or suppressed - the first analyst's transition opens a request, and a different team admin has to approve it. And an organization can rule on a component once instead of every project reviewing the same package independently; a project's own decision still wins where one exists.
See License policies, Approvals and Vulnerabilities.
A read-only viewer role
Every account below developer used to be unable to look at anything at all;
developer itself bundles scan execution, every write, and the source tree
together with ordinary reads. The new viewer grade opens 47 read routes -
projects, components, vulnerabilities, licenses, obligations, SBOMs, reports,
search, the approval queue, license policy - while leaving the source tree,
credentials, the audit log and every write where they were.
Sign in through the deployment's own identity provider
A third OAuth provider, oidc, configured by issuer rather than pinned like
GitHub and Google. An operator supplies an issuer, a client ID and a secret,
and the portal reads the rest from the issuer's discovery document. Identity
provider groups can also decide the grade a new member gets, through a map an
operator writes - useful for a deployment where a whole company signs in,
where the previous behavior (making every arriving person the administrator
of their own personal team) was the wrong default.
See Users and teams.
The worker fleet and search paths are hardened for real load
A concurrency and scaling pass touched the pipeline end to end. The Celery worker fleet can now subscribe separately to scan work and everything else, in both the Helm chart and Compose, so an hour-long scan and a one-second alert no longer queue behind each other. The Helm chart can autoscale workers on queue depth instead of only CPU, since scan pipelines are network- and disk-wait heavy and can leave CPU reading idle while the queue backs up. Compose deployments, which have no autoscaler, get an install-time capacity formula and a queue-backlog alert instead.
A worker pod's termination grace period used to be far shorter than a scan can legitimately run (30 seconds by Kubernetes' default), so scaling the worker pool down under load could restart in-flight scans from zero. It now matches the scan pipeline's own hard time limit. And a source scan skips cdxgen entirely when nothing it depends on has changed since the project's prior scan on the same ref, reusing the preserved SBOM instead of spending 5 to 30 minutes regenerating an identical one.
API keys move off bcrypt onto HMAC-SHA256
An API-key secret is a 192-bit random value, not a human-chosen password, so bcrypt's deliberate slowness defends nothing while still costing roughly 213ms of CPU per verification. New keys now hash with HMAC-SHA256; keys issued before this release keep authenticating against their existing bcrypt hash, and a new admin endpoint reports how many are still on the legacy format. The change reopened a timing oracle - whether a guessed key prefix existed on the legacy format could leak through response time alone - which is closed by padding every verification branch to a shared floor until the fleet has migrated.
Every production container now runs as its own unprivileged user
The last containers still running as root are fixed in this release: the
frontend image now runs as nginx, and the worker image (the hardest case,
since its bundled SCA toolchain caches under whoever's HOME runs it) now
runs as trustedoss with every cache path pinned under its own home
directory. The Helm chart gives every workload a real pod and container
security context to match: a pinned non-root user, every capability
dropped, no privilege escalation, and a sealed root filesystem wherever the
image allows it.
Scan detail pages show what a scan actually looked at
A source scan now records the manifests and lockfiles its fetched tree carried; an ingested SBOM records what its generator claimed about itself, read from the original uploaded bytes. Both are visible on the scan detail page, so a scan that reports fewer components than expected has an answer: did it not find the package, or did it never see the file that declares it.
Fixes worth knowing about
The vulnerabilities table stopped at 100 findings
A project with more than a hundred findings hid the rest silently, with nothing on screen to say the list was cut. The licenses and obligations grids had the identical defect. All three now page the way the components table already did.
An open redirect on the OAuth authorize endpoint
The redirect_after parameter on the public OAuth authorize endpoint was
never validated, so a link could carry an absolute URL that traveled into the
signed OAuth state and out of the callback as a redirect - in the same
response that sets the refresh cookie, at the exact moment a lookalike page
is most convincing. It is now checked when the state is minted and again when
it is read, resolved only against the frontend's own origin.
A scan running past the broker's visibility timeout could occupy two worker slots
The Celery broker's default visibility timeout sat below this deployment's scan hard time limit, so a scan running long enough could be redelivered to a second worker while the first was still running it. The timeout is now derived from the scan hard time limit plus a margin. Operators may see scan failures that this had previously been masking as a silent retry.
Smaller corrections worth knowing about
- An organization with more than roughly 32,000 in-scope projects got a 500 instead of a page, because the dashboard's scan-inventory query bound one database parameter per project and the driver refuses a statement past 32,767 of them.
- bcrypt password verification ran on the request event loop, blocking every other request on that worker for its full ~213ms during every login. Verification, SBOM export, and the dashboard's action-queue recount are all offloaded or batched now.
- The per-user WebSocket connection cap lived in a process-local dict, so whether a second tab evicted the first depended on which worker process or pod the sockets happened to land on. It is now exact across the deployment, and a new global cap protects the whole system once it is saturated.
/auth/reset-passwordhad no rate limit and verified up to 256 candidate tokens with a synchronous bcrypt loop, letting an unauthenticated caller stall a worker for tens of seconds. It now shares login's rate limit and runs the verify loop as one offloaded unit of work.- Web fonts are served from this origin instead of a third-party CDN, which used to fail outright on an air-gapped install and announce every user's browser to a third party on every page load.
- OFL-1.1 (the SIL Open Font License) was classified as unknown instead of the weak-copyleft license it is, and a tri-licensed component's single forbidden classifier was not re-checked against the license registry.
- A Creative Commons NonCommercial license normalized to plain CC-BY and passed the build gate. "Attribution-NonCommercial 4.0 International" stored, gated and displayed as an ordinary permissive license; NC now classifies as forbidden and ND as conditional.
- The public webhook receiver could return a 500 instead of refusing cleanly, and had no request body-size cap or rate limit ahead of the point where a delivery's signature is checked. Both are fixed.
Upgrading
Follow the standard upgrade procedure. No
schema migration in this release requires manual intervention beyond the
standard alembic upgrade head step the upgrade script already runs.
Two actions are worth planning for:
- Helm installs: generate and set
env.secret.apiKeyHmacSecretbefore upgrading, or the release will fail to render. - Review the new
worker.scan/worker.defaultqueue split if running the Helm chart or Compose at scale. Both worker kinds subscribe to both queues by default, so an upgrade drains cleanly without any change; narrowing each to its own queue is optional and documented in the chart README.