CoursesSecure CI/CD with GitLabDependency scanning (SCA)

Dependency scanning (SCA)

The CVEs you inherited from your libraries.

Intermediate12 min · lesson 10 of 17

Your Node service pins [email protected] - a version you locked eighteen months ago and never touched, because it works. It does work, and it is also vulnerable to CVE-2021-23337, a command-injection flaw in lodash's template() helper that was fixed in 4.17.21. Nobody on your team wrote that bug. You inherited it the instant you ran npm install. Now multiply it by the hundreds of packages a typical service drags in transitively, and the uncomfortable truth is that most of your attack surface is code you have never read. Software Composition Analysis (SCA) - GitLab calls it Dependency Scanning - is the pipeline stage that finds those inherited CVEs before an attacker who reads vulnerability feeds for a living does.

What dependency scanning actually reads

First, two terms. A direct dependency is one you named yourself in package.json (or requirements.txt, go.mod, pom.xml); a transitive dependency is one your dependencies pulled in - the package three levels down you have never heard of. A lockfile - package-lock.json, yarn.lock, Gemfile.lock, poetry.lock, go.sum - records the exact resolved version of every package in that tree. GitLab's analyzer, gemnasium, parses the lockfile, reconstructs the full graph, and matches each pinned version against the GitLab Advisory Database: a curated feed of known vulnerabilities keyed to a package name and an affected version range. It runs none of your code and never contacts your package registry - it is a fast, deterministic lookup against a file already in your repo. Every match is written to a report artifact that GitLab ingests to populate the merge request security widget. (Gemnasium was deprecated in GitLab 17.9 - proposed for removal in 20.0 - in favour of the newer SBOM-based analyzer added by Jobs/Dependency-Scanning.v2.gitlab-ci.yml, whose job is named dependency-scanning rather than gemnasium-dependency_scanning; the gl-dependency-scanning-report.json format and the report-then-gate workflow below are unchanged.)

.gitlab-ci.yml
include:
- template: Jobs/Dependency-Scanning.gitlab-ci.yml
# The template already defines the gemnasium-dependency_scanning job.
# Re-declaring it by name MERGES into the template job - here we
# scope it with rules and drop dev-only packages that never ship.
gemnasium-dependency_scanning:
variables:
DS_INCLUDE_DEV_DEPENDENCIES: "false"
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
gemnasium-dependency_scanning (job log)
$ /analyzer run
[INFO] [gemnasium] Detecting project dependencies from lockfiles
[INFO] [gemnasium] Found package-lock.json (npm)
[INFO] [gemnasium] Analyzing 1 lockfile(s), 214 dependencies
[WARN] [gemnasium] Found 1 vulnerability
Uploading artifacts for successful job
gl-dependency-scanning-report.json: found 1 matching files
Job succeeded

Notice the last line: the job succeeded. This trips up almost everyone. The gemnasium analyzer exits 0 whether it finds zero vulnerabilities or fifty - including the template does not, by itself, block anything. Findings flow into the report artifact and the MR security widget for humans to see; enforcement is a separate decision. That split is deliberate. A large dependency tree always carries some open CVEs, many with no fix available, and failing every pipeline on them just teaches developers to delete the scanner. So GitLab separates reporting (always on, everything visible) from gating (a policy you opt into, scoped to the actionable subset). We will wire the gate after we read the finding.

gl-dependency-scanning-report.json
{
"version": "15.0.6",
"scan": {
"scanner": { "id": "gemnasium", "name": "Gemnasium", "version": "5.4.0",
"vendor": { "name": "GitLab" } },
"type": "dependency_scanning",
"status": "success",
"end_time": "2026-07-14T09:13:05"
},
"vulnerabilities": [
{
"name": "Command Injection in lodash",
"severity": "High",
"location": {
"file": "package-lock.json",
"dependency": { "package": { "name": "lodash" }, "version": "4.17.20" }
},
"identifiers": [
{ "type": "cve", "name": "CVE-2021-23337",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2021-23337" },
{ "type": "ghsa", "name": "GHSA-35jh-r3h4-6jhm" }
],
"solution": "Upgrade lodash to 4.17.21 or later."
}
]
}

Everything you need to act is right there: the vulnerable package and exact version (lodash 4.17.20), where it entered the tree (package-lock.json), the severity, the CVE and GitHub advisory IDs for triage, and - the part that makes SCA actionable - a solution with the fixed version. gemnasium only fills in solution when a patched release exists, which is precisely the finding a developer can close today rather than merely log.

Fixing it is a version bump

The remediation for a known CVE is almost always the same shape: move to a version outside the vulnerable range. You bump the dependency, regenerate the lockfile so the whole transitive graph re-resolves, and push. Do it deliberately - read the changelog for breaking changes and run your tests - but the mechanical step is small. Renovate and Dependabot automate exactly this, opening one merge request per upgrade so the fix arrives as a reviewable, testable change instead of a manual chore. Here it is by hand:

terminal
$ npm install [email protected] # rewrites package.json + package-lock.json
added 0 packages, changed 1 package, audited 214 packages in 2s
$ git commit -am "fix(deps): bump lodash 4.17.20 -> 4.17.21 (CVE-2021-23337)"
$ git push -o merge_request.create # push option opens the MR in one step
# ...the MR pipeline re-runs gemnasium against the new lockfile:
[INFO] [gemnasium] Analyzing 1 lockfile(s), 214 dependencies
[INFO] [gemnasium] Found 0 vulnerabilities
Job succeeded

Trust the widget, but verify from the outside - especially when you are building an automated gate. GitLab serves every job artifact over its REST API, so you can pull the report from any pipeline and count exactly what matters:

terminal
# Pull the DS report from the latest main pipeline, count High/Critical findings
$ curl -sf --header "PRIVATE-TOKEN: $GITLAB_TOKEN" \
"https://gitlab.example.com/api/v4/projects/1024/jobs/artifacts/main/raw/gl-dependency-scanning-report.json?job=gemnasium-dependency_scanning" \
| jq '[.vulnerabilities[] | select(.severity=="Critical" or .severity=="High")] | length'
0

That one number is your evidence. For native enforcement, GitLab's Merge Request Approval Policies read the same report and require approval - or block the merge - when a pipeline introduces a new High or Critical dependency-scanning finding, comparing the MR against its target branch so a pre-existing CVE on main does not wall off unrelated work. Report everything; gate only on new, fixable, High-and-above results. It is the same two-tier pattern every scanner in this course uses.

At scale the friction is triage, not detection. A monorepo with a dozen lockfiles produces a long report, and not every High is reachable - the vulnerable function may sit in a code path you never call. gemnasium matches by version, not by reachability, so some findings are present but not exploitable in your usage; dismiss those in the vulnerability report with a written justification (it stays audited and will not re-alert) rather than adding blanket severity overrides that also hide real issues. Point DS_MAX_DEPTH at nested projects so every lockfile is discovered, and treat forked-MR pipelines carefully: a contributor's fork can rewrite the lockfile, so scan the merged result and never on a runner that holds production credentials.

SCA is not container scanning

It is tempting to assume dependency scanning and the container scanning of the next lesson are the same check run twice. They read different inputs. SCA reads your source repository - the manifests and lockfiles you committed, which describe the full declared dependency graph (dev packages included) before any image is even built. Container scanning reads the built image's filesystem: the operating-system packages from your base image (glibc, openssl, the distro's curl) plus whatever ended up in the final layers. The overlap is your application's libraries; the gaps are the whole point. SCA catches a vulnerable transitive package for projects that ship no container at all, and sees dev-time dependencies an image never contains. Container scanning catches base-image OS CVEs that appear in no lockfile you wrote. They are complementary, not redundant - which is exactly why the next lesson adds the second scan, on the image you are about to push.

The dependency-scanning gate: what to do with a finding
A gemnasium finding
from gl-dependency-scanning-report.json
High/Critical + fix exists
Block / require approval
bump the version - the developer can act now
High/Critical, no fix yet
Report, do not block
track it; mitigate or accept with a written justification
Medium / Low
Report only
visibility, never a hard gate
Not reachable in your code path
Dismiss with reason
stays audited in the vulnerability report
Report everything; gate only on the new, fixable, High-and-above subset.
No lockfile, no findings - a silent zero
gemnasium resolves versions from your lockfile, not your top-level manifest. If a project commits package.json but not package-lock.json (or Pipfile without Pipfile.lock), the analyzer often cannot pin the transitive tree and reports nothing - a green pipeline that has scanned almost nothing. A clean dependency-scanning result is only trustworthy when a committed lockfile exists. Enforce lockfiles in review, and check the log line: 'Found 0 vulnerabilities' next to '0 dependencies analyzed' is the tell that you scanned an empty tree, not a safe one.
Quick check
01You add the Dependency-Scanning template, the merge request shows a Critical dependency finding - and the merge button is still enabled. Why?
Incorrect — Unfixable findings are still reported; that would not silently allow the merge either.
Incorrect — Artifacts persist for the pipeline, and the widget reads the current report.
Correct — Including the template makes findings visible; it does not block on its own.
Incorrect — Role affects visibility of some data, not whether a scanner enforces a gate.
02How does gemnasium decide whether your project is affected by a known CVE?
Incorrect — gemnasium runs none of your code; it is a static lookup, not a test run.
Correct — it is a fast, deterministic lookup against a file already in your repo; it runs no code and never contacts your package registry.
Incorrect — it never contacts your package registry; the vulnerability data comes from the GitLab Advisory Database.
Incorrect — that is runtime reachability analysis; gemnasium matches by version, not by whether the code path runs.
03A repo commits package.json but not package-lock.json. The dependency-scanning job is green and the log reads 'Found 0 vulnerabilities.' Is the project actually clean?
Correct — 'Found 0 vulnerabilities' next to a near-empty dependency count is the tell that you scanned an empty tree, not a safe one.
Incorrect — with no lockfile the analyzer cannot resolve the transitive graph, so zero often means nothing was scanned.
Incorrect — gemnasium needs the lockfile to pin exact versions; the top-level manifest alone is not enough.
Incorrect — the job still succeeds and shows green; that green is exactly the trap, because it scanned almost nothing.

Try this

Run npm install [email protected] # rewrites package.json + package-lock.json on a scratch host or disposable cluster and read the output against what this lesson described. Then change one input so it fails, and re-run: the error you get is the one you will meet in production.

Takeaway

The trap worth remembering here: no lockfile, no findings - a silent zero. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.

Related