Vandorisk API

Two endpoints, meant for one job: screen an SBOM from your build pipeline, write the result into the product’s technical file as dated evidence, and let the build fail when a high-severity finding has nobody’s name against it.

The API runs the same screening pipeline as the upload button in the assessment wizard, writes the same evidence line to Annex I Part I(2), and stores the same scan record. A pipeline scan and a hand upload mean the same thing in the file — that is the point of having the API at all.

Base URL: https://www.vandorisk.com

Authentication

Create a key on the API keys page. It looks like vrk_live_… and is shown once, because only its SHA-256 hash is stored. Send it on every request:

Authorization: Bearer vrk_live_xxxxxxxxxxxxxxxxxxxxxxxx

A key belongs to one workspace. Every request is scoped to that workspace: an assessment id belonging to anyone else returns 404, which does not confirm that the id exists.

The key is the only credential these routes accept; cookies are ignored. Keep it in your CI secret store, never in the repository. Revoking a key on the keys page stops it authenticating immediately.

Every key expires. You choose how long it lasts when you create it, up to a year; the keys page shows the date. Create its replacement and update the secret before then.

A key acts with the authority of the owner who created it. It stops working when that person leaves the workspace or stops being an owner, and while the workspace requires two-factor authentication that they have not set up. Create pipeline keys from an account that will stay.

Scopes

A key carries only what you tick. A scan key that cannot read assessments is a smaller blast radius if the runner leaks it. It is also never told when an SBOM names a different product than the assessment (see warning in the scan’s response below), so give the key assessments:read as well if you want the pipeline to catch a wrong assessment id.

ScopeGrants
sbom:scanPOST /api/v1/scan
assessments:readGET /api/v1/assessments/{id}

A request with a valid key but the wrong scope returns 403 and names the scope it needed.

Rate limits and body limits

POST /api/v1/scan

Scope sbom:scan. Body: { assessmentId, sbom } where sbom is a parsed CycloneDX 1.4–1.6 or SPDX 2.2/2.3 JSON document (the object itself, not a string).

Each call screens the components and stores an SbomScan record. It also appends a dated evidence line to requirement I.2 of the assessment, unless it repeats the latest scan line there (see evidenceLineAdded).

curl -sS -X POST https://www.vandorisk.com/api/v1/scan \
  -H "Authorization: Bearer $VANDORISK_API_KEY" \
  -H "Content-Type: application/json" \
  -d "{\"assessmentId\":\"$ASSESSMENT_ID\",\"sbom\":$(cat sbom.cdx.json)}"

Response

{
  "scan": {
    "id": "clx…",
    "createdAt": "2026-08-21T09:14:02.114Z",
    "assessmentId": "clx…",
    "format": "cyclonedx",
    "componentCount": 412,
    "screenedCount": 361,
    "notScreenableCount": 51,
    "unscannedCount": 0,
    "findings": [
      { "id": "CVE-2021-23337", "severity": "HIGH", "score": 7.2, "component": "lodash" }
    ],
    "findingsTruncated": false,
    "evidenceLine": "SBOM scanned 2026-08-21: 412 components, 361 screened, 1 with known vulnerabilities (OSV), highest: CVE-2021-23337 (HIGH 7.2, lodash), 51 not screenable by OSV (busybox, …)",
    "evidenceLineAdded": true,
    "kevChecked": true,
    "enrichmentComplete": true,
    "unratedCount": 0
  },
  "gate": {
    "total": 1,
    "high": 1,
    "worst": { "id": "CVE-2021-23337", "severity": "HIGH", "score": 7.2, "component": "lodash" },
    "untriagedHigh": 1,
    "kev": 0,
    "unrated": 0
  }
}

notScreenableCount is not a failure and not a pass: those components carry no purl of a type OSV indexes, so OSV was never asked about them. An empty answer for them would be a false “clean”, so they are counted separately and named in the evidence line. Check EUVD or NVD for them by hand.

enrichmentComplete is false when the details of some advisories could not be fetched in time. Those findings carry "unrated": true, with severity and score set to null because they are unknown, not because they are low. They could not be checked against the known-exploited catalogue either. unratedCount says how many there are; re-running the scan usually rates them.

unscannedCount above zero means the screen did not finish for those components. An incomplete screen is not a pass: it does not clear findings an earlier complete screen reported, and the example below fails the build on it. Re-run to complete it.

For a key that also holds assessments:read, warning is present when the SBOM names a different product than the assessment’s, and names both. The scan still ran and was recorded against that assessment, so check the assessment id your pipeline sends. The example below prints it as a warning. A key without assessments:read gets an empty warning on every scan, whatever the SBOM names. Either way, the assessment’s SBOM panel shows people in your workspace each scan whose SBOM named another product until someone who can edit acknowledges it, whatever is scanned after it.

The gate object

Everything a pipeline needs to decide an exit code.

{
  "total": 12,            // advisory rows across all components
  "high": 3,              // of those, HIGH or CRITICAL
  "worst": {              // the single worst finding, CVE id when one exists
    "id": "CVE-2021-23337",
    "severity": "HIGH",
    "score": 7.2,
    "component": "lodash"
  },
  "untriagedHigh": 1,     // HIGH/CRITICAL findings with no not_affected/fixed disposition
  "kev": 0,               // untriaged findings CISA lists as exploited in the wild
  "unrated": 0            // untriaged findings whose severity could not be established
}

Fail the build when untriagedHigh, kev or unrated is above zero, not on high. Annex I Part I(2) is not “ship no CVEs” — it is a judgement per finding: this one cannot be reached in our product, that one is already fixed, this one is still open. In Vandorisk that judgement is a disposition recorded on the finding (VEX-style: not_affected, affected, fixed, under_investigation). not_affected will not save without a written justification, because that is the claim an assessor challenges first.

A finding dispositioned not_affected or fixed stops counting toward untriagedHigh, kev and unrated. So the gate goes green when the team has actually answered the findings — not when the scanner happens to be quiet.

kev counts findings CISA’s Known Exploited Vulnerabilities catalogue lists as exploited in the wild; treat any of them as a hard stop. A finding an earlier screen of the assessment found listed stays counted when the catalogue cannot be read, but a listing made since then cannot be seen, so when scan.kevChecked is false the example fails the job and asks for a re-run. unrated counts findings whose severity could not be established. Unknown is not low: an unrated finding may be critical, so it stops the build too.

Dispositions are recorded in the assessment wizard’s SBOM panel. There is no API for writing them yet (see below).

GET /api/v1/assessments/{id}

Scope assessments:read. Returns the assessment summary and its readiness, scored by the same engine the wizard and the exported technical file use.

curl -sS https://www.vandorisk.com/api/v1/assessments/$ASSESSMENT_ID \
  -H "Authorization: Bearer $VANDORISK_API_KEY"
{
  "assessment": {
    "id": "clx…",
    "productName": "Sensor",
    "versionLabel": "1.0",
    "packVersion": "0.4.0",
    "currentPackVersion": "0.4.0",
    "category": "default",
    "route": "self-assessment"
  },
  "readiness": {
    "pct": 79,
    "evidencedPct": 65,
    "total": 28, "applicable": 26, "assessed": 24,
    "met": 19, "metEvidenced": 12, "metAsserted": 7,
    "partial": 3, "notMet": 2, "na": 2,
    "blockers": ["Risk assessment incomplete (3/6 fields) — mandatory under Art. 13(2)-(3). Not answered: Assets to be protected; Expected time in use; Threats & risk analysis.","Manufacturer address not recorded — Annex V(2) asks for the manufacturer's address."],
    "identityBlockers": ["Manufacturer address not recorded — Annex V(2) asks for the manufacturer's address."],
    "gapCount": 7,
    "orderedGaps": [
      { "requirementId": "I.3b", "title": "Access control / authentication", "legalRef": "Annex I Part I(2)(d)", "state": "not met" }
    ]
  },
  "sbom": {
    "lastScanAt": "2026-08-21T09:14:02.114Z",
    "componentCount": 412, "screenedCount": 361, "notScreenableCount": 51,
    "findingCount": 1, "highCount": 1,
    "untriagedHigh": 1, "untriagedTotal": 1, "untriagedUnrated": 0,
    "unscannedCount": 0, "complete": true
  }
}

pct counts a “met” answer in full. evidencedPct counts a “met” with no evidence text at half weight — it is the more honest number to put on a dashboard, because a claim with nothing behind it gives an assessor nothing to check. blockers are everything the Declaration of Conformity is held for: hard stops under Art. 13(2)–(4) that cannot be scored away, the Annex V details the product has not recorded (also listed on their own in identityBlockers), the scope question while it is unanswered, the conformity route and the family refusal. Treat a non-empty array as a failure. outsideScope is true when the product is recorded as outside the CRA’s scope: no Declaration of Conformity or technical file will be issued for it, whatever the numbers say, and blockers lists only the Art. 13 hard stops, because the scope answer is what holds its Declaration.

vertical is null unless a vertical standard is attached and the product’s classification still covers it; then it names the standard and gives its own applicable, assessed and met counts — an interim draft is voluntary, so it never moves the numbers above and never adds a blocker.

sbom describes the latest screen. An untriagedHigh of 0 is not a clean result on its own: untriagedUnrated counts findings whose severity could not be established, and complete is false when the screen did not reach every component, so it cannot show that a finding went away.

Machine-readable description

The same two endpoints as an OpenAPI 3.1 document, served without a key because it documents a public API surface and contains no secrets:

curl -sS https://www.vandorisk.com/api/v1/openapi

/api/v1/openapi — request and response schemas (including the gate object), the bearer security scheme, the scope each operation needs as x-required-scope, and the error responses (400, 401, 403, 404, 413, 422, 429 and 500). Enough to generate a client, and enough to import into Postman or Insomnia.

It is not a replacement for this page: what untriagedHigh, unrated and notScreenableCount mean is explained here.

The rule pack the assessment engine evaluates is public in the same spirit: /api/pack serves the versioned requirement pack and its changelog as JSON.

CI example: GitHub Actions

Generates an SBOM, screens it, prints the evidence line into the job log and fails the build when a HIGH or CRITICAL, known-exploited or unrated finding has not been triaged. Needs jq, which is preinstalled on GitHub-hosted runners. Store the key as the repository secret VANDORISK_API_KEY and the assessment id as the variable VANDORISK_ASSESSMENT_ID.

# .github/workflows/cra-sbom.yml
name: CRA SBOM screen

on: [push]

jobs:
  sbom-screen:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      # Produce a CycloneDX SBOM however your stack does it. Examples:
      #   npm:    npx @cyclonedx/cyclonedx-npm --output-file sbom.cdx.json
      #   syft:   syft dir:. -o cyclonedx-json=sbom.cdx.json
      - name: Generate SBOM
        run: npx --yes @cyclonedx/cyclonedx-npm --output-file sbom.cdx.json

      - name: Screen it with Vandorisk
        env:
          VANDORISK_API_KEY: ${{ secrets.VANDORISK_API_KEY }}
          ASSESSMENT_ID: ${{ vars.VANDORISK_ASSESSMENT_ID }}
        run: |
          jq -n --arg id "$ASSESSMENT_ID" --slurpfile sbom sbom.cdx.json \
            '{assessmentId: $id, sbom: $sbom[0]}' > payload.json

          http_code=$(curl -sS -o response.json -w '%{http_code}' \
            -X POST https://www.vandorisk.com/api/v1/scan \
            -H "Authorization: Bearer $VANDORISK_API_KEY" \
            -H "Content-Type: application/json" \
            --data @payload.json)

          if [ "$http_code" != "200" ]; then
            echo "::error::Vandorisk scan failed (HTTP $http_code)"
            cat response.json
            exit 1
          fi

          jq -r '.scan.evidenceLine' response.json

          untriaged_high=$(jq -r '.gate.untriagedHigh' response.json)
          kev=$(jq -r '.gate.kev // 0' response.json)
          unrated=$(jq -r '.gate.unrated // 0' response.json)
          not_screenable=$(jq -r '.scan.notScreenableCount' response.json)
          unscanned=$(jq -r '.scan.unscannedCount' response.json)
          kev_checked=$(jq -r '.scan.kevChecked' response.json)

          if [ "$not_screenable" -gt 0 ]; then
            echo "::warning::$not_screenable components could not be screened by OSV — check EUVD/NVD manually"
          fi

          warning=$(jq -r '.scan.warning // empty' response.json)
          if [ -n "$warning" ]; then
            echo "::warning::$warning If it is the right file, check VANDORISK_ASSESSMENT_ID."
          fi

          status=0
          if [ "$unscanned" -gt 0 ]; then
            echo "::error::$unscanned components were not screened this run, so the screen is incomplete. Re-run the job."
            status=1
          fi
          if [ "$kev_checked" != "true" ]; then
            echo "::error::This screen could not consult CISA's Known Exploited Vulnerabilities catalogue, so a newly listed finding would not show. Re-run the job."
            status=1
          fi
          if [ "$kev" -gt 0 ]; then
            echo "::error::$kev untriaged findings are listed in CISA's Known Exploited Vulnerabilities catalogue."
            status=1
          fi
          if [ "$untriaged_high" -gt 0 ]; then
            echo "::error::$untriaged_high untriaged HIGH/CRITICAL findings. Triage them in Vandorisk (not affected / fixed, with a reason) or fix the dependency."
            status=1
          fi
          if [ "$unrated" -gt 0 ]; then
            echo "::error::$unrated untriaged findings could not be rated, so their severity is unknown. Re-run the job, or triage them in Vandorisk."
            status=1
          fi
          exit $status

The same shape works on any runner: POST, read .gate.untriagedHigh, .gate.kev, .gate.unrated and .scan.unscannedCount, and exit non-zero when any of them is above zero.

Errors

Each of these answers is JSON with an error string:

A path not on this page gets a 404 with {"error":"Not found"}. A method a path on this page does not take gets a 405 with no body.

What is not here yet

Rather than let you find these out in an outage:

Vandorisk is guided self-assessment software. Screening an SBOM is evidence toward Annex I Part I(2); it is not a conformity assessment, and the manufacturer remains responsible for the Declaration of Conformity.