Mapping Your Attack Surface
You cannot secure what you do not know exists — and most organisations have more APIs than they think.
The first finding of most API security reviews is not a vulnerability. It is an endpoint nobody remembered: a v1 kept alive for one legacy client, a staging host that answers on the public internet, a debug route behind a feature flag that defaults on.
What counts as surface
- Every documented endpoint, and every undocumented one.
- Every version still answering — v1 usually never got the fix v2 did.
- Every environment reachable from outside: staging, demo, a developer's tunnel.
- Non-production data paths — exports, admin tools, internal dashboards.
- Third-party integrations that call you, and that you call.
- Anything that used to be internal and acquired an ingress.
Shadow and zombie APIs
OWASP names this as its own category. Shadow APIs were never in the inventory — built for a partner, shipped from a branch, deployed by a team you do not talk to. Zombie APIs are old versions still running with nobody watching. Both are dangerous for the same reason: they are excluded from every control you think is universal. Your WAF rules, your rate limits, your logging, your pentest scope — none of it covered them.
Building the inventory
Three sources, and you need all three because each misses what the others catch:
- From code — generate the route table in CI. Complete for what is in the repository.
- From traffic — gateway and load balancer logs show what is actually being called, including things absent from the code you know about.
- From outside — DNS enumeration, certificate transparency logs, and a port scan of your own ranges. This finds the host nobody documented.
Diff them. Anything in traffic but not in code, or in DNS but not in the inventory, is the finding.
Make it a gate
An inventory built once decays in a month. Generate it in CI, fail the build when a new route appears without an owner and an authentication annotation, and alert when traffic arrives at a path the inventory does not contain.
Example
# Certificate transparency logs list every certificate ever issued for a domain.
# It is public, and it is the fastest way to find hosts you forgot.
curl -s 'https://crt.sh/?q=%25.abc.com&output=json' \
| jq -r '.[].name_value' | tr ',' '\n' | sed 's/^\*\.//' | sort -u
# api.abc.com
# api-v1.abc.com ← still running?
# staging-api.abc.com ← public? with production data?
# internal-tools.abc.com ← "internal"
# old-admin.abc.com ← decommissioned two years ago. Or so everyone thought.
# Which of those actually answer?
for h in $(cat hosts.txt); do
code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 "https://$h/health")
[ "$code" != "000" ] && echo "$h → $code"
done
# Attackers run exactly this. Running it first is not optional.When to use it
- A certificate transparency search reveals a staging API on the public internet serving a copy of production data.
- A CI-generated route inventory catches a new endpoint merged without an authentication annotation before it reaches production.
- Gateway logs show traffic to a v1 path that no current code serves, revealing an old deployment still running in a forgotten cluster.
More examples
Generating the inventory from code, in CI
Requiring an explicit publicIntentionally or ownershipChecked flag forces a human decision per route, which is the point — the file becomes a reviewed record.
// scripts/route-inventory.mjs — run in CI; fail on anything unowned.
import { app } from '../src/app.js';
import fs from 'fs';
function extractRoutes(stack, prefix = '') {
const out = [];
for (const layer of stack) {
if (layer.route) {
const middleware = layer.route.stack.map((s) => s.name);
out.push({
method: Object.keys(layer.route.methods)[0].toUpperCase(),
path: prefix + layer.route.path,
// What protects it? Inferred from the middleware chain.
auth: middleware.some((n) => /auth|session|bearer/i.test(n)),
rateLimit: middleware.some((n) => /limit|throttle/i.test(n)),
adminOnly: middleware.some((n) => /admin|requireRole/i.test(n)),
takesId: /:\w*[Ii]d\b/.test(layer.route.path), // BOLA candidate
});
} else if (layer.handle?.stack) {
out.push(...extractRoutes(layer.handle.stack, prefix + cleanPrefix(layer.regexp)));
}
}
return out;
}
const routes = extractRoutes(app._router.stack);
const owners = JSON.parse(fs.readFileSync('route-owners.json', 'utf8'));
const findings = [];
for (const r of routes) {
const key = `${r.method} ${r.path}`;
if (!owners[key]) findings.push(`UNOWNED: ${key}`);
if (!r.auth && !owners[key]?.publicIntentionally) {
findings.push(`UNAUTHENTICATED: ${key}`);
}
if (r.takesId && !owners[key]?.ownershipChecked) {
findings.push(`BOLA CANDIDATE (unreviewed): ${key}`);
}
}
fs.writeFileSync('route-inventory.json', JSON.stringify(routes, null, 2));
if (findings.length) {
console.error(findings.join('\n'));
process.exit(1); // ← the gate. A new route cannot merge unreviewed.
}Diffing code, traffic and DNS
Normalising ids to {id} before sorting is what makes the traffic list comparable to the route list — without it every id is a separate 'unknown' path.
# Each source misses what the others catch. The gaps between them are findings.
# 1. From code — what we believe we run
jq -r '.[] | "\(.method) \(.path)"' route-inventory.json | sort -u > from-code.txt
# 2. From traffic — what is actually being called (gateway/ALB logs)
aws logs filter-log-events --log-group-name /aws/apigateway/prod \
--start-time $(($(date +%s) - 604800))000 \
| jq -r '.events[].message | fromjson | "\(.httpMethod) \(.path)"' \
| sed -E 's|/[0-9a-f-]{8,}|/{id}|g; s|/[0-9]+|/{id}|g' \
| sort -u > from-traffic.txt
# 3. The gap that matters most
comm -13 from-code.txt from-traffic.txt
# → live traffic to paths this codebase does not serve.
# Another deployment. An old version. Something nobody owns.
comm -23 from-code.txt from-traffic.txt
# → routes nobody calls. Candidates for deletion — the safest kind of fix.
# 4. From outside — hosts, not paths
dig +short api.abc.com api-v1.abc.com staging-api.abc.com
curl -s 'https://crt.sh/?q=%25.abc.com&output=json' | jq -r '.[].name_value' | sort -u
# Schedule all four weekly. An inventory built once is out of date in a month.Alerting on the unknown path
The steady-low-rate case is the valuable one: it finds the integration built by another team two years ago that nobody has patched since.
// Traffic to a path that is not in the inventory means one of three things:
// a probe, a forgotten client, or an endpoint you did not know you had.
// All three are worth knowing about within minutes.
import inventory from './route-inventory.json' with { type: 'json' };
const known = new Set(inventory.map((r) => `${r.method} ${normalise(r.path)}`));
function normalise(path) {
return path
.replace(/:[^/]+/g, '{id}') // express params
.replace(/\/[0-9a-f]{8}-[0-9a-f-]{27}/gi, '/{id}') // uuids
.replace(/\/\d+/g, '/{id}'); // numeric ids
}
app.use((req, res, next) => {
const key = `${req.method} ${normalise(req.path)}`;
if (!known.has(key)) {
metrics.increment('api.unknown_path', { method: req.method });
logger.warn({
path: req.path, method: req.method,
ip: req.ip, ua: req.get('user-agent'),
authenticated: Boolean(req.user),
}, 'request to a path not in the inventory');
}
next();
});
// Two alerts worth having:
// spike in api.unknown_path → someone is enumerating your API
// steady low rate on ONE path → a real client you did not know about.
// Do not just block it; find the owner.
// The second is why this is an inventory tool and not only a security alert.
Discussion