Improper Inventory Management
OWASP API9 — the endpoint that causes the breach is usually the one nobody remembered was running.
API9 is the only Top 10 entry that is not about code. It is about knowing what you run, and most organisations do not.
The two categories
- Shadow APIs — never in the inventory. Built for a partner, shipped from a branch, deployed by a team you do not talk to, exposed by an infrastructure change.
- Zombie APIs — old versions still answering, with nobody watching them.
Both are dangerous for the same reason: they are excluded from every control you believe is universal. Your WAF rules, rate limits, logging, alerting and pentest scope all covered the endpoints you knew about.
What an inventory entry needs
A path and method is not enough. Each entry should carry an owner, an environment, an authentication requirement, a data classification, a version, and a deprecation date if it has one. Without an owner, nothing gets fixed; without a data classification, you cannot prioritise.
Three sources, and the gaps between them
- Code — generate the route table in CI. Complete for what is in this repository.
- Traffic — gateway logs show what is actually called, including things this repository does not serve.
- Outside — DNS, certificate transparency, and a scan of your own ranges.
Diff them. In traffic but not in code means another deployment. In DNS but not in the inventory means a host nobody documented.
Make it a gate
An inventory built once is stale within a month. Generate it in CI, fail the build when a route appears without an owner and an authentication decision, and alert when traffic arrives at an unknown path.
Non-production is production
A staging environment on the public internet with a copy of production data is a production breach waiting to happen. Inventory environments, not just endpoints.
Example
# What is actually reachable, from outside
curl -s 'https://crt.sh/?q=%25.dfg.com&output=json' \
| jq -r '.[].name_value' | tr ',' '\n' | sed 's/^\*\.//' | sort -u > hosts.txt
while read -r h; do
code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 "https://$h/" || echo 000)
[ "$code" != "000" ] && printf '%-34s %s\n' "$h" "$code"
done < hosts.txt
# api.dfg.com 200
# api-v1.dfg.com 200 ← still running. Patched? Monitored? Owned by whom?
# staging-api.dfg.com 200 ← public, with production data?
# old-admin.dfg.com 401 ← decommissioned two years ago. Apparently not.When to use it
- A v1 endpoint kept alive for a single legacy client is exploited months after v2 received the fix.
- A staging API on the public internet is discovered through certificate transparency logs during a routine review.
- Gateway logs reveal steady traffic to a path this codebase does not serve, leading to a forgotten deployment in another account.
More examples
An inventory entry that is actually useful
The stale-review check is what keeps the file from becoming a rubber stamp — without it, an entry written once is never looked at again.
// route-owners.json — committed, reviewed, and enforced in CI.
{
"GET /api/invoices": {
"owner": "@billing-team",
"authentication": "required",
"authorization": "user-scoped",
"dataClassification": "pii",
"version": "v2",
"rateLimit": "standard",
"publicIntentionally": false,
"ownershipChecked": true,
"lastReviewed": "2026-08-04"
},
"GET /api/health": {
"owner": "@platform-team",
"authentication": "none",
"publicIntentionally": true, // ← an explicit decision, not an omission
"dataClassification": "none",
"lastReviewed": "2026-08-04"
},
"POST /api/v1/invoices": {
"owner": "@billing-team",
"authentication": "required",
"version": "v1",
"deprecated": true,
"deprecatedAt": "2026-01-01",
"sunsetAt": "2026-12-31", // ← a date, so it cannot drift forever
"remainingClients": ["legacy-partner-A"],
"lastReviewed": "2026-08-04"
}
}
// The CI gate
import routes from './route-inventory.json' with { type: 'json' };
import owners from './route-owners.json' with { type: 'json' };
const problems = [];
const MAX_REVIEW_AGE_DAYS = 365;
for (const route of routes) {
const key = `${route.method} ${route.path}`;
const entry = owners[key];
if (!entry) { problems.push(`UNOWNED: ${key}`); continue; }
if (!entry.owner) problems.push(`NO OWNER: ${key}`);
// An unauthenticated route must be an explicit decision
if (!route.auth && !entry.publicIntentionally) {
problems.push(`UNAUTHENTICATED without publicIntentionally: ${key}`);
}
// A route taking an object id must have had its ownership check reviewed
if (route.takesId && !entry.ownershipChecked) {
problems.push(`BOLA CANDIDATE unreviewed: ${key}`);
}
// A sunset date that has passed
if (entry.sunsetAt && new Date(entry.sunsetAt) < new Date()) {
problems.push(`PAST SUNSET, still deployed: ${key}`);
}
// A review that is a year old
if (daysSince(entry.lastReviewed) > MAX_REVIEW_AGE_DAYS) {
problems.push(`STALE REVIEW (${entry.lastReviewed}): ${key}`);
}
}
// And the reverse direction: entries with no matching route are dead weight
const live = new Set(routes.map((r) => `${r.method} ${r.path}`));
for (const key of Object.keys(owners)) {
if (!live.has(key)) problems.push(`ORPHANED ENTRY (route removed?): ${key}`);
}
if (problems.length) { console.error(problems.join('\n')); process.exit(1); }Reconciling code, traffic and DNS
The 'routes nobody calls' list is the most satisfying output: deleting an endpoint is the only fix with no ongoing maintenance cost.
#!/usr/bin/env bash
# Weekly. Each source misses what the others catch; the gaps are the findings.
set -uo pipefail
# ── 1. From code ─────────────────────────────────────────────────────
jq -r '.[] | "\(.method) \(.path)"' route-inventory.json | sort -u > /tmp/code.txt
# ── 2. From traffic (normalise ids so the lists are comparable) ───────
aws logs filter-log-events \
--log-group-name /aws/apigateway/prod \
--start-time $(( ($(date +%s) - 604800) * 1000 )) \
| jq -r '.events[].message | fromjson | "\(.httpMethod) \(.path)"' \
| sed -E 's|/[0-9a-f]{8}-[0-9a-f-]{27}|/:id|g; s|/[0-9]+|/:id|g' \
| sort -u > /tmp/traffic.txt
# ── 3. The gap that matters most ─────────────────────────────────────
echo "=== Live traffic to paths this codebase does not serve ==="
comm -13 /tmp/code.txt /tmp/traffic.txt
# → another deployment, an old version, or something nobody owns
echo "=== Routes nobody calls (deletion candidates) ==="
comm -23 /tmp/code.txt /tmp/traffic.txt
# → the safest kind of fix: delete it
# ── 4. Hosts, not paths ──────────────────────────────────────────────
echo "=== Hosts in certificate transparency ==="
curl -s 'https://crt.sh/?q=%25.dfg.com&output=json' \
| jq -r '.[].name_value' | tr ',' '\n' | sed 's/^\*\.//' | sort -u
# ── 5. And what is publicly reachable that should not be ─────────────
echo "=== Non-production hosts answering publicly ==="
for h in $(grep -E 'staging|dev|test|uat|demo|old|legacy' /tmp/hosts.txt); do
code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 "https://$h/" || echo 000)
[ "$code" != "000" ] && echo " $h → $code ⚠️ non-production, publicly reachable"
done
# Findings, in the order they usually matter:
# 1. non-production environments with production data, publicly reachable
# 2. traffic to code you do not have
# 3. old versions with no owner
# 4. routes nobody calls (delete them — free risk reduction)Alerting on the unknown path
Splitting the alert by authenticated versus anonymous is what makes it actionable — authenticated unknown paths are almost always a real client, not an attack.
// Traffic to an unlisted path means a probe, a forgotten client, or an
// endpoint you did not know you had. All three are worth a notification.
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')
.replace(/\/[0-9a-f]{8}-[0-9a-f-]{27}/gi, '/:id')
.replace(/\/\d+/g, '/:id');
}
app.use((req, res, next) => {
const key = `${req.method} ${normalise(req.path)}`;
if (!known.has(key)) {
metrics.increment('api.unknown_path', {
method: req.method,
authenticated: String(Boolean(req.user)),
});
logger.warn({
path: req.path, method: req.method, ip: req.ip,
ua: req.get('user-agent'), userId: req.user?.id ?? null,
status: 'pending',
}, 'request to a path not in the inventory');
}
next();
});
// Two alerts, for two very different situations:
//
// 1. A SPIKE in api.unknown_path from few IPs
// → someone is enumerating. Rate limit, and look at what they are trying.
//
// 2. A STEADY LOW RATE on ONE path, often AUTHENTICATED
// → a real client you did not know about. Do not block it — find the owner.
// This is the alert that discovers the integration another team built two
// years ago and nobody has patched since.
// And a monthly report that turns it into work:
export async function unknownPathReport() {
const rows = await metrics.query(`
sum by (path, authenticated) (rate(api_unknown_path[30d]))
`);
return rows
.filter((r) => r.value > 0.001) // sustained, not a one-off probe
.map((r) => ({
path: r.path,
authenticated: r.authenticated === 'true',
requestsPerDay: Math.round(r.value * 86400),
action: r.authenticated === 'true'
? 'IDENTIFY THE CLIENT — likely a real integration'
: 'probably scanning — confirm and rate limit',
}));
}
Discussion