From e9e20318eb6901af6ebe50073470b2757d7d85cb Mon Sep 17 00:00:00 2001 From: Pyronewbic Date: Wed, 20 May 2026 09:34:52 +0530 Subject: [PATCH 1/2] sec: add RASP middleware with runtime attack detection lib/security/rasp.js: inspect all inputs (query, body, path, headers) for SQLi, XSS, command injection, path traversal, NoSQL injection, prototype pollution. Per-IP anomaly scoring with 5-min half-life decay, bot fingerprinting (scanner UA detection, ZAP allowlisting). Events logged to security-events Firestore collection. Monitor mode by default (RASP_MODE=block to enforce). GET /api/security/events (owner-only). 43 unit tests covering all detection categories + false positive safety. --- CLAUDE.md | 142 ++++++++++++++++++ README.md | 1 + api.js | 15 +- docs/env-vars.md | 1 + docs/internals.md | 5 +- lib/security/rasp.js | 338 +++++++++++++++++++++++++++++++++++++++++++ test/unit-test.js | 175 ++++++++++++++++++++++ 7 files changed, 675 insertions(+), 2 deletions(-) create mode 100644 CLAUDE.md create mode 100644 lib/security/rasp.js diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..3efd070 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,142 @@ +# CLAUDE.md + +## Project + +Casecomp — Pokemon TCG card research tool. API at api.casecomp.xyz (Cloud Run `casecomp-api`), frontend at casecomp.xyz (Cloud Run `casecomp-site` + Cloudflare). Chrome extension for drop queue auto-join. + +## Git workflow + +- **Ask before every shared-state action.** Push, PR, merge, deploy, terraform apply — each requires separate explicit approval. Never chain them. Stop after each action and wait. +- **No Co-Authored-By lines** in commits. No "Generated with Claude Code" in PR descriptions or any public text. +- **Branch flow:** push to dev or main → CI runs → deploy on main push. +- **Commits:** concise message, no attribution trailers. +- **PR template:** .github/PULL_REQUEST_TEMPLATE.md + +## Code style + +- No comments unless the WHY is non-obvious. +- No emojis in code, commits, or PRs. +- Node 24 pinned across Dockerfile, CI, package.json. +- User-facing language: "sample data" not "demo". + +## lib/ structure + +``` +lib/ + sources/ ebay, magi (cheerio), snkrdunk, yahooauctions, tcgplayer + grading/ grading (8-subgrade v3), preprocessing (card detection + SSRF + corner crops), psa, psaTiers + auth/ auth (Google OAuth + JWT), api-keys (developer key CRUD) + cards/ card-database, card-identity, demo, price-history, grading-dataset + security/ rasp (RASP middleware, detection rules, anomaly scoring, Firestore event logging) + data/ firestore, cache, redis-cache, analytics, csv, email + search/ filters (condition, outlier), listingQuery, ebayCategories, output + swagger.js OpenAPI spec +``` + +## UI design rules + +Strict palette — no deviations: +- Backgrounds: `#07070a` (body), `#0c0d12` (panels), `#14151c` (inset) +- Gold accent: `#d9b676` only. No blue, no purple. +- Status: `#7ce0a8` (green), `#ff5d5d` (red). No other greens/reds. +- Borders: `rgba(255,255,255,0.08)` — not solid gray. +- Fonts: Space Grotesk for headings, Inter Tight for body, JetBrains Mono for mono/labels. +- No emojis anywhere. + +## Architecture decisions + +- **Caching:** Firestore only, no Redis. Stale-while-revalidate on active listings. TCGdex card DB cached (24h TTL). PSA negative caching (7 days). +- **CORS:** Wildcard `*`. API key is the access control layer. +- **Auth:** Owner `CC_LIVE_` (60/min), sandbox (5/min), developer keys (per-key rate limit enforced), demo `?demo=true` (360/min). Local: no auth. +- **authMiddleware** checks owner → sandbox → JWT (Google OAuth) → Firestore developer keys (30s cache). `apiAuthMiddleware` adds demo bypass. +- **Developer self-serve:** auto-key on first Google sign-in, max 3 per user, rotate, revoke. `GET/POST/DELETE /api/developer/keys` + stats. +- **Admin:** `CASECOMP_ADMIN_SUB` env var (Google sub). `GET/PATCH/DELETE /api/admin/keys`. +- **eBay search:** active returned immediately, sold in background. Ship-to skipped. Seller feedback + relevance filtering. +- **Autocomplete:** TCGdex EN+JP (~29K cards) cached in Firestore, instant startup. +- **Error responses:** Sanitized via `safeErrorMessage()`. Never leak internals. Global JSON 404 handler + error handler at bottom of `api.js` — unmatched routes return `{"error":"Not found"}`, unhandled exceptions return sanitized JSON. +- **RASP:** `lib/security/rasp.js` middleware inspects all inputs (query, body, path, headers) for SQLi, XSS, command injection, path traversal, NoSQL injection, prototype pollution. Bot fingerprinting + per-IP anomaly scoring (5-min half-life). Monitor mode by default (`RASP_MODE=block` to enforce). Events logged to `security-events` Firestore collection. `GET /api/security/events` (owner-only). + +## AI grading + +- **v3 pipeline:** card detection (Sonnet, or Together AI GLM-4.6V when `TOGETHER_API_KEY` set) → crop to card → corner crops per side → 8 parallel subgrade calls. +- 8 subgrades: centering/corners/edges/surface × front/back. Each receives only its target image. +- Overall = `(frontAvg × 0.60) + (backAvg × 0.40)`, capped at lowest subgrade + 1 (excessive defect rule). +- Card detection: 4-corner detection → tilt correction (sharp rotate) → crop. Skips when card fills >80% of frame. SSRF-protected (DNS resolution, private IP blocking). +- Centering subgrades return `lr`/`tb` ratios (e.g. "55/45", "52/48") for frontend overlay positioning. +- Full PSA rubric (grades 5-10). Corner crops via `sharp`. eBay images upgraded to s-l1600. +- Falls back to single prompt for non-Claude or missing back image. +- Token usage + estimated cost tracked per grade ($3/$15 per 1M for Claude, $2.50/$10 for OpenAI). +- **Grade probability distribution:** `gradeDistribution` field in response (e.g. `{"8": 65, "8.5": 12, "7.5": 23}`). Computed from overall + confidence. +- **Centering hint:** POST `/api/grade` accepts optional `centeringHint` with user-measured ratios, appended to centering prompts. +- **Shareable reports:** `GET /api/grade/report/:id` returns PNG card (SVG→sharp→PNG) with scores, distribution, limiter. +- **ML dataset pipeline:** track-prices passively collects graded slab images (eBay sold) into `grading-dataset` Firestore collection. Parses PSA/BGS/CGC/TAG grade from listing title. `GET /api/grading-dataset/stats` (owner-only) monitors collection. +- **Roadmap:** multi-pass median for deterministic grading, defect heatmap overlay, accuracy calibration once dataset reaches ~500 images. + +## Set browser + +- `GET /api/sets` — 238 sets with logos, era groups, officialCards/secretCards +- `GET /api/sets/:setCode` — cards with rarity (~4K tagged) +- `GET /api/portfolio/set/:setCode` — collection tracking (owned cardIds) + +## Key endpoints + +- `/api/search` — multi-source search +- `/api/autocomplete` — card name autocomplete (29K cards) +- `/api/sets`, `/api/sets/:setCode` — set browser +- `/api/card/view/:setCode/:number` — card view with raw/graded, PSA, grading ROI +- `/api/price-history` — historical prices + trend signal +- `/api/psa` — PSA grading signal +- `/api/grade` — AI pre-grade (accepts cardId + centeringHint, returns gradeId + gradeDistribution) +- `/api/grade/report/:id` — shareable grade report as PNG +- `/api/grades/mine` — user's grade history +- `/api/grades/:id` (DELETE) — delete a grade +- `/api/upload-url` — signed GCS upload URL for card images +- `/api/portfolio` — portfolio CRUD +- `/api/portfolio/set/:setCode` — collection progress +- `/api/developer/keys` — self-serve API key CRUD +- `/api/developer/stats` — per-user usage stats +- `/api/admin/keys` — admin key management +- `/api/analytics` — request analytics (owner-only) +- `/api/grading-dataset/stats` — ML dataset collection stats (owner-only) +- `/api/security/events` — RASP security event log (owner-only) +- `/auth/google` — Google OAuth → JWT + +## Free tier strategy + +**Public (no key):** card DB, sets, autocomplete, sitemap, docs, health. +**Demo (`?demo=true`):** 3 hardcoded sample cards. No live API calls. +**Gated (requires key):** live search, AI grading, alerts, portfolio CRUD, admin. + +## Infrastructure + +- GCP: Cloud Run in asia-south1 + us-central1, Firestore (asia-south1), HTTPS LB (global, geo-routes), Cloud CDN, Secret Manager, Cloud Scheduler +- Terraform: GCS state, 8 files by resource type, CI plan/apply, `for_each` over regions +- **CI (ci.yml):** single workflow. Jobs: unit, api (demo-based, continue-on-error), smoke (non-blocking), codeql, scan (SBOM+Grype), audit (npm+lockfile-lint), secrets (gitleaks). Required: unit + codeql. +- **Deploy:** Kaniko v1.23.2 --reproducible → cosign sign → SLSA provenance → deploy by digest → both regions → health check → OWASP ZAP DAST +- **Custom Wolfi base image:** gcr.io/casecomp-495718/casecomp-node24. Built with apko. 9 smoke tests. 0 CVEs. +- **Supply chain:** Dependabot, lockfile-lint, Socket.dev, pre-commit hook (blocks .env, secrets, large files) +- **Binary Authorization:** ENFORCED on both Cloud Run services +- **Secret workflow:** Add to secrets.tf → CI creates → `gcloud secrets versions add` for value. Never `gcloud secrets create`. +- Secrets: EBAY_CLIENT_ID/SECRET, ANTHROPIC_API_KEY, TOGETHER_API_KEY, PSA_AUTH_TOKEN, CASECOMP_API_KEY, CASECOMP_SANDBOX_KEY, RESEND_API_KEY, CASECOMP_JWT_SECRET, GOOGLE_OAUTH_CLIENT_ID, CASECOMP_ADMIN_SUB + +## Frontend (casecomp.xyz) + +- Separate repo: Pyronewbic/casecomp-drop +- TanStack Start SSR, Cloudflare Workers +- 63 Lovable prompts. 9 routes: /, /search, /sets, /set/$, /card/$, /portfolio, /price-guide, /developers, /install +- Auth: GoogleLogin popup → JWT → authFetch. isAdmin flag for admin features. +- GradeCard.tsx: 2-step upload (front/back), card autocomplete selection (cardId), cloud grading + WebLLM local, v3 8-subgrade display with tap-to-expand, post-grade centering lines, grade probability distribution bar, share button, "Add to collection" post-grade +- Portfolio: graded cards history section (GET /api/grades/mine), expandable subgrade details, delete grades +- Landing: nav cards (clickable feature grid), supported sources, pricing. Agent-ready (.well-known/*). +- CardSearch.tsx: TrendBadge, Raw/Graded tabs, ROI card, Grade/Prices detail panel, AlertForm, Add to Portfolio +- MyApiKeys.tsx, AdminKeys.tsx on /developers +- Nav: Search · Sets · Portfolio · API + avatar dropdown (My Portfolio, Sign Out) + +## Backlog + +- **Defect heatmap overlay** — LLM outputs bounding boxes for detected defects, overlay on image via sharp +- **Grading accuracy eval** — benchmark script once dataset hits ~500 images, publish accuracy vs PSA +- **Self-hosted vLLM** — RunPod serverless with GLM-4.6V-Flash when volume justifies (~$4/1K grades vs $7.40 Together) +- **TCGPlayer seeding** — accumulate daily +- **Price comparison table** — side-by-side 2-3 cards +- **Grading batch calculator** — multi-card ROI \ No newline at end of file diff --git a/README.md b/README.md index d794d37..5cb771b 100644 --- a/README.md +++ b/README.md @@ -129,6 +129,7 @@ curl -X POST -H "Authorization: Bearer $CASECOMP_KEY" \ - **Reproducible builds** - Kaniko `--reproducible` flag, pinned version - **Multi-region** - Cloud Run in asia-south1 + us-central1, global LB auto geo-routes to nearest - **Custom base image** - Wolfi + apko Node 24 image, manual rebuild, zero CVEs by design +- **RASP** - runtime request inspection for SQLi, XSS, command injection, path traversal, NoSQL injection, prototype pollution. Per-IP anomaly scoring, bot fingerprinting, Firestore event logging - **Supply chain** - SLSA provenance, Dependabot, lockfile-lint, Socket.dev, pre-commit secret blocking ## Claude Code Skills diff --git a/api.js b/api.js index 1bde09c..f80809a 100644 --- a/api.js +++ b/api.js @@ -27,6 +27,7 @@ import { verifyGoogleToken, generateJwt, verifyJwt } from "./lib/auth/auth.js"; import { seedFromTCGPlayer } from "./lib/sources/tcgplayer.js"; import { getOrCreateCard, findCardByQuery, parseCardIdentity, resolveCardIdToQuery, SET_NAME_MAP } from "./lib/cards/card-identity.js"; import { initCardDatabase, searchCards, refreshCardDatabase, getAllSets, getSetWithCards, findCardByCardId } from "./lib/cards/card-database.js"; +import { raspMiddleware, getSecurityEvents } from "./lib/security/rasp.js"; import { fileURLToPath } from "url"; import path from "path"; @@ -43,9 +44,10 @@ app.use(helmet({ app.use(compression()); app.use(express.json({ limit: "100kb" })); +app.use(raspMiddleware({ mode: process.env.RASP_MODE || "monitor" })); app.use((req, res, next) => { - req.requestId = crypto.randomUUID().slice(0, 8); + req.requestId = req.requestId || crypto.randomUUID().slice(0, 8); res.setHeader("X-Request-Id", req.requestId); res.setHeader("Access-Control-Allow-Origin", "*"); res.setHeader("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS"); @@ -647,6 +649,17 @@ app.get("/api/grading-dataset/stats", ownerOnly, async (req, res) => { } }); +app.get("/api/security/events", ownerOnly, async (req, res) => { + const days = Math.min(30, Math.max(1, Number(req.query.days) || 7)); + const category = req.query.category || null; + try { + const events = await getSecurityEvents({ days, limit: 200, category }); + res.json({ events, count: events.length, days }); + } catch (e) { + res.status(500).json({ error: safeErrorMessage(e), requestId: req.requestId }); + } +}); + // GET /api/health app.get("/api/health", async (req, res) => { const firestoreStatus = await getFirestoreStatus(); diff --git a/docs/env-vars.md b/docs/env-vars.md index fc67f1f..34d0639 100644 --- a/docs/env-vars.md +++ b/docs/env-vars.md @@ -55,6 +55,7 @@ Copy **`.env.example`** to **`.env`** and fill in the required values. | `CASECOMP_ADMIN_SUB` | *(none)* | Google account `sub` claim for admin access | | `GOOGLE_OAUTH_CLIENT_ID` | *(none)* | Google OAuth client ID for sign-in (popup flow) | | `TOGETHER_API_KEY` | *(none)* | Together AI key for card detection (GLM-4.6V, falls back to Claude Sonnet) | +| `RASP_MODE` | `monitor` | RASP enforcement mode: `monitor` (log only) or `block` (reject malicious requests) | ## Email notifications diff --git a/docs/internals.md b/docs/internals.md index b847cd7..8eb321a 100644 --- a/docs/internals.md +++ b/docs/internals.md @@ -35,6 +35,8 @@ lib/ email.js Alert emails via Resend firestore.js Firestore: grade logs, drops, webhooks, cache redis-cache.js Redis cache (optional) + security/ + rasp.js RASP middleware, detection rules, anomaly scoring, event logging search/ filters.js Language, relevance, condition detection, outlier flagging listingQuery.js eBay search query builder (raw vs slab) @@ -59,7 +61,7 @@ test/ - **Auth middleware**: owner key (`CC_LIVE_`) → sandbox → JWT (Google OAuth) → Firestore developer keys (30s cache). `apiAuthMiddleware` adds demo bypass. - **Rate limiting**: 60/min authenticated, 360/min demo, 5/min sandbox, 10/min auth endpoint. -- **Security**: Helmet headers, trust proxy = 1, request IDs, compression, `safeErrorMessage()` on all errors. Global JSON 404 catch-all + error handler at bottom of file. +- **Security**: Helmet headers, trust proxy = 1, request IDs, compression, `safeErrorMessage()` on all errors. Global JSON 404 catch-all + error handler at bottom of file. RASP middleware on all routes. - **CORS**: wildcard `*` — API key is the access control layer. - **Dashboard**: static files from `public/` served at `/` and `/admin`. - **Docs**: Swagger UI at `/docs`, spec at `/docs/spec.json`. @@ -184,6 +186,7 @@ Three workflows: `ci.yml` (all checks), `deploy.yml` (build + sign + deploy), `t | Pre-commit hook | Local | Blocks .env, >1MB files, secret patterns | | apko + Wolfi | Base image | Custom Node 24 image, manual `workflow_dispatch` | | Dependabot | Weekly | npm + GitHub Actions version updates | +| RASP | Runtime | SQLi/XSS/cmdi/traversal/NoSQLi/proto-pollution detection, anomaly scoring | | Binary Auth | Cloud Run | ENFORCED policy (blocks unsigned images) | ## Scheduled tasks diff --git a/lib/security/rasp.js b/lib/security/rasp.js new file mode 100644 index 0000000..c3a2f98 --- /dev/null +++ b/lib/security/rasp.js @@ -0,0 +1,338 @@ +import crypto from "crypto"; +import { Firestore } from "@google-cloud/firestore"; + +const COLLECTION = "security-events"; + +let db = null; +function getDb() { + if (db) return db; + try { db = new Firestore(); return db; } catch { return null; } +} + +// ── Detection rules ── + +export const DETECTION_RULES = [ + { id: "sqli-union", category: "sqli", severity: "high", pattern: /\bunion\s+(all\s+)?select\b/i }, + { id: "sqli-stacked", category: "sqli", severity: "high", pattern: /['"];\s*(drop|alter|insert|update|delete|exec)\b/i }, + { id: "sqli-tautology", category: "sqli", severity: "high", pattern: /\bor\b\s+['"]?\d+['"]?\s*=\s*['"]?\d+/i }, + { id: "sqli-comment", category: "sqli", severity: "high", pattern: /'\s*(--|#|\/\*)/i }, + { id: "sqli-keyword", category: "sqli", severity: "high", pattern: /\b(select\s+.{1,60}\s+from|insert\s+into|update\s+.{1,60}\s+set|delete\s+from|drop\s+(table|database)|alter\s+table)\b/i }, + + { id: "xss-script", category: "xss", severity: "high", pattern: /]/i }, + { id: "xss-protocol", category: "xss", severity: "high", pattern: /javascript\s*:/i }, + { id: "xss-event", category: "xss", severity: "high", pattern: /\bon(error|load|click|mouseover|focus|blur|submit|change|input)\s*=/i }, + { id: "xss-embed", category: "xss", severity: "high", pattern: /<(iframe|embed|object|form)\b/i }, + { id: "xss-svg", category: "xss", severity: "high", pattern: /]*on\w+\s*=/i }, + + { id: "cmdi-semicolon", category: "cmdi", severity: "critical", pattern: /;\s*(ls|cat|rm|wget|curl|bash|sh|nc|netcat|python|perl|ruby|php)\b/i }, + { id: "cmdi-pipe", category: "cmdi", severity: "critical", pattern: /\|\s*(ls|cat|rm|wget|curl|bash|sh|nc|python|perl)\b/i }, + { id: "cmdi-backtick", category: "cmdi", severity: "critical", pattern: /`[^`]{2,}`/ }, + { id: "cmdi-subshell", category: "cmdi", severity: "critical", pattern: /\$\([^)]{2,}\)/ }, + + { id: "traversal-dotdot", category: "traversal", severity: "high", pattern: /(\.\.\/)|(\.\.\\)/ }, + { id: "traversal-encoded", category: "traversal", severity: "high", pattern: /(%2e%2e%2f|%2e%2e\/|\.\.%2f|%252e%252e)/i }, + { id: "traversal-etc", category: "traversal", severity: "high", pattern: /\/etc\/(passwd|shadow|hosts)|\/proc\/self/i }, + + { id: "nosqli-operator", category: "nosqli", severity: "high", pattern: /\$(?:gt|gte|lt|lte|ne|eq|in|nin|regex|exists|where|elemMatch|or|and|not|nor)\b/ }, +]; + +const SCORE_MAP = { sqli: 30, xss: 25, cmdi: 40, traversal: 20, proto: 35, nosqli: 25, bot: 15 }; + +const SCANNER_PATTERNS = /sqlmap|nikto|nmap|dirbuster|gobuster|wfuzz|hydra|burpsuite|acunetix|nessus/i; +const ZAP_PATTERN = /zaproxy|owasp.*zap/i; + +// ── Detection functions ── + +export function detectSqli(input) { + if (typeof input !== "string") return null; + for (const rule of DETECTION_RULES) { + if (rule.category === "sqli" && rule.pattern.test(input)) return { ruleId: rule.id, severity: rule.severity }; + } + return null; +} + +export function detectXss(input) { + if (typeof input !== "string") return null; + for (const rule of DETECTION_RULES) { + if (rule.category === "xss" && rule.pattern.test(input)) return { ruleId: rule.id, severity: rule.severity }; + } + return null; +} + +export function detectCommandInjection(input) { + if (typeof input !== "string") return null; + for (const rule of DETECTION_RULES) { + if (rule.category === "cmdi" && rule.pattern.test(input)) return { ruleId: rule.id, severity: rule.severity }; + } + return null; +} + +export function detectPathTraversal(input) { + if (typeof input !== "string") return null; + for (const rule of DETECTION_RULES) { + if (rule.category === "traversal" && rule.pattern.test(input)) return { ruleId: rule.id, severity: rule.severity }; + } + return null; +} + +export function detectNoSqlInjection(input) { + if (typeof input !== "string") return null; + for (const rule of DETECTION_RULES) { + if (rule.category === "nosqli" && rule.pattern.test(input)) return { ruleId: rule.id, severity: rule.severity }; + } + return null; +} + +const POLLUTION_KEYS = new Set(["__proto__", "constructor", "prototype"]); + +export function detectPrototypePollution(obj, depth = 0) { + if (!obj || typeof obj !== "object" || depth > 5) return null; + if (Array.isArray(obj)) { + for (const item of obj.slice(0, 50)) { + const r = detectPrototypePollution(item, depth + 1); + if (r) return r; + } + return null; + } + const keys = Object.keys(obj); + for (const key of keys.slice(0, 50)) { + if (POLLUTION_KEYS.has(key)) return { ruleId: "proto-key", severity: "high" }; + if (typeof obj[key] === "object") { + const r = detectPrototypePollution(obj[key], depth + 1); + if (r) return r; + } + } + return null; +} + +export function fingerprintRequest(req) { + const flags = []; + const ua = req.headers?.["user-agent"] || ""; + + if (!ua) flags.push("missing-ua"); + else if (SCANNER_PATTERNS.test(ua)) flags.push("scanner-ua"); + else if (ZAP_PATTERN.test(ua)) flags.push("zap-ua"); + + if (ua && /mozilla|chrome|safari/i.test(ua) && !req.headers?.accept) flags.push("missing-accept"); + + const headerCount = Object.keys(req.headers || {}).length; + if (headerCount < 2) flags.push("few-headers"); + if (headerCount > 30) flags.push("many-headers"); + + const botScore = flags.reduce((s, f) => s + (f === "zap-ua" ? 5 : 15), 0); + return { botScore, flags }; +} + +// ── Input extraction ── + +function extractInputStrings(req) { + const inputs = []; + + if (req.query) { + for (const [key, val] of Object.entries(req.query)) { + if (typeof val === "string") inputs.push({ source: "query", key, value: val }); + inputs.push({ source: "query-key", key, value: key }); + } + } + + if (req.path) inputs.push({ source: "path", key: "path", value: req.path }); + + if (req.body && typeof req.body === "object") { + walkObject(req.body, "body", inputs, 0); + } + + const checkHeaders = ["referer", "x-forwarded-for"]; + for (const h of checkHeaders) { + if (req.headers?.[h]) inputs.push({ source: "header", key: h, value: req.headers[h] }); + } + + return inputs; +} + +function walkObject(obj, source, inputs, depth) { + if (!obj || typeof obj !== "object" || depth > 5 || inputs.length > 100) return; + const entries = Array.isArray(obj) ? obj.map((v, i) => [String(i), v]) : Object.entries(obj); + for (const [key, val] of entries.slice(0, 50)) { + inputs.push({ source, key, value: key }); + if (typeof val === "string") { + inputs.push({ source, key, value: val }); + } else if (typeof val === "object" && val !== null) { + walkObject(val, source, inputs, depth + 1); + } + } +} + +// ── Anomaly scoring ── + +const MAX_ENTRIES = 10000; +const HALF_LIFE_MS = 5 * 60 * 1000; +const anomalyMap = new Map(); + +function hashIp(ip) { + if (!ip) return "unknown"; + return crypto.createHash("sha256").update(ip).digest("hex").slice(0, 8); +} + +function getOrCreateEntry(ipHash) { + let entry = anomalyMap.get(ipHash); + const now = Date.now(); + if (entry) { + const elapsed = now - entry.lastUpdate; + entry.score *= Math.pow(0.5, elapsed / HALF_LIFE_MS); + entry.lastUpdate = now; + } else { + if (anomalyMap.size >= MAX_ENTRIES) { + let oldest = null, oldestKey = null; + for (const [k, v] of anomalyMap) { + if (!oldest || v.lastUpdate < oldest.lastUpdate) { oldest = v; oldestKey = k; } + } + if (oldestKey) anomalyMap.delete(oldestKey); + } + entry = { score: 0, lastUpdate: now }; + anomalyMap.set(ipHash, entry); + } + return entry; +} + +export function getAnomalyScore(ipHash) { + const entry = anomalyMap.get(ipHash); + if (!entry) return 0; + const elapsed = Date.now() - entry.lastUpdate; + return entry.score * Math.pow(0.5, elapsed / HALF_LIFE_MS); +} + +export function resetAnomalyScores() { + anomalyMap.clear(); +} + +const cleanupInterval = setInterval(() => { + const now = Date.now(); + for (const [key, entry] of anomalyMap) { + const score = entry.score * Math.pow(0.5, (now - entry.lastUpdate) / HALF_LIFE_MS); + if (score < 1) anomalyMap.delete(key); + } +}, 10 * 60 * 1000); +cleanupInterval.unref(); + +// ── Firestore logging ── + +function sanitizeSnippet(val) { + if (typeof val !== "string") return ""; + return val.slice(0, 200).replace(//g, ">"); +} + +export async function logSecurityEvent(event) { + const fs = getDb(); + if (!fs) return; + try { await fs.collection(COLLECTION).add(event); } catch {} +} + +export async function getSecurityEvents({ days = 7, limit = 200, category = null } = {}) { + const fs = getDb(); + if (!fs) return []; + const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString(); + try { + let ref = fs.collection(COLLECTION).where("ts", ">=", cutoff).orderBy("ts", "desc").limit(limit); + if (category) ref = ref.where("category", "==", category); + const snap = await ref.get(); + return snap.docs.map(d => ({ id: d.id, ...d.data() })); + } catch { + return []; + } +} + +// ── Middleware ── + +const detectors = [ + { fn: detectSqli, category: "sqli" }, + { fn: detectXss, category: "xss" }, + { fn: detectCommandInjection, category: "cmdi" }, + { fn: detectPathTraversal, category: "traversal" }, + { fn: detectNoSqlInjection, category: "nosqli" }, +]; + +export function raspMiddleware({ mode = "monitor" } = {}) { + const shouldBlock = mode === "block"; + + return (req, res, next) => { + req.requestId = req.requestId || crypto.randomUUID().slice(0, 8); + + const ipHash = hashIp(req.ip); + const entry = getOrCreateEntry(ipHash); + const detections = []; + + const inputs = extractInputStrings(req); + + for (const input of inputs) { + for (const { fn, category } of detectors) { + const result = fn(input.value); + if (result) { + detections.push({ ...result, category, source: input.source, key: input.key, value: input.value }); + } + } + } + + if (req.body && typeof req.body === "object") { + const proto = detectPrototypePollution(req.body); + if (proto) { + detections.push({ ...proto, category: "proto", source: "body", key: "__proto__", value: "" }); + } + } + + const fp = fingerprintRequest(req); + entry.score += fp.botScore; + + for (const det of detections) { + entry.score += SCORE_MAP[det.category] || 20; + } + + const action = shouldBlock && (detections.length > 0 || entry.score >= 100) ? "block" : "monitor"; + + if (detections.length > 0 || fp.flags.length > 0) { + const ua = (req.headers?.["user-agent"] || "").slice(0, 200); + for (const det of detections) { + logSecurityEvent({ + ts: new Date().toISOString(), + requestId: req.requestId, + ipHash, + path: req.path, + method: req.method, + category: det.category, + ruleId: det.ruleId, + severity: det.severity, + source: det.source, + key: det.key, + snippet: sanitizeSnippet(det.value), + action, + anomalyScore: Math.round(entry.score), + userAgent: ua, + }).catch(() => {}); + } + + if (fp.flags.length > 0 && !detections.length) { + logSecurityEvent({ + ts: new Date().toISOString(), + requestId: req.requestId, + ipHash, + path: req.path, + method: req.method, + category: "bot", + ruleId: fp.flags.join(","), + severity: "low", + source: "headers", + key: "user-agent", + snippet: ua, + action: "monitor", + anomalyScore: Math.round(entry.score), + userAgent: ua, + }).catch(() => {}); + } + } + + if (action === "block") { + return res.status(403).json({ error: "Request blocked by security policy", requestId: req.requestId }); + } + + next(); + }; +} diff --git a/test/unit-test.js b/test/unit-test.js index a049ca2..846b94d 100644 --- a/test/unit-test.js +++ b/test/unit-test.js @@ -33,6 +33,11 @@ import { buildAlertEmailSubject, sendAlertEmail } from "../lib/data/email.js"; import { csvEscape, csvRow } from "../lib/data/csv.js"; import { matchesQuery, searchCards, getAllSets, getSetWithCards } from "../lib/cards/card-database.js"; import { computePriceTrend } from "../lib/cards/price-history.js"; +import { + detectSqli, detectXss, detectCommandInjection, + detectPathTraversal, detectPrototypePollution, detectNoSqlInjection, + fingerprintRequest, DETECTION_RULES, resetAnomalyScores, +} from "../lib/security/rasp.js"; import { findCardByCardId } from "../lib/cards/card-database.js"; let passed = 0; @@ -2055,6 +2060,176 @@ test("parseSpecPopItem: handles partial data", () => { eq(r.popTotal, 500); }); +// ── RASP detection ── + +console.log("\n\x1b[1m=== RASP: SQLi detection ===\x1b[0m"); + +test("detectSqli: catches UNION SELECT", () => { + assert(detectSqli("' UNION SELECT * FROM users--") !== null); +}); +test("detectSqli: catches union all select", () => { + assert(detectSqli("1 union all select 1,2,3") !== null); +}); +test("detectSqli: catches stacked query", () => { + assert(detectSqli("'; DROP TABLE cards--") !== null); +}); +test("detectSqli: catches tautology", () => { + assert(detectSqli("' OR '1'='1") !== null); +}); +test("detectSqli: catches comment injection", () => { + assert(detectSqli("admin'--") !== null); +}); +test("detectSqli: safe — normal card name", () => { + assert(detectSqli("Pikachu ex SAR 234/193") === null); +}); +test("detectSqli: safe — V-UNION card name", () => { + assert(detectSqli("Pikachu V-UNION") === null); +}); +test("detectSqli: safe — null input", () => { + assert(detectSqli(null) === null); +}); + +console.log("\n\x1b[1m=== RASP: XSS detection ===\x1b[0m"); + +test("detectXss: catches script tag", () => { + assert(detectXss("") !== null); +}); +test("detectXss: catches javascript protocol", () => { + assert(detectXss("javascript:void(0)") !== null); +}); +test("detectXss: catches onerror handler", () => { + assert(detectXss('') !== null); +}); +test("detectXss: catches iframe", () => { + assert(detectXss("