Safeguard
DevSecOps

Security Regression Testing: Make Sure Fixed Vulnerabilities Stay Fixed

A vulnerability you patched last quarter that quietly comes back this quarter is worse than one you never fixed — because you thought it was handled. Here is how to build security regression testing that keeps fixes fixed.

Priya Mehta
DevSecOps Lead
6 min read

Functional regression testing is a solved cultural problem: no serious team ships without a test suite that fails when a fixed bug comes back. Security has no equivalent reflex, and it shows. A team patches an authentication bypass, closes the ticket, and moves on. Six months later a refactor reverts the guard clause, a dependency bump reintroduces a vulnerable transitive package, or a merge silently drops the fix — and nobody notices, because there was no test asserting the vulnerability stays dead. A reintroduced vulnerability is uniquely dangerous precisely because everyone believes it is handled.

Security regression testing closes that gap by treating "this vulnerability must never come back" as an assertion the pipeline enforces on every change.

What security regression testing is

Security regression testing is the practice of continuously verifying that previously identified and remediated security issues do not reappear. It borrows the mechanics of functional regression testing — automated checks that fail when known-bad behavior returns — and points them at security properties: a specific vulnerability, an insecure configuration, a dependency version, or a class of flaw. The unit of work is a test that would have caught the original issue, kept forever, run on every build.

There are two distinct kinds of regression to defend against, and they need different mechanisms:

  • Code-level reintroduction — someone re-writes the vulnerable pattern, or a merge/refactor reverts a fix. Defended with targeted regression tests and baseline diffing.
  • Dependency reintroduction — a vulnerable package version comes back through a version bump, a lockfile regeneration, or a transitive change. Defended with version-pinning assertions and continuous SCA against baselines.

Technique 1 — Write a regression test for every fixed vulnerability

The highest-value habit is simple: when you fix a security bug, write a test that fails against the unfixed code and passes against the fix, then keep that test forever. This is exactly the discipline mature teams already apply to functional bugs — extend it to security. For an authorization fix, that means a test asserting the endpoint returns 403 for an unauthorized principal. For an injection fix, a test feeding the original malicious payload and asserting it is neutralized.

# tests/security/test_regression.py
def test_cve_2026_1234_authz_bypass_stays_fixed(client):
    # original bug: /admin/export was reachable without the admin role
    resp = client.get("/admin/export", headers=as_role("viewer"))
    assert resp.status_code == 403   # must never regress to 200

def test_no_sqli_in_search(client):
    # original payload that triggered injection
    resp = client.get("/search", params={"q": "'; DROP TABLE users;--"})
    assert resp.status_code != 500
    assert "syntax error" not in resp.text.lower()

These live in your normal test suite and run on every PR. A refactor that reverts the fix now fails a test instead of shipping.

Technique 2 — Baseline diffing so fixed findings can't silently return

Scanner-detected findings need a different mechanism, because you will not hand-write a unit test for every one of hundreds of SCA and SAST results. Instead, maintain a baseline of the current accepted findings and fail the build when a finding that was previously resolved reappears. This is the scanner-native form of regression testing.

# ci: fail if a previously-fixed finding comes back
regression-gate:
  stage: security
  script:
    - safeguard scan --all-engines --json > current.json
    # compare against the stored baseline of resolved findings
    - safeguard baseline diff \
        --baseline .safeguard/baseline.json \
        --current current.json \
        --fail-on reintroduced

The key state is the baseline: it records what was resolved, so a resolved finding that shows up again is flagged as a regression specifically — distinct from a brand-new finding, and often more urgent, because it means a control you thought you had is gone.

Technique 3 — Pin and assert dependency versions

Dependency reintroduction is sneaky because no application code changed — a transitive bump or a regenerated lockfile did the damage. Defend it two ways. First, commit lockfiles and require review on lockfile changes, so a version change is a visible decision. Second, run SCA against the baseline continuously (not just on code changes) so a re-emerged vulnerable version is caught even when the triggering change was a dependency-only update. The xz-utils incident is a reminder that a dependency you trusted yesterday can become the problem today; continuous baseline-aware SCA is how you notice.

Technique 4 — Continuous re-testing against the SBOM

Some regressions are not your fault at all: the code and dependencies are unchanged, but a newly disclosed CVE now affects a component you already ship. Re-matching your stored SBOMs against fresh advisories on a schedule catches this, and folding it into your regression view means "a component we shipped is now known-vulnerable" is treated with the same seriousness as a reintroduced fix. It is regression against the world's knowledge rather than against your own history, but the operational response is identical.

Where each technique fits in the pipeline

Regression riskMechanismRuns
Fix reverted in codeTargeted regression testEvery PR
Resolved finding reappearsBaseline diffEvery PR / merge
Vulnerable dep version returnsLockfile review + baseline SCAEvery PR + scheduled
New CVE hits shipped componentSBOM re-matchScheduled (e.g. hourly)

The anti-patterns to avoid

  • Closing a security ticket without a test. If nothing asserts the fix, the fix is on the honor system.
  • Treating a reintroduced finding like a new one. Regressions signal a process failure — a lost fix, a bad merge, an unreviewed lockfile — and deserve a root-cause look, not just a re-patch.
  • No baseline. Without stored state, your scanner cannot tell "returned" from "new," and you lose the most useful signal there is.
  • Regression tests that are flaky. A flaky security test gets muted, and a muted security test is worse than none because it looks like coverage.

How Safeguard helps

Safeguard keeps fixed vulnerabilities fixed by maintaining a baseline of resolved findings and flagging reintroductions distinctly from new issues, across SCA, SAST, DAST, and secrets — so a control you thought you had cannot silently disappear. Continuous SBOM re-matching from SBOM Studio catches the other kind of regression, where an unchanged component becomes newly vulnerable, and surfaces it in the same queue. When auto-fix remediates an issue, the fix and its baseline entry are recorded together, so the pipeline knows exactly what must never come back. Policy-as-code gates let you make "no reintroduced findings" a hard merge rule.

Compare baseline-aware, regression-conscious scanning to point-in-time tools in our Checkmarx comparison, then get started free or read the documentation.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.