From 094a253c37c1a4cf241a0158dea2a034a200ee48 Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Mon, 29 Jun 2026 12:10:54 -0400 Subject: [PATCH] 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. --- .security-review-skip | 9 ++++ canary-meta/CANARY_FLOOR | 1 + canary-meta/KEY.md | 52 +++++++++++++++++++++ canary/infra/template.yaml | 46 +++++++++++++++++++ canary/src/crypto_utils.py | 24 ++++++++++ canary/src/dotnet/PaymentController.cs | 59 ++++++++++++++++++++++++ canary/src/node/orders_api.js | 53 ++++++++++++++++++++++ canary/src/payment_handler.py | 62 ++++++++++++++++++++++++++ canary/src/webhook_handler.py | 34 ++++++++++++++ canary/web/PaymentForm.jsx | 15 +++++++ 10 files changed, 355 insertions(+) create mode 100644 .security-review-skip create mode 100644 canary-meta/CANARY_FLOOR create mode 100644 canary-meta/KEY.md create mode 100644 canary/infra/template.yaml create mode 100644 canary/src/crypto_utils.py create mode 100644 canary/src/dotnet/PaymentController.cs create mode 100644 canary/src/node/orders_api.js create mode 100644 canary/src/payment_handler.py create mode 100644 canary/src/webhook_handler.py create mode 100644 canary/web/PaymentForm.jsx diff --git a/.security-review-skip b/.security-review-skip new file mode 100644 index 0000000..cfadd26 --- /dev/null +++ b/.security-review-skip @@ -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. diff --git a/canary-meta/CANARY_FLOOR b/canary-meta/CANARY_FLOOR new file mode 100644 index 0000000..45a4fb7 --- /dev/null +++ b/canary-meta/CANARY_FLOOR @@ -0,0 +1 @@ +8 diff --git a/canary-meta/KEY.md b/canary-meta/KEY.md new file mode 100644 index 0000000..1d98999 --- /dev/null +++ b/canary-meta/KEY.md @@ -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. diff --git a/canary/infra/template.yaml b/canary/infra/template.yaml new file mode 100644 index 0000000..4f73e3c --- /dev/null +++ b/canary/infra/template.yaml @@ -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 diff --git a/canary/src/crypto_utils.py b/canary/src/crypto_utils.py new file mode 100644 index 0000000..8a7ecf4 --- /dev/null +++ b/canary/src/crypto_utils.py @@ -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() diff --git a/canary/src/dotnet/PaymentController.cs b/canary/src/dotnet/PaymentController.cs new file mode 100644 index 0000000..1a94ea3 --- /dev/null +++ b/canary/src/dotnet/PaymentController.cs @@ -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); + } + } +} diff --git a/canary/src/node/orders_api.js b/canary/src/node/orders_api.js new file mode 100644 index 0000000..5655303 --- /dev/null +++ b/canary/src/node/orders_api.js @@ -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; diff --git a/canary/src/payment_handler.py b/canary/src/payment_handler.py new file mode 100644 index 0000000..79324ca --- /dev/null +++ b/canary/src/payment_handler.py @@ -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])} diff --git a/canary/src/webhook_handler.py b/canary/src/webhook_handler.py new file mode 100644 index 0000000..4c5b262 --- /dev/null +++ b/canary/src/webhook_handler.py @@ -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} diff --git a/canary/web/PaymentForm.jsx b/canary/web/PaymentForm.jsx new file mode 100644 index 0000000..749c290 --- /dev/null +++ b/canary/web/PaymentForm.jsx @@ -0,0 +1,15 @@ +import React from "react"; + +export default function PaymentForm({ note, amount }) { + // A note of `` executes in the user's session. + return ( +
+

Confirm payment of ${amount}

+
+ +
+ ); +}