Installation¶
Vectispire is two processes and a database: a Spring Boot control plane that serves the API and the compiled interface, and — optionally — one or more remote agents. A single-machine install needs neither the agent nor any agent configuration.
Prerequisites¶
| Requirement | Why |
|---|---|
| Docker, running and reachable | Every scanner runs as an ephemeral container that Vectispire asks a Docker daemon to start — through a socket proxy in the composition, see below. This is not optional: there is one scan backend and it is Docker. |
| MySQL 8 (default) or PostgreSQL | Both are supported and exercised by the integration campaign; MySQL is the default and the engine the composition ships. The engine is read from the JDBC URL; there is no separate dialect setting. |
| Git | Vectispire clones what it scans. |
| Node 24 (LTS), JDK 25 | Only if you build from source rather than running the published images. Node is pinned by .nvmrc; Angular 22 refuses Node 25. |
Access to a Docker daemon
Vectispire runs its scanners as containers, so it needs to reach a daemon — but it does
not mount the socket. The composition puts a docker-socket-proxy in front, on an
internal network, and points the control plane at it through DOCKER_HOST. Nothing on
your side to configure, and no docker group to join.
Running outside compose, straight against a daemon? Then the user does need access to
/var/run/docker.sock, and on Linux that usually means the docker group. Without it
every scan fails at the first container.
The daemon named by DOCKER_HOST and every host of the JDBC URL are reserved: no
webhook, AI server, SIEM collector or forge connection may be pointed at them, whatever the
policy. In a Kubernetes pod, so is the cluster's API service (KUBERNETES_SERVICE_HOST and
KUBERNETES_SERVICE_PORT, which every pod is given), and so are ports 6443 and 10250 on every
address — the API server's and the kubelets' own, on node addresses no variable names
(Kubernetes says what that leaves to the NetworkPolicy). So the
control plane refuses to start when it cannot read those hosts — a DOCKER_HOST that is
not unix://, npipe://, tcp://, http:// or https://, or a JDBC URL whose hosts are
not in the string (jdbc:mysql+srv://). Multi-host and replication URLs, MySQL's
address=(host=…) form and host names with underscores are read; for MySQL the X protocol
port 33060 is reserved beside the classic one.
One host means one blast radius
With the built-in worker on — the default — the process that can create containers is the
process that holds ENCRYPTION_KEY, and daemon access is root on that host. The proxy
narrows what can be asked of the daemon; it does not separate the two. For anything beyond
a single-team installation, run a remote agent and set
VECTISPIRE_EMBEDDED_WORKER=false on the control plane. See
decision 0018.
The quickest route: Docker Compose¶
That brings up MySQL and the control plane on http://localhost:3180. To also start a
dedicated remote agent:
What the composition keeps apart
- Secrets arrive as files.
MYSQL_PASSWORD,MYSQL_ROOT_PASSWORD,ENCRYPTION_KEY,VECTISPIRE_BOOTSTRAP_PASSWORD,VECTISPIRE_SIGNING_KEYandVECTISPIRE_OIDC_CLIENT_SECRETare still read from.env, but Compose hands them to the containers as files under/run/secrets/, not as environment: a container's environment is whatdocker inspectreturns to anything that can talk to the daemon. The last two are optional and stay declared in.envwhen unused — empty, and no file is mounted; a.envwithout them stopsdocker compose upwith a message naming the variable. Upgrading: add both lines, and move the OIDC client secret out of.env.oidc, whose values still reach the container as environment. - The database answers the control plane only. It sits on an internal network with the
control plane alone and publishes no port — a port bound to
127.0.0.1is still reachable from every other container on the host. For a SQL session:docker compose exec db mysql -u vectispire -p vectispire. - The agent reaches the API and its own daemon proxy, nothing else — not the database, not the control plane's proxy.
- No setting may point at the daemon's proxy or at the database. An Ollama, webhook, SIEM or tracker URL naming either is refused, whatever the destination policy.
Scans live in a directory of the host, at the same path inside the container
Every scanner is a container the daemon starts, and the daemon resolves the directories it
mounts into it on its own host — not inside the control plane's container. A scan's
workspace (the clone, the SBOM, the secrets report while it runs) and the vulnerability
database are therefore created in VECTISPIRE_WORK_DIR — /var/lib/vectispire/work by
default — which the composition mounts into the control plane at that same absolute path
and hands to the image's user (1000:1000, mode 0700) with the one-shot work-dir service before
the control plane starts. The with-agent profile does the same for the agent with
VECTISPIRE_AGENT_WORK_DIR (/var/lib/vectispire/agent-work).
- The disk under it holds the matcher's database, some 3 GB, and each running scan's clone.
- Change the path in
.envif you like, never in one of the two places only: the bind ispath:pathon purpose. - Outside this composition — your own Compose file, Kubernetes,
docker run— mount a host directory at the same path and point the JVM's temporary directory at it (JDK_JAVA_OPTIONS=-Djava.io.tmpdir=<path>). Left at the container's own/tmp, every scanner is handed an empty directory and every scan fails. - The same directory holds the process's home,
home/under it (-Duser.home). That is where the forges' host keys are recorded,home/.ssh/known_hosts, and kept across restarts; the agent has its own underVECTISPIRE_AGENT_WORK_DIR. To pin them in advance, see Over SSH: the forge's host key. The images run as 1000 with no account of that number and carryHOME=/home/vectispire, so a plaindocker runhas a writable home too — but in the container's own layer, lost with the container: a host recorded there is met again as new after a re-creation, and under--read-onlythe home cannot be written at all. Outside this composition, add-Duser.home=<path>/hometoJDK_JAVA_OPTIONSas well, or mount a volume at/home/vectispire; the flag wins over the image'sHOME. - Your own
~/.sshis not mounted, andVECTISPIRE_HOST_SSHisfalsehere: attach a deploy key to each private repository. The composition used to mount it read-only, where the process never read it — and had it been read, it would have handed every key you hold to the process that holdsENCRYPTION_KEY. - On Docker Desktop the path is in its virtual machine, not on your Mac or PC, which is what you want: both sides of the mount are there.
The composition pulls two published images, so nothing here needs a JDK or a Gradle cache:
They are public — no login, no token. Pull one on its own with
docker pull ghcr.io/asmolabs/vectispire:0.10.0, and see
Verifying a release before you run it.
Before the first start¶
Most settings live in the database and are edited from Settings once the application runs. Four things have to be right before the first start, because they are needed to reach that screen at all.
The database¶
VECTISPIRE_DB_URL=jdbc:mysql://localhost:3306/vectispire
VECTISPIRE_DB_USER=vectispire
VECTISPIRE_DB_PASSWORD=…
That URL is also the default. For PostgreSQL, point the same variable at it —
jdbc:postgresql://localhost:5432/vectispire — and change nothing else. One MySQL server setting matters if you use report plugins
on large projects: at the default max_allowed_packet (64 MiB) a report's export is kept up to about
32 MiB, and --max-allowed-packet=160M restores the whole 64 MiB bound.
The schema belongs to Flyway migrations, applied at startup. There is no separate
migration command to run, and ddl-auto is validate deliberately: a schema synthesised
from the entities is not the one production receives, so testing against it would let a
faulty migration through.
The encryption key¶
Vectispire encrypts the secrets it holds — deploy keys above all. Saving a secret is refused until a key is set.
Prefer ENCRYPTION_KEY_FILE over ENCRYPTION_KEY in production: a file is what a Docker
or Kubernetes secret mounts, and it keeps the value out of /proc/<pid>/environ,
docker inspect and your orchestrator's logs. Setting both is refused. A path that does
not resolve stops the application rather than starting with no key.
See Rotation and purge for changing it later.
The first account¶
There is no self-registration page. The first account comes from bootstrap variables, and the SUPERUSER is created when the user table is empty:
Once any account exists, both variables are ignored. Change that password at first login.
Where to run it¶
Vectispire is built for an internal network, not for the public internet. It is an operations console for a team that already has access to the code it scans, and its design assumes that everyone who can reach the login page is somebody you would have given a login to anyway.
That assumption is load-bearing, so it is worth stating plainly rather than leaving it to be inferred from the defaults:
- The control plane publishes port
3180on every interface of its host. That is deliberate — you have to reach the interface — but it means a host with a public address serves Vectispire to the internet the moment it starts. The database, by contrast, publishes no port at all — not even on loopback, which other containers on the host would still reach; the difference is intentional and visible indocker-compose.yml. - A signed-in user who can register a repository can make the control plane clone a URL they chose. That is the product working as intended, and it is also why who can sign in is the boundary that matters most.
If the host is reachable from outside your network, put it behind something — a VPN, an
identity-aware proxy, or a firewall rule — before anything else. If you terminate TLS in front
of it, name the proxy in VECTISPIRE_TRUSTED_PROXIES (vectispire.security.trusted-proxies); left empty, the rate limiter
counts the proxy's address rather than the caller's and stops protecting anyone.
Two settings that change with the size of the install¶
| Setting | Default | Change it when |
|---|---|---|
VECTISPIRE_HOST_SSH |
true |
More than one team shares the install. With the fallback on, a repository with no key of its own is cloned using the host's ~/.ssh identity — so adding a URL is enough to have Vectispire clone it as that identity. On a single-team install the host key already reaches every target and the fallback costs nothing; on a shared one, set it to false and attach a deployment key per repository. The shipped docker-compose.yml sets it to false and mounts no ~/.ssh: inside its container there is no host key to fall back on. |
Inbound webhook secret (ticket_webhook_secret) |
empty | You wire a tracker webhook. This one is not an environment variable: it is a database setting, set in Settings → Tickets → Inbound webhook secret and stored encrypted. While it is empty the webhook route refuses every call with a 403 saying the webhook is not configured, which the tracker shows in its delivery log — the route cannot hold a session, so the secret is its whole authentication. Set the same value in the tracker. A delivery is acted on once within thirty days, and a decision it carries is queued for approval rather than applied; see Ticketing. |
Both are recorded with their reasoning in the project's threat model.
Kubernetes¶
A Helm chart ships in deploy/helm/vectispire/.
It follows decision 0038.
It deploys:
- the control plane, which also serves the interface;
- its Ingress;
- the audit mirror's volume.
It does not deploy the database or the Secrets, which are yours. It runs no container itself, so every scan runs on an agent. Agents run on a Docker host outside the cluster (recommended), or in the cluster as opt-in pods.
Report plugins are not available on Kubernetes yet
A report plugin runs on the control plane's own Docker
endpoint, and a pod has none. Requests answer 409 report-executor-unavailable until a later
version can reach a remote daemon. Built-in reports and exports are not affected: they are
rendered by the application itself.
The MySQL server¶
Installed and backed up separately. The chart points at it:
- One host in the URL.
jdbc:mysql://mysql.example.org:3306/vectispire. A URL listing several hosts, ormysql+srv, is refused at startup: Vectispire must know the database's address to keep webhooks from reaching it. - TLS. Add
sslMode=VERIFY_IDENTITYto the URL. If the server's certificate is from a private CA, put that CA in a PKCS12 truststore and store it in a Secret named bydatabase.trustStore. Then addtrustCertificateKeyStoreUrl=file:/etc/vectispire/mysql/truststore.p12&trustCertificateKeyStoreType=PKCS12to the URL. max_allowed_packetat least160M. Report exports travel hex-encoded at twice their size.- UTC:
default_time_zone = '+00:00'. Some columns default toCURRENT_TIMESTAMP, which the server evaluates in the session's time zone. Do not setTZon the pod. - Back up before every upgrade. Migrations run at startup and have no way back. Rolling back means restoring the backup and the previous image digest. The step from the previous release is executed every night on MySQL and PostgreSQL over a database holding data, which then reads back unchanged; a release is not cut on a night that failed.
Installing¶
kubectl create namespace vectispire
kubectl -n vectispire create secret generic vectispire-database \
--from-literal=password='<the database password>' # gitleaks:allow
kubectl -n vectispire create secret generic vectispire-keys \
--from-literal=encryption-key="$(openssl rand -base64 32)" \
--from-literal=bootstrap-password="$(openssl rand -base64 24)" # gitleaks:allow
kubectl -n vectispire create secret tls vectispire-tls --cert=tls.crt --key=tls.key
cp deploy/helm/vectispire/values.example.yaml my-values.yaml # edit it
helm install vectispire deploy/helm/vectispire -n vectispire -f my-values.yaml
In a namespace you were given. When you can write only to one namespace, skip kubectl create
namespace and install into it with -n <your namespace>: the chart creates nothing cluster-wide, and
with agents off everything it renders lands there. Agents in the cluster need two more values, see
below.
Back up the encryption key outside the cluster. It decrypts every deployment key and token Vectispire holds; a cluster rebuilt without it holds secrets nobody can read.
Also set a signing key (secrets.signingKey, a P-256 PKCS#8 PEM): on 0.10.0, an installation
without one answers 500 on its first evidence bundle (fixed in the next release). Keep it: replacing
it makes every document already signed unverifiable.
The chart refuses to render without its required values, and says which is missing. The full list is in the chart's README.
Shape of the deployment:
- one replica;
strategy: Recreate, so an old pod never serves a migrated schema;- the image pinned by digest;
- the pod: uid 1000, read-only root filesystem, every capability dropped, no service-account token;
- probes on
/actuator/health/livenessand/actuator/health/readiness; readiness includes the database.
Keep the API server out of reach by address, too. Settings that send requests — a forge connection
on the internal network, a webhook, an AI server — are refused the cluster's API service and, on any
address, ports 6443 (the API server's usual port) and 10250 (the kubelet's). The service address is not
the only way to the API server: kube-proxy translates it to the server's real endpoints, node or
control-plane addresses that the application cannot know without the API access it does not have. A
managed cluster that publishes its API server on 443 cannot be refused by port without refusing every
HTTPS destination, so that one is the NetworkPolicy's. With networkPolicy.restrictEgress, the chart
lets the control plane reach DNS and what networkPolicy.egress lists: keep the API server's endpoint
addresses (kubectl get endpointslices -n default -l kubernetes.io/service-name=kubernetes) and the nodes'
range out of that list. Without it, egress is open, and only the pod's lack of a service-account token
keeps such a request anonymous.
The Ingress¶
The default annotations are ingress-nginx's. With another controller, set the same three things in its own words:
| Setting | Why |
|---|---|
| read timeout ≥ 60 s | an agent's long poll holds its request 30 s |
| body size ≥ 256 MB | an agent's result carries the SBOM |
| TLS | the interface signs people in |
Set trustedProxies to the ingress controller's address range. Left empty, every audit entry and
rate limit names the controller instead of the caller. TLS ending at the Ingress would also read as
plain HTTP to the application.
And enable networkPolicy, which the chart then requires. A peer in that range is believed about
the client's address. The controller's pods have no fixed address, so the range is usually the pod
range, and without a policy keeping port 3180 to the controller any pod could call it directly and name
any address: a fresh rate-limit bucket on every request, an audit entry naming somebody else. The
policy only holds on a CNI that enforces NetworkPolicy. trustedProxiesWithoutNetworkPolicy renders the
chart anyway, for a range holding the controller alone.
Behind a load balancer, keep the client's address up to the controller. trustedProxies makes the
application believe what the controller writes in X-Forwarded-For, which is only what the controller
saw: the application takes the rightmost address in that header that trustedProxies does not name. With
the controller's Service on externalTrafficPolicy: Cluster, the default, a node translates the source on
the way in (SNAT) and the controller sees a node's address. Every client arriving through that node then
shares one rate-limit bucket, and every audit entry and SIEM event names the node — or the controller
itself, when trustedProxies covers the nodes' range too. What restores the client's address depends on
the load balancer:
- one that passes packets through (a network load balancer):
externalTrafficPolicy: Localon the controller's Service; - one that opens its own connection to the nodes: the PROXY protocol, switched on at the load balancer
and at the controller (ingress-nginx's
use-proxy-protocol) together; - an HTTP load balancer, which writes
X-Forwarded-Foritself: the controller must pass it on (ingress-nginx'suse-forwarded-headers, withproxy-real-ip-cidrset to the load balancer's addresses), and every proxy that adds its own address to the header belongs intrustedProxies. Only when the load balancer is the controller's sole way in: otherwise a client writes the header the controller passes on, and is believed.
Two replicas are possible but not the default. They need:
- cookie affinity (
ingress.stickySessions): single sign-on keeps its state in the session of the pod that sent the person to the provider; - a ReadWriteMany volume for the audit mirror (
auditMirror.accessMode): each pod writes its own file there.
Agents on a Docker host (recommended)¶
A Linux VM with Docker, outside the cluster, reaching the Ingress over HTTPS. Create the agent on the
Agents screen, then run it the way the composition's with-agent profile does:
- its own socket proxy;
- its work directory mounted at the same path on both sides — the daemon resolves a scanner's bind on its own host.
# docker-compose.yml on the agent's host
services:
agent:
image: ghcr.io/asmolabs/vectispire-agent:0.10.0@sha256:121ec47d0db949946b55ec034b72fdaff909470c92353efda434bbbbb428d72e
environment:
VECTISPIRE_URL: https://vectispire.example.org
DOCKER_HOST: tcp://agent-docker-proxy:2375
# The key as a file, read through the configuration tree, not as a variable.
SPRING_CONFIG_IMPORT: optional:configtree:/run/secrets/
JDK_JAVA_OPTIONS: >-
-Djava.io.tmpdir=/var/lib/vectispire/agent-work
-Duser.home=/var/lib/vectispire/agent-work/home
secrets:
- source: agent_token
target: VECTISPIRE_AGENT_TOKEN
volumes:
- /var/lib/vectispire/agent-work:/var/lib/vectispire/agent-work
depends_on: [agent-docker-proxy]
restart: unless-stopped
networks: [outside, docker]
agent-docker-proxy:
image: tecnativa/docker-socket-proxy:0.3.0@sha256:9e4b9e7517a6b660f2cc903a19b257b1852d5b3344794e3ea334ff00ae677ac2
# What ContainerRunner calls, and nothing else; every other section stays at its default, 0.
# docker-compose.yml lists them all, each with its reason.
environment: {PING: 1, VERSION: 1, INFO: 1, CONTAINERS: 1, IMAGES: 1, POST: 1, EXEC: 0}
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
restart: unless-stopped
networks: [docker]
secrets:
agent_token:
file: ./agent-token # the key shown once on the Agents screen, mode 0600
networks:
outside: {}
docker: {internal: true}
Before the first start, give the work directory to the agent's user:
Trust. If the Ingress certificate is from a private CA, give the agent's JVM a truststore through
JDK_JAVA_OPTIONS:
The truststore replaces the JVM's own: include every authority the agent must trust.
See Agents for:
- credentials modes;
- pinning a signing key;
- concurrency.
Agents in the cluster (opt-in)¶
agents.enabled: true deploys one agent pod per release. The pod has two containers:
- the agent, read-only, as uid 1000;
- a Docker daemon of its own (
docker:dind), on a Unix socket shared through anemptyDir. It never listens on TCP: the image's entrypoint would otherwise open port 2375 to the whole cluster, with no authentication.
The work directory is a second emptyDir, mounted at the same path in both containers.
The daemon runs in a privileged container, which is root on its node. A scanner that escapes its own container reaches that daemon, and through it the node. Give these pods:
- their own namespace: the chart creates
vectispire-agentswith Pod Security Admission'sprivilegedlevel. In a single namespace you were given, setagents.namespace: ""andagents.createNamespace: false: the agents go beside the control plane, and the pod is admitted only if that namespace's owner has set its level toprivileged— for every pod in it.kubectl get namespace <your namespace> --show-labelstells you;restrictedorbaselinemeans a Docker host; - their own nodes:
agents.nodeSelector(or a required node affinity), andagents.tolerationsmatching a taint only they tolerate. The chart refuses to render without both (agents.acknowledgeSharedNodesoverrides). Whatever you set, it keeps the control plane off the node of every agent — of any release, in any namespace — with a required anti-affinity: the control plane holdsENCRYPTION_KEY. On a single node it stays Pending rather than share one; - the NetworkPolicy: on by default. It denies all ingress. List in
agents.networkPolicy.excludeCidrswhat an escaped scanner must not reach: the cluster's pod, service and node ranges (the kubelets, and the API server's real address), and the database's subnet when it is outside them, as a managed MySQL usually is. Egress then reaches the outside world. The chart refuses an empty list, and a policy turned off (agents.networkPolicy.enabled: false), unlessagents.networkPolicy.acknowledgeClusterReachableis set; it excludes169.254.0.0/16— the cloud metadata endpoint — and the metadata addresses outside it (100.100.100.200,192.0.0.192,168.63.129.16) whatever it says. Give the agents the Ingress back inagents.networkPolicy.allowTo: kube-proxy translates the Ingress's LoadBalancer address to an ingress controller pod's, which lies in the excluded pod range. An entry is the controller'snamespaceSelectorandpodSelectortogether withports: [{port: 443}]; an internal Git host or registry in an excluded range is anipBlockwith 443 or 22. The chart refuses an entry withoutports, which would open every port of what it selects (chart README). DNS goes to port 53 anywhere unlessagents.networkPolicy.dnsTonames your resolvers; - a pinned signing key (
agents.signingKey.secretName), which the chart requires: without one the control plane accepts the agent's results unattested.
Where the cluster forbids privileged pods, use a Docker host.
The rootless variant (agents.dind.variant: rootless, docker:dind-rootless) does not scan
with this version's agent:
- under the rootless daemon's user namespace, the workspace the agent owns as uid 1000 appears to the scanner as owned by root;
- the scanner, also running as uid 1000, cannot read it, and every scanner ends up absent;
- on most clusters it also needs a privileged container anyway, or unconfined seccomp and AppArmor.
The chart renders it only with agents.dind.rootless.acknowledgeUnreadableWorkspaces: true.
Each restart of the pod downloads the scanner images and the vulnerability database again — about
3 GB. agents.dind.imageCache keeps the images on a volume.
Running from source¶
git clone https://github.com/asmolabs/vectispire.git
cd vectispire
npm ci
cd vectispire-java && ./gradlew :vectispire-core:bootRun --args='--server.port=3180'
npm --workspace @vectispire/frontend start # UI on :4280, proxies /api to :3180
npm covers the interface alone. The control plane is a Gradle build in vectispire-java/
and shares nothing with it but the HTTP contract.
Verifying a release¶
Each release carries the jar, its SBOM, the CI gate script and —
from the release after 0.10.0 on — the CLI vectispire-cli.sh, each with a Sigstore bundle verified
as the jar is below. Verify before running anything — a security tool you took on trust is a contradiction.
cosign verify-blob \
--bundle vectispire-0.10.0.jar.cosign.bundle \
--certificate-identity "https://github.com/asmolabs/vectispire/.github/workflows/release.yml@refs/tags/v0.10.0" \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
vectispire-0.10.0.jar
Each flag pins something, and dropping any one of them gives back most of what signing was
for. --certificate-identity names the workflow file and the tag, not the repository:
matching the repository alone would accept a signature minted by any workflow anybody can
add to it, including one added in a pull request. --certificate-oidc-issuer says the
identity came from GitHub's token service — without it, a string that merely looks like
the identity above is enough. Replace the tag in both places for another version; the
identity is per-tag by design.
Verifying an image¶
The images are signed the same way, by digest rather than by tag — a tag can be moved to another image, a digest cannot. So verify the digest, and deploy that same digest: checking the tag and then pulling it again checks an image you may not be running. Resolve the tag once, with the command the release workflow uses itself:
DIGEST="$(docker buildx imagetools inspect ghcr.io/asmolabs/vectispire:0.10.0 --format '{{.Manifest.Digest}}')"
echo "$DIGEST" # sha256:…
cosign verify \
--certificate-identity "https://github.com/asmolabs/vectispire/.github/workflows/release.yml@refs/tags/v0.10.0" \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
"ghcr.io/asmolabs/vectispire@${DIGEST}"
Then reference the image as ghcr.io/asmolabs/vectispire@sha256:<digest> in your Compose file
or chart rather than by its tag.
Each image also carries its SBOM as an attestation rather than as a file beside it, because a file beside an image is one anybody can swap:
cosign verify-attestation --type cyclonedx \
--certificate-identity "https://github.com/asmolabs/vectispire/.github/workflows/release.yml@refs/tags/v0.10.0" \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
"ghcr.io/asmolabs/vectispire@${DIGEST}"
There is no signing key. Sigstore keyless signs with the workflow's own OIDC identity, so there is nothing in anybody's custody to steal or rotate.
Run the same command against the SBOM's filenames. It is worth doing: an SBOM is what somebody feeds to their own scanner, and an unsigned one is a dependency list anybody can rewrite before you read it.
Verifying the build provenance¶
A signature says which workflow produced a file. Each release after v0.9.0 also carries a SLSA build provenance attestation, for the jar and for each image, that says how: the repository, the commit the tag pointed at when the release ran, the workflow and the runner. A tag can be moved after the fact; the commit recorded in the provenance cannot, and it is the one to check out if you want to read or rebuild the source that shipped.
GitHub's CLI verifies it, with no file to download beside the artefact:
gh attestation verify vectispire-<version>.jar \
--repo asmolabs/vectispire \
--signer-workflow asmolabs/vectispire/.github/workflows/release.yml \
--source-ref refs/tags/v<version>
gh attestation verify oci://ghcr.io/asmolabs/vectispire@sha256:<digest> \
--repo asmolabs/vectispire \
--signer-workflow asmolabs/vectispire/.github/workflows/release.yml \
--source-ref refs/tags/v<version>
The image is named by digest — the one printed in the release notes, or the one
docker buildx imagetools inspect ghcr.io/asmolabs/vectispire:<version> --format '{{.Manifest.Digest}}'
returns — for the same reason the signature is. The agent image verifies the same way under
ghcr.io/asmolabs/vectispire-agent. From the release after 0.10.0 on, a third image is published,
ghcr.io/asmolabs/vectispire-report-demo — the demonstration report plugin,
which the composition does not pull — and it verifies the same way under its own name.
--repo alone is the command GitHub documents, and it is not enough on its own: it accepts an
attestation made by any workflow of the repository, on any branch. --signer-workflow narrows
it to the release workflow and --source-ref to the tag you meant to install — the same two
things --certificate-identity pins in the cosign commands above. Add --format json to read
the statement itself; the commit is under buildDefinition.resolvedDependencies.
The provenance adds to the signature and does not replace it: v0.9.0 and the releases before it
have a signature and no provenance, and the cosign commands above remain the check every release
supports.