OSPulse

Scan in CI/CD

Run an OSPulse scan as part of your pipeline, wait for it, read the policy violations it produced, and fail the build when they cross a threshold you set. This guide gives one gate script and four complete, copy-pasteable pipeline configurations that call it.

The CLI is the shorter path

Most teams should use the OSPulse CLI, which wraps everything below in a single command and handles polling, retries and exit codes for you — see CLI. Use the raw API approach on this page when you need custom thresholds, a runner image you cannot add tools to, or logic the CLI does not cover.

How the gate works

There is no special "CI endpoint". A pipeline gate is four ordinary API calls:

  1. POST /api/v1/repositories/{repositoryId}/scan — queue a scan, get back a scanId.
  2. GET /api/v1/scans/{scanId} — poll until the scan leaves Queued/Running.
  3. GET /api/v1/policy-violations — read what the scan flagged.
  4. Compare against your threshold, and exit 1 if it is breached.

The scan is evaluated against the policies attached to that repository. The pipeline does not decide what counts as a violation — policies do. Your pipeline decides only how many, and how severe, is too many.

Scans are repository-scoped, not commit-scoped

OSPulse scans the repository as OSPulse knows it, from the manifests on the branch it tracks. It is not a per-commit diff. Use this gate on your default branch and on release pipelines; on a short-lived feature branch it tells you about the branch you merged from, not the change in front of you.

Store the token as a CI secret

The gate needs a token that can trigger a scan and read scans and policy violations. Create a dedicated one per pipeline, with an expiry, so you can revoke it without disrupting anyone — a CI token that never expires is a credential you will forget you issued.

bash
curl -X POST https://api.ospulse.app/api/v1/api-tokens \
  -H "Authorization: Bearer $OSPULSE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "name": "ci-checkout-api", "expiresAtUtc": "2027-07-28T00:00:00Z" }'

The value is returned once. Store it immediately as a GitHub Actions repository secret, a masked and protected GitLab CI/CD variable, or an Azure Pipelines secret variable — named OSPULSE_TOKEN in each case.

The gate script

One script, no dependencies beyond curl and jq, used unchanged by every pipeline below. It polls with a hard timeout, filters violations to the repository being built, and returns a distinct exit code per failure mode so the pipeline can react intelligently.

bash
#!/usr/bin/env bash
# OSPulse CI gate. Requires: curl, jq, $OSPULSE_TOKEN, $OSPULSE_REPOSITORY_ID
set -uo pipefail
 
API="${OSPULSE_API:-https://api.ospulse.app/api/v1}"
REPO_ID="${OSPULSE_REPOSITORY_ID:?set OSPULSE_REPOSITORY_ID}"
FAIL_ON="${OSPULSE_FAIL_ON:-Critical}"        # Critical | High | Medium | Low
MAX_VIOLATIONS="${OSPULSE_MAX_VIOLATIONS:-0}" # allowed at or above FAIL_ON
TIMEOUT_SECONDS="${OSPULSE_TIMEOUT:-900}"
POLL_SECONDS="${OSPULSE_POLL_INTERVAL:-10}"
WARN_ONLY="${OSPULSE_WARN_ONLY:-false}"
 
auth=(-H "Authorization: Bearer ${OSPULSE_TOKEN:?set OSPULSE_TOKEN}")
 
api() { curl -fsS --retry 3 --retry-connrefused --max-time 60 "${auth[@]}" "$@"; }
 
# --- 1. queue the scan -------------------------------------------------------
queued=$(api -X POST "$API/repositories/$REPO_ID/scan") || {
  echo "::error:: could not queue scan"; exit 3; }
 
scan_id=$(jq -r '.scanId' <<<"$queued")
echo "queued scan $scan_id"
 
# --- 2. poll until it leaves a running state ---------------------------------
deadline=$(( SECONDS + TIMEOUT_SECONDS ))
state="Queued"
 
while [[ "$state" == "Queued" || "$state" == "Running" ]]; do
  if (( SECONDS >= deadline )); then
    echo "::error:: scan $scan_id still $state after ${TIMEOUT_SECONDS}s"
    exit 4
  fi
  sleep "$POLL_SECONDS"
  detail=$(api "$API/scans/$scan_id") || continue
  state=$(jq -r '.state' <<<"$detail")
  printf 'scan %s: %s (%ss elapsed)\n' "$scan_id" "$state" "$SECONDS"
done
 
if [[ "$state" != "Completed" ]]; then
  echo "::error:: scan finished as $state: $(jq -r '.failureReason // "no reason given"' <<<"$detail")"
  exit 4
fi
 
echo "scored $(jq -r '.packagesScored' <<<"$detail") packages"
 
# --- 3. read violations for this repository ----------------------------------
violations=$(api "$API/policy-violations") || { echo "::error:: could not read violations"; exit 3; }
 
rank() { case "$1" in Critical) echo 4;; High) echo 3;; Medium) echo 2;; *) echo 1;; esac; }
floor=$(rank "$FAIL_ON")
 
blocking=$(jq --arg repo "$REPO_ID" --argjson floor "$floor" '
  [ .items[]
    | select(.repositoryId == $repo)
    | select(.status != "Resolved" and .status != "Excepted")
    | . + { rank: ({Critical:4, High:3, Medium:2}[.severity] // 1) }
    | select(.rank >= $floor) ]' <<<"$violations")
 
count=$(jq 'length' <<<"$blocking")
jq -r '.[] | "  \(.severity)\t\(.violationId)\tdetected \(.detectedAtUtc)"' <<<"$blocking"
echo "$count violation(s) at or above $FAIL_ON (allowed: $MAX_VIOLATIONS)"
 
# --- 4. decide ---------------------------------------------------------------
if (( count > MAX_VIOLATIONS )); then
  if [[ "$WARN_ONLY" == "true" ]]; then
    echo "::warning:: gate would have failed — running in warn mode"
    exit 0
  fi
  exit 1
fi
 
echo "OSPulse gate passed"
Exit codeMeaningSuggested pipeline behaviour
0Gate passed, or warn modeContinue
1Threshold breachedFail the build
3API or auth errorFail loudly — do not treat as a pass
4Scan failed or timed outRetry once, then fail

Separating "risk found" from "OSPulse unreachable" matters. A gate that silently passes when the API is down is not a gate.

Roll it out in warn mode first

Never turn a new gate straight to blocking. You do not yet know how many violations your repository already carries, and a first run that fails every build teaches your team to route around the gate.

Run it non-blocking for a week

Set OSPULSE_WARN_ONLY=true. The script still reports everything it would have blocked, but always exits 0. Read the output and count what you are actually carrying.

Fix or except the existing backlog

Anything you accept for now should be an explicit, expiring exception rather than something tolerated by a loose threshold — the gate script already skips Excepted violations:

bash
curl -X POST https://api.ospulse.app/api/v1/policy-violations/9a3c.../exception \
  -H "Authorization: Bearer $OSPULSE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
        "reason": "Replacing transitive dep in Q4; compensating WAF rule in place",
        "expiresAtUtc": "2026-10-31T00:00:00Z",
        "evidenceAttachmentBlobUri": null
      }'

Enforce at Critical, then tighten

Drop OSPULSE_WARN_ONLY and keep OSPULSE_FAIL_ON=Critical with OSPULSE_MAX_VIOLATIONS=0 — that blocks the cases nobody argues about. Move to High once the Critical gate has been green for a few weeks. Tightening a quiet gate is easy; loosening a noisy one after everyone has learned to ignore it is not.

Pipeline configurations

Each of these checks out the repository, makes OSPULSE_TOKEN available, and runs the gate script. Store OSPULSE_REPOSITORY_ID as a plain variable — it is not a secret. Find it with GET /api/v1/repositories (see Import a repository).

yaml
# .github/workflows/ospulse-gate.yml
name: OSPulse dependency gate
 
on:
  push:
    branches: [main]
  schedule:
    - cron: '0 6 * * 1'
  workflow_dispatch:
 
permissions:
  contents: read
 
jobs:
  gate:
    runs-on: ubuntu-latest
    timeout-minutes: 20
    env:
      OSPULSE_TOKEN: ${{ secrets.OSPULSE_TOKEN }}
      OSPULSE_REPOSITORY_ID: ${{ vars.OSPULSE_REPOSITORY_ID }}
      OSPULSE_FAIL_ON: Critical
      OSPULSE_MAX_VIOLATIONS: '0'
      # Flip to 'false' once the backlog is clear.
      OSPULSE_WARN_ONLY: 'true'
    steps:
      - uses: actions/checkout@v4
 
      - name: Run OSPulse gate
        run: |
          chmod +x scripts/ospulse-gate.sh
          ./scripts/ospulse-gate.sh | tee ospulse-gate.log
 
      - name: Upload gate log
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: ospulse-gate-log
          path: ospulse-gate.log

The API calls in detail

Queue a scan

bash
curl -X POST https://api.ospulse.app/api/v1/repositories/b28f4d51-77c0-4f2a-9d16-3ea5c7180b44/scan \
  -H "Authorization: Bearer $OSPULSE_TOKEN"
json
{
  "scanId": "1f7b3d90-42ac-4d7e-9b55-c0a91e2f6d38",
  "repositoryId": "b28f4d51-77c0-4f2a-9d16-3ea5c7180b44",
  "state": "Queued",
  "queuedAtUtc": "2026-07-28T10:31:12Z"
}

A 400 here usually means the repository has no scannable manifest; a 404 means the id does not belong to your tenant.

Poll for completion

GET /api/v1/scans/{scanId} returns the scan's current state. Poll on a fixed interval rather than as fast as you can — the gate script's default of ten seconds is a sensible balance.

json
{
  "scanId": "1f7b3d90-42ac-4d7e-9b55-c0a91e2f6d38",
  "repositoryId": "b28f4d51-77c0-4f2a-9d16-3ea5c7180b44",
  "repositoryFullName": "acme-engineering/checkout-api",
  "state": "Completed",
  "trigger": "Api",
  "queuedAtUtc": "2026-07-28T10:31:12Z",
  "startedAtUtc": "2026-07-28T10:31:20Z",
  "completedAtUtc": "2026-07-28T10:33:04Z",
  "packagesScored": 612,
  "resultSummary": "612 packages scored, 3 policy violations raised",
  "failureReason": null
}

If a scan stalls, GET /api/v1/scans/{scanId}/logs returns its audit trail, and POST /api/v1/scans/{scanId}/cancel stops it.

Read the violations

GET /api/v1/policy-violations returns the tenant-wide violation list:

json
{
  "items": [
    {
      "violationId": "9a3c51e8-0d47-4b2a-8f16-24e7c9a0b153",
      "policyId": "3d90f2c1-6b84-4a15-9e0d-77bc31a5e402",
      "repositoryId": "b28f4d51-77c0-4f2a-9d16-3ea5c7180b44",
      "severity": "Critical",
      "status": "Open",
      "detectedAtUtc": "2026-07-28T10:33:01Z"
    }
  ],
  "totalEstimate": 3
}

The list is tenant-wide, so filter to your repositoryId in the client — that is exactly what the jq expression in the gate script does. For the evidence behind a single violation, including recommendedAction, fetch GET /api/v1/policy-violations/{violationId}.

For a one-line risk snapshot in the build log — dependency count, overall health score and a breakdown by risk level — add a call to GET /api/v1/repositories/{repositoryId}/risk-summary. It gives reviewers context without them opening the app.

Common pitfalls

Treating an API failure as a pass. set -e plus an unchecked curl will happily exit 0 on an empty response. Keep the distinct exit codes, and never wrap the gate in || true.

Polling without a timeout. A scan that never terminates will hold a runner until your CI platform kills the job — often at a much longer timeout than you expected, on a paid minute. The gate script's OSPULSE_TIMEOUT bounds it.

Gating on feature branches. Because scans are repository-scoped, a feature-branch gate reports the state of the tracked branch and produces confusing red builds. Gate the default branch and release pipelines.

Setting OSPULSE_MAX_VIOLATIONS above zero permanently. A non-zero allowance hides new violations behind old ones — three permitted violations means your fourth, brand new Critical is the one that fails the build, weeks late. Use expiring exceptions instead.

One shared token across every pipeline. Rotation then means coordinating every team at once. One token per pipeline, each with an expiry. Keep the gate output as a build artifact so the correlationId on any error response is still there when support needs it days later.

Next steps