CI/CD Gate Flow
Overview
The CI/CD Gate Flow describes how StellaOps integrates into continuous integration and deployment pipelines to provide automated security gates. The flow covers CLI-based scanning, policy evaluation, and pass/fail decisions that control pipeline progression.
Audience: DevOps and platform engineers wiring the stella CLI into CI/CD pipelines (GitHub Actions, GitLab CI, Gitea Actions) to gate releases on policy verdicts and attestation checks.
Business Value: Shift-left security by catching vulnerabilities before deployment, with deterministic, reproducible verdicts that integrate into existing DevOps workflows.
Actors
| Actor | Type | Role |
|---|---|---|
| CI Pipeline | System | GitHub Actions, GitLab CI, Gitea Actions (template generator); other CI can invoke the CLI directly |
StellaOps CLI (stella) | Tool | Runs scan/gate/verify from the pipeline |
| Gateway | Service | API entry point |
| Scanner | Service | Performs image analysis |
| Policy Engine | Service | Evaluates the release gate (api/v1/policy/gate/evaluate) |
| Attestor | Service | Signs scan results (DSSE) |
Prerequisites
The
stellaCLI release imageregistry.stella-ops.org/stella-cli, pinned by sha256 digest inSTELLAOPS_CLI_IMAGE; no install script is downloaded. Offline-first default: mirror the image into a registry the runners reach, or preload it on self-hosted runners (generate with--offline --cli-image, §1). Alternative: a connected runner pulls it fromregistry.stella-ops.orgby digest. Take the digest from the release qualification receipt.A CLI image that runs the commands the generated workflows use.
docker run --rm "$STELLAOPS_CLI_IMAGE" gate evaluate --helpmust list--missing-baseline; an image without it fails the gate step with “Unrecognized command or argument” before any call to Stella Ops. In such an imagegate status --decision-idparses but is a placeholder that exits9, andregistry scanandscan sarifcall endpoints the gateway does not route. The check is necessary, not sufficient: images built from source revisions that list--missing-baselinebut predate the scan-route correction (dcd135c275) fail at the scan step, and images that predate the bootstrap scope change (below) cannot enroll a first baseline. Pin a digest whose release qualification receipt covers the gate pipeline.Authority credentials: a confidential OAuth client (client credentials) for automation, or the seeded human client (username/password); see
stella auth loginbelow. Each pipeline command requests its own token, so the automation client needs every scope its workflow uses:Command Scopes Gate templates that run it stella attest buildattest:create,attest:readall gate templates stella registry scanscanner:scan,scanner:readall gate templates stella scan sarifscanner:readall gate templates stella gate evaluatepolicy:runall gate templates stella gate evaluatewithSTELLAOPS_MISSING_BASELINE=bootstrappolicy:run,policy:writeall gate templates (first-baseline run) stella gate statuspolicy:readGitHub gate template (summary step) stella sbom uploadsbom:writeGitLab gate template stella sbom generatenone (reads the image from its registry) GitLab gate template A command whose scope the client lacks is refused, and its step fails. Build provenance uses
attest:createto sign and record the entry, thenattest:readto retrieve its retained envelope. Grant both to the automation client.Build provenance needs an existing configured Evidence signing key, primary log and working archive, plus a reported builder URI (
STELLAOPS_BUILDER_ID), full source commit, credential-free repository URL and actual run reference. The CLI records signed SLSA v1 caller-reported lineage; the signature does not independently prove CI execution. Use qualified CLI/Evidence versions containing the reported build-provenance contract. Source tests do not qualify a live pipeline, grant a client/key or authorize publication.A gate baseline: until the tenant has one,
--missing-baselinedecides (§4b).block, the templates’ default, denies the run (exit1);warnexits10, which the templates also treat as a failure; the first run on a new installation setsSTELLAOPS_MISSING_BASELINE=bootstrap. The gate refusesbootstrap(HTTP403) unless the token also carriespolicy:write, so the automation client needs that scope for the first-baseline run; remove it once the baseline exists.Container image built and pushed to a registry connected to Stella Ops
Source of truth: the CLI command tree is assembled in
src/Cli/StellaOps.Cli/Commands/CommandFactory.cs. The CI/CD-facing verbs below arescan,gate,ci,guard,proof, andverify.
Supported CI/CD Platforms
The CLI ships first-class workflow templates via stella ci init for the platforms below. Other CI systems can still invoke the CLI directly (it is a self-contained binary), but no template generator exists for them.
| Platform | Template generator (stella ci init --platform) | Credentials |
|---|---|---|
| GitHub Actions | github | Authority client credentials |
| GitLab CI | gitlab | Authority client credentials |
| Gitea Actions | gitea | Authority client credentials |
NOT IMPLEMENTED (no
ci inittemplate): Azure DevOps, Jenkins, CircleCI, Tekton.stella ci initaccepts onlygithub,gitlab,gitea, orall(seeCiCommandGroup.cs). These platforms can run the CLI by hand but are not generated.
Flow Diagram
┌─────────────────────────────────────────────────────────────────────────────────┐
│ CI/CD Gate Flow │
└─────────────────────────────────────────────────────────────────────────────────┘
┌────────────┐ ┌───────────┐ ┌─────────┐ ┌─────────┐ ┌────────┐ ┌─────────┐
│ CI Pipeline│ │StellaOps │ │ Gateway │ │ Scanner │ │ Policy │ │ Attestor│
│ │ │ CLI │ │ │ │ │ │ │ │ │
└─────┬──────┘ └─────┬─────┘ └────┬────┘ └────┬────┘ └───┬────┘ └────┬────┘
│ │ │ │ │ │
│ docker build │ │ │ │ │
│───────┐ │ │ │ │ │
│ │ │ │ │ │ │
│<──────┘ │ │ │ │ │
│ │ │ │ │ │
│ stella scan │ │ │ │ │
│ image <ref> │ │ │ │ │
│ + gate eval │ │ │ │ │
│──────────────>│ │ │ │ │
│ │ │ │ │ │
│ │ POST /scans │ │ │ │
│ │────────────>│ │ │ │
│ │ │ │ │ │
│ │ │ Dispatch │ │ │
│ │ │───────────>│ │ │
│ │ │ │ │ │
│ │ │ │ Analyze │ │
│ │ │ │ image │ │
│ │ │ │───┐ │ │
│ │ │ │ │ │ │
│ │ │ │<──┘ │ │
│ │ │ │ │ │
│ │ │ │ Evaluate │ │
│ │ │ │──────────>│ │
│ │ │ │ │ │
│ │ │ │ │ Apply │
│ │ │ │ │ rules │
│ │ │ │ │───┐ │
│ │ │ │ │ │ │
│ │ │ │ │<──┘ │
│ │ │ │ │ │
│ │ │ │ Verdict │ │
│ │ │ │<──────────│ │
│ │ │ │ │ │
│ │ │ │ Sign │ │
│ │ │ │──────────────────────>│
│ │ │ │ │ │
│ │ │ │ DSSE │ │
│ │ │ │<──────────────────────│
│ │ │ │ │ │
│ │ │ Result │ │ │
│ │ │<───────────│ │ │
│ │ │ │ │ │
│ │ Verdict │ │ │ │
│ │<────────────│ │ │ │
│ │ │ │ │ │
│ Exit code │ │ │ │ │
│ (0=pass, │ │ │ │ │
│ 1=fail) │ │ │ │ │
│<──────────────│ │ │ │ │
│ │ │ │ │ │
│ [if pass] │ │ │ │ │
│ docker push │ │ │ │ │
│───────┐ │ │ │ │ │
│ │ │ │ │ │ │
│<──────┘ │ │ │ │ │
│ │ │ │ │ │
Step-by-Step
1. Pipeline Configuration
Generate these workflows with stella ci init --platform <platform> --template gate.
Set STELLAOPS_CLI_IMAGE to the qualified registry.stella-ops.org/stella-cli@sha256:<digest> release reference. The wrapper mounts the workspace and forwards Authority credentials and CI provenance by environment-variable name. Configure a confidential Authority client using STELLAOPS_AUTHORITY_URL, STELLAOPS_AUTHORITY_CLIENT_ID, STELLAOPS_AUTHORITY_CLIENT_SECRET and the tenant.
For a private CA, provide a PEM CA bundle through STELLAOPS_CLI_CA_BUNDLE. The wrapper mounts it read-only; TLS verification remains enabled.
For an air-gap mirror, use its qualified digest reference. For preloaded runners, generate with --offline --cli-image sha256:<qualified-local-image-id>. Retain the release qualification receipt and verify the transferred image archive checksum before loading it. The offline CLI installer only inspects the preloaded digest. Prepare self-hosted runners with Docker, jq, provider actions and any GitLab job/service images locally available.
Github
# StellaOps Release Gate Workflow
# Generated by: stella ci init --platform github --template gate
# Documentation: https://docs.stellaops.io/ci/github-actions
name: StellaOps Release Gate
on:
push:
branches: [main, release/*]
pull_request:
branches: [main]
workflow_dispatch:
permissions:
contents: read
security-events: write # For SARIF upload
env:
STELLAOPS_BACKEND_URL: ${{ secrets.STELLAOPS_BACKEND_URL }}
STELLAOPS_CLI_IMAGE: ${{ vars.STELLAOPS_CLI_IMAGE }}
STELLAOPS_AUTHORITY_URL: ${{ secrets.STELLAOPS_AUTHORITY_URL }}
STELLAOPS_AUTHORITY_CLIENT_ID: ${{ secrets.STELLAOPS_AUTHORITY_CLIENT_ID }}
STELLAOPS_AUTHORITY_CLIENT_SECRET: ${{ secrets.STELLAOPS_AUTHORITY_CLIENT_SECRET }}
STELLAOPS_AUTHORITY_TENANT: ${{ vars.STELLAOPS_AUTHORITY_TENANT }}
# A repository in a registry connected to Stella Ops (`stella registry list`), for example
# registry.example.com/team/app. The scan runs server-side and pulls the pushed image from it.
IMAGE_REPOSITORY: ${{ vars.STELLAOPS_IMAGE_REPOSITORY }}
# What the gate does while the tenant has no baseline: block (default), warn, or bootstrap
# (set it for the first run on a new installation to create the tenant's first baseline).
STELLAOPS_MISSING_BASELINE: ${{ vars.STELLAOPS_MISSING_BASELINE || 'block' }}
# The Attestor signing key the build provenance is signed with (`stella attest build`).
STELLAOPS_ATTESTOR_KEY_ID: ${{ vars.STELLAOPS_ATTESTOR_KEY_ID }}
STELLAOPS_BUILDER_ID: ${{ vars.STELLAOPS_BUILDER_ID }} # actual reported builder URI
jobs:
gate:
name: Release Gate Evaluation
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up StellaOps CLI
run: |
# Set STELLAOPS_CLI_IMAGE to the qualified registry.stella-ops.org/stella-cli
# release digest, its air-gap mirror, or a qualified preloaded local image ID.
export STELLA_CLI_IMAGE="$STELLAOPS_CLI_IMAGE"
printf '%s\n' "$STELLA_CLI_IMAGE" | grep -Eq '^([A-Za-z0-9][A-Za-z0-9._:/-]*@)?sha256:[0-9a-f]{64}$' || {
echo 'STELLAOPS_CLI_IMAGE must name a qualified sha256 digest' >&2
exit 2
}
docker pull "$STELLA_CLI_IMAGE"
mkdir -p .stella-ci/bin
cat > .stella-ci/bin/stella <<'STELLA_CLI'
#!/bin/sh
set -eu
: "${STELLA_CLI_IMAGE:?Set STELLAOPS_CLI_IMAGE to the release digest}"
set -- "$STELLA_CLI_IMAGE" "$@"
# Forward credentials and CI provenance by variable name; never print values.
for name in $(env | sed -n '/^STELLAOPS_[A-Z0-9_]*=/s/=.*//p; /^GITHUB_[A-Z0-9_]*=/s/=.*//p; /^GITEA_[A-Z0-9_]*=/s/=.*//p; /^CI_[A-Z0-9_]*=/s/=.*//p; /^GIT_[A-Z0-9_]*=/s/=.*//p; /^BUILD_[A-Z0-9_]*=/s/=.*//p'); do
set -- --env "$name" "$@"
done
if [ -n "${STELLAOPS_CLI_CA_BUNDLE:-}" ]; then
test -f "$STELLAOPS_CLI_CA_BUNDLE"
set -- --volume "$STELLAOPS_CLI_CA_BUNDLE:/run/stellaops/ca-bundle.pem:ro" --env SSL_CERT_FILE=/run/stellaops/ca-bundle.pem "$@"
fi
if [ -n "${STELLAOPS_CLI_NETWORK:-}" ]; then
set -- --network "$STELLAOPS_CLI_NETWORK" "$@"
fi
exec docker run --rm --pull=never --cap-drop=ALL --security-opt=no-new-privileges \
--user "$(id -u):$(id -g)" --env HOME=/tmp \
--volume "$PWD:/workspace" --workdir /workspace "$@"
STELLA_CLI
chmod +x .stella-ci/bin/stella
export PATH="$PWD/.stella-ci/bin:$PATH"
echo "STELLA_CLI_IMAGE=$STELLA_CLI_IMAGE" >> "$GITHUB_ENV"
echo "$PWD/.stella-ci/bin" >> "$GITHUB_PATH"
- name: Build and Push Container Image
# The scan, the provenance and the gate all name the manifest digest the registry
# returned for this push; a tag never reaches them.
run: |
echo "${{ secrets.STELLAOPS_REGISTRY_PASSWORD }}" \
| docker login "${IMAGE_REPOSITORY%%/*}" -u "${{ secrets.STELLAOPS_REGISTRY_USERNAME }}" --password-stdin
docker build -t "$IMAGE_REPOSITORY:${{ github.sha }}" .
docker push "$IMAGE_REPOSITORY:${{ github.sha }}"
IMAGE_REF=$(docker inspect --format='{{index .RepoDigests 0}}' "$IMAGE_REPOSITORY:${{ github.sha }}")
test -n "$IMAGE_REF"
echo "IMAGE_REF=$IMAGE_REF" >> "$GITHUB_ENV"
echo "IMAGE_DIGEST=${IMAGE_REF#*@}" >> "$GITHUB_ENV"
- name: Record Build Provenance
# Records caller-reported SLSA v1 through Evidence sign/submit/exact export.
# commit/repo/run come from GITHUB_*; the configured builder URI is required.
# --subject is the pushed image digest. --attach additionally publishes an OCI referrer.
run: |
stella attest build --subject "$IMAGE_REF"
- name: Scan Image
id: scan
run: |
stella registry scan "$IMAGE_REF" --wait --json > stellaops-scan.json
SCAN_ID=$(jq -r '.status.scanId' stellaops-scan.json)
stella scan sarif --scan-id "$SCAN_ID" --output results.sarif
- name: Upload SARIF
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: results.sarif
- name: Evaluate Gate
id: gate
# Without --baseline the gate compares against the last approved baseline
# (--baseline last-approved). The process exit code is the verdict: 0 pass, 1 block,
# 10 warn, 2 input error, 11/12/99 operational errors.
run: |
set +e
stella gate evaluate \
--image "$IMAGE_DIGEST" \
--missing-baseline "$STELLAOPS_MISSING_BASELINE" \
--commit "${{ github.sha }}" \
--output json > gate-result.json
EXIT_CODE=$?
set -e
echo "exit_code=$EXIT_CODE" >> "$GITHUB_OUTPUT"
echo "decision_id=$(jq -r '.decisionId // empty' gate-result.json 2>/dev/null)" >> "$GITHUB_OUTPUT"
- name: Gate Summary
if: always() && steps.gate.outputs.decision_id != ''
run: |
echo "## Release Gate Result" >> "$GITHUB_STEP_SUMMARY"
stella gate status \
--decision-id "${{ steps.gate.outputs.decision_id }}" \
--output table >> "$GITHUB_STEP_SUMMARY" || true
- name: Check Gate Status
if: steps.gate.outputs.exit_code != '0'
run: |
echo "::error::Release gate check failed (exit ${{ steps.gate.outputs.exit_code }})"
exit ${{ steps.gate.outputs.exit_code }}
Gitlab
# StellaOps GitLab CI Pipeline
# Generated by: stella ci init --platform gitlab --template gate
# Documentation: https://docs.stellaops.io/ci/gitlab-ci
stages:
- build
- scan
- gate
- deploy
variables:
STELLAOPS_BACKEND_URL: $STELLAOPS_BACKEND_URL
DOCKER_TLS_CERTDIR: "/certs"
# What the gate does while the tenant has no baseline: block, warn, or bootstrap (set it as a
# project or pipeline variable for the first run on a new installation).
STELLAOPS_MISSING_BASELINE: "block"
# The Attestor signing key the build provenance is signed with (`stella attest build`).
STELLAOPS_ATTESTOR_KEY_ID: $STELLAOPS_ATTESTOR_KEY_ID
STELLAOPS_BUILDER_ID: $STELLAOPS_BUILDER_ID # actual reported builder URI
.stellaops-setup:
image: docker:24-dind
services: [docker:24-dind]
before_script:
- apk add --no-cache jq
- |
# Set STELLAOPS_CLI_IMAGE to the qualified registry.stella-ops.org/stella-cli
# release digest, its air-gap mirror, or a qualified preloaded local image ID.
export STELLA_CLI_IMAGE="$STELLAOPS_CLI_IMAGE"
printf '%s\n' "$STELLA_CLI_IMAGE" | grep -Eq '^([A-Za-z0-9][A-Za-z0-9._:/-]*@)?sha256:[0-9a-f]{64}$' || {
echo 'STELLAOPS_CLI_IMAGE must name a qualified sha256 digest' >&2
exit 2
}
docker pull "$STELLA_CLI_IMAGE"
mkdir -p .stella-ci/bin
cat > .stella-ci/bin/stella <<'STELLA_CLI'
#!/bin/sh
set -eu
: "${STELLA_CLI_IMAGE:?Set STELLAOPS_CLI_IMAGE to the release digest}"
set -- "$STELLA_CLI_IMAGE" "$@"
# Forward credentials and CI provenance by variable name; never print values.
for name in $(env | sed -n '/^STELLAOPS_[A-Z0-9_]*=/s/=.*//p; /^GITHUB_[A-Z0-9_]*=/s/=.*//p; /^GITEA_[A-Z0-9_]*=/s/=.*//p; /^CI_[A-Z0-9_]*=/s/=.*//p; /^GIT_[A-Z0-9_]*=/s/=.*//p; /^BUILD_[A-Z0-9_]*=/s/=.*//p'); do
set -- --env "$name" "$@"
done
if [ -n "${STELLAOPS_CLI_CA_BUNDLE:-}" ]; then
test -f "$STELLAOPS_CLI_CA_BUNDLE"
set -- --volume "$STELLAOPS_CLI_CA_BUNDLE:/run/stellaops/ca-bundle.pem:ro" --env SSL_CERT_FILE=/run/stellaops/ca-bundle.pem "$@"
fi
if [ -n "${STELLAOPS_CLI_NETWORK:-}" ]; then
set -- --network "$STELLAOPS_CLI_NETWORK" "$@"
fi
exec docker run --rm --pull=never --cap-drop=ALL --security-opt=no-new-privileges \
--user "$(id -u):$(id -g)" --env HOME=/tmp \
--volume "$PWD:/workspace" --workdir /workspace "$@"
STELLA_CLI
chmod +x .stella-ci/bin/stella
export PATH="$PWD/.stella-ci/bin:$PATH"
build:
stage: build
extends: .stellaops-setup
image: docker:24-dind
services:
- docker:24-dind
script:
# $CI_REGISTRY_IMAGE must be in a registry connected to Stella Ops (`stella registry list`).
- docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA .
- docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA | tee /tmp/push.log
# The pushed manifest digest, read from the docker push output, is what the scan, the
# provenance and the gate name; later jobs receive it through the dotenv report.
- export IMAGE_DIGEST=$(sed -n 's/.*digest: \(sha256:[0-9a-f]*\).*/\1/p' /tmp/push.log | head -n1)
- test -n "$IMAGE_DIGEST"
- echo "IMAGE_DIGEST=$IMAGE_DIGEST" > build.env
# Record signed caller-reported SLSA v1 through Evidence and retain its exact envelope.
# commit/repo/pipeline auto-fill from CI_COMMIT_SHA/CI_PROJECT_URL/CI_PIPELINE_URL.
- stella attest build --subject "$CI_REGISTRY_IMAGE@$IMAGE_DIGEST"
artifacts:
reports:
dotenv: build.env
rules:
- if: $CI_COMMIT_BRANCH == "main" || $CI_MERGE_REQUEST_ID
scan:
stage: scan
extends: .stellaops-setup
script:
- stella registry scan "$CI_REGISTRY_IMAGE@$IMAGE_DIGEST" --wait --json > scan-results.json
- export SCAN_ID=$(jq -r '.status.scanId' scan-results.json)
- stella scan sarif --scan-id "$SCAN_ID" --output scan.sarif
- stella sbom generate --image "$CI_REGISTRY_IMAGE@$IMAGE_DIGEST" --output sbom.cdx.json
- stella sbom upload --file sbom.cdx.json
--artifact "$CI_REGISTRY_IMAGE@$IMAGE_DIGEST"
artifacts:
paths:
- sbom.cdx.json
- scan-results.json
- scan.sarif
rules:
- if: $CI_COMMIT_BRANCH == "main" || $CI_MERGE_REQUEST_ID
gate:
stage: gate
extends: .stellaops-setup
script:
# Without --baseline the gate compares against the last approved baseline
# (--baseline last-approved). The process exit code is the verdict: 0 pass, 1 block,
# 10 warn, 2 input error, 11/12/99 operational errors.
- |
set +e
stella gate evaluate \
--image "$IMAGE_DIGEST" \
--missing-baseline "$STELLAOPS_MISSING_BASELINE" \
--commit "$CI_COMMIT_SHA" \
--output json > gate-result.json
EXIT_CODE=$?
set -e
if [ "$EXIT_CODE" != "0" ]; then
echo "Release gate failed (exit $EXIT_CODE)"
exit $EXIT_CODE
fi
artifacts:
when: always
paths:
- gate-result.json
rules:
- if: $CI_COMMIT_BRANCH == "main"
deploy:
stage: deploy
script:
- echo "Deploy $CI_REGISTRY_IMAGE@$IMAGE_DIGEST"
rules:
- if: $CI_COMMIT_BRANCH == "main"
needs:
- gate
Gitea
# StellaOps Gitea Actions Release Gate Workflow
# Generated by: stella ci init --platform gitea --template gate
# Documentation: https://docs.stellaops.io/ci/gitea-actions
name: StellaOps Release Gate
on:
push:
branches: [main, release/*]
pull_request:
branches: [main]
env:
STELLAOPS_BACKEND_URL: ${{ secrets.STELLAOPS_BACKEND_URL }}
STELLAOPS_CLI_IMAGE: ${{ vars.STELLAOPS_CLI_IMAGE }}
STELLAOPS_AUTHORITY_URL: ${{ secrets.STELLAOPS_AUTHORITY_URL }}
STELLAOPS_AUTHORITY_CLIENT_ID: ${{ secrets.STELLAOPS_AUTHORITY_CLIENT_ID }}
STELLAOPS_AUTHORITY_CLIENT_SECRET: ${{ secrets.STELLAOPS_AUTHORITY_CLIENT_SECRET }}
STELLAOPS_AUTHORITY_TENANT: ${{ vars.STELLAOPS_AUTHORITY_TENANT }}
# A repository in a registry connected to Stella Ops (`stella registry list`), for example
# registry.example.com/team/app. The scan runs server-side and pulls the pushed image from it.
IMAGE_REPOSITORY: ${{ vars.STELLAOPS_IMAGE_REPOSITORY }}
# What the gate does while the tenant has no baseline: block (default), warn, or bootstrap
# (set it for the first run on a new installation to create the tenant's first baseline).
STELLAOPS_MISSING_BASELINE: ${{ vars.STELLAOPS_MISSING_BASELINE || 'block' }}
# The Attestor signing key the build provenance is signed with (`stella attest build`).
STELLAOPS_ATTESTOR_KEY_ID: ${{ vars.STELLAOPS_ATTESTOR_KEY_ID }}
jobs:
gate:
runs-on: ubuntu-latest
env:
STELLAOPS_BUILDER_ID: ${{ vars.STELLAOPS_BUILDER_ID }} # actual reported builder URI
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up StellaOps CLI
run: |
# Set STELLAOPS_CLI_IMAGE to the qualified registry.stella-ops.org/stella-cli
# release digest, its air-gap mirror, or a qualified preloaded local image ID.
export STELLA_CLI_IMAGE="$STELLAOPS_CLI_IMAGE"
printf '%s\n' "$STELLA_CLI_IMAGE" | grep -Eq '^([A-Za-z0-9][A-Za-z0-9._:/-]*@)?sha256:[0-9a-f]{64}$' || {
echo 'STELLAOPS_CLI_IMAGE must name a qualified sha256 digest' >&2
exit 2
}
docker pull "$STELLA_CLI_IMAGE"
mkdir -p .stella-ci/bin
cat > .stella-ci/bin/stella <<'STELLA_CLI'
#!/bin/sh
set -eu
: "${STELLA_CLI_IMAGE:?Set STELLAOPS_CLI_IMAGE to the release digest}"
set -- "$STELLA_CLI_IMAGE" "$@"
# Forward credentials and CI provenance by variable name; never print values.
for name in $(env | sed -n '/^STELLAOPS_[A-Z0-9_]*=/s/=.*//p; /^GITHUB_[A-Z0-9_]*=/s/=.*//p; /^GITEA_[A-Z0-9_]*=/s/=.*//p; /^CI_[A-Z0-9_]*=/s/=.*//p; /^GIT_[A-Z0-9_]*=/s/=.*//p; /^BUILD_[A-Z0-9_]*=/s/=.*//p'); do
set -- --env "$name" "$@"
done
if [ -n "${STELLAOPS_CLI_CA_BUNDLE:-}" ]; then
test -f "$STELLAOPS_CLI_CA_BUNDLE"
set -- --volume "$STELLAOPS_CLI_CA_BUNDLE:/run/stellaops/ca-bundle.pem:ro" --env SSL_CERT_FILE=/run/stellaops/ca-bundle.pem "$@"
fi
if [ -n "${STELLAOPS_CLI_NETWORK:-}" ]; then
set -- --network "$STELLAOPS_CLI_NETWORK" "$@"
fi
exec docker run --rm --pull=never --cap-drop=ALL --security-opt=no-new-privileges \
--user "$(id -u):$(id -g)" --env HOME=/tmp \
--volume "$PWD:/workspace" --workdir /workspace "$@"
STELLA_CLI
chmod +x .stella-ci/bin/stella
export PATH="$PWD/.stella-ci/bin:$PATH"
echo "STELLA_CLI_IMAGE=$STELLA_CLI_IMAGE" >> "$GITHUB_ENV"
echo "$PWD/.stella-ci/bin" >> "$GITHUB_PATH"
- name: Build and Push Image
# The scan, the provenance and the gate all name the manifest digest the registry
# returned for this push; a tag never reaches them.
run: |
echo "${{ secrets.STELLAOPS_REGISTRY_PASSWORD }}" \
| docker login "${IMAGE_REPOSITORY%%/*}" -u "${{ secrets.STELLAOPS_REGISTRY_USERNAME }}" --password-stdin
docker build -t "$IMAGE_REPOSITORY:${{ gitea.sha }}" .
docker push "$IMAGE_REPOSITORY:${{ gitea.sha }}"
IMAGE_REF=$(docker inspect --format='{{index .RepoDigests 0}}' "$IMAGE_REPOSITORY:${{ gitea.sha }}")
test -n "$IMAGE_REF"
echo "IMAGE_REF=$IMAGE_REF" >> "$GITHUB_ENV"
echo "IMAGE_DIGEST=${IMAGE_REF#*@}" >> "$GITHUB_ENV"
- name: Record Build Provenance
# The builder URI and actual commit/repository/run are required.
# If GitHub-compatible runner variables are absent, set STELLAOPS_GIT_COMMIT,
# STELLAOPS_GIT_REPO and STELLAOPS_PIPELINE_REF to the actual execution inputs.
run: |
stella attest build --subject "$IMAGE_REF"
- name: Scan and Gate
# Without --baseline the gate compares against the last approved baseline
# (--baseline last-approved). The process exit code is the verdict: 0 pass, 1 block,
# 10 warn, 2 input error, 11/12/99 operational errors.
run: |
stella registry scan "$IMAGE_REF" --wait --json > stellaops-scan.json
SCAN_ID=$(jq -r '.status.scanId' stellaops-scan.json)
stella scan sarif --scan-id "$SCAN_ID" --output results.sarif
stella gate evaluate \
--image "$IMAGE_DIGEST" \
--missing-baseline "$STELLAOPS_MISSING_BASELINE" \
--commit "${{ gitea.sha }}"
2. CLI Authentication
Authentication is handled by stella auth login. It acquires and caches an access token from the configured StellaOps Authority. The grant used depends on how the CLI profile is configured (see CommandFactory.BuildAuthCommand and CommandHandlers.HandleAuthLoginAsync):
- Automation (CI/CD): configure a confidential OAuth client (client ID + secret, via
stella configorSTELLAOPS_*environment variables);stella auth loginthen performs a client-credentials grant. - Interactive: the seeded human client prompts for username/password.
# Acquire and cache a token (uses the configured client credentials in CI)
stella auth login
# CI/kiosk: keep the token in process memory only (do not write the secrets blob / OS keystore)
stella auth login --no-save # implied when STELLAOPS_LOGIN_NO_SAVE=1 in a non-interactive shell
# Force re-authentication, ignoring any cached token
stella auth login --force
| Command | Description |
|---|---|
stella auth login | Acquire and cache an access token |
stella auth logout | Remove cached tokens for the current credentials |
stella auth status | Display cached token status |
stella auth whoami | Display cached token claims (subject, scopes, expiry) |
CORRECTED: there are no
--token,--oidc, or--keylessflags onstella auth login. The only flags are--forceand--no-save. Generated workflows configure Authority client credentials; no published Stella Ops GitHub authentication action exists. The git provider’s CI token (e.g.CI_JOB_TOKEN) is used by SCM steps, not passed toauth login.
3. Scan Execution
CORRECTED: there is no flat
stellaops scan <image> --policy ... --fail-on violationcommand. Thescancommand is a group (CommandFactory.BuildScanCommand) whose subcommands includerun,upload,sarif,entrytrace,replay,layers,layer-sbom,recipe,diff,delta,verify-patches,download,workers,secrets, andsubmit-list. In CI the scan step emits results (SARIF/JSON/SBOM); the pass/fail decision is a separategate evaluatestep.
The sample
scan imagegroup is retired.stella image inspect <ref> --print-layersreads OCI metadata; it does not submit a scan. The generated templates submit the scan to the backend instead:stella registry scan <ref>submits it for an image in a registry connected to Stella Ops,--waitpolls until it reaches a terminal state, and--jsonwrites the result with the scan id at.status.scanId.stella scan sarif --scan-id <id>then exports that scan as SARIF.
The generated templates run the scan step like this (from CiTemplates.cs):
stella registry scan registry.example.com/myorg/myapp:v1.2.3 --wait --json > stellaops-scan.json
SCAN_ID=$(jq -r '.status.scanId' stellaops-scan.json)
stella scan sarif --scan-id "$SCAN_ID" --output scan-results.sarif
Relevant scan subcommands
| Subcommand | Purpose |
|---|---|
stella scan run | Execute a scanner bundle with the configured runner (--runner dotnet|self|docker, --entry, --target). This is the implemented scanning verb. |
stella scan submit-list | Submit a list of images/artifacts to the backend for scanning |
stella scan upload --file <path> | Upload completed scan results to the backend |
stella scan sarif --scan-id <id> | Export Scanner’s SARIF 2.1.0 document (--output/-o, --pretty); preserve the owner’s metadata |
stella scan replay | Deterministic verdict replay from explicit input hashes (--artifact, --manifest, --feeds, --policy) |
stella image inspect <ref> --print-layers | Pull OCI metadata and layer digests from the registry. The sample scan image group is retired. |
The
scan sarifdestination flags are--output/-o(file, defaults to stdout), with--prettyfor indentation. Severity filtering is not a CLI option.scan runforwardsscanner-argsto the runner rather than taking a--format/--outputpair. Policy evaluation and the deploy gate are applied bystella gate evaluate(below), which takes the policy/baseline — not by thescanstep.
3a. Source-tree SBOM (stella sbom generate --directory)
For a source-side bill of materials — generated from a checked-out working tree before (or instead of) an image build — use the deterministic sbom generate verb (Commands/Sbom/SbomGenerateCommand.cs). Unlike the image-analysis helpers under scan image, this is a real, implemented, reproducible SBOM producer.
# Source-tree SBOM from the checked-out repo (deterministic; offline-friendly)
stella sbom generate \
--directory ./src \
--format cyclonedx \
--output sbom.cdx.json
Flags (verified against SbomGenerateCommand.cs):
| Flag | Purpose |
|---|---|
--directory/-d | Local directory to scan (mutually exclusive with --image/-i). |
--format/-f | cyclonedx, spdx, or both. |
--output/-o | Output file path (or directory when --format both). |
--source-date | Deterministic SBOM timestamp (Unix epoch or ISO-8601). For --directory this is the only timestamp source; defaults to SOURCE_DATE_EPOCH, else 1970-01-01. |
--force | Overwrite an existing output file. |
--show-hash | Print the golden hash after generation (verify with stella sbom hash / sbom verify). |
Because it is timestamp-pinned and deterministic, the same source tree yields a byte-identical SBOM (and golden hash) across machines — suitable as a CI artifact and as a material for build provenance.
3c. Build provenance (stella attest build)
After the image is built (and pushed, so a manifest digest exists), mint build provenance — the commit -> artifact lineage as an in-toto link — and submit it to the Attestor’s links endpoint (POST /api/evidence/v1/attestor/links). The endpoint signs the link and returns the DSSE envelope to the pipeline; it records no Evidence entry and no transparency-log entry for the pushed digest (the command warns that no transparency-log entry was returned). The ci init gate templates (§1) emit this step automatically.
The Console counts CI evidence as received only when Evidence holds a provenance attestation for a release component digest whose statement records the repository, commit and pipeline run (Release Orchestrator GET /api/v1/release-orchestrator/first-delivery/ci-receipt; see the first-delivery API). Because the links endpoint records nothing, a pipeline built from these templates does not yet complete that step; a scan or a commit recorded on the release component does not complete it either.
# commit / repo / pipeline auto-fill from CI env (CI_COMMIT_SHA, CI_PROJECT_URL, CI_PIPELINE_URL,
# GITHUB_SHA, GITHUB_REPOSITORY, GITHUB_RUN_ID, ...). --subject is the artifact digest.
stella attest build --subject registry/app@sha256:<digest>
# Air-gapped / no backend: emit the (clearly-labelled) UNSIGNED predicate locally instead of submitting.
stella attest build --subject registry/app@sha256:<digest> --offline --output provenance.intoto.json
Key flags (verified against Commands/AttestCommandGroup.cs): --subject/-s (required — the artifact ref/digest; --subject-digest supplies the digest when the ref carries none), --commit/-c, --repo/-r, --builder/-b, --pipeline, --offline (emit unsigned, do not submit), --attach (also push the signed DSSE as an OCI referrer), --no-rekor (sign but skip the transparency log). Missing required lineage (subject digest, commit, repo) is refused with an actionable error — no field is fabricated. Verify a minted envelope with stella attest verify --attestation <dsse.json> --key <public.pem> (add --uuid <rekor-uuid> for a live inclusion check; --offline labels inclusion as NOT CHECKED rather than claiming it).
3b. AI Code Guard (optional)
Run AI code guard checks on a change set and emit CI-friendly output (GuardCommandGroup.cs):
stella guard run . \
--policy .stellaops.yml \
--format sarif \
--output guard.sarif
Key options: a positional path argument (defaults to .), --policy/-p, --base/--head (diff analysis), --format/-f (json default, sarif, gitlab, table), --output/-o (defaults to stdout), --confidence (default 0.7), --min-severity (default low), --sealed (deterministic, no network), --categories/-c, --exclude/-e, --server.
CORRECTED: the output flag is
--output/-o, not--out. Exit codes (GuardExitCodes) are:
| Verdict | Exit Code |
|---|---|
| Pass | 0 |
| Pass with warnings | 1 |
| Fail (blocking findings) | 2 |
| Input error | 10 |
| Network error | 11 |
| Analysis error | 12 |
| Unknown error | 99 |
So a blocking verdict exits
2, not1; warnings exit1.
4. Policy Evaluation
Policy engine evaluates findings against CI-specific rules:
# Policy Set: production
version: "stella-dsl@1"
name: production
rules:
- name: block-critical
condition: severity == 'critical' AND vex_status != 'not_affected'
action: FAIL
- name: block-high-unfixed
condition: severity == 'high' AND fixed_version == null
action: FAIL
- name: block-known-exploited
condition: kev == true
action: FAIL
- name: require-sbom
condition: sbom_complete == false
action: FAIL
message: "SBOM must cover all detected packages"
gates:
ci:
max_critical: 0
max_high_unfixed: 0
require_attestation: true
The policy/gate YAML above is illustrative of the policy authoring shape; the authoritative DSL and gate-condition grammar live in the Policy module. The CI step that invokes the gate is
stella gate evaluate(below).
4b. Gate Evaluation (stella gate)
The deploy decision is produced by the gate command group (GateCommandGroup.cs), which calls the Policy Engine’s gate endpoint (POST api/v1/policy/gate/evaluate) and maps the result to a process exit code.
stella gate evaluate \
--image "$IMAGE_DIGEST" \
--baseline last-approved \
--policy <policy-id> \
--branch "$CI_COMMIT_BRANCH" \
--commit "$CI_COMMIT_SHA" \
--pipeline "$CI_PIPELINE_ID" \
--env production \
--output json
| Option | Description |
|---|---|
--image / -i | Image manifest digest to evaluate (sha256: and 64 hex characters). A tag or a name@digest reference exits 2 without calling the gate. Required. |
--baseline / -b | Baseline to compare against: last-approved (the default when omitted), previous-build, production (alias production-deployed), branch-base, or a snapshot (snapshot:<id> or a snapshot GUID). Any other value exits 2: the gate would resolve it as last-approved |
--missing-baseline | What the gate does when no baseline exists: block (the server default), warn, or bootstrap (create the tenant’s first baseline; only without --baseline). Any other value, or bootstrap with --baseline, exits 2 without calling the gate |
--policy / -p | Policy ID to use for evaluation |
--allow-override | Allow override of blocking gates (requires --justification) |
--justification / -j | Justification for override |
--branch, --commit, --pipeline, --env | Git/CI context recorded with the decision |
--output / -o | table (default), json, or exit-code-only |
--timeout | Request timeout in seconds (default 60) |
Sibling subcommands include stella gate score (score-based gating) and stella gate status, which reads a decision back by the decisionId that gate evaluate printed:
stella gate status --decision-id "$DECISION_ID" --output json
gate status calls GET api/v1/policy/gate/decision/{decisionId} (scope policy:read), prints the decision in the --output format (table, json, or exit-code-only), and exits with the decision’s outcome code, as gate evaluate does (§5). The gate keeps a decision for 30 minutes after evaluation; an unknown or expired decision exits 12.
Without --missing-baseline, a tenant with no baseline is denied (missing-baseline, exit 1). A fresh install creates its first baseline once with stella gate evaluate --image <digest> --missing-baseline bootstrap (no --baseline): the gate enrolls the scanned image automatically (a policy run over its scanned components, then the baseline snapshot). In that mode the CLI requests policy:run policy:write instead of policy:run, because the gate refuses initial-baseline enrollment without write authority; a client that may not have policy:write gets no token and the step fails. The first-baseline procedure describes that path, the cases in which it is refused, and the manual fallback (submit a policy run, wait for it to complete, then evaluate with bootstrap). Later stella gate evaluate calls use that baseline.
4c. Template Scaffolding (stella ci)
stella ci (CiCommandGroup.cs) generates and lists CI/CD workflow templates:
# Scaffold a release-gate workflow into the current repo
stella ci init --platform github --template gate
# List the available templates
stella ci list
# Check that a template path exists (it does not parse or check the workflow)
stella ci validate .github/workflows/stellaops-gate.yml
stella ci validate reports success for any existing file or directory; it does not parse the workflow, its stella commands or their options. The generated templates’ commands are checked against the CLI command tree by the CLI test suite (CiTemplatesCommandParseTests), not by this command.
Option (ci init) | Values |
|---|---|
--platform / -p | github, gitlab, gitea, all (required) |
--template / -t | gate (default), scan, verify, full |
--output / -o | Output directory (default: current directory) |
--offline | Generate offline-friendly bundle with pinned digests |
--cli-image | Qualified CLI registry digest or preloaded local sha256 image ID |
--force / -f | Overwrite existing files |
--template selects the workflow steps. Gate templates include build provenance, a scan, and gate evaluation; scan and verify templates provide their respective workflows. full emits the provider’s complete template set.
5. Verdict and Exit Code
stella gate evaluate, stella gate status and stella guard run translate their verdict into a process exit code. The values below are the canonical codes (GateExitCodes / GuardExitCodes; the gate codes follow the CLI-wide convention in exit codes):
| Verdict | gate evaluate / gate status | guard run | Pipeline Result |
|---|---|---|---|
| PASS | 0 | 0 | Continue |
| WARN | 10 | 1 (a warning fails the guard) | Continue or block (pipeline decides; the code is non-zero) |
| FAIL / block | 1 | 1 | Block deployment |
| Input error | 2 | 10 | Invalid parameters |
| Network error | 11 | 11 | Unable to reach gate/scanner service |
| Policy/analysis error | 12 | 12 | Evaluation failed (for gate status, also an unknown or expired decision) |
| Unknown error | 99 | 99 | Unexpected failure |
There is no
--fail-onflag controlling this. Theproof verifycommand uses a different exit-code scheme (see §5a). Pipelines branch on the process exit code. The JSONexitCodefield is the policy engine’s own code (0pass,1warn,2fail) and does not equal the process exit code for a warning or a block.
5a. Attestation / Proof Verification
Sprint: SPRINT_20260112_004_DOC_cicd_gate_verification
Before deploying, pipelines verify the attestation chain and (online) Rekor inclusion. This ensures attestation integrity and a tamper-evident audit trail. Two CLI surfaces apply here:
stella verify image <ref>— verify the attestation chain attached to a container image (VerifyCommandGroup.cs).stella proof verify --bundle <path>— verify an exported attestation bundle’s proof chain (Proof/ProofCommandGroup.cs).
CORRECTED: there is no
stella proof verify --image ... --attestation-type ... --check-rekor --fail-on-missingform, and no--ledger-path. Those flags do not exist.proof verifyoperates on a--bundlefile. Image-level attestation verification isstella verify image. There is nostella evidence-pack verify; theevidence-packverb is an export subcommand (stella export evidence-pack) — bundle verification isstella proof verifyorstella verify bundle.
Verify the attestation chain on an image (online)
stella verify image ghcr.io/org/myapp@sha256:... \
--require sbom vex decision \
--strict \
--output json
stella verify image flags: positional reference; --require/-r (required attestation types — defaults to sbom vex decision; also approval); --trust-policy (path to a YAML/JSON trust policy); --output/-o (table/json/sarif); --strict (fail if any required attestation is missing); --allow-unsigned (opt out of the trust-policy gate with a WARN — see below).
Honest default (SPRINT_20260704_001 / CAT-3): with no
--trust-policyconfigured,verify imagenow fails closed with an explicitNo trust policy configuredmessage instead of silently passing — signer trust cannot be enforced without a policy. Pass--allow-unsignedto explicitly proceed (attestations are structurally parsed but signer trust is NOT enforced; emitted with a[WARN]). The image transparency signal is annotation-presence only and is reported as such (transparency: presence-only), never as a verified inclusion proof.
The generated
verifyworkflow templates (stella ci init --template verify,CiTemplates.cs) runstella verify image <ref> --require sbom --strict.--require/-ris a single repeatable option that takes attestation-type values (sbom,vex,decision,approval); widen it to the attestations your pipeline produces.
Verify an exported bundle (offline / air-gapped)
# proof verify operates on an exported attestation bundle (.tar.gz)
stella proof verify \
--bundle /path/to/attestation-bundle.tar.gz \
--offline \
--output json
# E2E evidence bundle (replay-checked)
stella verify bundle --bundle /path/to/evidence-pack.tar.gz --output json
stella proof verify flags: --bundle/-b (required, path to .tar.gz); --offline (skip Rekor / transparency verification); --output/-o (text default, or json); --verbose. (proof also exposes proof spine show <bundle-id>, but spine retrieval is not yet implemented — it prints a placeholder.)
CORRECTED: the exit codes that
stella proof verifyactually returns come fromAttestationBundleExitCodes(Services/Models/AttestationBundleModels.cs), surfaced viaresult.ExitCodeinProofCommandGroup.HandleVerifyAsync. TheProofExitCodestype (Proof/ProofExitCodes.cs) defines the advisory §15.2 scheme but the verify handler only uses it for the catch-allSystemError(exception path); a missing bundle returnsAttestationBundleExitCodes.FileNotFound(6), notProofExitCodes.KeyRevoked. The two schemes diverge at codes 2–8, so trust the runtime (AttestationBundleExitCodes) table below, not the advisoryProofExitCodesnumbering.
stella proof verify runtime exit codes (AttestationBundleExitCodes):
| Code | Meaning |
|---|---|
| 0 | Success — bundle verified |
| 1 | General verification failure |
| 2 | Checksum mismatch (bundle integrity) |
| 3 | Signature failure (DSSE envelope) |
| 4 | Missing transparency / Rekor inclusion (online mode) |
| 5 | Format error (malformed bundle) |
| 6 | Bundle file not found |
| 7 | Import failed |
For reference, the advisory
ProofExitCodesscheme (defined but largely unused by the verify handler) is: 0 Success · 1 PolicyViolation · 2 SystemError · 3 VerificationFailed · 4 TrustAnchorError · 5 RekorVerificationFailed · 6 KeyRevoked · 7 OfflineModeError · 8 InputError. Do not rely on this numbering forproof verifybranching today.
GitHub Actions Integration
- name: Verify attestation chain
run: |
stella verify image ghcr.io/org/myapp@${{ steps.build.outputs.digest }} \
--require sbom vex decision \
--strict
- name: Push to registry (only if verified)
if: success()
run: |
docker push ghcr.io/org/myapp:${{ github.sha }}
NOT IMPLEMENTED / external: the cosign
verify-attestationexamples previously shown here are upstream Sigstore commands, not StellaOps CLI commands, and the--type https://stellaops.io/attestation/...predicate URIs were illustrative. Usestella verify image/stella proof verifyfor the in-product path. If you verify with cosign directly, consult cosign’s own documentation for current flags.
Related Documentation:
6. SARIF Integration
CLI outputs SARIF for IDE and GitHub integration:
{
"$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json",
"version": "2.1.0",
"runs": [
{
"tool": {
"driver": {
"name": "StellaOps",
"version": "2.1.0",
"informationUri": "https://stellaops.io"
}
},
"results": [
{
"ruleId": "CVE-2024-1234",
"level": "error",
"message": {
"text": "Critical vulnerability in lodash@4.17.20"
},
"locations": [
{
"physicalLocation": {
"artifactLocation": {
"uri": "package-lock.json"
}
}
}
]
}
]
}
]
}
7. Attestation Verification
CORRECTED: there is no top-level
stellaops attestationcommand and noattestation show --scanverb. DSSE attestations are inspected and verified through theattestgroup (AttestCommandGroup, wired viaCommandFactory.BuildAttestCommand) and the unifiedverifygroup (VerifyCommandGroup).
The live root stella attest verify (CommandFactory.BuildAttestCommand → HandleAttestVerifyAsync) is envelope-anchored: it takes --envelope/-e (required, path to a DSSE envelope file), --policy (JSON policy: required predicate types / minimum signatures / required signers), --root (an EC/RSA public key or X.509 certificate PEM), --transparency-checkpoint, --require-timestamp, --max-skew, --output/-o, --format/-f (table/json), and --explain.
Real signature check (SPRINT_20260704_001 / CAT-2): the
Signature Verificationstep is now a real cryptographic DSSE check over PAE(payloadType, payload) using the public key in--root— it passes only when a signature actually verifies. (It was previously a placeholder that passed merely because a--rootfile existed.) A tampered payload, a wrong key, or an unsigned envelope fails. The transparency step in this online mode is presence-only and is reported as such — usestella attest verify-offlinefor a real inclusion proof.
The image-anchored path is stella verify image <ref> / stella verify attestation --image <ref> (real ImageAttestationVerifier; §5a — fails closed without a trust policy). The attest group’s own client-side DSSE checker also accepts a raw envelope + public key (stella attest verify --attestation <dsse.json> --key <public.pem> [--uuid <rekor>] [--offline]).
# Verify a DSSE envelope's signature (+ policy / timestamp) against trust material
stella attest verify \
--envelope provenance.intoto.json \
--root cosign.pub \
--require-timestamp \
--format json
# Image-anchored: verify the attestations attached to an OCI image
stella verify attestation --image ghcr.io/org/myapp@sha256:... --trust-policy trust-policy.yaml
# Offline / air-gapped: verify an exported evidence bundle (no network)
stella attest verify-offline --bundle /path/to/attestation-bundle.tar.gz --trust-root ./trust-root
8. PR/MR Comment and Status Integration
QUARANTINED — not available in this release (PLAN_20260704 finding A5, decision D3 — quarantine, not delete; see the evidence-moat program plan “Explicitly deferred / cut” list). The outbound SCM-feedback path (the
stella githubcommand group and the GitHub/GitLab SCM annotation clients) is built but not wired: noIGitHubCodeScanningClientis DI-registered, so everystella github *verb now exits with a cleannot available in this releaseerror (exit code 1, no stack trace). The verb code and the annotation clients are preserved for a future SCM-feedback initiative. The sanctioned scan ingress and feedback path is CLI-in-CI: emit SARIF with the scanner and, on GitHub, upload it with GitHub’s owngithub/codeql-action/upload-sarifaction (see §“CI/CD Pipeline Integration” above). Treat the command table and example below as a design reference, not a runnable path.
StellaOps historically surfaced scan results in the SCM platform via the github command group (GitHubCommandGroup.cs): uploading SARIF to GitHub Code Scanning and managing the resulting code-scanning alerts. The inline --pr-comment / --check-run flags described historically below are roadmap and are not wired on any scan/gate command today.
CORRECTED: the
githubgroup does NOT update commit status, and there is no commit-status command. Its only five subcommands (GitHubCommandGroup.cs) areupload-sarif,list-alerts,get-alert,update-alert, andupload-status— andupload-statuschecks the processing status of a prior SARIF upload (it takes a<sarif-id>argument), it does not write a commit status.
GitHub Integration (QUARANTINED — verbs exit not available in this release)
The github command shapes (design reference; not runnable in this release) are:
| Command | Purpose |
|---|---|
stella github upload-sarif <file> --repo owner/repo | Upload SARIF to GitHub Code Scanning (--ref, --sha, --wait/-w, --timeout/-t, --tool-name, --github-url) |
stella github list-alerts --repo owner/repo | List code-scanning alerts |
stella github get-alert --repo owner/repo | Inspect a single alert |
stella github update-alert --repo owner/repo | Update a code-scanning alert state (dismiss/resolve) |
stella github upload-status <sarif-id> --repo owner/repo | Check the processing status of a prior SARIF upload (NOT a commit-status writer) |
# GitHub Actions: emit SARIF with the scanner, then upload with GitHub's own action.
# (The quarantined `stella github upload-sarif` push is NOT used here.)
- name: Scan and emit SARIF
run: |
stella registry scan "registry.example.com/myapp:${{ github.sha }}" --wait --json > stellaops-scan.json
stella scan sarif --scan-id "$(jq -r '.status.scanId' stellaops-scan.json)" --output results.sarif
- name: Upload SARIF
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: results.sarif
ORPHANED (Draft / roadmap): the rich inline-comment surface below —
--pr-comment,--mr-comment,--check-run,--commit-status,--update-existing,--collapse-details,--evidence-link,--github-token,--gitlab-token— does not exist on any current CLI command (the only reference in source is the CLITASKS.mdbacklog). It is retained here as a design target for the comment/annotation format; do not treat the flags as runnable.
# DRAFT (roadmap) — flags below are not implemented
- name: Scan with PR feedback
run: |
stellaops scan myapp:${{ github.sha }} \
--policy production \
--pr-comment \
--check-run \
--github-token ${{ secrets.GITHUB_TOKEN }}
Example PR comment format:
## StellaOps Scan Results
**Verdict:** :warning: WARN
| Severity | Count |
|----------|-------|
| Critical | 0 |
| High | 2 |
| Medium | 5 |
| Low | 12 |
### Findings Requiring Attention
| CVE | Severity | Package | Status |
|-----|----------|---------|--------|
| CVE-2026-1234 | High | lodash@4.17.21 | Fix available: 4.17.22 |
| CVE-2026-5678 | High | express@4.18.0 | VEX: Not affected |
<details>
<summary>View full report</summary>
[Download SARIF](https://stellaops.example.com/scans/abc123/sarif)
[View in Console](https://stellaops.example.com/scans/abc123)
</details>
---
*Scan ID: abc123 | Policy: production | [Evidence](https://stellaops.example.com/evidence/abc123)*
GitLab MR Integration (Draft / roadmap)
ORPHANED (Draft / roadmap): GitLab MR note/discussion posting via scan flags is not implemented. The block below is a design target, not a runnable command.
# DRAFT (roadmap) — flags below are not implemented
scan:
stage: test
script:
- stellaops scan $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA \
--policy production \
--mr-comment \
--commit-status \
--gitlab-token $CI_JOB_TOKEN
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
Comment Behavior Options (Draft / roadmap)
ORPHANED (Draft / roadmap): none of the options below are implemented on any current CLI command; they describe the intended comment-posting behavior.
| Option | Description | Default |
|---|---|---|
--pr-comment / --mr-comment | Post summary comment | false |
--check-run | Create GitHub check run with annotations | false |
--commit-status | Update commit status | false |
--update-existing | Edit previous comment instead of new | true |
--collapse-details | Use collapsible sections for long output | true |
--evidence-link | Include link to evidence bundle | true |
Evidence Anchoring in Comments
Comments include evidence references for auditability:
- Scan ID: Unique identifier for the scan
- Policy Version: The policy version used for evaluation
- Attestation Digest: DSSE envelope digest for signed results
- Rekor Entry: Log index when transparency logging is enabled
Error Handling
| Scenario | Behavior |
|---|---|
| No SCM token | Skip comment, log warning |
| API rate limit | Retry with backoff, then skip |
| Comment too long | Truncate with link to full report |
| PR already merged | Skip comment |
Evidence-First Annotation Format
PR/MR comments use ASCII-only output for determinism and maximum compatibility:
## StellaOps Security Scan
**Verdict:** [BLOCKING] Policy violation detected
| Status | Finding | Package | Action |
| --- | --- | --- | --- |
| [+] New | CVE-2026-1234 | lodash@4.17.21 | Fix: 4.17.22 |
| [-] Fixed | CVE-2025-9999 | express@4.17.0 | Resolved |
| [^] Upgraded | CVE-2026-5678 | axios@1.0.0 | High -> Medium |
| [v] Downgraded | CVE-2026-4321 | react@18.0.0 | Medium -> Low |
### Evidence
| Field | Value |
| --- | --- |
| Attestation Digest | sha256:abc123... |
| Policy Verdict | FAIL |
| Verify Command | `stella verify image <ref> --strict` |
---
*[OK] 12 findings unchanged | Policy: production v2.1.0*
ASCII Indicator Reference:
| Indicator | Meaning |
|---|---|
[OK] | Pass / Success |
[BLOCKING] | Fail / Hard gate triggered |
[WARNING] | Soft gate / Advisory |
[+] | New finding introduced |
[-] | Finding fixed / removed |
[^] | Severity upgraded |
[v] | Severity downgraded |
Offline Mode
In air-gapped environments:
- Comments are queued locally
- Export comment payload for manual posting
- Generate markdown file for offline review
The DSSE envelope shape and verification details belong to the Attestor module; the example envelope below illustrates the stored signed result that
stella attest verify/stella verify attestationvalidate.
Attestation is stored as a DSSE envelope:
{
"payloadType": "application/vnd.in-toto+json",
"payload": "eyJfdHlwZSI6Imh0dHBzOi8vaW4tdG90by5pby9TdGF0ZW1lbnQvdjEi...",
"signatures": [
{
"keyid": "sha256:abc123...",
"sig": "MEQCI..."
}
]
}
Gate Behaviors
Soft Gate (Warning Only)
# .stellaops.yaml
gates:
ci:
mode: soft # Report but don't fail
notify:
- slack://security-channel
Hard Gate (Blocking)
gates:
ci:
mode: hard # Fail pipeline on violations
exceptions:
- CVE-2024-9999 # Known false positive
Progressive Gate
gates:
ci:
mode: progressive
thresholds:
- branch: main
max_critical: 0
max_high: 5
- branch: develop
max_critical: 2
max_high: 20
- branch: feature/*
mode: soft # Warn only on feature branches
Data Contracts
Gate Evaluate Output (gate evaluate --output json)
This is the JSON gate evaluate --output json and gate status --output json print: the policy engine’s response, shape from GateEvaluateResponse in GateCommandGroup.cs. The pipeline verdict is the CLI’s process exit code (§5); the exitCode field is the policy engine’s own code and differs from it for a warning (1 here, exit 10) and a block (2 here, exit 1). Templates read decisionId from it for gate status.
interface GateEvaluateResponse {
decisionId: string;
status: 0 | 1 | 2; // 0 = Pass, 1 = Warn, 2 = Fail (serialized as a number)
exitCode: number; // policy engine code: 0 = Pass, 1 = Warn, 2 = Fail
imageDigest: string;
baselineRef?: string;
decidedAt: string; // ISO-8601
summary?: string;
advisory?: string;
gates?: Array<{
name: string;
result: string; // pass | warn | fail | block
reason: string;
note?: string;
condition?: string;
}>;
blockedBy?: string;
blockReason?: string;
suggestion?: string;
overrideApplied: boolean;
deltaSummary?: { added: number; removed: number; unchanged: number };
}
CLI Scan Output Schema (illustrative)
The schema below is an illustrative shape for scan output; it does not correspond to a single CLI command’s contract (the
scangroup emits SARIF/JSON/SBOM via subcommands, not one fixed envelope). TreatGateEvaluateResponseabove as the load-bearing CI contract.
interface CliScanOutput {
scan_id: string;
image: string;
digest: string;
verdict: 'PASS' | 'WARN' | 'FAIL';
confidence: number;
summary: {
critical: number;
high: number;
medium: number;
low: number;
};
violations: Array<{
cve: string;
severity: string;
package: string;
rule: string;
message: string;
}>;
attestation?: {
digest: string;
rekor_log_index?: number;
};
timing: {
queued_ms: number;
scan_ms: number;
policy_ms: number;
total_ms: number;
};
}
Error Handling
Error/exit codes follow each command’s own scheme (see §5 for gate/guard, §5a for proof). For the gate/guard groups, common conditions are:
| Error | Exit Code | Recovery |
|---|---|---|
| Input error (invalid parameters) | 2 (gate), 10 (guard) | Check arguments (e.g. --image digest, --justification when overriding) |
| Network error (gate/scanner unreachable) | 11 | Check connectivity / backend URL; retry with --timeout |
| Policy / analysis error | 12 | Inspect policy/baseline; check service logs |
| Unknown error | 99 | Re-run with --verbose; inspect logs |
There is no uniform “exit 2 for everything” scheme.
gateuses0pass,1block,2input error,10warn and11/12/99for the other error classes;guarduses0/1for its verdict and10/11/12/99for error classes;proof verifyuses the0–8scheme in §5a.
Observability
Metrics
The CLI emits OpenTelemetry metrics under the stellaops.cli.* namespace (CliMetrics.cs). Relevant counters/histograms:
| Metric | Type | Notes |
|---|---|---|
stellaops.cli.scan.run.count | Counter | scan run invocations |
stellaops.cli.command.duration.ms | Histogram | Per-command duration (tagged with command name) |
stellaops.cli.attest.verify.count | Counter | attest verify invocations |
stellaops.cli.scanner.download.count | Counter | Scanner-bundle downloads |
CORRECTED: the previously listed
cli_scan_total,cli_scan_duration_seconds, andcli_gate_blocked_totalare not emitted by the CLI; the canonical names use thestellaops.cli.prefix.
CI/CD Annotations
GitHub Actions annotations:
::error file=package-lock.json::CVE-2024-1234: Critical vulnerability in lodash@4.17.20
::warning file=Dockerfile::Using outdated base image
Related Flows
- Scan Submission Flow - Underlying scan mechanics
- Policy Evaluation Flow - Policy rule details
- Binary Delta Attestation Flow - Attestation details
