Supply chain
Release artefacts here are meant to be auditable by somebody who does not trust this repository: a wheel, its hash, its bill of materials, and a signature chaining all three back to the commit and the workflow run that produced them. This page says how that is arranged, and — the part that is easier to leave out — what each layer does not cover.
The convention is the specification's: MUST, SHOULD, MAY.
1. The chain, and the job that realises each arrow
.github/workflows/rc-build.yml is one dispatchable workflow, three jobs, in this order. Each
arrow below is a needs: edge or a step, not a description of intent:
| arrow | realised by |
|---|---|
| source commit → CI | qualify, checked out at the dispatched ref with fetch-depth: 0 |
| CI → tests | qualify, step The suite |
| tests → conformance | qualify, steps Conformance Suite v2 and Evidence records |
| conformance → build | build declares needs: qualify — a red suite builds nothing |
| build → SBOM | build, syft over the clean install created from the wheel that was just gated |
| SBOM → artefact hash | build, one sha256sum table over dist/ and sbom/ |
| artefact hash → attestation | attest declares needs: build and re-checks SHA256SUMS first |
| attestation → proof | attest, gh attestation verify on every artefact |
The build happens once. gates/wheel_install.py --export-dist exports the artefacts that
gate judged, and nothing downstream rebuilds them. The alternative — a workflow that runs
python -m build and then runs the gate — produces two files that are not byte-identical, and
that gate's own docstring carries the two digests that proved it. Every hash, SBOM and attestation
in this workflow is over the bytes the thirteen checks passed.
Two SBOM formats, and one of the two producers is a cross-check. The release artefacts are
syft's: synapse_cdm.spdx.json (SPDX 2.3, JSON) and synapse_cdm.cdx.json (CycloneDX,
JSON), both taken over the clean-install environment — a venv that has never seen the source
tree, holding the gated wheel and its runtime closure and nothing else — rather than over the
wheel file or the runner's own environment. (Until 2026-09-16 this page and rc-build.yml said
"read off the gated wheel", and syft over a wheel file catalogues one SPDX package, the
filename, and zero CycloneDX components; publish.yml has taken the venv as its subject since
round PS.) Beside them, cyclonedx-py environment reads the same venv and writes its document
outside the artefact set — a second tool asking the interpreter what is importable, where syft
catalogues the directory. The library components of the two are compared and the difference is
printed, never asserted empty: syft's document also carries file and application components,
which cyclonedx-py has no notion of. Four refusals then stop the build on an SBOM that
describes nothing — no packages, no components, no synapse-cdm entry, or a version that is not
PACKAGE_VERSION.
Nothing here publishes. rc-build.yml has no PyPI step, creates no release and moves no tag.
Publishing is .github/workflows/publish.yml, which a v* tag starts.
2. Six scanners, and what each one is blind to
Stated as a table because the failure mode is mistaking one green for another. (Five rows until
2026-09-16: the npm audit row was missing, although the job had run on every push since round
PB on 2026-09-08 and both image-size exception files named it as their enforcing mitigation.)
| layer | where | what it reads | blind to |
|---|---|---|---|
| Dependabot alerts + security updates | repository settings | the dependency graph GitHub builds from the committed manifests | anything not in a manifest; a vulnerability with no advisory yet |
| Dependabot version updates | .github/dependabot.yml | pip in /packages/cdm, github-actions in /, npm in /docs | a dependency reachable only at runtime |
| Dependency review | .github/workflows/dependency-review.yml | what a pull request adds | every commit pushed directly, which is how this campaign is developed — see below |
pip-audit | ci.yml, job supply-chain | the installed environment, and the wheel's own frozen closure in a clean venv | JavaScript, which is the row below; anything outside Python packaging |
npm audit | ci.yml, job docs-audit | the committed docs/package-lock.json, installed with npm ci, at high, with the exception allowlist derived from security/exceptions/ | Python; a moderate advisory, which is reported and never blocks; anything outside the docs toolchain's lock |
| CodeQL | .github/workflows/codeql.yml | python and javascript-typescript sources, security-extended | dependencies (that is the four rows above) |
The dependency-review row is the one worth reading twice. That action compares the dependency
graph of a pull request's base and head refs, so it exercises only on pull requests. The SOIF
Part 1 campaign pushes directly to its branch, which means dependency review did not run on the
commits that built this package. The layer that covers the dependency set as it actually stands,
on every push, is pip-audit in ci.yml for the Python distribution and npm audit in the
docs-audit job for the documentation toolchain (this sentence named pip-audit alone until
2026-09-16). The Python readings are two, because they answer different questions:
- the installed environment: the package plus its
testextra plus everything those drag in; - the wheel's frozen closure: the wheel installed alone into a clean virtual environment and
pip freezed. This is whatpip install synapse-cdmactually creates, and it is the smaller and more relevant of the two.
The npm reading is one: npm ci from the committed docs/package-lock.json, then
npm audit --audit-level=high verbatim for the log, then a step that fails on any high or
critical advisory not derived from security/exceptions/ — and, last in the same job, the
site's own npm run ci, so a lock that clears an advisory and breaks the build is not green.
Where an advisory sits in a package reachable only under @docusaurus/*, the repair is a floor
in docs/package.json's overrides (fifteen today — six until 2026-09-17, each for a high
advisory, then a seventh for the one moderate advisory this job had reported on every
push and by its own rule never blocked, uuid's, since 2026-10-05 three more,
brace-expansion, http-cache-semantics and joi, for four high advisories that had fixed
versions, and since 2026-10-06 four more, compression, proxy-addr, source-map-js and
tinypool, for five high or critical advisories the job named on the 3.2.0 release
commit, each with a fixed version, and later that day a fifteenth, shell-quote, for the
critical advisory the database published after that reading; ten until 2026-10-06 and fourteen
until the shell-quote floor), never npm audit fix, which downgraded qs
into its vulnerable range when it was tried; the dated reason for each pin is in
packages/cdm/synapse_cdm/MIGRATIONS.md, and tests/test_cdm_security_policy.py holds this
count and SECURITY.md's row to the block itself.
3. What blocks, and the one way past it
HIGH and CRITICAL findings block. A CVSS security-severity of 7.0 or above fails the gate;
9.0 and above is reported as critical rather than used as a second threshold.
Precision is printed for every finding and is not part of the blocking test. A
medium-precision CVSS 9.8 result that passed a gate claiming to block critical findings would be
the gate's whole failure mode. A query that is genuinely imprecise is a query problem — narrow it
or suppress it at the source — and an exception is deliberately not the tool for that.
Findings below 7.0, and results whose rule carries no security-severity at all (CodeQL sets that
property on security queries only, and security-extended runs quality queries too), are
reported and counted, never dropped. The gate prints each one.
The one way past a blocking finding is a documented, time-bounded exception:
security/exceptions/<identifier>.json
with an identifier, affected_package, version_range, risk, reason, mitigation,
upstream_status, owner, expiry, created and at least one reference — the four prose
fields separated by what they answer: the impact here, why the exception is granted, the
compensating controls in force now, and the upstream state with the event that ends the
exception before its expiry. Two properties make it a mechanism rather than a note:
- One source, three consumers.
gates/codeql_gate.pyreads the directory, and thepip-auditstep and thedocs-auditjob both runpython gates/codeql_gate.py --emit-pip-audit-ignoresto derive their allowlists. No workflow carries a list, and a test asserts that none ever will — two lists diverge silently in the direction that suppresses a live finding. - Expiry is enforced by the suite, unconditionally.
tests/test_cdm_security_exceptions.pyfails the whole suite on the day anexpirypasses. No flag, no grace period, no skip. The repair is to fix the finding or to write a new exception saying what changed — never to move the date.
There is one exception today, GHSA-vfj7-8cjw-p6xm: braces's stack-exhaustion denial of
service, high, in a package the documentation toolchain pulls in under @docusaurus/core and the
Python distribution does not carry, with no fixed release to move to — written on 2026-10-05 on
the maintainer's ruling of that date, the maintainer its owner, expiry 2026-12-04, sixty days.
(Corrected 2026-10-05: until that date this paragraph opened "There are no exceptions
today, and that is a reading rather than a first state"; the rest of it is kept as it stood.)
Two were in force from 2026-09-08 to 2026-09-16 — GHSA-w3rx-r6r6-pgpr and GHSA-5p2g-fcmc-qvqq,
image-size's ICNS and JXL/HEIF denial-of-service advisories, high, reachable only when
Docusaurus measures an image at documentation build time, granted because no fixed release
existed and bounded at sixty days — and both were deleted, as their own upstream_status
required, when image-size 2.0.3 (published 2026-09-14) turned out to carry the fix for exactly
those three parsers. docs/package.json now pins image-size at ^2.0.4 in overrides, and
npm audit over the committed lock read zero high or critical advisories — and, from
2026-09-17, when uuid took the seventh floor, zero advisories at any severity. On 2026-10-05 it
read eight advisories, five of them high; after the three floors of that date it reads three:
braces's, excepted, a moderate one and a low one. On 2026-10-06, on the 3.2.0 release
commit, it read nine, five of them high or critical and not excepted, in compression,
proxy-addr, source-map-js and tinypool; after the four floors of that date it reads four:
braces's, excepted, two moderate ones and a low one. pip-audit --strict
reported No known vulnerabilities found on 2026-09-08, and no Python finding and no CodeQL
finding has ever been excepted. (Until 2026-09-16 this paragraph still read as it had on
2026-09-08, and was false for the eight days the two files existed.)
4. The gate runs the same way on your machine
gates/codeql_gate.py is a file in the tree rather than an expression inside a workflow, so a
downloaded SARIF gets the same verdict CI gave:
gh run download <run-id> --name codeql-sarif-python
python gates/codeql_gate.py *.sarif
Exit 0 is clean, 1 is a blocking finding, 2 is a refusal — a missing or malformed SARIF,
which is deliberately not the same thing as a clean one. A gate that treated an absent SARIF as an
empty one would print a green for an analysis that never ran.
5. Signing: OIDC, and no key anywhere
Attestation is actions/attest-build-provenance, which is Sigstore-backed and authenticated by
the workflow run's own OIDC token. There is no signing key in this repository, in its secrets,
or on anybody's disk — nothing to rotate, nothing to leak, and nothing that outlives the run
that used it.
attestations: write is declared on the two attest jobs — rc-build.yml's and publish.yml's
— and nowhere else; id-token: write is declared on those two and on publish.yml's publish
job, which mints the PyPI Trusted Publishing token with it. (This paragraph said "the attest job
of rc-build.yml and nowhere else" until 2026-09-16, which had been false since the release
pipeline gained its own attestation stage.) Every other write is on the job that needs it and
names its reason: contents: write on publish.yml's release and witness jobs, for the
Release and its assets; security-events: write on CodeQL's analysis job, for the SARIF upload;
and pull-requests: write on dependency review, for its summary comment. Everything else is
contents: read.
Every uses: in every workflow is pinned to a full commit SHA with the tag it corresponded to
in a trailing comment. A tag is a moving target on somebody else's account; github-actions is in
Dependabot's config precisely because a SHA pin is the kind of staleness nobody notices by
reading.
6. What this does not claim
- Not a claim about dependencies' own code.
pip-auditreports known advisories. It is not an audit, and "no known vulnerabilities" is a statement about what is published. - Not reproducible builds. The wheel is built once and hashed; two runs of this workflow are not asserted to produce identical bytes. The attestation says this run produced these bytes from this commit, which is a weaker and checkable claim.
- Not a claim about the runner. GitHub-hosted runners are trusted here. An attacker with
control of the runner image is outside the boundary
(
spec/sc-oes/14-security-considerations.mdis where the trust boundaries are written down). - No CodeQL finding has been evaluated yet. Advanced setup landed in round P6 and its first
run is round P7's to record. The gate is in place and tested against synthetic SARIF; it has
not yet been handed a real analysis of this repository. (Dated correction, 2026-09-16: this
bullet had been false since round P7 read the first analysis on 2026-09-08 — run 34212170555,
RED on four findings at or above 7.0, none in the shipped package, all four fixed at their
sites by round PC with no exception written, and the next run GREEN.
SECURITY.md's CodeQL row is the record, and the gate has since become a stage of the release pipeline as well.)