๐งช Nuclei Regression Templates: Make Every Proven Bug Stay Fixed
A bug gets reported, proven, fixed and closed. Three sprints later someone refactors the invoices controller, the ownership check quietly disappears, and the same bug is back in production. Nobody notices, because the on
A bug gets reported, proven, fixed and closed. Three sprints later someone refactors the invoices controller, the ownership check quietly disappears, and the same bug is back in production. Nobody notices, because the only proof that it ever existed is a PDF in a ticket.
The fix for that is boring and very effective: once you've proven a finding, turn the proof into a check that runs on every deploy. Nuclei is a good fit for this. Its templates are small YAML files, they can send several requests in a row and compare the responses, and ProjectDiscovery's own CI/CD docs list "regression checks for known issues" as a use case.
This article walks through one regression template end to end, for a broken object level authorization (BOLA/IDOR) bug. The part most people skip is the important one: a regression check is itself a claim, and it needs proving too. A template that can't reach the endpoint looks exactly like a fixed bug unless you design for it.
Everything here uses stand-in targets (staging.app.example.com, seeded test accounts). Only run templates against systems you own or are explicitly authorized to test.
What a regression template is (and isn't)
A normal nuclei template is a detector: it looks for a pattern across many targets. A regression template is narrower. It encodes one finding you've already confirmed, on one app you own, with the exact requests that proved it. Its job is to answer one question on every run: "is this specific bug back?"
That changes how you write it:
- Exact requests, not patterns. You already know the endpoint, the object ID and the two accounts involved. Hard-code them.
- Seeded test data. Create two test users and an object owned by one of them, in staging, and keep them there. Never point a regression check at real customer data.
- Proof in the matcher. The matcher should check what proved the bug in the first place (another user's data in the response), not just a status code.
- Silence means fixed only if the test worked. This is the part that's easy to miss, and most of this article is about it.
The finding we're encoding
Here's the stand-in finding. On GET /api/v1/invoices/{id}, any logged-in user could read any invoice. The original proof used two seeded accounts:
-
usr_test_awith tokentok_test_a -
usr_test_bwith tokentok_test_b, who owns invoiceinv_test_b_0001
User A asked for B's invoice and got a 200 with "owner_id": "usr_test_b" in the body. That's the OWASP API1:2023 pattern: the API trusts the object ID in the request and never checks that the caller owns the object.
The fix made the API return 404 when the caller isn't the owner. We want to know, on every deploy, if that ever stops being true.
Step 1: the naive template, and why it lies
The obvious first draft sends A's token to B's invoice and matches on a 200:
http:
- method: GET
path:
- "{{BaseURL}}/api/v1/invoices/inv_test_b_0001"
headers:
Authorization: "Bearer {{token_a}}"
matchers:
- type: status
status:
- 200
This has two problems.
It can match for the wrong reason. A 200 doesn't prove A saw B's data. A soft-error page, a generic "invoice not found" JSON with a 200, or a redirect to a login page that returns 200 would all trip it.
Worse, it can stay silent for the wrong reason. If B's invoice was deleted when someone reset the staging database, or the token expired, the request gets a 404 or a 401, the matcher doesn't fire, and your pipeline reports "fixed". That's the dangerous failure: the check stops checking, and nobody finds out.
Step 2: add a control request
The way out is the same thing you'd do when proving the bug by hand: first show the object exists and is readable by its owner, then show someone else can read it too. Nuclei can do this in one template. The docs call it a request condition: when a DSL matcher refers to status_code_1, body_2 and so on, it can compare the responses to several requests in order.
Here's the full template:
id: invoice-bola-regression
info:
name: Invoices API - user A can read user B's invoice (BOLA regression)
author: your-team
severity: high
description: |
Replays a confirmed BOLA finding with two seeded test accounts.
No result means fixed. Any result means still vulnerable, or the test itself is broken.
reference:
- https://owasp.org/API-Security/editions/2023/en/0xa1-broken-object-level-authorization/
tags: regression,bola,internal
http:
- raw:
- |
GET /api/v1/invoices/inv_test_b_0001 HTTP/1.1
Host: {{Hostname}}
Authorization: Bearer {{token_b}}
Accept: application/json
- |
GET /api/v1/invoices/inv_test_b_0001 HTTP/1.1
Host: {{Hostname}}
Authorization: Bearer {{token_a}}
Accept: application/json
disable-cookie: true
matchers:
- type: dsl
name: bola-reproduced
dsl:
- 'status_code_1 == 200 && contains(body_1, "usr_test_b")'
- 'status_code_2 == 200 && contains(body_2, "usr_test_b")'
condition: and
- type: dsl
name: control-failed
dsl:
- 'status_code_1 != 200 || !contains(body_1, "usr_test_b")'
What each part does:
- Request 1 is the control. The owner (B) reads their own invoice. If this doesn't return 200 with B's ID in it, the test environment is broken and nothing the second request says means anything.
- Request 2 is the replay. A asks for the same invoice with their own token, exactly as in the original proof.
-
bola-reproducedfires only when the control worked and A got B's data back. The marker (usr_test_b) is the thing that proved the bug the first time, not a status code on its own. -
control-failedfires when the control didn't work. With nuclei's default OR between matchers, either named matcher can produce a result, and the matcher name shows up in the output, so you can tell the two apart. -
Raw requests keep the host generic. Each request uses
Host: {{Hostname}}, as the raw HTTP docs suggest, so the target comes from-uand the same file runs against staging or a local copy.
The outcome is the property you want from a regression check: silence means one thing only. No result means the owner could read the invoice and the other user couldn't. Anything else produces a line of output.
The cookie trap
Nuclei reuses cookies between the requests in a template by default; the docs describe it as browser-like session handling, which is exactly what you want for multi-step exploits. It's the opposite of what you want here.
If your app sets a session cookie when B's bearer token is accepted, request 2 will carry B's cookie along with A's token. If the app checks the cookie first, request 2 is authenticated as B, gets B's invoice, and you get a "still vulnerable" result for a bug that's fixed. disable-cookie: true turns cookie reuse off for this request block, so each request carries only the identity you put in it.
The general rule: in any access control check, make sure each request carries exactly one identity, and that it's the one you think.
Step 3: keep the tokens out of the file
Tokens expire and shouldn't live in a repository. The template refers to {{token_a}} and {{token_b}}, and you pass them at run time with -var (short form -V), which takes key=value and can be repeated:
nuclei -t invoice-bola-regression.yaml \
-u https://staging.app.example.com \
-var token_a="$TEST_TOKEN_A" \
-var token_b="$TEST_TOKEN_B" \
-omit-raw -jsonl -o bola.jsonl
Two flags in there matter for secrets. By default, nuclei's JSON and JSONL output includes the raw request and response for each finding, which here means bearer tokens. -omit-raw leaves those out. And -debug, which prints every request and response, will print the tokens to your CI log, so use it locally only.
Step 4: prove the check before you trust it
A regression template is a claim like any other: it says "this bug is detected if it comes back". Before it goes into CI, prove both halves of that claim.
-
Validate the syntax.
nuclei -validate -t invoice-bola-regression.yamlchecks that the template parses. Mandatory fields areid,info.nameandinfo.author, and the ID can't contain spaces. -
Positive control: run it against the vulnerable code. Check out the commit before the fix, run it in a local or ephemeral environment, and confirm you get
[invoice-bola-regression:bola-reproduced]. If you don't, the template doesn't detect the bug and is worthless as a regression check. -
Negative control: run it against the fixed code. You should get no output. Adding
-ms(matcher status) shows match failures too, which helps when you're debugging why something did or didn't fire. -
Break the control on purpose. Pass a bogus
token_band confirm you getcontrol-failed. This is the test of your test: it shows the check can't go quiet just because the environment broke.
If you can't run the vulnerable version any more, write a tiny mock that reproduces the bug. Here's a stand-in invoices API with a vulnerable and a fixed mode, plus the same two-request logic in plain Python, so you can see the three outcomes before you ever touch nuclei:
# mock_api.py - a stand-in invoices API for practising regression templates.
# Run: python mock_api.py vulnerable (or: python mock_api.py fixed)
import json, sys
from http.server import BaseHTTPRequestHandler, HTTPServer
MODE = sys.argv[1] if len(sys.argv) > 1 else "vulnerable"
TOKENS = {"tok_test_a": "usr_test_a", "tok_test_b": "usr_test_b"}
INVOICES = {
"inv_test_a_0001": {"id": "inv_test_a_0001", "owner_id": "usr_test_a", "total": "19.00"},
"inv_test_b_0001": {"id": "inv_test_b_0001", "owner_id": "usr_test_b", "total": "42.00"},
}
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
user = TOKENS.get(self.headers.get("Authorization", "").removeprefix("Bearer "))
prefix = "/api/v1/invoices/"
if user is None:
return self.reply(401, {"error": "unauthenticated"})
if not self.path.startswith(prefix):
return self.reply(404, {"error": "not found"})
invoice = INVOICES.get(self.path[len(prefix):])
if invoice is None:
return self.reply(404, {"error": "not found"})
if MODE == "fixed" and invoice["owner_id"] != user:
return self.reply(404, {"error": "not found"}) # same answer as "doesn't exist"
return self.reply(200, invoice)
def reply(self, status, body):
data = json.dumps(body).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
def log_message(self, *args):
pass
if __name__ == "__main__":
print(f"mock invoices API ({MODE}) on http://127.0.0.1:8085")
HTTPServer(("127.0.0.1", 8085), Handler).serve_forever()
# replay_check.py - the same two-request logic as the template, in plain Python.
import sys, urllib.request, urllib.error
BASE = "http://127.0.0.1:8085"
INVOICE, MARKER = "inv_test_b_0001", "usr_test_b"
def get(token):
req = urllib.request.Request(f"{BASE}/api/v1/invoices/{INVOICE}",
headers={"Authorization": f"Bearer {token}"})
try:
with urllib.request.urlopen(req) as r:
return r.status, r.read().decode()
except urllib.error.HTTPError as e:
return e.code, e.read().decode()
token_a, token_b = sys.argv[1], sys.argv[2]
s1, b1 = get(token_b) # control: the owner reads their own invoice
s2, b2 = get(token_a) # replay: someone else asks for the same invoice
if not (s1 == 200 and MARKER in b1):
print(f"control-failed control={s1} -> the check proves nothing, fix the test")
elif s2 == 200 and MARKER in b2:
print(f"bola-reproduced control={s1} replay={s2} -> still vulnerable")
else:
print(f"no result control={s1} replay={s2} -> fixed (for this case)")
Running the checker against the vulnerable mock, then the fixed mock, then the fixed mock with an expired owner token (needs Python 3.9+ for removeprefix):
$ python replay_check.py tok_test_a tok_test_b # mock in vulnerable mode
bola-reproduced control=200 replay=200 -> still vulnerable
$ python replay_check.py tok_test_a tok_test_b # mock in fixed mode
no result control=200 replay=404 -> fixed (for this case)
$ python replay_check.py tok_test_a tok_expired_b # fixed mode, broken control
control-failed control=401 -> the check proves nothing, fix the test
Point nuclei at http://127.0.0.1:8085 with the same tokens and you should see the same three outcomes as matcher names. If you don't, trust the mismatch, not the template: something in your template doesn't do what you think it does.
Step 5: wire it into CI
The simplest CI rule that works: run the regression templates against staging, and fail the job if the output file has anything in it. The example runs on pushes to main and on demand; hook it to whatever runs after your staging deploy.
name: security-regressions
on:
workflow_dispatch: {}
push:
branches: [main]
jobs:
regressions:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install nuclei
uses: projectdiscovery/nuclei-action@v3
with:
install-only: true
- name: Run regression templates
env:
TEST_TOKEN_A: ${{ secrets.TEST_TOKEN_A }}
TEST_TOKEN_B: ${{ secrets.TEST_TOKEN_B }}
run: |
nuclei -t security/regressions/ \
-u https://staging.app.example.com \
-var token_a="$TEST_TOKEN_A" -var token_b="$TEST_TOKEN_B" \
-omit-raw -jsonl -o regressions.jsonl
if [ -s regressions.jsonl ]; then
echo "Security regression or broken check:"
cat regressions.jsonl
exit 1
fi
A few notes on this:
-
test -sis true only for a file that exists and isn't empty, so the job fails on any result,bola-reproducedorcontrol-failed. Both deserve a human: one is the bug, the other is a check that has stopped working. -
nuclei-action@v3is the current major version; its README says thev2references are deprecated.install-only: trueinstalls nuclei without running a scan, so you control the exact command. - Keep templates in the repository (
security/regressions/here), next to the code they protect, and store tokens in your CI secrets, as the nuclei CI docs recommend. - Mint the test tokens from the seeded staging accounts in an earlier step if they're short-lived, rather than storing long-lived ones.
Fixing the bug, not just testing it
The regression template only tells you the bug is back. The fix is the same as for any BOLA: check object ownership on the server for every request that takes an object ID, ideally in one shared place (a policy layer or a scoped query such as WHERE id = ? AND owner_id = ?) instead of in each handler. Return the same response for "not yours" and "doesn't exist", like the mock's fixed mode does, so the endpoint doesn't confirm which IDs are real.
Then write the template the day the fix ships, while the proof is fresh and the vulnerable commit is easy to check out.
Try it yourself
Only test targets you own or have explicit permission to test. These are built for it:
- The mock above. Run it locally in both modes and build the template against it until all three outcomes behave.
- OWASP crAPI. Its first challenge is a BOLA: access details of another user's vehicle. Prove it by hand with two accounts, then write a regression template for it and run it against your local crAPI.
- VAmPI. A deliberately vulnerable API with a switch between vulnerable and secure modes, which gives you a positive and a negative control for free.
- PortSwigger Web Security Academy. The "User ID controlled by request parameter" lab is a quick way to practise the manual proof before you automate it.
Conclusion
A finding you've proven once is worth encoding, because proof is the expensive part and you've already paid for it. But a regression check that can't tell "fixed" from "broken test" just moves the false confidence somewhere quieter. Give every template a control request, give each failure mode its own name, and prove the template against the vulnerable and fixed versions before you trust its silence. Detection isn't proof, and neither is an empty CI log until you've shown it can fill up.
I wrote the template against the nuclei v3.11.1 documentation and source (the latest release at the time of writing, 8 August 2026). The Python mock and checker above were run locally and the output shown is from that run. Run -validate and the three checks in Step 4 in your own setup before relying on it.
Further reading
- Nuclei on GitHub (README, CLI flags, releases)
- Nuclei docs: template structure
- Nuclei docs: basic HTTP (request conditions, cookie reuse)
- Nuclei docs: raw HTTP
- Nuclei docs: matchers
- Nuclei docs: running nuclei in CI/CD
- projectdiscovery/nuclei-action
- OWASP API Security Top 10 2023: API1 Broken Object Level Authorization
- OWASP crAPI and VAmPI
- PortSwigger lab: User ID controlled by request parameter
I'm Krishna, a pentester and the founder of Xalgorix, an open-source AI pentester that only reports vulnerabilities it has actually exploited.
๐ If this was useful, follow me for more hands-on web & API security write-ups.
๐ Connect with me on LinkedIn: https://www.linkedin.com/in/xalgord/
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes โ full credit and traffic to the original publisher.