unbrowse 3.0.2 → 3.1.0-experiments.5e7a7bb
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/dist/cli.js +629 -101
- package/dist/mcp.js +710 -73
- package/package.json +1 -1
- package/runtime-src/api/browse-index.ts +26 -4
- package/runtime-src/api/routes.ts +43 -9
- package/runtime-src/browser/index.ts +2 -1
- package/runtime-src/build-info.generated.ts +5 -5
- package/runtime-src/capture/index.ts +113 -0
- package/runtime-src/cli.ts +190 -2
- package/runtime-src/client/index.ts +28 -12
- package/runtime-src/execution/index.ts +43 -21
- package/runtime-src/execution/token-resolver.ts +122 -0
- package/runtime-src/graph/index.ts +14 -6
- package/runtime-src/impact-log.ts +227 -0
- package/runtime-src/kuri/client.ts +5 -1
- package/runtime-src/marketplace/index.ts +9 -1
- package/runtime-src/mcp.ts +247 -34
- package/runtime-src/orchestrator/browser-agent.ts +2 -1
- package/runtime-src/orchestrator/index.ts +7 -3
- package/runtime-src/payments/lobster-pay.ts +182 -0
- package/runtime-src/reverse-engineer/token-sources.ts +357 -0
- package/runtime-src/types/skill.ts +19 -0
- package/vendor/kuri/darwin-arm64/kuri +0 -0
- package/vendor/kuri/darwin-x64/kuri +0 -0
- package/vendor/kuri/linux-arm64/kuri +0 -0
- package/vendor/kuri/linux-x64/kuri +0 -0
- package/vendor/kuri/manifest.json +10 -6
- package/vendor/kuri/win-x64/kuri.exe +0 -0
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lobster.cash x402 payment bridge.
|
|
3
|
+
*
|
|
4
|
+
* Handles the pay-and-retry cycle:
|
|
5
|
+
* 1. Receives x402 payment terms from a 402 response
|
|
6
|
+
* 2. Calls `lobstercash x402 fetch <url>` to pay + retry
|
|
7
|
+
* 3. Returns the paid response body
|
|
8
|
+
*
|
|
9
|
+
* Delegation boundary:
|
|
10
|
+
* - Unbrowse owns: detecting 402, passing the URL, using the result
|
|
11
|
+
* - Lobster owns: wallet signing, transaction broadcast, proof construction
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { execFile, execFileSync } from "node:child_process";
|
|
15
|
+
import { existsSync } from "node:fs";
|
|
16
|
+
import { homedir } from "node:os";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
|
|
19
|
+
const LOBSTER_PAY_TIMEOUT_MS = 30_000;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Resolve the lobster CLI command. Prefers the direct binary if
|
|
23
|
+
* installed globally, otherwise falls back to npx.
|
|
24
|
+
*/
|
|
25
|
+
function getLobsterCommand(): { cmd: string; prefix: string[] } | null {
|
|
26
|
+
// 1. Direct binary in PATH
|
|
27
|
+
try {
|
|
28
|
+
execFileSync("lobstercash", ["--version"], { stdio: "ignore", timeout: 3_000 });
|
|
29
|
+
return { cmd: "lobstercash", prefix: [] };
|
|
30
|
+
} catch (_e) { /* not in PATH */ }
|
|
31
|
+
|
|
32
|
+
// 2. Check npm global bin (often not in agent PATH)
|
|
33
|
+
try {
|
|
34
|
+
const npmPrefix = execFileSync("npm", ["config", "get", "prefix"], { encoding: "utf8", timeout: 5_000 }).trim();
|
|
35
|
+
const lobsterPath = join(npmPrefix, "bin", "lobstercash");
|
|
36
|
+
if (existsSync(lobsterPath)) {
|
|
37
|
+
execFileSync(lobsterPath, ["--version"], { stdio: "ignore", timeout: 3_000 });
|
|
38
|
+
return { cmd: lobsterPath, prefix: [] };
|
|
39
|
+
}
|
|
40
|
+
} catch (_e) { /* npm not available */ }
|
|
41
|
+
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
let cachedCommand: { cmd: string; prefix: string[] } | null | undefined = undefined;
|
|
46
|
+
function lobsterCmd(): { cmd: string; prefix: string[] } | null {
|
|
47
|
+
if (cachedCommand === undefined) cachedCommand = getLobsterCommand();
|
|
48
|
+
return cachedCommand;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface LobsterPayResult {
|
|
52
|
+
success: boolean;
|
|
53
|
+
body: string;
|
|
54
|
+
statusCode?: number;
|
|
55
|
+
error?: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Check if the lobster CLI is available and wallet is configured.
|
|
60
|
+
*/
|
|
61
|
+
export function isLobsterAvailable(): boolean {
|
|
62
|
+
const agentsPath = join(process.env.HOME || homedir(), ".lobster", "agents.json");
|
|
63
|
+
return existsSync(agentsPath);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Pay for an x402-gated URL via lobster.cash and return the response.
|
|
68
|
+
*
|
|
69
|
+
* Shells out to `lobstercash x402 fetch <url>` which handles:
|
|
70
|
+
* - Reading 402 + payment terms
|
|
71
|
+
* - Signing + broadcasting the USDC transfer
|
|
72
|
+
* - Retrying with PAYMENT-SIGNATURE header
|
|
73
|
+
* - Returning the paid response
|
|
74
|
+
*/
|
|
75
|
+
export function lobsterX402Fetch(
|
|
76
|
+
url: string,
|
|
77
|
+
options?: {
|
|
78
|
+
jsonBody?: string;
|
|
79
|
+
headers?: Record<string, string>;
|
|
80
|
+
timeoutMs?: number;
|
|
81
|
+
},
|
|
82
|
+
): Promise<LobsterPayResult> {
|
|
83
|
+
return new Promise((resolve) => {
|
|
84
|
+
const resolved = lobsterCmd();
|
|
85
|
+
if (!resolved) {
|
|
86
|
+
resolve({ success: false, body: "", error: "lobstercash CLI not in PATH" });
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
const { cmd, prefix } = resolved;
|
|
90
|
+
const args = [...prefix, "x402", "fetch", url, "--debug"];
|
|
91
|
+
|
|
92
|
+
if (options?.jsonBody) {
|
|
93
|
+
args.push("--json", options.jsonBody);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (options?.headers) {
|
|
97
|
+
for (const [key, value] of Object.entries(options.headers)) {
|
|
98
|
+
args.push("--header", `${key}:${value}`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const timeout = options?.timeoutMs ?? LOBSTER_PAY_TIMEOUT_MS;
|
|
103
|
+
args.push("--timeout", String(timeout));
|
|
104
|
+
|
|
105
|
+
execFile(cmd, args, { timeout: timeout + 5_000, maxBuffer: 1024 * 1024 }, (err, stdout, stderr) => {
|
|
106
|
+
if (err) {
|
|
107
|
+
const msg = stderr?.trim() || err.message;
|
|
108
|
+
console.warn(`[lobster-pay] x402 fetch failed: ${msg}`);
|
|
109
|
+
resolve({ success: false, body: "", error: msg });
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (stderr) {
|
|
114
|
+
for (const line of stderr.split("\n").filter(Boolean)) {
|
|
115
|
+
console.log(`[lobster-pay] ${line}`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Parse status from stdout header line: "Status: 200"
|
|
120
|
+
const statusMatch = stdout.match(/^Status:\s*(\d+)/m);
|
|
121
|
+
const statusCode = statusMatch ? parseInt(statusMatch[1], 10) : undefined;
|
|
122
|
+
|
|
123
|
+
if (statusCode && statusCode >= 400) {
|
|
124
|
+
resolve({ success: false, body: stdout, statusCode, error: `HTTP ${statusCode}` });
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
resolve({ success: true, body: stdout, statusCode });
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Pay for an x402-gated API request and return the parsed JSON response.
|
|
135
|
+
*
|
|
136
|
+
* This is the high-level entry point for the pay-and-retry loop.
|
|
137
|
+
* Returns null if payment fails or lobster is unavailable.
|
|
138
|
+
*/
|
|
139
|
+
export async function payAndRetry<T = unknown>(
|
|
140
|
+
fullUrl: string,
|
|
141
|
+
options?: {
|
|
142
|
+
body?: unknown;
|
|
143
|
+
headers?: Record<string, string>;
|
|
144
|
+
},
|
|
145
|
+
): Promise<{ data: T; paid: true } | null> {
|
|
146
|
+
if (!isLobsterAvailable()) {
|
|
147
|
+
console.log("[lobster-pay] lobster.cash not configured — skipping payment");
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
console.log(`[lobster-pay] attempting x402 payment for ${fullUrl}`);
|
|
152
|
+
|
|
153
|
+
const result = await lobsterX402Fetch(fullUrl, {
|
|
154
|
+
jsonBody: options?.body ? JSON.stringify(options.body) : undefined,
|
|
155
|
+
headers: options?.headers,
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
if (!result.success) {
|
|
159
|
+
console.warn(`[lobster-pay] payment failed: ${result.error}`);
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
try {
|
|
163
|
+
// lobstercash x402 fetch outputs header lines before the JSON body:
|
|
164
|
+
// x402 FETCH <url>
|
|
165
|
+
// Status: 200
|
|
166
|
+
// Content-Type: application/json
|
|
167
|
+
// <blank line>
|
|
168
|
+
// {"actual":"json",...}
|
|
169
|
+
// Extract the JSON portion by finding the first '{' or '['
|
|
170
|
+
const raw = result.body;
|
|
171
|
+
const jsonStart = Math.min(
|
|
172
|
+
...[raw.indexOf("{"), raw.indexOf("[")].filter((i) => i >= 0),
|
|
173
|
+
);
|
|
174
|
+
const jsonStr = jsonStart >= 0 ? raw.slice(jsonStart) : raw;
|
|
175
|
+
const data = JSON.parse(jsonStr) as T;
|
|
176
|
+
console.log("[lobster-pay] payment successful — got paid response");
|
|
177
|
+
return { data, paid: true };
|
|
178
|
+
} catch (_e) {
|
|
179
|
+
console.warn("[lobster-pay] paid response was not valid JSON");
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Token source discovery - finds where a captured token value lives in HTML / JS
|
|
3
|
+
* so the value can be re-scraped fresh at replay time.
|
|
4
|
+
*
|
|
5
|
+
* Input: a concrete token value we saw in a captured request header
|
|
6
|
+
* Output: an ordered list of AuthTokenSource locators (meta tag, inline script regex,
|
|
7
|
+
* or JS bundle regex) that will re-produce the same value when re-evaluated
|
|
8
|
+
*
|
|
9
|
+
* The resolver (src/execution/token-resolver.ts) walks these sources in order at
|
|
10
|
+
* replay time and injects the freshest value into the outgoing request. This closes
|
|
11
|
+
* the gap for sites that rotate CSRF tokens per page-load, keep bearer tokens in JS
|
|
12
|
+
* bundles (Twitter public API), or hydrate tokens into window.__INITIAL_STATE__.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { AuthTokenSource } from "../types/index.js";
|
|
16
|
+
|
|
17
|
+
// Minimum token length to consider - below this, false-positive matches dominate
|
|
18
|
+
// (common 8-char hex substrings appear in every page).
|
|
19
|
+
const MIN_TOKEN_LENGTH = 16;
|
|
20
|
+
|
|
21
|
+
// Maximum sources to return per token - higher values make resolution slower
|
|
22
|
+
// without meaningfully improving robustness.
|
|
23
|
+
const MAX_SOURCES = 5;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Find all deterministic locators that currently produce the given token value
|
|
27
|
+
* from the supplied HTML and JS bundle contents. The locators are captured in a
|
|
28
|
+
* form that allows re-evaluation at replay time against freshly-fetched page
|
|
29
|
+
* state - if the site rotates the token each page-load, the same locator will
|
|
30
|
+
* return the new value.
|
|
31
|
+
*/
|
|
32
|
+
export function findTokenSources(
|
|
33
|
+
tokenValue: string,
|
|
34
|
+
html: string | undefined,
|
|
35
|
+
jsBundles?: Map<string, string>,
|
|
36
|
+
): AuthTokenSource[] {
|
|
37
|
+
if (!tokenValue || tokenValue.length < MIN_TOKEN_LENGTH) return [];
|
|
38
|
+
|
|
39
|
+
const sources: AuthTokenSource[] = [];
|
|
40
|
+
|
|
41
|
+
if (html) {
|
|
42
|
+
sources.push(...findHtmlMetaSources(tokenValue, html));
|
|
43
|
+
sources.push(...findInlineScriptSources(tokenValue, html));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (jsBundles && jsBundles.size > 0) {
|
|
47
|
+
sources.push(...findJsBundleSources(tokenValue, jsBundles));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Dedupe and cap
|
|
51
|
+
const seen = new Set<string>();
|
|
52
|
+
const out: AuthTokenSource[] = [];
|
|
53
|
+
for (const src of sources) {
|
|
54
|
+
const key = JSON.stringify(src);
|
|
55
|
+
if (seen.has(key)) continue;
|
|
56
|
+
seen.add(key);
|
|
57
|
+
out.push(src);
|
|
58
|
+
if (out.length >= MAX_SOURCES) break;
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// HTML <meta> scanner
|
|
64
|
+
// Matches patterns like:
|
|
65
|
+
// <meta name="csrf-token" content="ABC123...">
|
|
66
|
+
// <meta name="csrfToken" value="ABC123..." />
|
|
67
|
+
// <meta property="csrf" content="ABC123...">
|
|
68
|
+
|
|
69
|
+
const META_TAG_PATTERN = /<meta\s+([^>]+?)\s*\/?>/gi;
|
|
70
|
+
|
|
71
|
+
function findHtmlMetaSources(tokenValue: string, html: string): AuthTokenSource[] {
|
|
72
|
+
const out: AuthTokenSource[] = [];
|
|
73
|
+
META_TAG_PATTERN.lastIndex = 0;
|
|
74
|
+
let match: RegExpExecArray | null;
|
|
75
|
+
while ((match = META_TAG_PATTERN.exec(html)) !== null) {
|
|
76
|
+
const attrs = match[1];
|
|
77
|
+
if (!attrs.includes(tokenValue)) continue;
|
|
78
|
+
|
|
79
|
+
const name =
|
|
80
|
+
extractAttr(attrs, "name") ??
|
|
81
|
+
extractAttr(attrs, "property") ??
|
|
82
|
+
extractAttr(attrs, "itemprop");
|
|
83
|
+
if (!name) continue;
|
|
84
|
+
|
|
85
|
+
for (const attrName of ["content", "value"] as const) {
|
|
86
|
+
const attrValue = extractAttr(attrs, attrName);
|
|
87
|
+
if (attrValue === tokenValue) {
|
|
88
|
+
out.push({
|
|
89
|
+
kind: "html-meta",
|
|
90
|
+
meta_name: name,
|
|
91
|
+
meta_attr: attrName,
|
|
92
|
+
});
|
|
93
|
+
break;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return out;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function extractAttr(attrs: string, name: string): string | undefined {
|
|
101
|
+
const re = new RegExp(`\\b${escapeRegExp(name)}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))`, "i");
|
|
102
|
+
const m = attrs.match(re);
|
|
103
|
+
if (!m) return undefined;
|
|
104
|
+
return m[1] ?? m[2] ?? m[3];
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Inline <script> scanner
|
|
108
|
+
|
|
109
|
+
const INLINE_SCRIPT_PATTERN = /<script(?:\s[^>]*)?>([\s\S]*?)<\/script>/gi;
|
|
110
|
+
const CONTEXT_WINDOW = 48;
|
|
111
|
+
|
|
112
|
+
function findInlineScriptSources(tokenValue: string, html: string): AuthTokenSource[] {
|
|
113
|
+
const out: AuthTokenSource[] = [];
|
|
114
|
+
INLINE_SCRIPT_PATTERN.lastIndex = 0;
|
|
115
|
+
let match: RegExpExecArray | null;
|
|
116
|
+
while ((match = INLINE_SCRIPT_PATTERN.exec(html)) !== null) {
|
|
117
|
+
const scriptBody = match[1];
|
|
118
|
+
const idx = scriptBody.indexOf(tokenValue);
|
|
119
|
+
if (idx < 0) continue;
|
|
120
|
+
|
|
121
|
+
const regex = buildContextRegex(scriptBody, idx, tokenValue);
|
|
122
|
+
if (!regex) continue;
|
|
123
|
+
|
|
124
|
+
out.push({
|
|
125
|
+
kind: "html-inline-script",
|
|
126
|
+
inline_script_regex: regex,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
return out;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// JS bundle scanner
|
|
133
|
+
|
|
134
|
+
function findJsBundleSources(
|
|
135
|
+
tokenValue: string,
|
|
136
|
+
bundles: Map<string, string>,
|
|
137
|
+
): AuthTokenSource[] {
|
|
138
|
+
const out: AuthTokenSource[] = [];
|
|
139
|
+
for (const [bundleUrl, content] of bundles) {
|
|
140
|
+
const idx = content.indexOf(tokenValue);
|
|
141
|
+
if (idx < 0) continue;
|
|
142
|
+
|
|
143
|
+
const regex = buildContextRegex(content, idx, tokenValue);
|
|
144
|
+
if (!regex) continue;
|
|
145
|
+
|
|
146
|
+
out.push({
|
|
147
|
+
kind: "js-bundle",
|
|
148
|
+
bundle_url_pattern: bundleUrlPattern(bundleUrl),
|
|
149
|
+
bundle_regex: regex,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
return out;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function bundleUrlPattern(bundleUrl: string): string {
|
|
156
|
+
try {
|
|
157
|
+
const u = new URL(bundleUrl);
|
|
158
|
+
const path = u.pathname.replace(/[.-][a-f0-9]{6,}\./, ".");
|
|
159
|
+
return path.replace(/\.(m?js)$/i, "");
|
|
160
|
+
} catch {
|
|
161
|
+
return bundleUrl.replace(/[.-][a-f0-9]{6,}\./, ".").replace(/\.(m?js)$/i, "");
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Context regex builder
|
|
166
|
+
|
|
167
|
+
function buildContextRegex(
|
|
168
|
+
content: string,
|
|
169
|
+
tokenIdx: number,
|
|
170
|
+
tokenValue: string,
|
|
171
|
+
): string | null {
|
|
172
|
+
const start = Math.max(0, tokenIdx - CONTEXT_WINDOW);
|
|
173
|
+
let context = content.slice(start, tokenIdx);
|
|
174
|
+
|
|
175
|
+
const delimMatch = context.match(/([{,\[(]\s*["'`]?[\w$-]+\s*["'`]?\s*[:=]\s*["'`])[^{,\[(]*$/);
|
|
176
|
+
if (delimMatch) {
|
|
177
|
+
context = delimMatch[1];
|
|
178
|
+
} else {
|
|
179
|
+
context = context.trimStart();
|
|
180
|
+
if (context.length < 6) return null;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const afterIdx = tokenIdx + tokenValue.length;
|
|
184
|
+
const terminator = content.charAt(afterIdx);
|
|
185
|
+
|
|
186
|
+
let terminatorPattern: string;
|
|
187
|
+
if (terminator === '"') terminatorPattern = '"';
|
|
188
|
+
else if (terminator === "'") terminatorPattern = "'";
|
|
189
|
+
else if (terminator === "`") terminatorPattern = "`";
|
|
190
|
+
else if (terminator === "," || terminator === "}" || terminator === "]" || terminator === ")") {
|
|
191
|
+
terminatorPattern = `[${escapeRegExp(terminator)}]`;
|
|
192
|
+
} else {
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const tokenCharset = inferTokenCharset(tokenValue);
|
|
197
|
+
return `${escapeRegExp(context)}(${tokenCharset}+?)(?=${terminatorPattern})`;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function inferTokenCharset(tokenValue: string): string {
|
|
201
|
+
if (/^[A-Za-z0-9+/=]+$/.test(tokenValue)) return "[A-Za-z0-9+/=]";
|
|
202
|
+
if (/^[A-Za-z0-9._-]+$/.test(tokenValue)) return "[A-Za-z0-9._-]";
|
|
203
|
+
if (/^[A-Fa-f0-9]+$/.test(tokenValue)) return "[A-Fa-f0-9]";
|
|
204
|
+
if (/^[A-Za-z0-9]+$/.test(tokenValue)) return "[A-Za-z0-9]";
|
|
205
|
+
return "[^\"'`,}\\]\\s)]";
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function escapeRegExp(s: string): string {
|
|
209
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Resolver-side helpers
|
|
213
|
+
|
|
214
|
+
export function extractTokenFromHtml(
|
|
215
|
+
source: AuthTokenSource,
|
|
216
|
+
html: string,
|
|
217
|
+
): string | undefined {
|
|
218
|
+
if (source.kind === "html-meta" && source.meta_name) {
|
|
219
|
+
const attr = source.meta_attr ?? "content";
|
|
220
|
+
const re = new RegExp(
|
|
221
|
+
`<meta\\s+[^>]*?(?:name|property|itemprop)\\s*=\\s*["']${escapeRegExp(source.meta_name)}["'][^>]*?${attr}\\s*=\\s*["']([^"']+)["'][^>]*>`,
|
|
222
|
+
"i",
|
|
223
|
+
);
|
|
224
|
+
const m = html.match(re);
|
|
225
|
+
if (m) return m[1];
|
|
226
|
+
const re2 = new RegExp(
|
|
227
|
+
`<meta\\s+[^>]*?${attr}\\s*=\\s*["']([^"']+)["'][^>]*?(?:name|property|itemprop)\\s*=\\s*["']${escapeRegExp(source.meta_name)}["'][^>]*>`,
|
|
228
|
+
"i",
|
|
229
|
+
);
|
|
230
|
+
const m2 = html.match(re2);
|
|
231
|
+
if (m2) return m2[1];
|
|
232
|
+
return undefined;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (source.kind === "html-inline-script" && source.inline_script_regex) {
|
|
236
|
+
const scriptPattern = /<script(?:\s[^>]*)?>([\s\S]*?)<\/script>/gi;
|
|
237
|
+
const inner = new RegExp(source.inline_script_regex);
|
|
238
|
+
let m: RegExpExecArray | null;
|
|
239
|
+
scriptPattern.lastIndex = 0;
|
|
240
|
+
while ((m = scriptPattern.exec(html)) !== null) {
|
|
241
|
+
const hit = m[1].match(inner);
|
|
242
|
+
if (hit && hit[1]) return hit[1];
|
|
243
|
+
}
|
|
244
|
+
return undefined;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return undefined;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export function extractTokenFromBundle(
|
|
251
|
+
source: AuthTokenSource,
|
|
252
|
+
bundleContent: string,
|
|
253
|
+
): string | undefined {
|
|
254
|
+
if (source.kind !== "js-bundle" || !source.bundle_regex) return undefined;
|
|
255
|
+
const m = bundleContent.match(new RegExp(source.bundle_regex));
|
|
256
|
+
return m?.[1];
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// ─── Endpoint enrichment ──────────────────────────────────────────────────
|
|
260
|
+
//
|
|
261
|
+
// Walks captured requests, extracts auth-sensitive headers, runs findTokenSources
|
|
262
|
+
// for each, and attaches matching TokenBindings to the corresponding endpoints.
|
|
263
|
+
//
|
|
264
|
+
// This is the capture-side glue that wires HTML/JS token discovery into the DAG.
|
|
265
|
+
|
|
266
|
+
import type { EndpointDescriptor, AuthTokenBinding, AuthTokenSource as _TS } from "../types/index.js";
|
|
267
|
+
|
|
268
|
+
/** Header names that commonly carry site-computed tokens worth scanning for.
|
|
269
|
+
* Bearer auth, CSRF tokens, and transaction IDs are the prime targets. */
|
|
270
|
+
const TOKEN_HEADER_PATTERN =
|
|
271
|
+
/^(authorization|x-csrf-token|x-xsrf-token|x-csrftoken|csrf-token|x-api-key|x-auth-token|x-access-token|x-session-token|x-shopify-storefront-access-token|x-twitter-auth-type|x-client-transaction-id|x-requested-with|x-client-version)$/i;
|
|
272
|
+
|
|
273
|
+
interface MinimalRequest {
|
|
274
|
+
url: string;
|
|
275
|
+
method: string;
|
|
276
|
+
request_headers: Record<string, string>;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* For each captured request that uses a token-bearing header, find that token's
|
|
281
|
+
* source locations in the captured HTML / JS bundles and attach AuthTokenBinding
|
|
282
|
+
* entries to the endpoint that matches the request URL + method.
|
|
283
|
+
*
|
|
284
|
+
* Runs in-place: mutates endpoint.auth_tokens.
|
|
285
|
+
*/
|
|
286
|
+
export function enrichEndpointsWithTokenSources(
|
|
287
|
+
endpoints: EndpointDescriptor[],
|
|
288
|
+
requests: MinimalRequest[],
|
|
289
|
+
html: string | undefined,
|
|
290
|
+
jsBundles: Map<string, string> | undefined,
|
|
291
|
+
): number {
|
|
292
|
+
if (!html && (!jsBundles || jsBundles.size === 0)) return 0;
|
|
293
|
+
|
|
294
|
+
let enriched = 0;
|
|
295
|
+
for (const req of requests) {
|
|
296
|
+
const matching = endpoints.filter((ep) => endpointMatchesRequest(ep, req));
|
|
297
|
+
if (matching.length === 0) continue;
|
|
298
|
+
|
|
299
|
+
for (const [headerName, headerValue] of Object.entries(req.request_headers)) {
|
|
300
|
+
if (!TOKEN_HEADER_PATTERN.test(headerName)) continue;
|
|
301
|
+
// Bearer tokens are prefixed — strip before scanning
|
|
302
|
+
const tokenValue = extractTokenValue(headerName, headerValue);
|
|
303
|
+
if (!tokenValue) continue;
|
|
304
|
+
|
|
305
|
+
const sources = findTokenSources(tokenValue, html, jsBundles);
|
|
306
|
+
if (sources.length === 0) continue;
|
|
307
|
+
|
|
308
|
+
const binding: AuthTokenBinding = {
|
|
309
|
+
param_name: headerName.toLowerCase(),
|
|
310
|
+
param_location: "header",
|
|
311
|
+
sources,
|
|
312
|
+
refresh_on_401: true,
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
for (const ep of matching) {
|
|
316
|
+
if (!ep.auth_tokens) ep.auth_tokens = [];
|
|
317
|
+
// Dedupe: skip if a binding for the same param already exists
|
|
318
|
+
if (ep.auth_tokens.some((b) => b.param_name === binding.param_name)) continue;
|
|
319
|
+
ep.auth_tokens.push(binding);
|
|
320
|
+
enriched++;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
return enriched;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** For Authorization: Bearer XYZ → returns "XYZ". For plain headers → returns value as-is. */
|
|
328
|
+
function extractTokenValue(headerName: string, headerValue: string): string | null {
|
|
329
|
+
if (!headerValue) return null;
|
|
330
|
+
if (headerName.toLowerCase() === "authorization") {
|
|
331
|
+
const parts = headerValue.split(/\s+/, 2);
|
|
332
|
+
if (parts.length === 2 && /^(bearer|token)$/i.test(parts[0])) return parts[1];
|
|
333
|
+
return headerValue;
|
|
334
|
+
}
|
|
335
|
+
return headerValue;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** Loose match: compare hostname + path prefix of the captured request against the endpoint's url_template. */
|
|
339
|
+
function endpointMatchesRequest(ep: EndpointDescriptor, req: MinimalRequest): boolean {
|
|
340
|
+
if (ep.method !== req.method) return false;
|
|
341
|
+
try {
|
|
342
|
+
const reqUrl = new URL(req.url);
|
|
343
|
+
const tplUrl = new URL(ep.url_template.replace(/\{[^}]+\}/g, "x"));
|
|
344
|
+
if (reqUrl.hostname !== tplUrl.hostname) return false;
|
|
345
|
+
// Compare path segment counts + non-template segments
|
|
346
|
+
const reqSegs = reqUrl.pathname.split("/").filter(Boolean);
|
|
347
|
+
const tplSegs = tplUrl.pathname.split("/").filter(Boolean);
|
|
348
|
+
if (reqSegs.length !== tplSegs.length) return false;
|
|
349
|
+
for (let i = 0; i < tplSegs.length; i++) {
|
|
350
|
+
if (tplSegs[i] === "x") continue; // was a {template} placeholder
|
|
351
|
+
if (tplSegs[i] !== reqSegs[i]) return false;
|
|
352
|
+
}
|
|
353
|
+
return true;
|
|
354
|
+
} catch {
|
|
355
|
+
return false;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
@@ -130,6 +130,23 @@ export interface EndpointPathBindingCandidate {
|
|
|
130
130
|
matched_page_hint?: boolean;
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
+
export interface AuthTokenSource {
|
|
134
|
+
kind: "cookie" | "html-meta" | "html-inline-script" | "js-bundle" | "observed-request";
|
|
135
|
+
cookie_names?: string[];
|
|
136
|
+
meta_name?: string;
|
|
137
|
+
meta_attr?: "content" | "value";
|
|
138
|
+
inline_script_regex?: string;
|
|
139
|
+
bundle_url_pattern?: string;
|
|
140
|
+
bundle_regex?: string;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export interface AuthTokenBinding {
|
|
144
|
+
param_name: string;
|
|
145
|
+
param_location: "header" | "body" | "query";
|
|
146
|
+
sources: AuthTokenSource[];
|
|
147
|
+
refresh_on_401?: boolean;
|
|
148
|
+
}
|
|
149
|
+
|
|
133
150
|
export interface EndpointDescriptor {
|
|
134
151
|
endpoint_id: string;
|
|
135
152
|
method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD" | "OPTIONS" | "WS";
|
|
@@ -146,6 +163,8 @@ export interface EndpointDescriptor {
|
|
|
146
163
|
body?: Record<string, unknown>;
|
|
147
164
|
csrf_plan?: CsrfPlan;
|
|
148
165
|
oauth_plan?: OAuthPlan;
|
|
166
|
+
/** Token bindings discovered during capture — maps auth headers to their page sources */
|
|
167
|
+
auth_tokens?: AuthTokenBinding[];
|
|
149
168
|
transform_ref?: string;
|
|
150
169
|
idempotency: Idempotency;
|
|
151
170
|
verification_status: VerificationStatus;
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -1,24 +1,28 @@
|
|
|
1
1
|
{
|
|
2
2
|
"repo_url": "https://github.com/justrach/kuri.git",
|
|
3
3
|
"branch": "adding-extensions",
|
|
4
|
-
"source_sha": "
|
|
5
|
-
"built_at": "2026-04-
|
|
4
|
+
"source_sha": "08eecbe3740f046a46f656eed7ebfc66c1bad9bb",
|
|
5
|
+
"built_at": "2026-04-05T06:43:57.212Z",
|
|
6
6
|
"binaries": {
|
|
7
7
|
"darwin-arm64": {
|
|
8
8
|
"zig_target": "aarch64-macos",
|
|
9
|
-
"sha256": "
|
|
9
|
+
"sha256": "1553633e722d18059dedffa8a52d55ed6c052e4961fd2753ee0b62be60b241bf"
|
|
10
10
|
},
|
|
11
11
|
"darwin-x64": {
|
|
12
12
|
"zig_target": "x86_64-macos",
|
|
13
|
-
"sha256": "
|
|
13
|
+
"sha256": "b5eb07e631c6ddad64019c8d0c86c32cb76a74ff0791ac5611a3aa3550767ec8"
|
|
14
14
|
},
|
|
15
15
|
"linux-arm64": {
|
|
16
16
|
"zig_target": "aarch64-linux",
|
|
17
|
-
"sha256": "
|
|
17
|
+
"sha256": "ea88a26f7b335d5842b0c1d83bfa4066bed0a119284560f6bd3833f1d240cce2"
|
|
18
18
|
},
|
|
19
19
|
"linux-x64": {
|
|
20
20
|
"zig_target": "x86_64-linux",
|
|
21
|
-
"sha256": "
|
|
21
|
+
"sha256": "175a7c59e458e952a26974f0fb5c2ce374e56f2c4c352903b481b5aa5a16978f"
|
|
22
|
+
},
|
|
23
|
+
"win-x64": {
|
|
24
|
+
"zig_target": "x86_64-windows",
|
|
25
|
+
"sha256": "176291ad9827a183ba7322ddb56cc1fa5edc7c214a264ecdf8a1d5d18366d686"
|
|
22
26
|
}
|
|
23
27
|
}
|
|
24
28
|
}
|
|
Binary file
|