diff --git a/gate-engine/review/eval/reviewers/cases-api-security.jsonl b/gate-engine/review/eval/reviewers/cases-api-security.jsonl index 473a5816..f187e772 100644 --- a/gate-engine/review/eval/reviewers/cases-api-security.jsonl +++ b/gate-engine/review/eval/reviewers/cases-api-security.jsonl @@ -12,3 +12,19 @@ {"id": "apisec-tagged-sql-nearmiss", "reviewer": "api-security-reviewer", "expected": "PASS", "repo": {"base": {"api/analytics.ts": "import postgres from 'postgres';\n\nconst sql = postgres(process.env.DATABASE_URL as string);\n\n// Roll up event counts over a fixed, trusted window.\nexport async function dailyCounts(): Promise<{ day: string; total: number }[]> {\n return sql`SELECT day, count(*) AS total FROM events GROUP BY day ORDER BY day`;\n}\n"}, "staged": {"api/analytics.ts": "import postgres from 'postgres';\n\nconst sql = postgres(process.env.DATABASE_URL as string);\n\n// Roll up event counts over a fixed, trusted window.\nexport async function dailyCounts(): Promise<{ day: string; total: number }[]> {\n return sql`SELECT day, count(*) AS total FROM events GROUP BY day ORDER BY day`;\n}\n\n// NOTE: ${tenantId} is interpolated into the tagged template, but this is NOT\n// string-built SQL — the postgres.js `sql` tag sends the value as a bound $1\n// parameter, never as query text, so it is injection-safe.\nexport async function countsForTenant(tenantId: string): Promise {\n const rows =\n await sql`SELECT count(*) AS total FROM events WHERE tenant_id = ${tenantId}`;\n return Number(rows[0].total);\n}\n"}}, "note": "countsForTenant interpolates tenantId into a postgres.js tagged template, which binds it as a $1 parameter rather than SQL text — pattern-matchers flag it but the library exonerates it. PASS.", "difficulty": "adversarial", "provenance": "authored", "variantOf": null, "holdout": false} {"id": "apisec-mass-assignment", "reviewer": "api-security-reviewer", "expected": "FAIL", "expectItems": ["mass-assignment"], "reasonPattern": "mass.?assign|allowlist|named field|spread|role|req\\.body", "repo": {"base": {"api/profile.ts": "import type { Request, Response } from 'express';\nimport { db } from './db';\n\n// Profile updates write named fields only — the row also carries role/orgId columns.\nexport async function updateDisplayName(req: Request, res: Response): Promise {\n const user = await db.user.update({\n where: { id: req.session.userId },\n data: { displayName: String(req.body.displayName ?? '').slice(0, 80) },\n });\n res.json({ id: user.id, displayName: user.displayName });\n}\n"}, "staged": {"api/profile.ts": "import type { Request, Response } from 'express';\nimport { db } from './db';\n\n// Profile updates write named fields only — the row also carries role/orgId columns.\nexport async function updateDisplayName(req: Request, res: Response): Promise {\n const user = await db.user.update({\n where: { id: req.session.userId },\n data: { displayName: String(req.body.displayName ?? '').slice(0, 80) },\n });\n res.json({ id: user.id, displayName: user.displayName });\n}\n\nexport async function updateProfile(req: Request, res: Response): Promise {\n const user = await db.user.update({\n where: { id: req.session.userId },\n data: { ...req.body },\n });\n res.json(user);\n}\n"}}, "note": "Spreading req.body into db.user.update lets a caller set role/orgId or any other column; the sibling handler shows the named-field pattern. Gold for the new mass-assignment item.", "difficulty": "clear", "provenance": "authored", "variantOf": null, "holdout": false} {"id": "apisec-idor-lookup", "reviewer": "api-security-reviewer", "expected": "FAIL", "expectItems": ["object-level-authz"], "reasonPattern": "owner|tenant|authoriz|idor|scope|object.?level", "repo": {"base": {"api/invoices.ts": "import type { Request, Response } from 'express';\nimport { db } from './db';\nimport { requireAuth } from './middleware';\n\n// List endpoint scopes rows to the signed-in owner.\nexport const listInvoices = [\n requireAuth,\n async (req: Request, res: Response): Promise => {\n const invoices = await db.invoice.findMany({\n where: { ownerId: req.session.userId },\n select: { id: true, total: true, issuedAt: true },\n });\n res.json(invoices);\n },\n];\n"}, "staged": {"api/invoices.ts": "import type { Request, Response } from 'express';\nimport { db } from './db';\nimport { requireAuth } from './middleware';\n\n// List endpoint scopes rows to the signed-in owner.\nexport const listInvoices = [\n requireAuth,\n async (req: Request, res: Response): Promise => {\n const invoices = await db.invoice.findMany({\n where: { ownerId: req.session.userId },\n select: { id: true, total: true, issuedAt: true },\n });\n res.json(invoices);\n },\n];\n\nexport const getInvoice = [\n requireAuth,\n async (req: Request, res: Response): Promise => {\n const invoice = await db.invoice.findUnique({ where: { id: req.params.id } });\n if (!invoice) {\n res.status(404).end();\n return;\n }\n res.json(invoice);\n },\n];\n"}}, "note": "getInvoice is authenticated but unscoped: any signed-in user can read any invoice by ID, while the sibling list endpoint shows the ownerId-scoped pattern. Authn present, authz missing — gold for the new object-level-authz item.", "difficulty": "borderline", "provenance": "authored", "variantOf": null, "holdout": false} +{"id":"apisec-json-stringify-quote-insufficient-fixed","reviewer":"api-security-reviewer","expected":"PASS","repo":{"base":{"api/reportDiff.ts":"import { execFile } from 'node:child_process';\nimport { promisify } from 'node:util';\n\nconst run = promisify(execFile);\n\n// Compare a fixed pair of known report ids.\nexport async function diffKnownReports(a: string, b: string): Promise {\n const { stdout } = await run('report-tool', ['diff', '--a', a, '--b', b]);\n return stdout;\n}\n"},"staged":{"api/reportDiff.ts":"import { execFile } from 'node:child_process';\nimport { promisify } from 'node:util';\nimport type { Request, Response } from 'express';\n\nconst run = promisify(execFile);\n\n// Compare a fixed pair of known report ids.\nexport async function diffKnownReports(a: string, b: string): Promise {\n const { stdout } = await run('report-tool', ['diff', '--a', a, '--b', b]);\n return stdout;\n}\n\n// New: diff an arbitrary uploaded report against the known baseline — the\n// filename stays a discrete argv entry, so no shell ever parses it.\nexport async function diffUploadedReport(req: Request, res: Response): Promise {\n const file = String(req.query.file);\n const { stdout } = await run('report-tool', ['diff', '--', file]);\n res.type('text/plain').send(stdout);\n}\n"}},"note":"Same diffUploadedReport handler as the gold twin but the filename is passed as its own execFile argv entry instead of any shell-string quoting scheme — no shell ever parses it. PASS.","difficulty":"borderline","provenance":"adapted","caseId":"apisec-json-stringify-quote-insufficient","sourcePr":1,"variantOf":"apisec-json-stringify-quote-insufficient","holdout":true,"outcomeEvidence":"resolved+line-touched","scopeConfirmed":"unverifiable"} +{"id":"apisec-json-stringify-quote-insufficient","reviewer":"api-security-reviewer","expected":"FAIL","expectItems":["command-injection"],"reasonPattern":"shell|substitution|\\$\\(|stringify|quote|inject|exec","repo":{"base":{"api/reportDiff.ts":"import { execFile } from 'node:child_process';\nimport { promisify } from 'node:util';\n\nconst run = promisify(execFile);\n\n// Compare a fixed pair of known report ids.\nexport async function diffKnownReports(a: string, b: string): Promise {\n const { stdout } = await run('report-tool', ['diff', '--a', a, '--b', b]);\n return stdout;\n}\n"},"staged":{"api/reportDiff.ts":"import { exec, execFile } from 'node:child_process';\nimport { promisify } from 'node:util';\nimport type { Request, Response } from 'express';\n\nconst run = promisify(execFile);\nconst runShell = promisify(exec);\n\n// Compare a fixed pair of known report ids.\nexport async function diffKnownReports(a: string, b: string): Promise {\n const { stdout } = await run('report-tool', ['diff', '--a', a, '--b', b]);\n return stdout;\n}\n\n// New: diff an arbitrary uploaded report against the known baseline. The\n// filename is JSON.stringify()'d before interpolation, which looks quoted —\n// but runShell still runs the string through a shell, and command\n// substitution inside double quotes is not neutralized by that quoting.\nexport async function diffUploadedReport(req: Request, res: Response): Promise {\n const file = String(req.query.file);\n const quoted = JSON.stringify(file);\n const { stdout } = await runShell(`report-tool diff -- ${quoted}`);\n res.type('text/plain').send(stdout);\n}\n"}},"note":"JSON.stringify(file) does not make the shell string safe: runShell still runs it through a shell, so a filename containing `$(...)` is expanded before report-tool ever runs. Quoting is not a substitute for argv-based execution.","difficulty":"borderline","provenance":"mined","caseId":"apisec-json-stringify-quote-insufficient","sourcePr":1,"variantOf":null,"holdout":false,"outcomeEvidence":"resolved+line-touched","scopeConfirmed":"unverifiable"} +{"id":"apisec-unescaped-filename-exec-fixed","reviewer":"api-security-reviewer","expected":"PASS","repo":{"base":{"api/diffExport.ts":"import { execFile } from 'node:child_process';\nimport { promisify } from 'node:util';\n\nconst run = promisify(execFile);\n\n// Diff a fixed, known-good snapshot — the id is a discrete argv entry.\nexport async function diffSnapshot(snapshotId: string): Promise {\n const { stdout } = await run('diff-tool', ['snapshot', '--id', snapshotId]);\n return stdout;\n}\n"},"staged":{"api/diffExport.ts":"import { execFile } from 'node:child_process';\nimport { promisify } from 'node:util';\nimport type { Request, Response } from 'express';\n\nconst run = promisify(execFile);\n\n// Diff a fixed, known-good snapshot — the id is a discrete argv entry.\nexport async function diffSnapshot(snapshotId: string): Promise {\n const { stdout } = await run('diff-tool', ['snapshot', '--id', snapshotId]);\n return stdout;\n}\n\n// New: diff an uploaded file by name against the fixed snapshot — the name\n// stays a discrete argv entry, never spliced into a shell string.\nexport async function diffUploadedFile(req: Request, res: Response): Promise {\n const file = String(req.query.file);\n const { stdout } = await run('diff-tool', ['file', '--path', file]);\n res.type('text/plain').send(stdout);\n}\n"}},"note":"Same diffUploadedFile handler as the gold twin but the filename is passed as its own execFile argv entry — no shell ever parses it, so no injection. PASS.","difficulty":"clear","provenance":"adapted","caseId":"apisec-unescaped-filename-exec","sourcePr":1,"variantOf":"apisec-unescaped-filename-exec","holdout":false,"outcomeEvidence":"resolved+line-touched","scopeConfirmed":"unverifiable"} +{"id":"apisec-unescaped-filename-exec","reviewer":"api-security-reviewer","expected":"FAIL","expectItems":["command-injection"],"reasonPattern":"inject|shell|exec|splice|unescaped|metachar|quote","repo":{"base":{"api/diffExport.ts":"import { execFile } from 'node:child_process';\nimport { promisify } from 'node:util';\n\nconst run = promisify(execFile);\n\n// Diff a fixed, known-good snapshot — the id is a discrete argv entry.\nexport async function diffSnapshot(snapshotId: string): Promise {\n const { stdout } = await run('diff-tool', ['snapshot', '--id', snapshotId]);\n return stdout;\n}\n"},"staged":{"api/diffExport.ts":"import { exec, execFile } from 'node:child_process';\nimport { promisify } from 'node:util';\nimport type { Request, Response } from 'express';\n\nconst run = promisify(execFile);\nconst runShell = promisify(exec);\n\n// Diff a fixed, known-good snapshot — the id is a discrete argv entry.\nexport async function diffSnapshot(snapshotId: string): Promise {\n const { stdout } = await run('diff-tool', ['snapshot', '--id', snapshotId]);\n return stdout;\n}\n\n// New: diff an uploaded file by name against the fixed snapshot.\nexport async function diffUploadedFile(req: Request, res: Response): Promise {\n const file = String(req.query.file);\n const { stdout } = await runShell(`diff-tool file --path \"${file}\"`);\n res.type('text/plain').send(stdout);\n}\n"}},"note":"diffUploadedFile splices req.query.file into a double-quoted shell string run by exec — a filename like `\"; rm -rf / #` breaks out of the quotes and executes arbitrary commands; diffSnapshot right above shows the safe execFile arg-array pattern.","difficulty":"clear","provenance":"mined","caseId":"apisec-unescaped-filename-exec","sourcePr":1,"variantOf":null,"holdout":true,"outcomeEvidence":"resolved+line-touched","scopeConfirmed":"unverifiable"} +{"id":"apisec-shell-roots-injection-fixed","reviewer":"api-security-reviewer","expected":"PASS","repo":{"base":{"api/archiveExport.ts":"import { execFile } from 'node:child_process';\nimport { promisify } from 'node:util';\n\nconst run = promisify(execFile);\n\n// Archive a single fixed report — the id is a discrete argv entry, never\n// spliced into a shell string.\nexport async function archiveReport(reportId: string): Promise {\n const { stdout } = await run('archive-tool', ['export', '--id', reportId]);\n return stdout;\n}\n"},"staged":{"api/archiveExport.ts":"import { execFile } from 'node:child_process';\nimport { promisify } from 'node:util';\nimport type { Request, Response } from 'express';\n\nconst run = promisify(execFile);\n\n// Archive a single fixed report — the id is a discrete argv entry, never\n// spliced into a shell string.\nexport async function archiveReport(reportId: string): Promise {\n const { stdout } = await run('archive-tool', ['export', '--id', reportId]);\n return stdout;\n}\n\n// New: let an admin archive several config-declared directories at once — each\n// root stays a discrete argv entry, so it is never parsed by a shell.\nexport async function exportRoots(req: Request, res: Response): Promise {\n const roots: string[] = req.body.roots ?? [];\n const { stdout } = await run('archive-tool', ['export', '--roots', ...roots]);\n res.type('application/zip').send(stdout);\n}\n"}},"note":"Same exportRoots handler as the gold twin but each root is passed as its own execFile argv entry — no shell ever parses them, so no injection. PASS.","difficulty":"clear","provenance":"adapted","caseId":"apisec-shell-roots-injection","sourcePr":1,"variantOf":"apisec-shell-roots-injection","holdout":false,"outcomeEvidence":"resolved+line-touched","scopeConfirmed":"unverifiable"} +{"id":"apisec-shell-roots-injection","reviewer":"api-security-reviewer","expected":"FAIL","expectItems":["command-injection"],"reasonPattern":"inject|shell|exec|splice|unescaped|metachar|roots","repo":{"base":{"api/archiveExport.ts":"import { execFile } from 'node:child_process';\nimport { promisify } from 'node:util';\n\nconst run = promisify(execFile);\n\n// Archive a single fixed report — the id is a discrete argv entry, never\n// spliced into a shell string.\nexport async function archiveReport(reportId: string): Promise {\n const { stdout } = await run('archive-tool', ['export', '--id', reportId]);\n return stdout;\n}\n"},"staged":{"api/archiveExport.ts":"import { exec, execFile } from 'node:child_process';\nimport { promisify } from 'node:util';\nimport type { Request, Response } from 'express';\n\nconst run = promisify(execFile);\nconst runShell = promisify(exec);\n\n// Archive a single fixed report — the id is a discrete argv entry, never\n// spliced into a shell string.\nexport async function archiveReport(reportId: string): Promise {\n const { stdout } = await run('archive-tool', ['export', '--id', reportId]);\n return stdout;\n}\n\n// New: let an admin archive several config-declared directories at once.\nexport async function exportRoots(req: Request, res: Response): Promise {\n const roots: string[] = req.body.roots ?? [];\n const { stdout } = await runShell(`archive-tool export --roots ${roots.join(' ')}`);\n res.type('application/zip').send(stdout);\n}\n"}},"note":"exportRoots joins req.body.roots and hands the string to exec — a root containing shell metacharacters (e.g. `; rm -rf / #`) breaks out and runs arbitrary commands; archiveReport right above shows the safe argv-array pattern.","difficulty":"clear","provenance":"mined","caseId":"apisec-shell-roots-injection","sourcePr":1,"variantOf":null,"holdout":true,"outcomeEvidence":"resolved+line-touched","scopeConfirmed":"unverifiable"} +{"id":"apisec-masked-pipe-failure-approval-fixed","reviewer":"api-security-reviewer","expected":"PASS","repo":{"base":{"api/permissionGate.ts":"import type { Request, Response } from 'express';\nimport { lookupResourceUuid } from './resourceStore';\n\n// Look up the canonical UUID for a resource by its external id.\nexport async function resolveResource(req: Request, res: Response): Promise {\n const uuid = await lookupResourceUuid(String(req.params.id));\n res.json({ uuid });\n}\n"},"staged":{"api/permissionGate.ts":"import { exec } from 'node:child_process';\nimport { promisify } from 'node:util';\nimport type { Request, Response } from 'express';\nimport { lookupResourceUuid } from './resourceStore';\n\nconst runShell = promisify(exec);\n\n// Look up the canonical UUID for a resource by its external id.\nexport async function resolveResource(req: Request, res: Response): Promise {\n const uuid = await lookupResourceUuid(String(req.params.id));\n res.json({ uuid });\n}\n\n// New: check whether a resource still has an outstanding policy violation\n// before letting an admin unlock it — a non-zero exit from policy-scan\n// rejects the promise, so a scanner failure surfaces as an error instead of\n// silently reading as clear.\nexport async function checkAndUnlock(req: Request, res: Response): Promise {\n const uuid = await lookupResourceUuid(String(req.params.id));\n try {\n const { stdout } = await runShell(`policy-scan --id ${uuid}`);\n const hasViolation = stdout.trim().length > 0;\n if (hasViolation) {\n res.status(409).json({ message: 'Resource has an outstanding violation' });\n return;\n }\n unlockResource(uuid);\n res.json({ unlocked: true });\n } catch {\n res.status(500).json({ message: 'Unlock failed' });\n }\n}\n\nfunction unlockResource(id: string): void {\n // ...\n}\n"}},"note":"Same checkAndUnlock handler as the gold twin but without the `| head -1` pipe — a policy-scan failure now rejects the promise and is caught, so the unlock path only runs on a genuine clean result. PASS.","difficulty":"borderline","provenance":"adapted","caseId":"apisec-masked-pipe-failure-approval","sourcePr":34,"variantOf":"apisec-masked-pipe-failure-approval","holdout":false,"outcomeEvidence":"resolved+line-touched","scopeConfirmed":"unverifiable"} +{"id":"apisec-masked-pipe-failure-approval","reviewer":"api-security-reviewer","expected":"FAIL","expectItems":["error-handling"],"reasonPattern":"pipe|exit.?code|mask|fail.?open|fail.?closed|swallow|silent","repo":{"base":{"api/permissionGate.ts":"import type { Request, Response } from 'express';\nimport { lookupResourceUuid } from './resourceStore';\n\n// Look up the canonical UUID for a resource by its external id.\nexport async function resolveResource(req: Request, res: Response): Promise {\n const uuid = await lookupResourceUuid(String(req.params.id));\n res.json({ uuid });\n}\n"},"staged":{"api/permissionGate.ts":"import { exec } from 'node:child_process';\nimport { promisify } from 'node:util';\nimport type { Request, Response } from 'express';\nimport { lookupResourceUuid } from './resourceStore';\n\nconst runShell = promisify(exec);\n\n// Look up the canonical UUID for a resource by its external id.\nexport async function resolveResource(req: Request, res: Response): Promise {\n const uuid = await lookupResourceUuid(String(req.params.id));\n res.json({ uuid });\n}\n\n// New: check whether a resource still has an outstanding policy violation\n// before letting an admin unlock it.\nexport async function checkAndUnlock(req: Request, res: Response): Promise {\n const uuid = await lookupResourceUuid(String(req.params.id));\n try {\n const { stdout } = await runShell(`policy-scan --id ${uuid} | head -1`);\n const hasViolation = stdout.trim().length > 0;\n if (hasViolation) {\n res.status(409).json({ message: 'Resource has an outstanding violation' });\n return;\n }\n unlockResource(uuid);\n res.json({ unlocked: true });\n } catch {\n res.status(500).json({ message: 'Unlock failed' });\n }\n}\n\nfunction unlockResource(id: string): void {\n // ...\n}\n"}},"note":"checkAndUnlock pipes policy-scan into `head -1` — the pipeline can still resolve with empty stdout even when policy-scan itself crashed or errored, so a real failure silently reads as \"no violation\" and the resource gets unlocked. Should fail closed instead of masking the exit status.","difficulty":"borderline","provenance":"mined","caseId":"apisec-masked-pipe-failure-approval","sourcePr":34,"variantOf":null,"holdout":false,"outcomeEvidence":"resolved+line-touched","scopeConfirmed":"unverifiable"} +{"id":"apisec-unquoted-scope-list-injection-fixed","reviewer":"api-security-reviewer","expected":"PASS","repo":{"base":{"api/scopeExport.ts":"import { execFile } from 'node:child_process';\nimport { promisify } from 'node:util';\n\nconst run = promisify(execFile);\n\n// Export a single, fixed scope.\nexport async function exportScope(scope: string): Promise {\n const { stdout } = await run('export-tool', ['run', '--scope', scope]);\n return stdout;\n}\n"},"staged":{"api/scopeExport.ts":"import { execFile } from 'node:child_process';\nimport { promisify } from 'node:util';\nimport type { Request, Response } from 'express';\n\nconst run = promisify(execFile);\n\n// Export a single, fixed scope.\nexport async function exportScope(scope: string): Promise {\n const { stdout } = await run('export-tool', ['run', '--scope', scope]);\n return stdout;\n}\n\n// New: export several config-declared scopes in one call — each scope stays\n// its own argv entry, so a scope with a space or glob character never changes\n// how many arguments the command sees.\nexport async function exportScopes(req: Request, res: Response): Promise {\n const scopes: string[] = req.body.scopes ?? [];\n const { stdout } = await run('export-tool', ['run', '--scopes', ...scopes]);\n res.type('application/json').send(stdout);\n}\n"}},"note":"Same exportScopes handler as the gold twin but each scope is spread as its own execFile argv entry — no shell re-splitting or globbing. PASS.","difficulty":"clear","provenance":"adapted","caseId":"apisec-unquoted-scope-list-injection","sourcePr":34,"variantOf":"apisec-unquoted-scope-list-injection","holdout":false,"outcomeEvidence":"addressed-marker","scopeConfirmed":"unverifiable"} +{"id":"apisec-unquoted-scope-list-injection","reviewer":"api-security-reviewer","expected":"FAIL","expectItems":["command-injection"],"reasonPattern":"unquoted|word.?split|glob|argv|shell|inject|scopes","repo":{"base":{"api/scopeExport.ts":"import { execFile } from 'node:child_process';\nimport { promisify } from 'node:util';\n\nconst run = promisify(execFile);\n\n// Export a single, fixed scope.\nexport async function exportScope(scope: string): Promise {\n const { stdout } = await run('export-tool', ['run', '--scope', scope]);\n return stdout;\n}\n"},"staged":{"api/scopeExport.ts":"import { exec, execFile } from 'node:child_process';\nimport { promisify } from 'node:util';\nimport type { Request, Response } from 'express';\n\nconst run = promisify(execFile);\nconst runShell = promisify(exec);\n\n// Export a single, fixed scope.\nexport async function exportScope(scope: string): Promise {\n const { stdout } = await run('export-tool', ['run', '--scope', scope]);\n return stdout;\n}\n\n// New: export several config-declared scopes in one call.\nexport async function exportScopes(req: Request, res: Response): Promise {\n const scopes: string[] = req.body.scopes ?? [];\n const joined = scopes.join(' ');\n const { stdout } = await runShell(`export-tool run --scopes ${joined}`);\n res.type('application/json').send(stdout);\n}\n"}},"note":"exportScopes flattens the scopes array into one space-joined string and hands it to a shell, which re-splits and glob-expands it unquoted — a scope containing a space or `*` changes how many arguments the command receives instead of staying one value.","difficulty":"clear","provenance":"mined","caseId":"apisec-unquoted-scope-list-injection","sourcePr":34,"variantOf":null,"holdout":false,"outcomeEvidence":"addressed-marker","scopeConfirmed":"unverifiable"} +{"id":"apisec-malformed-policy-silent-bypass-fixed","reviewer":"api-security-reviewer","expected":"PASS","repo":{"base":{"api/policyValidation.ts":"import type { Request, Response } from 'express';\n\ninterface Policy {\n allowedScopes: string[];\n}\n\n// Parse and validate a policy document before it's used for any access\n// decision — malformed JSON is rejected outright.\nexport function parsePolicy(raw: string): Policy {\n const parsed = JSON.parse(raw);\n if (!parsed || !Array.isArray(parsed.allowedScopes)) {\n throw new Error('invalid policy document');\n }\n return parsed;\n}\n\nexport function applyPolicy(req: Request, res: Response): void {\n const policy = parsePolicy(String(req.body.policy));\n const requested = String(req.query.scope);\n if (!policy.allowedScopes.includes(requested)) {\n res.status(403).json({ message: 'Scope not permitted' });\n return;\n }\n res.json({ granted: true });\n}\n"},"staged":{"api/policyValidation.ts":"import type { Request, Response } from 'express';\n\ninterface Policy {\n allowedScopes: string[];\n}\n\n// Parse and validate a policy document before it's used for any access\n// decision — malformed JSON is rejected outright.\nexport function parsePolicy(raw: string): Policy {\n const parsed = JSON.parse(raw);\n if (!parsed || !Array.isArray(parsed.allowedScopes)) {\n throw new Error('invalid policy document');\n }\n return parsed;\n}\n\nexport function applyPolicy(req: Request, res: Response): void {\n const policy = parsePolicy(String(req.body.policy));\n const requested = String(req.query.scope);\n if (!policy.allowedScopes.includes(requested)) {\n res.status(403).json({ message: 'Scope not permitted' });\n return;\n }\n res.json({ granted: true });\n}\n\n// New: a bulk variant that loads the caller-supplied policy document once\n// and reuses it for every scope in the request body — a malformed document\n// is rejected outright instead of silently granting whatever was requested.\nexport function applyPolicyBulk(req: Request, res: Response): void {\n let policy: Policy;\n try {\n policy = parsePolicy(String(req.body.policy));\n } catch {\n res.status(400).json({ message: 'Invalid policy document' });\n return;\n }\n const requested: string[] = req.body.scopes ?? [];\n const granted = requested.every((s) => policy.allowedScopes.includes(s));\n res.json({ granted });\n}\n"}},"note":"Same applyPolicyBulk handler as the gold twin but a malformed policy document now returns 400 instead of falling back to an allow-everything policy. PASS.","difficulty":"clear","provenance":"adapted","caseId":"apisec-malformed-policy-silent-bypass","sourcePr":34,"variantOf":"apisec-malformed-policy-silent-bypass","holdout":false,"outcomeEvidence":"addressed-marker","scopeConfirmed":"unverifiable"} +{"id":"apisec-malformed-policy-silent-bypass","reviewer":"api-security-reviewer","expected":"FAIL","expectItems":["input-validation"],"reasonPattern":"malformed|fallback|silent|bypass|fail.?open|catch|validate","repo":{"base":{"api/policyValidation.ts":"import type { Request, Response } from 'express';\n\ninterface Policy {\n allowedScopes: string[];\n}\n\n// Parse and validate a policy document before it's used for any access\n// decision — malformed JSON is rejected outright.\nexport function parsePolicy(raw: string): Policy {\n const parsed = JSON.parse(raw);\n if (!parsed || !Array.isArray(parsed.allowedScopes)) {\n throw new Error('invalid policy document');\n }\n return parsed;\n}\n\nexport function applyPolicy(req: Request, res: Response): void {\n const policy = parsePolicy(String(req.body.policy));\n const requested = String(req.query.scope);\n if (!policy.allowedScopes.includes(requested)) {\n res.status(403).json({ message: 'Scope not permitted' });\n return;\n }\n res.json({ granted: true });\n}\n"},"staged":{"api/policyValidation.ts":"import type { Request, Response } from 'express';\n\ninterface Policy {\n allowedScopes: string[];\n}\n\n// Parse and validate a policy document before it's used for any access\n// decision — malformed JSON is rejected outright.\nexport function parsePolicy(raw: string): Policy {\n const parsed = JSON.parse(raw);\n if (!parsed || !Array.isArray(parsed.allowedScopes)) {\n throw new Error('invalid policy document');\n }\n return parsed;\n}\n\nexport function applyPolicy(req: Request, res: Response): void {\n const policy = parsePolicy(String(req.body.policy));\n const requested = String(req.query.scope);\n if (!policy.allowedScopes.includes(requested)) {\n res.status(403).json({ message: 'Scope not permitted' });\n return;\n }\n res.json({ granted: true });\n}\n\n// New: a bulk variant that loads the caller-supplied policy document once\n// and reuses it for every scope in the request body.\nexport function applyPolicyBulk(req: Request, res: Response): void {\n let policy: Policy;\n try {\n policy = parsePolicy(String(req.body.policy));\n } catch {\n policy = { allowedScopes: req.body.scopes ?? [] };\n }\n const requested: string[] = req.body.scopes ?? [];\n const granted = requested.every((s) => policy.allowedScopes.includes(s));\n res.json({ granted });\n}\n"}},"note":"applyPolicyBulk catches a malformed policy document and falls back to `{ allowedScopes: req.body.scopes }` — using the caller's own requested scopes as the allow-list, so an invalid document silently grants access to everything requested instead of being rejected.","difficulty":"clear","provenance":"mined","caseId":"apisec-malformed-policy-silent-bypass","sourcePr":34,"variantOf":null,"holdout":true,"outcomeEvidence":"addressed-marker","scopeConfirmed":"unverifiable"} +{"id":"apisec-json-escaped-secret-leak-fixed","reviewer":"api-security-reviewer","expected":"PASS","repo":{"base":{"api/auditExport.ts":"import type { Request, Response } from 'express';\nimport { fetchAuditRecord } from './auditStore';\n\nconst SECRET_RE = /sk_live_[a-zA-Z0-9]+/;\n\n// Redact obvious secret-shaped tokens before an audit record ever reaches the\n// client — decode first, so a JSON-escaped token can't slip past the regex.\nfunction containsSecret(rawJson: string): boolean {\n const decoded = JSON.parse(rawJson);\n return SECRET_RE.test(JSON.stringify(decoded));\n}\n\nexport async function getAuditRecord(req: Request, res: Response): Promise {\n const record = await fetchAuditRecord(String(req.params.id));\n const rawJson = JSON.stringify(record);\n if (containsSecret(rawJson)) {\n res.status(500).json({ message: 'Audit record failed redaction check' });\n return;\n }\n res.json(record);\n}\n"},"staged":{"api/auditExport.ts":"import type { Request, Response } from 'express';\nimport { fetchAuditRecord, fetchRawAuditLog } from './auditStore';\n\nconst SECRET_RE = /sk_live_[a-zA-Z0-9]+/;\n\n// Redact obvious secret-shaped tokens before an audit record ever reaches the\n// client — decode first, so a JSON-escaped token can't slip past the regex.\nfunction containsSecret(rawJson: string): boolean {\n const decoded = JSON.parse(rawJson);\n return SECRET_RE.test(JSON.stringify(decoded));\n}\n\nexport async function getAuditRecord(req: Request, res: Response): Promise {\n const record = await fetchAuditRecord(String(req.params.id));\n const rawJson = JSON.stringify(record);\n if (containsSecret(rawJson)) {\n res.status(500).json({ message: 'Audit record failed redaction check' });\n return;\n }\n res.json(record);\n}\n\n// New: stream the raw stored audit log lines straight back to the client —\n// each line is decoded before scanning, so a JSON-escaped token can't slip\n// past the filter.\nexport async function exportRawAuditLog(req: Request, res: Response): Promise {\n const lines = await fetchRawAuditLog(String(req.query.date));\n const clean = lines.filter((line) => !containsSecret(line));\n res.type('application/jsonl').send(clean.join('\\n'));\n}\n"}},"note":"Same exportRawAuditLog handler as the gold twin but each line is decoded through containsSecret (JSON.parse then re-stringify) before scanning, so an escaped token is normalized into the literal form the regex can match. PASS.","difficulty":"borderline","provenance":"adapted","caseId":"apisec-json-escaped-secret-leak","sourcePr":76,"variantOf":"apisec-json-escaped-secret-leak","holdout":true,"outcomeEvidence":"addressed-marker","scopeConfirmed":"unverifiable"} +{"id":"apisec-json-escaped-secret-leak","reviewer":"api-security-reviewer","expected":"FAIL","expectItems":["output-security"],"reasonPattern":"escape|decode|unicode|raw|redact|secret|leak|json\\.parse","repo":{"base":{"api/auditExport.ts":"import type { Request, Response } from 'express';\nimport { fetchAuditRecord } from './auditStore';\n\nconst SECRET_RE = /sk_live_[a-zA-Z0-9]+/;\n\n// Redact obvious secret-shaped tokens before an audit record ever reaches the\n// client — decode first, so a JSON-escaped token can't slip past the regex.\nfunction containsSecret(rawJson: string): boolean {\n const decoded = JSON.parse(rawJson);\n return SECRET_RE.test(JSON.stringify(decoded));\n}\n\nexport async function getAuditRecord(req: Request, res: Response): Promise {\n const record = await fetchAuditRecord(String(req.params.id));\n const rawJson = JSON.stringify(record);\n if (containsSecret(rawJson)) {\n res.status(500).json({ message: 'Audit record failed redaction check' });\n return;\n }\n res.json(record);\n}\n"},"staged":{"api/auditExport.ts":"import type { Request, Response } from 'express';\nimport { fetchAuditRecord, fetchRawAuditLog } from './auditStore';\n\nconst SECRET_RE = /sk_live_[a-zA-Z0-9]+/;\n\n// Redact obvious secret-shaped tokens before an audit record ever reaches the\n// client — decode first, so a JSON-escaped token can't slip past the regex.\nfunction containsSecret(rawJson: string): boolean {\n const decoded = JSON.parse(rawJson);\n return SECRET_RE.test(JSON.stringify(decoded));\n}\n\nexport async function getAuditRecord(req: Request, res: Response): Promise {\n const record = await fetchAuditRecord(String(req.params.id));\n const rawJson = JSON.stringify(record);\n if (containsSecret(rawJson)) {\n res.status(500).json({ message: 'Audit record failed redaction check' });\n return;\n }\n res.json(record);\n}\n\n// New: stream the raw stored audit log lines straight back to the client,\n// scanning each line's on-disk text before it goes out.\nexport async function exportRawAuditLog(req: Request, res: Response): Promise {\n const lines = await fetchRawAuditLog(String(req.query.date));\n const clean = lines.filter((line) => !SECRET_RE.test(line));\n res.type('application/jsonl').send(clean.join('\\n'));\n}\n"}},"note":"exportRawAuditLog tests SECRET_RE against each on-disk JSONL line's raw text — a token stored with JSON string escapes (e.g. `sk\\u005flive\\u005f...`) never matches the literal pattern, so it slips past the filter and reaches the client untouched. getAuditRecord above shows the safe pattern: decode, then re-stringify before scanning.","difficulty":"borderline","provenance":"mined","caseId":"apisec-json-escaped-secret-leak","sourcePr":76,"variantOf":null,"holdout":true,"outcomeEvidence":"addressed-marker","scopeConfirmed":"unverifiable"} +{"id":"apisec-supply-chain-age-gate-disabled-fixed","reviewer":"api-security-reviewer","expected":"PASS","repo":{"base":{"api/dependencyPolicy.toml":"# Third-party dependency install policy for this service's build pipeline.\n# New releases must sit for seven days before they are pulled into installs,\n# giving time to spot a bad or compromised release upstream.\nminimumReleaseAge = 604800\nminimumReleaseAgeExcludes = []\n"},"staged":{"api/dependencyPolicy.toml":"# Third-party dependency install policy for this service's build pipeline.\n# New releases must sit for seven days before they are pulled into installs,\n# giving time to spot a bad or compromised release upstream.\nminimumReleaseAge = 604800\nminimumReleaseAgeExcludes = [\"internal-report-tool\"]\n"}},"note":"The seven-day minimumReleaseAge delay stays in place; only a single, narrowly-scoped package is added to minimumReleaseAgeExcludes. No global weakening of the supply-chain gate. PASS.","difficulty":"borderline","provenance":"adapted","caseId":"apisec-supply-chain-age-gate-disabled","sourcePr":77,"variantOf":"apisec-supply-chain-age-gate-disabled","holdout":false,"outcomeEvidence":"resolved+line-touched","scopeConfirmed":"unverifiable"} +{"id":"apisec-supply-chain-age-gate-disabled","reviewer":"api-security-reviewer","expected":"FAIL","expectItems":["general-security"],"reasonPattern":"supply.?chain|release.?age|minimumReleaseAge|delay|compromised|disable","repo":{"base":{"api/dependencyPolicy.toml":"# Third-party dependency install policy for this service's build pipeline.\n# New releases must sit for seven days before they are pulled into installs,\n# giving time to spot a bad or compromised release upstream.\nminimumReleaseAge = 604800\nminimumReleaseAgeExcludes = []\n"},"staged":{"api/dependencyPolicy.toml":"# Third-party dependency install policy for this service's build pipeline.\n# New releases must sit for seven days before they are pulled into installs,\n# giving time to spot a bad or compromised release upstream.\nminimumReleaseAge = 0\nminimumReleaseAgeExcludes = []\n"}},"note":"minimumReleaseAge is set to 0, removing the seven-day install delay for every third-party package the service pulls in — a newly published, compromised release becomes installable immediately instead of being held back long enough for it to be caught upstream.","difficulty":"borderline","provenance":"mined","caseId":"apisec-supply-chain-age-gate-disabled","sourcePr":77,"variantOf":null,"holdout":false,"outcomeEvidence":"resolved+line-touched","scopeConfirmed":"unverifiable"}