diff --git a/content/posts/2026-09-24-announcing-sbomify-action-v26-9-0-the-one-that-runs-on-every-ci.md b/content/posts/2026-09-24-announcing-sbomify-action-v26-9-0-the-one-that-runs-on-every-ci.md new file mode 100644 index 0000000..1b5b1c1 --- /dev/null +++ b/content/posts/2026-09-24-announcing-sbomify-action-v26-9-0-the-one-that-runs-on-every-ci.md @@ -0,0 +1,171 @@ +--- +title: "Announcing sbomify-action v26.9.0: The One That Runs on Every CI" +description: "sbomify-action v26.9.0 gives TeamCity, Jenkins, CircleCI and Travis CI first-class support through a new CI platform subsystem, fixes the GitLab invocation we had published in every non-GitHub example, adds DOCUMENT_FILE for publishing PDFs and reports from a pipeline, and makes SPDX 3 documents survive a round trip intact." +keywords: "sbomify-action release, SBOM CI/CD, TeamCity SBOM, Jenkins SBOM, CircleCI SBOM, Travis CI SBOM, GitLab SBOM pipeline, SPDX 3 round trip, DOCUMENT_FILE, SBOM audit trail" +author: + display_name: Viktor Petersson +categories: + - announcement +tags: [sbom, release, ci-cd, spdx, teamcity, jenkins, circleci, gitlab, supply-chain] +tldr: "sbomify-action v26.9.0 stops treating GitHub Actions as the CI system and everything else as a fallback. TeamCity, Jenkins, CircleCI and Travis CI each get a platform that reads what their vendor actually publishes, so your SBOMs carry the branch the build was for rather than whatever tag happened to point at a detached HEAD. It also fixes an embarrassing one: the GitLab example we shipped — and had copied into roughly nineteen language guides on this site — invoked a script that does not exist, so those pipelines were never running the tool at all. Plus DOCUMENT_FILE for publishing a pentest report or a compliance PDF straight from CI, SPDX 3 documents that survive a round trip without losing 708 licence relationships, and an audit trail that no longer records the build machine's directory layout." +date: 2026-09-24 +slug: announcing-sbomify-action-v26-9-0-the-one-that-runs-on-every-ci +--- + +The action has always claimed to work on any CI system. That was true in the sense that it ran, and false in every sense that matters once you look at the SBOM it produced. GitHub Actions, GitLab CI and Bitbucket Pipelines each had a provider reading their environment variables. Everything else fell through to a generic path that read the git checkout — which works right up to the moment you ask it something the checkout cannot answer. + +v26.9.0 is mostly about closing that gap, and about one invocation we had published that could never have worked. + +--- + +## Every CI System Gets a Platform of Its Own + +Supporting a new CI system used to mean editing four unrelated modules: the console branched on GitHub in ten places, OIDC gated on `GITHUB_ACTIONS`, the CLI hardcoded `/github/workspace`, and each vendor needed its own augmentation provider. Adding one was four edits and a growing list. + +There is now a `CIPlatform` abstraction carrying everything the action knows about the system a run is under — checkout root, VCS metadata, log dialect, OIDC issuer, telemetry — resolved exclusively, first match by priority: + +```text +github-actions 10 → gitlab-ci 20 → bitbucket-pipelines 30 → teamcity 40 +→ jenkins 50 → circleci 60 → travis 70 → generic-ci 90 → local 100 +``` + +Adding a CI system is now one module and one registration. Nothing else learns its name. + +### Why this changes your SBOM, not just our codebase + +**Jenkins, CircleCI and Travis CI all check out a detached HEAD.** The generic path read the git checkout, which means the ref in your SBOM was whatever tag happened to point at that commit, or nothing at all. The vendor knows which branch the build was for, and on a pull request it knows which branch the request came _from_. Each of the three now reads that, keeping the checkout as the fallback, so a job publishing nothing behaves exactly as it did before. + +Two judgement calls worth stating, because both could have been done the lazy way: + +- On Jenkins, `CHANGE_BRANCH` beats `BRANCH_NAME`, because on a multibranch PR build `BRANCH_NAME` is Jenkins' own `PR-42` — a job name, not a ref anyone can check out. Same reasoning for Travis's `TRAVIS_PULL_REQUEST_BRANCH` over `TRAVIS_BRANCH`, which on a PR build names the branch being merged _into_. +- No repository URL is constructed from CircleCI's `CIRCLE_PROJECT_USERNAME` / `CIRCLE_PROJECT_REPONAME`. CircleCI serves GitHub, Bitbucket and GitLab projects and publishes nothing naming which, so that pair would only ever produce a plausible URL pointing at the wrong host. Travis's `TRAVIS_REPO_SLUG` is left alone for the same reason. An empty field beats a confident wrong one. + +### TeamCity + +TeamCity builds previously produced SBOMs with no VCS provenance at all unless you hand-authored `sbomify.json`. It is the awkward one to support: the other platforms hand you everything in environment variables, while TeamCity exposes only a version marker, the VCS revision, and a path to a build properties file. The repository URL and branch are _configuration parameters_, reachable only by reading that file and following a key in it to a second file. + +Three assumptions taken from TeamCity's documentation turned out to be wrong, which is why this was verified against real servers on five major lines from 2024.12 to 2026.1 rather than from the docs. + +One deliberate limitation: TeamCity is VCS-agnostic, and under Subversion, Perforce or TFVC the revision is a changelist or a timestamp, not a commit hash. The SBOM VCS fields are Git-shaped, so recording a Perforce changelist as a commit would put a false claim into an attestation document. Non-Git roots record nothing instead. + +**CircleCI also gets release tags.** `VERSION_FROM_RELEASE_TAG` and `NORMALIZE_VERSION` read the tag that triggered the build — and read nothing CircleCI sets, so a CircleCI user who turned either switch on got no error and no effect. `CIRCLE_TAG` joins the list, and `CIRCLE_PROJECT_REPONAME` with it, so the foreign-tag check can still tell a release of your repository from a monorepo's per-package tag. + +--- + +## The GitLab Example Was Broken, and We Had Copied It Everywhere + +This one deserves to be said plainly rather than buried in a changelog. + +Our GitLab CI config ran: + +```yaml +script: + - /sbomify.sh +``` + +There is no such script. The image's entrypoint is `sbomify-action`. That job cannot have been running the tool, and the same invocation had been copied from it into this site's CI/CD guide and roughly nineteen language guides — so **every GitLab, Jenkins and CircleCI example we published was broken**. Both the source and the published guides are fixed. + +Related, in the same pass: + +- Our own configs still set `SBOM_VERSION`, which has been deprecated in favour of `COMPONENT_VERSION` for a while and logs a warning on every run. Fixed, in both, so the next copy-paste picks the right one. +- The Bitbucket pipe catalogue entry said `sbomfiy`. It says `sbomify` now. +- The container image gains `WORKDIR /workspace`, so `docker run -v "$PWD:/workspace" …` is a complete invocation with no `-w` flag. The final stage had none, the container started in `/`, and every caller had to pass one — which is exactly how a GitHub-specific path ended up pasted into docs for other CI systems. Existing pipelines that pass `-w`, and GitHub Actions' own `/github/workspace` mount, keep working unchanged. + +--- + +## Publish Documents, Not Just SBOMs + +The platform has held document artifacts — pentest reports, compliance PDFs, NDAs — for a while, and the action had no way to publish one. So the pipeline that produced your pentest report ended at a shared drive and somebody uploaded it by hand. + +`DOCUMENT_FILE` closes that: + +```yaml +- uses: sbomify/sbomify-action@v26.9.0 + env: + TOKEN: ${{ secrets.SBOMIFY_TOKEN }} + COMPONENT_ID: ${{ vars.DOCS_COMPONENT_ID }} + DOCUMENT_FILE: reports/pentest-2026.pdf + DOCUMENT_TYPE: pentest-report + DOCUMENT_VERSION: "2026.1" +``` + +The file is uploaded **exactly as authored** — no generation, augmentation, enrichment or re-serialization — so a signed report stays byte-for-byte the one that was signed. `DOCUMENT_NAME` defaults to the file name, `DOCUMENT_TYPE` defaults to `other` and is validated against the backend's types, and `DOCUMENT_COMPLIANCE_SUBCATEGORY` tags an ISO 27001 or SOC 2 report so the platform can recognise it — which is what the new [certification badges](/2026/09/24/announcing-sbomify-v26-9-0-the-one-that-speaks-spdx-3/) on the Trust Center read. `PRODUCT_RELEASE` tags the uploaded document into a release exactly as it does an SBOM, and OIDC trusted publishing works unchanged. + +--- + +## SPDX 3 Documents Survive the Round Trip + +If you feed the action an SPDX 3 document, it parses it, does its work, and writes it back out. Until now, what came out was not what went in. + +Measured on the published Yocto 6.0.3 `core-image-minimal` SBOM, 3,049 elements, plain round trip with no augmentation or enrichment: + +| | before | after | +| -------------------------------------------------------- | --------: | ----: | +| schema errors in the document we wrote | thousands | **0** | +| packages that lost `primaryPurpose` | **38** | 0 | +| `hasDeclaredLicense` relationships downgraded to `other` | **708** | 0 | + +That last row is the one that validated while it lied. Across the three published Yocto SPDX 3 images, every declared licence relationship — 708 in one document, 1,667 in another — was rewritten as a generic `other` relationship. The document still parsed. It just no longer said what its producer said. + +Alongside that: + +- **SPDX 3.0 documents are validated now.** The bundled schemas held 2.2, 2.3 and 3.0.1, so a 3.0 document matched nothing, validated as "skipped", and went to the upload unchecked — while the README promised generated SBOMs are validated against their JSON schemas. 3.0 is what syft, Microsoft's sbom-tool, JFrog Xray and Zephyr emit, and what Yocto 5.1 declares. +- **SPDX 3.1 gets a real answer.** It used to produce "could not detect spdx spec version", or nothing at all. It now says 3.1 is unsupported and names what is accepted, in the backend's own wording, so you hear one answer rather than two. 3.1 is at RC1, BSI accepts released versions only, and the platform refuses it. +- **The version is read from what the document states**, not inferred from the URL in its context. +- **Licence sanitization actually looks at SPDX 3.** It walked `packages[]`, `files[]` and `snippets[]` for fields an SPDX 3 document does not have, returned zero having read nothing, and zero reads as "nothing to fix". +- **`SPEC_VERSION` stops advertising a value that cannot work.** The help text offered `3.0.1` as an example; nothing in the toolchain generates SPDX 3. Asking for any 3.0.x now points you at the routes that do work, and asking for 3.1 tells you it is refused rather than sending you to a second route to hear the same no. + +--- + +## The Audit Trail Stops Leaking Your Build Machine + +The audit trail is a compliance artifact. It exists to be handed to someone other than whoever generated it. It was recording lines like: + +```text +# Input: /private/tmp/claude-501/-Users--PycharmProjects-sbomify/.../requirements.txt +``` + +which tells that reader nothing usable while publishing the generating machine's directory layout and the username on it. A path under the working directory is now recorded relative to it (`src/requirements.txt`); a path anywhere else keeps only its file name. Both emitters are covered — the `audit_trail.txt` file and the copy printed to stdout for attestation — and there is no flag to opt back in, because a flag whose only function is to re-enable a leak is config surface for no gain. + +--- + +## Smaller Things + +- **A crash on one component's copyright header no longer fails the whole document.** cdxgen writes a licence body straight into `license.text`, where the CycloneDX schema wants an object, and the parser dies on it naming neither the component nor the field. Four of the six parse call sites already guarded against it; the two that did not have been a steady trickle of failed runs. They are paired now. +- **The README is a quick start again.** It had grown to twelve hundred lines and was drifting from the [documentation site](/sbomify-action/), which is now the single place any of it is maintained. +- **Tool versions are frozen at build time**, so a release carries the versions it was actually built against rather than resolving them later. + +--- + +## Getting Started + +Run it straight from PyPI, with no install step: + +```bash +pipx run sbomify-action +# or +uvx sbomify-action +``` + +As a GitHub Action: + +```yaml +- uses: sbomify/sbomify-action@v26.9.0 +``` + +On anything else, as a container: + +```bash +docker run --rm \ + -v "$(pwd):/workspace" \ + -e SBOMIFY_TOKEN=$SBOMIFY_TOKEN \ + ghcr.io/sbomify/sbomify-action:v26.9.0 +``` + +If you build on **TeamCity, Jenkins, CircleCI or Travis**, upgrade and look at the VCS fields in your next SBOM — the branch and repository that were missing or wrong should now be there, and the [runtime guides](/sbomify-action/runtimes/) have a page for each. If you build on **GitLab**, check your pipeline is calling `sbomify-action` and not `/sbomify.sh`; if it came from our docs, it is not. + +The platform side shipped the same day: [sbomify v26.9.0](/2026/09/24/announcing-sbomify-v26-9-0-the-one-that-speaks-spdx-3/) makes SPDX 3 a first-class format end to end, which is the other half of the Yocto work above. + +For the full technical detail, see the [v26.9.0 release notes on GitHub](https://github.com/sbomify/sbomify-action/releases/tag/v26.9.0). + +As always, if the action is doing something surprising with your project, we want to hear about it. Both the GitLab fix and the TeamCity support in this release started as somebody telling us their pipeline was quietly producing nothing useful. diff --git a/content/posts/2026-09-24-announcing-sbomify-v26-9-0-the-one-that-speaks-spdx-3.md b/content/posts/2026-09-24-announcing-sbomify-v26-9-0-the-one-that-speaks-spdx-3.md new file mode 100644 index 0000000..d87f8ba --- /dev/null +++ b/content/posts/2026-09-24-announcing-sbomify-v26-9-0-the-one-that-speaks-spdx-3.md @@ -0,0 +1,146 @@ +--- +title: "Announcing sbomify v26.9.0: The One That Speaks SPDX 3" +description: "sbomify v26.9.0 makes SPDX 3 a first-class format end to end, ships a v2 API that calls things what the product calls them, adds a Helm chart for self-hosting on Kubernetes, puts certification badges and CSAF 2.0 discovery on the Trust Center, and rebuilds vulnerability browsing around a real findings table." +keywords: "sbomify release, SPDX 3.0.1, SPDX 3 scanning, Yocto SBOM, SBOM API v2, sbomify Helm chart, Kubernetes SBOM, CSAF 2.0, security.txt CSAF, CISA 2026 minimum elements, trust center certification badges" +author: + display_name: Viktor Petersson +categories: + - announcement +tags: [sbom, release, spdx, yocto, api, kubernetes, trust-center, csaf, compliance] +tldr: "sbomify v26.9.0 stops treating SPDX 3 as a document it will store but not understand. An SPDX 3 upload is now checked against the official 3.0.1 schema when it claims that version, scanned for vulnerabilities through a derived copy instead of being skipped, read for the VEX and licence data it carries, and large enough to actually arrive — the request ceiling went from an effective 20 MB to 100 MB, which is what a Yocto image SBOM needs. There is also a v2 API that says artifacts and workspaces rather than sboms and teams, with v1 deprecated but not going anywhere; a Helm chart for running sbomify on Kubernetes; certification badges, reachable gated documents and CSAF 2.0 discovery on the Trust Center; the CISA 2026 Minimum Elements scored by a plugin that is finally switched on; and a vulnerabilities panel you can filter, page and triage from without the page taking five seconds to render." +date: 2026-09-24 +slug: announcing-sbomify-v26-9-0-the-one-that-speaks-spdx-3 +--- + +If you generate SBOMs from an embedded Linux build, you have probably had a version of this experience with us: the document uploads, sbomify stores it, and then most of the interesting things quietly do not happen. No scan. No validated schema. Licences flattened. And if the image was big enough, not even an upload — just a `400` that named no size. + +That is the main thing v26.9.0 fixes. SPDX 3 is now a format sbomify understands rather than one it merely accepts. + +--- + +## SPDX 3, End to End + +The work splits into four pieces, and each one was its own dead end. + +**It is validated now.** An SPDX 3 upload was never held to a schema at all. It was parsed for structure, its version looked up in a table, and that was the whole of the check — so a document could be malformed in every way the specification actually cares about and still land. The official SPDX 3.0.1 schema now ships with the platform, and a document claiming 3.0.1 or later is validated against it, with the failures named rather than surfacing as a generic parse error. A document claiming 3.0 or 3.0.0 is deliberately left lenient: SPDX never published a 3.0.0 schema, and 3.0 is what syft, Microsoft’s sbom-tool and JFrog Xray emit — so the only schema we could hold those documents to is 3.0.1’s, which renamed properties after they were written and would fail them for it. SPDX 3.1 now gets a refusal of its own, naming the version and saying that we accept 2.2, 2.3 and 3.0.x, and that 3.0.1 is the one that also clears the BSI TR-03183-2 floor. + +**It is scanned now.** osv-scanner has no SPDX 3 reader and Dependency Track takes CycloneDX only, so every SPDX document came back as a skip telling you to run `syft convert` yourself and upload the result. Doing that for you is the whole change: the scan path derives a copy in a format the scanner reads, scans the copy, and reports findings against the stored artifact, which is never modified. The conversion is lossy and that is survivable, because scanners match on package identity and PURLs come through intact. The result records that it read a derived copy, so a surprising finding is traceable to the conversion rather than looking like a scanner fault. + +**It is read properly now.** A pile of individually small bugs added up to a document we were interpreting through guesswork: the BOM subject came from whichever package happened to be first rather than from `rootElement`, several single-field reads pointed at properties the spec does not have, set-valued properties were only read in one of their several legal shapes, licences resolved to booleans instead of expressions, and an external-reference type written as an IRI read as a different type than the same thing written as a bare name. A document that roots itself on its `Sbom` element is followed, and the subject is read from the relationship Yocto actually writes. The VEX an SPDX 3 document carries about itself is now honoured instead of ignored. + +**It fits now.** The SBOM endpoint advertised a 100 MB cap that Django refused at 20 MB, before the check could run, so anything in between came back as a bare `400 Invalid request` naming no size. A Yocto SPDX 3 image SBOM lands squarely in that gap. The ceiling is one number now, enforced where it is stated, configurable through `DATA_UPLOAD_MAX_MEMORY_SIZE_MB`, and defaulting to the 100 MB the docs always claimed. + +The regression test for all of this is a real 50 MiB `core-image-sato-sdk` document, alongside Yocto SPDX 2.2 and 3.0 samples. If you build with Yocto, this is the release to re-upload on. + +--- + +## A v2 API That Says What the Product Says + +The v1 API has been accumulating vocabulary it grew out of. `/api/v1/sboms/` now holds eight artifact types, most of which are not SBOMs. `/api/v1/workspaces/{team_key}` is a prefix that was renamed and a path parameter that was not, in twenty-nine places. And one resource answers on both `/sboms/{id}` and `/sboms/sbom/{id}`. + +None of that is fixable in place without breaking every caller, which is what a second version is for. + +**`/api/v2/` serves the same resources from the same code, named the way the product names them.** Artifacts rather than SBOMs, workspaces rather than teams, and one path per resource. The views are shared, not forked, so the two versions cannot drift apart. + +Two things to know if you integrate against us: + +- **v1 is deprecated, not scheduled for removal.** It answers with an RFC 9745 `Deprecation` header, and every v1 operation is struck through in `/api/v1/docs` and flagged in generated clients. There is no `Sunset` header, deliberately: `Sunset` is a promise of a date, and we are not making one we have not thought through. When there eventually is a date, it will be a long way out. +- **Error codes are now reliable enough to branch on.** Around two hundred error returns shipped with no `error_code` at all, so a client reading it got `null` and had to fall back to matching prose. Every response now carries one. Separately, thirty-four handlers answered a server-side crash with `400` and the message "Internal server error" — telling you your request was malformed when the server had in fact fallen over. Those are `500`s now, which matters because sensible clients retry one and not the other. + +Also new on the API side: security advisories can be created, read and deleted through it, and a workspace's plugins can be enabled and disabled over it rather than only in the UI. + +In the product itself, the on-screen copy says workspace throughout — templates, toasts, admin labels. The v1 wire format was deliberately left untouched, down to the prose in its error messages, because a client may be matching on it. + +--- + +## Running sbomify on Kubernetes + +There is now a **Helm chart**, at `charts/sbomify` in the repository. + +It deploys the application: Caddy at the edge, a gunicorn/uvicorn web tier, a pool of Dramatiq workers, a singleton cron scheduler, and a migration job that runs before any of them serve traffic. It deliberately does **not** deploy PostgreSQL, Redis, object storage or Keycloak — you point it at ones you run, and it refuses to render if you have not, rather than quietly standing up a database nobody is backing up. + +```bash +helm install sbomify ./charts/sbomify \ + --namespace sbomify --create-namespace \ + --set app.baseUrl=https://sbom.example.com \ + --set database.host=pg.internal \ + ... +``` + +HPA, PodDisruptionBudget, NetworkPolicy and ServiceAccount templates are included, secrets can come from a Secret you manage with External Secrets, SOPS or Vault, and `./bin/kind-up.sh` brings the whole thing up on a laptop with throwaway backing services — provisioned outside the chart, exactly as production does, so there is no all-in-one code path to drift. + +On AWS and GCP you can drop the object storage credentials entirely and use workload identity instead. That work is documented as an architecture decision record in the repository, and the short version is that static keys for three buckets were six secrets to rotate and one leak away from a problem that lasts until someone notices. + +--- + +## The Trust Center + +**Certification badges.** A workspace that has published a company-wide ISO 27001 or SOC 2 report now shows it as a seal at the top of its Trust Center, which is usually the question the visitor came to the page to answer. A badge is earned rather than ticked: the document has to hang off a company-wide component, that component has to be published, and the document has to have an actual file on it. An empty component named "ISO 27001" claims nothing, because that is the easiest way to accidentally claim a certification you do not hold. Gated counts — "we hold this, ask us for the report" is what a badge is for — and SOC 2 splits into Type I and Type II, because a reader wants to know which one they are being shown. An NDA is deliberately not a badge: signing one says nothing about your own security. + +**Gated documents are reachable again.** If you set a document component to gated, the row appeared on your Trust Center and then 404'd for anyone who clicked it, because slug resolution only scanned public components. The artifact behind it was the second half of the same dead end, 403'ing into a generic error page. Gated components now resolve like every other published component, and the request-access gate is what a reader without a grant sees — which was the entire point of gating rather than hiding. + +**CSAF 2.0 discovery.** We have served CSAF documents from the public API since advisories shipped, but nothing linked to them, `security.txt` carried no CSAF field, and the provider metadata URL that field points at did not exist — so a CSAF consumer had no way to learn we publish CSAF at all. The three documents CSAF 2.0 defines for discovery are now served, gated exactly as `security.txt` is, and the `security.txt` CSAF field is derived from your own domain rather than waiting for someone to paste it in. Everything under there is TLP:WHITE for every reader, built from public advisories only, so an aggregator holding an NDA grant can never republish gated content as WHITE. + +**Your vulnerability posture is now opt-in, and it was not before.** This one was reported from production and is worth stating plainly: a Trust Center release page published severity counts and every finding by ID, package and version to anyone with the link, whenever the release had a completed scan. There was no setting behind it. There is now — a workspace toggle, off unless you turn it on. If you have been running a public Trust Center with release pages, this is the paragraph to act on. + +--- + +## Vulnerabilities You Can Actually Work Through + +A trial workspace uploaded two large SBOMs and started hitting gateway timeouts. The upload was not the problem. The **component detail page** rendered every finding of the newest SBOM into the HTML server-side and let the browser show five at a time. + +For a component with 2,390 findings, measured on production: deriving the rows took 0.49s, rendering them took **5.10s**, and the page shipped **8.5 MB** of HTML for one panel. On a CPU-bound render that holds the GIL for the whole worker process, that is enough to time out unrelated requests routed to the same process. + +Three changes come out of that: + +- **Findings live in a table of their own.** A finding previously existed only inside a scan run's JSON blob, so anything wanting to rank, filter or page findings across a workspace loaded every run and did the work in Python. "Critical findings across this product, worst first, page 3" could not be asked at all. It can now. The table is derived and rebuildable; the scan result stays the record of what the scanner said. +- **Search, severity, VEX state and the KEV toggle run on the server**, before the slice rather than after it, on the component page and in the full scan report. The dashboard digest reads the same table. +- **You can triage from the vulnerabilities panel**, with a row count you choose. Previously you found the finding in the panel, went to the artifact page, opened the right plugin card, and paged an unfiltered five-at-a-time list until you found it again. + +Alongside that: a scan that every provider declined is no longer reported as a clean scan, the page says _why_ each scanner skipped an SBOM rather than showing an unexplained gap, a suppressed finding no longer counts toward your vulnerability totals, a fixed finding no longer reads as "not affected", and assessment runs that nothing will ever come back for are settled rather than sitting in flight forever. + +--- + +## The CISA 2026 Minimum Elements, Scored + +CISA published the [2026 Minimum Elements](https://www.cisa.gov/resources-tools/resources/2026-minimum-elements-software-bill-materials-sbom) on 29 July 2026 with the NSA, the FBI and fifteen international partners, replacing the 2021 NTIA elements. Our plugin implemented the August 2025 draft and — this is the awkward part — was never registered, so it had never run for a single user. Parking it was right while the standard was a draft. That reason expired in July. + +It is switched on now, against the published standard: seventeen data fields rather than eleven, including the six that had no check at all, and three outcomes rather than two. That third outcome matters. The standard repeatedly tells an author who does not have a value to _say so_ rather than omit it, so a declared unknown is not scored as a miss — but it is not real data either, so it warns where an omission fails. `NOASSERTION` is the declared unknown; `NONE` is an answer and passes. + +Six scoring defects went with it, each of which had mis-scored real documents: a tool counted as the author (`Tool: syft` passed, and so did `banana`), Component Producer read only the distributor and not the creator, the timestamp check accepted formats RFC 9557 does not and rejected ones it does, OmniBOR and SWHID were not accepted as component identifiers, and SPDX 3's declared licence did not count where only the concluded one did. + +--- + +## Advisories, VEX and the Smaller Corrections + +The advisory work from [v26.8.0](/2026/08/25/announcing-sbomify-v26-8-0-the-one-with-security-advisories/) got a round of hardening now that people are using it: + +- An advisory can be asked which releases it affects, resolved on read from both the pinned releases and the version strings. A version that will not parse answers "undetermined" rather than "not affected", because the latter is a security claim nobody verified. +- A VEX statement matches every ID the advisory is known by, can be scoped to a CPE as well as a PURL, and says so when it cannot be applied rather than silently doing nothing. +- A withdrawn advisory says it is withdrawn on the list, not only on its own page, and the VEX preview counts advisories rather than scan rows. +- A release VEX download respects component visibility, and an advisory body renders as prose rather than as its own markup. + +And a set of things that were simply wrong: + +- Deleting a workspace cancels its Stripe subscription. It did not, from either place you can delete one. +- Rate limiting counts the visitor's IP rather than the Cloudflare edge's, so one noisy visitor no longer spends everyone else's budget. +- A Redis blip refuses API requests with a `429`, not a `500`. +- Deleting a release no longer destroys its lifecycle history. +- Documents are unique by name and version within a component, can be made workspace-wide from their own page, and an upload whose declared type does not match the document is refused. +- Every allauth page — login, password reset, the lot — renders inside the styled shell rather than dropping you onto a bare Django template. + +--- + +## Getting Started + +If you are on the **hosted platform**, this is already live. Two things worth ten minutes: if you run a public Trust Center with release pages, check the new vulnerability posture toggle and decide deliberately whether it should be on. And if you have SPDX 3 or Yocto SBOMs that previously uploaded but never scanned, re-upload one and look at what comes back. + +If you **self-host**, pull `ghcr.io/sbomify/sbomify:v26.9.0`. There are database migrations in this release, including index and table renames from the workspace terminology work, so take a snapshot first as usual. If you have been running the Docker Compose deployment on a single box and want something that survives a node, the new Helm chart is worth a look. + +If you **integrate against the API**, start reading `/api/v2/docs`. Nothing is being taken away from you on a deadline, but v2 is where the vocabulary and the URL shapes stop being a compromise with history. + +The [sbomify-action v26.9.0](/2026/09/24/announcing-sbomify-action-v26-9-0-the-one-that-runs-on-every-ci/) release went out at the same time. If you build anywhere other than GitHub Actions, read that one — it is the release where the other CI systems stop being second-class. + +For the full technical detail, see the [v26.9.0 release notes on GitHub](https://github.com/sbomify/sbomify/releases/tag/v26.9.0). + +As always, tell us how this lands. The SPDX 3 work in particular came out of documents real people sent us that we handled badly, and there are certainly more of those. If sbomify does something surprising with yours, open a support ticket from inside [the app](https://app.sbomify.com) and it will reach us.