@patchstack/connect 0.3.2 → 0.3.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENT-INSTALL.md +74 -0
- package/README.md +5 -1
- package/dist/cli.js +205 -70
- package/dist/cli.js.map +1 -1
- package/dist/index.cjs +92 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +42 -1
- package/dist/index.d.ts +42 -1
- package/dist/index.js +89 -0
- package/dist/index.js.map +1 -1
- package/dist/protect/templates/engine.d.ts +57 -0
- package/dist/protect/templates/engine.js +137 -0
- package/dist/protect/templates/guard.ts +95 -52
- package/dist/protect/templates/manifest.d.ts +1 -0
- package/dist/protect/templates/rules.json +7 -4
- package/package.json +2 -6
- package/dist/protect.cjs +0 -1240
- package/dist/protect.cjs.map +0 -1
- package/dist/protect.d.ts +0 -51
- package/dist/protect.js +0 -1210
- package/dist/protect.js.map +0 -1
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// @patchstack/protect — the runtime protection engine.
|
|
2
|
+
//
|
|
3
|
+
// One pure function: evaluate(ctx, rules, manifest) -> Verdict.
|
|
4
|
+
// No framework coupling — the framework adapter (the guard) builds `ctx` and acts on the
|
|
5
|
+
// Verdict. This is the offsite-frozen contract between Group 1 (engine) and Group 2 (surface).
|
|
6
|
+
//
|
|
7
|
+
// SCOPE OF THIS BUILD: implements the two demo-critical pieces from the prototype —
|
|
8
|
+
// - `inline_xss` (ps-node never implemented it; npm XSS attacks need it)
|
|
9
|
+
// - `package_cond` (the line between virtual patching and a dumb wall)
|
|
10
|
+
// plus `equals` / `contains` and the `urldecode` mutation. The full rule_v2 zoo
|
|
11
|
+
// (AND/OR, nested rules, whitelists, the ~15 other match types) lands when the
|
|
12
|
+
// ps-node RuleEngine is ported in — this file keeps the same signature so that swap
|
|
13
|
+
// is drop-in.
|
|
14
|
+
|
|
15
|
+
/** @type {{ALLOW:'ALLOW',LOG:'LOG',BLOCK:'BLOCK',REDIRECT:'REDIRECT'}} */
|
|
16
|
+
export const ACTIONS = { ALLOW: "ALLOW", LOG: "LOG", BLOCK: "BLOCK", REDIRECT: "REDIRECT" };
|
|
17
|
+
|
|
18
|
+
/** Iteratively URL-decode (catches %2527 -> %27 -> '). Safe on malformed input. */
|
|
19
|
+
export function urldecode(value) {
|
|
20
|
+
let out = String(value);
|
|
21
|
+
for (let i = 0; i < 5; i++) {
|
|
22
|
+
let next;
|
|
23
|
+
try {
|
|
24
|
+
next = decodeURIComponent(out.replace(/\+/g, " "));
|
|
25
|
+
} catch {
|
|
26
|
+
break;
|
|
27
|
+
}
|
|
28
|
+
if (next === out) break;
|
|
29
|
+
out = next;
|
|
30
|
+
}
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const MUTATIONS = { urldecode };
|
|
35
|
+
|
|
36
|
+
function applyMutations(value, mutations) {
|
|
37
|
+
let v = String(value ?? "");
|
|
38
|
+
for (const m of mutations ?? []) {
|
|
39
|
+
if (MUTATIONS[m]) v = MUTATIONS[m](v);
|
|
40
|
+
}
|
|
41
|
+
return v;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Heuristic script-injection detector. Deliberately simple and readable for the demo;
|
|
45
|
+
// the ported engine replaces this with the audited ps-node inline_xss implementation.
|
|
46
|
+
const XSS_PATTERNS = [
|
|
47
|
+
/<\s*script\b/i,
|
|
48
|
+
/<\s*\/\s*script\s*>/i,
|
|
49
|
+
/\bon\w+\s*=/i, // onerror=, onload=, ...
|
|
50
|
+
/javascript\s*:/i,
|
|
51
|
+
/<\s*img\b[^>]*\bon\w+\s*=/i,
|
|
52
|
+
/<\s*svg\b[^>]*\bon\w+\s*=/i,
|
|
53
|
+
/\bdata\s*:\s*text\/html/i,
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
function looksLikeInlineXss(value) {
|
|
57
|
+
const v = String(value ?? "");
|
|
58
|
+
return XSS_PATTERNS.some((re) => re.test(v));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Resolve a rule parameter like "insert.title" or "body.content" against ctx.
|
|
62
|
+
// The guard normalizes framework specifics into ctx.body; parameters read from there.
|
|
63
|
+
function resolveParameter(ctx, parameter) {
|
|
64
|
+
const [, ...rest] = String(parameter).split(".");
|
|
65
|
+
const key = rest.length ? rest.join(".") : parameter;
|
|
66
|
+
const body = ctx?.body ?? {};
|
|
67
|
+
if (key in body) return body[key];
|
|
68
|
+
// shallow scan for nested payloads (e.g. { record: { title } })
|
|
69
|
+
for (const v of Object.values(body)) {
|
|
70
|
+
if (v && typeof v === "object" && key in v) return v[key];
|
|
71
|
+
}
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function matchCondition(ctx, cond) {
|
|
76
|
+
const params = Array.isArray(cond.parameter) ? cond.parameter : [cond.parameter];
|
|
77
|
+
for (const param of params) {
|
|
78
|
+
const raw = resolveParameter(ctx, param);
|
|
79
|
+
if (raw == null) continue;
|
|
80
|
+
const value = applyMutations(raw, cond.mutations);
|
|
81
|
+
const type = cond.match?.type;
|
|
82
|
+
let hit = false;
|
|
83
|
+
if (type === "inline_xss") hit = looksLikeInlineXss(value);
|
|
84
|
+
else if (type === "contains") hit = value.includes(cond.match.value);
|
|
85
|
+
else if (type === "equals") hit = value === cond.match.value;
|
|
86
|
+
if (hit) {
|
|
87
|
+
return { param, type, value };
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// package_cond: only fire if the app actually has the vulnerable package@version.
|
|
94
|
+
// If no manifest is supplied, fail-open on the gate (assume present) — detection already
|
|
95
|
+
// flagged it; the guard can tighten this once it passes the real manifest.
|
|
96
|
+
function packageCondSatisfied(pkgCond, manifest) {
|
|
97
|
+
if (!pkgCond) return true;
|
|
98
|
+
if (!manifest || !manifest.packages) return true;
|
|
99
|
+
const installed = manifest.packages[pkgCond.package];
|
|
100
|
+
if (installed == null) return false;
|
|
101
|
+
if (!pkgCond.vulnerable_versions) return true;
|
|
102
|
+
return pkgCond.vulnerable_versions.includes(installed);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Evaluate one request against the rule set.
|
|
107
|
+
* @param {import('./index.js').RequestContext} ctx
|
|
108
|
+
* @param {import('./index.js').Rule[]} rules
|
|
109
|
+
* @param {import('./index.js').Manifest} [manifest]
|
|
110
|
+
* @returns {import('./index.js').Verdict}
|
|
111
|
+
*/
|
|
112
|
+
export function evaluate(ctx, rules, manifest) {
|
|
113
|
+
const trace = [];
|
|
114
|
+
for (const rule of rules ?? []) {
|
|
115
|
+
if (!packageCondSatisfied(rule.package_cond, manifest)) {
|
|
116
|
+
trace.push({ rule_id: rule.id, skipped: "package_cond not satisfied" });
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
for (const cond of rule.rule_v2 ?? []) {
|
|
120
|
+
const m = matchCondition(ctx, cond);
|
|
121
|
+
if (m) {
|
|
122
|
+
return {
|
|
123
|
+
matched: true,
|
|
124
|
+
action: ACTIONS.BLOCK,
|
|
125
|
+
rule_id: rule.id,
|
|
126
|
+
vulnerability_id: rule.vulnerability_id ?? null,
|
|
127
|
+
package: rule.package_cond?.package ?? null,
|
|
128
|
+
version: manifest?.packages?.[rule.package_cond?.package] ?? null,
|
|
129
|
+
explain: [`${m.param} matched ${m.type}${cond.mutations?.length ? ` after ${cond.mutations.join("+")}` : ""}`],
|
|
130
|
+
trace,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
trace.push({ rule_id: rule.id, matched: false });
|
|
135
|
+
}
|
|
136
|
+
return { matched: false, action: ACTIONS.ALLOW, rule_id: null, vulnerability_id: null, package: null, version: null, explain: [], trace };
|
|
137
|
+
}
|
|
@@ -1,59 +1,102 @@
|
|
|
1
|
-
// Patchstack
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
//
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
} from "@patchstack/connect/protect";
|
|
19
|
-
import fallbackRules from "./rules.json";
|
|
20
|
-
|
|
21
|
-
export { GUARD_PATH };
|
|
22
|
-
|
|
23
|
-
// One shared protection policy for both guards (rules load once).
|
|
24
|
-
let _protection: Awaited<ReturnType<typeof createProtection>> | undefined;
|
|
25
|
-
async function getProtection() {
|
|
26
|
-
if (!_protection) {
|
|
27
|
-
// Always-on: block by default. An explicit PATCHSTACK_MODE=dry-run downgrades to log-only.
|
|
28
|
-
const mode = process.env.PATCHSTACK_MODE === "dry-run" ? "dry-run" : "block";
|
|
29
|
-
const token = process.env.PATCHSTACK_WAF_TOKEN;
|
|
30
|
-
_protection = await createProtection(
|
|
31
|
-
token
|
|
32
|
-
? { token, mode, cacheDir: ".patchstack" } // live per-site rules from the Patchstack API (cached)
|
|
33
|
-
: { rules: fallbackRules as never, mode }, // demo fallback until a token is set
|
|
34
|
-
);
|
|
35
|
-
}
|
|
36
|
-
return _protection;
|
|
1
|
+
// Patchstack auto-guard — installed automatically by "add Patchstack"; the vibe coder never
|
|
2
|
+
// touches this. It runs in the app's own server (the Cloudflare Worker). The patched Supabase
|
|
3
|
+
// client tunnels every browser data call here; we inspect the payload against the rules, then
|
|
4
|
+
// either block it (virtual patch) or forward it to real Supabase.
|
|
5
|
+
import { evaluate, type Rule } from "./engine.js";
|
|
6
|
+
import rulesData from "./rules.json";
|
|
7
|
+
import { manifest } from "./manifest.js";
|
|
8
|
+
|
|
9
|
+
export const GUARD_PATH = "/_patchstack/guard";
|
|
10
|
+
|
|
11
|
+
// Headers that must not be copied verbatim when we re-emit the upstream response.
|
|
12
|
+
const HOP_BY_HOP = new Set(["content-encoding", "content-length", "transfer-encoding", "connection"]);
|
|
13
|
+
|
|
14
|
+
function mode(): "dry-run" | "block" {
|
|
15
|
+
// Always-on: protection blocks by default. Only an explicit PATCHSTACK_MODE=dry-run
|
|
16
|
+
// downgrades to log-only (for testing) — there is no silent dry-run.
|
|
17
|
+
return process.env.PATCHSTACK_MODE === "dry-run" ? "dry-run" : "block";
|
|
37
18
|
}
|
|
38
19
|
|
|
39
|
-
// Request-middleware path: the browser tunnels its direct Supabase calls here.
|
|
40
|
-
let _handle: ((request: Request) => Promise<Response>) | undefined;
|
|
41
20
|
export async function handleGuardRequest(request: Request): Promise<Response> {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
21
|
+
const target = request.headers.get("x-ps-target");
|
|
22
|
+
if (!target) return new Response("patchstack: missing x-ps-target", { status: 400 });
|
|
23
|
+
|
|
24
|
+
// SSRF guard: the client names the target, but we only ever forward to the app's own
|
|
25
|
+
// Supabase project (origin known server-side). Anything else — internal hosts, cloud
|
|
26
|
+
// metadata, a different scheme — is rejected before we make any outbound request.
|
|
27
|
+
const supabaseUrl = process.env.SUPABASE_URL;
|
|
28
|
+
let targetUrl: URL;
|
|
29
|
+
try {
|
|
30
|
+
targetUrl = new URL(target);
|
|
31
|
+
} catch {
|
|
32
|
+
return new Response("patchstack: invalid target", { status: 400 });
|
|
47
33
|
}
|
|
48
|
-
|
|
49
|
-
}
|
|
34
|
+
if (!supabaseUrl || targetUrl.protocol !== "https:" || targetUrl.origin !== new URL(supabaseUrl).origin) {
|
|
35
|
+
return new Response("patchstack: target not allowed", { status: 403 });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const hasBody = request.method !== "GET" && request.method !== "HEAD";
|
|
39
|
+
const bodyText = hasBody ? await request.text() : "";
|
|
50
40
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
41
|
+
let record: Record<string, unknown> = {};
|
|
42
|
+
if (bodyText) {
|
|
43
|
+
try {
|
|
44
|
+
const parsed = JSON.parse(bodyText);
|
|
45
|
+
record = Array.isArray(parsed) ? (parsed[0] ?? {}) : parsed;
|
|
46
|
+
} catch {
|
|
47
|
+
// not JSON — leave record empty; the rule simply won't match
|
|
48
|
+
}
|
|
57
49
|
}
|
|
58
|
-
|
|
50
|
+
|
|
51
|
+
const ctx = {
|
|
52
|
+
method: request.method,
|
|
53
|
+
url: target,
|
|
54
|
+
headers: Object.fromEntries(request.headers),
|
|
55
|
+
// expose the record under a few shapes so rule parameters like "insert.title" resolve
|
|
56
|
+
body: { ...record, insert: record, update: record },
|
|
57
|
+
ip: request.headers.get("x-forwarded-for") ?? "",
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const t0 = performance.now();
|
|
61
|
+
const verdict = evaluate(ctx, (rulesData as { firewall: Rule[] }).firewall, manifest);
|
|
62
|
+
const latencyMs = Math.round((performance.now() - t0) * 100) / 100;
|
|
63
|
+
|
|
64
|
+
// Always log the flag (this is the "dry-run sees it too" behavior).
|
|
65
|
+
const decision = verdict.matched ? `MATCH ${verdict.rule_id}` : "clean";
|
|
66
|
+
console.log(`[patchstack] ${request.method} ${new URL(target).pathname} -> ${decision} · mode=${mode()}`);
|
|
67
|
+
|
|
68
|
+
if (verdict.matched && mode() === "block") {
|
|
69
|
+
const receipt = {
|
|
70
|
+
blocked: true,
|
|
71
|
+
rule_id: verdict.rule_id,
|
|
72
|
+
vulnerability_id: verdict.vulnerability_id,
|
|
73
|
+
package: verdict.package,
|
|
74
|
+
version: verdict.version,
|
|
75
|
+
matched_conditions: verdict.explain,
|
|
76
|
+
latency_ms: latencyMs,
|
|
77
|
+
};
|
|
78
|
+
console.log("[patchstack] BLOCKED", JSON.stringify(receipt));
|
|
79
|
+
return new Response(JSON.stringify(receipt), {
|
|
80
|
+
status: 403,
|
|
81
|
+
headers: { "content-type": "application/json", "x-patchstack": "blocked" },
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Forward to real Supabase, server-side.
|
|
86
|
+
const forwardHeaders = new Headers(request.headers);
|
|
87
|
+
forwardHeaders.delete("x-ps-target");
|
|
88
|
+
forwardHeaders.delete("host");
|
|
89
|
+
const upstream = await fetch(target, {
|
|
90
|
+
method: request.method,
|
|
91
|
+
headers: forwardHeaders,
|
|
92
|
+
body: hasBody ? bodyText : undefined,
|
|
93
|
+
redirect: "manual",
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const outHeaders = new Headers();
|
|
97
|
+
upstream.headers.forEach((value, key) => {
|
|
98
|
+
if (!HOP_BY_HOP.has(key.toLowerCase())) outHeaders.set(key, value);
|
|
99
|
+
});
|
|
100
|
+
const buf = await upstream.arrayBuffer();
|
|
101
|
+
return new Response(buf, { status: upstream.status, statusText: upstream.statusText, headers: outHeaders });
|
|
59
102
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const manifest: { packages: Record<string, string> };
|
|
@@ -1,19 +1,22 @@
|
|
|
1
1
|
{
|
|
2
|
-
"_comment": "
|
|
2
|
+
"_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.",
|
|
3
3
|
"firewall": [
|
|
4
4
|
{
|
|
5
5
|
"id": "rm-npm-0001",
|
|
6
6
|
"title": "Block stored XSS via vulnerable markdown renderer (marked)",
|
|
7
7
|
"vulnerability_id": "CVE-2022-21681",
|
|
8
|
+
"package_cond": {
|
|
9
|
+
"package": "marked",
|
|
10
|
+
"vulnerable_versions": ["4.0.0"]
|
|
11
|
+
},
|
|
8
12
|
"rule_v2": [
|
|
9
13
|
{
|
|
10
|
-
"parameter": "
|
|
14
|
+
"parameter": ["insert.title", "body.title", "update.title"],
|
|
11
15
|
"mutations": ["urldecode"],
|
|
12
16
|
"match": { "type": "inline_xss" }
|
|
13
17
|
}
|
|
14
18
|
]
|
|
15
19
|
}
|
|
16
20
|
],
|
|
17
|
-
"whitelists": []
|
|
18
|
-
"whitelist_keys": {}
|
|
21
|
+
"whitelists": []
|
|
19
22
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@patchstack/connect",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.3",
|
|
4
4
|
"description": "Patchstack connector for JavaScript applications. Scans your lockfile and reports installed packages to Patchstack for vulnerability monitoring.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"patchstack",
|
|
@@ -21,11 +21,6 @@
|
|
|
21
21
|
"types": "./dist/index.d.ts",
|
|
22
22
|
"import": "./dist/index.js",
|
|
23
23
|
"require": "./dist/index.cjs"
|
|
24
|
-
},
|
|
25
|
-
"./protect": {
|
|
26
|
-
"types": "./dist/protect.d.ts",
|
|
27
|
-
"import": "./dist/protect.js",
|
|
28
|
-
"require": "./dist/protect.cjs"
|
|
29
24
|
}
|
|
30
25
|
},
|
|
31
26
|
"bin": {
|
|
@@ -34,6 +29,7 @@
|
|
|
34
29
|
"files": [
|
|
35
30
|
"dist",
|
|
36
31
|
"README.md",
|
|
32
|
+
"AGENT-INSTALL.md",
|
|
37
33
|
"LICENSE"
|
|
38
34
|
],
|
|
39
35
|
"scripts": {
|