Skip to content

GitLab Vulnerability Report ​

The gitlab-vulnerabilities converter reads a project's GitLab Vulnerability Report: the deduplicated, persisted, triaged view of a project's findings that GitLab Ultimate maintains from every security scan ingested on the default branch. It is a different source from the gitlab converter, which reads the raw gl-*-report.json artifact one CI job wrote and knows nothing about what a reviewer later decided.

gitlab (CI artifact)gitlab-vulnerabilities (Vulnerability Report)
Sourceone job's gl-*-report.jsonthe Vulnerability Report over GraphQL
Scopeone project, one job, one scan typeone project, every scan type
Statepoint-in-time scanner outputcurrent state after triage
Triageabsentdismissal reason, statement, author, timestamp, full history
TierFree, Premium, UltimateUltimate only
Requirement idthe report's idthe vulnerability's uuid

The two ids differ, so documents produced by the two converters do not diff against each other by requirement. Treat them as two views, not two snapshots of one view.

Input: the fetcher envelope ​

The converter consumes the document the gitlab-vulnerabilities fetcher assembles for one project. There is no GitLab export of this shape; the CSV export loses the history and the identities, so the envelope is the only supported input.

json
{
  "metadata": { "enterprise": true, "version": "18.9.1-ee" },
  "filters": { "reportTypes": ["SAST", "SECRET_DETECTION"] },
  "project": {
    "id": "gid://gitlab/Project/1",
    "fullPath": "security-demo/juice-shop",
    "webUrl": "https://gitlab.example.com/security-demo/juice-shop",
    "repository": { "rootRef": "master" },
    "securityScanners": { "enabled": ["SAST", "SECRET_DETECTION"], "pipelineRun": ["SAST", "SECRET_DETECTION"] },
    "vulnerabilityStatistic": { "total": 89 },
    "latestDefaultBranchPipeline": { "iid": "5", "sha": "556a44e4…", "securityReportSummary": { "sast": { "scans": { "nodes": [{ "status": "SUCCEEDED" }] } } } }
  },
  "vulnerabilities": [ ],
  "fetchedAt": "2026-09-20T19:50:18Z"
}

vulnerabilities[] holds the GraphQL Vulnerability nodes with their stateTransitions and severityOverrides connections. fetchedAt is the clock every override expiry is judged against, so a given envelope always converts to the same document.

filters records what the fetch asked for and is present only when --state or --report-type narrowed the result. It has to be there, because the ingestion signals an empty report is judged against describe the whole project: without it, a filter that matched nothing would be indistinguishable from a project with no findings and would convert to a clean bill of health. A filtered fetch that matches nothing produces a single No matching findings requirement whose text says it describes the selection only.

An absent or null vulnerabilities field is rejected as a malformed envelope. Only an empty array is an empty report.

Document shape ​

  • One baseline per report type (SAST, SECRET_DETECTION, DEPENDENCY_SCANNING, …), ordered by name, summarising the scanners that contributed.
  • One requirement per vulnerability, id = the GitLab uuid. code holds the whole node as canonical JSON (keys sorted, two-space indent), so nothing the typed mapping drops is lost and both language implementations emit the same bytes.
  • cvss[] carries every vendor assessment GitLab attached to the finding: the base vector and score, GitLab's own severity band, the overall score as computedScore, and the finding's CVE as source. SAST and secret-detection findings carry none; dependency scanning is where they come from.
  • One repository component carrying the provenance the fetcher already knows: name = project path, url, branch = default branch, commit = the SHA the latest default-branch pipeline scanned, and labels gitlab/project-id and gitlab/full-path.
  • tool.version is the GitLab version; the document timestamp is fetchedAt.

Triage state → HDF ​

Status is raw-primary: results[].status is what the scanner last reported on the default branch, and a human decision never rewrites it. Each decision becomes an attributed, expiring Status_Override, and effectiveStatus, effectiveImpact and disposition are computed from the override history by the shared status ladder (see Status determination), never assigned.

GitLab stateraw results[].statusoverride → statusjustificationdispositioneffectiveStatus
DETECTEDfailednonefailed
CONFIRMEDfailednone (confirmedAt/confirmedBy in tags)failed
RESOLVED, scanner no longer detects itpassednonepassed
RESOLVED, scanner still detects itfailedattestation → passedattestationpassed
DISMISSED / FALSE_POSITIVEfailedfalsePositive → notApplicablefalsePositivenotApplicable
DISMISSED / ACCEPTABLE_RISKfailedwaiver → passedwaiverpassed
DISMISSED / MITIGATING_CONTROLfailedwaiver → passedinline_mitigations_already_existwaiverpassed
DISMISSED / USED_IN_TESTSfailedwaiver → notApplicablevulnerable_code_not_in_execute_pathwaivernotApplicable
DISMISSED / NOT_APPLICABLEfailedwaiver → notApplicablewaivernotApplicable

Rows the mapping deliberately does not turn into overrides:

  • CONFIRMED is corroboration, not a status change. An attestation would tell consumers the finding was accepted out of the actionable set, which is the opposite of what confirming means.
  • GitLab's automatic falsePositive flag is a heuristic; GitLab itself leaves the finding open. It is carried as gitlab/falsePositive and never laundered into a notApplicable.
  • "No longer detected" (resolvedOnDefaultBranch) is scanner evidence, not a decision. Alone it stays a tag; combined with a human RESOLVED it makes the raw status a genuine pass.
  • NOT_APPLICABLE is a waiver, not a false positive. The schema says disposition is what tells the two apart, so collapsing them would destroy that distinction.

Every override carries the reviewer's statement as reason, the reviewer as appliedBy (public email when GitLab exposes one, otherwise the username; automation is attributed to system/gitlab, never to an invented person), the transition time as appliedAt, and the finding's page as an external reference.

History and reverts ​

statusOverrides[] is built from the full stateTransitions history, one override per transition into a decision state, newest first. A revert (a transition back to DETECTED) creates no override of its own: it closes the previous one by setting its expiresAt to the revert time, so the ladder stops it governing while the record stays intact. A finding dismissed, reopened and dismissed again therefore carries two overrides, the first expired at the reopen. GitLab performs this revert itself when a later scan re-detects a finding someone had marked resolved, so a re-detected resolution reads as failed again with the attestation kept as expired history.

When GitLab returns no history for a triaged finding, the governing override is synthesised from the current-state fields. When the newest transition disagrees with the current state, the current state wins: every override the history left open is closed at the finding's last update before the current decision is synthesised, and gitlab/stateHistoryInconsistent is set. Without that closing step a decision GitLab has already moved past would keep governing the requirement.

The history itself is never truncated. The fetcher requests a modest nested page because GitLab caps query complexity, then completes any finding whose stateTransitions or severityOverrides reports another page with a follow-up query for that finding alone. Both connections are oldest-first, so a short list would hide the newest transition, which is the one that governs.

Severity overrides ​

The scanner's original severity is recovered from the earliest severity change rather than the first one in the list, because the connection's order is not part of GitLab's contract.

A human severity change becomes an impact-only riskAdjustment. requirement.impact keeps the scanner's original severity; effectiveImpact reflects the override. Because disposition is the most recent non-expired override of any kind, a severity change made after a dismissal reads as riskAdjustment while effectiveStatus still comes from the older status override. That is what the schema defines, and consumers should branch on effectiveStatus and disposition separately.

Expiry ​

The schema requires expiresAt on every override, and GitLab dismissals never expire, so each override takes the default of one year after it was applied. A dismissal older than a year will read as failed again at conversion time. The dismissal itself is never lost: gitlab/state and gitlab/dismissalReason stay on the requirement and the expired override stays in the history.

Tags ​

Every requirement carries the full triage record as always-present tags (null when GitLab has no value): gitlab/state, gitlab/dismissalReason, gitlab/stateComment, gitlab/falsePositive, gitlab/presentOnDefaultBranch, gitlab/resolvedOnDefaultBranch, gitlab/detectedAt, gitlab/updatedAt, gitlab/confirmedAt, gitlab/confirmedBy, gitlab/dismissedAt, gitlab/dismissedBy, gitlab/resolvedAt, gitlab/resolvedBy, gitlab/initialDetectedPipeline, gitlab/latestDetectedPipeline, gitlab/scanner, gitlab/severity, gitlab/originalSeverity, gitlab/reportType, gitlab/id, gitlab/uuid, gitlab/webUrl, gitlab/project and gitlab/stateTransitionCount, alongside the usual nist, cci and identifier tags (cwe, semgrep_id, gitleaks_rule_id, …).

An empty report is not always a clean report ​

GitLab's securityScanners.enabled and pipelineRun are derived from the CI job definitions on the latest default-branch pipeline, so a Free-tier project whose SAST job ran reads exactly like an Ultimate project. They cannot tell "no vulnerabilities" from "nothing was ever ingested". The converter uses the signals ingestion itself leaves behind:

vulnerabilitiesvulnerabilityStatisticlatest default-branch scansresult
non-emptyanyanydocument
emptypresentanygenuinely clean: one passed no-findings requirement per ingested scanner type
emptynullany CREATED, none SUCCEEDEDerror: reports were produced but never ingested; the Vulnerability Report requires GitLab Ultimate
emptynullREPORT_ERROR / JOB_FAILED / PREPARATION_FAILEDerror naming the scanner, its status and GitLab's error text
emptynullno pipeline or no scanserror: no security scanner has run on the default branch
anyanymetadata.enterprise falseerror: Community Edition has no Vulnerability Report

These are errors rather than documents because a passed no-findings requirement for a report nothing ever populated would be the one truly silent false pass.

Usage ​

Fetch and convert in one step. The token comes from GITLAB_TOKEN, GLAB_TOKEN or the glab CLI config, never a flag.

On scope: the fetcher only ever issues read queries, so read_api is the scope it should need, and api certainly works — that is what this was developed and recorded against. read_api has not been exercised end to end on an Ultimate instance, so if a read-only token fails, that is worth reporting rather than assuming. Separately, the token's owner needs at least Developer access to each project; access is what governs whether the Vulnerability Report is visible at all, and a role below Developer reads as an empty report rather than an error.

Pages hold 25 findings by default. GitLab rejects an authenticated GraphQL query scoring above 250 complexity and the full Vulnerability selection costs roughly 1.1 per finding, so the page is what shrinks to fit; fields are never dropped. Raise it with --page-size on an instance with a higher cap.

bash
# one project's triaged findings
hdf fetch gitlab-vulnerabilities --url https://gitlab.example.com --project my-org/my-project out.json

# every project in a group, one HDF per repository, provenance pre-filled
hdf fetch gitlab-vulnerabilities --url https://gitlab.example.com --group my-org --out-dir ./hdf/

# only what is still outstanding, or only the dismissed set
hdf fetch gitlab-vulnerabilities --project my-org/my-project --state DETECTED,CONFIRMED out.json
hdf fetch gitlab-vulnerabilities --project my-org/my-project --state DISMISSED out.json

# probe only: edition, license when readable, and whether the report was ever populated
hdf fetch gitlab-vulnerabilities --project my-org/my-project --check

# the same probe for every project in a group: one line each, under a count
hdf fetch gitlab-vulnerabilities --url https://gitlab.example.com --group my-org --check

# keep the fetched envelope and convert it later
hdf fetch gitlab-vulnerabilities --project my-org/my-project --format raw -o envelope.json
hdf convert envelope.json -o results.hdf.json          # auto-detected

In group mode each file is named after the project path with / replaced by __. A project that cannot be fetched is reported on stderr and the command exits non-zero after the others are written, so a sweep never looks complete when it is not.

ts
import { convertGitlabVulnerabilitiesToHdf } from '@mitre/hdf-converters';
const hdf = await convertGitlabVulnerabilitiesToHdf(envelopeJson);

The full flag reference is on the hdf-cli page.

Released under the Apache 2.0 License.