@node9/proxy 2.8.5 → 2.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +3766 -1865
- package/dist/cli.mjs +3702 -1801
- package/dist/dashboard.mjs +854 -165
- package/dist/index.js +968 -113
- package/dist/index.mjs +964 -109
- package/dist/scan-ink.mjs +213 -99
- package/package.json +1 -1
package/dist/index.mjs
CHANGED
|
@@ -133,13 +133,21 @@ function filePathFromArgs(args) {
|
|
|
133
133
|
return void 0;
|
|
134
134
|
}
|
|
135
135
|
function appendLocalAudit(toolName, args, decision, checkedBy, meta, auditHashArgsEnabled) {
|
|
136
|
-
const isDlpRow = checkedBy.toLowerCase().includes("dlp") || Boolean(meta?.dlpPattern) || /dlp|taint/i.test(String(meta?.ruleName ?? ""));
|
|
136
|
+
const isDlpRow = checkedBy.toLowerCase().includes("dlp") || checkedBy.toLowerCase().includes("pii") || Boolean(meta?.dlpPattern) || Boolean(meta?.piiPatterns) || Boolean(meta?.canaryId) || /dlp|taint/i.test(String(meta?.ruleName ?? ""));
|
|
137
137
|
const preview = auditHashArgsEnabled && !isDlpRow ? buildArgsPreview(args) : void 0;
|
|
138
138
|
const argsField = auditHashArgsEnabled ? { argsHash: hashArgs(args), ...preview ? { argsPreview: preview } : {} } : { args: args ? JSON.parse(redactSecrets(JSON.stringify(args))) : {} };
|
|
139
139
|
const testRun = isTestCall(toolName, args) || process.env.NODE9_TESTING === "1" ? { testRun: true } : {};
|
|
140
140
|
const ruleNameField = meta?.ruleName ? { ruleName: meta.ruleName } : {};
|
|
141
141
|
const agentToolNameField = meta?.agentToolName ? { agentToolName: meta.agentToolName } : {};
|
|
142
142
|
const dlpFields = meta?.dlpPattern ? { dlpPattern: meta.dlpPattern, dlpSample: meta.dlpSample } : {};
|
|
143
|
+
const canaryFields = meta?.canaryId ? {
|
|
144
|
+
canaryId: meta.canaryId,
|
|
145
|
+
...meta.canaryHash && { canaryHash: meta.canaryHash },
|
|
146
|
+
...meta.canaryKind && { canaryKind: meta.canaryKind },
|
|
147
|
+
...meta.canaryPath && { canaryPath: meta.canaryPath },
|
|
148
|
+
...meta.canaryView && { canaryView: meta.canaryView },
|
|
149
|
+
...meta.canaryRetired && { canaryRetired: true }
|
|
150
|
+
} : {};
|
|
143
151
|
const cloudLinkField = meta?.cloudRequestId ? { cloudRequestId: meta.cloudRequestId } : {};
|
|
144
152
|
const workingDirField = meta?.workingDir ? { workingDir: meta.workingDir } : {};
|
|
145
153
|
const shell = process.env.SHELL ? path.basename(process.env.SHELL) : void 0;
|
|
@@ -165,6 +173,7 @@ function appendLocalAudit(toolName, args, decision, checkedBy, meta, auditHashAr
|
|
|
165
173
|
checkedBy,
|
|
166
174
|
...ruleNameField,
|
|
167
175
|
...dlpFields,
|
|
176
|
+
...canaryFields,
|
|
168
177
|
...cloudLinkField,
|
|
169
178
|
...workingDirField,
|
|
170
179
|
...shellTypeField,
|
|
@@ -342,7 +351,11 @@ var ConfigFileSchema = z.object({
|
|
|
342
351
|
mode: z.enum(["off", "review", "block"]).optional(),
|
|
343
352
|
allow: z.array(z.string()).optional(),
|
|
344
353
|
deny: z.array(z.string()).optional(),
|
|
345
|
-
allowPrivate: z.boolean().optional()
|
|
354
|
+
allowPrivate: z.boolean().optional(),
|
|
355
|
+
// SSRF floor. `ssrfAllow` exempts OVERRIDABLE tiers only; a tier-1
|
|
356
|
+
// entry is dropped with a warning at load, never silently honoured.
|
|
357
|
+
ssrfAllow: z.array(z.string()).optional(),
|
|
358
|
+
ssrfStrict: z.boolean().optional()
|
|
346
359
|
}).optional(),
|
|
347
360
|
loopDetection: z.object({
|
|
348
361
|
enabled: z.boolean().optional(),
|
|
@@ -379,8 +392,8 @@ function sanitizeConfig(raw) {
|
|
|
379
392
|
}
|
|
380
393
|
}
|
|
381
394
|
const lines = result.error.issues.map((issue) => {
|
|
382
|
-
const
|
|
383
|
-
return ` \u2022 ${
|
|
395
|
+
const path15 = issue.path.length > 0 ? issue.path.join(".") : "root";
|
|
396
|
+
return ` \u2022 ${path15}: ${issue.message}`;
|
|
384
397
|
});
|
|
385
398
|
return {
|
|
386
399
|
sanitized,
|
|
@@ -396,11 +409,178 @@ import os2 from "os";
|
|
|
396
409
|
|
|
397
410
|
// packages/policy-engine/dist/index.mjs
|
|
398
411
|
import safeRegex from "safe-regex2";
|
|
412
|
+
import { createHash as createHash2 } from "crypto";
|
|
399
413
|
import mvdanSh from "mvdan-sh";
|
|
400
414
|
import pm from "picomatch";
|
|
401
415
|
import safeRegex2 from "safe-regex2";
|
|
402
416
|
import safeRegex3 from "safe-regex2";
|
|
403
417
|
import crypto2 from "crypto";
|
|
418
|
+
function validateLuhn(digits) {
|
|
419
|
+
if (!/^\d+$/.test(digits)) return false;
|
|
420
|
+
if (digits.length < 12) return false;
|
|
421
|
+
if (!/[1-9]/.test(digits)) return false;
|
|
422
|
+
let sum = 0;
|
|
423
|
+
let double = false;
|
|
424
|
+
for (let i = digits.length - 1; i >= 0; i--) {
|
|
425
|
+
let d = digits.charCodeAt(i) - 48;
|
|
426
|
+
if (double) {
|
|
427
|
+
d *= 2;
|
|
428
|
+
if (d > 9) d -= 9;
|
|
429
|
+
}
|
|
430
|
+
sum += d;
|
|
431
|
+
double = !double;
|
|
432
|
+
}
|
|
433
|
+
return sum % 10 === 0;
|
|
434
|
+
}
|
|
435
|
+
var IBAN_LENGTH = {
|
|
436
|
+
AD: 24,
|
|
437
|
+
AE: 23,
|
|
438
|
+
AL: 28,
|
|
439
|
+
AT: 20,
|
|
440
|
+
AZ: 28,
|
|
441
|
+
BA: 20,
|
|
442
|
+
BE: 16,
|
|
443
|
+
BG: 22,
|
|
444
|
+
BH: 22,
|
|
445
|
+
BI: 27,
|
|
446
|
+
BR: 29,
|
|
447
|
+
BY: 28,
|
|
448
|
+
CH: 21,
|
|
449
|
+
CR: 22,
|
|
450
|
+
CY: 28,
|
|
451
|
+
CZ: 24,
|
|
452
|
+
DE: 22,
|
|
453
|
+
DJ: 27,
|
|
454
|
+
DK: 18,
|
|
455
|
+
DO: 28,
|
|
456
|
+
EE: 20,
|
|
457
|
+
EG: 29,
|
|
458
|
+
ES: 24,
|
|
459
|
+
FI: 18,
|
|
460
|
+
FK: 18,
|
|
461
|
+
FO: 18,
|
|
462
|
+
FR: 27,
|
|
463
|
+
GB: 22,
|
|
464
|
+
GE: 22,
|
|
465
|
+
GI: 23,
|
|
466
|
+
GL: 18,
|
|
467
|
+
GR: 27,
|
|
468
|
+
GT: 28,
|
|
469
|
+
HN: 28,
|
|
470
|
+
HR: 21,
|
|
471
|
+
HU: 28,
|
|
472
|
+
IE: 22,
|
|
473
|
+
IL: 23,
|
|
474
|
+
IQ: 23,
|
|
475
|
+
IS: 26,
|
|
476
|
+
IT: 27,
|
|
477
|
+
JO: 30,
|
|
478
|
+
KW: 30,
|
|
479
|
+
KZ: 20,
|
|
480
|
+
LB: 28,
|
|
481
|
+
LC: 32,
|
|
482
|
+
LI: 21,
|
|
483
|
+
LT: 20,
|
|
484
|
+
LU: 20,
|
|
485
|
+
LV: 21,
|
|
486
|
+
LY: 25,
|
|
487
|
+
MC: 27,
|
|
488
|
+
MD: 24,
|
|
489
|
+
ME: 22,
|
|
490
|
+
MK: 19,
|
|
491
|
+
MN: 20,
|
|
492
|
+
MR: 27,
|
|
493
|
+
MT: 31,
|
|
494
|
+
MU: 30,
|
|
495
|
+
NI: 28,
|
|
496
|
+
NL: 18,
|
|
497
|
+
NO: 15,
|
|
498
|
+
OM: 23,
|
|
499
|
+
PK: 24,
|
|
500
|
+
PL: 28,
|
|
501
|
+
PS: 29,
|
|
502
|
+
PT: 25,
|
|
503
|
+
QA: 29,
|
|
504
|
+
RO: 24,
|
|
505
|
+
RS: 22,
|
|
506
|
+
RU: 33,
|
|
507
|
+
SA: 24,
|
|
508
|
+
SC: 31,
|
|
509
|
+
SD: 18,
|
|
510
|
+
SE: 24,
|
|
511
|
+
SI: 19,
|
|
512
|
+
SK: 24,
|
|
513
|
+
SM: 27,
|
|
514
|
+
SN: 28,
|
|
515
|
+
SO: 23,
|
|
516
|
+
ST: 25,
|
|
517
|
+
SV: 28,
|
|
518
|
+
TL: 23,
|
|
519
|
+
TN: 24,
|
|
520
|
+
TR: 26,
|
|
521
|
+
UA: 29,
|
|
522
|
+
VA: 22,
|
|
523
|
+
VG: 24,
|
|
524
|
+
XK: 20,
|
|
525
|
+
YE: 30
|
|
526
|
+
};
|
|
527
|
+
function validateIban(raw) {
|
|
528
|
+
const s = raw.replace(/[ -]/g, "").toUpperCase();
|
|
529
|
+
if (!/^[A-Z]{2}\d{2}/.test(s)) return false;
|
|
530
|
+
const want = IBAN_LENGTH[s.slice(0, 2)];
|
|
531
|
+
if (want === void 0 || s.length < want) return false;
|
|
532
|
+
const iban = s.slice(0, want);
|
|
533
|
+
if (!/^[A-Z0-9]+$/.test(iban)) return false;
|
|
534
|
+
const rearranged = iban.slice(4) + iban.slice(0, 4);
|
|
535
|
+
let mod = 0;
|
|
536
|
+
for (const ch of rearranged) {
|
|
537
|
+
const code = ch.charCodeAt(0);
|
|
538
|
+
const digits = code >= 65 ? String(code - 55) : ch;
|
|
539
|
+
for (const d of digits) mod = (mod * 10 + (d.charCodeAt(0) - 48)) % 97;
|
|
540
|
+
}
|
|
541
|
+
return mod === 1;
|
|
542
|
+
}
|
|
543
|
+
var B58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
|
|
544
|
+
var B58_INDEX = Object.fromEntries(
|
|
545
|
+
[...B58].map((c, i) => [c, i])
|
|
546
|
+
);
|
|
547
|
+
function validateBase58Check(s) {
|
|
548
|
+
if (!s) return null;
|
|
549
|
+
let n = 0n;
|
|
550
|
+
for (const c of s) {
|
|
551
|
+
const v = B58_INDEX[c];
|
|
552
|
+
if (v === void 0) return null;
|
|
553
|
+
n = n * 58n + BigInt(v);
|
|
554
|
+
}
|
|
555
|
+
let hex = n.toString(16);
|
|
556
|
+
if (hex.length % 2) hex = "0" + hex;
|
|
557
|
+
let zeros = 0;
|
|
558
|
+
for (const c of s) {
|
|
559
|
+
if (c !== "1") break;
|
|
560
|
+
zeros++;
|
|
561
|
+
}
|
|
562
|
+
const bytes = Buffer.concat([
|
|
563
|
+
Buffer.alloc(zeros),
|
|
564
|
+
n === 0n ? Buffer.alloc(0) : Buffer.from(hex, "hex")
|
|
565
|
+
]);
|
|
566
|
+
if (bytes.length < 5) return null;
|
|
567
|
+
const body = bytes.subarray(0, bytes.length - 4);
|
|
568
|
+
const check = bytes.subarray(bytes.length - 4);
|
|
569
|
+
const h = createHash2("sha256").update(createHash2("sha256").update(body).digest()).digest();
|
|
570
|
+
return h.subarray(0, 4).equals(check) ? body : null;
|
|
571
|
+
}
|
|
572
|
+
function validateWif(s) {
|
|
573
|
+
const p = validateBase58Check(s);
|
|
574
|
+
if (!p || p[0] !== 128) return false;
|
|
575
|
+
return p.length === 33 || p.length === 34 && p[33] === 1;
|
|
576
|
+
}
|
|
577
|
+
var XPRV_VERSIONS = /* @__PURE__ */ new Set([76066276, 77428856, 78791436]);
|
|
578
|
+
function validateXprv(s) {
|
|
579
|
+
const p = validateBase58Check(s);
|
|
580
|
+
if (!p || p.length !== 78) return false;
|
|
581
|
+
const version = p.readUInt32BE(0);
|
|
582
|
+
return XPRV_VERSIONS.has(version) && p[45] === 0;
|
|
583
|
+
}
|
|
404
584
|
var ASSIGNMENT_CONTEXT_RE = /\b(?:password|passwd|secret|token|api[_-]?key|auth(?:_key|_token)?|credential|private[_-]?key|access[_-]?key|client[_-]?secret)\s*[=:]\s*/i;
|
|
405
585
|
function isAssignmentContext(text) {
|
|
406
586
|
return ASSIGNMENT_CONTEXT_RE.test(text);
|
|
@@ -593,6 +773,27 @@ var DLP_PATTERNS = [
|
|
|
593
773
|
severity: "block",
|
|
594
774
|
keywords: ["sg."]
|
|
595
775
|
},
|
|
776
|
+
// ── Cryptocurrency private keys (base58check-validated) ───────────────────
|
|
777
|
+
// Both are anchored with \b on each side: unanchored, `[KL][base58]{51}`
|
|
778
|
+
// matches INSIDE any longer base58 blob (an xprv, a Solana keypair, a
|
|
779
|
+
// Monero address). Lookbehind fails safe-regex2; \b is the house style
|
|
780
|
+
// (see the card regexes). Mainnet only, matching validateWif / validateXprv;
|
|
781
|
+
// testnet (WIF 0xEF, tprv) is deferred. Cost was measured: the WIF regex
|
|
782
|
+
// runs on every string (first keyword-less pattern) at 0.024 ms per 100 KB
|
|
783
|
+
// of prose, so no prefilter is warranted.
|
|
784
|
+
{
|
|
785
|
+
name: "Bitcoin WIF Private Key",
|
|
786
|
+
regex: /\b(?:5[1-9A-HJ-NP-Za-km-z]{50}|[KL][1-9A-HJ-NP-Za-km-z]{51})\b/,
|
|
787
|
+
severity: "block",
|
|
788
|
+
validate: validateWif
|
|
789
|
+
},
|
|
790
|
+
{
|
|
791
|
+
name: "Extended Private Key",
|
|
792
|
+
regex: /\b[xyz]prv[1-9A-HJ-NP-Za-km-z]{107}\b/,
|
|
793
|
+
severity: "block",
|
|
794
|
+
keywords: ["xprv", "yprv", "zprv"],
|
|
795
|
+
validate: validateXprv
|
|
796
|
+
},
|
|
596
797
|
// ── Private keys (PEM) ────────────────────────────────────────────────────
|
|
597
798
|
{
|
|
598
799
|
name: "Private Key (PEM)",
|
|
@@ -949,6 +1150,33 @@ function maskSecret(raw, pattern) {
|
|
|
949
1150
|
var MAX_DEPTH = 5;
|
|
950
1151
|
var MAX_STRING_BYTES = 1e5;
|
|
951
1152
|
var MAX_JSON_PARSE_BYTES = 1e4;
|
|
1153
|
+
function suppressed(pattern, raw) {
|
|
1154
|
+
if (pattern.validate) {
|
|
1155
|
+
let ok;
|
|
1156
|
+
try {
|
|
1157
|
+
ok = pattern.validate(raw);
|
|
1158
|
+
} catch {
|
|
1159
|
+
ok = true;
|
|
1160
|
+
}
|
|
1161
|
+
return !ok;
|
|
1162
|
+
}
|
|
1163
|
+
if (DLP_STOPWORDS.some((sw) => raw.toLowerCase().includes(sw))) return true;
|
|
1164
|
+
if (pattern.minEntropy !== void 0 && shannonEntropy(raw) < pattern.minEntropy) return true;
|
|
1165
|
+
return false;
|
|
1166
|
+
}
|
|
1167
|
+
function firstAcceptedMatch(pattern, text) {
|
|
1168
|
+
const flags = pattern.regex.flags.includes("g") ? pattern.regex.flags : pattern.regex.flags + "g";
|
|
1169
|
+
const re = new RegExp(pattern.regex.source, flags);
|
|
1170
|
+
let m;
|
|
1171
|
+
while ((m = re.exec(text)) !== null) {
|
|
1172
|
+
if (m[0].length === 0) {
|
|
1173
|
+
re.lastIndex = m.index + 1;
|
|
1174
|
+
continue;
|
|
1175
|
+
}
|
|
1176
|
+
if (!suppressed(pattern, m[0])) return m[0];
|
|
1177
|
+
}
|
|
1178
|
+
return null;
|
|
1179
|
+
}
|
|
952
1180
|
function scanArgs(args, depth = 0, fieldPath = "args") {
|
|
953
1181
|
if (depth > MAX_DEPTH || args === null || args === void 0) return null;
|
|
954
1182
|
if (Array.isArray(args)) {
|
|
@@ -973,18 +1201,16 @@ function scanArgs(args, depth = 0, fieldPath = "args") {
|
|
|
973
1201
|
if (pattern.keywords && !pattern.keywords.some((kw) => textLower.includes(kw.toLowerCase()))) {
|
|
974
1202
|
continue;
|
|
975
1203
|
}
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
};
|
|
987
|
-
}
|
|
1204
|
+
const raw = firstAcceptedMatch(pattern, text);
|
|
1205
|
+
if (raw === null) continue;
|
|
1206
|
+
const severity = pattern.contextBoost && assignmentCtx ? "block" : pattern.severity;
|
|
1207
|
+
return {
|
|
1208
|
+
patternName: pattern.name,
|
|
1209
|
+
fieldPath,
|
|
1210
|
+
// Mask the ACCEPTED token, not the first regex hit in the field.
|
|
1211
|
+
redactedSample: maskSecret(raw, pattern.regex),
|
|
1212
|
+
severity
|
|
1213
|
+
};
|
|
988
1214
|
}
|
|
989
1215
|
if (text.length < MAX_JSON_PARSE_BYTES) {
|
|
990
1216
|
const trimmed = text.trim();
|
|
@@ -1048,13 +1274,13 @@ function matchesPattern(text, patterns) {
|
|
|
1048
1274
|
return isMatch(withoutDotSlash) || isMatch(`./${withoutDotSlash}`);
|
|
1049
1275
|
}
|
|
1050
1276
|
var FORBIDDEN_PATH_SEGMENTS = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype"]);
|
|
1051
|
-
function getNestedValue(obj,
|
|
1277
|
+
function getNestedValue(obj, path15) {
|
|
1052
1278
|
if (!obj || typeof obj !== "object") return null;
|
|
1053
|
-
const
|
|
1054
|
-
for (const seg of
|
|
1279
|
+
const segments2 = path15.split(".");
|
|
1280
|
+
for (const seg of segments2) {
|
|
1055
1281
|
if (FORBIDDEN_PATH_SEGMENTS.has(seg)) return null;
|
|
1056
1282
|
}
|
|
1057
|
-
return
|
|
1283
|
+
return segments2.reduce((prev, curr) => prev?.[curr], obj);
|
|
1058
1284
|
}
|
|
1059
1285
|
function evaluateSmartConditions(args, rule) {
|
|
1060
1286
|
if (!rule.conditions || rule.conditions.length === 0) return true;
|
|
@@ -2022,6 +2248,50 @@ function extractShellDestinations(command) {
|
|
|
2022
2248
|
}
|
|
2023
2249
|
return out;
|
|
2024
2250
|
}
|
|
2251
|
+
function extractShellDestTokens(command) {
|
|
2252
|
+
const f = parseShared(command);
|
|
2253
|
+
if (f === PARSE_FAIL) return [];
|
|
2254
|
+
const out = [];
|
|
2255
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2256
|
+
try {
|
|
2257
|
+
syntax.Walk(f, (node) => {
|
|
2258
|
+
if (!node) return false;
|
|
2259
|
+
const n = node;
|
|
2260
|
+
if (syntax.NodeType(n) !== "CallExpr") return true;
|
|
2261
|
+
const callArgs = n.Args || [];
|
|
2262
|
+
if (callArgs.length === 0) return true;
|
|
2263
|
+
const name = (resolveWordLiteral(callArgs[0]) || "").toLowerCase();
|
|
2264
|
+
if (!NET_BINARIES.has(name)) return true;
|
|
2265
|
+
const rest = callArgs.slice(1).map((a) => resolveWordLiteral(a));
|
|
2266
|
+
for (const raw of destTokensForBinary(name, rest)) {
|
|
2267
|
+
if (!raw) continue;
|
|
2268
|
+
let tok = raw.trim();
|
|
2269
|
+
const scheme = /^[a-z][a-z0-9+.-]*:\/\//i.exec(tok);
|
|
2270
|
+
const hasScheme = scheme !== null;
|
|
2271
|
+
if (hasScheme) tok = tok.slice(scheme[0].length);
|
|
2272
|
+
tok = tok.split(/[/?#]/)[0];
|
|
2273
|
+
const at = tok.lastIndexOf("@");
|
|
2274
|
+
if (at >= 0) tok = tok.slice(at + 1);
|
|
2275
|
+
if (tok.startsWith("[")) {
|
|
2276
|
+
const close = tok.indexOf("]");
|
|
2277
|
+
if (close > 0) tok = tok.slice(0, close + 1);
|
|
2278
|
+
} else {
|
|
2279
|
+
tok = tok.split(":")[0];
|
|
2280
|
+
}
|
|
2281
|
+
if (!tok) continue;
|
|
2282
|
+
if (!hasScheme && /^\d+$/.test(tok) && Number(tok) < 16777216) continue;
|
|
2283
|
+
const key = `${name}:${tok}`;
|
|
2284
|
+
if (seen.has(key)) continue;
|
|
2285
|
+
seen.add(key);
|
|
2286
|
+
out.push({ token: tok, binary: name });
|
|
2287
|
+
}
|
|
2288
|
+
return true;
|
|
2289
|
+
});
|
|
2290
|
+
} catch {
|
|
2291
|
+
return out;
|
|
2292
|
+
}
|
|
2293
|
+
return out;
|
|
2294
|
+
}
|
|
2025
2295
|
var FS_OP_CACHE_MAX = 5e3;
|
|
2026
2296
|
var fsOpCache = /* @__PURE__ */ new Map();
|
|
2027
2297
|
function analyzeFsOperation(command) {
|
|
@@ -2226,8 +2496,8 @@ function analyzeShellCommand(command) {
|
|
|
2226
2496
|
if (allTokens.length === 0) {
|
|
2227
2497
|
const normalized = command.replace(/\\(.)/g, "$1");
|
|
2228
2498
|
const sanitized = normalized.replace(/["'<>]/g, " ");
|
|
2229
|
-
const
|
|
2230
|
-
|
|
2499
|
+
const segments2 = sanitized.split(/[|;&]|\$\(|\)|`/);
|
|
2500
|
+
segments2.forEach((segment) => {
|
|
2231
2501
|
const tokens = segment.trim().split(/\s+/).filter(Boolean);
|
|
2232
2502
|
if (tokens.length > 0) {
|
|
2233
2503
|
const action = tokens[0].toLowerCase();
|
|
@@ -2390,7 +2660,7 @@ function isSensitivePath(p) {
|
|
|
2390
2660
|
return SENSITIVE_PATTERNS.some((re) => re.test(p));
|
|
2391
2661
|
}
|
|
2392
2662
|
function splitOnPipe(cmd) {
|
|
2393
|
-
const
|
|
2663
|
+
const segments2 = [];
|
|
2394
2664
|
let current = "";
|
|
2395
2665
|
let inSingle = false;
|
|
2396
2666
|
let inDouble = false;
|
|
@@ -2403,21 +2673,21 @@ function splitOnPipe(cmd) {
|
|
|
2403
2673
|
inDouble = !inDouble;
|
|
2404
2674
|
current += ch;
|
|
2405
2675
|
} else if (ch === "|" && !inSingle && !inDouble && cmd[i + 1] !== "|" && (i === 0 || cmd[i - 1] !== "|")) {
|
|
2406
|
-
|
|
2676
|
+
segments2.push(current.trim());
|
|
2407
2677
|
current = "";
|
|
2408
2678
|
} else {
|
|
2409
2679
|
current += ch;
|
|
2410
2680
|
}
|
|
2411
2681
|
}
|
|
2412
|
-
if (current.trim())
|
|
2413
|
-
return
|
|
2682
|
+
if (current.trim()) segments2.push(current.trim());
|
|
2683
|
+
return segments2.filter(Boolean);
|
|
2414
2684
|
}
|
|
2415
2685
|
function positionalTokens(segment) {
|
|
2416
2686
|
return segment.split(/\s+/).slice(1).filter((t) => !t.startsWith("-") && !t.startsWith("@") && t.length > 0);
|
|
2417
2687
|
}
|
|
2418
2688
|
function analyzePipeChain(command) {
|
|
2419
|
-
const
|
|
2420
|
-
if (
|
|
2689
|
+
const segments2 = splitOnPipe(command);
|
|
2690
|
+
if (segments2.length < 2) {
|
|
2421
2691
|
return {
|
|
2422
2692
|
isPipeline: false,
|
|
2423
2693
|
hasSensitiveSource: false,
|
|
@@ -2433,7 +2703,7 @@ function analyzePipeChain(command) {
|
|
|
2433
2703
|
let hasSensitiveSource = false;
|
|
2434
2704
|
let hasExternalSink = false;
|
|
2435
2705
|
let hasObfuscation = false;
|
|
2436
|
-
for (const segment of
|
|
2706
|
+
for (const segment of segments2) {
|
|
2437
2707
|
const tokens = segment.split(/\s+/).filter(Boolean);
|
|
2438
2708
|
if (tokens.length === 0) continue;
|
|
2439
2709
|
const binary = tokens[0].toLowerCase();
|
|
@@ -2471,8 +2741,8 @@ function analyzePipeChain(command) {
|
|
|
2471
2741
|
};
|
|
2472
2742
|
}
|
|
2473
2743
|
function basename(p) {
|
|
2474
|
-
const
|
|
2475
|
-
return
|
|
2744
|
+
const segments2 = p.split(/[\\/]/);
|
|
2745
|
+
return segments2[segments2.length - 1] || "";
|
|
2476
2746
|
}
|
|
2477
2747
|
var FLAGS_WITH_VALUES = {
|
|
2478
2748
|
curl: /* @__PURE__ */ new Set([
|
|
@@ -2635,6 +2905,204 @@ function extractAllSshHosts(tokens) {
|
|
|
2635
2905
|
}
|
|
2636
2906
|
return [...hosts].filter(Boolean);
|
|
2637
2907
|
}
|
|
2908
|
+
var SSRF_MAX_HOST = 253;
|
|
2909
|
+
function parseComponent(s) {
|
|
2910
|
+
if (!s) return null;
|
|
2911
|
+
if (/^0[xX][0-9a-fA-F]+$/.test(s)) return parseInt(s.slice(2), 16);
|
|
2912
|
+
if (s === "0") return 0;
|
|
2913
|
+
if (/^0[0-7]+$/.test(s)) return parseInt(s.slice(1), 8);
|
|
2914
|
+
if (/^[1-9][0-9]*$/.test(s)) return Number(s);
|
|
2915
|
+
return null;
|
|
2916
|
+
}
|
|
2917
|
+
function parseIpv4(input) {
|
|
2918
|
+
let s = input;
|
|
2919
|
+
if (s.endsWith(".")) s = s.slice(0, -1);
|
|
2920
|
+
if (!s) return null;
|
|
2921
|
+
const parts = s.split(".");
|
|
2922
|
+
if (parts.length > 4) return null;
|
|
2923
|
+
const vals = [];
|
|
2924
|
+
for (const p of parts) {
|
|
2925
|
+
const v = parseComponent(p);
|
|
2926
|
+
if (v === null || !Number.isFinite(v) || v < 0) return null;
|
|
2927
|
+
vals.push(v);
|
|
2928
|
+
}
|
|
2929
|
+
const n = vals.length;
|
|
2930
|
+
for (let i = 0; i < n - 1; i++) if (vals[i] > 255) return null;
|
|
2931
|
+
const last = vals[n - 1];
|
|
2932
|
+
const remainingBytes = 4 - (n - 1);
|
|
2933
|
+
const limit = Math.pow(256, remainingBytes);
|
|
2934
|
+
if (last >= limit) return null;
|
|
2935
|
+
let value = last;
|
|
2936
|
+
for (let i = 0; i < n - 1; i++) value += vals[i] * Math.pow(256, 3 - i);
|
|
2937
|
+
if (value > 4294967295) return null;
|
|
2938
|
+
return [value >>> 24 & 255, value >>> 16 & 255, value >>> 8 & 255, value & 255].join(".");
|
|
2939
|
+
}
|
|
2940
|
+
function expandIpv6(input) {
|
|
2941
|
+
const s = input.toLowerCase();
|
|
2942
|
+
if (!/^[0-9a-f:.]+$/.test(s)) return null;
|
|
2943
|
+
if ((s.match(/::/g) ?? []).length > 1) return null;
|
|
2944
|
+
let head = s;
|
|
2945
|
+
let tailV4 = null;
|
|
2946
|
+
const lastColon = s.lastIndexOf(":");
|
|
2947
|
+
const afterLast = s.slice(lastColon + 1);
|
|
2948
|
+
if (afterLast.includes(".")) {
|
|
2949
|
+
const dotted = parseIpv4(afterLast);
|
|
2950
|
+
if (!dotted) return null;
|
|
2951
|
+
const o = dotted.split(".").map(Number);
|
|
2952
|
+
tailV4 = [o[0] << 8 | o[1], o[2] << 8 | o[3]];
|
|
2953
|
+
head = s.slice(0, lastColon + 1) + "0";
|
|
2954
|
+
}
|
|
2955
|
+
const [lhs, rhs] = head.includes("::") ? head.split("::") : [head, null];
|
|
2956
|
+
const toGroups = (part) => {
|
|
2957
|
+
if (!part) return [];
|
|
2958
|
+
const out = [];
|
|
2959
|
+
for (const g of part.split(":")) {
|
|
2960
|
+
if (!/^[0-9a-f]{1,4}$/.test(g)) return null;
|
|
2961
|
+
out.push(parseInt(g, 16));
|
|
2962
|
+
}
|
|
2963
|
+
return out;
|
|
2964
|
+
};
|
|
2965
|
+
const left = toGroups(lhs);
|
|
2966
|
+
if (left === null) return null;
|
|
2967
|
+
let right = [];
|
|
2968
|
+
if (rhs !== null) {
|
|
2969
|
+
const r = toGroups(rhs);
|
|
2970
|
+
if (r === null) return null;
|
|
2971
|
+
right = r;
|
|
2972
|
+
}
|
|
2973
|
+
if (tailV4) {
|
|
2974
|
+
if (rhs !== null) right = right.slice(0, -1).concat(tailV4);
|
|
2975
|
+
else left.splice(left.length - 1, 1, ...tailV4);
|
|
2976
|
+
}
|
|
2977
|
+
const groups = rhs === null ? left : left.concat(new Array(8 - left.length - right.length).fill(0), right);
|
|
2978
|
+
if (rhs === null && groups.length !== 8) return null;
|
|
2979
|
+
if (rhs !== null && left.length + right.length > 8) return null;
|
|
2980
|
+
if (groups.length !== 8) return null;
|
|
2981
|
+
return groups;
|
|
2982
|
+
}
|
|
2983
|
+
function compressIpv6(g) {
|
|
2984
|
+
let bestStart = -1;
|
|
2985
|
+
let bestLen = 0;
|
|
2986
|
+
let i = 0;
|
|
2987
|
+
while (i < 8) {
|
|
2988
|
+
if (g[i] !== 0) {
|
|
2989
|
+
i++;
|
|
2990
|
+
continue;
|
|
2991
|
+
}
|
|
2992
|
+
let j = i;
|
|
2993
|
+
while (j < 8 && g[j] === 0) j++;
|
|
2994
|
+
if (j - i > bestLen) {
|
|
2995
|
+
bestLen = j - i;
|
|
2996
|
+
bestStart = i;
|
|
2997
|
+
}
|
|
2998
|
+
i = j;
|
|
2999
|
+
}
|
|
3000
|
+
const hex = g.map((x) => x.toString(16));
|
|
3001
|
+
if (bestLen < 2) return hex.join(":");
|
|
3002
|
+
return hex.slice(0, bestStart).join(":") + "::" + hex.slice(bestStart + bestLen).join(":");
|
|
3003
|
+
}
|
|
3004
|
+
function normalizeIpLiteral(host) {
|
|
3005
|
+
try {
|
|
3006
|
+
if (typeof host !== "string") return null;
|
|
3007
|
+
let s = host.trim();
|
|
3008
|
+
if (!s || s.length > SSRF_MAX_HOST) return null;
|
|
3009
|
+
if (s.startsWith("[") && s.endsWith("]")) s = s.slice(1, -1);
|
|
3010
|
+
const zone = s.indexOf("%");
|
|
3011
|
+
if (zone >= 0) s = s.slice(0, zone);
|
|
3012
|
+
if (!s) return null;
|
|
3013
|
+
if (s.includes(":")) {
|
|
3014
|
+
const g = expandIpv6(s);
|
|
3015
|
+
if (!g) return null;
|
|
3016
|
+
const mapped = g.slice(0, 5).every((x) => x === 0) && g[5] === 65535;
|
|
3017
|
+
if (mapped) {
|
|
3018
|
+
return [g[6] >> 8 & 255, g[6] & 255, g[7] >> 8 & 255, g[7] & 255].join(".");
|
|
3019
|
+
}
|
|
3020
|
+
return compressIpv6(g);
|
|
3021
|
+
}
|
|
3022
|
+
return parseIpv4(s);
|
|
3023
|
+
} catch {
|
|
3024
|
+
return null;
|
|
3025
|
+
}
|
|
3026
|
+
}
|
|
3027
|
+
var METADATA_ADDRESSES = /* @__PURE__ */ new Set([
|
|
3028
|
+
"169.254.169.254",
|
|
3029
|
+
// AWS / Azure / DigitalOcean / OpenStack IMDS
|
|
3030
|
+
"169.254.170.2",
|
|
3031
|
+
// AWS ECS task role
|
|
3032
|
+
"168.63.129.16",
|
|
3033
|
+
// Azure WireServer
|
|
3034
|
+
"fd00:ec2::254"
|
|
3035
|
+
// AWS IMDS over IPv6 (inside fc00::/7, which is NOT a tier)
|
|
3036
|
+
]);
|
|
3037
|
+
var METADATA_HOSTNAMES = /* @__PURE__ */ new Set(["metadata.google.internal", "metadata.goog", "metadata"]);
|
|
3038
|
+
var v4Octets = (a) => {
|
|
3039
|
+
const p = a.split(".");
|
|
3040
|
+
return p.length === 4 ? p.map(Number) : null;
|
|
3041
|
+
};
|
|
3042
|
+
function classifySsrf(host) {
|
|
3043
|
+
try {
|
|
3044
|
+
if (typeof host !== "string" || !host) return null;
|
|
3045
|
+
const lower = host.trim().toLowerCase().replace(/\.$/, "");
|
|
3046
|
+
const ip = normalizeIpLiteral(host);
|
|
3047
|
+
if (ip === null) {
|
|
3048
|
+
return METADATA_HOSTNAMES.has(lower) ? { tier: "metadata", overridable: false, kind: "hostname" } : null;
|
|
3049
|
+
}
|
|
3050
|
+
const hit = (tier, overridable) => ({
|
|
3051
|
+
tier,
|
|
3052
|
+
overridable,
|
|
3053
|
+
kind: "address",
|
|
3054
|
+
normalized: ip
|
|
3055
|
+
});
|
|
3056
|
+
if (METADATA_ADDRESSES.has(ip)) return hit("metadata", false);
|
|
3057
|
+
const o = v4Octets(ip);
|
|
3058
|
+
if (o) {
|
|
3059
|
+
if (o[0] === 0 && o[1] === 0 && o[2] === 0 && o[3] === 0) return hit("unspecified", false);
|
|
3060
|
+
if (o[0] === 169 && o[1] === 254) return hit("link-local", false);
|
|
3061
|
+
if (o[0] >= 224 && o[0] <= 239) return hit("multicast", false);
|
|
3062
|
+
if (o[0] === 100 && o[1] >= 64 && o[1] <= 127) return hit("cgnat", true);
|
|
3063
|
+
if (o[0] === 127) return hit("private", true);
|
|
3064
|
+
if (o[0] === 10) return hit("private", true);
|
|
3065
|
+
if (o[0] === 192 && o[1] === 168) return hit("private", true);
|
|
3066
|
+
if (o[0] === 172 && o[1] >= 16 && o[1] <= 31) return hit("private", true);
|
|
3067
|
+
return null;
|
|
3068
|
+
}
|
|
3069
|
+
const g = expandIpv6(ip);
|
|
3070
|
+
if (!g) return null;
|
|
3071
|
+
if (g.every((x) => x === 0)) return hit("unspecified", false);
|
|
3072
|
+
if ((g[0] & 65472) === 65152) return hit("link-local", false);
|
|
3073
|
+
if ((g[0] & 65280) === 65280) return hit("multicast", false);
|
|
3074
|
+
if (g.slice(0, 7).every((x) => x === 0) && g[7] === 1) return hit("private", true);
|
|
3075
|
+
return null;
|
|
3076
|
+
} catch {
|
|
3077
|
+
return null;
|
|
3078
|
+
}
|
|
3079
|
+
}
|
|
3080
|
+
var TIER_REASON = {
|
|
3081
|
+
metadata: "a cloud instance-metadata endpoint, the classic credential-theft target",
|
|
3082
|
+
"link-local": "a link-local address",
|
|
3083
|
+
multicast: "a multicast address",
|
|
3084
|
+
unspecified: "the unspecified address",
|
|
3085
|
+
cgnat: "a carrier-grade NAT address",
|
|
3086
|
+
private: "a loopback or private address"
|
|
3087
|
+
};
|
|
3088
|
+
function ssrfFloor(tokens, opts = {}) {
|
|
3089
|
+
const exempt = new Set(
|
|
3090
|
+
(opts.ssrfAllow ?? []).map((e) => normalizeIpLiteral(e) ?? e.trim().toLowerCase())
|
|
3091
|
+
);
|
|
3092
|
+
for (const { token, binary } of tokens) {
|
|
3093
|
+
const m = classifySsrf(token);
|
|
3094
|
+
if (!m) continue;
|
|
3095
|
+
if (m.tier === "private" && !opts.ssrfStrict) continue;
|
|
3096
|
+
if (m.overridable && m.normalized && exempt.has(m.normalized)) continue;
|
|
3097
|
+
return {
|
|
3098
|
+
...m,
|
|
3099
|
+
host: token,
|
|
3100
|
+
binary,
|
|
3101
|
+
reason: `Blocked: ${token} is ${TIER_REASON[m.tier]}` + (m.normalized && m.normalized !== token ? ` (${m.normalized})` : "") + (m.overridable ? "." : ". This address cannot be allowlisted.")
|
|
3102
|
+
};
|
|
3103
|
+
}
|
|
3104
|
+
return null;
|
|
3105
|
+
}
|
|
2638
3106
|
function resolveCheck(v) {
|
|
2639
3107
|
return v === "off" || v === "block" ? v : "review";
|
|
2640
3108
|
}
|
|
@@ -2867,6 +3335,22 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
|
|
|
2867
3335
|
}
|
|
2868
3336
|
const builtin = strictestVerdict(candidates);
|
|
2869
3337
|
if (builtin) return builtin;
|
|
3338
|
+
{
|
|
3339
|
+
const ssrf = ssrfFloor(extractShellDestTokens(shellCommand), {
|
|
3340
|
+
ssrfAllow: config.policy.egress?.ssrfAllow,
|
|
3341
|
+
ssrfStrict: config.policy.egress?.ssrfStrict
|
|
3342
|
+
});
|
|
3343
|
+
if (ssrf) {
|
|
3344
|
+
return {
|
|
3345
|
+
decision: "block",
|
|
3346
|
+
blockedByLabel: "\u{1F310} Node9 Egress (Protected Address)",
|
|
3347
|
+
reason: ssrf.reason,
|
|
3348
|
+
ruleName: `ssrf:${ssrf.tier}:${ssrf.binary}:${ssrf.host}`,
|
|
3349
|
+
ruleDescription: ssrf.reason,
|
|
3350
|
+
tier: 3
|
|
3351
|
+
};
|
|
3352
|
+
}
|
|
3353
|
+
}
|
|
2870
3354
|
if (config.policy.egress?.enabled) {
|
|
2871
3355
|
const dests = extractShellDestinations(shellCommand);
|
|
2872
3356
|
if (dests.length > 0) {
|
|
@@ -3808,28 +4292,236 @@ function evaluateLoopWindow(records, tool, args, threshold, windowMs, now) {
|
|
|
3808
4292
|
var PII_EMAIL_RE = /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/;
|
|
3809
4293
|
var PII_SSN_RE = /\b\d{3}-\d{2}-\d{4}\b/;
|
|
3810
4294
|
var PII_PHONE_RE = /\b(?:\+?1[-.\s]?)?\(?\d{3}\)?[-.\s]\d{3}[-.\s]\d{4}\b/;
|
|
3811
|
-
var
|
|
4295
|
+
var PII_CC16_RE = /\b(?:4\d{3}|5[1-5]\d{2}|6\d{3})[-\s]?\d{4}[-\s]?\d{4}[-\s]?\d{4}\b/;
|
|
4296
|
+
var PII_CC15_RE = /\b3[47]\d{2}[-\s]?\d{6}[-\s]?\d{5}\b/;
|
|
4297
|
+
var PII_IBAN_RE = /\b[A-Z]{2}\d{2}(?:[A-Z0-9]|[ -][A-Z0-9]){11,30}\b/;
|
|
4298
|
+
function hasValidCard(text) {
|
|
4299
|
+
for (const base of [PII_CC16_RE, PII_CC15_RE]) {
|
|
4300
|
+
const re = new RegExp(base.source, "g");
|
|
4301
|
+
let m;
|
|
4302
|
+
while ((m = re.exec(text)) !== null) {
|
|
4303
|
+
if (validateLuhn(m[0].replace(/\D/g, ""))) return true;
|
|
4304
|
+
re.lastIndex = m.index + 1;
|
|
4305
|
+
}
|
|
4306
|
+
}
|
|
4307
|
+
return false;
|
|
4308
|
+
}
|
|
4309
|
+
function hasValidIban(text) {
|
|
4310
|
+
const re = new RegExp(PII_IBAN_RE.source, "g");
|
|
4311
|
+
let m;
|
|
4312
|
+
while ((m = re.exec(text)) !== null) {
|
|
4313
|
+
if (validateIban(m[0])) return true;
|
|
4314
|
+
re.lastIndex = m.index + 1;
|
|
4315
|
+
}
|
|
4316
|
+
return false;
|
|
4317
|
+
}
|
|
3812
4318
|
function detectPii(text) {
|
|
3813
4319
|
const found = /* @__PURE__ */ new Set();
|
|
3814
4320
|
if (/@/.test(text) && PII_EMAIL_RE.test(text)) found.add("Email");
|
|
3815
4321
|
if (/-/.test(text) && PII_SSN_RE.test(text)) found.add("SSN");
|
|
3816
4322
|
if (PII_PHONE_RE.test(text)) found.add("Phone");
|
|
3817
|
-
if (
|
|
4323
|
+
if (hasValidCard(text)) found.add("Credit Card");
|
|
4324
|
+
if (hasValidIban(text)) found.add("IBAN");
|
|
3818
4325
|
return [...found];
|
|
3819
4326
|
}
|
|
3820
|
-
var REALTIME_PII_PATTERNS = ["SSN", "Credit Card"];
|
|
4327
|
+
var REALTIME_PII_PATTERNS = ["SSN", "Credit Card", "IBAN"];
|
|
3821
4328
|
var MAX_PII_SCAN_BYTES = 1e5;
|
|
4329
|
+
function* stringLeaves(v, depth = 0) {
|
|
4330
|
+
if (depth > 6) return;
|
|
4331
|
+
if (typeof v === "string") {
|
|
4332
|
+
if (v.length > 0) yield v;
|
|
4333
|
+
return;
|
|
4334
|
+
}
|
|
4335
|
+
if (typeof v === "number") {
|
|
4336
|
+
if (Number.isFinite(v)) yield String(v);
|
|
4337
|
+
return;
|
|
4338
|
+
}
|
|
4339
|
+
if (!v || typeof v !== "object") return;
|
|
4340
|
+
if (Array.isArray(v)) {
|
|
4341
|
+
for (const x of v) yield* stringLeaves(x, depth + 1);
|
|
4342
|
+
return;
|
|
4343
|
+
}
|
|
4344
|
+
for (const x of Object.values(v)) yield* stringLeaves(x, depth + 1);
|
|
4345
|
+
}
|
|
3822
4346
|
function detectArgsPii(args) {
|
|
3823
4347
|
if (args === null || args === void 0) return [];
|
|
3824
|
-
|
|
4348
|
+
const found = /* @__PURE__ */ new Set();
|
|
4349
|
+
let budget = MAX_PII_SCAN_BYTES;
|
|
3825
4350
|
try {
|
|
3826
|
-
|
|
4351
|
+
for (const leaf of stringLeaves(args)) {
|
|
4352
|
+
if (budget <= 0) break;
|
|
4353
|
+
const t = leaf.length > budget ? leaf.slice(0, budget) : leaf;
|
|
4354
|
+
budget -= t.length;
|
|
4355
|
+
for (const p of detectPii(t)) {
|
|
4356
|
+
if (REALTIME_PII_PATTERNS.includes(p)) found.add(p);
|
|
4357
|
+
}
|
|
4358
|
+
}
|
|
3827
4359
|
} catch {
|
|
3828
4360
|
return [];
|
|
3829
4361
|
}
|
|
3830
|
-
|
|
3831
|
-
|
|
3832
|
-
|
|
4362
|
+
return [...found];
|
|
4363
|
+
}
|
|
4364
|
+
var CANARY_MIN_LENGTH = 16;
|
|
4365
|
+
var MAX_TEXT = 1e5;
|
|
4366
|
+
var MAX_DEPTH2 = 6;
|
|
4367
|
+
var MAX_JSON_PARSE = 1e4;
|
|
4368
|
+
var URL_DEPTH = 4;
|
|
4369
|
+
var B64_DEPTH = 3;
|
|
4370
|
+
var MIN_SEGMENT = 16;
|
|
4371
|
+
var SEPARATORS = /[./\\?&= \t\n\r:;,\-_@%+#]/g;
|
|
4372
|
+
var stripSeparators = (s) => s.replace(SEPARATORS, "");
|
|
4373
|
+
function percentDecodeOnce(s) {
|
|
4374
|
+
try {
|
|
4375
|
+
return decodeURIComponent(s);
|
|
4376
|
+
} catch {
|
|
4377
|
+
return s.replace(/%([0-9A-Fa-f]{2})/g, (_, h) => String.fromCharCode(parseInt(h, 16)));
|
|
4378
|
+
}
|
|
4379
|
+
}
|
|
4380
|
+
function segments(s, alphabet) {
|
|
4381
|
+
const out = /* @__PURE__ */ new Set();
|
|
4382
|
+
if (alphabet.test(s)) out.add(s);
|
|
4383
|
+
for (const seg of s.split(/[?&\s"'<>]+/)) {
|
|
4384
|
+
if (seg.length >= MIN_SEGMENT && alphabet.test(seg)) out.add(seg);
|
|
4385
|
+
for (const part of seg.split("=")) {
|
|
4386
|
+
if (part.length >= MIN_SEGMENT && alphabet.test(part)) out.add(part);
|
|
4387
|
+
}
|
|
4388
|
+
}
|
|
4389
|
+
return [...out];
|
|
4390
|
+
}
|
|
4391
|
+
var looksText = (s) => s.length > 0 && !/[\x00-\x08\x0b\x0c\x0e-\x1f]/.test(s);
|
|
4392
|
+
var CANARY_DECODERS = {
|
|
4393
|
+
url: (s) => {
|
|
4394
|
+
const out = [];
|
|
4395
|
+
let cur = s;
|
|
4396
|
+
for (let i = 0; i < URL_DEPTH; i++) {
|
|
4397
|
+
const d = percentDecodeOnce(cur);
|
|
4398
|
+
if (d === cur) break;
|
|
4399
|
+
out.push(d);
|
|
4400
|
+
cur = d;
|
|
4401
|
+
}
|
|
4402
|
+
return out;
|
|
4403
|
+
},
|
|
4404
|
+
base64: (s) => {
|
|
4405
|
+
const out = [];
|
|
4406
|
+
let frontier = segments(s, /^[A-Za-z0-9+/\-_=]+$/);
|
|
4407
|
+
for (let depth = 0; depth < B64_DEPTH && frontier.length; depth++) {
|
|
4408
|
+
const next = [];
|
|
4409
|
+
for (const c of frontier) {
|
|
4410
|
+
const d = Buffer.from(c, "base64").toString("utf8");
|
|
4411
|
+
if (!looksText(d) || d.length < CANARY_MIN_LENGTH) continue;
|
|
4412
|
+
out.push(d);
|
|
4413
|
+
next.push(...segments(d, /^[A-Za-z0-9+/\-_=]+$/));
|
|
4414
|
+
}
|
|
4415
|
+
frontier = next;
|
|
4416
|
+
}
|
|
4417
|
+
return out;
|
|
4418
|
+
},
|
|
4419
|
+
hex: (s) => {
|
|
4420
|
+
const out = [];
|
|
4421
|
+
for (const c of segments(s, /^[0-9A-Fa-f]+$/)) {
|
|
4422
|
+
if (c.length % 2 !== 0 || c.length < CANARY_MIN_LENGTH * 2) continue;
|
|
4423
|
+
const d = Buffer.from(c, "hex").toString("utf8");
|
|
4424
|
+
if (looksText(d)) out.push(d);
|
|
4425
|
+
}
|
|
4426
|
+
return out;
|
|
4427
|
+
},
|
|
4428
|
+
separators: (s) => [stripSeparators(s)]
|
|
4429
|
+
};
|
|
4430
|
+
function lowestOffset(cands, needles, stripped) {
|
|
4431
|
+
let best = null;
|
|
4432
|
+
for (const c of cands) {
|
|
4433
|
+
for (const n of needles) {
|
|
4434
|
+
const off = c.indexOf(stripped ? n.stripped : n.raw);
|
|
4435
|
+
if (off >= 0 && (best === null || off < best.off)) best = { off, v: n.v };
|
|
4436
|
+
}
|
|
4437
|
+
}
|
|
4438
|
+
return best?.v ?? null;
|
|
4439
|
+
}
|
|
4440
|
+
var VIEWS = [
|
|
4441
|
+
{ view: "url-decoded", decoder: "url", stripped: false },
|
|
4442
|
+
{ view: "base64-decoded", decoder: "base64", stripped: false },
|
|
4443
|
+
{ view: "hex-decoded", decoder: "hex", stripped: false },
|
|
4444
|
+
{ view: "separators-stripped", decoder: "separators", stripped: true }
|
|
4445
|
+
];
|
|
4446
|
+
function prepare(values) {
|
|
4447
|
+
const out = [];
|
|
4448
|
+
for (const v of values) {
|
|
4449
|
+
if (typeof v.value !== "string" || v.value.length < CANARY_MIN_LENGTH) {
|
|
4450
|
+
console.error(
|
|
4451
|
+
`[node9 engine] canary ${v.id}: value shorter than ${CANARY_MIN_LENGTH}, skipped`
|
|
4452
|
+
);
|
|
4453
|
+
continue;
|
|
4454
|
+
}
|
|
4455
|
+
out.push({ v, raw: v.value, stripped: stripSeparators(v.value) });
|
|
4456
|
+
}
|
|
4457
|
+
return out;
|
|
4458
|
+
}
|
|
4459
|
+
function matchPrepared(text, needles) {
|
|
4460
|
+
if (!text || needles.length === 0) return null;
|
|
4461
|
+
const t = text.length > MAX_TEXT ? text.slice(0, MAX_TEXT) : text;
|
|
4462
|
+
const raw = lowestOffset([t], needles, false);
|
|
4463
|
+
if (raw) return { v: raw, view: "raw" };
|
|
4464
|
+
for (const { view, decoder, stripped } of VIEWS) {
|
|
4465
|
+
let cands;
|
|
4466
|
+
try {
|
|
4467
|
+
cands = CANARY_DECODERS[decoder](t);
|
|
4468
|
+
} catch (e) {
|
|
4469
|
+
console.error(
|
|
4470
|
+
`[node9 engine] canary view ${view} failed, skipped:`,
|
|
4471
|
+
e instanceof Error ? e.message : String(e)
|
|
4472
|
+
);
|
|
4473
|
+
continue;
|
|
4474
|
+
}
|
|
4475
|
+
const hit = lowestOffset(cands, needles, stripped);
|
|
4476
|
+
if (hit) return { v: hit, view };
|
|
4477
|
+
}
|
|
4478
|
+
return null;
|
|
4479
|
+
}
|
|
4480
|
+
function matchCanaryArgs(args, values) {
|
|
4481
|
+
if (values.length === 0) return null;
|
|
4482
|
+
const needles = prepare(values);
|
|
4483
|
+
if (needles.length === 0) return null;
|
|
4484
|
+
const walk = (v, depth, fieldPath) => {
|
|
4485
|
+
if (depth > MAX_DEPTH2) return null;
|
|
4486
|
+
if (typeof v === "string") {
|
|
4487
|
+
const hit = matchPrepared(v, needles);
|
|
4488
|
+
if (hit) return { id: hit.v.id, view: hit.view, fieldPath, retired: Boolean(hit.v.retired) };
|
|
4489
|
+
if (v.length < MAX_JSON_PARSE) {
|
|
4490
|
+
const trimmed = v.trim();
|
|
4491
|
+
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
|
|
4492
|
+
try {
|
|
4493
|
+
return walk(JSON.parse(v), depth + 1, fieldPath);
|
|
4494
|
+
} catch {
|
|
4495
|
+
}
|
|
4496
|
+
}
|
|
4497
|
+
}
|
|
4498
|
+
return null;
|
|
4499
|
+
}
|
|
4500
|
+
if (typeof v === "number" && Number.isFinite(v)) return walk(String(v), depth, fieldPath);
|
|
4501
|
+
if (Array.isArray(v)) {
|
|
4502
|
+
for (let i = 0; i < v.length; i++) {
|
|
4503
|
+
const h = walk(v[i], depth + 1, `${fieldPath}[${i}]`);
|
|
4504
|
+
if (h) return h;
|
|
4505
|
+
}
|
|
4506
|
+
return null;
|
|
4507
|
+
}
|
|
4508
|
+
if (v && typeof v === "object") {
|
|
4509
|
+
for (const [k, child] of Object.entries(v)) {
|
|
4510
|
+
const h = walk(child, depth + 1, fieldPath ? `${fieldPath}.${k}` : k);
|
|
4511
|
+
if (h) return h;
|
|
4512
|
+
}
|
|
4513
|
+
}
|
|
4514
|
+
return null;
|
|
4515
|
+
};
|
|
4516
|
+
try {
|
|
4517
|
+
return walk(args, 0, "");
|
|
4518
|
+
} catch (e) {
|
|
4519
|
+
console.error(
|
|
4520
|
+
"[node9 engine] canary args walk failed:",
|
|
4521
|
+
e instanceof Error ? e.message : String(e)
|
|
4522
|
+
);
|
|
4523
|
+
return null;
|
|
4524
|
+
}
|
|
3833
4525
|
}
|
|
3834
4526
|
var LONG_OUTPUT_THRESHOLD_BYTES = 100 * 1024;
|
|
3835
4527
|
|
|
@@ -3959,6 +4651,12 @@ function applyManagedEgress(local, managed, locked, localModeUserSet = true) {
|
|
|
3959
4651
|
if (Array.isArray(managed.deny) && managed.deny.length > 0) {
|
|
3960
4652
|
next.deny = [.../* @__PURE__ */ new Set([...local.deny ?? [], ...managed.deny])];
|
|
3961
4653
|
}
|
|
4654
|
+
if (typeof managed.ssrfStrict === "boolean") {
|
|
4655
|
+
next.ssrfStrict = managed.ssrfStrict;
|
|
4656
|
+
}
|
|
4657
|
+
if (Array.isArray(managed.ssrfAllow)) {
|
|
4658
|
+
next.ssrfAllow = [...managed.ssrfAllow];
|
|
4659
|
+
}
|
|
3962
4660
|
if (typeof managed.allowPrivate === "boolean") {
|
|
3963
4661
|
next.allowPrivate = locked.includes("egressAllowPrivate") ? managed.allowPrivate : (local.allowPrivate ?? true) && managed.allowPrivate;
|
|
3964
4662
|
}
|
|
@@ -4040,9 +4738,9 @@ var B = "[\\s/\\\\]";
|
|
|
4040
4738
|
var SEP = "[/\\\\]";
|
|
4041
4739
|
function pathToRegexFragment(rawPath) {
|
|
4042
4740
|
const tail = rawPath.trim().replace(/^~[\\/]?/, "").replace(/^\$\{?HOME\}?[\\/]?/, "").replace(/^\/(?:home|Users)\/[^\\/]+[\\/]?/, "").replace(/^[A-Za-z]:[\\/]Users[\\/][^\\/]+[\\/]?/, "").replace(/^[\\/]+/, "").replace(/[\\/]+$/, "");
|
|
4043
|
-
const
|
|
4044
|
-
if (
|
|
4045
|
-
return `(^|${B})${
|
|
4741
|
+
const segments2 = tail.split(/[\\/]+/).filter(Boolean).map(escapeRegex);
|
|
4742
|
+
if (segments2.length === 0) return "";
|
|
4743
|
+
return `(^|${B})${segments2.join(SEP)}(${B}|$)`;
|
|
4046
4744
|
}
|
|
4047
4745
|
function pathMatchesFragment(candidate, rawPath) {
|
|
4048
4746
|
const value = pathToRegexFragment(rawPath);
|
|
@@ -4147,6 +4845,20 @@ function isTrustedHost(host) {
|
|
|
4147
4845
|
}
|
|
4148
4846
|
|
|
4149
4847
|
// src/config/index.ts
|
|
4848
|
+
function sanitizeSsrfAllow(entries, source) {
|
|
4849
|
+
const kept = [];
|
|
4850
|
+
for (const entry of entries) {
|
|
4851
|
+
const m = classifySsrf(entry);
|
|
4852
|
+
if (m && !m.overridable) {
|
|
4853
|
+
process.emitWarning(
|
|
4854
|
+
`[node9] ${source} ssrfAllow entry "${entry}" is a protected address (${m.tier}) and cannot be exempted; ignoring it.`
|
|
4855
|
+
);
|
|
4856
|
+
continue;
|
|
4857
|
+
}
|
|
4858
|
+
kept.push(entry);
|
|
4859
|
+
}
|
|
4860
|
+
return kept;
|
|
4861
|
+
}
|
|
4150
4862
|
var DANGEROUS_WORDS = [
|
|
4151
4863
|
"mkfs",
|
|
4152
4864
|
// formats/wipes a filesystem partition
|
|
@@ -4156,6 +4868,7 @@ var DANGEROUS_WORDS = [
|
|
|
4156
4868
|
var DEFAULT_CONFIG = {
|
|
4157
4869
|
version: "1.0",
|
|
4158
4870
|
policySource: "local",
|
|
4871
|
+
ssrfStrictSource: "default",
|
|
4159
4872
|
settings: {
|
|
4160
4873
|
mode: "standard",
|
|
4161
4874
|
autoStartDaemon: true,
|
|
@@ -4339,7 +5052,17 @@ var DEFAULT_CONFIG = {
|
|
|
4339
5052
|
}
|
|
4340
5053
|
],
|
|
4341
5054
|
dlp: { enabled: true, scanIgnoredTools: true, pii: "off" },
|
|
4342
|
-
egress: {
|
|
5055
|
+
egress: {
|
|
5056
|
+
enabled: false,
|
|
5057
|
+
mode: "review",
|
|
5058
|
+
allow: [],
|
|
5059
|
+
deny: [],
|
|
5060
|
+
allowPrivate: true,
|
|
5061
|
+
// The SSRF floor is always on and needs no default; these two only
|
|
5062
|
+
// widen or narrow it. See doc/roadmap/active/ssrf-floor-design.md.
|
|
5063
|
+
ssrfAllow: [],
|
|
5064
|
+
ssrfStrict: false
|
|
5065
|
+
},
|
|
4343
5066
|
loopDetection: { enabled: true, threshold: 5, windowSeconds: 120 },
|
|
4344
5067
|
injectionScan: { enabled: false, minConfidence: "medium", allow: [] },
|
|
4345
5068
|
skillPinning: { enabled: false, mode: "warn", roots: [] },
|
|
@@ -4522,7 +5245,8 @@ function getConfig(cwd) {
|
|
|
4522
5245
|
egress: {
|
|
4523
5246
|
...DEFAULT_CONFIG.policy.egress,
|
|
4524
5247
|
allow: [...DEFAULT_CONFIG.policy.egress.allow],
|
|
4525
|
-
deny: [...DEFAULT_CONFIG.policy.egress.deny]
|
|
5248
|
+
deny: [...DEFAULT_CONFIG.policy.egress.deny],
|
|
5249
|
+
ssrfAllow: [...DEFAULT_CONFIG.policy.egress.ssrfAllow ?? []]
|
|
4526
5250
|
},
|
|
4527
5251
|
loopDetection: { ...DEFAULT_CONFIG.policy.loopDetection },
|
|
4528
5252
|
injectionScan: {
|
|
@@ -4548,6 +5272,7 @@ function getConfig(cwd) {
|
|
|
4548
5272
|
};
|
|
4549
5273
|
const pr2Creds = getCredentials();
|
|
4550
5274
|
const keyed = !!pr2Creds?.apiKey && pr2Creds.localOnly !== true;
|
|
5275
|
+
let ssrfStrictSource = "default";
|
|
4551
5276
|
const applyLayer = (source, isProject = false, isCloud = false) => {
|
|
4552
5277
|
if (!source) return;
|
|
4553
5278
|
const s = source.settings || {};
|
|
@@ -4630,6 +5355,15 @@ function getConfig(cwd) {
|
|
|
4630
5355
|
if (Array.isArray(e.deny)) mergedPolicy.egress.deny.push(...e.deny);
|
|
4631
5356
|
if (e.allowPrivate !== void 0 && !(isProject && e.allowPrivate === true))
|
|
4632
5357
|
mergedPolicy.egress.allowPrivate = e.allowPrivate;
|
|
5358
|
+
if (!isProject) {
|
|
5359
|
+
if (Array.isArray(e.ssrfAllow)) {
|
|
5360
|
+
mergedPolicy.egress.ssrfAllow = sanitizeSsrfAllow(e.ssrfAllow, "egress.");
|
|
5361
|
+
}
|
|
5362
|
+
if (e.ssrfStrict !== void 0) {
|
|
5363
|
+
mergedPolicy.egress.ssrfStrict = e.ssrfStrict;
|
|
5364
|
+
ssrfStrictSource = "local";
|
|
5365
|
+
}
|
|
5366
|
+
}
|
|
4633
5367
|
}
|
|
4634
5368
|
if (p.loopDetection) {
|
|
4635
5369
|
const ld = p.loopDetection;
|
|
@@ -4736,6 +5470,13 @@ function getConfig(cwd) {
|
|
|
4736
5470
|
if (deny) mergedPolicy.egress.deny = deny;
|
|
4737
5471
|
if (typeof e.allowPrivate === "boolean")
|
|
4738
5472
|
mergedPolicy.egress.allowPrivate = e.allowPrivate;
|
|
5473
|
+
if (typeof e.ssrfStrict === "boolean") {
|
|
5474
|
+
mergedPolicy.egress.ssrfStrict = e.ssrfStrict;
|
|
5475
|
+
ssrfStrictSource = "workspace";
|
|
5476
|
+
}
|
|
5477
|
+
const ssrfAllow = hosts(e.ssrfAllow);
|
|
5478
|
+
if (ssrfAllow)
|
|
5479
|
+
mergedPolicy.egress.ssrfAllow = sanitizeSsrfAllow(ssrfAllow, "managed egress.");
|
|
4739
5480
|
} else {
|
|
4740
5481
|
mergedPolicy.egress = applyManagedEgress(
|
|
4741
5482
|
mergedPolicy.egress,
|
|
@@ -4744,7 +5485,16 @@ function getConfig(cwd) {
|
|
|
4744
5485
|
mode: typeof mc.egress.mode === "string" ? mc.egress.mode : void 0,
|
|
4745
5486
|
allow: hosts(mc.egress.allow),
|
|
4746
5487
|
deny: hosts(mc.egress.deny),
|
|
4747
|
-
allowPrivate: typeof mc.egress.allowPrivate === "boolean" ? mc.egress.allowPrivate : void 0
|
|
5488
|
+
allowPrivate: typeof mc.egress.allowPrivate === "boolean" ? mc.egress.allowPrivate : void 0,
|
|
5489
|
+
ssrfStrict: (() => {
|
|
5490
|
+
if (typeof mc.egress.ssrfStrict !== "boolean") return void 0;
|
|
5491
|
+
ssrfStrictSource = "workspace";
|
|
5492
|
+
return mc.egress.ssrfStrict;
|
|
5493
|
+
})(),
|
|
5494
|
+
ssrfAllow: (() => {
|
|
5495
|
+
const list = hosts(mc.egress.ssrfAllow);
|
|
5496
|
+
return list ? sanitizeSsrfAllow(list, "managed egress.") : void 0;
|
|
5497
|
+
})()
|
|
4748
5498
|
},
|
|
4749
5499
|
locked,
|
|
4750
5500
|
egressModeUserSet
|
|
@@ -4839,13 +5589,13 @@ function getConfig(cwd) {
|
|
|
4839
5589
|
}
|
|
4840
5590
|
if (Array.isArray(mc.jailPaths)) {
|
|
4841
5591
|
for (const jp of mc.jailPaths) {
|
|
4842
|
-
const
|
|
4843
|
-
if (!
|
|
5592
|
+
const path15 = typeof jp?.path === "string" ? jp.path.trim() : "";
|
|
5593
|
+
if (!path15) continue;
|
|
4844
5594
|
const verdict = jp?.verdict === "review" ? "review" : "block";
|
|
4845
|
-
for (const r of pathRules(
|
|
5595
|
+
for (const r of pathRules(path15, verdict, "org-managed jail")) {
|
|
4846
5596
|
mergedPolicy.smartRules.push({ ...r, name: `org:${r.name}` });
|
|
4847
5597
|
}
|
|
4848
|
-
mergedPolicy.managedJailPaths.push({ path:
|
|
5598
|
+
mergedPolicy.managedJailPaths.push({ path: path15, verdict });
|
|
4849
5599
|
}
|
|
4850
5600
|
}
|
|
4851
5601
|
if (Array.isArray(mc.trustedHosts)) {
|
|
@@ -4974,6 +5724,7 @@ function getConfig(cwd) {
|
|
|
4974
5724
|
mergedPolicy.dangerousWords = [...new Set(mergedPolicy.dangerousWords)];
|
|
4975
5725
|
mergedPolicy.ignoredTools = [...new Set(mergedPolicy.ignoredTools)];
|
|
4976
5726
|
mergedPolicy.skillPinning.roots = [...new Set(mergedPolicy.skillPinning.roots)];
|
|
5727
|
+
const resolvedSsrfStrictSource = keyed && ssrfStrictSource === "local" ? "default" : ssrfStrictSource;
|
|
4977
5728
|
const result = {
|
|
4978
5729
|
settings: mergedSettings,
|
|
4979
5730
|
policy: mergedPolicy,
|
|
@@ -4981,7 +5732,10 @@ function getConfig(cwd) {
|
|
|
4981
5732
|
// PR-2 — the one truth introspection reads: which of the two working
|
|
4982
5733
|
// modes this machine is in. 'workspace' = keyed, policy from the cloud;
|
|
4983
5734
|
// 'local' = the local stack (incl. --local / named-profile keys).
|
|
4984
|
-
policySource: keyed ? "workspace" : "local"
|
|
5735
|
+
policySource: keyed ? "workspace" : "local",
|
|
5736
|
+
// A keyed machine drops the local policy layers wholesale, so a 'local'
|
|
5737
|
+
// provenance recorded before the fork cannot survive into the result.
|
|
5738
|
+
ssrfStrictSource: resolvedSsrfStrictSource
|
|
4985
5739
|
};
|
|
4986
5740
|
if (!cwd) cachedConfig = result;
|
|
4987
5741
|
return result;
|
|
@@ -5847,14 +6601,92 @@ end run`;
|
|
|
5847
6601
|
});
|
|
5848
6602
|
}
|
|
5849
6603
|
|
|
6604
|
+
// src/canary/registry.ts
|
|
6605
|
+
import fs10 from "fs";
|
|
6606
|
+
import os9 from "os";
|
|
6607
|
+
import path12 from "path";
|
|
6608
|
+
|
|
6609
|
+
// src/shields/jail.ts
|
|
6610
|
+
import fs9 from "fs";
|
|
6611
|
+
import os8 from "os";
|
|
6612
|
+
import path11 from "path";
|
|
6613
|
+
var USER_JAIL_SHIELD = "user-jail";
|
|
6614
|
+
function jailStorePath() {
|
|
6615
|
+
return path11.join(os8.homedir(), ".node9", "jail-paths.json");
|
|
6616
|
+
}
|
|
6617
|
+
function readJailPaths() {
|
|
6618
|
+
let text;
|
|
6619
|
+
try {
|
|
6620
|
+
text = fs9.readFileSync(jailStorePath(), "utf8");
|
|
6621
|
+
} catch (err) {
|
|
6622
|
+
if (err.code === "ENOENT") return [];
|
|
6623
|
+
throw err;
|
|
6624
|
+
}
|
|
6625
|
+
let parsed;
|
|
6626
|
+
try {
|
|
6627
|
+
parsed = JSON.parse(text);
|
|
6628
|
+
} catch {
|
|
6629
|
+
throw new Error(`${jailStorePath()} is not valid JSON \u2014 fix it before changing the jail.`);
|
|
6630
|
+
}
|
|
6631
|
+
if (!Array.isArray(parsed.paths)) return [];
|
|
6632
|
+
return parsed.paths.filter(
|
|
6633
|
+
(p) => !!p && typeof p.path === "string" && (p.verdict === "block" || p.verdict === "review")
|
|
6634
|
+
);
|
|
6635
|
+
}
|
|
6636
|
+
function findJailedPath(candidate) {
|
|
6637
|
+
return findJailedPathIn(candidate, readJailPaths());
|
|
6638
|
+
}
|
|
6639
|
+
function findJailedPathIn(candidate, paths) {
|
|
6640
|
+
if (!candidate) return null;
|
|
6641
|
+
for (const entry of paths) {
|
|
6642
|
+
if (pathMatchesFragment(candidate, entry.path)) return entry;
|
|
6643
|
+
}
|
|
6644
|
+
return null;
|
|
6645
|
+
}
|
|
6646
|
+
|
|
6647
|
+
// src/canary/registry.ts
|
|
6648
|
+
function canaryStorePath() {
|
|
6649
|
+
return path12.join(os9.homedir(), ".node9", "canaries.json");
|
|
6650
|
+
}
|
|
6651
|
+
function isRecord(x) {
|
|
6652
|
+
if (!x || typeof x !== "object") return false;
|
|
6653
|
+
const r = x;
|
|
6654
|
+
return typeof r.id === "string" && typeof r.value === "string" && typeof r.valueHash === "string";
|
|
6655
|
+
}
|
|
6656
|
+
function loadCanaries(opts) {
|
|
6657
|
+
const p = canaryStorePath();
|
|
6658
|
+
let raw;
|
|
6659
|
+
try {
|
|
6660
|
+
raw = fs10.readFileSync(p, "utf-8");
|
|
6661
|
+
} catch (e) {
|
|
6662
|
+
if (e.code === "ENOENT") return [];
|
|
6663
|
+
throw e;
|
|
6664
|
+
}
|
|
6665
|
+
let parsed;
|
|
6666
|
+
try {
|
|
6667
|
+
parsed = JSON.parse(raw);
|
|
6668
|
+
} catch (e) {
|
|
6669
|
+
throw new Error(
|
|
6670
|
+
`[node9] ${p} is not valid JSON; refusing to touch it (${e instanceof Error ? e.message : String(e)})`
|
|
6671
|
+
);
|
|
6672
|
+
}
|
|
6673
|
+
const recs = parsed?.records;
|
|
6674
|
+
if (!Array.isArray(recs)) return [];
|
|
6675
|
+
const out = recs.filter(isRecord);
|
|
6676
|
+
return opts?.includeRetired === false ? out.filter((r) => !r.retiredAt) : out;
|
|
6677
|
+
}
|
|
6678
|
+
function canaryValues() {
|
|
6679
|
+
return loadCanaries().map((r) => ({ id: r.id, value: r.value, retired: Boolean(r.retiredAt) }));
|
|
6680
|
+
}
|
|
6681
|
+
|
|
5850
6682
|
// src/auth/orchestrator.ts
|
|
5851
6683
|
init_audit();
|
|
5852
6684
|
|
|
5853
6685
|
// src/auth/cloud.ts
|
|
5854
6686
|
init_audit();
|
|
5855
|
-
import
|
|
5856
|
-
import
|
|
5857
|
-
import
|
|
6687
|
+
import fs11 from "fs";
|
|
6688
|
+
import os10 from "os";
|
|
6689
|
+
import path13 from "path";
|
|
5858
6690
|
async function initNode9SaaS(toolName, args, creds, meta, riskMetadata, agentPolicy, forceReview) {
|
|
5859
6691
|
const controller = new AbortController();
|
|
5860
6692
|
const timeout = setTimeout(() => controller.abort(), 1e4);
|
|
@@ -5862,10 +6694,10 @@ async function initNode9SaaS(toolName, args, creds, meta, riskMetadata, agentPol
|
|
|
5862
6694
|
let ciContext;
|
|
5863
6695
|
if (process.env.CI) {
|
|
5864
6696
|
try {
|
|
5865
|
-
const ciContextPath =
|
|
5866
|
-
const stats =
|
|
6697
|
+
const ciContextPath = path13.join(os10.homedir(), ".node9", "ci-context.json");
|
|
6698
|
+
const stats = fs11.statSync(ciContextPath);
|
|
5867
6699
|
if (stats.size > 1e4) throw new Error("ci-context.json exceeds 10 KB");
|
|
5868
|
-
const raw =
|
|
6700
|
+
const raw = fs11.readFileSync(ciContextPath, "utf8");
|
|
5869
6701
|
const parsed = JSON.parse(raw);
|
|
5870
6702
|
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
5871
6703
|
throw new Error("ci-context.json is not a plain object");
|
|
@@ -5898,9 +6730,9 @@ async function initNode9SaaS(toolName, args, creds, meta, riskMetadata, agentPol
|
|
|
5898
6730
|
context: {
|
|
5899
6731
|
agent: meta?.agent,
|
|
5900
6732
|
mcpServer: meta?.mcpServer,
|
|
5901
|
-
hostname:
|
|
6733
|
+
hostname: os10.hostname(),
|
|
5902
6734
|
cwd: process.cwd(),
|
|
5903
|
-
platform:
|
|
6735
|
+
platform: os10.platform()
|
|
5904
6736
|
},
|
|
5905
6737
|
...riskMetadata && { riskMetadata },
|
|
5906
6738
|
...ciContext && { ciContext },
|
|
@@ -5964,14 +6796,14 @@ async function resolveNode9SaaS(requestId, creds, approved, decidedBy) {
|
|
|
5964
6796
|
});
|
|
5965
6797
|
clearTimeout(timer);
|
|
5966
6798
|
if (!res.ok) {
|
|
5967
|
-
|
|
6799
|
+
fs11.appendFileSync(
|
|
5968
6800
|
HOOK_DEBUG_LOG,
|
|
5969
6801
|
`[resolve-cloud] PATCH ${resolveUrl} \u2192 HTTP ${res.status}
|
|
5970
6802
|
`
|
|
5971
6803
|
);
|
|
5972
6804
|
}
|
|
5973
6805
|
} catch (err) {
|
|
5974
|
-
|
|
6806
|
+
fs11.appendFileSync(
|
|
5975
6807
|
HOOK_DEBUG_LOG,
|
|
5976
6808
|
`[resolve-cloud] PATCH failed for ${requestId}: ${err.message}
|
|
5977
6809
|
`
|
|
@@ -5980,16 +6812,16 @@ async function resolveNode9SaaS(requestId, creds, approved, decidedBy) {
|
|
|
5980
6812
|
}
|
|
5981
6813
|
|
|
5982
6814
|
// src/loop-detector.ts
|
|
5983
|
-
import
|
|
5984
|
-
import
|
|
5985
|
-
import
|
|
6815
|
+
import fs12 from "fs";
|
|
6816
|
+
import path14 from "path";
|
|
6817
|
+
import os11 from "os";
|
|
5986
6818
|
function loopStateFile() {
|
|
5987
|
-
return
|
|
6819
|
+
return path14.join(os11.homedir(), ".node9", "loop-state.json");
|
|
5988
6820
|
}
|
|
5989
6821
|
function readState() {
|
|
5990
6822
|
try {
|
|
5991
|
-
if (!
|
|
5992
|
-
const raw =
|
|
6823
|
+
if (!fs12.existsSync(loopStateFile())) return [];
|
|
6824
|
+
const raw = fs12.readFileSync(loopStateFile(), "utf-8");
|
|
5993
6825
|
const parsed = JSON.parse(raw);
|
|
5994
6826
|
if (!Array.isArray(parsed)) return [];
|
|
5995
6827
|
return parsed;
|
|
@@ -5998,11 +6830,11 @@ function readState() {
|
|
|
5998
6830
|
}
|
|
5999
6831
|
}
|
|
6000
6832
|
function writeState(records) {
|
|
6001
|
-
const dir =
|
|
6002
|
-
if (!
|
|
6003
|
-
const tmpPath = `${loopStateFile()}.${
|
|
6004
|
-
|
|
6005
|
-
|
|
6833
|
+
const dir = path14.dirname(loopStateFile());
|
|
6834
|
+
if (!fs12.existsSync(dir)) fs12.mkdirSync(dir, { recursive: true });
|
|
6835
|
+
const tmpPath = `${loopStateFile()}.${os11.hostname()}.${process.pid}.tmp`;
|
|
6836
|
+
fs12.writeFileSync(tmpPath, JSON.stringify(records));
|
|
6837
|
+
fs12.renameSync(tmpPath, loopStateFile());
|
|
6006
6838
|
}
|
|
6007
6839
|
function recordAndCheck(tool, args, threshold = 3, windowMs = 12e4) {
|
|
6008
6840
|
try {
|
|
@@ -6014,44 +6846,6 @@ function recordAndCheck(tool, args, threshold = 3, windowMs = 12e4) {
|
|
|
6014
6846
|
}
|
|
6015
6847
|
}
|
|
6016
6848
|
|
|
6017
|
-
// src/shields/jail.ts
|
|
6018
|
-
import fs11 from "fs";
|
|
6019
|
-
import os10 from "os";
|
|
6020
|
-
import path13 from "path";
|
|
6021
|
-
var USER_JAIL_SHIELD = "user-jail";
|
|
6022
|
-
function jailStorePath() {
|
|
6023
|
-
return path13.join(os10.homedir(), ".node9", "jail-paths.json");
|
|
6024
|
-
}
|
|
6025
|
-
function readJailPaths() {
|
|
6026
|
-
let text;
|
|
6027
|
-
try {
|
|
6028
|
-
text = fs11.readFileSync(jailStorePath(), "utf8");
|
|
6029
|
-
} catch (err) {
|
|
6030
|
-
if (err.code === "ENOENT") return [];
|
|
6031
|
-
throw err;
|
|
6032
|
-
}
|
|
6033
|
-
let parsed;
|
|
6034
|
-
try {
|
|
6035
|
-
parsed = JSON.parse(text);
|
|
6036
|
-
} catch {
|
|
6037
|
-
throw new Error(`${jailStorePath()} is not valid JSON \u2014 fix it before changing the jail.`);
|
|
6038
|
-
}
|
|
6039
|
-
if (!Array.isArray(parsed.paths)) return [];
|
|
6040
|
-
return parsed.paths.filter(
|
|
6041
|
-
(p) => !!p && typeof p.path === "string" && (p.verdict === "block" || p.verdict === "review")
|
|
6042
|
-
);
|
|
6043
|
-
}
|
|
6044
|
-
function findJailedPath(candidate) {
|
|
6045
|
-
return findJailedPathIn(candidate, readJailPaths());
|
|
6046
|
-
}
|
|
6047
|
-
function findJailedPathIn(candidate, paths) {
|
|
6048
|
-
if (!candidate) return null;
|
|
6049
|
-
for (const entry of paths) {
|
|
6050
|
-
if (pathMatchesFragment(candidate, entry.path)) return entry;
|
|
6051
|
-
}
|
|
6052
|
-
return null;
|
|
6053
|
-
}
|
|
6054
|
-
|
|
6055
6849
|
// src/auth/orchestrator.ts
|
|
6056
6850
|
var WRITE_TOOLS = /* @__PURE__ */ new Set([
|
|
6057
6851
|
"write",
|
|
@@ -6237,6 +7031,53 @@ async function _authorizeHeadlessCore(toolName, args, metaArg, options) {
|
|
|
6237
7031
|
taintWarning = `\u26A0\uFE0F node9 flagged this session \u2014 earlier tool output contained ${sessionTaint.record.source}. Approve this ${isWriteTool(toolName) ? "write" : "network"} action before it proceeds.`;
|
|
6238
7032
|
}
|
|
6239
7033
|
}
|
|
7034
|
+
const canaryVals = safeCanaryValues();
|
|
7035
|
+
if (canaryVals.length > 0) {
|
|
7036
|
+
const canaryHit = matchCanaryArgs(args, canaryVals);
|
|
7037
|
+
if (canaryHit) {
|
|
7038
|
+
const rec = canaryRecordById(canaryHit.id);
|
|
7039
|
+
const shape = scanArgs(args);
|
|
7040
|
+
const canaryReason = `\u{1F6A8} DECOY CREDENTIAL: the fake ${rec?.kind ?? "credential"} node9 planted at ${rec?.path ?? "a decoy file"} appeared in field "${canaryHit.fieldPath || "args"}". Something read that file; nothing legitimate does.`;
|
|
7041
|
+
if (!isManual)
|
|
7042
|
+
appendLocalAudit(
|
|
7043
|
+
toolName,
|
|
7044
|
+
args,
|
|
7045
|
+
"deny",
|
|
7046
|
+
isObserveMode ? "observe-mode-dlp-canary-would-block" : "dlp-canary-block",
|
|
7047
|
+
{
|
|
7048
|
+
...meta,
|
|
7049
|
+
canaryId: canaryHit.id,
|
|
7050
|
+
canaryHash: rec?.valueHash,
|
|
7051
|
+
canaryKind: rec?.kind,
|
|
7052
|
+
canaryPath: rec?.path,
|
|
7053
|
+
canaryView: canaryHit.view,
|
|
7054
|
+
canaryRetired: canaryHit.retired,
|
|
7055
|
+
...shape ? { dlpPattern: shape.patternName, dlpSample: shape.redactedSample } : {}
|
|
7056
|
+
},
|
|
7057
|
+
true
|
|
7058
|
+
);
|
|
7059
|
+
if (isObserveMode) {
|
|
7060
|
+
return {
|
|
7061
|
+
approved: true,
|
|
7062
|
+
checkedBy: "audit",
|
|
7063
|
+
observeWouldBlock: true,
|
|
7064
|
+
blockedByLabel: "\u{1F6A8} Node9 DLP (Decoy Credential)"
|
|
7065
|
+
};
|
|
7066
|
+
}
|
|
7067
|
+
return {
|
|
7068
|
+
approved: false,
|
|
7069
|
+
reason: canaryReason,
|
|
7070
|
+
blockedBy: "local-config",
|
|
7071
|
+
blockedByLabel: "\u{1F6A8} Node9 DLP (Decoy Credential)",
|
|
7072
|
+
// The /dev/tty banner renders ruleDescription under "Triggered by".
|
|
7073
|
+
// Without it the terminal said only "Decoy Credential" and never named
|
|
7074
|
+
// the file, while `node9 canary plant` promises node9 tells you which
|
|
7075
|
+
// file was read. Witnessed by canary-block-message.spec.ts, which calls
|
|
7076
|
+
// the orchestrator directly: this field never reaches the hook stdout.
|
|
7077
|
+
ruleDescription: `The fake credential node9 planted in ${rec?.path ?? "a decoy file"} just left that file. Nothing legitimate reads it.`
|
|
7078
|
+
};
|
|
7079
|
+
}
|
|
7080
|
+
}
|
|
6240
7081
|
if (config.policy.dlp.enabled && (!isIgnoredTool2(toolName) || config.policy.dlp.scanIgnoredTools)) {
|
|
6241
7082
|
const argsObj = args && typeof args === "object" && !Array.isArray(args) ? args : {};
|
|
6242
7083
|
const filePath = String(argsObj.file_path ?? argsObj.path ?? argsObj.filename ?? "");
|
|
@@ -6892,6 +7733,20 @@ async function authorizeAction(toolName, args) {
|
|
|
6892
7733
|
const result = await authorizeHeadless(toolName, args);
|
|
6893
7734
|
return result.approved;
|
|
6894
7735
|
}
|
|
7736
|
+
function safeCanaryValues() {
|
|
7737
|
+
try {
|
|
7738
|
+
return canaryValues();
|
|
7739
|
+
} catch {
|
|
7740
|
+
return [];
|
|
7741
|
+
}
|
|
7742
|
+
}
|
|
7743
|
+
function canaryRecordById(id) {
|
|
7744
|
+
try {
|
|
7745
|
+
return loadCanaries().find((r) => r.id === id) ?? null;
|
|
7746
|
+
} catch {
|
|
7747
|
+
return null;
|
|
7748
|
+
}
|
|
7749
|
+
}
|
|
6895
7750
|
|
|
6896
7751
|
// src/index.ts
|
|
6897
7752
|
function protect(toolName, fn) {
|