# Vulnerability Triage

> Parse, normalize, group, and prioritize security findings from GitLab vulnerability reports (JSON), and produce evidence-backed dispositions (fix vs. justified false positive). Triggers on phrases like: vulnerability report, gl-dependency-scanning-report.json, gl-sast-report.json, GitLab security report, CVE, CVSS, EPSS, KEV, triage findings, group vulnerabilities, deduplicate findings, false positive, is this exploitable, reachability, severity, prioritize remediation. Use whenever a GitLab (or GitLab-schema) security report is being analyzed, even if the user just pastes a JSON report or asks 'which of these do we actually need to fix?'

- Skill: `aws-samples/vulnerability-triage` (Agent Skill, multi-file: 5 files)
- Install (CLI): `npx skillmds@latest add aws-samples/vulnerability-triage`
- Raw SKILL.md: https://api.skillmd.com/api/skills/aws-samples/vulnerability-triage/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: DevOps & Infra
- Author: aws-samples (https://skillmd.com/u/aws-samples)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/aws-samples/vulnerability-triage

---


# Vulnerability Triage

Turn a raw GitLab security report into a **prioritized, deduplicated, dispositioned backlog** a team can act on. This is the analysis brain of the remediation pipeline: it decides *what matters*, *why*, and *whether each finding is real* — before any code is changed.

> **Where this runs:** this is the logic the pipeline's **triage** stage automates (encoded into a Lambda/Bedrock step). It is equally valid run interactively when designing or validating that stage.

## Why this skill exists

The manual process it replaces is slow because a human opens a JSON report with hundreds of entries, mentally de-duplicates the same CVE appearing across dozens of dependency paths, guesses at real-world risk, and writes ad-hoc justifications for false positives. Each of those steps is mechanical or rule-based enough to do consistently and fast — but only if you understand the report schema and apply a defensible prioritization and disposition standard. That is exactly what this skill supplies.

## The triage pipeline (do these in order)

1. **Parse & normalize** — read the report, detect its type, extract findings into a uniform shape.
2. **Group & deduplicate** — collapse the same underlying vulnerability appearing many times.
3. **Prioritize** — score each *group* by real risk (severity + exploitability + reachability + exposure), not by raw count.
4. **Disposition** — for each group decide **FIX**, **FALSE POSITIVE (justified)**, or **ACCEPT/DEFER (risk-accepted)**, each with evidence.
5. **Emit the triage backlog** — a table the remediation and MR skills consume.

Do not skip grouping before prioritizing. Scoring 200 ungrouped rows wastes effort and produces a backlog nobody can act on; scoring 12 groups is tractable and honest.

## Step 1 — Parse & normalize

GitLab writes one JSON file per scanner, each following the GitLab security report schema. The common shape:

```jsonc
{
  "version": "15.x.x",
  "scan": { "scanner": { "id": "gemnasium", "name": "Gemnasium" }, "type": "dependency_scanning" },
  "vulnerabilities": [
    {
      "id": "…",                         // stable per-finding UUID (newer schemas)
      "category": "dependency_scanning",
      "name": "Prototype pollution in lodash",
      "severity": "High",                // Critical|High|Medium|Low|Info|Unknown
      "solution": "Upgrade to lodash 4.17.21",
      "identifiers": [                    // ← the key to grouping
        { "type": "cve",       "name": "CVE-2021-23337", "value": "CVE-2021-23337" },
        { "type": "gemnasium", "name": "GHSA-…",         "value": "…" }
      ],
      "location": {                       // shape varies by scanner (see reference)
        "file": "package-lock.json",
        "dependency": { "package": { "name": "lodash" }, "version": "4.17.19" }
      },
      "links": [ { "url": "https://…" } ],
      "flags": [ { "type": "flagged-as-likely-false-positive" } ]  // SAST only
    }
  ],
  "remediations": [ /* dependency scanning may include auto-fix patches */ ]
}
```

Normalize every finding to this internal record so downstream steps are scanner-agnostic:

`{ key, title, scanner, category, severity, package, version, filePath, cve[], cwe[], ghsa[], fixedVersion, links[], gitlabFlags[], rawId }`

**Report types and where their fields live differ.** Before parsing an unfamiliar report, read [references/gitlab-report-schemas.md](references/gitlab-report-schemas.md) — it maps `location`, identifiers, and severity for Dependency Scanning, SAST, Container Scanning, Secret Detection, and the CycloneDX SBOM variant.

## Step 2 — Group & deduplicate

The same vulnerability shows up repeatedly. Collapse it using a stable **group key**, chosen in this priority order:

1. **CVE id** (`identifiers[].type == "cve"`) — the strongest cross-scanner key.
2. **GHSA / advisory id** when no CVE exists.
3. **`(package name + vulnerability name)`** as a last resort when neither id is present.

Group so that "CVE-2021-23337 in lodash" is **one** item carrying a list of every affected path/version/manifest, not 30 items. Record per group: all affected `package@version` locations, the manifests they came from, and whether they are **direct** or **transitive** dependencies (this heavily informs remediation later).

Watch for genuine distinctions the naive key would merge or split:
- Same CVE, **different package** → different groups (a CVE can affect multiple packages).
- Same package, **different CVEs** → different groups.
- Same CVE across **prod vs. dev** dependencies → keep as one group but tag exposure; dev-only changes the priority.

## Step 3 — Prioritize by real risk

Sort the backlog by a defensible composite, not by GitLab severity alone. Use four signals:

| Signal | Source | What it tells you |
|---|---|---|
| **Severity / CVSS** | report `severity`; CVSS vector in links/identifiers | Intrinsic impact if exploited |
| **EPSS** | FIRST.org EPSS score (0–1) | Probability it will be exploited in the wild in ~30 days |
| **KEV** | CISA Known Exploited Vulnerabilities catalog | Whether it is *actively* exploited now (hard override → top priority) |
| **Reachability & exposure** | your codebase + dependency graph | Whether the vulnerable code is actually invoked, and whether the component is internet-facing, dev-only, or unused |

A Critical CVSS finding in a dev-only, never-imported transitive dep can rank **below** a Medium finding in an internet-facing, reachable code path. Say so explicitly in the backlog rather than deferring to the scanner's label.

For CVSS vector interpretation, EPSS/KEV lookup mechanics, reachability heuristics for Node.js, and the exact priority formula, read [references/severity-and-prioritization.md](references/severity-and-prioritization.md). To enrich a finding that lacks a fix version or has a suspicious severity (cross-check GHSA/OSV/NVD/npm advisories), read [references/advisory-sources.md](references/advisory-sources.md).

## Step 4 — Disposition each group

Every group gets exactly one disposition, with evidence. This is the step your developers do by hand today; the value is doing it to a **consistent, auditable standard**.

- **FIX** — real, reachable or plausibly reachable, a remediation path exists. Hand to `dependency-remediation`.
- **FALSE POSITIVE (justified)** — the finding does not actually apply. This requires *evidence*, not assertion. Valid justification classes (not exhaustive): vulnerable function never called / not reachable; only the dev/test toolchain is affected and never ships; the CVE applies to a different platform or configuration than yours; already mitigated by a compensating control; the scanner mis-identified the package or version.
- **ACCEPT / DEFER (risk-accepted)** — real but consciously deferred (e.g., no fix available yet, or effort outweighs low risk this cycle). Must name who accepts it and a revisit trigger.

A false-positive claim without evidence is not a disposition — it is a guess, and it will fail review. Use the disposition taxonomy and the copy-paste justification template in [references/false-positive-justification.md](references/false-positive-justification.md).

## Step 5 — Emit the triage backlog

Produce a single table (and persist it to the workflow's analysis output). One row per **group**:

```markdown
| ID | CVE / Advisory | Package | Affected paths | Direct/Transitive | Severity | EPSS | KEV | Reachable? | Priority | Disposition | Evidence / Justification | Fixed in |
|----|----------------|---------|----------------|-------------------|----------|------|-----|-----------|----------|-------------|--------------------------|----------|
| V-01 | CVE-2021-23337 | lodash | 3 (package-lock) | Transitive | High | 0.12 | No | Yes (util import) | P1 | FIX | Reachable via `_.template` in report builder | 4.17.21 |
| V-02 | CVE-2020-8203 | lodash | 1 (dev) | Transitive (dev) | High | 0.04 | No | No (build-only) | P4 | FALSE POSITIVE | Dev-only, `devDependencies`, never bundled — see justification | 4.17.20 |
```

This table is the contract handed to `dependency-remediation` (for FIX rows) and `gitlab-mr` (for the MR body and any GitLab write-back). Keep the IDs stable so the plan, the code changes, and the MR all reference the same groups.

## Quick reference

| Task | Do this |
|---|---|
| Unknown report type | Detect via `scan.type`; map fields per references/gitlab-report-schemas.md |
| Dedupe | Group by CVE → GHSA → (package+name) |
| "Is this real?" | Reachability + exposure → disposition with evidence |
| Missing fix version | Enrich via GHSA/OSV/NVD (references/advisory-sources.md) |
| Actively exploited? | Check CISA KEV — if listed, escalate to top priority |
| Justify a false positive | Use the evidence template (references/false-positive-justification.md) |

## Common mistakes

- **Prioritizing by count.** 50 hits of one dev-only CVE is still one low-priority group. Group first.
- **Trusting `severity` blindly.** It's intrinsic, not contextual. Layer EPSS + KEV + reachability.
- **Asserting "false positive" without evidence.** Every FP needs a named justification class and a concrete reason tied to *this* codebase.
- **Losing the direct/transitive distinction.** It determines whether remediation is a simple bump or an override — capture it during grouping, not later.
- **Merging distinct CVEs** because they share a package, or **splitting one CVE** across paths. The group key is the vulnerability identity, not the location.

