@reneturcios/hookcheck 0.1.10 → 0.1.11

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/core.js CHANGED
@@ -6,7 +6,7 @@
6
6
  */
7
7
  import { existsSync, lstatSync, readdirSync, readFileSync, statSync } from "node:fs";
8
8
  import { dirname, join, relative, resolve, sep } from "node:path";
9
- export const VERSION = "0.1.10";
9
+ export const VERSION = "0.1.11";
10
10
  export const RISKY_RULES = [
11
11
  { id: "DECODE-PIPE-SH", pattern: /\bbase64\s+(-d|--decode|-D)\b[^|]*\|\s*(sudo\s+)?(sh|bash|zsh|node|python3?|perl|ruby)\b/, evidence: "decodes a base64 payload and pipes it straight into an interpreter", severe: true, remote: true },
12
12
  { id: "CURL-PIPE-SH", pattern: /\b(curl|wget)\b[^|]*\|\s*(sudo\s+)?(sh|bash|zsh|node|python3?|perl|ruby)\b/, evidence: "pipes a network download straight into an interpreter", severe: true, remote: true },
@@ -95,13 +95,60 @@ function inRanges(idx, ranges) {
95
95
  function refOutsideData(seg, name, segStart, dataRanges) {
96
96
  return refOutsideRanges(seg, name, segStart, dataRanges);
97
97
  }
98
- function refOutsideRanges(seg, name, segStart, ranges) {
98
+ /**
99
+ * Positions (global indices) of genuine references to `name` inside
100
+ * `seg`: occurrences that do not begin inside an excluded range, are
101
+ * not a property name (obj.name reads a property of obj, never the
102
+ * variable), and are not an object or dict KEY or a parameter
103
+ * declaration (a name followed by ":" whose previous non-space
104
+ * character is "{", ",", "(", or a line start is a binding or a key,
105
+ * not a read). The key rule is the n8n set-latest shape (P-110): a
106
+ * response variable named `body` tainted by a local helper call made
107
+ * every fetch options object with a `body:` key read as a flow of
108
+ * that variable into the send. A ternary value (cond ? name : other)
109
+ * keeps counting: its previous character is "?", not a key context.
110
+ */
111
+ function refPositions(seg, name, segStart, ranges) {
112
+ const out = [];
99
113
  const re = new RegExp(`\\b${name.replace(/\$/g, "\\$")}\\b`, "g");
100
114
  for (const m of seg.matchAll(re)) {
101
- if (!inRanges(segStart + (m.index ?? 0), ranges))
102
- return true;
115
+ const idx = m.index ?? 0;
116
+ if (inRanges(segStart + idx, ranges))
117
+ continue;
118
+ if (idx > 0 && seg[idx - 1] === ".")
119
+ continue;
120
+ let j = idx + name.length;
121
+ while (j < seg.length && (seg[j] === " " || seg[j] === "\t"))
122
+ j++;
123
+ if (seg[j] === ":") {
124
+ let k = idx - 1;
125
+ while (k >= 0 && (seg[k] === " " || seg[k] === "\t"))
126
+ k--;
127
+ if (k < 0 || seg[k] === "{" || seg[k] === "," || seg[k] === "(" || seg[k] === "\n")
128
+ continue;
129
+ }
130
+ // An assignment target is a binding, not a read: a name at a
131
+ // statement position followed by a single "=" (not "==" or
132
+ // "=>") is being bound by this statement, so a send statement
133
+ // that ASSIGNS its result from a command substitution carrying
134
+ // an authenticated curl (id_token_status="$(curl ...)") does not
135
+ // flow the variable anywhere by mentioning its own name.
136
+ if (seg[j] === "=" && seg[j + 1] !== "=" && seg[j + 1] !== ">") {
137
+ let k = idx - 1;
138
+ while (k >= 0 && (seg[k] === " " || seg[k] === "\t"))
139
+ k--;
140
+ if (k < 0 || seg[k] === ";" || seg[k] === "\n" || seg[k] === "," || seg[k] === "{" || seg[k] === "(")
141
+ continue;
142
+ const prevWord = seg.slice(Math.max(0, k - 6), k + 1).match(/(?:^|\s)(?:let|const|var|export)$/);
143
+ if (prevWord)
144
+ continue;
145
+ }
146
+ out.push(segStart + idx);
103
147
  }
104
- return false;
148
+ return out;
149
+ }
150
+ function refOutsideRanges(seg, name, segStart, ranges) {
151
+ return refPositions(seg, name, segStart, ranges).length > 0;
105
152
  }
106
153
  function blankRanges(text, ranges) {
107
154
  if (ranges.length === 0)
@@ -1118,7 +1165,7 @@ function consumeValueExpr(text, start) {
1118
1165
  }
1119
1166
  return i;
1120
1167
  }
1121
- const AUTH_HEADER_KEY = /^(authorization|proxy-authorization|api[-_]?key|apikey|x-api-key|x-auth[-_]?token|auth[-_]?token|x-access[-_]?token|access[-_]?token|x-session[-_]?token|session[-_]?token)$/i;
1168
+ const AUTH_HEADER_KEY = /^(authorization|proxy-authorization|auth|api[-_]?key|apikey|x-api-key|x-auth[-_]?token|auth[-_]?token|x-access[-_]?token|access[-_]?token|x-session[-_]?token|session[-_]?token)$/i;
1122
1169
  function isAuthHeaderKey(key) {
1123
1170
  const k = key.toLowerCase();
1124
1171
  return AUTH_HEADER_KEY.test(k) || k.endsWith("-api-key") || k.endsWith("-apikey") || k.endsWith("-auth-token") || k.endsWith("-access-token");
@@ -1150,9 +1197,19 @@ function isAuthHeaderKey(key) {
1150
1197
  * or request options object;
1151
1198
  * - header setter calls (set, append, setHeader) and Python
1152
1199
  * requests-style auth= tuples.
1153
- * A credential that reaches a payload position (body, query, URL path,
1154
- * printed output next to a send) still fires: only the value
1155
- * expressions of these header positions are scrubbed.
1200
+ * A credential that reaches a payload position (body, a data named
1201
+ * query parameter, a partial URL path segment, printed output next
1202
+ * to a send) still fires: only the value expressions of these
1203
+ * positions are scrubbed.
1204
+ *
1205
+ * Mega-corpus (P-106, P-107): the scrub now also covers curl -u and
1206
+ * --user basic auth pairs, the argument following a git
1207
+ * http.extraheader key, and ::add-mask:: registrations; and
1208
+ * destination positions inside the URL of a send (entire path
1209
+ * segments, the whole URL, the URL origin, authentication named
1210
+ * query parameters, the curl URL operand, and the destination first
1211
+ * argument of a fetch style call) are computed separately in
1212
+ * destAuthRanges, always subject to the body test.
1156
1213
  */
1157
1214
  function authRanges(text) {
1158
1215
  const ranges = [];
@@ -1191,6 +1248,68 @@ function authRanges(text) {
1191
1248
  ranges.push(valueAfter(i));
1192
1249
  }
1193
1250
  }
1251
+ // curl -u and --user basic authentication pairs (P-107): the flag
1252
+ // value (user:password, usually a variable pair) is the credential
1253
+ // presented to the destination as authentication, in exactly the
1254
+ // position the protocol reserves for it. nodejs/node uploads its
1255
+ // own WPT report with -u "$USER:$PASSWORD" and n8n posts to its own
1256
+ // internal webhook with -u "name:$WEBHOOK_PASSWORD"; both fired
1257
+ // ENV-EXFIL on 0.1.10 because the scrubber knew --token style flags
1258
+ // but not the basic auth pair.
1259
+ const flagValue = (from) => {
1260
+ let i = from;
1261
+ while (i < text.length && /\s/.test(text[i]))
1262
+ i++;
1263
+ if (i >= text.length)
1264
+ return;
1265
+ if (text[i] === "'" || text[i] === '"' || text[i] === "`") {
1266
+ const end = consumeLiteral(text, i);
1267
+ ranges.push({ start: i + 1, end: Math.max(i + 1, end - 1) });
1268
+ }
1269
+ else {
1270
+ ranges.push(valueAfter(i));
1271
+ }
1272
+ };
1273
+ for (const m of text.matchAll(/(?:^|[\s;|&])(?:-u|--user|--http-user|--http-password)(?:\s+|=)/g)) {
1274
+ flagValue(m.index + m[0].length);
1275
+ }
1276
+ // git http.extraheader indirection (P-107): the value configured
1277
+ // for an extraheader key IS an Authorization header value, however
1278
+ // it was built (microsoft/TypeScript base64-encodes its app token
1279
+ // into a variable, registers it with add-mask, and configures it as
1280
+ // http.https://github.com/.extraheader "AUTHORIZATION: basic ...").
1281
+ // The argument following the extraheader key is scrubbed.
1282
+ for (const m of text.matchAll(/extraheader\b/gi)) {
1283
+ let i = m.index + m[0].length;
1284
+ while (i < text.length && /[\s"']/.test(text[i]))
1285
+ i++;
1286
+ if (text[i] === "=" || text[i] === ":") {
1287
+ i++;
1288
+ while (i < text.length && /[\s"']/.test(text[i]))
1289
+ i++;
1290
+ }
1291
+ if (i >= text.length)
1292
+ continue;
1293
+ if (text[i] === "'" || text[i] === '"' || text[i] === "`") {
1294
+ const end = consumeLiteral(text, i);
1295
+ ranges.push({ start: i + 1, end: Math.max(i + 1, end - 1) });
1296
+ }
1297
+ else {
1298
+ ranges.push(valueAfter(i));
1299
+ }
1300
+ }
1301
+ // add-mask registrations: an echo whose argument is the workflow
1302
+ // command ::add-mask::<value> registers the value for masking in
1303
+ // the log; printing it there is the masking idiom itself, not a
1304
+ // disclosure of the value as data.
1305
+ for (const m of text.matchAll(/::add-mask::/g)) {
1306
+ const start = m.index + m[0].length;
1307
+ let end = start;
1308
+ while (end < text.length && text[end] !== "\n" && text[end] !== '"' && text[end] !== "'")
1309
+ end++;
1310
+ if (end > start)
1311
+ ranges.push({ start, end });
1312
+ }
1194
1313
  // Header setter calls and requests-style auth= tuples.
1195
1314
  for (const m of text.matchAll(/\b(?:set|append|setHeader|addHeader|header)\s*\(\s*["'][A-Za-z0-9-]+["']\s*,\s*/g)) {
1196
1315
  const end = consumeValueExpr(text, m.index + m[0].length);
@@ -1230,17 +1349,111 @@ function authRanges(text) {
1230
1349
  return ranges;
1231
1350
  }
1232
1351
  /**
1233
- * Variables assigned (transitively) from a credential value expression,
1234
- * mapped to their inheritance purity (P-091): a variable is pure when
1235
- * every taint source in its assignment chain is an inheritance atom
1236
- * or another pure variable (ENV = {**os.environ, ...} is pure; a
1237
- * payload built by serializing that same environment is not, because
1238
- * the serializing atom in its own right-hand side is impure).
1239
- * References inside authentication positions do not propagate taint
1240
- * (P-061): a headers dict whose only credential use is its
1241
- * Authorization value is an authentication container, not a tainted
1242
- * payload, and passing it to fetch does not become exfil.
1352
+ * Functions defined in the same text (P-110). Python `def` bodies are
1353
+ * line ranges bounded by indentation; JavaScript `function` bodies
1354
+ * and arrow function bodies are the balanced brace span after the
1355
+ * signature. Only plain (non-method) definitions are collected.
1356
+ */
1357
+ function localFunctions(text) {
1358
+ const fns = [];
1359
+ const lines = text.split("\n");
1360
+ const lineStart = [];
1361
+ let off = 0;
1362
+ for (const l of lines) {
1363
+ lineStart.push(off);
1364
+ off += l.length + 1;
1365
+ }
1366
+ for (let li = 0; li < lines.length; li++) {
1367
+ const dm = lines[li].match(/^(\s*)def\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(/);
1368
+ if (!dm)
1369
+ continue;
1370
+ const indent = dm[1].length;
1371
+ let endLine = lines.length - 1;
1372
+ for (let lj = li + 1; lj < lines.length; lj++) {
1373
+ const l = lines[lj];
1374
+ if (l.trim() === "")
1375
+ continue;
1376
+ const ind = l.match(/^\s*/)?.[0].length ?? 0;
1377
+ if (ind <= indent) {
1378
+ endLine = lj - 1;
1379
+ break;
1380
+ }
1381
+ }
1382
+ fns.push({ name: dm[2], bodyStart: lineStart[li], bodyEnd: lineStart[endLine] + lines[endLine].length });
1383
+ }
1384
+ const jsRe = /\bfunction\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*\(|(?:^|[;\n])\s*(?:const|let|var)\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*=\s*(?:async\s*)?(?:\([^)]*\)|[A-Za-z_$][A-Za-z0-9_$]*)\s*=>/g;
1385
+ let jm;
1386
+ while ((jm = jsRe.exec(text)) !== null) {
1387
+ const name = jm[1] ?? jm[2];
1388
+ if (!name)
1389
+ continue;
1390
+ const brace = text.indexOf("{", jm.index + jm[0].length);
1391
+ if (brace === -1)
1392
+ continue;
1393
+ fns.push({ name, bodyStart: jm.index, bodyEnd: consumeBalanced(text, brace) });
1394
+ }
1395
+ return fns;
1396
+ }
1397
+ /**
1398
+ * True when a locally defined function returns tainted data: a return
1399
+ * expression inside its body that references a credential atom or a
1400
+ * tainted variable at a genuine reference position (outside data and
1401
+ * authentication ranges), or returns the result of another local
1402
+ * function that does (one level of recursion, depth capped).
1403
+ */
1404
+ function fnReturnsTaint(fn, text, live, tainted, dataRanges, auth, fns, depth) {
1405
+ const body = text.slice(fn.bodyStart, fn.bodyEnd);
1406
+ for (const m of body.matchAll(/\breturn\b/g)) {
1407
+ const exprStart = fn.bodyStart + (m.index ?? 0) + m[0].length;
1408
+ let exprEnd = text.indexOf("\n", exprStart);
1409
+ if (exprEnd === -1)
1410
+ exprEnd = fn.bodyEnd;
1411
+ const semi = text.indexOf(";", exprStart);
1412
+ if (semi !== -1 && semi < exprEnd)
1413
+ exprEnd = semi;
1414
+ exprEnd = Math.min(exprEnd, fn.bodyEnd);
1415
+ const expr = text.slice(exprStart, exprEnd);
1416
+ for (const x of live) {
1417
+ if (x.start >= exprStart && x.start < exprEnd)
1418
+ return true;
1419
+ }
1420
+ for (const [v] of tainted) {
1421
+ if (refOutsideRanges(expr, v, exprStart, [...dataRanges, ...auth]))
1422
+ return true;
1423
+ }
1424
+ if (depth < 2) {
1425
+ for (const cm of expr.matchAll(/\b([A-Za-z_$][A-Za-z0-9_$]*)\s*\(/g)) {
1426
+ const inner = fns.find((f) => f.name === cm[1] && f !== fn);
1427
+ if (inner && fnReturnsTaint(inner, text, live, tainted, dataRanges, auth, fns, depth + 1))
1428
+ return true;
1429
+ }
1430
+ }
1431
+ }
1432
+ return false;
1433
+ }
1434
+ /**
1435
+ * The innermost plain call whose argument list contains `pos`
1436
+ * within the assignment right-hand side, when that call is to a
1437
+ * function defined in the same text. Returns the function, or null
1438
+ * when the position is not inside a local call's arguments.
1243
1439
  */
1440
+ function localCallAt(text, rhsStart, rhsEnd, pos, fns) {
1441
+ const rhs = text.slice(rhsStart, rhsEnd);
1442
+ let best = null;
1443
+ for (const m of rhs.matchAll(/\b([A-Za-z_$][A-Za-z0-9_$]*)\s*\(/g)) {
1444
+ const nameStart = rhsStart + (m.index ?? 0);
1445
+ if (nameStart > 0 && text[nameStart - 1] === ".")
1446
+ continue;
1447
+ const fn = fns.find((f) => f.name === m[1]);
1448
+ if (!fn)
1449
+ continue;
1450
+ const open = nameStart + m[0].length - 1;
1451
+ const span = callSpan(text, open);
1452
+ if (pos > open && pos < span.end && (!best || open > best.open))
1453
+ best = { fn, open };
1454
+ }
1455
+ return best?.fn ?? null;
1456
+ }
1244
1457
  function taintedVars(text, atoms, dataRanges, auth = []) {
1245
1458
  const tainted = new Map();
1246
1459
  const assignRe = /(?:^|[;\n,])\s*(?:export\s+|const\s+|let\s+|var\s+)?([A-Za-z_$][A-Za-z0-9_$]*)\s*=(?![=<>])\s*/g;
@@ -1254,18 +1467,59 @@ function taintedVars(text, atoms, dataRanges, auth = []) {
1254
1467
  assigns.push({ name: m[1], rhsStart, rhsEnd });
1255
1468
  }
1256
1469
  const live = atoms.filter((a) => !a.inData);
1470
+ const fns = localFunctions(text);
1471
+ // Local helper taint (P-110). Passing a credential as an ARGUMENT
1472
+ // to a function defined in the same text does not taint that call's
1473
+ // result, unless the helper itself returns tainted data: the
1474
+ // helper's own body is part of the analyzed text, so a credential
1475
+ // it posts from inside is caught there directly, and a credential
1476
+ // it only presents as authentication (the davila7 and ticker
1477
+ // shapes: fetch_stargazers(token) and fetch_repos(token), whose
1478
+ // results are star dates and repo metadata) never becomes the
1479
+ // result's value. Calls to functions NOT defined in the text
1480
+ // (JSON.stringify, encodeURIComponent, library helpers) keep the
1481
+ // conservative rule: their result carries their arguments' taint.
1482
+ const consumedByLocalCall = (a, pos) => {
1483
+ if (fns.length === 0)
1484
+ return false;
1485
+ const fn = localCallAt(text, a.rhsStart, a.rhsEnd, pos, fns);
1486
+ if (!fn)
1487
+ return false;
1488
+ return !fnReturnsTaint(fn, text, live, tainted, dataRanges, auth, fns, 0);
1489
+ };
1257
1490
  for (let pass = 0; pass < 4; pass++) {
1258
1491
  for (const a of assigns) {
1259
1492
  if (tainted.has(a.name))
1260
1493
  continue;
1261
1494
  const sources = [];
1495
+ // Destination positions inside the right-hand side do not
1496
+ // taint the RESULT of a call or command substitution either
1497
+ // (P-106): a variable assigned a send's result (a status code,
1498
+ // a response) does not carry the credential that was presented
1499
+ // in the send's URL. The skip applies only inside a call or
1500
+ // substitution span: a plain string or expression that
1501
+ // CONTAINS a destination atom (a capability URL built into a
1502
+ // variable) keeps its taint, because printing or posting that
1503
+ // variable would disclose the credential.
1504
+ const dest = destAuthRanges(text, { start: a.rhsStart, end: a.rhsEnd }, true)
1505
+ .filter((r) => {
1506
+ const rhs = text.slice(a.rhsStart, a.rhsEnd);
1507
+ for (const cm of rhs.matchAll(/\$\(|[A-Za-z_$][A-Za-z0-9_$]*\s*\(/g)) {
1508
+ const open = a.rhsStart + (cm.index ?? 0) + cm[0].length - 1;
1509
+ const span = callSpan(text, open);
1510
+ if (r.start >= span.start && r.start < span.end)
1511
+ return true;
1512
+ }
1513
+ return false;
1514
+ });
1262
1515
  for (const x of live) {
1263
- if (x.start >= a.rhsStart && x.start < a.rhsEnd)
1516
+ if (x.start >= a.rhsStart && x.start < a.rhsEnd && !consumedByLocalCall(a, x.start) && !inRanges(x.start, dest))
1264
1517
  sources.push(x.pure === true);
1265
1518
  }
1266
1519
  const rhs = text.slice(a.rhsStart, a.rhsEnd);
1267
1520
  for (const [v, vPure] of tainted) {
1268
- if (refOutsideRanges(rhs, v, a.rhsStart, [...dataRanges, ...auth]))
1521
+ const positions = refPositions(rhs, v, a.rhsStart, [...dataRanges, ...auth]);
1522
+ if (positions.some((p) => !consumedByLocalCall(a, p) && !inRanges(p, dest)))
1269
1523
  sources.push(vPure);
1270
1524
  }
1271
1525
  if (sources.length > 0)
@@ -1309,11 +1563,11 @@ function callSpan(text, openIdx) {
1309
1563
  * A span carrying even one impure hit (a credential shaped read, a
1310
1564
  * serialization of the whole environment) is an impure flow.
1311
1565
  */
1312
- function spanHasTaint(text, span, atoms, tainted, dataRanges, auth = []) {
1566
+ function spanHasTaint(text, span, atoms, tainted, dataRanges, auth = [], extra = []) {
1313
1567
  let found = false;
1314
1568
  let pure = true;
1315
1569
  for (const a of atoms) {
1316
- if (!a.inData && a.start >= span.start && a.start < span.end) {
1570
+ if (!a.inData && a.start >= span.start && a.start < span.end && !inRanges(a.start, extra)) {
1317
1571
  found = true;
1318
1572
  if (a.pure !== true)
1319
1573
  pure = false;
@@ -1322,7 +1576,7 @@ function spanHasTaint(text, span, atoms, tainted, dataRanges, auth = []) {
1322
1576
  if (tainted.size > 0) {
1323
1577
  const seg = text.slice(span.start, span.end);
1324
1578
  for (const [v, vPure] of tainted) {
1325
- if (refOutsideRanges(seg, v, span.start, [...dataRanges, ...auth])) {
1579
+ if (refOutsideRanges(seg, v, span.start, [...dataRanges, ...auth, ...extra])) {
1326
1580
  found = true;
1327
1581
  if (!vPure)
1328
1582
  pure = false;
@@ -1331,6 +1585,222 @@ function spanHasTaint(text, span, atoms, tainted, dataRanges, auth = []) {
1331
1585
  }
1332
1586
  return { found, pure };
1333
1587
  }
1588
+ /**
1589
+ * Body ranges inside one send span or statement: the values handed
1590
+ * to curl data flags (-d, --data*, -F, --form, --json) and to body,
1591
+ * data, json, or payload properties and keyword arguments. A
1592
+ * credential inside a body range is payload by construction; the
1593
+ * destination rules below never scrub inside a body range, which is
1594
+ * what makes the body test decisive (P-106): a capability URL shape
1595
+ * PLUS credential data in the body still fires.
1596
+ */
1597
+ function bodyRanges(text, span) {
1598
+ const ranges = [];
1599
+ const seg = text.slice(span.start, span.end);
1600
+ for (const m of seg.matchAll(/(?:^|\s)(?:-d|--data(?:-raw|-binary|-urlencode|-ascii)?|--form(?:-string)?|-F|--json)(?:\s+|=)/g)) {
1601
+ let i = span.start + (m.index ?? 0) + m[0].length;
1602
+ while (i < span.end && /\s/.test(text[i]))
1603
+ i++;
1604
+ if (i >= span.end)
1605
+ continue;
1606
+ if (text[i] === "'" || text[i] === '"' || text[i] === "`") {
1607
+ ranges.push({ start: i, end: Math.min(span.end, consumeLiteral(text, i)) });
1608
+ }
1609
+ else {
1610
+ let end = i;
1611
+ while (end < span.end && !/[\s,;]/.test(text[end]))
1612
+ end++;
1613
+ ranges.push({ start: i, end });
1614
+ }
1615
+ }
1616
+ for (const m of seg.matchAll(/\b(?:body|data|json|payload)\s*[:=]\s*/g)) {
1617
+ const start = span.start + (m.index ?? 0) + m[0].length;
1618
+ const end = consumeValueExpr(text, start);
1619
+ if (end > start)
1620
+ ranges.push({ start, end: Math.min(end, span.end) });
1621
+ }
1622
+ return ranges;
1623
+ }
1624
+ const AUTH_PARAM_NAME = /^(?:.*[-_])?(?:token|api[-_]?key|access[-_]?token|auth|secret|signature|sig|key|session[-_]?id)$/i;
1625
+ /** True when a URL path segment is exactly one reference (a variable, an Actions expression, a template or f-string hole). */
1626
+ function segmentRefRange(seg, segGlobalStart) {
1627
+ const t = seg.replace(/^["'`(]+/, "").replace(/["'`),;]+$/, "");
1628
+ const lead = seg.length - seg.replace(/^["'`(]+/, "").length;
1629
+ const base = segGlobalStart + lead;
1630
+ if (/^\$\{\{[^}]*\}\}$/.test(t) || /^\$\{[^}]*\}$/.test(t) || /^\$[A-Za-z_][A-Za-z0-9_]*$/.test(t) || /^\{[A-Za-z_][A-Za-z0-9_.]*\}$/.test(t)) {
1631
+ return { start: base, end: base + t.length };
1632
+ }
1633
+ return null;
1634
+ }
1635
+ /**
1636
+ * Destination authentication ranges inside one send span or
1637
+ * statement (P-106, P-107). A credential PRESENTED in the URL of a
1638
+ * send authenticates that send to the destination: capability URLs
1639
+ * are credentials by design (a Netlify build hook URL, a preview
1640
+ * token as a path segment, a token query parameter), and the URL a
1641
+ * configuration expression expands to is the destination itself.
1642
+ * Recognized presentations:
1643
+ * - a reference that is an entire URL path segment;
1644
+ * - the value of an authentication named query parameter (token,
1645
+ * api_key, access_token, auth, secret, signature, key, and their
1646
+ * prefixed forms); a data named parameter (?t=, ?q=, ?payload=)
1647
+ * is NOT a presentation and keeps firing;
1648
+ * - a reference at the start of a URL token, in the origin position
1649
+ * (an expression or variable the rest of the URL is built on);
1650
+ * - the whole URL token when it is exactly one reference;
1651
+ * - in a curl or wget statement, a bare reference token in the URL
1652
+ * operand position (not a flag value): the Actions OIDC request
1653
+ * URL variable shape.
1654
+ * Nothing inside a body range is scrubbed here, and a reference in
1655
+ * any other URL position (a partial path segment, a data parameter)
1656
+ * is payload and keeps full exfil weight.
1657
+ */
1658
+ function destAuthRanges(text, span, isStatement) {
1659
+ const ranges = [];
1660
+ const bodies = bodyRanges(text, span);
1661
+ const inBody = (r) => bodies.some((b) => r.start >= b.start && r.start < b.end);
1662
+ const seg = text.slice(span.start, span.end);
1663
+ const tokenRe = /[^\s]+/g;
1664
+ let tm;
1665
+ while ((tm = tokenRe.exec(seg)) !== null) {
1666
+ const tokStart = span.start + (tm.index ?? 0);
1667
+ const tok = tm[0];
1668
+ const tokEnd = tokStart + tok.length;
1669
+ if (inBody({ start: tokStart, end: tokEnd }))
1670
+ continue;
1671
+ const schemeAt = tok.indexOf("://");
1672
+ if (schemeAt !== -1) {
1673
+ // Authority ends at the first path, query, or fragment char.
1674
+ let authEnd = tok.length;
1675
+ for (let i = schemeAt + 3; i < tok.length; i++) {
1676
+ if (tok[i] === "/" || tok[i] === "?" || tok[i] === "#") {
1677
+ authEnd = i;
1678
+ break;
1679
+ }
1680
+ }
1681
+ // Path segments that are exactly one reference.
1682
+ let p = authEnd;
1683
+ while (p < tok.length && tok[p] === "/") {
1684
+ let q = p + 1;
1685
+ while (q < tok.length && tok[q] !== "/" && tok[q] !== "?" && tok[q] !== "#")
1686
+ q++;
1687
+ const r = segmentRefRange(tok.slice(p + 1, q), tokStart + p + 1);
1688
+ if (r)
1689
+ ranges.push(r);
1690
+ p = q;
1691
+ if (tok[p] === "?" || tok[p] === "#")
1692
+ break;
1693
+ }
1694
+ // Authentication named query parameters.
1695
+ const qAt = tok.indexOf("?");
1696
+ if (qAt !== -1) {
1697
+ let qs = tok.slice(qAt + 1);
1698
+ const hash = qs.indexOf("#");
1699
+ if (hash !== -1)
1700
+ qs = qs.slice(0, hash);
1701
+ let off = qAt + 1;
1702
+ for (const part of qs.split("&")) {
1703
+ const eq = part.indexOf("=");
1704
+ if (eq > 0 && AUTH_PARAM_NAME.test(part.slice(0, eq))) {
1705
+ ranges.push({ start: tokStart + off + eq + 1, end: tokStart + off + part.length });
1706
+ }
1707
+ off += part.length + 1;
1708
+ }
1709
+ }
1710
+ continue;
1711
+ }
1712
+ // Expression or variable led token: the leading reference in the
1713
+ // origin position, or the whole token as one reference.
1714
+ const lead = tok.replace(/^["'`(]+/, "");
1715
+ const leadOff = tok.length - lead.length;
1716
+ const ref = lead.match(/^\$\{\{[^}]*\}\}|^\$\{[^}]*\}|^\$[A-Za-z_][A-Za-z0-9_]*/);
1717
+ if (ref) {
1718
+ const after = lead[ref[0].length] ?? "";
1719
+ if (after === "" || after === "/" || after === "?" || after === "#") {
1720
+ ranges.push({ start: tokStart + leadOff, end: tokStart + leadOff + ref[0].length });
1721
+ }
1722
+ continue;
1723
+ }
1724
+ // A bare reference token in a curl statement's operand position.
1725
+ if (isStatement) {
1726
+ const bare = tok.replace(/^["'`(]+/, "").replace(/["'`),;]+$/, "");
1727
+ if (/^\{[A-Za-z_][A-Za-z0-9_.]*\}$/.test(bare)) {
1728
+ ranges.push({ start: tokStart, end: tokEnd });
1729
+ }
1730
+ }
1731
+ }
1732
+ if (isStatement) {
1733
+ // Bare operand variables: tokenize with flag value awareness.
1734
+ const VALUE_FLAGS = new Set(["-d", "--data", "--data-raw", "--data-binary", "--data-urlencode", "--data-ascii", "-F", "--form", "--form-string", "-H", "--header", "-u", "--user", "-o", "--output", "-X", "--request", "-e", "--referer", "-A", "--user-agent", "-b", "--cookie", "-c", "--cookie-jar", "--cert", "--key", "--cacert", "--connect-timeout", "--max-time", "--retry", "--url", "-T", "--upload-file", "--json", "--oauth2-bearer", "-m", "-E"]);
1735
+ const toks = Array.from(seg.matchAll(/[^\s]+/g));
1736
+ let prevWasValueFlag = false;
1737
+ for (const t of toks) {
1738
+ const raw = t[0];
1739
+ const start = span.start + (t.index ?? 0);
1740
+ const clean = raw.replace(/^["'`(]+/, "").replace(/["'`),;]+$/, "");
1741
+ if (!prevWasValueFlag && /^(?:\$[A-Za-z_][A-Za-z0-9_]*|\$\{[^}]*\}|\$\{\{[^}]*\}\})$/.test(clean) && !inBody({ start, end: start + raw.length })) {
1742
+ ranges.push({ start, end: start + raw.length });
1743
+ }
1744
+ if (raw.includes("=") && raw.startsWith("--"))
1745
+ prevWasValueFlag = false;
1746
+ else if (VALUE_FLAGS.has(raw))
1747
+ prevWasValueFlag = true;
1748
+ else
1749
+ prevWasValueFlag = false;
1750
+ }
1751
+ }
1752
+ return ranges;
1753
+ }
1754
+ /** The first argument span of a call whose "(" is at openIdx. */
1755
+ function firstArgSpan(text, openIdx) {
1756
+ let depth = 0;
1757
+ let quote = "";
1758
+ for (let j = openIdx; j < text.length; j++) {
1759
+ const c = text[j];
1760
+ if (quote) {
1761
+ if (c === "\\") {
1762
+ j++;
1763
+ continue;
1764
+ }
1765
+ if (c === quote)
1766
+ quote = "";
1767
+ continue;
1768
+ }
1769
+ if (c === "'" || c === '"' || c === "`") {
1770
+ quote = c;
1771
+ continue;
1772
+ }
1773
+ if (c === "(" || c === "[" || c === "{")
1774
+ depth++;
1775
+ else if (c === ")" || c === "]" || c === "}") {
1776
+ depth--;
1777
+ if (depth === 0)
1778
+ return { start: openIdx + 1, end: j };
1779
+ }
1780
+ else if (c === "," && depth === 1)
1781
+ return { start: openIdx + 1, end: j };
1782
+ }
1783
+ return { start: openIdx + 1, end: text.length };
1784
+ }
1785
+ /**
1786
+ * Aggregate ranges inside a printed argument (P-110): a length or
1787
+ * count of a value is metadata about the value, not the value (the
1788
+ * P-020 principle: ${#NAME} is a length, not a read). References
1789
+ * inside len(...), count(...), or size(...) calls, and the object of
1790
+ * a .length or .size property, do not count as print disclosure.
1791
+ */
1792
+ function aggregateRanges(argText, argStart) {
1793
+ const ranges = [];
1794
+ for (const m of argText.matchAll(/\b(?:len|count|size)\s*\(/g)) {
1795
+ const openLocal = (m.index ?? 0) + m[0].length - 1;
1796
+ const local = callSpan(argText, openLocal);
1797
+ ranges.push({ start: argStart + (m.index ?? 0), end: argStart + local.end });
1798
+ }
1799
+ for (const m of argText.matchAll(/\b([A-Za-z_$][A-Za-z0-9_$]*)\s*\.\s*(?:length|size)\b/g)) {
1800
+ ranges.push({ start: argStart + (m.index ?? 0), end: argStart + (m.index ?? 0) + m[1].length });
1801
+ }
1802
+ return ranges.filter((r) => r.end > r.start);
1803
+ }
1334
1804
  /**
1335
1805
  * Exfil value flow (Round 2, P-018). ENV-EXFIL used to fire on mere
1336
1806
  * co-occurrence: a credential shaped name and any URL inside one finding.
@@ -1356,7 +1826,61 @@ function spanHasTaint(text, span, atoms, tainted, dataRanges, auth = []) {
1356
1826
  function analyzeExfil(text, views, lang) {
1357
1827
  const dataRanges = views.dataRanges;
1358
1828
  const auth = authRanges(text);
1359
- const atoms = findAtoms(text, dataRanges).filter((a) => !inRanges(a.start, auth));
1829
+ const rawAtoms = findAtoms(text, dataRanges).filter((a) => !inRanges(a.start, auth));
1830
+ // Bound-clean shell variables (P-107, the n8n OIDC probe shape):
1831
+ // a $NAME occurrence is a credential atom by NAME, but when the
1832
+ // same text assigns that name from a value that contains no
1833
+ // credential atom at all, the name is bound to a known
1834
+ // non-credential value in this scope: ID_TOKEN_RESPONSE bound to
1835
+ // "$RUNNER_TEMP/npm-oidc-id-token.json" is a response FILE PATH
1836
+ // (used as a curl --output sink), not a token. A name bound from a
1837
+ // credential (TOKEN2="$API_TOKEN") keeps its atom, and a name never
1838
+ // assigned in the text (a runner or environment provided value)
1839
+ // keeps it too.
1840
+ const boundClean = new Set();
1841
+ {
1842
+ const assignRe = /(?:^|[;\n,])\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=(?![=<>])\s*/g;
1843
+ let am;
1844
+ while ((am = assignRe.exec(text)) !== null) {
1845
+ const rhsStart = am.index + am[0].length;
1846
+ let rhsEnd = rhsStart;
1847
+ while (rhsEnd < text.length && text[rhsEnd] !== ";" && text[rhsEnd] !== "\n")
1848
+ rhsEnd++;
1849
+ // Atoms of names already proven bound-clean (a literal path
1850
+ // variable used as an output sink) are not credential atoms
1851
+ // for this test either: cleanliness is transitive.
1852
+ const inRhs = rawAtoms.filter((x) => {
1853
+ if (x.start < rhsStart || x.start >= rhsEnd)
1854
+ return false;
1855
+ const nm = text.slice(x.start, x.end).match(/^\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?$/);
1856
+ return !(nm && boundClean.has(nm[1]));
1857
+ });
1858
+ if (inRhs.length === 0) {
1859
+ boundClean.add(am[1]);
1860
+ continue;
1861
+ }
1862
+ // A name bound to the RESULT of a command substitution whose
1863
+ // only credential atoms sit in destination positions of the
1864
+ // substituted send is bound to that result (the OIDC probe's
1865
+ // id_token_status is an HTTP status code), not to the
1866
+ // credential presented in the URL. A name bound to a plain
1867
+ // string or expression that CONTAINS a destination atom (a
1868
+ // capability URL held in a variable) is not clean: printing
1869
+ // that variable would print the credential.
1870
+ const rhsText = text.slice(rhsStart, rhsEnd);
1871
+ if (rhsText.includes("$(")) {
1872
+ const dest = destAuthRanges(text, { start: rhsStart, end: rhsEnd }, true);
1873
+ if (inRhs.every((x) => inRanges(x.start, dest)))
1874
+ boundClean.add(am[1]);
1875
+ }
1876
+ }
1877
+ }
1878
+ const atoms = rawAtoms.filter((a) => {
1879
+ if (a.kind !== "cred")
1880
+ return true;
1881
+ const m = text.slice(a.start, a.end).match(/^\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?$/);
1882
+ return !(m && boundClean.has(m[1]));
1883
+ });
1360
1884
  // Inheritance purity (P-091): a bulk atom inside an inheritance
1361
1885
  // expression passed as a subprocess env= is marked pure. Flows are
1362
1886
  // accumulated with their purity instead of returning at the first
@@ -1385,6 +1909,26 @@ function analyzeExfil(text, views, lang) {
1385
1909
  sawImpureFlow = true;
1386
1910
  };
1387
1911
  const has = (span) => note(spanHasTaint(text, span, atoms, tainted, dataRanges, auth));
1912
+ // Destination aware variant (P-106): taint that sits only in a
1913
+ // destination position (a capability URL presentation, the URL
1914
+ // operand, or the destination first argument of a fetch style
1915
+ // call) is authentication, not a flow into a send. Taint anywhere
1916
+ // else in the span - above all in a body range - fires as before.
1917
+ const hasDest = (span, isStatement, firstArg) => {
1918
+ const extra = destAuthRanges(text, span, isStatement);
1919
+ if (firstArg) {
1920
+ const open = text.indexOf("(", span.start);
1921
+ if (open !== -1 && open < span.end) {
1922
+ const fa = firstArgSpan(text, open);
1923
+ const faText = text.slice(fa.start, fa.end).trim();
1924
+ const bodies = bodyRanges(text, span);
1925
+ const faHasBody = bodies.some((b) => b.start >= fa.start && b.start < fa.end);
1926
+ if (!faText.startsWith("{") && !faHasBody)
1927
+ extra.push(fa);
1928
+ }
1929
+ }
1930
+ note(spanHasTaint(text, span, atoms, tainted, dataRanges, auth, extra));
1931
+ };
1388
1932
  const callRes = [
1389
1933
  /\bfetch\s*\(/g,
1390
1934
  /\baxios(?:\s*\.\s*(?:post|put|patch|get|request|delete))?\s*\(/g,
@@ -1397,7 +1941,14 @@ function analyzeExfil(text, views, lang) {
1397
1941
  for (const re of callRes) {
1398
1942
  let m;
1399
1943
  while ((m = re.exec(text)) !== null) {
1400
- has(callSpan(text, m.index + m[0].length - 1));
1944
+ const span = callSpan(text, m.index + m[0].length - 1);
1945
+ // XHR .send( takes the BODY as its argument, so it keeps the
1946
+ // plain rule; every other send call takes its destination
1947
+ // first, so its first argument is a destination position.
1948
+ if (re.source.includes("send"))
1949
+ has(span);
1950
+ else
1951
+ hasDest(span, false, true);
1401
1952
  }
1402
1953
  }
1403
1954
  // Fetch positions run on the code view with data blanked (indices
@@ -1416,7 +1967,7 @@ function analyzeExfil(text, views, lang) {
1416
1967
  }
1417
1968
  if (!isFetchStmt)
1418
1969
  continue;
1419
- has(stmt);
1970
+ hasDest(stmt, true, false);
1420
1971
  }
1421
1972
  for (const stmt of statements) {
1422
1973
  const stmtText = text.slice(stmt.start, stmt.end);
@@ -1448,27 +1999,36 @@ function analyzeExfil(text, views, lang) {
1448
1999
  if (hasSendConstruct) {
1449
2000
  for (const m of text.matchAll(/(?:^|[;\n|&]\s*)(?:echo|printf)\s+([^\n;|]*)/g)) {
1450
2001
  const span = { start: m.index, end: m.index + m[0].length };
2002
+ const argText = m[1] ?? "";
2003
+ const argStart = m.index + m[0].length - argText.length;
2004
+ const agg = aggregateRanges(argText, argStart);
1451
2005
  for (const a of atoms) {
1452
- if (a.start >= span.start && a.start < span.end)
2006
+ if (a.start >= span.start && a.start < span.end && !inRanges(a.start, agg))
1453
2007
  note({ found: true, pure: a.pure === true });
1454
2008
  }
1455
- const argText = m[1] ?? "";
1456
- const argStart = m.index + m[0].length - argText.length;
1457
2009
  for (const [v, vPure] of tainted) {
1458
- if (lang === "shell" || refOutsideRanges(argText, v, argStart, [...dataRanges, ...auth]))
2010
+ // Shell echo arguments are the printed values themselves, so
2011
+ // a reference inside a quoted argument still counts; what
2012
+ // does not count is a reference inside an authentication or
2013
+ // masking position (the add-mask registration) or inside an
2014
+ // aggregate (a printed length or count).
2015
+ const ranges = lang === "shell" ? [...auth, ...agg] : [...dataRanges, ...auth, ...agg];
2016
+ if (refOutsideRanges(argText, v, argStart, ranges))
1459
2017
  note({ found: true, pure: vPure });
1460
2018
  }
1461
2019
  }
1462
2020
  for (const m of text.matchAll(/\b(?:console\.(?:log|info|debug)|print)\s*\(([^)]*)\)/g)) {
1463
2021
  const span = { start: m.index, end: m.index + m[0].length };
2022
+ const argText = m[1] ?? "";
2023
+ const argStart = m.index + m[0].indexOf(argText);
2024
+ const agg = aggregateRanges(argText, argStart);
1464
2025
  for (const a of atoms) {
1465
- if (a.start >= span.start && a.start < span.end && (lang === "shell" || !a.inData))
2026
+ if (a.start >= span.start && a.start < span.end && (lang === "shell" || !a.inData) && !inRanges(a.start, agg))
1466
2027
  note({ found: true, pure: a.pure === true });
1467
2028
  }
1468
- const argText = m[1] ?? "";
1469
- const argStart = m.index + m[0].indexOf(argText);
1470
2029
  for (const [v, vPure] of tainted) {
1471
- if (lang === "shell" || refOutsideRanges(argText, v, argStart, [...dataRanges, ...auth]))
2030
+ const ranges = lang === "shell" ? [...auth, ...agg] : [...dataRanges, ...auth, ...agg];
2031
+ if (refOutsideRanges(argText, v, argStart, ranges))
1472
2032
  note({ found: true, pure: vPure });
1473
2033
  }
1474
2034
  }