Security testing tools: ZAP, Semgrep and Trivy
Install and use three free security tools: Semgrep to scan source code, Trivy to scan dependencies, images and config, and OWASP ZAP to test a running app. Practise only on your own code and a deliberately vulnerable lab.
Reading code for security bugs is slow and people miss things. Security testing tools do the boring, repeatable part for you. In this tutorial you will use three free, open-source tools that cover three different questions:
- Semgrep (SAST, static analysis): is there a risky pattern in my source code? - Trivy: do my dependencies, container images and config files contain known vulnerabilities or mistakes? - OWASP ZAP (DAST, dynamic analysis): when my app is running, does it behave in an unsafe way?
Prerequisites: Python and pip, Docker Desktop (or Docker Engine) for ZAP, Trivy and the practice app. Use these tools only on code and apps that you own or have written permission to test. Scanning someone else's website without permission can be illegal. In this lesson the only web target is a deliberately vulnerable app running on your own machine.
What you will do
- 1Semgrep: scan source code and fix what it finds.
- 2Trivy: scan dependencies, an image and config files.
- 3ZAP: run a baseline scan against a local practice app.
- 4Put the scans into CI.
What each tool is good at
Three tools, three views
| Tool | Type | Looks at | Finds | Misses |
|---|---|---|---|---|
| Semgrep | SAST | Source code | Injection patterns, hard-coded secrets, unsafe functions | Problems that only appear at runtime, business-logic flaws |
| Trivy | SCA, image and config scanner | Dependencies, container images, IaC files | Known CVEs, exposed secrets, misconfigurations | Unknown vulnerabilities, your own logic bugs |
| OWASP ZAP | DAST | A running web app | Missing security headers, some injection and XSS issues, cookie flags | Code that the scanner cannot reach, authorisation flaws that need understanding of your users |
No tool finds everything, and every tool produces false positives (a warning that is not a real problem) and false negatives (a real problem it misses). A scanner is a helper, not a judge. You still read each result and decide.
Part 1: Semgrep on source code
Create a folder scan-demo with a file app.py. It is intentionally unsafe so the tool has something to find. The key is fake.
import sqlite3
import subprocess
API_KEY = "sk_live_51HxExampleNotARealKey1234567890" # hard-coded secret
def find_order(db: sqlite3.Connection, customer: str):
# SQL built with string formatting: injectable
query = "SELECT * FROM orders WHERE customer_name = '%s'" % customer
return db.execute(query).fetchall()
def ping(host: str):
# shell=True with user input: command injection
return subprocess.run("ping -c 1 " + host, shell=True, capture_output=True)Three problems: a hard-coded secret, SQL built with string formatting, and a shell command built from user input.
pip install semgrep
cd scan-demo
semgrep scan --config auto .--config auto picks rules from the Semgrep Registry that fit the languages it finds. It needs an internet connection to download the rules. On Windows, Semgrep's own docs recommend running it through WSL or Docker if the pip install gives you trouble.
Findings:
app.py
python.lang.security.audit.subprocess-shell-true...
Found 'subprocess' function 'run' with 'shell=True'...
python.lang.security.audit.formatted-sql-query...
Detected possible formatted SQL query. Use parameterized queries instead.Read each finding: the file and line, the rule that matched and the explanation. Then fix the code. Here is the safe version:
import os
import sqlite3
import subprocess
API_KEY = os.environ["API_KEY"] # from the environment, never in the code
def find_order(db: sqlite3.Connection, customer: str):
# Parameterised query: the driver keeps data separate from SQL
return db.execute(
"SELECT * FROM orders WHERE customer_name = ?", (customer,)
).fetchall()
def ping(host: str):
# Validate first, and pass a list so no shell is involved
if not host.replace(".", "").replace("-", "").isalnum():
raise ValueError("bad host")
return subprocess.run(["ping", "-c", "1", host], capture_output=True)Secret from the environment, parameterised SQL, no shell. See *Managing secrets and API keys* and *Injection attacks: SQL, command and XSS* for the why.
semgrep scan --config auto .Run it again. The three findings should disappear. If a finding remains, read it: either fix it or understand why it is a false positive.
Writing a small custom rule
Semgrep rules look like the code they find. Save this as rules.yml to flag Flask apps started with debug mode on (debug mode lets anyone who can reach the app run code through the debugger, so it must never be on in production).
rules:
- id: no-debug-true
languages: [python]
severity: WARNING
message: Flask debug mode must not be enabled in production.
pattern: $APP.run(..., debug=True, ...)$APP is a metavariable that matches any name. ... matches any arguments.
# demo.py
from flask import Flask
app = Flask(__name__)
app.run(host="0.0.0.0", debug=True)A file the custom rule should flag.
semgrep scan --config rules.yml demo.pyRuns only your rule. Custom rules are how teams enforce their own standards, for example "always use our safe_query() helper".
Part 2: Trivy on dependencies, images and config
Trivy is a scanner from Aqua Security. Install it from the official instructions for your system (Windows, macOS, Linux packages, or run it in Docker). Check with trivy --version. The first run downloads a vulnerability database, so it needs internet.
# 1. Scan a project folder: dependency files, secrets, misconfigurations
trivy fs .
# Only show serious findings
trivy fs --severity HIGH,CRITICAL .
# 2. Scan a container image (public image used as a practice example)
trivy image python:3.9-slim
# 3. Scan Dockerfiles, Kubernetes YAML and Terraform for misconfigurations
trivy config .fs reads lockfiles such as package-lock.json and requirements.txt. image scans the packages inside an image. config checks infrastructure files.
Trivy marks each finding with a severity (LOW to CRITICAL), the installed version and a fixed version if one exists. The fix is usually "upgrade this package to that version". If no fix exists, check whether the vulnerable feature is even used by you.
# Make the command fail (exit code 1) when serious issues exist: useful in CI
trivy fs --severity HIGH,CRITICAL --exit-code 1 .A non-zero exit code makes a CI job fail, which blocks a risky merge.
Bonus: SBOM
Trivy can also generate an SBOM (the ingredients list mentioned in *Dependencies and supply-chain security*): run trivy fs --format cyclonedx --output sbom.json . and look at the file.
Part 3: OWASP ZAP on a running app
ZAP (Zed Attack Proxy) is a free OWASP tool that sits between a browser and a web app, or crawls the app itself, and tests it. For safe practice, start OWASP Juice Shop, an intentionally vulnerable shop built for learning. It runs only on your computer.
docker run --rm -p 3000:3000 bkimminich/juice-shopStarts Juice Shop at http://localhost:3000. Leave this terminal open. Stop it with Ctrl+C.
ZAP's baseline scan crawls the site for a short time and only runs passive checks, so it does not attack the app. It is safe for CI and for learning. Run it in a second terminal. The folder you mount receives the report.
mkdir zap-out
# macOS / Windows with Docker Desktop (host.docker.internal reaches your computer)
docker run --rm -v "$(pwd)/zap-out:/zap/wrk:rw" -t ghcr.io/zaproxy/zaproxy:stable \
zap-baseline.py -t http://host.docker.internal:3000 -r report.html
# Linux: use host networking instead
docker run --rm --network host -v "$(pwd)/zap-out:/zap/wrk:rw" -t ghcr.io/zaproxy/zaproxy:stable \
zap-baseline.py -t http://localhost:3000 -r report.htmlWrites zap-out/report.html. If the report cannot be written because of folder permissions on Linux, check the ZAP Docker documentation for the user and permission settings. On Windows PowerShell use ${PWD} instead of $(pwd).
PASS: ...
WARN-NEW: Content Security Policy (CSP) Header Not Set [10038] x 5
WARN-NEW: Missing Anti-clickjacking Header [10020] x 3
...
FAIL-NEW: 0 FAIL-INPROG: 0 WARN-NEW: 9 WARN-INPROG: 0 INFO: 0 IGNORE: 0 PASS: 50Open report.html in a browser. Each alert has a risk level, the URL, an explanation and a suggested fix. Typical baseline findings are missing security headers (such as Content-Security-Policy or X-Frame-Options), cookies without HttpOnly or Secure flags, and information leaks in headers. These are real, common and usually cheap to fix.
The desktop ZAP application (a free download) also has an active scan that sends attack-like requests, and a proxy mode to explore the app by hand. Active scans can change data or overload an app, so use them only on your own test copies.
Only scan what you are allowed to scan
Never point ZAP, or any scanner, at a site you do not own or have written permission to test. Scanning someone else's server can break the law and can crash it. Practise only on your own apps and on deliberate practice targets such as Juice Shop.
Putting it into your workflow
A simple pipeline
| When | What to run | Why |
|---|---|---|
| While coding | Semgrep in your editor or semgrep scan | Fast feedback on risky patterns |
| Before each commit or pull request | Semgrep and trivy fs in CI | Stops new problems from being merged |
| When building images | trivy image | Finds old OS packages and libraries |
| On a test deployment | ZAP baseline scan | Checks headers, cookies and visible behaviour |
name: security
on: [pull_request]
jobs:
scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Semgrep
run: |
pip install semgrep
semgrep scan --config auto --error .
- name: Trivy
uses: aquasecurity/trivy-action@0.28.0
with:
scan-type: fs
severity: HIGH,CRITICAL
exit-code: 1Runs Semgrep and Trivy on every pull request and fails the check on findings. Check each action's page for the latest recommended version before using it.
I ran [Semgrep / Trivy / ZAP] on my [language] project and got this finding:
<finding>
[paste the finding]
</finding>
Explain in plain English: what the risk is, how an attacker could abuse it in my own app, whether it could be a false positive, and show a minimal code or config fix. If you are unsure, say what I should check.What to expect: Verify the fix by re-running the tool and testing your app. Do not paste real secrets.
Try it: Scan and fix your own project
Run semgrep scan --config auto . and trivy fs --severity HIGH,CRITICAL . on one of your projects. Pick three findings. For each, write: real problem or false positive, the fix, and how you re-checked. Commit the fixes.
Try it: Fix the header warnings
Using the ZAP report from Juice Shop as a guide, pick the two header warnings. Then add those headers to a small FastAPI or Express app of your own (a middleware that sets X-Content-Type-Options: nosniff and X-Frame-Options: DENY is enough), and scan your own app with the ZAP baseline to confirm the warnings are gone.
Key takeaways
- SAST (Semgrep) reads code, SCA and image scanners (Trivy) check dependencies and config, DAST (ZAP) tests a running app. They find different things.
semgrep scan --config auto .,trivy fs .and ZAP'szap-baseline.pyare good first commands.- Treat findings as leads: read, judge for false positives, fix, then scan again.
- Put scanners in CI with a failing exit code so problems are caught before merging.
- Scan only what you own or have written permission to test; practise on deliberately vulnerable apps like Juice Shop.
Check yourself
Finishing the quiz marks the lesson as done.
1. Which tool analyses your source code without running the program?
2. Trivy reports a HIGH vulnerability in a package that your code never uses. What is the sensible response?
3. What is a false positive?
4. Why run ZAP against Juice Shop on your own machine rather than a random website?
5. Which pipeline setting makes a CI job fail when serious vulnerabilities are found by Trivy?
Saved on this device. Create a free account to keep it on every device.
Practise this with Sam
A real mock interview, out loud, with an honest scorecard. Free to start.