@reneturcios/hookcheck 0.1.9 → 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/README.md +2 -2
- package/dist/core.d.ts +1 -1
- package/dist/core.js +655 -40
- package/dist/core.js.map +1 -1
- package/package.json +1 -1
- package/server.json +2 -2
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.
|
|
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
|
-
|
|
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
|
-
|
|
102
|
-
|
|
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
|
|
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)
|
|
@@ -847,9 +894,15 @@ function inheritanceRanges(text) {
|
|
|
847
894
|
if (!isInheritanceExpr(text.slice(rhsStart, end)))
|
|
848
895
|
continue;
|
|
849
896
|
const name = m[1].replace(/\$/g, "\\$");
|
|
850
|
-
|
|
851
|
-
|
|
897
|
+
let usedAsEnv = false;
|
|
898
|
+
for (const em of text.matchAll(/\benv\s*[:=]\s*/g)) {
|
|
899
|
+
if (envValueRegionMentions(text, em.index + em[0].length, name)) {
|
|
900
|
+
usedAsEnv = true;
|
|
901
|
+
break;
|
|
902
|
+
}
|
|
852
903
|
}
|
|
904
|
+
if (usedAsEnv)
|
|
905
|
+
ranges.push({ start: rhsStart, end });
|
|
853
906
|
}
|
|
854
907
|
return ranges;
|
|
855
908
|
}
|
|
@@ -861,9 +914,14 @@ function inheritanceRanges(text) {
|
|
|
861
914
|
* discount applies only when the flow's destinations are provably
|
|
862
915
|
* all local, the same standard as the P-004 loopback downgrade.
|
|
863
916
|
*/
|
|
864
|
-
function loopbackOnlyDestinations(text) {
|
|
917
|
+
function loopbackOnlyDestinations(text, views) {
|
|
918
|
+
// URL destinations come from the URL view (P-064): a URL inside a
|
|
919
|
+
// data literal (a stand-in server's canned payload in a test
|
|
920
|
+
// script, an error message) is not a destination of any send in
|
|
921
|
+
// the flow, while a URL handed to a network call is restored into
|
|
922
|
+
// the view and counts. In shell text the URL view is the full text.
|
|
865
923
|
const hosts = [];
|
|
866
|
-
for (const m of
|
|
924
|
+
for (const m of views.urlText.matchAll(/https?:\/\/([^\s"'`\/:?#]+)/g))
|
|
867
925
|
hosts.push(m[1]);
|
|
868
926
|
for (const m of text.matchAll(/\b(?:nc|netcat)\s+(?:-\S+\s+)*([A-Za-z0-9._-]+)/g))
|
|
869
927
|
hosts.push(m[1]);
|
|
@@ -871,6 +929,50 @@ function loopbackOnlyDestinations(text) {
|
|
|
871
929
|
return false;
|
|
872
930
|
return hosts.every((h) => isLoopbackHost(h));
|
|
873
931
|
}
|
|
932
|
+
/**
|
|
933
|
+
* The value region of an env= or env: argument: the text from the
|
|
934
|
+
* value start to the next top-level comma, closing bracket, or line
|
|
935
|
+
* end (bounded). A variable counts as passed as the subprocess
|
|
936
|
+
* environment when its name appears in that region, including inside
|
|
937
|
+
* a fallback expression: the xod CLI test wrapper runs its subprocess
|
|
938
|
+
* with env=env or ENV, where ENV is the inherited-environment dict
|
|
939
|
+
* and env is the wrapper's own parameter.
|
|
940
|
+
*/
|
|
941
|
+
function envValueRegionMentions(text, from, name) {
|
|
942
|
+
let depth = 0;
|
|
943
|
+
let quote = "";
|
|
944
|
+
let end = from;
|
|
945
|
+
const cap = Math.min(text.length, from + 200);
|
|
946
|
+
while (end < cap) {
|
|
947
|
+
const c = text[end];
|
|
948
|
+
if (quote) {
|
|
949
|
+
if (c === "\\") {
|
|
950
|
+
end += 2;
|
|
951
|
+
continue;
|
|
952
|
+
}
|
|
953
|
+
if (c === quote)
|
|
954
|
+
quote = "";
|
|
955
|
+
end++;
|
|
956
|
+
continue;
|
|
957
|
+
}
|
|
958
|
+
if (c === "'" || c === '"' || c === "`") {
|
|
959
|
+
quote = c;
|
|
960
|
+
end++;
|
|
961
|
+
continue;
|
|
962
|
+
}
|
|
963
|
+
if (c === "(" || c === "{" || c === "[")
|
|
964
|
+
depth++;
|
|
965
|
+
else if (c === ")" || c === "}" || c === "]") {
|
|
966
|
+
if (depth === 0)
|
|
967
|
+
break;
|
|
968
|
+
depth--;
|
|
969
|
+
}
|
|
970
|
+
else if ((c === "," || c === "\n" || c === ";") && depth === 0)
|
|
971
|
+
break;
|
|
972
|
+
end++;
|
|
973
|
+
}
|
|
974
|
+
return new RegExp(`\\b${name}\\b`).test(text.slice(from, end));
|
|
975
|
+
}
|
|
874
976
|
/**
|
|
875
977
|
* Credential value atoms: text positions where a credential shaped env
|
|
876
978
|
* value, or the whole environment, is read. Atoms that start inside a
|
|
@@ -1063,7 +1165,7 @@ function consumeValueExpr(text, start) {
|
|
|
1063
1165
|
}
|
|
1064
1166
|
return i;
|
|
1065
1167
|
}
|
|
1066
|
-
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;
|
|
1067
1169
|
function isAuthHeaderKey(key) {
|
|
1068
1170
|
const k = key.toLowerCase();
|
|
1069
1171
|
return AUTH_HEADER_KEY.test(k) || k.endsWith("-api-key") || k.endsWith("-apikey") || k.endsWith("-auth-token") || k.endsWith("-access-token");
|
|
@@ -1095,9 +1197,19 @@ function isAuthHeaderKey(key) {
|
|
|
1095
1197
|
* or request options object;
|
|
1096
1198
|
* - header setter calls (set, append, setHeader) and Python
|
|
1097
1199
|
* requests-style auth= tuples.
|
|
1098
|
-
* A credential that reaches a payload position (body,
|
|
1099
|
-
*
|
|
1100
|
-
*
|
|
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.
|
|
1101
1213
|
*/
|
|
1102
1214
|
function authRanges(text) {
|
|
1103
1215
|
const ranges = [];
|
|
@@ -1136,6 +1248,68 @@ function authRanges(text) {
|
|
|
1136
1248
|
ranges.push(valueAfter(i));
|
|
1137
1249
|
}
|
|
1138
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
|
+
}
|
|
1139
1313
|
// Header setter calls and requests-style auth= tuples.
|
|
1140
1314
|
for (const m of text.matchAll(/\b(?:set|append|setHeader|addHeader|header)\s*\(\s*["'][A-Za-z0-9-]+["']\s*,\s*/g)) {
|
|
1141
1315
|
const end = consumeValueExpr(text, m.index + m[0].length);
|
|
@@ -1175,17 +1349,111 @@ function authRanges(text) {
|
|
|
1175
1349
|
return ranges;
|
|
1176
1350
|
}
|
|
1177
1351
|
/**
|
|
1178
|
-
*
|
|
1179
|
-
*
|
|
1180
|
-
*
|
|
1181
|
-
*
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
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.
|
|
1188
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
|
+
}
|
|
1189
1457
|
function taintedVars(text, atoms, dataRanges, auth = []) {
|
|
1190
1458
|
const tainted = new Map();
|
|
1191
1459
|
const assignRe = /(?:^|[;\n,])\s*(?:export\s+|const\s+|let\s+|var\s+)?([A-Za-z_$][A-Za-z0-9_$]*)\s*=(?![=<>])\s*/g;
|
|
@@ -1199,18 +1467,59 @@ function taintedVars(text, atoms, dataRanges, auth = []) {
|
|
|
1199
1467
|
assigns.push({ name: m[1], rhsStart, rhsEnd });
|
|
1200
1468
|
}
|
|
1201
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
|
+
};
|
|
1202
1490
|
for (let pass = 0; pass < 4; pass++) {
|
|
1203
1491
|
for (const a of assigns) {
|
|
1204
1492
|
if (tainted.has(a.name))
|
|
1205
1493
|
continue;
|
|
1206
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
|
+
});
|
|
1207
1515
|
for (const x of live) {
|
|
1208
|
-
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))
|
|
1209
1517
|
sources.push(x.pure === true);
|
|
1210
1518
|
}
|
|
1211
1519
|
const rhs = text.slice(a.rhsStart, a.rhsEnd);
|
|
1212
1520
|
for (const [v, vPure] of tainted) {
|
|
1213
|
-
|
|
1521
|
+
const positions = refPositions(rhs, v, a.rhsStart, [...dataRanges, ...auth]);
|
|
1522
|
+
if (positions.some((p) => !consumedByLocalCall(a, p) && !inRanges(p, dest)))
|
|
1214
1523
|
sources.push(vPure);
|
|
1215
1524
|
}
|
|
1216
1525
|
if (sources.length > 0)
|
|
@@ -1254,11 +1563,11 @@ function callSpan(text, openIdx) {
|
|
|
1254
1563
|
* A span carrying even one impure hit (a credential shaped read, a
|
|
1255
1564
|
* serialization of the whole environment) is an impure flow.
|
|
1256
1565
|
*/
|
|
1257
|
-
function spanHasTaint(text, span, atoms, tainted, dataRanges, auth = []) {
|
|
1566
|
+
function spanHasTaint(text, span, atoms, tainted, dataRanges, auth = [], extra = []) {
|
|
1258
1567
|
let found = false;
|
|
1259
1568
|
let pure = true;
|
|
1260
1569
|
for (const a of atoms) {
|
|
1261
|
-
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)) {
|
|
1262
1571
|
found = true;
|
|
1263
1572
|
if (a.pure !== true)
|
|
1264
1573
|
pure = false;
|
|
@@ -1267,7 +1576,7 @@ function spanHasTaint(text, span, atoms, tainted, dataRanges, auth = []) {
|
|
|
1267
1576
|
if (tainted.size > 0) {
|
|
1268
1577
|
const seg = text.slice(span.start, span.end);
|
|
1269
1578
|
for (const [v, vPure] of tainted) {
|
|
1270
|
-
if (refOutsideRanges(seg, v, span.start, [...dataRanges, ...auth])) {
|
|
1579
|
+
if (refOutsideRanges(seg, v, span.start, [...dataRanges, ...auth, ...extra])) {
|
|
1271
1580
|
found = true;
|
|
1272
1581
|
if (!vPure)
|
|
1273
1582
|
pure = false;
|
|
@@ -1276,6 +1585,222 @@ function spanHasTaint(text, span, atoms, tainted, dataRanges, auth = []) {
|
|
|
1276
1585
|
}
|
|
1277
1586
|
return { found, pure };
|
|
1278
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
|
+
}
|
|
1279
1804
|
/**
|
|
1280
1805
|
* Exfil value flow (Round 2, P-018). ENV-EXFIL used to fire on mere
|
|
1281
1806
|
* co-occurrence: a credential shaped name and any URL inside one finding.
|
|
@@ -1301,7 +1826,61 @@ function spanHasTaint(text, span, atoms, tainted, dataRanges, auth = []) {
|
|
|
1301
1826
|
function analyzeExfil(text, views, lang) {
|
|
1302
1827
|
const dataRanges = views.dataRanges;
|
|
1303
1828
|
const auth = authRanges(text);
|
|
1304
|
-
const
|
|
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
|
+
});
|
|
1305
1884
|
// Inheritance purity (P-091): a bulk atom inside an inheritance
|
|
1306
1885
|
// expression passed as a subprocess env= is marked pure. Flows are
|
|
1307
1886
|
// accumulated with their purity instead of returning at the first
|
|
@@ -1330,6 +1909,26 @@ function analyzeExfil(text, views, lang) {
|
|
|
1330
1909
|
sawImpureFlow = true;
|
|
1331
1910
|
};
|
|
1332
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
|
+
};
|
|
1333
1932
|
const callRes = [
|
|
1334
1933
|
/\bfetch\s*\(/g,
|
|
1335
1934
|
/\baxios(?:\s*\.\s*(?:post|put|patch|get|request|delete))?\s*\(/g,
|
|
@@ -1342,7 +1941,14 @@ function analyzeExfil(text, views, lang) {
|
|
|
1342
1941
|
for (const re of callRes) {
|
|
1343
1942
|
let m;
|
|
1344
1943
|
while ((m = re.exec(text)) !== null) {
|
|
1345
|
-
|
|
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);
|
|
1346
1952
|
}
|
|
1347
1953
|
}
|
|
1348
1954
|
// Fetch positions run on the code view with data blanked (indices
|
|
@@ -1361,7 +1967,7 @@ function analyzeExfil(text, views, lang) {
|
|
|
1361
1967
|
}
|
|
1362
1968
|
if (!isFetchStmt)
|
|
1363
1969
|
continue;
|
|
1364
|
-
|
|
1970
|
+
hasDest(stmt, true, false);
|
|
1365
1971
|
}
|
|
1366
1972
|
for (const stmt of statements) {
|
|
1367
1973
|
const stmtText = text.slice(stmt.start, stmt.end);
|
|
@@ -1393,27 +1999,36 @@ function analyzeExfil(text, views, lang) {
|
|
|
1393
1999
|
if (hasSendConstruct) {
|
|
1394
2000
|
for (const m of text.matchAll(/(?:^|[;\n|&]\s*)(?:echo|printf)\s+([^\n;|]*)/g)) {
|
|
1395
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);
|
|
1396
2005
|
for (const a of atoms) {
|
|
1397
|
-
if (a.start >= span.start && a.start < span.end)
|
|
2006
|
+
if (a.start >= span.start && a.start < span.end && !inRanges(a.start, agg))
|
|
1398
2007
|
note({ found: true, pure: a.pure === true });
|
|
1399
2008
|
}
|
|
1400
|
-
const argText = m[1] ?? "";
|
|
1401
|
-
const argStart = m.index + m[0].length - argText.length;
|
|
1402
2009
|
for (const [v, vPure] of tainted) {
|
|
1403
|
-
|
|
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))
|
|
1404
2017
|
note({ found: true, pure: vPure });
|
|
1405
2018
|
}
|
|
1406
2019
|
}
|
|
1407
2020
|
for (const m of text.matchAll(/\b(?:console\.(?:log|info|debug)|print)\s*\(([^)]*)\)/g)) {
|
|
1408
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);
|
|
1409
2025
|
for (const a of atoms) {
|
|
1410
|
-
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))
|
|
1411
2027
|
note({ found: true, pure: a.pure === true });
|
|
1412
2028
|
}
|
|
1413
|
-
const argText = m[1] ?? "";
|
|
1414
|
-
const argStart = m.index + m[0].indexOf(argText);
|
|
1415
2029
|
for (const [v, vPure] of tainted) {
|
|
1416
|
-
|
|
2030
|
+
const ranges = lang === "shell" ? [...auth, ...agg] : [...dataRanges, ...auth, ...agg];
|
|
2031
|
+
if (refOutsideRanges(argText, v, argStart, ranges))
|
|
1417
2032
|
note({ found: true, pure: vPure });
|
|
1418
2033
|
}
|
|
1419
2034
|
}
|
|
@@ -1421,7 +2036,7 @@ function analyzeExfil(text, views, lang) {
|
|
|
1421
2036
|
if (sawImpureFlow)
|
|
1422
2037
|
return true;
|
|
1423
2038
|
if (sawPureFlow)
|
|
1424
|
-
return !loopbackOnlyDestinations(text);
|
|
2039
|
+
return !loopbackOnlyDestinations(text, views);
|
|
1425
2040
|
return false;
|
|
1426
2041
|
}
|
|
1427
2042
|
function isLoopbackHost(host) {
|