DEV Community

Cover image for ๐Ÿงช Nuclei Regression Templates: Make Every Proven Bug Stay Fixed
Krishna Kumar
Krishna Kumar

Posted on

๐Ÿงช 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 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_a with token tok_test_a
  • usr_test_b with token tok_test_b, who owns invoice inv_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
Enter fullscreen mode Exit fullscreen mode

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.

Three outcomes of a regression template: still vulnerable, fixed, or the test itself is broken

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")'
Enter fullscreen mode Exit fullscreen mode

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-reproduced fires 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-failed fires 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 -u and 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
Enter fullscreen mode Exit fullscreen mode

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.

  1. Validate the syntax. nuclei -validate -t invoice-bola-regression.yaml checks that the template parses. Mandatory fields are id, info.name and info.author, and the ID can't contain spaces.
  2. 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.
  3. 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.
  4. Break the control on purpose. Pass a bogus token_b and confirm you get control-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()
Enter fullscreen mode Exit fullscreen mode
# 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)")
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

A few notes on this:

  • test -s is true only for a file that exists and isn't empty, so the job fails on any result, bola-reproduced or control-failed. Both deserve a human: one is the bug, the other is a check that has stopped working.
  • nuclei-action@v3 is the current major version; its README says the v2 references are deprecated. install-only: true installs 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

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/

Top comments (0)