Parascope Docs

Software Supply Chain

What has to pass before a change can merge, how production images are signed, the command you can run to verify one yourself, how a release reaches production, and what the pipeline does not check.

A change to Parascope reaches production along one path: a pull request that has to pass a single required check, an image built and signed by a GitHub Actions workflow on merge, and a deploy script that pins the cluster to that image's commit. This page names the checks in that gate, publishes the command that verifies an image signature along with what a passing result proves, and sets out the parts of the chain that produce evidence without enforcing anything.

What has to pass before a change merges

The main branch is protected by one required status check, CI Passed. It is an aggregating job that waits on the lint, typecheck, test, and security jobs in the pipeline and fails if any of them failed. The security checks in that list are:

CheckWhat it looks at
Repository CVE scanDependency CVEs across the repository. Fails the job at CRITICAL or HIGH severity.
Configuration scanDockerfile and infrastructure-manifest misconfigurations. Fails the job at CRITICAL or HIGH.
Python dependency auditAdvisories against the pinned Python dependencies of each service.
Node dependency auditAdvisories against the Node dependencies of each JavaScript app in the repository.
Python security lintSecurity lint over the backend source.
Custom static analysisA ruleset written for this codebase, covering patterns a generic ruleset does not carry.
Secret detectionSecret detection across the full git history.
GitHub Actions pin checkRefuses a third-party action referenced by a mutable version tag. A commit SHA is required.

None of these is advisory. Each one sits in the required check's dependency list with no continue-on-error escape on it, so a finding blocks the merge. The repository CVE and configuration scans, the Python and Node dependency audits, the Python security lint, and secret detection run on every pull request and on every push to main. The custom static analysis runs on every push to main, and on the pull requests that touch backend Python source.

A finding can be accepted by adding an entry to a tracked allowlist, each entry carrying a justification, so accepting a residual risk is itself a reviewed change. Each scanner uploads its report as a workflow artifact attached to the run.

The same gate carries the pre-commit hook suite and the rest of the test matrix. Two qualifications on the word "required" are worth having. A change can be committed straight to main without opening a pull request, in which case the identical pipeline runs on the push and a failure surfaces once the commit has landed. And when the self-hosted runner pool is unavailable the check cannot run at all, so an urgent merge is forced through by the repository owner and the pipeline runs against main once the runners return.

Signed images

A push to main that changes a service's source rebuilds that service's image and tags it sha-<commit>, naming the commit that produced it. That sha- tag is what the deploy script ships to clusters. The mutable :latest tag is not promoted to the production registry, and it is not separately signed, so there is no floating tag for a cluster to drift along.

Each sha- tag is signed with Cosign, keyless, through Sigstore. Keyless means there is no long-lived signing key held anywhere that could be stolen: the build workflow authenticates to Sigstore with its GitHub OIDC identity, receives a short-lived certificate, and the signature it produces binds the image to the identity of the workflow that built it. Signatures are Sigstore bundles attached to the image digest as OCI 1.1 referrers, so verifying one needs Cosign v3 or later.

Verify a signature

The command below is the check that stands behind the signing claim on this page. It runs against registry.parascope.io, the private registry production clusters pull from, so running it needs pull access there. Publishing the command and the identity it asserts is what makes the claim checkable: it states exactly what a passing verification proves, and anyone holding access can run it against our images.

cosign verify \
  --certificate-identity-regexp "^https://github.com/parascope-io/parascope/.github/workflows/build-images.yml@refs/heads/main$" \
  --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
  registry.parascope.io/parascope-api:sha-abc12345

The identity regexp is the substance of the check. It asserts that the signature was produced by the build-images.yml workflow in the parascope-io/parascope repository, running on the main branch, with the certificate issued by GitHub's own OIDC issuer. A signature minted by any other workflow, by a workflow in another repository, or by the same workflow run from any other branch, fails the match. The trailing $ is doing real work, because Cosign matches the regexp unanchored. Our deploy script pins the identical value, so what a customer can check by hand is what production enforces before it ships anything. Anyone running the command can confirm that property holds by re-running it with a repository name that is not ours and watching it fail.

Where the signature lives. The build workflow signs the digest its own build step produced, and promotes that digest into registry.parascope.io, so the command above verifies the same copy a production cluster pulls. The signing subject is the digest, never the tag: the workflow refuses to sign or promote anything unless the sha- tag resolves to exactly what it just built, and the promotion compares digests rather than checking whether the tag exists. Promotion and signing are two steps, and the second one can fail on its own. When it does, the workflow goes red and the run's summary names the service as promoted but unsigned, which is a state an operator can see and a re-run can repair. The image also lands on ghcr.io with a signature of its own from the same workflow, so a second independent copy of the evidence exists.

What a passing verification proves, and what it does not. It proves these exact bytes were signed by our build workflow running on main, and that workflow signs only a digest it built itself. It does not, on its own, name the commit: the certificate identifies the workflow and the branch, and the sha-<commit> tag is a name rather than a cryptographic binding. The deploy script closes that separately: after a rollout reports healthy it reads BUILD_SHA out of the running container and fails the deploy unless it matches the commit being deployed. Signature and BUILD_SHA together answer "is this the reviewed commit's code"; the signature alone answers "did our workflow build this". We publish no SLSA build-provenance attestation today, so the step from reviewed commit to signed digest is workflow logic rather than a claim a verifier can check offline.

An image in the production registry can end up carrying no CI signature two ways. The first is the failed signing step above. The second is a locally built image: the deploy script ships the image CI already built, and building locally and pushing takes an explicit override, DEPLOY_ALLOW_LOCAL_BUILD. Without the override the deploy stops. With it, the script warns that the image it is about to push will carry no signature, and pushes it under a local-<commit> tag instead of the sha-<commit> one. CI's tag namespace is CI's alone, so an unsigned local build cannot occupy a tag the build workflow reads and signs, and anything running from a local build says so in the tag it is pinned to. Before it rolls anything out the deploy script verifies the signature on the digest the tag resolves to, against the identity above, and stops if the check fails or cannot be performed at all. Getting past that takes a second explicit override, DEPLOY_ALLOW_UNSIGNED, which the script warns about in the same way. A locally built image is exempt from the check, because no signature for it could exist. The credential CI pushes with is create-only, so an existing sha- tag cannot be repointed at other content by the build pipeline.

Appliance release images are keyless-signed the same way, and they carry a second signature as well, made with a key of our own, which the appliance verifies offline before it applies an update. That path is covered below.

How a change reaches production

Deploys go out through a single script, which holds the properties a reviewer usually asks about:

  • Immutable tags only. The script pushes sha-<commit> and points the Kubernetes deployment at that tag. Rolling back means pointing the deployment at the previous sha- tag, which still exists and is still signed.
  • A dirty working tree is refused. If the source tree has uncommitted changes the deploy stops, because the image content would not match the commit its tag names. An override exists for emergencies and stamps the tag with a -dirty-<timestamp> suffix, so anything deployed that way identifies itself.
  • The running commit is verified inside the pod. After the rollout reports healthy, the script reads the BUILD_SHA value out of the running container and exits non-zero unless it matches the SHA it deployed. A rollout that looks healthy while still serving the previous image fails the deploy.
  • Per-tenant services roll out canary first, serialized. One tenant namespace is imaged and fully verified before the next is touched. A failure part-way through stops the run and leaves the namespaces it has not reached on the previous SHA.
  • The commit must have passed CI. A signature attests who built an image, not that the code in it works. So before it rolls anything out the script asks GitHub whether the test-and-scan pipeline succeeded for the exact commit being deployed, and stops if it did not, if the pipeline never ran, or if the question cannot be answered at all. Getting past that takes an explicit override, which the script warns about the same way it warns about the others. A locally built image is exempt, because no pipeline run for it could exist.

The appliance verifies its own updates

If you run collectors on an appliance inside your network, that appliance does not take the platform's word for what it should run.

Each appliance release image is signed at build time with a private key, and the matching public key is baked into the appliance image at /usr/lib/parascope/trust/cosign.pub, inside a root-owned trust tree that the unprivileged agent process cannot write to. Before applying an update, the appliance verifies the image against that baked key itself, offline. The check does not consult a transparency log and needs no network path back to us, so a compromised control plane, or a compromised message path between us and your appliance, cannot induce it to run content that key did not sign.

The posture is fail closed. A first-party image that is not digest-pinned, or that fails verification, is refused and the update stops. Upstream images the appliance mirrors are not signed by our key and remain gated by an allowlist. Enforcement is conditional on the baked public key being present, so appliances built before that key existed keep the earlier digest-pinning behaviour and do not brick on an update.

Pinned dependencies

The pipeline pins what it pulls in from outside.

  • Third-party GitHub Actions are referenced by commit SHA, with the human-readable version in a trailing comment, so a step resolves to fixed content even if its publisher moves the version tag. The pin check in the merge gate enforces this.
  • Base images in Dockerfiles are pinned by digest, in the form FROM python:3.14-slim@sha256:cea0e6..., so rebuilding a commit pulls the same base layers it was built against.
  • Python dependencies are compiled into requirements files carrying artifact hashes, so an install verifies the hash of each package it fetches.

Renovate opens pull requests when a pinned digest or version moves, and Dependabot opens weekly pull requests for the Python and Node dependency sets; both kinds pass the same merge gate as any other change. Automatic merge is deliberately narrow: it covers patch and digest bumps from GitHub's and Docker's own action namespaces, and it is off for base-image digest updates, because accepting a new digest without a human looking at it hands back the trust that pinning was introduced to remove.

Scheduled re-scans

The merge gate scans the tree as it stood at merge time. Vulnerability databases move afterwards, and a dependency that was clean on Friday can carry a published CVE by Monday.

A separate workflow runs weekly, Monday at 03:00 UTC, and re-runs the filesystem and configuration scans against a freshly downloaded CVE database, alongside image scans of the published parascope-* packages. Those legs are gated on the scanner's exit code, so a new finding fails the run, and a failing run posts to the same ntfy topic the platform's production alerts use, so it reaches the operator through a channel that is already being watched.

The same workflow also scans the third-party images the platform's charts pin, and those legs are report-only by design, apart from the registry component whose version this repository chooses. A CVE in someone else's published image cannot be fixed here by failing a build, and a check that reddens every week for reasons nobody can act on is a check that stops being read. Those findings land in the run's report and are worked from there.

The image scans skip CVEs with no upstream fix available, since a base-image CVE nobody has patched yet cannot be actioned by bumping a version. Those unfixable findings are still recorded in the run's report artifact. The image matrix is derived from the workflow that publishes the packages, so a newly added service is scanned the following Monday without anyone remembering to add it to a second list.

One scope limit is worth stating. The weekly image scan covers the ghcr build channel at its most recent build of main. It is evidence about that build channel. It does not enumerate the SHA-pinned images a particular cluster is running today. Vulnerability Management covers what happens to a finding from either scan, including how an accepted one is justified and recorded.

What this does not cover

There is no admission-time signature enforcement for cloud images. The Kubernetes clusters do not run an admission webhook that checks an image signature before a pod starts. Signing produces an evidence chain you can check; enforcing that only signed images are allowed to run is a separate control, and that one does not exist today. What stands in for it is described above: deploys go through a script that ships the CI-built sha- tag unless an operator sets the override, that script verifies the image's signature before it rolls anything out, and the registry credential CI holds cannot overwrite a tag that already exists. That verification runs on the deploy machine at deploy time, not in the cluster when a pod starts, so it constrains what our deploy path ships and nothing that might reach a node by another route. An admission webhook is intended for after launch and has no date on it. The appliance and the control plane's own update path do verify signatures before acting, as described above.

There is no GitHub code scanning. Scanner results are not uploaded as SARIF and there is no Security tab to browse, because GitHub Advanced Security is unavailable on this repository. Findings are read from the failing job's output and from the report artifacts attached to each run, which are kept for a year on the merge-gate scans and for 90 days on the weekly ones.

None of this has been examined by a third party. Everything on this page describes controls that exist in the pipeline and that you can test at the points listed below. No auditor has reviewed them. Compliance Posture states the certification position in full.

Verify it yourself

  • Reproduce the signature check with the Cosign command above, if you hold pull access to the production registry. Cosign v3 or later is required, since v2 cannot read the bundle format. A pass tells you the image was produced by this repository's build workflow at that commit.
  • Run the negative control alongside it. The same command with a --certificate-identity-regexp naming a different repository fails, which is what tells you the first result was a real identity check.
  • Verify your own appliance images, if you run one. The public key is on the appliance at /usr/lib/parascope/trust/cosign.pub, and cosign verify --key against a release image reproduces the check the appliance performs on itself before an update.
  • Ask us to run it with you. The production registry requires credentials, so tying a running image back to its signature is a check we walk through with you. It is the one command above pointed at the tag your cluster is on. Send the service and tag to security@parascope.io.
  • Supply-chain controls with their own verify pointers are listed in the control matrix. Anything this page leaves open goes to security@parascope.io.