Securing a web app you don't fully understand
Somewhere out there is a web app you’re responsible for, and nobody can tell you with a straight face whether the login form is safe to expose to the internet. Maybe you inherited the app. Maybe a colleague vibe-coded it over a weekend. Maybe it’s a decade old and the original team is long gone. All of these are the same problem: you need to secure a web app whose internals you do not fully understand. You cannot walk through every route, every auth check, every dependency, and every database call and vouch for it personally. Rewriting takes quarters you don’t have, and leaving it exposed isn’t acceptable. The middle ground is what this article is about.
When a full audit is not immediately practical, several controls can reduce exposure while you investigate the application. Start by identifying public entry points, sensitive data, and access privileges. This article focuses on three complementary controls:
| Layer | What it does |
|---|---|
| Secret scanning | Finds credentials leaked into the repo and its history. |
| Edge filtering | A WAF in front of the app, filtering obviously malicious traffic. |
| Bot protection | CAPTCHA or score-based bot detection on the forms that have to stay public. |
Secret scanning examines repository content and history. A WAF filters traffic before it reaches the application. Bot protection helps control automated abuse. These work at different layers and do not establish that the application itself is secure.
Virtual patching means blocking exploitation of a specific vulnerability without changing the vulnerable code, for example with a targeted WAF rule. It is one use of edge filtering, not a name for every control in this article.
The edge is where this article spends most of its time, but we’ll also cover a little of what you can do beyond the edge — looking inward instead, at the libraries the app actually runs, at its source code, and at its runtime behavior. Dependency scanning finds known-vulnerable libraries the app already ships, and SAST and DAST at the end of the article catch code-level patterns no edge layer can see.
Most of these layers have a solid self-hosted option that works on any host — gitleaks for secrets, ModSecurity for the WAF, OSV-Scanner for dependencies, Semgrep for SAST, ZAP for DAST. These stay primary throughout. For the managed-cloud equivalents — where you trade some configuration work for a vendor’s tuned ruleset and threat intel — every major cloud ships its own. We’ll reach for Google Cloud Platform as the concrete reference when a managed example helps: Cloud Armor for the WAF, Artifact Analysis for container scanning, reCAPTCHA Enterprise and Adaptive Protection for bot defense. Picking one cloud keeps those examples specific rather than a hand-wave; AWS, Azure, and Cloudflare offer equivalents we’ll mention briefly but not deep-dive.
Three categories of unknown
Before the tools, it is worth naming what you are actually defending against, because that shapes which layer matters most. The unknowns fall into three groups.
What’s in the code and the repo. You don’t know which routes exist — legacy apps grow organically and there are probably admin endpoints nobody mentioned. You don’t know what the code actually does, either because you can’t read it fast enough (legacy) or because nobody read it carefully in the first place (AI-generated). And you don’t know what’s already been leaked into git history. DAST probes the running app from the outside to map what’s actually exposed; SAST gives you a lossy but useful map of risky code patterns; secret scanning catches credentials forgotten in old commits.
Dependencies. Old or unmaintained libraries may have known vulnerabilities. A dependency inventory and scanner can identify affected versions, but findings still need assessment against the application’s actual exposure.
Production traffic. Request and WAF logs can reveal observed attack patterns and false positives. They provide partial visibility, not a complete account of attackers or proof that unflagged traffic is benign.
No tool solves all of these. Each of the three layers below addresses one or more of them; dependency scanning (covered after the layers) and SAST/DAST (at the end) push the depth further once the basics are in place.
Secret scanning
Credentials can end up in source, configuration, fixtures, and documentation. Removing a value from the current file does not remove it from earlier commits, so review repository history as well as the current tree.
Scan the relevant repository history, investigate findings, and revoke or rotate real exposed credentials. Add checks for future changes in CI.
Tools
- gitleaks — fast, Go-based, excellent default rules covering most providers (AWS, GCP, Azure, Stripe, Twilio, GitHub tokens, private keys, JWTs). Run it against the full history, not just HEAD.
- trufflehog — similar detection but adds a verification step: it tests whether a detected secret is actually live (e.g., makes an
sts:GetCallerIdentitycall for AWS keys). Higher-signal output, worth the extra latency. - git-secrets — AWS-focused, lightweight, useful as a pre-commit hook.
- GitHub Secret Scanning — free for public repos, Advanced Security on private. Provider partners may revoke detected tokens; confirm revocation rather than assuming it.
Running a scan and reading the output
gitleaks can be installed several ways — a prebuilt binary, Homebrew, go install, or via Docker. We’ll use Docker here for convenience: nothing to install on the host, the same command works on any machine with a Docker daemon, and CI runners get the same invocation as your laptop.
I just ran this against the repo this article lives in:
docker run --rm -v "$(pwd):/repo" zricethezav/gitleaks:latest \
git /repo --log-opts="--all" --redact --verbose--log-opts="--all" walks every commit on every branch, not just HEAD; --redact masks the secret value in the output so the report itself isn’t a new leak; --verbose prints the full per-finding block instead of just a summary count.
gitleaks emits one finding per potential secret string it identifies — the same block of fields for every match, regardless of which rule fired. A typical finding looks like this:
Finding: STRIPE_SECRET_KEY=REDACTED
Secret: REDACTED
RuleID: stripe-access-token
Entropy: 4.175736
File: blog/src/content/blog/anatomy-of-a-developer-targeted-supply-chain-attack.mdx
Line: 225
Commit: 49e4ca23a5ee6d1ef6b1a566a580a4086fbb84aa
Fingerprint: 49e4ca23a5ee6d1ef6b1a566a580a4086fbb84aa:blog/src/content/blog/anatomy-of-a-developer-targeted-supply-chain-attack.mdx:stripe-access-token:225The five fields that matter:
| Field | What it tells you |
|---|---|
| RuleID | Which detection rule fired (stripe-access-token, aws-access-token, generic-api-key, private-key, etc.). The full default ruleset lives in config/gitleaks.toml in the gitleaks repo — every rule with its regex, description, and any keyword filters. |
| File + Line | Where the match is. |
| Commit | Which commit introduced it — not necessarily the latest one. gitleaks finds it wherever it first appears in history. |
| Entropy | Shannon entropy of the matched string, in bits per character. Higher = more random-looking, which usually means more likely to be a real secret. Generic-API-key rules use entropy as a primary signal; provider-specific rules (Stripe, AWS) match the prefix and don’t need it. |
| Fingerprint | A stable identifier you’ll use to suppress this finding if it turns out to be acceptable. More on that below. |
What entropy actually measures
The character-frequency entropy used in secret scanning is −Σ p(c) log₂ p(c). Its maximum is log₂(N) for N equally frequent symbols. It ignores character order, so a predictable sequence can score highly; it does not prove cryptographic randomness.
Entropy is a heuristic used alongside patterns and context. Hex has a theoretical ceiling of 4 bits per character, a 62-symbol alphanumeric alphabet about 5.95, and Base64’s 64-symbol alphabet 6. Short samples often fall below those ceilings.
The finding’s score of 4.175736 is one signal, not proof that the value is a live key. Check the matched rule and the credential’s provenance before deciding how to handle it.
Triaging findings
When you actually run gitleaks against a real repo, you get three categories of finding — and the same response doesn’t fit all three:
- Real credential: revoke or rotate it, investigate exposure, and remove it from active files. Rewriting history may reduce further exposure but does not revoke a leaked key.
- Intentional example: establish that the value is a non-secret placeholder or an approved, revoked sample before suppressing it. Being quoted in a security article is not proof that it is harmless.
- False positive: record why the match is not a secret and suppress that specific finding.
For categories 2 and 3, the fix is a .gitleaksignore file at the repo root listing the identifiers — the Fingerprint field from the table above — of findings you’ve reviewed and approved:
# Intentional: secrets quoted as evidence in supply-chain writeup
49e4ca23a5ee6d1ef6b1a566a580a4086fbb84aa:blog/src/content/blog/anatomy-of-a-developer-targeted-supply-chain-attack.mdx:stripe-access-token:225
49e4ca23a5ee6d1ef6b1a566a580a4086fbb84aa:blog/src/content/blog/anatomy-of-a-developer-targeted-supply-chain-attack.mdx:generic-api-key:193gitleaks will skip these specific findings on subsequent scans. Comment liberally — future-you needs to know whether each entry was approved because it’s intentional or because it’s a false positive, and the comment is the only durable record of that decision.
Stop the next leak at commit time
Once the historical scan is clean (or fingerprinted-and-suppressed), wire gitleaks into:
- CI: scan commits before merging and publishing.
- Pre-commit hooks: inspect staged changes before a commit is created. These checks reduce accidental leaks but can miss unsupported patterns or be bypassed.
Edge filtering with a WAF
A Web Application Firewall inspects HTTP(S) requests against a ruleset. It decides to allow, log, block, or challenge each request before it reaches your app. A WAF is useful for:
- Catching obvious payloads for classes of attacks: SQL injection, XSS, local/remote file inclusion, command injection, path traversal.
- Blocking known bad paths (
/wp-admin/,.git/config,.env). - Rate limiting, IP reputation blocking, geo filtering.
- Buying time when a CVE drops in a dependency you cannot upgrade today.
It is not useful for broken business logic, broken authorization, or bad session design. It is a compensating control, not a cure.
Managed and self-hosted WAFs differ in engines, rules, limits, and operational features. A common self-hosted stack uses ModSecurity with the OWASP Core Rule Set. CRS can combine matched rules through anomaly scoring; managed products may use CRS-derived and proprietary detections.
Managed: Cloud Armor
A managed WAF reduces the infrastructure you operate, but still requires integration, tuning, and review of false positives. Choose based on where traffic enters the app and which controls you need.
The GCP-native option is Google Cloud Armor. It attaches to a GCP HTTP(S) load balancer, and its preconfigured rule groups (sqli-v33-stable, xss-v33-stable, lfi-v33-stable, rce-v33-stable, etc.) are derived directly from the OWASP CRS — the same ruleset you’d install by hand with ModSecurity.
You can configure Cloud Armor through the GCP Console, the gcloud CLI, the REST API, or — what we’ll show below — declaratively in Terraform. The model is the same regardless of interface: a Cloud Armor security policy is a list of rules with match conditions and actions (allow, deny(403), rate_based_ban). A minimal policy enabling the preconfigured CRS rule groups and a sensible default action looks like:
resource "google_compute_security_policy" "app" {
name = "app-waf"
rule {
action = "deny(403)"
preview = true
priority = 1000
match {
expr { expression = "evaluatePreconfiguredWaf('sqli-v33-stable', {'sensitivity': 2})" }
}
description = "Block SQL injection"
}
rule {
action = "deny(403)"
preview = true
priority = 1001
match {
expr { expression = "evaluatePreconfiguredWaf('xss-v33-stable', {'sensitivity': 2})" }
}
description = "Block XSS"
}
rule {
action = "allow"
priority = 2147483647
match {
versioned_expr = "SRC_IPS_V1"
config {
src_ip_ranges = ["*"]
}
}
description = "Default allow"
}
}Preview mode evaluates a rule without enforcing its blocking action. Configure request logging and sampling, then inspect representative traffic before enabling enforcement. A quiet sampled log alone does not establish that a rule has no false positives.
Cloud Armor’s custom rules use a subset of CEL, so ModSecurity SecRule rules need to be translated rather than copied. You can choose among the CRS versions Google supports, but cannot load an arbitrary upstream release. Account for policy and request charges as well as load-balancer costs.
Other managed options include AWS WAF, Azure WAF, and Cloudflare WAF. Their deployment points, rulesets, and preview or logging controls differ.
Self-hosted: ModSecurity + NGINX
The self-hosted path is the right choice when you want full control over custom rules, you are not on a cloud with a good managed offering, cost-per-request matters at your volume, or compliance constraints make an appliance you operate easier to reason about than a vendor service.
The NGINX connector loads as a dynamic module and connects NGINX to libmodsecurity, which evaluates the configured rules. The architecture is:
Internet → NGINX + ModSecurity → upstream appHere’s how the three pieces are arranged:
- NGINX is the reverse proxy. It terminates TLS, accepts incoming HTTP requests, and — when nothing blocks them — proxies them to the upstream app on a private port.
- libmodsecurity is the rule-evaluation engine. It’s a C++ library, separate from NGINX itself, with no network code of its own — it just takes a request as input and returns a verdict.
- The NGINX ModSecurity connector is a dynamic module (
.sofile) that bridges the two. It hooks into NGINX’s request-handling pipeline and, for every incoming request, hands the URL, headers, and body to libmodsecurity for evaluation.
All three pieces run inside the same NGINX process. The mechanism is NGINX’s dynamic-module loader — both the connector and libmodsecurity end up loaded into each NGINX worker at startup, with no separate ModSecurity daemon, no IPC, no socket between them.
The connector calls libmodsecurity within each NGINX worker process. This avoids an extra network hop, but rule evaluation still adds work. Build the connector for the NGINX version and module configuration in use.
For each request, NGINX passes the URL, headers, and body to libmodsecurity (via the connector). libmodsecurity runs them through the loaded rules — the OWASP CRS plus any custom ones — accumulates an anomaly score, and returns an action: allow, log, or deny. If the verdict is deny, NGINX returns the configured error response (usually 403) and never forwards to the upstream. Otherwise the request proceeds normally.
Once the WAF is in place, make sure these are all true:
- Restrict access to the app so requests must pass through the WAF; a publicly accessible origin lets attackers bypass it.
- TLS terminates at the WAF. ModSecurity cannot inspect what it cannot decrypt.
- The WAF is now a single point of failure — run at least two instances behind an LB if uptime matters.
- Audit log volume will spike. Plan storage.
Install shape on Debian/Ubuntu (abbreviated):
# libmodsecurity
git clone --depth 1 -b v3/master https://github.com/owasp-modsecurity/ModSecurity
cd ModSecurity && git submodule init && git submodule update
./build.sh && ./configure && make -j$(nproc) && make install
# NGINX connector module (must match your NGINX version)
git clone --depth 1 https://github.com/owasp-modsecurity/ModSecurity-nginx
cd nginx-${NGINX_VERSION}
./configure --with-compat --add-dynamic-module=../ModSecurity-nginx
make modules && cp objs/ngx_http_modsecurity_module.so /etc/nginx/modules/
# OWASP Core Rule Set
git clone --depth 1 https://github.com/coreruleset/coreruleset /etc/nginx/modsec/crs
cp /etc/nginx/modsec/crs/crs-setup.conf.example /etc/nginx/modsec/crs/crs-setup.confThen in nginx.conf:
load_module modules/ngx_http_modsecurity_module.so;
http {
modsecurity on;
modsecurity_rules_file /etc/nginx/modsec/main.conf;
server {
listen 443 ssl;
server_name legacy-app.example.com;
ssl_certificate /etc/ssl/certs/app.crt;
ssl_certificate_key /etc/ssl/private/app.key;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
}Roll out in detection-only mode first. Set SecRuleEngine DetectionOnly for at least a week. Read the audit log. You are looking for false positives (CRS rules firing on legitimate traffic — editors, admin dashboards, file uploads) and unexpected true positives (attacks already hitting you). The CRS uses anomaly scoring — rules contribute a score and a request is only blocked when the total crosses a threshold — which makes tuning gentler than per-rule blocking.
When you find a false positive, use SecRuleRemoveById scoped to the specific location first, lower the paranoia level on a path second, and disable the rule globally only as a last resort. Only after the logs are quiet enough to be meaningful, flip SecRuleEngine On.
For a virtual patch on /api/report?format=X, this phase-1 rule rejects query-string format values outside the allowlist. It does not require the parameter to be present or validate a request-body parameter:
SecRule REQUEST_URI "@beginsWith /api/report" \
"id:1000001,phase:1,chain,deny,status:400,\
msg:'virtual patch: /api/report format allowlist'"
SecRule ARGS:format "!@rx ^(pdf|csv|json)$"Bot protection and CAPTCHA
The other layers in this stack barely touch a threat class that will almost certainly hit a public legacy app: opportunistic bot abuse on endpoints that have to be unauthenticated — spam signups, credential-stuffing runs against your login form, mass scraping, automated coupon abuse, password-reset floods that trigger emails at scale.
Public login, signup, and reset endpoints need abuse controls as well as authentication. Rate limits and bot signals help, but neither can perfectly distinguish a distributed attack from legitimate traffic.
The options below cover browser challenges, request risk scores, and traffic-anomaly detection. These are related but distinct capabilities.
Browser integrations produce tokens that must be validated on the server or at the edge. Network-level detectors use other signals; some bot-management products also combine them with client-side JavaScript.
We’ll start with the easiest to set up — Cloudflare Turnstile — then look at the deeper GCP-native combination of reCAPTCHA Enterprise plus Cloud Armor Adaptive Protection for teams that need more.
Cloudflare Turnstile (the easiest path)
Cloudflare Turnstile provides browser challenges and a token validated through its Siteverify API. Managed mode can show an interaction when needed; it is not always invisible and does not expose a reCAPTCHA-style risk score. Migration from reCAPTCHA requires client and server integration changes.
Turnstile can be used without moving the site’s hosting or DNS to Cloudflare. Follow the setup documentation for both the widget and server-side validation.
Turnstile stops being enough once you need finer control — per-request scores you can act on (not just pass/fail), enforcement at the load balancer instead of in app code, or rules more nuanced than “challenge passed / failed.” That’s the gap the reCAPTCHA Enterprise + Cloud Armor combination below fills.
reCAPTCHA Enterprise (deeper GCP integration)
reCAPTCHA can return risk scores and assessment reasons. A score is a risk signal, not a calibrated probability that a visitor is human. Supported key types and challenge behavior depend on the integration.
Cloud Armor can evaluate supported reCAPTCHA tokens in security-policy expressions. Check token validity as well as the score; missing or invalid tokens need an explicit policy.
rule {
action = "deny(403)"
priority = 500
match {
expr {
expression = "token.recaptcha_action_token.valid && token.recaptcha_action_token.score < 0.5"
}
}
description = "Block low-score bot traffic to sensitive endpoints"
}For this integration, the client sends the supported token and Cloud Armor enforces the configured rule before the backend. The application still needs its own authentication, authorization, and abuse handling.
There’s also a WAF-proper mode in Cloud Armor called bot management actions (redirect, googleRecaptcha), which issues a reCAPTCHA challenge inline at the edge — useful for the “suspicious traffic spike, challenge everyone until it subsides” scenario.
Classifier-only detection (no client-side integration)
Edge services can detect traffic anomalies without adding a widget to each form. This is distinct from verifying a browser challenge or assigning a bot score to every request.
Cloud Armor Adaptive Protection focuses on Layer 7 DDoS detection and suggested mitigation rules. Cloudflare Bot Management provides bot-detection signals using several detection methods. They address overlapping concerns, but are not equivalent per-request classifiers.
Challenges add friction for automated abuse and legitimate users alike. Their value depends on the endpoint, attackers, accessibility needs, and false-positive rate. They cannot guarantee that a request comes from a human.
- Apply controls to abuse-prone actions such as signup, login, and password reset.
- Validate challenge tokens server-side and handle missing or invalid tokens explicitly.
- Combine them with rate limits and monitoring.
- Protect administrative access with authentication and authorization; add network restrictions where appropriate.
Beyond the edge — dependency scanning with OSV-Scanner
Dependency scanning examines the libraries shipped with the application. It complements traffic filtering by identifying known vulnerable versions that need assessment and, where applicable, an upgrade.
Why OSV-Scanner
OSV-Scanner checks dependency versions against the OSV vulnerability database, which aggregates advisories from multiple ecosystems, including GitHub Security Advisories. Advisory sources and package coverage vary across scanners.
It reads common lock files (package-lock.json, yarn.lock, Pipfile.lock, etc.), resolves each package at its exact pinned version, and reports vulnerabilities with CVE IDs, severity, and (where OSV has it) the version range that fixes them.
Running a scan and reading the output
OSV-Scanner can be installed via Go, Homebrew, a prebuilt binary, or — same as gitleaks above — run via Docker. We’ll show Docker for consistency.
I just ran this against the repo this article lives in:
docker run --rm -v "$(pwd):/src" ghcr.io/google/osv-scanner:latest \
scan source --recursive /src--recursive walks every directory under /src and finds lockfiles in nested projects (the blog, several demos, the server, draft experiments). Without it, the scanner only inspects the top level — fine for a single-project repo, but most real ones have lockfiles spread across several directories.
The summary at the end of the run looks like this:
Total 35 packages affected by 59 known vulnerabilities (2 Critical, 17 High, 37 Medium, 3 Low, 0 Unknown) from 2 ecosystems.
59 vulnerabilities can be fixed.The recorded scan includes findings such as the following. These are a snapshot of the repository and advisory data at scan time, not a statement about the current checkout.
| OSV URL | CVSS | Ecosystem | Package | Version | Fixed version | Source |
|---|---|---|---|---|---|---|
| GHSA-xq3m-2v4x-88gg | 9.4 | npm | protobufjs | 7.5.4 | 7.5.5 | blog/…/transformers-js-demo/package-lock.json |
| GHSA-p9ff-h696-f583 | 8.2 | npm | vite | 7.3.1 | 7.3.2 | blog/package-lock.json |
| PYSEC-2025-40 | 7.5 | PyPI | transformers | 4.48.3 | 4.49.0 | blog/…/from-scratch/requirements.txt |
| GHSA-r5fr-rjxr-66jc | 8.1 | npm | lodash | 4.17.23 | 4.18.0 | server/package-lock.json |
OSV URL— links straight to the advisory at osv.dev (CVE ID, attack vector, affected version range, references).CVSS— severity score on the 0–10 scale. 9+ is Critical, 7–8.9 is High, 4–6.9 is Medium.Ecosystem— which package registry the dependency comes from. The same package name can exist in multiple ecosystems and get different CVEs.PackageandVersion— what’s currently pinned in the lockfile.Fixed version— the lowest version that resolves the issue. Often a patch upgrade; sometimes a minor or major.Source— which lockfile the finding came from. Critical for monorepos: the same package can be pinned at different versions in different sub-projects, which is exactly what we see forvite,protobufjs, andpicomatchin the actual scan.
Notice that the summary breaks the count two ways: 59 known vulnerabilities total, and 59 vulnerabilities can be fixed — meaning each one has a known upgrade target. In this scan they happen to match, which isn’t always the case.
A listed fixed version is a candidate upgrade target, not proof that an upgrade is compatible or complete. Read the advisory, check relevant release branches and transitive dependencies, then test the change.
OSV-Scanner also supports lockfile, SBOM, and image workflows. Use the documentation for the installed version’s command syntax and container-access requirements.
What to do with the findings
Run it once on a legacy app and you’ll almost certainly get a wall of findings — easily dozens, sometimes hundreds. For each finding, two questions decide what to do: how serious is it, and is there a fix available.
Prioritize actively exploited and exposed vulnerabilities, considering severity, reachable code, data at risk, and available mitigations. A low score is not a blanket reason to ignore a finding, and a high score alone does not determine an identical deadline for every system.
Findings without a usable fix need mitigation and continued tracking:
If no usable upgrade exists, consider disabling the affected feature, restricting access, or replacing the dependency. A WAF rule is a possible temporary measure only when the exploit crosses traffic the WAF can inspect. Document any suppression with an owner and review date; suppressing a finding does not remove the vulnerability.
Alternatives and when to pick each
Other options include Artifact Analysis for supported registry images, Trivy for several artifact and configuration types, and Dependabot for alerts and update pull requests. Enable the required features and check their ecosystem coverage; they are not interchangeable scans of the same data.
A reasonable minimal stack: OSV-Scanner in CI (gates the build on high-severity new findings) + Dependabot (continuously opens upgrade PRs). If you already ship containers, either add Trivy for image scanning, or — if you push to a cloud registry — lean on the registry’s built-in scanner (Artifact Analysis on GCP, ECR + Inspector on AWS) rather than operating another tool.
Scanners aren’t supply-chain hygiene
Known-vulnerability scanning is not a complete supply-chain defense. Some feeds include known malicious packages, but scanners cannot guarantee detection of a new backdoor, compromised maintainer, or malicious install script. Review dependency changes and limit what installation can access.
The OWASP NPM Security Cheat Sheet is a useful concrete checklist for this if your stack is Node.js — covering --ignore-scripts, npm audit, scoped packages, typosquat-spotting, and more. OWASP’s Vulnerable Dependency Management Cheat Sheet covers the same ground language-agnostically. Both pair well with the scanner-based layer above: scanners for known-bad, hygiene for unknown-bad.
What this stack covers, and what could go further
If you deploy the three core layers plus dependency scanning, what have you actually bought yourself?
What these controls can reduce: exposed secrets, exploitation of known dependencies once patched, matching malicious requests, and some automated abuse. Coverage depends on configuration, detection limits, and follow-through on findings.
Still missed:
- Vulnerable patterns inside your own code. The WAF blocks payloads at request time but doesn’t help you find or fix the underlying vulnerable code. SAST and DAST below are how you start to.
- App-logic flaws — anything that requires knowing what the app is supposed to do. IDOR (a request to
/orders/1234when the user should only see1233), broken authentication or session management, missing or inconsistent authorization checks, business-logic bugs like skipping the payment step by replaying a cart-checkout request. None of these look malicious to a scanner. - Novel zero-days in the app or its dependencies, before signatures exist.
- Determined adversaries. Insiders abusing legitimate access. Well-resourced bot operators who pay CAPTCHA-solving services and rotate IPs faster than you can blocklist them.
What to add next: SAST and DAST
Once the three core layers and dependency scanning are in place, the natural next step is analyzing the code itself rather than only the traffic flowing through it. Two complementary techniques:
- SAST examines source without running it. Tools such as Semgrep and CodeQL can flag supported vulnerability patterns, but need suitable rules and review.
- DAST probes a running application. ZAP offers passive analysis and active testing. Even a baseline scan crawls the app and generates traffic; use an authorized target and account for routes with side effects. Run active tests in an isolated environment with disposable data.
Use authenticated DAST sessions where permitted to reach protected application routes. Combine scheduled scans with review of authorization and business logic that automated checks may miss.