Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -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": {
Expand Down
8 changes: 8 additions & 0 deletions scripts/copy-protect-templates.mjs
Original file line number Diff line number Diff line change
@@ -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');
16 changes: 16 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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):
Expand Down Expand Up @@ -190,6 +194,16 @@ async function runScan(args: ParsedArgs): Promise<number> {
return 0;
}

async function runProtectCommand(args: ParsedArgs): Promise<number> {
// 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<number> {
const config = await resolveConfig({
cwd: process.cwd(),
Expand Down Expand Up @@ -291,6 +305,8 @@ async function main(): Promise<number> {
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);
Expand Down
180 changes: 180 additions & 0 deletions src/protect/install.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = { ...pkg.dependencies, ...pkg.devDependencies };
const packages: Record<string, string> = {};
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.');
}
57 changes: 57 additions & 0 deletions src/protect/templates/engine.d.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>;
/** Parsed request payload; rule parameters read from here (e.g. "insert.title"). */
body: Record<string, unknown>;
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<string, string>;
}

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;
Loading
Loading