diff --git a/package.json b/package.json index 58ff367..948b54f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@patchstack/connect", - "version": "0.2.8", + "version": "0.2.10", "description": "Patchstack connector for JavaScript applications. Scans your lockfile and reports installed packages to Patchstack for vulnerability monitoring.", "keywords": [ "patchstack", @@ -32,13 +32,13 @@ "LICENSE" ], "scripts": { - "build": "tsup", + "build": "tsup && node scripts/copy-protect-templates.mjs", "dev": "tsup --watch", "test": "vitest run", "test:manifest": "bun scripts/test-manifest.ts", "test:watch": "vitest", "typecheck": "tsc --noEmit", - "prepare": "tsup", + "prepare": "npm run build", "prepublishOnly": "npm run typecheck && npm test && npm run build" }, "engines": { diff --git a/scripts/copy-protect-templates.mjs b/scripts/copy-protect-templates.mjs new file mode 100644 index 0000000..98e02af --- /dev/null +++ b/scripts/copy-protect-templates.mjs @@ -0,0 +1,8 @@ +// Copy the runtime-guard templates next to the built CLI so `patchstack-connect protect` +// can scaffold them. Runs AFTER tsup (post-build), so tsup's async .d.ts pass can't clobber +// the .d.ts templates (which it does if we copy via tsup's onSuccess). +import { cpSync, mkdirSync } from 'node:fs'; + +mkdirSync('dist/protect', { recursive: true }); +cpSync('src/protect/templates', 'dist/protect/templates', { recursive: true }); +console.log('copied protect templates -> dist/protect/templates'); diff --git a/src/cli.ts b/src/cli.ts index 45faf80..2fd0391 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -11,6 +11,7 @@ import { injectMarker, resolveBuildDir, } from './mark-build.js'; +import { runProtect } from './protect/install.js'; import { detectStack, type StackDescriptor } from './stack.js'; import { PatchstackError } from './types.js'; @@ -25,6 +26,9 @@ Usage: patchstack-connect status [options] Show current configuration patchstack-connect mark-build [options] Stamp built HTML with a production flag + build fingerprint (run as a postbuild step) + patchstack-connect protect [--manifest] Install runtime protection (the guard) into + a server-side JS app. --manifest only + regenerates the package manifest (prebuild). patchstack-connect help Print this message Options (for scan and status): @@ -190,6 +194,16 @@ async function runScan(args: ParsedArgs): Promise { return 0; } +async function runProtectCommand(args: ParsedArgs): Promise { + // Best-effort: like mark-build, this runs during builds and must never fail one. + try { + runProtect(process.cwd(), { manifestOnly: args.flags.get('manifest') === true }); + } catch (err) { + console.warn(`patchstack protect: skipped (${(err as Error).message}).`); + } + return 0; +} + async function runStatus(args: ParsedArgs): Promise { const config = await resolveConfig({ cwd: process.cwd(), @@ -291,6 +305,8 @@ async function main(): Promise { return runStatus(args); case 'mark-build': return runMarkBuild(args); + case 'protect': + return runProtectCommand(args); default: console.error(`Unknown command: ${args.command}\n`); console.error(HELP); diff --git a/src/protect/install.ts b/src/protect/install.ts new file mode 100644 index 0000000..67e42aa --- /dev/null +++ b/src/protect/install.ts @@ -0,0 +1,180 @@ +// `patchstack-connect protect` — installs the runtime guard into a server-side JS app. +// +// This is the paid "virtual patching" layer. "Add Patchstack" already installs the connector; +// this wires the guard so exploit requests against known-vulnerable packages are blocked, with +// zero changes to the user's own code. Idempotent: safe to run on every build. +// +// It only edits Patchstack-owned / auto-generated infra: the guard folder +// (src/integrations/patchstack/), the generated Supabase client, and the framework server +// entry. It never touches the user's routes or components. Best-effort — it must never fail a +// build, so callers treat a thrown error as "skip". + +import { readFileSync, writeFileSync, existsSync, mkdirSync, copyFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +// Guard templates ship next to the built CLI (dist/protect/templates). +const TEMPLATES = fileURLToPath(new URL('./protect/templates/', import.meta.url)); + +const CLIENT_TUNNEL = [ + '', + " // PATCHSTACK auto-guard: in the browser, tunnel Supabase traffic through the app's own", + ' // server guard (same-origin) so payloads are inspected before they reach Supabase.', + " if (typeof window !== 'undefined') {", + " const target = typeof input === 'string' ? input : input instanceof Request ? input.url : String(input);", + " const method = init?.method ?? (input instanceof Request ? input.method : 'GET');", + " headers.set('x-ps-target', target);", + " const guardUrl = new URL('/_patchstack/guard', window.location.origin).toString();", + ' return fetch(guardUrl, { ...init, method, headers });', + ' }', + '', +].join('\n'); + +const START_IMPORTS = [ + 'import { getRequest } from "@tanstack/react-start/server";', + 'import { GUARD_PATH, handleGuardRequest } from "@/integrations/patchstack/guard";', +].join('\n'); + +const START_MIDDLEWARE = [ + '', + '// Patchstack auto-guard: intercept the tunneled data traffic before anything else runs.', + 'const patchstackGuard = createMiddleware().server(async ({ next }) => {', + ' const request = getRequest();', + ' if (request) {', + ' const { pathname } = new URL(request.url);', + ' if (pathname === GUARD_PATH) return handleGuardRequest(request);', + ' }', + ' return next();', + '});', + '', +].join('\n'); + +const PS_DIR_REL = 'src/integrations/patchstack'; + +function log(msg: string): void { + console.log(`patchstack protect: ${msg}`); +} + +/** Returns true if this looks like a supported server-side app (TanStack Start + Supabase). */ +export function detectSupportedStack(cwd: string): boolean { + const pkgPath = join(cwd, 'package.json'); + if (!existsSync(pkgPath)) return false; + const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')); + const deps = { ...pkg.dependencies, ...pkg.devDependencies }; + return ( + Boolean(deps['@tanstack/react-start']) && + existsSync(join(cwd, 'src/start.ts')) && + existsSync(join(cwd, 'src/integrations/supabase/client.ts')) + ); +} + +export function generateManifest(cwd: string): void { + const pkg = JSON.parse(readFileSync(join(cwd, 'package.json'), 'utf8')); + const deps: Record = { ...pkg.dependencies, ...pkg.devDependencies }; + const packages: Record = {}; + for (const name of Object.keys(deps)) { + let version = String(deps[name]).replace(/^[^\d]*/, ''); + const installed = join(cwd, 'node_modules', name, 'package.json'); + if (existsSync(installed)) { + try { + version = JSON.parse(readFileSync(installed, 'utf8')).version; + } catch { + /* keep the range-derived version */ + } + } + packages[name] = version; + } + mkdirSync(join(cwd, PS_DIR_REL), { recursive: true }); + const body = + '// Patchstack auto-guard manifest — generated from the lockfile.\n' + + '// Do not edit; regenerated by `patchstack-connect protect --manifest` (wired into prebuild).\n' + + 'export const manifest = ' + + JSON.stringify({ packages }, null, 2) + + ';\n'; + writeFileSync(join(cwd, PS_DIR_REL, 'manifest.js'), body); + log(`wrote manifest.js (${Object.keys(packages).length} packages)`); +} + +function scaffold(cwd: string): void { + const dst = join(cwd, PS_DIR_REL); + mkdirSync(dst, { recursive: true }); + copyFileSync(join(TEMPLATES, 'engine.js'), join(dst, 'engine.js')); + copyFileSync(join(TEMPLATES, 'engine.d.ts'), join(dst, 'engine.d.ts')); + copyFileSync(join(TEMPLATES, 'manifest.d.ts'), join(dst, 'manifest.d.ts')); + copyFileSync(join(TEMPLATES, 'rules.json'), join(dst, 'rules.json')); + copyFileSync(join(TEMPLATES, 'guard.ts'), join(dst, 'guard.ts')); + log('scaffolded engine.js (+types), rules.json, guard.ts'); +} + +function patchClient(cwd: string): void { + const p = join(cwd, 'src/integrations/supabase/client.ts'); + let s = readFileSync(p, 'utf8'); + if (s.includes('x-ps-target')) { + log('client.ts already wired'); + return; + } + const anchor = "headers.set('apikey', supabaseKey);"; + if (!s.includes(anchor)) { + log('client.ts anchor not found (template changed?) — skipping client patch'); + return; + } + s = s.replace(anchor, anchor + '\n' + CLIENT_TUNNEL); + writeFileSync(p, s); + log('patched client.ts (tunnel Supabase through the guard)'); +} + +function patchStart(cwd: string): void { + const p = join(cwd, 'src/start.ts'); + let s = readFileSync(p, 'utf8'); + if (s.includes('patchstackGuard')) { + log('start.ts already wired'); + return; + } + const importAnchor = 'import { createStart, createMiddleware } from "@tanstack/react-start";'; + const exportAnchor = 'export const startInstance'; + const rmAnchor = 'requestMiddleware: ['; + if (!s.includes(importAnchor) || !s.includes(exportAnchor) || !s.includes(rmAnchor)) { + log('start.ts anchors not found (template changed?) — skipping start patch'); + return; + } + s = s.replace(importAnchor, importAnchor + '\n' + START_IMPORTS); + s = s.replace(exportAnchor, START_MIDDLEWARE + '\n' + exportAnchor); + s = s.replace(rmAnchor, rmAnchor + 'patchstackGuard, '); + writeFileSync(p, s); + log('patched start.ts (registered the guard as request middleware)'); +} + +function wirePrebuild(cwd: string): void { + const p = join(cwd, 'package.json'); + const pkg = JSON.parse(readFileSync(p, 'utf8')); + pkg.scripts = pkg.scripts || {}; + const step = 'patchstack-connect protect --manifest'; + const prebuild: string = pkg.scripts.prebuild || ''; + if (prebuild.includes(step)) { + log('prebuild already refreshes the manifest'); + return; + } + pkg.scripts.prebuild = prebuild ? prebuild + ' && ' + step : step; + writeFileSync(p, JSON.stringify(pkg, null, 2) + '\n'); + log('wired manifest refresh into prebuild'); +} + +/** Full install (or manifest-only refresh). */ +export function runProtect(cwd: string, opts: { manifestOnly?: boolean } = {}): void { + if (opts.manifestOnly) { + generateManifest(cwd); + return; + } + if (!detectSupportedStack(cwd)) { + log( + 'runtime protection currently supports TanStack Start + Supabase apps; stack not detected — skipping.', + ); + return; + } + scaffold(cwd); + patchClient(cwd); + patchStart(cwd); + generateManifest(cwd); + wirePrebuild(cwd); + log('done — guard wired and always-on (blocks by default). Set PATCHSTACK_MODE=dry-run for log-only.'); +} diff --git a/src/protect/templates/engine.d.ts b/src/protect/templates/engine.d.ts new file mode 100644 index 0000000..1e92123 --- /dev/null +++ b/src/protect/templates/engine.d.ts @@ -0,0 +1,57 @@ +export type Action = "ALLOW" | "LOG" | "BLOCK" | "REDIRECT"; + +export declare const ACTIONS: { + ALLOW: "ALLOW"; + LOG: "LOG"; + BLOCK: "BLOCK"; + REDIRECT: "REDIRECT"; +}; + +/** A normalized snapshot of one request. The framework guard builds this. */ +export interface RequestContext { + method: string; + url: string; + headers: Record; + /** Parsed request payload; rule parameters read from here (e.g. "insert.title"). */ + body: Record; + ip?: string; +} + +export interface RuleCondition { + parameter: string | string[]; + match: { type: "inline_xss" | "contains" | "equals" | string; value?: unknown }; + mutations?: string[]; +} + +/** Only apply the rule if the app has this package at a vulnerable version. */ +export interface PackageCond { + package: string; + vulnerable_versions?: string[]; +} + +export interface Rule { + id: string; + title?: string; + vulnerability_id?: string; + package_cond?: PackageCond; + rule_v2: RuleCondition[]; +} + +/** The app's installed package list, so package_cond can gate. */ +export interface Manifest { + packages: Record; +} + +export interface Verdict { + matched: boolean; + action: Action; + rule_id: string | null; + vulnerability_id: string | null; + package: string | null; + version: string | null; + explain: string[]; + trace: unknown[]; +} + +export declare function evaluate(ctx: RequestContext, rules: Rule[], manifest?: Manifest): Verdict; +export declare function urldecode(value: string): string; diff --git a/src/protect/templates/engine.js b/src/protect/templates/engine.js new file mode 100644 index 0000000..5a2982d --- /dev/null +++ b/src/protect/templates/engine.js @@ -0,0 +1,137 @@ +// @patchstack/protect — the runtime protection engine. +// +// One pure function: evaluate(ctx, rules, manifest) -> Verdict. +// No framework coupling — the framework adapter (the guard) builds `ctx` and acts on the +// Verdict. This is the offsite-frozen contract between Group 1 (engine) and Group 2 (surface). +// +// SCOPE OF THIS BUILD: implements the two demo-critical pieces from the prototype — +// - `inline_xss` (ps-node never implemented it; npm XSS attacks need it) +// - `package_cond` (the line between virtual patching and a dumb wall) +// plus `equals` / `contains` and the `urldecode` mutation. The full rule_v2 zoo +// (AND/OR, nested rules, whitelists, the ~15 other match types) lands when the +// ps-node RuleEngine is ported in — this file keeps the same signature so that swap +// is drop-in. + +/** @type {{ALLOW:'ALLOW',LOG:'LOG',BLOCK:'BLOCK',REDIRECT:'REDIRECT'}} */ +export const ACTIONS = { ALLOW: "ALLOW", LOG: "LOG", BLOCK: "BLOCK", REDIRECT: "REDIRECT" }; + +/** Iteratively URL-decode (catches %2527 -> %27 -> '). Safe on malformed input. */ +export function urldecode(value) { + let out = String(value); + for (let i = 0; i < 5; i++) { + let next; + try { + next = decodeURIComponent(out.replace(/\+/g, " ")); + } catch { + break; + } + if (next === out) break; + out = next; + } + return out; +} + +const MUTATIONS = { urldecode }; + +function applyMutations(value, mutations) { + let v = String(value ?? ""); + for (const m of mutations ?? []) { + if (MUTATIONS[m]) v = MUTATIONS[m](v); + } + return v; +} + +// Heuristic script-injection detector. Deliberately simple and readable for the demo; +// the ported engine replaces this with the audited ps-node inline_xss implementation. +const XSS_PATTERNS = [ + /<\s*script\b/i, + /<\s*\/\s*script\s*>/i, + /\bon\w+\s*=/i, // onerror=, onload=, ... + /javascript\s*:/i, + /<\s*img\b[^>]*\bon\w+\s*=/i, + /<\s*svg\b[^>]*\bon\w+\s*=/i, + /\bdata\s*:\s*text\/html/i, +]; + +function looksLikeInlineXss(value) { + const v = String(value ?? ""); + return XSS_PATTERNS.some((re) => re.test(v)); +} + +// Resolve a rule parameter like "insert.title" or "body.content" against ctx. +// The guard normalizes framework specifics into ctx.body; parameters read from there. +function resolveParameter(ctx, parameter) { + const [, ...rest] = String(parameter).split("."); + const key = rest.length ? rest.join(".") : parameter; + const body = ctx?.body ?? {}; + if (key in body) return body[key]; + // shallow scan for nested payloads (e.g. { record: { title } }) + for (const v of Object.values(body)) { + if (v && typeof v === "object" && key in v) return v[key]; + } + return undefined; +} + +function matchCondition(ctx, cond) { + const params = Array.isArray(cond.parameter) ? cond.parameter : [cond.parameter]; + for (const param of params) { + const raw = resolveParameter(ctx, param); + if (raw == null) continue; + const value = applyMutations(raw, cond.mutations); + const type = cond.match?.type; + let hit = false; + if (type === "inline_xss") hit = looksLikeInlineXss(value); + else if (type === "contains") hit = value.includes(cond.match.value); + else if (type === "equals") hit = value === cond.match.value; + if (hit) { + return { param, type, value }; + } + } + return null; +} + +// package_cond: only fire if the app actually has the vulnerable package@version. +// If no manifest is supplied, fail-open on the gate (assume present) — detection already +// flagged it; the guard can tighten this once it passes the real manifest. +function packageCondSatisfied(pkgCond, manifest) { + if (!pkgCond) return true; + if (!manifest || !manifest.packages) return true; + const installed = manifest.packages[pkgCond.package]; + if (installed == null) return false; + if (!pkgCond.vulnerable_versions) return true; + return pkgCond.vulnerable_versions.includes(installed); +} + +/** + * Evaluate one request against the rule set. + * @param {import('./index.js').RequestContext} ctx + * @param {import('./index.js').Rule[]} rules + * @param {import('./index.js').Manifest} [manifest] + * @returns {import('./index.js').Verdict} + */ +export function evaluate(ctx, rules, manifest) { + const trace = []; + for (const rule of rules ?? []) { + if (!packageCondSatisfied(rule.package_cond, manifest)) { + trace.push({ rule_id: rule.id, skipped: "package_cond not satisfied" }); + continue; + } + for (const cond of rule.rule_v2 ?? []) { + const m = matchCondition(ctx, cond); + if (m) { + return { + matched: true, + action: ACTIONS.BLOCK, + rule_id: rule.id, + vulnerability_id: rule.vulnerability_id ?? null, + package: rule.package_cond?.package ?? null, + version: manifest?.packages?.[rule.package_cond?.package] ?? null, + explain: [`${m.param} matched ${m.type}${cond.mutations?.length ? ` after ${cond.mutations.join("+")}` : ""}`], + trace, + }; + } + } + trace.push({ rule_id: rule.id, matched: false }); + } + return { matched: false, action: ACTIONS.ALLOW, rule_id: null, vulnerability_id: null, package: null, version: null, explain: [], trace }; +} diff --git a/src/protect/templates/guard.ts b/src/protect/templates/guard.ts new file mode 100644 index 0000000..6ca0a56 --- /dev/null +++ b/src/protect/templates/guard.ts @@ -0,0 +1,102 @@ +// Patchstack auto-guard — installed automatically by "add Patchstack"; the vibe coder never +// touches this. It runs in the app's own server (the Cloudflare Worker). The patched Supabase +// client tunnels every browser data call here; we inspect the payload against the rules, then +// either block it (virtual patch) or forward it to real Supabase. +import { evaluate, type Rule } from "./engine.js"; +import rulesData from "./rules.json"; +import { manifest } from "./manifest.js"; + +export const GUARD_PATH = "/_patchstack/guard"; + +// Headers that must not be copied verbatim when we re-emit the upstream response. +const HOP_BY_HOP = new Set(["content-encoding", "content-length", "transfer-encoding", "connection"]); + +function mode(): "dry-run" | "block" { + // Always-on: protection blocks by default. Only an explicit PATCHSTACK_MODE=dry-run + // downgrades to log-only (for testing) — there is no silent dry-run. + return process.env.PATCHSTACK_MODE === "dry-run" ? "dry-run" : "block"; +} + +export async function handleGuardRequest(request: Request): Promise { + const target = request.headers.get("x-ps-target"); + if (!target) return new Response("patchstack: missing x-ps-target", { status: 400 }); + + // SSRF guard: the client names the target, but we only ever forward to the app's own + // Supabase project (origin known server-side). Anything else — internal hosts, cloud + // metadata, a different scheme — is rejected before we make any outbound request. + const supabaseUrl = process.env.SUPABASE_URL; + let targetUrl: URL; + try { + targetUrl = new URL(target); + } catch { + return new Response("patchstack: invalid target", { status: 400 }); + } + if (!supabaseUrl || targetUrl.protocol !== "https:" || targetUrl.origin !== new URL(supabaseUrl).origin) { + return new Response("patchstack: target not allowed", { status: 403 }); + } + + const hasBody = request.method !== "GET" && request.method !== "HEAD"; + const bodyText = hasBody ? await request.text() : ""; + + let record: Record = {}; + if (bodyText) { + try { + const parsed = JSON.parse(bodyText); + record = Array.isArray(parsed) ? (parsed[0] ?? {}) : parsed; + } catch { + // not JSON — leave record empty; the rule simply won't match + } + } + + const ctx = { + method: request.method, + url: target, + headers: Object.fromEntries(request.headers), + // expose the record under a few shapes so rule parameters like "insert.title" resolve + body: { ...record, insert: record, update: record }, + ip: request.headers.get("x-forwarded-for") ?? "", + }; + + const t0 = performance.now(); + const verdict = evaluate(ctx, (rulesData as { firewall: Rule[] }).firewall, manifest); + const latencyMs = Math.round((performance.now() - t0) * 100) / 100; + + // Always log the flag (this is the "dry-run sees it too" behavior). + const decision = verdict.matched ? `MATCH ${verdict.rule_id}` : "clean"; + console.log(`[patchstack] ${request.method} ${new URL(target).pathname} -> ${decision} · mode=${mode()}`); + + if (verdict.matched && mode() === "block") { + const receipt = { + blocked: true, + rule_id: verdict.rule_id, + vulnerability_id: verdict.vulnerability_id, + package: verdict.package, + version: verdict.version, + matched_conditions: verdict.explain, + latency_ms: latencyMs, + }; + console.log("[patchstack] BLOCKED", JSON.stringify(receipt)); + return new Response(JSON.stringify(receipt), { + status: 403, + headers: { "content-type": "application/json", "x-patchstack": "blocked" }, + }); + } + + // Forward to real Supabase, server-side. + const forwardHeaders = new Headers(request.headers); + forwardHeaders.delete("x-ps-target"); + forwardHeaders.delete("host"); + const upstream = await fetch(target, { + method: request.method, + headers: forwardHeaders, + body: hasBody ? bodyText : undefined, + redirect: "manual", + }); + + const outHeaders = new Headers(); + upstream.headers.forEach((value, key) => { + if (!HOP_BY_HOP.has(key.toLowerCase())) outHeaders.set(key, value); + }); + const buf = await upstream.arrayBuffer(); + return new Response(buf, { status: upstream.status, statusText: upstream.statusText, headers: outHeaders }); +} diff --git a/src/protect/templates/manifest.d.ts b/src/protect/templates/manifest.d.ts new file mode 100644 index 0000000..5331d78 --- /dev/null +++ b/src/protect/templates/manifest.d.ts @@ -0,0 +1 @@ +export declare const manifest: { packages: Record }; diff --git a/src/protect/templates/rules.json b/src/protect/templates/rules.json new file mode 100644 index 0000000..599e17b --- /dev/null +++ b/src/protect/templates/rules.json @@ -0,0 +1,22 @@ +{ + "_comment": "DEMO SCAFFOLD — static stub; the real per-site rules endpoint does not exist in saas yet. CVE-2022-21681 is a real marked advisory affecting <4.0.10 (demo installs 4.0.0). The demo uses an XSS-shaped payload for visual clarity; exact payload<->CVE alignment is pinned with Group 1's corpus.", + "firewall": [ + { + "id": "rm-npm-0001", + "title": "Block stored XSS via vulnerable markdown renderer (marked)", + "vulnerability_id": "CVE-2022-21681", + "package_cond": { + "package": "marked", + "vulnerable_versions": ["4.0.0"] + }, + "rule_v2": [ + { + "parameter": ["insert.title", "body.title", "update.title"], + "mutations": ["urldecode"], + "match": { "type": "inline_xss" } + } + ] + } + ], + "whitelists": [] +} diff --git a/tsconfig.json b/tsconfig.json index b2cdb4b..c4a36da 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -18,5 +18,5 @@ "rootDir": "./src" }, "include": ["src/**/*"], - "exclude": ["dist", "node_modules", "tests"] + "exclude": ["dist", "node_modules", "tests", "src/protect/templates"] }