TRIAGE/Security Triage — Cross-Category Prioritization00 / 18
SECURITY TRIAGE
OWASP Top 10 · 2025
Which fix
comes first?
Every scenario has two real fixes. You pick the one with more leverage.
// the rules

You've completed 10 modules. Each one taught a category in isolation. Production systems don't work that way — vulnerabilities overlap, resources are finite, and you can't fix everything at once.

Ten scenarios follow. In each one, two OWASP categories intersect. Both fixes are real. Both matter. But you have budget for one. Pick the higher-leverage fix.

How this works: There are no trick questions. Both options in every scenario are defensible. The "right" answer is the one with higher leverage — the fix that reduces the most risk per unit of effort. Reasonable people can disagree. The tradeoff explanation after each choice is where the real learning happens.

// meet your learning buddy

This is Waspy.

Strict but supportive. Won't sugarcoat a bad catch block, but won't leave you alone with it either. He changes outfits depending on what's happening — click each state to see.

← Click a state
Go on, he doesn't bite. Technically he could sting, but he chooses education over violence.
👋 Introduce yourself (optional)

Tell Waspy your name, role, and what you find tricky in security. He'll remember it for this session and tailor his feedback.

Two ways to use Waspy on each scenario:

🛡 Explain this tradeoff — Waspy teaches you the core insight. No writing needed.
Check my reasoning → — Write why you chose your fix. Waspy evaluates your thinking.

// scenario 1 of 10

The search endpoint that talks too much

An internal API accepts user input in a search query and passes it to a SQL backend. The input is parameterized — but the error handler is not. When a query fails, the endpoint returns detailed error messages: table names, column types, query structure. When a query targets a nonexistent table, it returns a different error than when it targets an existing table with wrong permissions.

search.controller.js
try { const results = await db.query( 'SELECT * FROM products WHERE name ILIKE $1', [`%${req.query.q}%`] ); res.json(results.rows); } catch (err) { res.status(500).json({ error: err.message, // "relation 'users' does not exist" detail: err.detail, // column types, constraint names query: err.query // the failed SQL }); }

The query is parameterized (A05 injection is mitigated). But the error responses are a reconnaissance goldmine (A10 information disclosure). Two categories, one endpoint.

// scenario 1
which fix has higher leverage?
A05 · Injection
Add input validation + allowlisting on the search query
Restrict search input to alphanumeric characters. Add query length limits. Defense-in-depth against any future injection if parameterization is bypassed.
A10 · Exceptional Conditions
Replace detailed error responses with generic messages
Return "Search failed" to the client. Log the full error internally. Eliminate the reconnaissance path that maps your database schema.
// the tradeoff
scenario 1

Higher leverage: fix the error responses (A10).

The parameterized query already blocks SQL injection — the A05 risk is mitigated. But the verbose error messages hand an attacker a map of your database on every failed request.

before — leaks schema
catch (err) { res.status(500).json({ error: err.message, detail: err.detail, query: err.query }); }
after — generic + structured log
catch (err) { logger.error({ event: 'search_error', err: err.message, query: req.query.q }); res.status(500).json({ error: 'Search failed' }); }
🛡 security lead
The parameterized query buys us time. The error messages are active intelligence leakage. Fix the leak, then schedule the input validation for the next sprint.
⌨ dev lead
Took 20 minutes. Replaced the catch block with a generic message and added structured logging. The input validation is in the backlog — it's not urgent while parameterization holds.
// scenario 2 of 10

The build server that trusts everything

A CI/CD pipeline pulls dependencies from npm without a lockfile pinned to exact hashes. The package-lock.json exists but isn't committed — npm install resolves fresh ranges on every build.

Jenkinsfile
pipeline { agent { label 'build-01' } // shared VLAN with dev workstations stages { stage('Install') { steps { sh 'npm install' // resolves fresh ranges every time } // no npm ci, no integrity check } stage('Build') { steps { sh 'npm run build' archiveArtifacts 'dist/**' } } } }

Two supply chain risks: unpinned dependencies that could be swapped for malicious versions (A03), and a build environment on the same network as developer laptops — reachable via lateral movement (A08).

// scenario 2
which fix has higher leverage?
A03 · Supply Chain
Pin the lockfile and verify dependency integrity hashes
Commit package-lock.json with exact versions. Add npm ci to enforce it. Enable npm audit in CI. Stops dependency confusion and typosquatting.
A08 · Integrity Failures
Isolate the build server onto its own network segment
Move CI/CD to an isolated VLAN or cloud VPC. No inbound from developer workstations. Build artifacts are the only thing that leaves. Stops lateral movement from a compromised laptop.
// the tradeoff
scenario 2

Higher leverage: isolate the build server (A08).

Lockfile pinning stops one attack vector — dependency substitution. But if the build server is reachable from compromised endpoints, an attacker doesn't need to poison a package. They can inject code directly into the build process, modify artifacts, or exfiltrate secrets from CI environment variables.

before — .github/workflows/build.yml
jobs: build: runs-on: self-hosted # on-prem runner steps: - uses: actions/checkout@v4 - run: npm install # not npm ci # no lockfile integrity # runner on corp VLAN # reachable from dev laptops - run: npm run build
after — isolated runner + lockfile
jobs: build: runs-on: ubuntu-latest # ephemeral cloud permissions: contents: read # least privilege steps: - uses: actions/checkout@v4 - run: npm ci # lockfile enforced - run: npm audit --audit-level=high - run: npm run build
🛡 security lead
A compromised build server is game over — it touches every artifact we ship. Lockfile pinning matters, but it only covers one supply chain vector. Isolation covers them all.
⌨ dev lead
We moved CI to an isolated VPC with no inbound SSH. Deployments trigger via webhook, not direct access. Lockfile pinning went in the same week — it was a one-line CI change.
// scenario 3 of 10

The password reset nobody rate-limits

A SaaS application has two authentication paths. The login page uses email + password — no MFA. The password-reset flow sends a 6-digit code by email, valid for 15 minutes. There's no rate limit on reset code attempts.

reset.controller.js
router.post('/reset/verify', async (req, res) => { const { email, code } = req.body; const reset = await db.findReset(email); if (reset.code === code) { // 6 digits: 000000–999999 return res.json({ token: issueResetToken(email) }); } res.status(400).json({ error: 'Invalid code' }); // no rate limit // no attempt counter // no lockout after failures });

6 digits = 1,000,000 combinations · 15 min window · ~1,100 req/sec = full keyspace in under 15 minutes

The reset flow is a design flaw — a guessable code without rate limiting (A06). The login page lacks MFA (A07). Budget for one fix this quarter.

// scenario 3
which fix has higher leverage?
A06 · Insecure Design
Rate-limit the password-reset code + increase to 8 digits
Max 5 attempts per code. Lock the code after failures. Increase to 8 digits (100M combinations). Shuts down the brute-force path on the reset flow.
A07 · Authentication
Add MFA to the login flow
TOTP or WebAuthn on every login. Even if credentials are stolen via phishing or credential stuffing, the attacker can't complete authentication.
// the tradeoff
scenario 3

Higher leverage: add MFA to login (A07).

The login page is the primary authentication path — it's hit by credential stuffing attacks daily. MFA blocks them all, regardless of how the credentials were obtained.

login.controller.js — with TOTP
const user = await db.findByEmail(email); if (!user || !await bcrypt.compare(password, user.hash)) return res.status(401).json({ error: 'Invalid credentials' }); if (user.mfaEnabled) { const valid = speakeasy.totp.verify({ secret: user.mfaSecret, token: req.body.totpCode, window: 1 }); if (!valid) return res.status(401).json({ error: 'Invalid MFA code' }); } res.json({ token: issueJWT(user) });

The reset flow is vulnerable, and fixing it matters — but the login page is where the volume is. Protecting the front door beats reinforcing the side entrance.

🛡 security lead
MFA on login is the single highest-impact control in our entire auth stack. The reset flow is exploitable, but it's a narrower attack surface. Fix the high-traffic path first.
⌨ dev lead
We shipped TOTP in two weeks. The reset-flow rate limit was a half-day fix — we did it in the same sprint. But if we'd had to choose, MFA first, every time.
// scenario 4 of 10

The admin panel nobody changed the password for

A monitoring dashboard (Grafana) is exposed on the internal network with default credentials. It has read access to production metrics, database connection health, and service topology. The organization also has no centralized security logging.

$ terminal — anyone on the network
# this works right now, from any machine on the internal network curl 'http://grafana.internal:3000/api/datasources' \ -u admin:admin | jq '.[].url' # output: # "postgres://prod-db.internal:5432/main" # "http://prometheus.internal:9090" # "http://elasticsearch.internal:9200" # full database connection strings, internal hostnames, # service map — all with zero authentication effort

Default credentials on an admin panel (A02 security misconfiguration). No security monitoring or alerting (A09 logging failures). The team can fix one this sprint.

// scenario 4
which fix has higher leverage?
A02 · Misconfiguration
Change the default credentials + require SSO for admin panels
Rotate the password immediately. Add SSO/LDAP integration so Grafana uses corporate identity. Disable local admin login. Closes the open door now.
A09 · Logging Failures
Deploy centralized logging with security alerting
Ship logs to a SIEM. Create alerts for auth failures, privilege escalation, and data access anomalies. Build the detection capability the team is missing.
// the tradeoff
scenario 4

Higher leverage: change the credentials (A02).

Default credentials are exploitable right now, by anyone on the network, with zero skill required. The Grafana panel gives an attacker production connection strings and service topology — a foothold for lateral movement.

grafana.ini — the 5-minute fix
# Step 1: Disable local admin login [auth] disable_login_form = true # Step 2: Enable SSO via corporate identity [auth.generic_oauth] enabled = true client_id = grafana-prod auth_url = https://sso.corp.internal/authorize token_url = https://sso.corp.internal/token allowed_domains = company.com # Step 3: Restrict data source access [security] admin_user = "" # no local admin at all

Centralized logging helps you detect attacks — but it doesn't stop one that's already possible through an open door. Close the door first, then build the alarm system.

🛡 security lead
This is a 5-minute fix that removes an active vulnerability. Logging infrastructure takes weeks to deploy well. We need both, but the open admin panel can't wait.
⌨ dev lead
Changed the password in 2 minutes. Started SSO integration that afternoon. The SIEM project is in the quarterly roadmap — it's important but not the fire we need to put out today.
// scenario 5 of 10

The microservice that trusts everyone

A backend microservice accepts JWTs from the API gateway but doesn't validate the signature — it base64-decodes the payload and trusts the claims. The same service runs as root in its container.

auth-middleware.js
// decodes JWT payload without // verifying signature const payload = JSON.parse( atob(token.split('.')[1]) ); req.user = { id: payload.sub, role: payload.role // forgeable }; next();
Dockerfile
FROM node:20-alpine WORKDIR /app COPY . . RUN npm ci --production # no USER directive # runs as root by default # container escape = host root CMD ["node", "server.js"]

Missing JWT signature validation means anyone can forge tokens (A01 broken access control). Running as root means a container escape gives the attacker host-level access (A02 security misconfiguration). Both are real — but which one creates the vulnerability?

// scenario 5
which fix has higher leverage?
A01 · Broken Access Control
Validate the JWT signature before trusting claims
Use the gateway's public key to verify the token signature. Reject unsigned or tampered tokens. Claims become trustworthy only after cryptographic verification.
A02 · Misconfiguration
Run the container as a non-root user
Add USER node to the Dockerfile. Drop capabilities. Limit blast radius if the service is compromised — an attacker with root can escape the container.
// the tradeoff
scenario 5

Higher leverage: validate the JWT signature (A01).

Without signature validation, anyone can forge a token with any claims. The service has no access control at all. Running as root worsens the blast radius but doesn't create the vulnerability.

before — trusts any token
const payload = JSON.parse( atob(token.split('.')[1]) ); req.user = { id: payload.sub, role: payload.role }; next();
after — cryptographic verification
try { const payload = jwt.verify( token, GATEWAY_PUBLIC_KEY, { algorithms: ['RS256'] } ); req.user = { id: payload.sub, role: payload.role }; next(); } catch { res.status(401).json({ error: 'Invalid token' }); }
Dockerfile — the blast-radius fix (second priority)
FROM node:20-alpine WORKDIR /app COPY --chown=node:node . . RUN npm ci --production USER node # not root RUN apk add --no-cache dumb-init ENTRYPOINT ["dumb-init", "node", "server.js"]
🛡 security lead
The JWT validation is the vulnerability. Running as root is a severity multiplier. Always fix the vulnerability before reducing the blast radius — you can't contain what you can't prevent.
⌨ dev lead
Added jwt.verify() with the gateway's public key — 4 lines of code. The Dockerfile USER change was a separate PR the same day. Both shipped, but the signature check was the blocker.
// scenario 6 of 10

The API keys hashed with MD5

An internal service stores API keys for 200+ partner integrations. The keys are hashed — but with unsalted MD5. Meanwhile, a dependency audit flagged a medium-severity CVE in a logging library. No known exploit, CVSS 5.3.

key_store.py
import hashlib def store_api_key(partner_id, raw_key): hashed = hashlib.md5( raw_key.encode() ).hexdigest() # unsalted MD5 db.execute( "INSERT INTO keys (partner, hash) VALUES (%s, %s)", (partner_id, hashed) )

MD5-hashed API keys without salt (A04). A medium-severity CVE with no known exploit (A03). One fix this sprint.

// scenario 6
which fix has higher leverage?
A04 · Cryptographic Failures
Re-hash all API keys with bcrypt + per-key salt
Unsalted MD5 hashes are reversible in seconds on commodity hardware. A database leak = every partner key compromised instantly.
A03 · Supply Chain
Patch the vulnerable logging library
Update to patched version. Run pip audit in CI. Close the CVE before an exploit appears in the wild.
// the tradeoff
scenario 6

Higher leverage: re-hash the API keys (A04).

Unsalted MD5 hashes are reversible in seconds. A leaked database gives an attacker every partner's API key immediately. The CVE is medium-severity with no known exploit — a future risk. The MD5 keys are a present one.

before — MD5, no salt
hashlib.md5(key).hexdigest() # cracked in <1 sec per key
after — bcrypt, cost 12
bcrypt.hashpw(key, bcrypt.gensalt(12)) # ~250ms per attempt
🛡 security lead
A database leak with MD5-hashed keys is a full partner compromise. The CVE is a maybe. Fix the certain problem first.
⌨ dev lead
Key rotation took a weekend. Notified partners, issued new keys, ran both in parallel for 48 hours. The pip audit fix was a one-liner after.
// scenario 7 of 10

The report builder that concatenates SQL

An admin dashboard has a custom report builder. Most queries use the ORM — but the "advanced filter" concatenates user input directly into SQL. The same endpoint also lacks tenant-level authorization.

reports.controller.js
router.get('/reports/advanced', requireAdmin, async (req, res) => { // no tenant_id check — any admin sees all tenants const sql = `SELECT * FROM orders WHERE status = '${req.query.status}' AND created > '${req.query.from}'`; // string concat! const rows = await db.raw(sql); res.json(rows); });

SQL injection via string concatenation (A05). Missing tenant-level access control (A01). Both on the same endpoint.

// scenario 7
which fix has higher leverage?
A05 · Injection
Parameterize the SQL query
Replace string concatenation with parameterized queries. An attacker with injection can read, modify, or delete any data in the entire database.
A01 · Broken Access Control
Add tenant-scoped authorization
Filter queries by the admin's tenant_id. Each admin only sees their own organization's data.
// the tradeoff
scenario 7

Higher leverage: parameterize the SQL (A05).

SQL injection gives full database access — UNION SELECT * FROM users bypasses any row-level check. Tenant authorization limits what the application shows; injection lets the attacker talk directly to the database.

fix — parameterized + tenant-scoped
const rows = await db.query( 'SELECT * FROM orders WHERE status = $1 AND created > $2 AND tenant_id = $3', [req.query.status, req.query.from, req.user.tenant_id] );
🛡 security lead
Injection supersedes access control. An attacker with SQLi doesn't need your authorization layer — they talk to the database directly.
⌨ dev lead
Parameterized it and added tenant_id in the same PR. The parameterization was the blocker — tenant filtering was a WHERE clause addition.
// scenario 8 of 10

The file upload that accepts anything

A document-sharing feature lets users upload files with no validation — type, size, or content. The server saves whatever it receives to a publicly accessible directory. The download endpoint doesn't check ownership either.

upload.controller.js
router.post('/files/upload', async (req, res) => { const file = req.files[0]; // no type check — .php, .jsp, .exe all accepted await fs.writeFile( `/var/www/uploads/${file.originalname}`, // public dir! file.buffer ); res.json({ url: `/uploads/${file.originalname}` }); });

Unrestricted file upload to a public directory — potential RCE via webshell (A06). Download endpoint missing ownership check (A01). Budget for one fix.

// scenario 8
which fix has higher leverage?
A06 · Insecure Design
Validate uploads: allowlist types, scan content, isolate storage
Only accept .pdf/.docx/.png. Store outside webroot with randomized names. An unrestricted upload to a public directory is a webshell waiting to happen.
A01 · Broken Access Control
Add ownership check on the download endpoint
Verify the requesting user owns the file before serving it. Prevents unauthorized access to other users' documents.
// the tradeoff
scenario 8

Higher leverage: validate the uploads (A06).

Unrestricted file upload to a public directory = remote code execution. Upload shell.php, visit /uploads/shell.php, own the server. Broken download auth leaks documents — serious, but not code execution.

fix — allowlist + isolated storage
const ALLOWED = new Set(['.pdf', '.docx', '.png', '.jpg']); const ext = path.extname(file.originalname).toLowerCase(); if (!ALLOWED.has(ext)) return res.status(400).json({error: 'Type not allowed'}); const name = crypto.randomUUID() + ext; await fs.writeFile(`/var/data/files/${name}`, file.buffer); // outside webroot, no path traversal
🛡 security lead
RCE via webshell is game over. The broken download auth leaks data — but a server compromise lets them get ALL the data anyway.
⌨ dev lead
Moved uploads out of webroot, added type allowlist and randomized filenames. Download auth check was a follow-up PR same day.
// scenario 9 of 10

The typosquatted package already in the build

A security scan discovered lodsah (not lodash) in package.json. It was installed 8 months ago and runs a postinstall script on every npm install. Separately, the password policy allows 123456 and password.

package.json
{ "dependencies": { "express": "^4.18.0", "lodash": "^4.17.21", "lodsah": "^1.0.0", // typosquat! "pg": "^8.11.0" } }
password policy
if (password.length >= 6) { accept(); } // allows: 123456, password, // qwerty, letmein...

Active malware in the dependency tree running on every build (A03). Weak password policy allowing common passwords (A07). One fix first.

// scenario 9
which fix has higher leverage?
A03 · Supply Chain
Remove the typosquatted package + rotate all CI secrets
Remove lodsah. Audit the postinstall script. Rotate every secret CI had access to. The malware has been executing for 8 months.
A07 · Authentication
Implement password strength + breach-list check
Require 12+ chars, block common passwords via HIBP, force reset for weak passwords. Stops credential stuffing.
// the tradeoff
scenario 9

Higher leverage: remove the typosquatted package (A03).

This isn't a vulnerability — it's an active compromise. The malware has been running for 8 months on every build. It may have exfiltrated env vars, tokens, and source code. Weak passwords are a risk; the supply chain compromise is a breach in progress.

incident response
npm uninstall lodsah # 1. remove immediately # 2. audit the postinstall — what did it send? # 3. rotate ALL secrets CI ever touched npm ci --ignore-scripts # 4. rebuild clean
🛡 security lead
This is incident response, not vulnerability management. Every secret the CI ever touched is potentially exfiltrated. Full rotation.
⌨ dev lead
Removed the package, rotated every token and key in CI. Took two days. Password policy shipped the following week — important, but not on fire.
// scenario 10 of 10

The brute-force nobody notices

A login endpoint is receiving 500+ failed auth attempts per minute from rotating IPs. The app logs them — but nobody is watching. No alerting, no SIEM, no on-call trigger. Error responses on the same endpoint return stack traces with internal service names.

auth.log — last 60 seconds
2025-03-15T14:22:01Z FAIL user=admin src=198.51.100.12 2025-03-15T14:22:01Z FAIL user=admin src=203.0.113.45 2025-03-15T14:22:02Z FAIL user=ceo@company.com src=192.0.2.78 ... 497 more in the last 60 seconds ... ... nobody is paged ...

No security alerting on auth failures (A09). Stack traces leaking internals (A10). One fix this sprint.

// scenario 10
which fix has higher leverage?
A09 · Logging & Alerting
Set up auth failure alerting + auto-block
Alert on 50+ failures/min. Page on-call. Auto-block source IPs. The attack is happening right now and nobody knows.
A10 · Exceptional Conditions
Remove stack traces from error responses
Return generic error messages. Log details internally. Stop leaking internal hostnames and service names.
// the tradeoff
scenario 10

Higher leverage: set up alerting (A09).

500 failed logins/min is an active attack running undetected. Without alerting, credential stuffing runs for days until a hit succeeds. Stack traces leak info, but the attacker already has enough to attack. The alert stops the bleeding.

alerting rule — 15-line fix
alert: AuthBruteForce expr: rate(auth_failures_total[5m]) > 50 for: 2m labels: severity: critical annotations: summary: "{{ $value }} auth failures/min"
🛡 security lead
An active attack you can't see is worse than information leakage you can. The alert catches this in 2 minutes instead of 2 weeks.
⌨ dev lead
Prometheus alert: 15 lines of YAML. Stack trace fix: 1 line in the error handler. Both shipped together, but the alert was the urgent one.

// the ten habits — one from each module

A01
Check ownership, not just authentication. Every endpoint that takes a resource ID must verify the requestor owns that resource. CWE-639
A02
Audit default credentials on every new service. Before go-live, check admin panels, databases, and monitoring tools. CWE-1188
A03
Pin your dependencies. Commit lockfiles with exact hashes. Run npm audit / pip audit in CI. CWE-1357
A04
Check the cost parameter. "We use bcrypt" isn't enough. bcrypt at cost 4 is weaker than PBKDF2 at 100K iterations. CWE-916
A05
Parameterize every query. No string concatenation of user input into SQL, LDAP, OS commands, or template engines. CWE-89
A06
Write one abuse case per feature. Before building, ask: "How would an attacker use this feature against us?" CWE-840
A07
Require MFA on login. It's the single highest-impact authentication control. No exceptions for internal tools. CWE-308
A08
Isolate the build server. CI/CD should be unreachable from developer workstations. Build artifacts are the only output. CWE-829
A09
Alert on auth failures. If 50 failed logins don't trigger a page, your logging is decoration. CWE-778
A10
Audit every catch block. Does it deny or allow? If a catch block on a security path calls next(), it's a fail-open. CWE-636

// triage complete

the leverage rule

Fix the exploit before reducing the blast radius.

When two categories intersect, the one that's exploitable right now almost always has higher leverage. Close the open door before building the alarm. Stop the active leak before adding defense-in-depth.

SECURITY TRIAGE · Cross-Category Prioritization
0 / 10