All posts

Hands-On Testing · Part 6

Your first security scan with OWASP ZAP (hands-on)

ProvaLab team · · 9 min read

Part 5 explained the OWASP Top 10 and the six kinds of security testing. This post does one of them for real: a DAST scan, where a tool attacks a running application from the outside, the way an attacker would.

We'll use OWASP ZAP, the most widely used free security scanner, against the discount API we built in Part 4. Then we'll plant a real vulnerability and watch the scanner find it.

Every command and every line of output below was run while writing this post.

Before you start: a DAST scan sends thousands of hostile requests. Only ever point it at something you own, and never at a production system: it will create records, trip rate limits and fill logs. Our target runs on our own laptop.

How a DAST scan works

Four stages. Discover: the scanner learns the URLs by crawling or importing an OpenAPI document. Passive scan: it inspects normal responses for missing security headers and leaked information without sending anything hostile. Active scan: it sends attack payloads to every input and watches the reaction; test environments only. Report: findings grouped by risk, each with the request that triggered it, the evidence and a fix.

The scanner has to discover the URLs first. For a website it crawls the pages; for an API it reads the OpenAPI document, which lists every endpoint and parameter. FastAPI generates one for us automatically at /openapi.json, which is why the Part 4 API is a convenient target.

Step 1: Get ZAP (2 minutes, plus a download)

ZAP ships as a container image, so there's nothing to install beyond Docker:

docker pull ghcr.io/zaproxy/zaproxy:stable

It's large (about 3.7 GB), because it carries a full Java runtime and a browser.

Step 2: Start the target (1 minute)

In the first-api-test folder from Part 4, start the API and leave it running:

uvicorn app:app

Step 3: Run the scan (5 minutes)

ZAP includes a ready-made API scan script. One command runs discovery, the passive scan and the active scan, and writes a report:

mkdir -p zap && chmod 777 zap
docker run --rm --network host -v "$PWD/zap:/zap/wrk:rw" ghcr.io/zaproxy/zaproxy:stable \
  zap-api-scan.py -t http://127.0.0.1:8000/openapi.json -f openapi \
  -J report.json -w report.md -I \
  -z "-config replacer.full_list(0).description=auth \
      -config replacer.full_list(0).enabled=true \
      -config replacer.full_list(0).matchtype=REQ_HEADER \
      -config replacer.full_list(0).matchstr=Authorization \
      -config replacer.full_list(0).regex=false \
      -config replacer.full_list(0).replacement=Bearer\ token-alice"

What the pieces mean:

  • --network host lets the container reach the API on your machine. (On macOS and Windows, use http://host.docker.internal:8000 instead of 127.0.0.1 and drop --network host.)
  • -t …/openapi.json -f openapi tells ZAP to import the endpoints from the OpenAPI document.
  • -J and -w write JSON and Markdown reports into the zap folder.
  • -I stops the script from returning a failure code on warnings, so it finishes cleanly.
  • The long -z part configures ZAP's replacer to add Authorization: Bearer token-alice to every request. Without it, every endpoint would answer 401 and the scanner would test nothing but the sign-in wall.
  • If the output ends with "Failed to start the main proxy: Address already in use", something on your machine is using port 8080, which ZAP wants for itself. Add -P 8099 (any free port) after zap-api-scan.py. This happened to us on the third run.

Three minutes later, the end of the output:

Number of Imported URLs: 6
Total of 35 URLs
PASS: Directory Browsing [0]
PASS: Vulnerable JS Library (Powered by Retire.js) [10003]
...
PASS: Remote OS Command Injection (Time Based) [90037]
WARN-NEW: X-Content-Type-Options Header Missing [10021] x 1
	http://127.0.0.1:8000/openapi.json (200 OK)
WARN-NEW: Cross-Origin-Resource-Policy Header Missing or Invalid [90004] x 1
	http://127.0.0.1:8000/openapi.json (200 OK)
FAIL-NEW: 0	FAIL-INPROG: 0	WARN-NEW: 2	WARN-INPROG: 0	INFO: 0	IGNORE: 0	PASS: 118

118 checks passed, 2 warnings, no failures. Both warnings are the same kind of thing: a recommended security header the server doesn't send, flagged on the OpenAPI document itself. ZAP rates them Low. For a tiny API with no browser pages they barely matter, but they're real and the fix is one line of middleware, so they go on the backlog.

(A note on the words: the API-scan script reports every finding as WARN unless you give it a rules file saying which ones should FAIL the build. The actual risk level, High to Informational, is in the report.)

What the scanner cannot see

Remember the most valuable test from Part 4: Alice asking for Bob's order and getting 403. ZAP can't find that bug. It has one token, Alice's, and no way of knowing that order 43 isn't hers. If the API wrongly returned Bob's order, the scanner would see a perfectly healthy 200.

This is the single most important thing to understand about security scanners: they find the generic mistakes, not the ones specific to your business rules. The number-one risk in the OWASP Top 10, broken access control, is mostly found by tests you write. The scanner and your test suite are partners, not alternatives.

Step 4: Plant a vulnerability (3 minutes)

To see what a real finding looks like, let's add two things a team might ship by accident. Append this to app.py:

# ---- two things a real team might ship by accident ----
import sqlite3
import traceback

from fastapi import Request
from fastapi.responses import PlainTextResponse

db = sqlite3.connect(":memory:", check_same_thread=False)
db.execute("create table restaurants(name text, cuisine text)")
db.executemany("insert into restaurants values (?, ?)",
               [("Pizza Palace", "pizza"), ("Curry House", "indian"), ("Sushi Go", "japanese")])


@app.get("/restaurants")
def search_restaurants(q: str = "", customer: str = Depends(current_customer)):
    # the query is built by gluing the user's text into the SQL
    rows = db.execute(f"select name, cuisine from restaurants where name like '%{q}%'").fetchall()
    return [{"name": n, "cuisine": c} for n, c in rows]


@app.exception_handler(Exception)
async def show_errors(request: Request, exc: Exception):
    # "debug mode": send the full stack trace back to whoever asked
    return PlainTextResponse("".join(traceback.format_exception(exc)), status_code=500)

The first is A05 Injection: the search text is glued straight into an SQL statement. The second is A10 Mishandling of Exceptional Conditions (and a bit of A02): any crash sends the full stack trace to the caller.

Restart uvicorn, and try the search with a single quote, the classic probe:

curl "http://127.0.0.1:8000/restaurants?q=%27" -H "Authorization: Bearer token-alice"
Traceback (most recent call last):
  ...
sqlite3.OperationalError: unrecognized token: "'"

One character broke the query, and the server told us exactly which database it uses and what went wrong. That's what an attacker's first probe looks like, and what their first clue looks like.

Step 5: Scan again

Run the same docker run command. This time:

WARN-NEW: A Server Error response code was returned by the server [100000] x 1
WARN-NEW: X-Content-Type-Options Header Missing [10021] x 2
WARN-NEW: SQL Injection [40018] x 1
WARN-NEW: Cross-Origin-Resource-Policy Header Missing or Invalid [90004] x 2
FAIL-NEW: 0	FAIL-INPROG: 0	WARN-NEW: 4	WARN-INPROG: 0	INFO: 0	IGNORE: 0	PASS: 117

ZAP found the injection. The detail is in the report:

[High (Low)] SQL Injection (plugin 40018) x1  CWE 89
   uri:      http://127.0.0.1:8000/restaurants?q=%27 | method: GET | param: q
   attack:   '
   evidence: HTTP/1.1 500 Internal Server Error
   solution: Do not trust client side input, even if there is client side validation
             in place. In general, type check all data on the server side. ...
             use PreparedStatement ... with parameters passed by '?'

[Low (High)] A Server Error response code was returned by the server (plugin 100000) x1
   uri:      http://127.0.0.1:8000/restaurants?q=5%3BURL%3D... | method: GET
   evidence: 500

How to read a finding:

  • High (Low) means High risk, Low confidence. ZAP saw a 500 when it sent a quote, which is a strong hint but not proof. Confirm every finding by hand before you act on it. We already did: the curl above reproduced it.
  • param: q, attack: ' tell you exactly where and how. That's your bug report's steps to reproduce.
  • CWE 89 is the standard identifier for SQL injection, useful for looking up fixes and for compliance reports.
  • The server-error finding is the symptom of the same bug plus the debug handler: rated Low on its own, but the stack trace it leaks is a gift to an attacker.

Step 6: Triage

A scan report is not a to-do list. Sort it:

The findings sorted into three piles. Fix now: SQL Injection on GET /restaurants?q= (High), reproduced by hand with a single quote, raise a bug and fix today with a parameterised query; and the 500 with a stack trace, same root cause plus debug mode. Backlog, real but minor: X-Content-Type-Options and Cross-Origin-Resource-Policy headers missing (Low), one line of server config each. Not applicable: 22 informational notes that the API returned 404, and 4 about non-storable content, both expected for an API; mark them so the next scan is quieter.

  • Fix now: the injection and the stack-trace leak. Confirmed, serious, same root cause.
  • Backlog: the two headers. Real, minor, one line each.
  • Not applicable: the 22 informational notes that the API returned 404 for things that don't exist, and the 4 about responses not being cacheable. An API is supposed to do both. Mark them as not applicable in your tracker so the next scan doesn't repeat them.

Thirty lines of report become two bugs and two backlog items. The bug report for the injection writes itself: the URL, the parameter, the payload ', the expected result (a normal empty list or a 400), the actual result (a 500 with a stack trace), and the CWE.

Step 7: Fix and re-scan

Two changes. The query takes a parameter instead of glued text, and the debug handler goes:

    rows = db.execute("select name, cuisine from restaurants where name like ?", (f"%{q}%",)).fetchall()

Restart, and the single quote now returns an empty list with a 200, as it should:

curl "http://127.0.0.1:8000/restaurants?q=%27" -H "Authorization: Bearer token-alice"
[]

Scan a third time:

Number of Imported URLs: 7
Total of 36 URLs
WARN-NEW: X-Content-Type-Options Header Missing [10021] x 2
WARN-NEW: Cross-Origin-Resource-Policy Header Missing or Invalid [90004] x 2
FAIL-NEW: 0	FAIL-INPROG: 0	WARN-NEW: 2	WARN-INPROG: 0	INFO: 0	IGNORE: 0	PASS: 118

The injection and the server error are gone; 118 checks pass again.

The two header warnings are still there, as expected; they're on the backlog, and a scanner will keep mentioning them until the header is added. That's the right behaviour: a scan is a regression test for security.

Putting it in the pipeline

The same command runs in CI. Start the API, run the scan against it, and keep the report as an artifact:

      - run: pip install -r requirements.txt
      - run: uvicorn app:app &
      - run: mkdir -p zap && chmod 777 zap
      - run: >
          docker run --rm --network host -v "$PWD/zap:/zap/wrk:rw"
          ghcr.io/zaproxy/zaproxy:stable zap-api-scan.py
          -t http://127.0.0.1:8000/openapi.json -f openapi -J report.json -w report.md
          -z "-config replacer.full_list(0).description=auth -config replacer.full_list(0).enabled=true -config replacer.full_list(0).matchtype=REQ_HEADER -config replacer.full_list(0).matchstr=Authorization -config replacer.full_list(0).regex=false -config replacer.full_list(0).replacement=Bearer\ ${{ secrets.TEST_API_TOKEN }}"
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: zap-report
          path: zap/

Without -I, the job fails when ZAP finds anything new, which is what you want once the backlog items are either fixed or listed in a rules file as ignored. Run it nightly rather than on every commit: a full active scan takes minutes, not seconds.

Habits worth keeping

  1. Scan with a signed-in session. An unauthenticated scan tests your sign-in page and nothing else.
  2. Confirm by hand before raising a bug. "High (Low)" is a lead, not a verdict.
  3. Triage into fix-now, backlog, not-applicable, and record the last pile so it stops reappearing.
  4. Keep writing access-control tests yourself. The scanner can't.
  5. Never scan what you don't own.

What's next

In Part 7, the last of this series, we go hands-on with load testing: a k6 script against the same API, finding where it slows down and where it breaks.

ProvaLab AppSec runs this kind of scan, and API fuzzing, dependency, secrets, container and code scans, against targets you've verified you own, from the cloud, and presents the findings as a plain-language fix list ordered by severity, with your confirmed false positives remembered between runs. See it in the two-minute product tour.

Prefer to read offline? The whole Hands-On Testing series is one free PDF: get the PDF.

See ProvaLab working on a real app

Your coding agent runs the tests, reads the failures and fixes them.