GitLab Vulnerability Remediation
This skill governs how to remediate vulnerabilities detected by GitLab scanners (SAST, SCA / Dependency Scanning, Secret Detection). The goal is to produce safe, scoped, atomic merge requests that fix the finding without introducing new vulnerabilities, without changing business logic, and without leaking sensitive data.
The skill body covers the shared workflow, principles, and routing. Type-specific rules live in:
references/sca-remediation.md— dependency bumps, lockfiles, transitive handling per ecosystemreferences/sast-remediation.md— canonical fix patterns per CWE across Python, Node.js/TS, Java/Kotlin, Goreferences/secrets-remediation.md— migration to environment variables and mandatory rotation flaggingreferences/mr-template.md— MR description templates per vulnerability type
Read the relevant reference file in full before writing any code change.
Core Principles
These principles are non-negotiable because each one corresponds to a real failure mode this skill exists to prevent.
- Atomic MRs. One vulnerability per MR — or one coherent group (same CVE across multiple files of the same repo). Bundling unrelated fixes makes review impossible and rollback dangerous.
- Minimal blast radius. Touch only what is required to fix the finding. Do not reformat surrounding code, do not "clean up while we're here", do not rename unrelated symbols. Reviewers should be able to spot the security change in seconds.
- Never introduce a new vulnerability. Every fix must be validated against this: the bumped version itself has no open CVEs, the new code pattern does not trip another SAST rule, the new env-var path does not fall back silently to an empty string.
- Respect the project's conventions. Use the existing libraries, formatters, linters, and hooks. If the repo uses Black, run Black. If it uses Prettier, run Prettier. If
pre-commitexists, let it run. - Abort over guess. When the fix requires a major bump, a logic refactor, or post-fix validation fails, stop and report. A half-baked security fix is worse than a documented escalation. (Single exception: a major bump explicitly authorized by the user follows the reinforced-validation path in
references/sca-remediation.md— never assume authorization.) - For secrets, code fix ≠ resolution. Moving a hardcoded credential into an env var removes the value from
HEADonly. The secret is still in git history. Rotation is mandatory and must be flagged loudly in the MR description.
Workflow
Step 1 — Triage and prioritize
Fetch and prioritize findings via the GitLab Security MCP:
mcp__gitlab-security__list_vulnerabilities— list the project's confirmed/detected vulnerabilities when triaging a batch.mcp__gitlab-security__list_pipeline_security_findings— list findings from a specific pipeline (e.g. right after a scan, before they are promoted to vulnerabilities).mcp__gitlab-security__get_vulnerability_statistics— get counts by severity/state to decide sequencing (fix Critical/High first).mcp__gitlab-security__get_vulnerability— fetch the full detail of a single finding by ID.
Process one finding at a time, even if the list has many — atomic MRs depend on this. Prioritize by severity using the statistics.
From the get_vulnerability response, identify the vulnerability type field:
| GitLab type | Route to |
|---|---|
sast |
references/sast-remediation.md |
dependency_scanning |
references/sca-remediation.md |
secret_detection |
references/secrets-remediation.md |
Also extract: severity, CVE/CWE ID, affected file path, affected line range, vulnerable package + version (for SCA), and any solution field GitLab provides.
Step 2 — Read the surrounding code
Before changing anything, read the affected file in full (or the relevant module). For SAST findings, read the callers of the affected function — you need to understand whether your fix would break any consumer. For SCA, open the manifest and the lockfile to understand the current resolution.
This step is short but it is what separates a safe fix from a regression.
Step 2b — Confirm or dismiss the finding in GitLab
Once you have read the code and decided whether the finding is real, record that decision in GitLab before writing any fix:
- Real vulnerability that will be remediated →
mcp__gitlab-security__vulnerability_confirmon the finding ID. This moves the finding toconfirmedand signals the team that remediation is in progress. - False positive (test fixture, placeholder, public key, non-security RNG, CSRF-immune bearer API, scanner taint analysis not recognizing a correct fix, etc.) →
mcp__gitlab-security__vulnerability_dismisswith a clearcomment/reason explaining why it is not exploitable. Dismissing via the tool is the correct terminal action for a false positive — do not stop at a manual report. The evidence still goes in the output report, but the finding state is updated through the tool.
On the escalation path, confirm anyway. If the finding is real but you will NOT remediate it in this session (major bump without authorization, refactor required, etc.), still call vulnerability_confirm. Confirming records that triage happened and that the finding is genuine; it does not promise a fix. The only findings that stay untouched in detected are the ones you have not yet triaged.
When the MCP call is blocked or unavailable. vulnerability_dismiss is known to be blocked by the permission classifier in auto mode, and any of these tools may be missing if the gitlab MCP is not connected. The rule is the same for all of them, confirm included:
- Do not silently downgrade to a manual-report-only outcome, and do not treat the block as permission to skip the state change.
- Record the exact call you would have made, with its parameters, so it can be replayed.
- Report the block and ask the user to reconnect the
gitlabMCP with/mcp— after that the call goes through. - A blocked
confirmdoes not stop the fix: proceed with the remediation and carry the pending state change into your final report. A blockeddismissdoes stop you from declaring a false positive closed — the dismissal is the terminal action, so it stays pending until it actually runs.
Do not call mcp__gitlab-security__vulnerability_resolve here. resolved is reserved for Step 6, after the fix is merged and verified.
Step 3 — Prepare the working copy and apply the fix
Before editing, establish where you are working. The normal path is a local clone on a dedicated branch — that is what Steps 4 through 6 are written against, and setting it up now avoids redoing the work later.
- Get the repo. If you are not already inside the project's clone, clone it (
git clone <ssh_url>) into a working directory outside the user's general-purpose workspace. If a clone already exists,git fetchand make sure you are on an up-to-date default branch. - Verify the default branch from the project's GitLab metadata rather than assuming — everything downstream (branch base, MR target, post-merge verification) depends on it.
- Create the fix branch off the up-to-date default branch, named per
references/mr-template.md:security/<type>/<short-slug>. One branch per finding — this is what makes the MR atomic. - Apply the fix, then commit with the title format from the template. Let the repo's hooks run; never
--no-verify.
If the working copy is not a git repository and cannot be made into one (no remote, no clone possible), you can still apply and validate the fix — but read the "MR deferred" outcome in Step 5 before you start, because the terminal state of the task changes.
Follow the canonical pattern in the routed reference file. The references define:
- The decision tree (when to fix vs. abort).
- The exact pattern per language / per CWE.
- The validation steps to run after the change.
Do not improvise patterns. SAST in particular has well-known idiomatic fixes — invented ones tend to leak.
If the finding's line number does not match the code (the scan ran against an older revision), do not abort on that alone: locate the pattern the finding describes within the same file. If exactly one statement matches unambiguously, fix it and note the line discrepancy in the MR. If several candidates match, or none does, abort and report — you may be looking at a different revision than the scanner did.
Step 4 — Validate
Before creating the MR, verify all of the following. Do not skip any; if a check is not applicable, state so explicitly.
- The project's tests pass. Run the actual command (e.g.
pytest,npm test,mvn verify,go test ./...). If no tests exist, state that in the MR description. - The linter / formatter introduces no NEW failures versus the default-branch baseline. If the default branch already has pre-existing lint/test failures, run the check on the base commit first, record the baseline count, confirm the fix did not grow it, and declare the pre-existing failures in the MR description. Do not block the fix on failures that predate it — and do not fix them either (blast radius).
- The build / install step succeeds (
pip install -r requirements.txt,npm ci,mvn compile,go build ./...). - The original vulnerable pattern is no longer present (re-grep for it). Caveat for SAST: if a re-scan still flags a fix that is verifiably correct, that is a scanner false positive, not a failed validation — see "When the scanner keeps flagging a correct fix" in
references/sast-remediation.md. - No obvious new findings introduced — sanity-check the diff against the same scanner type's known patterns.
- For SCA, the resolved dependency tree is clean. Auditing the package you bumped is not sufficient: the bump reshuffles transitives and can install a vulnerable one. Check every package in the regenerated lock against OSV/GHSA and report the result — see "audit the whole resolved tree" in
references/sca-remediation.md.
One check is a precondition, not a validation: the target version must be verified safe before you bump (sca-remediation.md step 3). If you cannot reach any advisory database, you do not have an unverified fix — you have an arbitrary one. That case blocks the change rather than being declared and waived; see "If you cannot reach the advisory databases, stop".
A check that fails and a check you cannot run are different outcomes. Do not collapse them.
| Situation | What it means | What to do |
|---|---|---|
| The check runs and fails | The fix is broken, or it broke something | Fix with a minimal additional change in the same scope, or revert the working tree and escalate. Never push a broken fix. |
| The check runs and fails identically on the base commit | Pre-existing breakage, not yours | Record the baseline, confirm the fix did not grow it, declare it in the MR. Do not fix it (blast radius). |
| The check cannot run at all — no test suite, no linter config, no lockfile to install from, no network, no toolchain | Nothing is proven either way | This is not a failure and does not trigger a revert. Leave the item unchecked in the MR's validation section with the specific reason ("the repo defines no test suite", "no network access for npm ci"). Never tick a box for a command you did not run. |
When every check falls in the third row, say so plainly in the MR: the fix rests on code review alone. That is an acceptable outcome for a one-line canonical fix in a repo with no test infrastructure; it is not acceptable to hide it behind ticked boxes.
Step 5 — Create the merge request
Use mcp__gitlab__create_merge_request with the template from references/mr-template.md. Rules:
- Target branch: the project's
default_branch, read from the API — see "Target branch" inreferences/mr-template.md, the single source of truth for this rule. - Title in English, format:
fix(security): <short description> [<CVE-ID | CWE-ID | SECRET>]. - Description in English, using the per-type template.
- Include a link to the GitLab finding.
- State the severity.
- List the exact files changed and why.
- Document residual risk and rollback.
- Apply labels:
security, plus one ofsast,sca,secret. If the org uses additional labels (needs-rotationfor secrets, etc.), apply them. - Do not sign commits or MRs as AI unless the team has explicitly enabled it.
Fallback when the write MCP is unavailable. mcp__gitlab__create_merge_request requires interactive OAuth and may not be connected. In that case create the MR via git push options over SSH — do not skip MR creation and do not downgrade to a report:
git push origin <branch> \
-o merge_request.create \
-o merge_request.target=<default_branch> \
-o merge_request.title="fix(security): ..." \
-o merge_request.description="$DESC" \
-o merge_request.label="security"
Push options reject newlines (fatal: push options must not have new line characters): collapse the description to a single line using <br> separators (GitLab renders the HTML), and convert the template's markdown tables to bullets — tables do not survive the reflow. Preserve all template sections; only the layout changes.
Verify the push actually landed. A remote that does not advertise the push-options capability does not ignore the options and continue — it aborts the whole push (fatal: the receiving end does not support push options), leaving the branch unpublished. Never report the branch as pushed on the strength of the command having been issued: confirm with git ls-remote --heads origin <branch>. If the options were rejected, push the branch plainly first so the work is not lost, then fall back to the deferred outcome for the MR itself.
When both paths are impossible — the "MR deferred" outcome. If the write MCP is unavailable and the MR cannot be created by push — no git repository, no remote, or a remote that rejects push options (a mirror, a non-GitLab host) — then the MR cannot be created by any means available to you. Do not treat this as a failure to be hidden, and do not claim an MR exists. Deliver instead:
- The fix, applied and validated in the working tree.
- The complete MR body as a file (
MR_DESCRIPTION.md), fully populated — nothing is lost, someone else can paste it. - The exact remaining commands: branch name, target branch, and the
git push -o merge_request.create ...invocation ready to run once a remote exists. - An explicit statement that the MR is pending creation and what unblocks it (connect the
gitlabMCP with/mcp, or add the remote).
This is a legitimate terminal state — it is the "fix applied, MR pending" outcome in the output contract, not a violation of it. What remains forbidden is going quiet, silently downgrading to a report that discards the MR body, or implying the MR was created.
Step 6 — Resolve the finding (only after merge)
Do NOT mark the finding resolved when the MR is merely created. mcp__gitlab-security__vulnerability_resolve is called only once the fix is merged into the default branch and the remediation is verified (re-scan clean, or the vulnerable pattern confirmed gone in the merged state).
- When the MR is created but not yet merged: the finding stays in
confirmed(from Step 2b). Include the finding URL in the MR description so the reviewer can trace it. Do not resolve. - After merge + verification: call
mcp__gitlab-security__vulnerability_resolveon the finding ID. Verification means checking the merged state of the default branch, not the MR branch — in particular, when several security MRs touched the same manifest, confirm the default branch still contains every bump (a conflict resolution can silently revert an earlier one; seereferences/sca-remediation.md, "Sequencing multiple MRs").
When there is no path to a merge. If the MR ended in the "MR deferred" state (Step 5), there is no merge and therefore no verification, so resolve is unreachable — by design, not by oversight. The finding stays confirmed and your report states that resolution is blocked pending MR creation. Never resolve a finding to close the loop on work that was never merged; an unresolvable finding is the honest outcome here.
KEY RULE — mitigating/rotating ≠ resolving. For a secret, moving the value to an env var (and even rotating it on the source system) is a mitigation, not a resolution of the finding. The value is still in git history. The finding only moves to resolved when the remediation is verified and merged. Rotating a secret does not by itself justify vulnerability_resolve. Never call resolve prematurely.
Full lifecycle
detection (list/get) → confirm (real) | dismiss (false positive)
→ fix + validation → create_merge_request (target: default_branch)
→ (review + merge) → verification → vulnerability_resolve
Anti-patterns
Stop yourself if you catch the agent doing any of these:
- Bumping a major version without explicit user authorization. If only a major fixes the CVE, escalate; only proceed when the user explicitly authorizes it, following the reinforced-validation path in
references/sca-remediation.md. Never treat a past authorization in another repo or session as standing permission. "Major" is not just a leading-digit change: for pre-v1packages a0.x → 0.ybump counts as a major and triggers this same rule — see the bump-size table inreferences/sca-remediation.md. - Suppressing a SAST finding (
# noqa,// nolint,@SuppressWarnings,nosec) without a documented justification. Suppression is not remediation. - Replacing a hardcoded secret with another hardcoded value (e.g. an empty string, a placeholder, or a
"default"). Defaults hide config bugs. - Rewriting the function that has the SAST finding "because while we're here". The diff must be the minimum change.
- Skipping pre-commit hooks (
--no-verify) because they fail. If they fail, investigate. Bypassing hooks defeats the team's safety net. - Including the secret value (full or partial — even with
...truncation) in the MR description, commit message, branch name, or any comment. The secret is already burned; do not burn it further. Refer to the secret by its type ("Stripe live secret key", "JWT signing key") — never reproduce any portion of the actual value. For secret findings, the "Before/After" diff in the MR is prose, not a literal snippet of the file. - Reformatting an entire file to fix one line. Configure your editor to only format the changed lines, or revert formatter changes on unrelated lines.
- Adding a new dependency to fix a SAST finding when the language's stdlib or the existing framework already solves it.
- Updating multiple unrelated dependencies in one MR because they all happen to have CVEs. Each one needs its own atomic MR (unless they share a CVE).
When to escalate (no MR)
Stop and report rather than apply a fix when:
- SCA: only a major version bump fixes the CVE and the user has not explicitly authorized it. Recommend the team plan the migration (or ask the user whether they authorize the major — see
references/sca-remediation.md). - SAST: the fix would require refactoring business logic, changing public function signatures, or restructuring the module.
- Post-fix validation fails and you cannot resolve it with a minimal additional change. Revert and report.
- Multiple findings overlap in the same code region in a way that cannot be split into atomic MRs. Report and ask for guidance on sequencing.
- Generated / vendored code: the vulnerable code is auto-generated, vendored, or part of a third-party SDK shipped in-tree. The fix belongs upstream or in the codegen template, not in
HEAD.
Where the escalation is recorded. There is no MCP tool that posts a comment on a security finding — mcp__gitlab-security__* exposes only confirm / dismiss / resolve / revert. So "leave a comment on the finding" is not an available action. Record the escalation as follows, in this order:
mcp__gitlab-security__vulnerability_confirmon the finding — it is real, and confirming records that triage happened (see Step 2b).- The written report below, delivered to the user.
- If the team wants the escalation tracked on the platform,
mcp__gitlab__create_issueon the project, linking the finding URL and pasting the report. Do this when the escalation needs an owner and a due date; ask the user first if creating issues is not already routine for this repo.
For every escalation, the output is a clear written report stating what was found, why no MR was produced, and what manual action is required. Do not stay silent.
Output contract
When the skill is invoked to remediate a finding, the agent's final output should be one of:
- MR created — the MR URL plus a one-paragraph summary of what was changed and what validation was run.
- Escalation report — a structured report containing: finding ID, vulnerability type, severity, the specific reason no fix was applied (e.g. "requires a major bump", "requires a business-logic refactor", "post-fix validation failed: "), and an explicit list of suggested manual actions for the team. The report follows the same MR template structure (header, severity, etc.) but the title section is marked
[ESCALATION — no MR]so reviewers immediately know no MR was produced. - False positive — the finding is dismissed in GitLab via
mcp__gitlab-security__vulnerability_dismiss(with reason), plus a structured report containing: finding ID and the evidence it is not a real vulnerability. The dismissal through the tool is the terminal action — a manual report alone is not sufficient. When the dismissal comes with an inline suppression, that comment is a diff and needs its own MR (see "False-positive suppression" inreferences/mr-template.md); the exception is the scanner-taint case, where no file is touched at all. - MR deferred — the fix is applied and validated, but neither the write MCP nor the push-options fallback is available (see Step 5). The output is the complete populated MR body as a file, the exact commands left to run, and an explicit statement that the MR is pending creation. Valid only when both creation paths are genuinely blocked, never as a shortcut.
Anything in between is a bug in how the skill was followed: a fix applied with the MR body silently discarded, a vague "this is hard, abort" report without manual action items, an MR with validation skipped, or ticked validation boxes for commands that were never run.