@reneturcios/hookcheck 0.1.3 → 0.1.5

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, readdirSync, readFileSync, statSync } from "node:fs";
8
8
  import { join, relative, resolve, sep } from "node:path";
9
- export const VERSION = "0.1.3";
9
+ export const VERSION = "0.1.5";
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 },
@@ -29,7 +29,15 @@ const EXTRA_RULE_META = {
29
29
  "CRED-ACCESS": { credential: true },
30
30
  "ENV-ACCESS": { credential: true },
31
31
  "ENV-BULK": { credential: true },
32
- "ENV-EXFIL": { credential: true, remote: true },
32
+ // ENV-EXFIL is severe since v0.1.4 (P-018): it no longer fires on
33
+ // co-occurrence of a credential shaped name and a URL, only when the
34
+ // credential value demonstrably flows into an outbound send, so on its
35
+ // own it is strong evidence. Token as authentication (an Authorization
36
+ // header, the userinfo of a push URL, a --token flag) never counts.
37
+ "ENV-EXFIL": { credential: true, remote: true, severe: true },
38
+ // CRED-EXFIL: credential store contents piped straight into a network
39
+ // tool. Severe for the same reason: the value flow is in the text.
40
+ "CRED-EXFIL": { credential: true, remote: true, severe: true },
33
41
  };
34
42
  function ruleMeta(id) {
35
43
  const r = RISKY_RULES.find((x) => x.id === id);
@@ -54,6 +62,420 @@ export function stripComments(text) {
54
62
  })
55
63
  .join("\n");
56
64
  }
65
+ function langForFile(name) {
66
+ if (/\.py$/i.test(name))
67
+ return "python";
68
+ if (/\.(?:mjs|cjs|js|ts|tsx|jsx)$/i.test(name))
69
+ return "js";
70
+ return "shell";
71
+ }
72
+ function inRanges(idx, ranges) {
73
+ return ranges.some((r) => idx >= r.start && idx < r.end);
74
+ }
75
+ /**
76
+ * True when `name` is referenced at a code position inside `seg` (whose
77
+ * global start offset is `segStart`): at least one occurrence that does
78
+ * not begin inside a data literal. A variable merely NAMED in prose (help
79
+ * text, a banner string, a docstring) is mentioned, not used; the mention
80
+ * must not propagate taint or count as disclosure. Found in the v0.1.4
81
+ * re-validation on jpoley/flowspec: a kwarg default tainted the variable
82
+ * name `help` through its prose value ("GitHub token for API requests"),
83
+ * and a banner printing the word --help then read as credential
84
+ * disclosure next to the prose word "fetch" in a docstring.
85
+ */
86
+ function refOutsideData(seg, name, segStart, dataRanges) {
87
+ const re = new RegExp(`\\b${name.replace(/\$/g, "\\$")}\\b`, "g");
88
+ for (const m of seg.matchAll(re)) {
89
+ if (!inRanges(segStart + (m.index ?? 0), dataRanges))
90
+ return true;
91
+ }
92
+ return false;
93
+ }
94
+ function blankRanges(text, ranges) {
95
+ if (ranges.length === 0)
96
+ return text;
97
+ const chars = text.split("");
98
+ for (const r of ranges) {
99
+ for (let i = r.start; i < r.end && i < chars.length; i++) {
100
+ if (chars[i] !== "\n")
101
+ chars[i] = " ";
102
+ }
103
+ }
104
+ return chars.join("");
105
+ }
106
+ /** Content ranges of a literal with its interpolation holes left visible. */
107
+ function contentRanges(lit) {
108
+ const out = [];
109
+ let cur = lit.contentStart;
110
+ for (const h of lit.holes) {
111
+ if (h.start > cur)
112
+ out.push({ start: cur, end: h.start });
113
+ cur = Math.max(cur, h.end);
114
+ }
115
+ if (lit.contentEnd > cur)
116
+ out.push({ start: cur, end: lit.contentEnd });
117
+ return out;
118
+ }
119
+ function scanJsLiterals(text) {
120
+ const spans = [];
121
+ const n = text.length;
122
+ let i = 0;
123
+ let prevSig = "";
124
+ let prevWord = "";
125
+ const regexAfterChar = new Set("=({[,;:!&|?+-*%~^<>".split(""));
126
+ const regexAfterWord = new Set(["return", "typeof", "case", "in", "of", "new", "delete", "void", "instanceof", "yield", "await", "throw", "else", "do"]);
127
+ while (i < n) {
128
+ const ch = text[i];
129
+ if (ch === "/" && text[i + 1] === "/") {
130
+ const e = text.indexOf("\n", i);
131
+ i = e === -1 ? n : e;
132
+ continue;
133
+ }
134
+ if (ch === "/" && text[i + 1] === "*") {
135
+ const e = text.indexOf("*/", i + 2);
136
+ i = e === -1 ? n : e + 2;
137
+ continue;
138
+ }
139
+ if (ch === "'" || ch === '"') {
140
+ let j = i + 1;
141
+ while (j < n) {
142
+ if (text[j] === "\\") {
143
+ j += 2;
144
+ continue;
145
+ }
146
+ if (text[j] === ch || text[j] === "\n")
147
+ break;
148
+ j++;
149
+ }
150
+ spans.push({ start: i, end: Math.min(j + 1, n), contentStart: i + 1, contentEnd: j, holes: [] });
151
+ i = Math.min(j + 1, n);
152
+ prevSig = ch;
153
+ prevWord = "";
154
+ continue;
155
+ }
156
+ if (ch === "`") {
157
+ let j = i + 1;
158
+ const holes = [];
159
+ while (j < n) {
160
+ if (text[j] === "\\") {
161
+ j += 2;
162
+ continue;
163
+ }
164
+ if (text[j] === "`")
165
+ break;
166
+ if (text[j] === "$" && text[j + 1] === "{") {
167
+ const holeStart = j;
168
+ let depth = 1;
169
+ j += 2;
170
+ while (j < n && depth > 0) {
171
+ if (text[j] === "{")
172
+ depth++;
173
+ else if (text[j] === "}")
174
+ depth--;
175
+ j++;
176
+ }
177
+ holes.push({ start: holeStart, end: j });
178
+ continue;
179
+ }
180
+ j++;
181
+ }
182
+ spans.push({ start: i, end: Math.min(j + 1, n), contentStart: i + 1, contentEnd: j, holes });
183
+ i = Math.min(j + 1, n);
184
+ prevSig = "`";
185
+ prevWord = "";
186
+ continue;
187
+ }
188
+ if (ch === "/" && (prevSig === "" || regexAfterChar.has(prevSig) || regexAfterWord.has(prevWord))) {
189
+ let j = i + 1;
190
+ let inClass = false;
191
+ while (j < n) {
192
+ if (text[j] === "\\") {
193
+ j += 2;
194
+ continue;
195
+ }
196
+ if (text[j] === "[")
197
+ inClass = true;
198
+ else if (text[j] === "]")
199
+ inClass = false;
200
+ else if (text[j] === "/" && !inClass)
201
+ break;
202
+ else if (text[j] === "\n")
203
+ break;
204
+ j++;
205
+ }
206
+ let end = Math.min(j + 1, n);
207
+ while (end < n && /[a-z]/i.test(text[end]))
208
+ end++;
209
+ spans.push({ start: i, end, contentStart: i + 1, contentEnd: j, holes: [] });
210
+ i = end;
211
+ prevSig = "/";
212
+ prevWord = "";
213
+ continue;
214
+ }
215
+ if (/[A-Za-z0-9_$]/.test(ch)) {
216
+ prevWord += ch;
217
+ prevSig = ch;
218
+ i++;
219
+ continue;
220
+ }
221
+ if (/\s/.test(ch)) {
222
+ i++;
223
+ continue;
224
+ }
225
+ prevSig = ch;
226
+ prevWord = "";
227
+ i++;
228
+ }
229
+ return spans;
230
+ }
231
+ function scanPyLiterals(text) {
232
+ const spans = [];
233
+ const n = text.length;
234
+ let i = 0;
235
+ while (i < n) {
236
+ const ch = text[i];
237
+ if (ch === "#") {
238
+ const e = text.indexOf("\n", i);
239
+ i = e === -1 ? n : e;
240
+ continue;
241
+ }
242
+ if (ch === "'" || ch === '"') {
243
+ // String prefix letters (r, b, f, u) sit immediately before the quote.
244
+ let p = i - 1;
245
+ let prefix = "";
246
+ while (p >= 0 && /[rRbBfFuU]/.test(text[p])) {
247
+ prefix = text[p] + prefix;
248
+ p--;
249
+ }
250
+ const isF = /f/i.test(prefix);
251
+ const triple = text.startsWith(ch + ch + ch, i);
252
+ const qLen = triple ? 3 : 1;
253
+ let j = i + qLen;
254
+ const holes = [];
255
+ while (j < n) {
256
+ if (text[j] === "\\") {
257
+ j += 2;
258
+ continue;
259
+ }
260
+ if (triple ? text.startsWith(ch + ch + ch, j) : text[j] === ch || text[j] === "\n")
261
+ break;
262
+ if (isF && text[j] === "{") {
263
+ if (text[j + 1] === "{") {
264
+ j += 2;
265
+ continue;
266
+ }
267
+ const holeStart = j;
268
+ let depth = 1;
269
+ j++;
270
+ while (j < n && depth > 0) {
271
+ if (text[j] === "{")
272
+ depth++;
273
+ else if (text[j] === "}")
274
+ depth--;
275
+ j++;
276
+ }
277
+ holes.push({ start: holeStart, end: j });
278
+ continue;
279
+ }
280
+ if (isF && text[j] === "}" && text[j + 1] === "}") {
281
+ j += 2;
282
+ continue;
283
+ }
284
+ j++;
285
+ }
286
+ spans.push({ start: i, end: Math.min(j + qLen, n), contentStart: i + qLen, contentEnd: j, holes });
287
+ i = Math.min(j + qLen, n);
288
+ continue;
289
+ }
290
+ i++;
291
+ }
292
+ return spans;
293
+ }
294
+ const EXEC_CALLS = ["eval", "exec", "execSync", "execFile", "execFileSync", "spawn", "spawnSync", "system", "popen", "Popen", "Function", "runInThisContext", "runInNewContext", "runInContext", "setTimeout", "setInterval", "compile"];
295
+ const PATH_CALLS = ["readFileSync", "readFile", "createReadStream", "createWriteStream", "open", "join", "resolve", "Path", "expanduser", "homedir"];
296
+ /**
297
+ * First argument spans of calls to the named functions. The matching
298
+ * close paren is found with a scan that jumps over literal spans, so
299
+ * parens inside strings do not unbalance it.
300
+ */
301
+ function callFirstArgSpans(text, names, literals) {
302
+ const spans = [];
303
+ const litAt = new Map();
304
+ for (const l of literals)
305
+ litAt.set(l.start, l);
306
+ const re = new RegExp(`\\b(?:${names.join("|")})\\s*\\(`, "g");
307
+ let m;
308
+ while ((m = re.exec(text)) !== null) {
309
+ const open = m.index + m[0].length - 1;
310
+ let depth = 1;
311
+ let j = open + 1;
312
+ let firstArgEnd = -1;
313
+ while (j < text.length && depth > 0) {
314
+ const lit = litAt.get(j);
315
+ if (lit) {
316
+ j = lit.end;
317
+ continue;
318
+ }
319
+ const c = text[j];
320
+ if (c === "(")
321
+ depth++;
322
+ else if (c === ")") {
323
+ depth--;
324
+ if (depth === 0)
325
+ break;
326
+ }
327
+ else if (c === "," && depth === 1 && firstArgEnd === -1)
328
+ firstArgEnd = j;
329
+ j++;
330
+ }
331
+ spans.push({ start: open + 1, end: firstArgEnd === -1 ? j : firstArgEnd });
332
+ }
333
+ return spans;
334
+ }
335
+ /**
336
+ * Split shell text into segments on the given separators, tracking
337
+ * single and double quotes so separators inside quoted strings do not
338
+ * split. Returns segment ranges in the original text.
339
+ */
340
+ function splitShell(text, separators) {
341
+ const out = [];
342
+ let start = 0;
343
+ let i = 0;
344
+ let quote = "";
345
+ while (i < text.length) {
346
+ const c = text[i];
347
+ if (quote) {
348
+ if (c === "\\" && quote === '"') {
349
+ i += 2;
350
+ continue;
351
+ }
352
+ if (c === quote)
353
+ quote = "";
354
+ i++;
355
+ continue;
356
+ }
357
+ if (c === "'" || c === '"') {
358
+ quote = c;
359
+ i++;
360
+ continue;
361
+ }
362
+ const sep = separators.find((s) => text.startsWith(s, i));
363
+ if (sep) {
364
+ out.push({ start, end: i });
365
+ i += sep.length;
366
+ start = i;
367
+ continue;
368
+ }
369
+ i++;
370
+ }
371
+ out.push({ start, end: text.length });
372
+ return out;
373
+ }
374
+ function firstShellWord(segment) {
375
+ let s = segment.trim();
376
+ // Skip leading env assignments and sudo.
377
+ for (;;) {
378
+ const assign = s.match(/^[A-Za-z_][A-Za-z0-9_]*=\S+\s+/);
379
+ if (assign) {
380
+ s = s.slice(assign[0].length);
381
+ continue;
382
+ }
383
+ if (/^sudo\s+/.test(s)) {
384
+ s = s.replace(/^sudo\s+/, "");
385
+ continue;
386
+ }
387
+ break;
388
+ }
389
+ const w = s.match(/^[A-Za-z_][A-Za-z0-9_.-]*/);
390
+ return w ? w[0] : "";
391
+ }
392
+ /**
393
+ * Content ranges of echo and printf string arguments in shell text:
394
+ * output data, blanked in the code view, unless the pipeline ends in a
395
+ * bare interpreter (or a bare sh or bash), where the printed text is fed
396
+ * to the interpreter as its program.
397
+ */
398
+ function shellEchoDataRanges(text) {
399
+ const ranges = [];
400
+ for (const stmt of splitShell(text, ["\n", ";", "&&", "||"])) {
401
+ const stmtText = text.slice(stmt.start, stmt.end);
402
+ if (!stmtText.trim())
403
+ continue;
404
+ const segments = splitShell(stmtText, ["|"]);
405
+ const lastText = stmtText.slice(segments[segments.length - 1].start, segments[segments.length - 1].end);
406
+ const lastWord = firstShellWord(lastText);
407
+ let execSink = ["sh", "bash", "zsh", "dash", "eval", "source"].includes(lastWord);
408
+ if (!execSink && ["python", "python3", "node", "perl", "ruby"].includes(lastWord)) {
409
+ const rest = lastText.trim().replace(/^[A-Za-z_][A-Za-z0-9_.-]*/, "").trim();
410
+ // Bare interpreter (optionally a lone "-") reads its program from stdin.
411
+ execSink = rest === "" || rest === "-";
412
+ }
413
+ if (execSink)
414
+ continue;
415
+ for (const seg of segments) {
416
+ const segText = stmtText.slice(seg.start, seg.end);
417
+ const word = firstShellWord(segText);
418
+ if (word !== "echo" && word !== "printf")
419
+ continue;
420
+ // Every quoted literal in an echo segment is printed data.
421
+ let j = 0;
422
+ while (j < segText.length) {
423
+ const c = segText[j];
424
+ if (c === "'" || c === '"') {
425
+ let k = j + 1;
426
+ while (k < segText.length) {
427
+ if (segText[k] === "\\" && c === '"') {
428
+ k += 2;
429
+ continue;
430
+ }
431
+ if (segText[k] === c)
432
+ break;
433
+ k++;
434
+ }
435
+ ranges.push({ start: stmt.start + seg.start + j + 1, end: stmt.start + seg.start + k });
436
+ j = k + 1;
437
+ continue;
438
+ }
439
+ j++;
440
+ }
441
+ }
442
+ }
443
+ return ranges;
444
+ }
445
+ function buildViews(text, lang) {
446
+ if (lang === "shell") {
447
+ const dataRanges = shellEchoDataRanges(text);
448
+ return { codeText: blankRanges(text, dataRanges), credText: text, dataRanges };
449
+ }
450
+ const literals = lang === "python" ? scanPyLiterals(text) : scanJsLiterals(text);
451
+ const execSpans = callFirstArgSpans(text, EXEC_CALLS, literals);
452
+ const pathSpans = callFirstArgSpans(text, PATH_CALLS, literals);
453
+ const role = (lit) => {
454
+ if (execSpans.some((s) => lit.contentStart >= s.start && lit.contentEnd <= s.end))
455
+ return "code";
456
+ if (pathSpans.some((s) => lit.contentStart >= s.start && lit.contentEnd <= s.end))
457
+ return "path";
458
+ return "data";
459
+ };
460
+ const codeBlank = [];
461
+ const credBlank = [];
462
+ const dataRanges = [];
463
+ for (const lit of literals) {
464
+ const r = role(lit);
465
+ const contents = contentRanges(lit);
466
+ if (r === "data") {
467
+ codeBlank.push(...contents);
468
+ credBlank.push(...contents);
469
+ dataRanges.push(...contents);
470
+ }
471
+ else if (r === "path") {
472
+ codeBlank.push(...contents);
473
+ }
474
+ // "code" literals keep their contents in both views: the text is a
475
+ // program handed to an interpreter, and behavior rules should see it.
476
+ }
477
+ return { codeText: blankRanges(text, codeBlank), credText: blankRanges(text, credBlank), dataRanges };
478
+ }
57
479
  const CRED_FILE_PATTERNS = [
58
480
  /~\/\.ssh(\/|\s|$|["'])/, /\$HOME\/\.ssh(\/|\s|$|["'])/, /(^|[\s"'(=])\.ssh\//,
59
481
  /\.aws\/credentials\b/, /\.aws\/config\b/,
@@ -83,10 +505,27 @@ const CRED_ENV_NAME = /(SECRET|TOKEN|PASSWORD|PASSWD|API_?KEY|PRIVATE_?KEY|ACCES
83
505
  * Only credential shaped names (token, secret, password, api key, and
84
506
  * friends, in any access syntax) and bulk access (whole object
85
507
  * serialization, printenv, bare env dumps, whole os.environ use) count.
508
+ *
509
+ * Round 2 (P-020): the rules must match reads, not any mention.
510
+ * os.environ.setdefault writes one named key with a default and
511
+ * os.environ.pop removes one; neither is bulk access, and the bare
512
+ * os.environ scan below excludes the accessor, subscript, and mutation
513
+ * forms explicitly. Passing the environment to a subprocess
514
+ * (env=os.environ) is inheritance, not a read. In shell text,
515
+ * `env -u NAME` unsets a variable for the subprocess, the opposite of a
516
+ * read, and a bare [ -n "$NAME" ] or [ -z "$NAME" ] test checks
517
+ * existence without reading the value into anything; matches inside
518
+ * those spans, and matches that start inside a quoted data literal
519
+ * (an echo string, a pattern list), are skipped.
86
520
  */
87
- function detectEnvAccess(cmd) {
521
+ function detectEnvAccess(cmd, dataRanges) {
88
522
  let cred = false;
89
523
  let bulk = false;
524
+ const skipSpans = [
525
+ ...Array.from(cmd.matchAll(/\benv(?:\s+-u\s+[A-Za-z_][A-Za-z0-9_]*)+/g), (m) => ({ start: m.index, end: m.index + m[0].length })),
526
+ ...Array.from(cmd.matchAll(/\[\[?\s+-[nz]\s+"?\$\{?[A-Za-z_][A-Za-z0-9_]*\}?"?\s*\]\]?/g), (m) => ({ start: m.index, end: m.index + m[0].length })),
527
+ ];
528
+ const skipped = (idx) => inRanges(idx, dataRanges) || inRanges(idx, skipSpans);
90
529
  const nameRes = [
91
530
  /process\.env\.([A-Za-z_][A-Za-z0-9_]*)/g,
92
531
  /process\.env\[["']([A-Za-z_][A-Za-z0-9_]*)["']\]/g,
@@ -95,27 +534,325 @@ function detectEnvAccess(cmd) {
95
534
  /getenv\(["']([A-Za-z_][A-Za-z0-9_]*)["']/g,
96
535
  /Deno\.env\.get\(["']([A-Za-z_][A-Za-z0-9_]*)["']/g,
97
536
  /import\.meta\.env\.([A-Za-z_][A-Za-z0-9_]*)/g,
98
- /\$\{?([A-Z][A-Z0-9_]{2,})\}?/g,
537
+ /\$(?:\{)?([A-Za-z_][A-Za-z0-9_]*)\}?/g,
99
538
  ];
100
539
  for (const re of nameRes) {
101
540
  let m;
102
541
  while ((m = re.exec(cmd)) !== null) {
542
+ if (m[0].startsWith("${#"))
543
+ continue; // ${#NAME} is a length, not a value
544
+ if (skipped(m.index))
545
+ continue;
103
546
  if (CRED_ENV_NAME.test(m[1]))
104
547
  cred = true;
105
548
  }
106
549
  }
107
- if (/(JSON\.stringify|Object\.(keys|values|entries|assign)|util\.inspect)\s*\([^)]*process\.env/.test(cmd))
550
+ if (/(JSON\.stringify|Object\.(keys|values|entries|assign)|util\.inspect)\s*\([^)]*(process\.env|os\.environ)/.test(cmd))
108
551
  bulk = true;
109
- if (/\bprintenv\b/.test(cmd))
552
+ if (/\bprintenv\b/.test(cmd) && !inRanges(cmd.search(/\bprintenv\b/), dataRanges))
110
553
  bulk = true;
111
- if (/(^|[;&|]\s*)env\s*($|[|>;])/.test(cmd))
554
+ if (/(^|[;&|]\s*)env\s*($|[|>;])/m.test(cmd))
112
555
  bulk = true;
113
556
  if (/\b(str|dict|list)\s*\(\s*os\.environ\s*\)/.test(cmd))
114
557
  bulk = true;
115
- if (/\bos\.environ\b(?!\s*(\.get\s*\(|\[))/.test(cmd))
558
+ // Whole os.environ use (P-020): scan every occurrence and judge the
559
+ // operation that follows it, instead of one negative lookahead.
560
+ const envRe = /\bos\.environ\b/g;
561
+ let em;
562
+ while ((em = envRe.exec(cmd)) !== null) {
563
+ if (skipped(em.index))
564
+ continue;
565
+ const after = cmd.slice(em.index + em[0].length, em.index + em[0].length + 30);
566
+ if (/^\s*\.get\s*\(/.test(after))
567
+ continue; // named read, judged above
568
+ if (/^\s*\[/.test(after))
569
+ continue; // named subscript read
570
+ if (/^\s*\.(?:setdefault|pop|update)\s*\(/.test(after))
571
+ continue; // write or removal, not a read
572
+ if (/^\s*\.copy\s*\(/.test(after)) {
573
+ bulk = true;
574
+ continue;
575
+ } // whole copy is a bulk read
576
+ const before = cmd.slice(Math.max(0, em.index - 24), em.index);
577
+ if (/env\s*=\s*$/.test(before))
578
+ continue; // subprocess env inheritance
116
579
  bulk = true;
580
+ }
117
581
  return { cred, bulk };
118
582
  }
583
+ /**
584
+ * Credential value atoms: text positions where a credential shaped env
585
+ * value, or the whole environment, is read. Atoms that start inside a
586
+ * quoted data literal are marked inData: a pattern list that MENTIONS
587
+ * process.env.SECRET is not reading it. Print disclosure is the one
588
+ * analysis that still uses inData atoms, because echoing a value prints
589
+ * it whether or not it was quoted.
590
+ */
591
+ function findAtoms(text, dataRanges) {
592
+ const atoms = [];
593
+ const named = [
594
+ { re: /process\.env\.([A-Za-z_][A-Za-z0-9_]*)/g },
595
+ { re: /process\.env\[["']([A-Za-z_][A-Za-z0-9_]*)["']\]/g },
596
+ { re: /os\.environ\.get\(["']([A-Za-z_][A-Za-z0-9_]*)["']/g },
597
+ { re: /os\.environ\[["']([A-Za-z_][A-Za-z0-9_]*)["']\]/g },
598
+ { re: /getenv\(["']([A-Za-z_][A-Za-z0-9_]*)["']/g },
599
+ { re: /Deno\.env\.get\(["']([A-Za-z_][A-Za-z0-9_]*)["']/g },
600
+ { re: /import\.meta\.env\.([A-Za-z_][A-Za-z0-9_]*)/g },
601
+ { re: /\$(?:\{)?([A-Za-z_][A-Za-z0-9_]*)\}?/g },
602
+ { re: /\$\{\{\s*secrets\.([A-Za-z_][A-Za-z0-9_]*)\s*\}\}/g, always: true },
603
+ { re: /\$\{\{\s*env\.([A-Za-z_][A-Za-z0-9_]*)\s*\}\}/g },
604
+ ];
605
+ for (const { re, always } of named) {
606
+ let m;
607
+ while ((m = re.exec(text)) !== null) {
608
+ if (m[0].startsWith("${#"))
609
+ continue;
610
+ if (!always && !CRED_ENV_NAME.test(m[1]))
611
+ continue;
612
+ atoms.push({ start: m.index, end: m.index + m[0].length, kind: "cred", inData: inRanges(m.index, dataRanges) });
613
+ }
614
+ }
615
+ const bulkRes = [
616
+ /(?:JSON\.stringify|util\.inspect|Object\.(?:keys|values|entries|assign))\s*\([^)]*(?:process\.env|os\.environ)/g,
617
+ /\bprintenv\b/g,
618
+ /\b(?:str|dict|list)\s*\(\s*os\.environ\s*\)/g,
619
+ /\bos\.environ\.copy\s*\(\s*\)/g,
620
+ /\bprocess\.env(?!\s*(?:\.|\[))/g,
621
+ /(^|[;&|]\s*)env\s*($|[|>;])/gm,
622
+ ];
623
+ for (const re of bulkRes) {
624
+ let m;
625
+ while ((m = re.exec(text)) !== null) {
626
+ const rel = m[0].search(/process\.env|os\.environ|\benv\b|printenv/);
627
+ const at = m.index + (rel === -1 ? 0 : rel);
628
+ atoms.push({ start: at, end: at + 3, kind: "bulk", inData: inRanges(at, dataRanges) });
629
+ }
630
+ }
631
+ const envRe = /\bos\.environ\b/g;
632
+ let em;
633
+ while ((em = envRe.exec(text)) !== null) {
634
+ const after = text.slice(em.index + em[0].length, em.index + em[0].length + 30);
635
+ if (/^\s*(?:\.get|\[|\.setdefault|\.pop|\.update)/.test(after))
636
+ continue;
637
+ const before = text.slice(Math.max(0, em.index - 24), em.index);
638
+ if (/env\s*=\s*$/.test(before))
639
+ continue;
640
+ atoms.push({ start: em.index, end: em.index + em[0].length, kind: "bulk", inData: inRanges(em.index, dataRanges) });
641
+ }
642
+ return atoms;
643
+ }
644
+ /**
645
+ * Spans where a credential is used as authentication for the destination
646
+ * it belongs to: the userinfo of a URL (x-access-token pushes and clones
647
+ * to the repo's own host), the value of an Authorization header, and the
648
+ * value of a CLI auth flag (--token, --auth, --api-key). A token in one
649
+ * of these positions authenticates the send; it is not the payload, and
650
+ * Round 2 showed it driving false exfil verdicts on langfuse, PostHog,
651
+ * and cas release automation (P-018).
652
+ */
653
+ function authRanges(text) {
654
+ const ranges = [];
655
+ for (const m of text.matchAll(/https?:\/\/[^\s"'`]+/g)) {
656
+ const url = m[0];
657
+ const schemeEnd = url.indexOf("://") + 3;
658
+ const authority = url.slice(schemeEnd).split("/")[0];
659
+ const at = authority.lastIndexOf("@");
660
+ if (at > 0)
661
+ ranges.push({ start: m.index + schemeEnd, end: m.index + schemeEnd + at });
662
+ }
663
+ const valueAfter = (from) => {
664
+ let end = from;
665
+ while (end < text.length && end - from < 200 && !/[\s"'`,;]/.test(text[end]))
666
+ end++;
667
+ return { start: from, end };
668
+ };
669
+ for (const m of text.matchAll(/authorization\s*["']?\s*[:=]\s*["']?\s*(?:bearer|basic|token)\s+/gi)) {
670
+ ranges.push(valueAfter(m.index + m[0].length));
671
+ }
672
+ for (const m of text.matchAll(/--(?:token|auth|api[-_]?key|password|secret)(?:\s*=\s*|\s+)["']?/gi)) {
673
+ ranges.push(valueAfter(m.index + m[0].length));
674
+ }
675
+ return ranges;
676
+ }
677
+ /** Variables assigned (transitively) from a credential value expression. */
678
+ function taintedVars(text, atoms, dataRanges) {
679
+ const tainted = new Set();
680
+ const assignRe = /(?:^|[;\n,])\s*(?:export\s+|const\s+|let\s+|var\s+)?([A-Za-z_$][A-Za-z0-9_$]*)\s*=(?![=<>])\s*/g;
681
+ const assigns = [];
682
+ let m;
683
+ while ((m = assignRe.exec(text)) !== null) {
684
+ const rhsStart = m.index + m[0].length;
685
+ let rhsEnd = rhsStart;
686
+ while (rhsEnd < text.length && text[rhsEnd] !== ";" && text[rhsEnd] !== "\n")
687
+ rhsEnd++;
688
+ assigns.push({ name: m[1], rhsStart, rhsEnd });
689
+ }
690
+ const live = atoms.filter((a) => !a.inData);
691
+ for (let pass = 0; pass < 4; pass++) {
692
+ for (const a of assigns) {
693
+ if (tainted.has(a.name))
694
+ continue;
695
+ if (live.some((x) => x.start >= a.rhsStart && x.start < a.rhsEnd)) {
696
+ tainted.add(a.name);
697
+ continue;
698
+ }
699
+ const rhs = text.slice(a.rhsStart, a.rhsEnd);
700
+ for (const v of tainted) {
701
+ if (refOutsideData(rhs, v, a.rhsStart, dataRanges)) {
702
+ tainted.add(a.name);
703
+ break;
704
+ }
705
+ }
706
+ }
707
+ }
708
+ return tainted;
709
+ }
710
+ /** Balanced paren span starting at the "(" index, quote aware. */
711
+ function callSpan(text, openIdx) {
712
+ let depth = 0;
713
+ let quote = "";
714
+ for (let j = openIdx; j < text.length; j++) {
715
+ const c = text[j];
716
+ if (quote) {
717
+ if (c === "\\") {
718
+ j++;
719
+ continue;
720
+ }
721
+ if (c === quote)
722
+ quote = "";
723
+ continue;
724
+ }
725
+ if (c === "'" || c === '"' || c === "`") {
726
+ quote = c;
727
+ continue;
728
+ }
729
+ if (c === "(")
730
+ depth++;
731
+ else if (c === ")") {
732
+ depth--;
733
+ if (depth === 0)
734
+ return { start: openIdx, end: j + 1 };
735
+ }
736
+ }
737
+ return { start: openIdx, end: text.length };
738
+ }
739
+ function spanHasTaint(text, span, atoms, tainted, dataRanges) {
740
+ if (atoms.some((a) => !a.inData && a.start >= span.start && a.start < span.end))
741
+ return true;
742
+ if (tainted.size === 0)
743
+ return false;
744
+ const seg = text.slice(span.start, span.end);
745
+ for (const v of tainted) {
746
+ if (refOutsideData(seg, v, span.start, dataRanges))
747
+ return true;
748
+ }
749
+ return false;
750
+ }
751
+ /**
752
+ * Exfil value flow (Round 2, P-018). ENV-EXFIL used to fire on mere
753
+ * co-occurrence: a credential shaped name and any URL inside one finding.
754
+ * Since block scalars made a finding a whole 30 to 100 line step, and one
755
+ * hop following classifies whole referenced files, co-occurrence is the
756
+ * default state of any authenticated release step. Round 2 hand-read all
757
+ * 15 verdict drivers across langfuse, PostHog/posthog-js, attune-ai,
758
+ * cas, and flowspec: genuinely exfil shaped, zero. Every one was the
759
+ * repo's own automation using its token as authentication.
760
+ *
761
+ * ENV-EXFIL now requires the credential VALUE to flow into a send:
762
+ * - an atom or a tainted variable inside a fetch, axios, requests,
763
+ * http.get, urlopen, XHR send, or sendBeacon call span;
764
+ * - an atom or tainted variable anywhere in a curl or wget statement
765
+ * (payload, query string, or a non Authorization header), after
766
+ * authentication positions have been removed;
767
+ * - a pipeline whose earlier segments produce a credential value or a
768
+ * bulk dump and whose later segment is netcat, or curl or wget
769
+ * reading its body from stdin;
770
+ * - a credential value printed or logged while a send construct is
771
+ * present in the same text (CI logs are a real leak channel).
772
+ */
773
+ function analyzeExfil(text, views, lang) {
774
+ const dataRanges = views.dataRanges;
775
+ const auth = authRanges(text);
776
+ const atoms = findAtoms(text, dataRanges).filter((a) => !inRanges(a.start, auth));
777
+ const tainted = taintedVars(text, atoms, dataRanges);
778
+ const has = (span) => spanHasTaint(text, span, atoms, tainted, dataRanges);
779
+ const callRes = [
780
+ /\bfetch\s*\(/g,
781
+ /\baxios(?:\s*\.\s*(?:post|put|patch|get|request|delete))?\s*\(/g,
782
+ /\brequests\s*\.\s*(?:get|post|put|patch|request)\s*\(/g,
783
+ /\bhttps?\s*\.\s*(?:request|get)\s*\(/g,
784
+ /\burlopen\s*\(/g,
785
+ /\.\s*send\s*\(/g,
786
+ /\bsendBeacon\s*\(/g,
787
+ ];
788
+ for (const re of callRes) {
789
+ let m;
790
+ while ((m = re.exec(text)) !== null) {
791
+ if (has(callSpan(text, m.index + m[0].length - 1)))
792
+ return true;
793
+ }
794
+ }
795
+ const statements = splitShell(text, ["\n", ";", "&&", "||"]);
796
+ for (const stmt of statements) {
797
+ if (!/\b(?:curl|wget)\b/.test(text.slice(stmt.start, stmt.end)))
798
+ continue;
799
+ if (has(stmt))
800
+ return true;
801
+ }
802
+ for (const stmt of statements) {
803
+ const stmtText = text.slice(stmt.start, stmt.end);
804
+ const segments = splitShell(stmtText, ["|"]);
805
+ if (segments.length < 2)
806
+ continue;
807
+ for (let k = 1; k < segments.length; k++) {
808
+ const segText = stmtText.slice(segments[k].start, segments[k].end);
809
+ const word = firstShellWord(segText);
810
+ const isNc = word === "nc" || word === "netcat";
811
+ const isStdinSend = (word === "curl" || word === "wget") && /(?:-d\s+@-|--data(?:-raw|-binary)?\s+@-|-T\s+-)/.test(segText);
812
+ if (!isNc && !isStdinSend)
813
+ continue;
814
+ for (let e = 0; e < k; e++) {
815
+ const early = { start: stmt.start + segments[e].start, end: stmt.start + segments[e].end };
816
+ if (has(early))
817
+ return true;
818
+ }
819
+ }
820
+ }
821
+ // Print disclosure alongside a send construct in the same text. The
822
+ // send gate runs on the code view with data blanked: a send WORD in
823
+ // prose (a docstring saying "we fetch release metadata") is not a send
824
+ // construct. In script files a printed atom or variable only counts at
825
+ // a code position (an f-string or template interpolation hole is code;
826
+ // prose inside a plain string is not). In shell text, echo and printf
827
+ // arguments ARE the printed values, so references inside them count.
828
+ const gateText = blankRanges(views.codeText, dataRanges);
829
+ const hasSendConstruct = /\b(?:fetch|axios|requests\s*\.|urlopen|https?\s*\.\s*(?:request|get)|curl|wget|nc|netcat)\b/.test(gateText);
830
+ if (hasSendConstruct) {
831
+ for (const m of text.matchAll(/(?:^|[;\n|&]\s*)(?:echo|printf)\s+([^\n;|]*)/g)) {
832
+ const span = { start: m.index, end: m.index + m[0].length };
833
+ if (atoms.some((a) => a.start >= span.start && a.start < span.end))
834
+ return true;
835
+ const argText = m[1] ?? "";
836
+ const argStart = m.index + m[0].length - argText.length;
837
+ for (const v of tainted) {
838
+ if (lang === "shell" || refOutsideData(argText, v, argStart, dataRanges))
839
+ return true;
840
+ }
841
+ }
842
+ for (const m of text.matchAll(/\b(?:console\.(?:log|info|debug)|print)\s*\(([^)]*)\)/g)) {
843
+ const span = { start: m.index, end: m.index + m[0].length };
844
+ if (atoms.some((a) => a.start >= span.start && a.start < span.end && (lang === "shell" || !a.inData)))
845
+ return true;
846
+ const argText = m[1] ?? "";
847
+ const argStart = m.index + m[0].indexOf(argText);
848
+ for (const v of tainted) {
849
+ if (lang === "shell" || refOutsideData(argText, v, argStart, dataRanges))
850
+ return true;
851
+ }
852
+ }
853
+ }
854
+ return false;
855
+ }
119
856
  function isLoopbackHost(host) {
120
857
  const h = host.toLowerCase();
121
858
  return h === "localhost" || h === "127.0.0.1" || h === "0.0.0.0" || h === "::1" || h === "[::1]";
@@ -129,15 +866,28 @@ const SAFE_PATTERNS = [
129
866
  /^patch-package$/i,
130
867
  /^simple-git-hooks$/i,
131
868
  ];
132
- export function classifyCommand(command) {
869
+ export function classifyCommand(command, lang = "shell") {
133
870
  // Strip comments first (a docs URL in a comment is not a fetch), then
134
871
  // normalize shell line continuations and stray carriage returns, so a
135
872
  // payload split as `curl ... \` + newline + `| sh` classifies the same as
136
- // the one line form.
137
- const cmd = stripComments(command).replace(/\\\r?\n/g, " ").replace(/\r/g, " ").replace(/\n/g, " ").trim();
873
+ // the one line form. GitHub ${{ }} expressions are whitespace collapsed
874
+ // so URLs containing them parse as one token. Newlines are kept: the
875
+ // exfil value flow analysis works statement by statement.
876
+ const cmd = stripComments(command)
877
+ .replace(/\\\r?\n/g, " ")
878
+ .replace(/\r/g, " ")
879
+ .replace(/\$\{\{([\s\S]*?)\}\}/g, (_m, inner) => "${{" + inner.replace(/\s+/g, "") + "}}")
880
+ .trim();
881
+ // Behavior rules match the code view (P-019): quoted pattern text in a
882
+ // script file is data, not behavior. REMOTE-URL is the exception: a URL
883
+ // is a reference wherever it appears, so it matches the full text.
884
+ const views = buildViews(cmd, lang);
885
+ const joined = cmd.replace(/\n/g, " ");
886
+ const codeJoined = views.codeText.replace(/\n/g, " ");
138
887
  const fired = [];
139
888
  for (const rule of RISKY_RULES) {
140
- if (rule.pattern.test(cmd))
889
+ const hay = rule.id === "REMOTE-URL" ? joined : codeJoined;
890
+ if (rule.pattern.test(hay))
141
891
  fired.push({ id: rule.id, evidence: rule.evidence });
142
892
  }
143
893
  // Loopback downgrade (P-004): a fetch whose every URL is loopback is a
@@ -150,20 +900,31 @@ export function classifyCommand(command) {
150
900
  fired.splice(i, 1);
151
901
  }
152
902
  }
153
- // Credential store access, content and path based (P-004).
154
- if (detectCredAccess(cmd)) {
903
+ // Credential store access, content and path based (P-004), matched on
904
+ // the credential view so paths quoted as data in a script file (a
905
+ // guard's blocklist) do not count, while paths handed to file access
906
+ // calls do.
907
+ const credAccess = detectCredAccess(views.credText);
908
+ if (credAccess) {
155
909
  fired.push({ id: "CRED-ACCESS", evidence: "touches a credential store path or credential file" });
156
910
  }
157
- // Env access, credential shaped names or bulk only (P-005).
158
- const env = detectEnvAccess(cmd);
911
+ // Env access, credential shaped names or bulk only (P-005, P-020).
912
+ const env = detectEnvAccess(cmd, views.dataRanges);
159
913
  if (env.cred) {
160
914
  fired.push({ id: "ENV-ACCESS", evidence: "reads an environment variable whose name is credential shaped (token, secret, password, api key)" });
161
915
  }
162
916
  if (env.bulk) {
163
917
  fired.push({ id: "ENV-BULK", evidence: "accesses the whole environment at once (serialization, printenv, or env dump)" });
164
918
  }
165
- if ((env.cred || env.bulk) && (fired.some((f) => f.id === "NET-FETCH" || f.id === "REMOTE-URL") || /\b(nc|netcat)\b/.test(cmd))) {
166
- fired.push({ id: "ENV-EXFIL", evidence: "combines credential shaped or bulk environment access with an outbound send pattern" });
919
+ // Exfil is a value flow, not a co-occurrence (P-018): the credential
920
+ // value must reach a send. Token as authentication does not count.
921
+ if (analyzeExfil(cmd, views, lang)) {
922
+ fired.push({ id: "ENV-EXFIL", evidence: "a credential shaped or bulk environment value flows into an outbound send (payload, query string, printed output next to a send, or a pipe into a network tool); a token used as authentication does not count" });
923
+ }
924
+ if (credAccess && (/\|\s*(?:nc|netcat)\b/.test(joined) ||
925
+ /\|\s*(?:curl|wget)\b[^|\n]*?(?:-d\s+@-|--data(?:-raw|-binary)?\s+@-)/.test(joined) ||
926
+ /(?:-d|--data(?:-raw|-binary)?)\s+@[^\s"']*(?:\.ssh|\.aws|\.gnupg|id_rsa|credentials)/.test(joined))) {
927
+ fired.push({ id: "CRED-EXFIL", evidence: "credential store contents are piped or posted straight into an outbound network send" });
167
928
  }
168
929
  if (fired.length > 0) {
169
930
  return {
@@ -221,7 +982,7 @@ function escalateWithLocalFile(root, finding) {
221
982
  catch {
222
983
  continue;
223
984
  }
224
- const inner = classifyCommand(content);
985
+ const inner = classifyCommand(content, langForFile(token));
225
986
  if (inner.classification === "RISKY") {
226
987
  const rules = Array.from(new Set([...out.rules, ...inner.rules]));
227
988
  out = {
@@ -272,13 +1033,22 @@ function escalateWithScriptChain(root, scripts, currentName, finding) {
272
1033
  return out;
273
1034
  }
274
1035
  /**
275
- * Verdict calibration (Round 1, P-003 and P-004). COMPROMISED-PATTERN
276
- * requires strong evidence inside a single finding:
1036
+ * Verdict calibration (Round 1, P-003 and P-004; Round 2, P-018).
1037
+ * COMPROMISED-PATTERN requires strong evidence inside a single finding:
277
1038
  * (a) a severe rule: fetched or decoded content executed directly as
278
1039
  * code (curl piped to an interpreter, base64 decode piped to an
279
- * interpreter, eval of fetched content), or
280
- * (b) credential evidence (credential store access, a credential shaped
281
- * env name, or bulk env access) combined with network behavior.
1040
+ * interpreter, eval of fetched content), or a verified exfil flow
1041
+ * (ENV-EXFIL, CRED-EXFIL), or
1042
+ * (b) credential STORE access (CRED-ACCESS: actual credential files
1043
+ * and stores) combined with network behavior.
1044
+ * Round 2 narrowed path (b): in v0.1.2 and v0.1.3 any credential evidence
1045
+ * counted, including a credential shaped env NAME next to a URL. On real
1046
+ * estates that is the normal shape of a repo's own authenticated
1047
+ * automation (a release step reading its token and pushing to its own
1048
+ * repo), and it drove every one of the 15 false COMPROMISED drivers
1049
+ * hand-read in Round 2. Env references plus network behavior are RISKY
1050
+ * and verdict REVIEW; the verdict now needs the value to actually move
1051
+ * (path a) or an actual credential store to be touched (path b).
282
1052
  * Weak signals alone (a remote URL, a localhost fetch, an ordinary env
283
1053
  * read, a download by itself) classify RISKY at most, and a RISKY-only
284
1054
  * estate verdicts REVIEW, never COMPROMISED-PATTERN.
@@ -289,9 +1059,9 @@ export function computeVerdict(findings) {
289
1059
  if (f.classification === "RISKY") {
290
1060
  const metas = f.rules.map(ruleMeta);
291
1061
  const hasSevere = metas.some((x) => x.severe);
292
- const hasCred = metas.some((x) => x.credential);
1062
+ const hasCredStore = f.rules.includes("CRED-ACCESS");
293
1063
  const hasNet = metas.some((x) => x.remote);
294
- if (hasSevere || (hasCred && hasNet))
1064
+ if (hasSevere || (hasCredStore && hasNet))
295
1065
  return "COMPROMISED-PATTERN";
296
1066
  review = true;
297
1067
  }
@@ -723,6 +1493,13 @@ function scanWorkflows(root, findings) {
723
1493
  function scanGitHooks(root, findings) {
724
1494
  scanDirFiles(root, ".githooks", "githooks", findings);
725
1495
  }
1496
+ /**
1497
+ * Empty estate note (P-014, hookcheck half). A scan that inventories
1498
+ * nothing must say so next to the verdict, in JSON as well as in the
1499
+ * human output: CLEAN with total 0 is vacuously true and otherwise reads
1500
+ * as an all-clear.
1501
+ */
1502
+ export const EMPTY_ESTATE_NOTE = "Nothing was found to audit: no auto-exec hooks were inventoried under this path, so the verdict covers an empty estate. This can mean a hook-free repo, an empty directory, or a path that is not a repo root.";
726
1503
  export function scanHooks(root) {
727
1504
  const findings = [];
728
1505
  const warnings = [];
@@ -739,6 +1516,7 @@ export function scanHooks(root) {
739
1516
  path: root, verdict: "REVIEW", findings, warnings,
740
1517
  counts: { total: 0, safe: 0, risky: 0, unknown: 0 },
741
1518
  scannedAt: new Date().toISOString(),
1519
+ note: EMPTY_ESTATE_NOTE,
742
1520
  };
743
1521
  }
744
1522
  if (!rootStat.isDirectory()) {
@@ -747,6 +1525,7 @@ export function scanHooks(root) {
747
1525
  path: root, verdict: "REVIEW", findings, warnings,
748
1526
  counts: { total: 0, safe: 0, risky: 0, unknown: 0 },
749
1527
  scannedAt: new Date().toISOString(),
1528
+ note: EMPTY_ESTATE_NOTE,
750
1529
  };
751
1530
  }
752
1531
  scanPackageJson(root, findings, warnings);
@@ -782,6 +1561,7 @@ export function scanHooks(root) {
782
1561
  counts,
783
1562
  warnings,
784
1563
  scannedAt: new Date().toISOString(),
1564
+ ...(counts.total === 0 ? { note: EMPTY_ESTATE_NOTE } : {}),
785
1565
  };
786
1566
  }
787
1567
  export function explainHook(root, idOrFile) {
@@ -807,6 +1587,8 @@ export function renderReport(report) {
807
1587
  const lines = [];
808
1588
  lines.push(`hookcheck ${report.path}: ${report.verdict}`);
809
1589
  lines.push(` ${report.counts.total} hook(s): ${report.counts.safe} SAFE, ${report.counts.risky} RISKY, ${report.counts.unknown} UNKNOWN`);
1590
+ if (report.note)
1591
+ lines.push(` note: ${report.note}`);
810
1592
  for (const f of report.findings) {
811
1593
  lines.push(` [${f.classification}] ${f.id} (${f.source}${f.line !== undefined ? `, line ${f.line}` : ""})`);
812
1594
  lines.push(` command: ${f.command.slice(0, 200)}`);
@@ -821,7 +1603,7 @@ export function renderReport(report) {
821
1603
  else if (report.verdict === "REVIEW")
822
1604
  lines.push("REVIEW: at least one hook needs manual review.");
823
1605
  else
824
- lines.push("COMPROMISED-PATTERN: at least one risky hook shows strong evidence: fetched or decoded content executed as code, or credential access combined with network behavior. Treat as a Shai-Hulud style persistence pattern until proven otherwise.");
1606
+ lines.push("COMPROMISED-PATTERN: at least one risky hook shows strong evidence: fetched or decoded content executed as code, credential values flowing into an outbound send, or credential store access combined with network behavior. Treat as a Shai-Hulud style persistence pattern until proven otherwise.");
825
1607
  return lines.join("\n");
826
1608
  }
827
1609
  export { relative };