cortad 0.3.4 → 0.3.6
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/lib/proof.mjs +15 -6
- package/lib/pyhook/sitecustomize.py +60 -9
- package/lib/replay.mjs +34 -6
- package/lib/start.mjs +8 -0
- package/lib/trace.cjs +59 -9
- package/local.mjs +17 -9
- package/package.json +1 -1
package/lib/proof.mjs
CHANGED
|
@@ -440,10 +440,17 @@ function carriedFrom(later, earlier, ask) {
|
|
|
440
440
|
|
|
441
441
|
const cookiesIn = (ex) => Object.fromEntries(str(headerOf(ex.headers, "cookie")).split(";").map((c) => c.trim().split("=")).filter(([k, v]) => k && v !== undefined).map(([k, ...v]) => [k, v.join("=")]));
|
|
442
442
|
|
|
443
|
+
// A sign-in is never the conversation. A cookie or a header a trial is sent without is one that only
|
|
444
|
+
// names the conversation: Databuddy's sign-in cookie, refreshed on every reply, was taken for one, and
|
|
445
|
+
// each trial sent without it was refused; an Authorization header that stayed the same would be too.
|
|
446
|
+
const READS_AS_CREDENTIAL = /auth|token|jwt|csrf|xsrf|secret|key|sid$|sessionid|connect\.sid|phpsessid|jsessionid|__session|__host-|__secure-|^sb-|clerk|supabase|laravel_session/i;
|
|
447
|
+
const READS_AS_CONVERSATION = /conv|thread|chat|dialog|session[-_]?id/i;
|
|
448
|
+
const namesConversation = (name) => READS_AS_CONVERSATION.test(name) && !READS_AS_CREDENTIAL.test(name);
|
|
449
|
+
|
|
443
450
|
// What stayed the same from the earlier exchange to the later one and is not the ask: a field the app
|
|
444
451
|
// handed back, a path segment, a field that reads as an id, a cookie the app set, a header, another
|
|
445
452
|
// cookie. The first found carries the session.
|
|
446
|
-
function carrierOf(earlier, later, ask, door) {
|
|
453
|
+
export function carrierOf(earlier, later, ask, door) {
|
|
447
454
|
const same = (a, b) => a !== undefined && a !== "" && a === b;
|
|
448
455
|
const before = new Map(leaves(bodyOf(earlier) ?? {}).map((l) => [l.at.join("."), l.value]));
|
|
449
456
|
const own = leaves(bodyOf(later) ?? {}).filter((l) => !inList(l.at) && fieldOf(l.at) !== ask?.field);
|
|
@@ -466,13 +473,15 @@ function carrierOf(earlier, later, ask, door) {
|
|
|
466
473
|
if (idField) return { carrier: "body", key: idField.at.join("."), minted: "client" };
|
|
467
474
|
const jar = cookiesIn(later);
|
|
468
475
|
const jarBefore = cookiesIn(earlier);
|
|
469
|
-
// A cookie the app set on the earlier reply, sent back
|
|
470
|
-
|
|
476
|
+
// A cookie the app set on the earlier reply, sent back, which the earlier request could not carry.
|
|
477
|
+
// One that request already carried was only refreshed: a session the person signed in with, unless
|
|
478
|
+
// its name says it is the conversation.
|
|
479
|
+
const set = Object.keys(jar).find((k) => earlier.cookies.includes(k) && (jarBefore[k] === undefined || namesConversation(k)));
|
|
471
480
|
if (set) return { carrier: "cookie", key: set, minted: "app" };
|
|
472
|
-
const
|
|
473
|
-
const header = Object.keys(later.headers).find((k) => !HOP.test(k) && !PLAIN_HEADER.test(k) && same(later.headers[k], headerOf(earlier.headers, k.toLowerCase())));
|
|
481
|
+
const header = Object.keys(later.headers).find((k) => !HOP.test(k) && !PLAIN_HEADER.test(k) && namesConversation(k) && same(later.headers[k], headerOf(earlier.headers, k.toLowerCase())));
|
|
474
482
|
if (header) return { carrier: "header", key: header.toLowerCase(), minted: "client" };
|
|
475
|
-
|
|
483
|
+
const cookie = Object.keys(jar).find((k) => namesConversation(k) && same(jar[k], jarBefore[k]));
|
|
484
|
+
if (cookie) return { carrier: "cookie", key: cookie, minted: "client" };
|
|
476
485
|
return { carrier: "none", key: null };
|
|
477
486
|
}
|
|
478
487
|
|
|
@@ -1353,11 +1353,55 @@ def _install():
|
|
|
1353
1353
|
except Exception:
|
|
1354
1354
|
return True
|
|
1355
1355
|
|
|
1356
|
-
# A GraphQL query reads; a mutation writes. A `query` that is not GraphQL (SQL over HTTP
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1356
|
+
# A GraphQL query reads; a mutation writes. A `query` in JSON that is not GraphQL (SQL over HTTP,
|
|
1357
|
+
# where a SELECT can write through a function) is held.
|
|
1358
|
+
# ClickHouse, Trino and Presto take every query as a POST of its text, a SELECT too, and a SELECT
|
|
1359
|
+
# there cannot write: one statement that starts as a read and holds no word that writes, sent to
|
|
1360
|
+
# what its address or its headers show is one of them, goes out (lib/trace.cjs does the same).
|
|
1361
|
+
sql_quoted = re.compile(r"""'(?:[^'\\]|\\.|'')*'|"(?:[^"\\]|\\.|"")*"|`[^`]*`|--[^\n]*|/\*[\s\S]*?\*/""")
|
|
1362
|
+
sql_shows = re.compile(r"^(?:show|describe|desc|exists)\b", re.I | re.A)
|
|
1363
|
+
sql_selects = re.compile(r"^(?:select|with|explain)\b", re.I | re.A)
|
|
1364
|
+
sql_writes = re.compile(r"(?<![.\w])(?:insert|update|delete|merge|into|create|alter|drop|truncate|refresh|call|execute)(?![.\w])", re.I | re.A)
|
|
1365
|
+
sql_hands_on = re.compile(r"\b(?:table|query|jdbc|odbc|executable)\s*\(", re.I | re.A)
|
|
1366
|
+
sql_open = re.compile(r"""[;'"`]|/\*|\$\$""")
|
|
1367
|
+
analytics_name = re.compile(r"clickhouse|trino|presto", re.I)
|
|
1368
|
+
analytics_headers = ("x-clickhouse-user", "x-clickhouse-key", "x-clickhouse-database", "x-clickhouse-format", "x-trino-user", "x-presto-user")
|
|
1369
|
+
|
|
1370
|
+
def statement_of(sql):
|
|
1371
|
+
bare = sql_quoted.sub(lambda m: "''" if m.group(0)[0] == "'" else "x" if m.group(0)[0] in '"`' else " ", str(sql or "")).strip()
|
|
1372
|
+
return re.sub(r";\s*$", "", bare)
|
|
1373
|
+
|
|
1374
|
+
# A quote or a comment left open, a second statement or a dollar-quoted body is not read as a read.
|
|
1375
|
+
def selects(bare):
|
|
1376
|
+
if sql_open.search(bare.replace("''", "")):
|
|
1377
|
+
return False
|
|
1378
|
+
return bool(sql_shows.match(bare)) or bool(sql_selects.match(bare) and not sql_writes.search(bare) and not sql_hands_on.search(bare))
|
|
1379
|
+
|
|
1380
|
+
def analytics(parts, header_of):
|
|
1381
|
+
try:
|
|
1382
|
+
port = parts.port
|
|
1383
|
+
except ValueError:
|
|
1384
|
+
port = None
|
|
1385
|
+
return port in (8123, 8443) or bool(analytics_name.search(parts.hostname or "")) or bool(analytics_name.search(str(header_of("user-agent") or ""))) \
|
|
1386
|
+
or any(header_of(name) for name in analytics_headers) or parts.path == "/v1/statement"
|
|
1387
|
+
|
|
1388
|
+
def reads_only(raw, url=None, header_of=None):
|
|
1389
|
+
sent = str(raw or "").strip()
|
|
1390
|
+
b = parsed(sent) if sent else None
|
|
1391
|
+
if b is not None:
|
|
1392
|
+
q = b.get("query") if isinstance(b, dict) else None
|
|
1393
|
+
return isinstance(q, str) and bool(re.match(r"^\s*(?:\{|query\b|fragment\b)", q)) and not re.search(r"\bmutation\b", q)
|
|
1394
|
+
try:
|
|
1395
|
+
parts = urlsplit(str(url)) if url else None
|
|
1396
|
+
if parts is None or not analytics(parts, header_of or (lambda name: None)):
|
|
1397
|
+
return False
|
|
1398
|
+
# ClickHouse joins a `query` in the address to the body: both are read as the one statement.
|
|
1399
|
+
asked = "%s %s" % (dict(parse_qsl(parts.query)).get("query", ""), sent)
|
|
1400
|
+
# ClickHouse reads a backslash before a quote as part of the string and Trino as its end,
|
|
1401
|
+
# so where a string stops is not known here: held.
|
|
1402
|
+
return not re.search(r"""\\['"`]""", asked) and selects(statement_of(asked))
|
|
1403
|
+
except Exception:
|
|
1404
|
+
return False
|
|
1361
1405
|
|
|
1362
1406
|
held_count = itertools.count(1)
|
|
1363
1407
|
|
|
@@ -1381,7 +1425,7 @@ def _install():
|
|
|
1381
1425
|
return
|
|
1382
1426
|
held_said.add((method, host))
|
|
1383
1427
|
try:
|
|
1384
|
-
sys.stderr.write("cortad: a request Cortad sent made this app send %s to %s. Cortad kept that call on this machine and answered it with a stand-in success, since it could change something real. A failure right after this line
|
|
1428
|
+
sys.stderr.write("cortad: a request Cortad sent made this app send %s to %s. Cortad kept that call on this machine and answered it with a stand-in success, since it could change something real. A failure right after this line may come from that stand-in rather than from your code. With that service's test key the call goes out as sent.\n" % (method, host))
|
|
1385
1429
|
except Exception:
|
|
1386
1430
|
pass
|
|
1387
1431
|
|
|
@@ -2426,7 +2470,7 @@ def _install():
|
|
|
2426
2470
|
|
|
2427
2471
|
# A held call that cannot be answered fails in the app; it is never sent instead.
|
|
2428
2472
|
def held_httpx(request, sent):
|
|
2429
|
-
if not held_here(request.url, request.method, lambda: keys_in(request.headers)) or reads_only(sent):
|
|
2473
|
+
if not held_here(request.url, request.method, lambda: keys_in(request.headers)) or reads_only(sent, request.url, request.headers.get):
|
|
2430
2474
|
return None
|
|
2431
2475
|
kind = request.headers.get("content-type", "")
|
|
2432
2476
|
held_row(request.url, request.method, sent, kind, ctx.get())
|
|
@@ -2489,7 +2533,7 @@ def _install():
|
|
|
2489
2533
|
if not held_here(request.url, request.method, lambda: keys_in(request.headers)):
|
|
2490
2534
|
return None
|
|
2491
2535
|
raw = text(request.body, reply_max)
|
|
2492
|
-
if reads_only(raw):
|
|
2536
|
+
if reads_only(raw, request.url, request.headers.get):
|
|
2493
2537
|
return None
|
|
2494
2538
|
import datetime
|
|
2495
2539
|
models, structures = sys.modules["requests.models"], sys.modules["requests.structures"]
|
|
@@ -2540,6 +2584,13 @@ def _install():
|
|
|
2540
2584
|
basic = k.get("auth") or getattr(self, "_default_auth", None)
|
|
2541
2585
|
return [given.get(n) or defaults.get(n) for n in key_headers] + [basic.encode() if basic else None]
|
|
2542
2586
|
|
|
2587
|
+
def header_of(self, k, name):
|
|
2588
|
+
for headers in (k.get("headers"), getattr(self, "_default_headers", None)):
|
|
2589
|
+
for n, v in dict(headers or {}).items():
|
|
2590
|
+
if str(n).lower() == name:
|
|
2591
|
+
return v
|
|
2592
|
+
return None
|
|
2593
|
+
|
|
2543
2594
|
# A path relative to the session's base address is decided on the whole address.
|
|
2544
2595
|
def whole(self, str_or_url):
|
|
2545
2596
|
try:
|
|
@@ -2550,7 +2601,7 @@ def _install():
|
|
|
2550
2601
|
async def requested(self, method, str_or_url, *a, **k):
|
|
2551
2602
|
body = k.get("json") if k.get("json") is not None else k.get("data")
|
|
2552
2603
|
url = whole(self, str_or_url)
|
|
2553
|
-
if held_here(url, method, lambda: keys_of(self, k)) and not reads_only(text(body, reply_max)):
|
|
2604
|
+
if held_here(url, method, lambda: keys_of(self, k)) and not reads_only(text(body, reply_max), url, lambda name: header_of(self, k, name)):
|
|
2554
2605
|
port = held_port()
|
|
2555
2606
|
if port is None:
|
|
2556
2607
|
raise module.ClientConnectionError("this write was kept from leaving and could not be answered")
|
package/lib/replay.mjs
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// reads what the hook wrote, keeps the sign-in each request carried on this machine, and tells the
|
|
3
3
|
// cloud what each door is proven to do (lib/proof.mjs): the route, the method, the body, the header
|
|
4
4
|
// names and what the requests did inside the app. Header values never leave.
|
|
5
|
-
import { copyFileSync, existsSync, mkdtempSync, readFileSync, statSync } from "node:fs";
|
|
5
|
+
import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
6
6
|
import { SourceMap } from "node:module";
|
|
7
7
|
import { tmpdir } from "node:os";
|
|
8
8
|
import { dirname, isAbsolute, join, relative } from "node:path";
|
|
@@ -15,15 +15,17 @@ const HOOK = join(HERE, "trace.cjs");
|
|
|
15
15
|
const PYHOOK = join(HERE, "pyhook");
|
|
16
16
|
export const CAPTURED = "captured";
|
|
17
17
|
// Never replayed: they describe one connection, not the caller.
|
|
18
|
+
const SECRET_HEADER = /authorization|cookie|token|secret|session|csrf|api-?key/i;
|
|
18
19
|
const HOP = /^(?:host|content-length|connection|keep-alive|transfer-encoding|upgrade|expect|te|trailer|accept-encoding|x-cortad-as|x-cortad-turn)$/i;
|
|
19
20
|
|
|
20
21
|
// `files` lists the repository's source, relative to `root`, for placing a problem at its line;
|
|
21
22
|
// `appFolder` is the folder the app runs in, relative to `root`. `onProof` gets one payload per door,
|
|
22
23
|
// again whenever what it proves changes. `printed(from, to)` is what the app printed between two times.
|
|
23
|
-
export function makeCapture({ work, keepSecret, onProof = () => {}, writes = null, root = null, files = () => [], appFolder = () => "", printed = () => "" }) {
|
|
24
|
+
export function makeCapture({ work, keepSecret, onProof = () => {}, writes = null, root = null, files = () => [], appFolder = () => "", printed = () => "", signIns = null }) {
|
|
24
25
|
const file = join(work, "trace.jsonl");
|
|
25
26
|
let read = 0;
|
|
26
|
-
const accounts = makeAccounts();
|
|
27
|
+
const accounts = makeAccounts(signIns);
|
|
28
|
+
for (const headers of accounts.kept()) for (const [k, v] of Object.entries(headers)) if (SECRET_HEADER.test(k)) keepSecret(String(v).replace(/^Bearer\s+/i, ""));
|
|
27
29
|
let alive = false;
|
|
28
30
|
// The ports a hooked process listens on. Loading is not listening: turbo's own Node launcher loads
|
|
29
31
|
// the hook, and one app was told we would see its messages while its API on Bun carried none.
|
|
@@ -118,7 +120,7 @@ export function makeCapture({ work, keepSecret, onProof = () => {}, writes = nul
|
|
|
118
120
|
if (typeof row.ex !== "string" || typeof row.method !== "string" || typeof row.path !== "string" || !row.path.startsWith("/")) continue;
|
|
119
121
|
for (const [door, r] of taken) if (r.ex === row.ex) taken.delete(door);
|
|
120
122
|
const headers = Object.fromEntries(Object.entries(row.headers ?? {}).filter(([k, v]) => !HOP.test(k) && typeof v === "string"));
|
|
121
|
-
for (const [k, v] of Object.entries(headers)) if (
|
|
123
|
+
for (const [k, v] of Object.entries(headers)) if (SECRET_HEADER.test(k)) keepSecret(String(v).replace(/^Bearer\s+/i, ""));
|
|
122
124
|
// Speaking as the person means their own client's headers, never a trial's. Cortad's own test
|
|
123
125
|
// request (reach:*) carries the page and sign-in this machine found for the app, so a run speaks
|
|
124
126
|
// with them where no request of the person's came: without it every run request lost the Origin
|
|
@@ -451,15 +453,40 @@ function leavesOf(body) {
|
|
|
451
453
|
try { walk(typeof body === "string" ? JSON.parse(body) : body, "", 0); } catch { /* not JSON: nothing of theirs to read */ }
|
|
452
454
|
return out;
|
|
453
455
|
}
|
|
454
|
-
|
|
456
|
+
// `file`: where the accounts are kept between connects, on this machine and readable by this user
|
|
457
|
+
// alone. Databuddy's agent signed in once per connect, and every reconnect asked for it again.
|
|
458
|
+
// A sign-in that has ended is not read back.
|
|
459
|
+
export function makeAccounts(file = null) {
|
|
455
460
|
const kept = [];
|
|
456
461
|
let bare = null;
|
|
462
|
+
if (file) {
|
|
463
|
+
try {
|
|
464
|
+
const stored = JSON.parse(readFileSync(file, "utf8"));
|
|
465
|
+
// A visitor who is not signed in still sends what the app lets them in by (its Origin): ulaim
|
|
466
|
+
// refused every test request of a reconnect with 403, having been sent none of it.
|
|
467
|
+
const visitor = Array.isArray(stored) ? null : stored?.visitor;
|
|
468
|
+
if (visitor && typeof visitor === "object" && !signInKey(visitor)) bare = Object.fromEntries(Object.entries(visitor).filter(([k, v]) => typeof k === "string" && typeof v === "string"));
|
|
469
|
+
for (const a of Array.isArray(stored) ? stored : stored?.accounts ?? []) {
|
|
470
|
+
if (kept.length >= ACCOUNTS_MAX || typeof a?.key !== "string" || !a.headers || typeof a.headers !== "object") continue;
|
|
471
|
+
const headers = Object.fromEntries(Object.entries(a.headers).filter(([k, v]) => typeof k === "string" && typeof v === "string"));
|
|
472
|
+
if (signInKey(headers) !== a.key || (identityOf(headers).expiresInS ?? 1) <= 0) continue;
|
|
473
|
+
kept.push({ key: a.key, headers, bodies: new Map(Object.entries(a.bodies ?? {}).map(([door, leaves]) => [door, new Map(Object.entries(leaves ?? {}))])) });
|
|
474
|
+
}
|
|
475
|
+
} catch { /* none kept, or not ours to read: the next signed-in request is taken */ }
|
|
476
|
+
}
|
|
477
|
+
const save = () => {
|
|
478
|
+
if (!file) return;
|
|
479
|
+
try {
|
|
480
|
+
mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
|
|
481
|
+
writeFileSync(file, JSON.stringify({ visitor: bare, accounts: kept.map((a) => ({ key: a.key, headers: a.headers, bodies: Object.fromEntries([...a.bodies].map(([door, leaves]) => [door, Object.fromEntries(leaves)])) })) }), { mode: 0o600 });
|
|
482
|
+
} catch { /* kept for this connect only */ }
|
|
483
|
+
};
|
|
457
484
|
const at = (role) => { const m = ACCOUNT_ROLE.exec(role); return m ? kept[Number(m[1] ?? 1) - 1] ?? kept[0] ?? null : null; };
|
|
458
485
|
return {
|
|
459
486
|
// `door` and `body`: the request as that customer sent it, for the values in it that are theirs.
|
|
460
487
|
keep(headers, door, body) {
|
|
461
488
|
const key = signInKey(headers);
|
|
462
|
-
if (!key) { bare = headers; return; }
|
|
489
|
+
if (!key) { bare = headers; save(); return; }
|
|
463
490
|
let account = kept.find((a) => a.key === key);
|
|
464
491
|
if (account) account.headers = headers;
|
|
465
492
|
else {
|
|
@@ -470,6 +497,7 @@ export function makeAccounts() {
|
|
|
470
497
|
else return;
|
|
471
498
|
}
|
|
472
499
|
if (door) account.bodies.set(door, leavesOf(body));
|
|
500
|
+
save();
|
|
473
501
|
},
|
|
474
502
|
kept: () => kept.map((a) => a.headers),
|
|
475
503
|
speak(role) {
|
package/lib/start.mjs
CHANGED
|
@@ -239,3 +239,11 @@ export function installPlan(dir, onPath, root = dir) {
|
|
|
239
239
|
if (existsSync(join(dir, "pyproject.toml"))) return into("-e .");
|
|
240
240
|
return null;
|
|
241
241
|
}
|
|
242
|
+
|
|
243
|
+
// The start command as it leaves this machine: a value typed into it (DATABASE_URL=...) stays here
|
|
244
|
+
// unless it is a plain word, and it is cut to the length the server keeps. A command past that
|
|
245
|
+
// length was refused whole, as "port is required", with a valid port beside it.
|
|
246
|
+
export const SHOWN_COMMAND = 200;
|
|
247
|
+
export const shownCommand = (cmd, mask = (text) => text) => (cmd == null ? cmd
|
|
248
|
+
: mask(String(cmd)).replace(/\b([A-Za-z_][A-Za-z0-9_]*)=("[^"]*"|'[^']*'|\S+)/g, (whole, name, value) => (/^[\w.-]{1,24}$/.test(value) ? whole : `${name}=***`)).slice(0, SHOWN_COMMAND));
|
|
249
|
+
|
package/lib/trace.cjs
CHANGED
|
@@ -807,8 +807,43 @@ if (FILE) {
|
|
|
807
807
|
return !passNow().some((p) => host === p || host.endsWith("." + p));
|
|
808
808
|
} catch { return true; }
|
|
809
809
|
};
|
|
810
|
-
// A GraphQL query reads; a mutation writes. A `query` that is not GraphQL (Neon's SQL
|
|
811
|
-
|
|
810
|
+
// A GraphQL query reads; a mutation writes. A `query` in JSON that is not GraphQL (Neon's SQL, where
|
|
811
|
+
// a SELECT can write through a function) is held.
|
|
812
|
+
// ClickHouse, Trino and Presto take every query as a POST of its text, a SELECT too, and a SELECT
|
|
813
|
+
// there cannot write: one statement that starts as a read and holds no word that writes, sent to
|
|
814
|
+
// what its address or its headers show is one of them, goes out. Anything this cannot read as that
|
|
815
|
+
// stays held. Databuddy's agent had 45 replies come back empty while its own ClickHouse queries
|
|
816
|
+
// were answered here with a stand-in.
|
|
817
|
+
const SQL_QUOTED = /'(?:[^'\\]|\\.|'')*'|"(?:[^"\\]|\\.|"")*"|`[^`]*`|--[^\n]*|\/\*[\s\S]*?\*\//g;
|
|
818
|
+
// SHOW, DESCRIBE and EXISTS read whatever follows them. A SELECT, a WITH or an EXPLAIN reads
|
|
819
|
+
// unless it holds a word that writes inside it (a CTE that deletes, SELECT INTO, EXPLAIN ANALYZE
|
|
820
|
+
// of an insert) or hands its text on to another database to run.
|
|
821
|
+
const SQL_SHOWS = /^(?:show|describe|desc|exists)\b/i, SQL_SELECTS = /^(?:select|with|explain)\b/i;
|
|
822
|
+
const SQL_WRITES = /(?<![.\w])(?:insert|update|delete|merge|into|create|alter|drop|truncate|refresh|call|execute)(?![.\w])/i;
|
|
823
|
+
const SQL_HANDS_ON = /\b(?:table|query|jdbc|odbc|executable)\s*\(/i;
|
|
824
|
+
const statementOf = (sql) => String(sql || "").replace(SQL_QUOTED, (m) => (m[0] === "'" ? "''" : /^["`]/.test(m) ? "x" : " ")).trim().replace(/;\s*$/, "");
|
|
825
|
+
// A quote or a comment left open, a second statement or a dollar-quoted body is not read as a read.
|
|
826
|
+
const whole = (bare) => !/[;'"`]|\/\*|\$\$/.test(bare.replace(/''/g, ""));
|
|
827
|
+
const selects = (bare) => whole(bare) && (SQL_SHOWS.test(bare) || (SQL_SELECTS.test(bare) && !SQL_WRITES.test(bare) && !SQL_HANDS_ON.test(bare)));
|
|
828
|
+
const ANALYTICS_NAME = /clickhouse|trino|presto/i;
|
|
829
|
+
const ANALYTICS_HEADERS = ["x-clickhouse-user", "x-clickhouse-key", "x-clickhouse-database", "x-clickhouse-format", "x-trino-user", "x-presto-user"];
|
|
830
|
+
const analytics = (u, headerOf, port) => /^(?:8123|8443)$/.test(String(port || u.port)) || ANALYTICS_NAME.test(u.hostname) || ANALYTICS_NAME.test(headerOf("user-agent") || "")
|
|
831
|
+
|| ANALYTICS_HEADERS.some((name) => headerOf(name)) || u.pathname === "/v1/statement";
|
|
832
|
+
const readsOnly = (text, u, headerOf = () => "", port) => {
|
|
833
|
+
const sent = String(text || "").trim();
|
|
834
|
+
try { const q = JSON.parse(sent)?.query; return typeof q === "string" && /^\s*(?:\{|query\b|fragment\b)/.test(q) && !/\bmutation\b/.test(q); } catch { /* not JSON: a statement sent as text */ }
|
|
835
|
+
if (!u || !analytics(u, headerOf, port)) return false;
|
|
836
|
+
// ClickHouse joins a `query` in the address to the body: both are read as the one statement.
|
|
837
|
+
const asked = `${u.searchParams.get("query") || ""} ${sent}`;
|
|
838
|
+
// ClickHouse reads a backslash before a quote as part of the string and Trino as its end, so
|
|
839
|
+
// where a string stops is not known here: held.
|
|
840
|
+
// ponytail: a ClickHouse read with an escaped quote in a string is held too; told apart by which
|
|
841
|
+
// database answered if that is ever seen.
|
|
842
|
+
return !/\\['"`]/.test(asked) && selects(statementOf(asked));
|
|
843
|
+
};
|
|
844
|
+
// A call passed on from this hook's own server, after its body was read: never held again.
|
|
845
|
+
const PASSED = Symbol("cortad.passed");
|
|
846
|
+
const passedOn = (args) => { const o = typeof args[0] === "string" || args[0] instanceof URL ? args[1] : args[0]; return Boolean(o && o[PASSED]); };
|
|
812
847
|
let heldCount = 0;
|
|
813
848
|
const fieldsOf = (text, type) => {
|
|
814
849
|
try { const b = /form/i.test(type || "") ? Object.fromEntries(new URLSearchParams(text)) : JSON.parse(text); return b && typeof b === "object" && !Array.isArray(b) ? b : {}; } catch { return {}; }
|
|
@@ -821,7 +856,7 @@ if (FILE) {
|
|
|
821
856
|
const heldSay = (method, host) => {
|
|
822
857
|
if (heldSaid.has(`${method} ${host}`)) return;
|
|
823
858
|
heldSaid.add(`${method} ${host}`);
|
|
824
|
-
try { process.stderr.write(`cortad: a request Cortad sent made this app send ${method} to ${host}. Cortad kept that call on this machine and answered it with a stand-in success, since it could change something real. A failure right after this line
|
|
859
|
+
try { process.stderr.write(`cortad: a request Cortad sent made this app send ${method} to ${host}. Cortad kept that call on this machine and answered it with a stand-in success, since it could change something real. A failure right after this line may come from that stand-in rather than from your code. With that service's test key the call goes out as sent.\n`); } catch { /* no stderr */ }
|
|
825
860
|
};
|
|
826
861
|
// Named by method and host only: a webhook's path is its credential.
|
|
827
862
|
const heldRow = (u, method, text, type, ctx) => { heldSay(method, u.hostname); row({ dep: { at: Date.now(), host: u.hostname, status: 200, held: true, ...(ctx ? { ex: ctx.id, turn: ctx.turn } : {}), called: [{ name: `${method} ${u.hostname}`.slice(0, 80), arguments: argsText(fieldsOf(text, type)) }] } }); };
|
|
@@ -841,7 +876,22 @@ if (FILE) {
|
|
|
841
876
|
const id = String(req.headers["x-cortad-held"] || "");
|
|
842
877
|
const w = heldWaiting.get(id);
|
|
843
878
|
heldWaiting.delete(id);
|
|
844
|
-
const
|
|
879
|
+
const sent = Buffer.concat(parts);
|
|
880
|
+
const text = sent.toString("utf8").slice(0, MAX);
|
|
881
|
+
// A body that only reads, known now that it is here, goes on to where it was sent, as it was
|
|
882
|
+
// sent, and its answer comes back through this server.
|
|
883
|
+
if (w && parts.length < 256 && readsOnly(text, w.u, (name) => String(req.headers[name] || ""), w.port)) {
|
|
884
|
+
const { "x-cortad-held": _held, host: _host, connection: _connection, ...headers } = req.headers;
|
|
885
|
+
const given = typeof w.args[0] === "string" || w.args[0] instanceof URL;
|
|
886
|
+
const options = { ...((given ? w.args[1] : w.args[0]) || {}), headers, signal: undefined, [PASSED]: true };
|
|
887
|
+
// Sent inside the request that asked, so the call is recorded behind that reply.
|
|
888
|
+
const send = () => (w.secure ? https : http).request(...(given ? [w.args[0], options] : [options]));
|
|
889
|
+
const up = w.ctx ? als.run(w.ctx, send) : send();
|
|
890
|
+
up.on("response", (got) => { res.writeHead(got.statusCode || 502, got.headers); got.pipe(res); });
|
|
891
|
+
up.on("error", () => { if (!res.headersSent) res.writeHead(502); res.end(); });
|
|
892
|
+
res.on("close", () => up.destroy());
|
|
893
|
+
return up.end(sent);
|
|
894
|
+
}
|
|
845
895
|
if (w) heldRow(w.u, w.method, text, req.headers["content-type"], w.ctx);
|
|
846
896
|
res.writeHead(200, { "content-type": "application/json" });
|
|
847
897
|
res.end(heldReply(text, req.headers["content-type"]));
|
|
@@ -867,12 +917,12 @@ if (FILE) {
|
|
|
867
917
|
const method = String(o.method || "GET").toUpperCase();
|
|
868
918
|
const u = new URL(`${secure ? "https" : "http"}://${host.includes(":") && !host.startsWith("[") ? `[${host}]` : host}${path.startsWith("/") ? path : "/" + path}`);
|
|
869
919
|
const headerOf = (name) => headerIn(o.headers, name) || (name === "authorization" && o.auth ? `Basic ${Buffer.from(String(o.auth)).toString("base64")}` : "");
|
|
870
|
-
return heldHere(u, method, headerOf) ? { u, method, o, cb: args.find((a) => typeof a === "function") } : null;
|
|
920
|
+
return heldHere(u, method, headerOf) ? { u, method, o, args: args.filter((a) => typeof a !== "function"), port: o.port || (given && given.port) || "", cb: args.find((a) => typeof a === "function") } : null;
|
|
871
921
|
};
|
|
872
922
|
const httpRequest = http.request;
|
|
873
|
-
const sendHeld = ({ u, method, o, cb }, secure) => {
|
|
923
|
+
const sendHeld = ({ u, method, o, cb, args, port }, secure) => {
|
|
874
924
|
const id = String(++heldSent);
|
|
875
|
-
heldWaiting.set(id, { u, method, ctx: als.getStore() });
|
|
925
|
+
heldWaiting.set(id, { u, method, ctx: als.getStore(), args, port, secure });
|
|
876
926
|
const headers = { ...(o.headers && typeof o.headers === "object" && !Array.isArray(o.headers) ? o.headers : {}), "x-cortad-held": id };
|
|
877
927
|
// Port 1 answers nothing: a write held before the server listens fails instead of leaving.
|
|
878
928
|
const req = httpRequest({ host: "127.0.0.1", port: heldPort || 1, method, path: u.pathname + u.search, headers, ...(o.timeout ? { timeout: o.timeout } : {}), ...(o.signal ? { signal: o.signal } : {}) }, cb);
|
|
@@ -883,7 +933,7 @@ if (FILE) {
|
|
|
883
933
|
const heldFetch = async (realFetch, input, init) => {
|
|
884
934
|
const req = new Request(input, init);
|
|
885
935
|
const text = await req.clone().text().catch(() => "");
|
|
886
|
-
if (readsOnly(text)) return realFetch(req);
|
|
936
|
+
if (readsOnly(text, new URL(req.url), (name) => req.headers.get(name) || "")) return realFetch(req);
|
|
887
937
|
const u = new URL(req.url);
|
|
888
938
|
heldRow(u, req.method, text, req.headers.get("content-type"), als.getStore());
|
|
889
939
|
return new Response(heldReply(text, req.headers.get("content-type")), { status: 200, headers: { "content-type": "application/json" } });
|
|
@@ -945,7 +995,7 @@ if (FILE) {
|
|
|
945
995
|
const request = mod.request;
|
|
946
996
|
mod.request = function (...args) {
|
|
947
997
|
let held = null;
|
|
948
|
-
try { held = heldCall(args, mod === https); } catch { /* not a call we can read: sent as asked */ }
|
|
998
|
+
try { held = passedOn(args) ? null : heldCall(args, mod === https); } catch { /* not a call we can read: sent as asked */ }
|
|
949
999
|
if (held) return sendHeld(held, mod === https);
|
|
950
1000
|
const req = request.apply(this, args);
|
|
951
1001
|
try {
|
package/local.mjs
CHANGED
|
@@ -38,7 +38,7 @@ import { CAPTURED, makeCapture } from "./lib/replay.mjs";
|
|
|
38
38
|
import { sampleHere } from "./lib/sample.mjs";
|
|
39
39
|
import { mintAcross, originFor, servicePort, waitForPort } from "./lib/service.mjs";
|
|
40
40
|
import { listingUrl } from "./lib/listing.mjs";
|
|
41
|
-
import { hookable, installPlan, missingDependency, startPlan, workspaces } from "./lib/start.mjs";
|
|
41
|
+
import { hookable, installPlan, missingDependency, shownCommand, startPlan, workspaces } from "./lib/start.mjs";
|
|
42
42
|
import { openProxy } from "./lib/proxy.mjs";
|
|
43
43
|
import { liftedLimits, sessionLifts } from "./lib/switches.mjs";
|
|
44
44
|
import { makeKeeping } from "./lib/keeping.mjs";
|
|
@@ -410,7 +410,12 @@ async function verb(job) {
|
|
|
410
410
|
if (role === CAPTURED || role.startsWith(`${CAPTURED}:`)) {
|
|
411
411
|
spokenAs = role;
|
|
412
412
|
// Speaking as one of the customers whose requests we watched: every header their own client sent.
|
|
413
|
-
|
|
413
|
+
const theirs = capture?.headers(role);
|
|
414
|
+
for (const [name, value] of Object.entries(theirs ?? {})) { for (const k of Object.keys(headers)) if (k.toLowerCase() === name) delete headers[k]; headers[name] = value; }
|
|
415
|
+
// No request of theirs is held (a new connect): sent from the app's own address, as Cortad's
|
|
416
|
+
// own test request is, so an app that lets visitors in by Origin lets this one in.
|
|
417
|
+
const from = !theirs && app ? ownOrigin(app.port) : null;
|
|
418
|
+
if (from && !Object.keys(headers).some((k) => k.toLowerCase() === "origin")) { headers.origin = from; headers.referer = `${from}/`; }
|
|
414
419
|
} else {
|
|
415
420
|
const held = await identities?.headerFor(role);
|
|
416
421
|
if (held) { for (const k of Object.keys(headers)) if (k.toLowerCase() === held.name) delete headers[k]; headers[held.name] = held.value; }
|
|
@@ -663,6 +668,10 @@ async function ask(question) {
|
|
|
663
668
|
return line.trim() || null;
|
|
664
669
|
}
|
|
665
670
|
let child = null;
|
|
671
|
+
// An app this command did not start carries nothing that counts its model calls, and no run plays
|
|
672
|
+
// against it (the API holds Run): databuddy's free run played 39 conversations into a route that
|
|
673
|
+
// answers from code.
|
|
674
|
+
const UNSEEN = "your app was already running, so Cortad cannot see the calls it makes to its model, and no run can start.";
|
|
666
675
|
async function startApp() {
|
|
667
676
|
const wanted = Number(flag("--port"));
|
|
668
677
|
// "--port 8106 --start ..." is "start it, it will answer on 8106", not "it is already on 8106":
|
|
@@ -671,11 +680,10 @@ async function startApp() {
|
|
|
671
680
|
if (!(await answers(wanted))) return { port: null, said: "", why: `nothing is answering on port ${wanted}, so there is nothing to ask.`, noStart: true };
|
|
672
681
|
const took = await takeOver(wanted);
|
|
673
682
|
if (took) return took;
|
|
674
|
-
say("your app was already running, so its request limits stay as they are; if it answers 429, stop it and run this without --port and they are raised for the session");
|
|
675
683
|
if (proxyAsked) {
|
|
676
684
|
await useProxy();
|
|
677
|
-
say(`to
|
|
678
|
-
}
|
|
685
|
+
say(`your app was already running, so Cortad cannot see the calls it makes to its model until it is started with: ${Object.entries(proxy.env({ ...envExports(envFiles), ...process.env })).map(([k, v]) => `${k}=${v}`).join(" ")}`);
|
|
686
|
+
} else say(`${UNSEEN} Stop it and run this again without --port: this command starts your app itself.`);
|
|
679
687
|
return { port: wanted, cmd: null };
|
|
680
688
|
}
|
|
681
689
|
const plan = startPlan({ root, typed: flag("--start"), onPath });
|
|
@@ -718,7 +726,7 @@ async function startApp() {
|
|
|
718
726
|
await keeping.forget();
|
|
719
727
|
const took = await takeOver(held.port);
|
|
720
728
|
if (took) return took;
|
|
721
|
-
say(
|
|
729
|
+
say(`${UNSEEN} Stop the one on port ${held.port} and run this again: this command starts your app itself.`);
|
|
722
730
|
return { port: held.port, cmd: null };
|
|
723
731
|
}
|
|
724
732
|
}
|
|
@@ -1170,7 +1178,7 @@ identities = makeIdentities({ root, work, envFiles, sourceFiles: () => files, sa
|
|
|
1170
1178
|
// A real request that reached their model proves its door (lib/proof.mjs). What it proved goes up,
|
|
1171
1179
|
// masked like everything else, and nothing goes up that the mask could not read; the sign-in each
|
|
1172
1180
|
// request carried stays here.
|
|
1173
|
-
capture = makeCapture({ work, keepSecret, writes: join(homeOf(projectOf(root)), "writes"), root, files: () => files, appFolder: () => relative(root, appDir), printed: printedBetween, onProof: (proof) => {
|
|
1181
|
+
capture = makeCapture({ work, keepSecret, signIns: join(homeOf(projectOf(root)), "sign-ins.json"), writes: join(homeOf(projectOf(root)), "writes"), root, files: () => files, appFolder: () => relative(root, appDir), printed: printedBetween, onProof: (proof) => {
|
|
1174
1182
|
let masked;
|
|
1175
1183
|
try { masked = JSON.parse(mask(JSON.stringify(proof))); } catch { return; }
|
|
1176
1184
|
call("POST", `/local/${box}/proof`, masked).catch(() => {});
|
|
@@ -1288,7 +1296,7 @@ const announce = async () => {
|
|
|
1288
1296
|
// Through the proxy, a message is seen arriving at its front, and its model calls once the app was started with its settings on the proxy.
|
|
1289
1297
|
const hooked = Boolean(proxy ? launched || proxy.seen() : launched && capture?.watching(app.port));
|
|
1290
1298
|
const started = Boolean(launched);
|
|
1291
|
-
const told = await call("POST", `/local/${box}/app`, { port: doorPort(), cmd: app.cmd, origins: envOrigins(envFiles), lifted: app.lifted ?? [], data: (await keeping.lines(started)).map(mask), blocked: await keeping.blocked(started), watching: hooked, metered: hooked, proves: hooked });
|
|
1299
|
+
const told = await call("POST", `/local/${box}/app`, { port: doorPort(), cmd: shownCommand(app.cmd, mask), origins: envOrigins(envFiles), lifted: app.lifted ?? [], data: (await keeping.lines(started)).map(mask), blocked: await keeping.blocked(started), watching: hooked, metered: hooked, proves: hooked });
|
|
1292
1300
|
if (told.ok) await allowOutbound(told.data?.consent);
|
|
1293
1301
|
return told;
|
|
1294
1302
|
};
|
|
@@ -1337,7 +1345,7 @@ async function appLife(firstStart) {
|
|
|
1337
1345
|
clearStep();
|
|
1338
1346
|
if (!told.ok) return quit(told.data?.error ?? `could not register your app (${told.status})`);
|
|
1339
1347
|
machineOf = () => (app ? {
|
|
1340
|
-
cmd: app.cmd ?? "", dir: relative(root, appDir) || ".", port: app.port, started: Boolean(launched),
|
|
1348
|
+
cmd: shownCommand(app.cmd, mask) ?? "", dir: relative(root, appDir) || ".", port: app.port, started: Boolean(launched),
|
|
1341
1349
|
hooked: Boolean(launched && !proxy && capture?.watching(app.port)), proxy: proxy?.port ?? null,
|
|
1342
1350
|
changed: [...new Set([...(app.lifted ?? []), ...Object.keys(keeping.envNow())])],
|
|
1343
1351
|
copies: Object.keys(keeping.envNow()).length ? "made" : "none",
|
package/package.json
CHANGED