@node9/proxy 2.8.4 → 2.9.0
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 +3775 -1865
- package/dist/cli.mjs +3711 -1801
- package/dist/dashboard.mjs +854 -165
- package/dist/index.js +972 -113
- package/dist/index.mjs +968 -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();
|
|
@@ -2244,6 +2514,10 @@ function analyzeShellCommand(command) {
|
|
|
2244
2514
|
return { actions, paths, allTokens };
|
|
2245
2515
|
}
|
|
2246
2516
|
var DEFAULT_EGRESS_ALLOWLIST = [
|
|
2517
|
+
// node9's own control plane (api, app, dev-api, staging and the apex).
|
|
2518
|
+
// Without it, turning egress on asks the user to approve node9 itself.
|
|
2519
|
+
// A user `deny` entry still wins over this list, see evaluateEgress.
|
|
2520
|
+
"*.node9.ai",
|
|
2247
2521
|
"*.github.com",
|
|
2248
2522
|
"*.githubusercontent.com",
|
|
2249
2523
|
"*.npmjs.org",
|
|
@@ -2386,7 +2660,7 @@ function isSensitivePath(p) {
|
|
|
2386
2660
|
return SENSITIVE_PATTERNS.some((re) => re.test(p));
|
|
2387
2661
|
}
|
|
2388
2662
|
function splitOnPipe(cmd) {
|
|
2389
|
-
const
|
|
2663
|
+
const segments2 = [];
|
|
2390
2664
|
let current = "";
|
|
2391
2665
|
let inSingle = false;
|
|
2392
2666
|
let inDouble = false;
|
|
@@ -2399,21 +2673,21 @@ function splitOnPipe(cmd) {
|
|
|
2399
2673
|
inDouble = !inDouble;
|
|
2400
2674
|
current += ch;
|
|
2401
2675
|
} else if (ch === "|" && !inSingle && !inDouble && cmd[i + 1] !== "|" && (i === 0 || cmd[i - 1] !== "|")) {
|
|
2402
|
-
|
|
2676
|
+
segments2.push(current.trim());
|
|
2403
2677
|
current = "";
|
|
2404
2678
|
} else {
|
|
2405
2679
|
current += ch;
|
|
2406
2680
|
}
|
|
2407
2681
|
}
|
|
2408
|
-
if (current.trim())
|
|
2409
|
-
return
|
|
2682
|
+
if (current.trim()) segments2.push(current.trim());
|
|
2683
|
+
return segments2.filter(Boolean);
|
|
2410
2684
|
}
|
|
2411
2685
|
function positionalTokens(segment) {
|
|
2412
2686
|
return segment.split(/\s+/).slice(1).filter((t) => !t.startsWith("-") && !t.startsWith("@") && t.length > 0);
|
|
2413
2687
|
}
|
|
2414
2688
|
function analyzePipeChain(command) {
|
|
2415
|
-
const
|
|
2416
|
-
if (
|
|
2689
|
+
const segments2 = splitOnPipe(command);
|
|
2690
|
+
if (segments2.length < 2) {
|
|
2417
2691
|
return {
|
|
2418
2692
|
isPipeline: false,
|
|
2419
2693
|
hasSensitiveSource: false,
|
|
@@ -2429,7 +2703,7 @@ function analyzePipeChain(command) {
|
|
|
2429
2703
|
let hasSensitiveSource = false;
|
|
2430
2704
|
let hasExternalSink = false;
|
|
2431
2705
|
let hasObfuscation = false;
|
|
2432
|
-
for (const segment of
|
|
2706
|
+
for (const segment of segments2) {
|
|
2433
2707
|
const tokens = segment.split(/\s+/).filter(Boolean);
|
|
2434
2708
|
if (tokens.length === 0) continue;
|
|
2435
2709
|
const binary = tokens[0].toLowerCase();
|
|
@@ -2467,8 +2741,8 @@ function analyzePipeChain(command) {
|
|
|
2467
2741
|
};
|
|
2468
2742
|
}
|
|
2469
2743
|
function basename(p) {
|
|
2470
|
-
const
|
|
2471
|
-
return
|
|
2744
|
+
const segments2 = p.split(/[\\/]/);
|
|
2745
|
+
return segments2[segments2.length - 1] || "";
|
|
2472
2746
|
}
|
|
2473
2747
|
var FLAGS_WITH_VALUES = {
|
|
2474
2748
|
curl: /* @__PURE__ */ new Set([
|
|
@@ -2631,6 +2905,204 @@ function extractAllSshHosts(tokens) {
|
|
|
2631
2905
|
}
|
|
2632
2906
|
return [...hosts].filter(Boolean);
|
|
2633
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
|
+
}
|
|
2634
3106
|
function resolveCheck(v) {
|
|
2635
3107
|
return v === "off" || v === "block" ? v : "review";
|
|
2636
3108
|
}
|
|
@@ -2863,6 +3335,22 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
|
|
|
2863
3335
|
}
|
|
2864
3336
|
const builtin = strictestVerdict(candidates);
|
|
2865
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
|
+
}
|
|
2866
3354
|
if (config.policy.egress?.enabled) {
|
|
2867
3355
|
const dests = extractShellDestinations(shellCommand);
|
|
2868
3356
|
if (dests.length > 0) {
|
|
@@ -3804,28 +4292,236 @@ function evaluateLoopWindow(records, tool, args, threshold, windowMs, now) {
|
|
|
3804
4292
|
var PII_EMAIL_RE = /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/;
|
|
3805
4293
|
var PII_SSN_RE = /\b\d{3}-\d{2}-\d{4}\b/;
|
|
3806
4294
|
var PII_PHONE_RE = /\b(?:\+?1[-.\s]?)?\(?\d{3}\)?[-.\s]\d{3}[-.\s]\d{4}\b/;
|
|
3807
|
-
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
|
+
}
|
|
3808
4318
|
function detectPii(text) {
|
|
3809
4319
|
const found = /* @__PURE__ */ new Set();
|
|
3810
4320
|
if (/@/.test(text) && PII_EMAIL_RE.test(text)) found.add("Email");
|
|
3811
4321
|
if (/-/.test(text) && PII_SSN_RE.test(text)) found.add("SSN");
|
|
3812
4322
|
if (PII_PHONE_RE.test(text)) found.add("Phone");
|
|
3813
|
-
if (
|
|
4323
|
+
if (hasValidCard(text)) found.add("Credit Card");
|
|
4324
|
+
if (hasValidIban(text)) found.add("IBAN");
|
|
3814
4325
|
return [...found];
|
|
3815
4326
|
}
|
|
3816
|
-
var REALTIME_PII_PATTERNS = ["SSN", "Credit Card"];
|
|
4327
|
+
var REALTIME_PII_PATTERNS = ["SSN", "Credit Card", "IBAN"];
|
|
3817
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
|
+
}
|
|
3818
4346
|
function detectArgsPii(args) {
|
|
3819
4347
|
if (args === null || args === void 0) return [];
|
|
3820
|
-
|
|
4348
|
+
const found = /* @__PURE__ */ new Set();
|
|
4349
|
+
let budget = MAX_PII_SCAN_BYTES;
|
|
3821
4350
|
try {
|
|
3822
|
-
|
|
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
|
+
}
|
|
3823
4359
|
} catch {
|
|
3824
4360
|
return [];
|
|
3825
4361
|
}
|
|
3826
|
-
|
|
3827
|
-
|
|
3828
|
-
|
|
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
|
+
}
|
|
3829
4525
|
}
|
|
3830
4526
|
var LONG_OUTPUT_THRESHOLD_BYTES = 100 * 1024;
|
|
3831
4527
|
|
|
@@ -3955,6 +4651,12 @@ function applyManagedEgress(local, managed, locked, localModeUserSet = true) {
|
|
|
3955
4651
|
if (Array.isArray(managed.deny) && managed.deny.length > 0) {
|
|
3956
4652
|
next.deny = [.../* @__PURE__ */ new Set([...local.deny ?? [], ...managed.deny])];
|
|
3957
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
|
+
}
|
|
3958
4660
|
if (typeof managed.allowPrivate === "boolean") {
|
|
3959
4661
|
next.allowPrivate = locked.includes("egressAllowPrivate") ? managed.allowPrivate : (local.allowPrivate ?? true) && managed.allowPrivate;
|
|
3960
4662
|
}
|
|
@@ -4036,9 +4738,9 @@ var B = "[\\s/\\\\]";
|
|
|
4036
4738
|
var SEP = "[/\\\\]";
|
|
4037
4739
|
function pathToRegexFragment(rawPath) {
|
|
4038
4740
|
const tail = rawPath.trim().replace(/^~[\\/]?/, "").replace(/^\$\{?HOME\}?[\\/]?/, "").replace(/^\/(?:home|Users)\/[^\\/]+[\\/]?/, "").replace(/^[A-Za-z]:[\\/]Users[\\/][^\\/]+[\\/]?/, "").replace(/^[\\/]+/, "").replace(/[\\/]+$/, "");
|
|
4039
|
-
const
|
|
4040
|
-
if (
|
|
4041
|
-
return `(^|${B})${
|
|
4741
|
+
const segments2 = tail.split(/[\\/]+/).filter(Boolean).map(escapeRegex);
|
|
4742
|
+
if (segments2.length === 0) return "";
|
|
4743
|
+
return `(^|${B})${segments2.join(SEP)}(${B}|$)`;
|
|
4042
4744
|
}
|
|
4043
4745
|
function pathMatchesFragment(candidate, rawPath) {
|
|
4044
4746
|
const value = pathToRegexFragment(rawPath);
|
|
@@ -4143,6 +4845,20 @@ function isTrustedHost(host) {
|
|
|
4143
4845
|
}
|
|
4144
4846
|
|
|
4145
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
|
+
}
|
|
4146
4862
|
var DANGEROUS_WORDS = [
|
|
4147
4863
|
"mkfs",
|
|
4148
4864
|
// formats/wipes a filesystem partition
|
|
@@ -4152,6 +4868,7 @@ var DANGEROUS_WORDS = [
|
|
|
4152
4868
|
var DEFAULT_CONFIG = {
|
|
4153
4869
|
version: "1.0",
|
|
4154
4870
|
policySource: "local",
|
|
4871
|
+
ssrfStrictSource: "default",
|
|
4155
4872
|
settings: {
|
|
4156
4873
|
mode: "standard",
|
|
4157
4874
|
autoStartDaemon: true,
|
|
@@ -4335,7 +5052,17 @@ var DEFAULT_CONFIG = {
|
|
|
4335
5052
|
}
|
|
4336
5053
|
],
|
|
4337
5054
|
dlp: { enabled: true, scanIgnoredTools: true, pii: "off" },
|
|
4338
|
-
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
|
+
},
|
|
4339
5066
|
loopDetection: { enabled: true, threshold: 5, windowSeconds: 120 },
|
|
4340
5067
|
injectionScan: { enabled: false, minConfidence: "medium", allow: [] },
|
|
4341
5068
|
skillPinning: { enabled: false, mode: "warn", roots: [] },
|
|
@@ -4518,7 +5245,8 @@ function getConfig(cwd) {
|
|
|
4518
5245
|
egress: {
|
|
4519
5246
|
...DEFAULT_CONFIG.policy.egress,
|
|
4520
5247
|
allow: [...DEFAULT_CONFIG.policy.egress.allow],
|
|
4521
|
-
deny: [...DEFAULT_CONFIG.policy.egress.deny]
|
|
5248
|
+
deny: [...DEFAULT_CONFIG.policy.egress.deny],
|
|
5249
|
+
ssrfAllow: [...DEFAULT_CONFIG.policy.egress.ssrfAllow ?? []]
|
|
4522
5250
|
},
|
|
4523
5251
|
loopDetection: { ...DEFAULT_CONFIG.policy.loopDetection },
|
|
4524
5252
|
injectionScan: {
|
|
@@ -4544,6 +5272,7 @@ function getConfig(cwd) {
|
|
|
4544
5272
|
};
|
|
4545
5273
|
const pr2Creds = getCredentials();
|
|
4546
5274
|
const keyed = !!pr2Creds?.apiKey && pr2Creds.localOnly !== true;
|
|
5275
|
+
let ssrfStrictSource = "default";
|
|
4547
5276
|
const applyLayer = (source, isProject = false, isCloud = false) => {
|
|
4548
5277
|
if (!source) return;
|
|
4549
5278
|
const s = source.settings || {};
|
|
@@ -4626,6 +5355,15 @@ function getConfig(cwd) {
|
|
|
4626
5355
|
if (Array.isArray(e.deny)) mergedPolicy.egress.deny.push(...e.deny);
|
|
4627
5356
|
if (e.allowPrivate !== void 0 && !(isProject && e.allowPrivate === true))
|
|
4628
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
|
+
}
|
|
4629
5367
|
}
|
|
4630
5368
|
if (p.loopDetection) {
|
|
4631
5369
|
const ld = p.loopDetection;
|
|
@@ -4732,6 +5470,13 @@ function getConfig(cwd) {
|
|
|
4732
5470
|
if (deny) mergedPolicy.egress.deny = deny;
|
|
4733
5471
|
if (typeof e.allowPrivate === "boolean")
|
|
4734
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.");
|
|
4735
5480
|
} else {
|
|
4736
5481
|
mergedPolicy.egress = applyManagedEgress(
|
|
4737
5482
|
mergedPolicy.egress,
|
|
@@ -4740,7 +5485,16 @@ function getConfig(cwd) {
|
|
|
4740
5485
|
mode: typeof mc.egress.mode === "string" ? mc.egress.mode : void 0,
|
|
4741
5486
|
allow: hosts(mc.egress.allow),
|
|
4742
5487
|
deny: hosts(mc.egress.deny),
|
|
4743
|
-
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
|
+
})()
|
|
4744
5498
|
},
|
|
4745
5499
|
locked,
|
|
4746
5500
|
egressModeUserSet
|
|
@@ -4835,13 +5589,13 @@ function getConfig(cwd) {
|
|
|
4835
5589
|
}
|
|
4836
5590
|
if (Array.isArray(mc.jailPaths)) {
|
|
4837
5591
|
for (const jp of mc.jailPaths) {
|
|
4838
|
-
const
|
|
4839
|
-
if (!
|
|
5592
|
+
const path15 = typeof jp?.path === "string" ? jp.path.trim() : "";
|
|
5593
|
+
if (!path15) continue;
|
|
4840
5594
|
const verdict = jp?.verdict === "review" ? "review" : "block";
|
|
4841
|
-
for (const r of pathRules(
|
|
5595
|
+
for (const r of pathRules(path15, verdict, "org-managed jail")) {
|
|
4842
5596
|
mergedPolicy.smartRules.push({ ...r, name: `org:${r.name}` });
|
|
4843
5597
|
}
|
|
4844
|
-
mergedPolicy.managedJailPaths.push({ path:
|
|
5598
|
+
mergedPolicy.managedJailPaths.push({ path: path15, verdict });
|
|
4845
5599
|
}
|
|
4846
5600
|
}
|
|
4847
5601
|
if (Array.isArray(mc.trustedHosts)) {
|
|
@@ -4970,6 +5724,7 @@ function getConfig(cwd) {
|
|
|
4970
5724
|
mergedPolicy.dangerousWords = [...new Set(mergedPolicy.dangerousWords)];
|
|
4971
5725
|
mergedPolicy.ignoredTools = [...new Set(mergedPolicy.ignoredTools)];
|
|
4972
5726
|
mergedPolicy.skillPinning.roots = [...new Set(mergedPolicy.skillPinning.roots)];
|
|
5727
|
+
const resolvedSsrfStrictSource = keyed && ssrfStrictSource === "local" ? "default" : ssrfStrictSource;
|
|
4973
5728
|
const result = {
|
|
4974
5729
|
settings: mergedSettings,
|
|
4975
5730
|
policy: mergedPolicy,
|
|
@@ -4977,7 +5732,10 @@ function getConfig(cwd) {
|
|
|
4977
5732
|
// PR-2 — the one truth introspection reads: which of the two working
|
|
4978
5733
|
// modes this machine is in. 'workspace' = keyed, policy from the cloud;
|
|
4979
5734
|
// 'local' = the local stack (incl. --local / named-profile keys).
|
|
4980
|
-
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
|
|
4981
5739
|
};
|
|
4982
5740
|
if (!cwd) cachedConfig = result;
|
|
4983
5741
|
return result;
|
|
@@ -5843,14 +6601,92 @@ end run`;
|
|
|
5843
6601
|
});
|
|
5844
6602
|
}
|
|
5845
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
|
+
|
|
5846
6682
|
// src/auth/orchestrator.ts
|
|
5847
6683
|
init_audit();
|
|
5848
6684
|
|
|
5849
6685
|
// src/auth/cloud.ts
|
|
5850
6686
|
init_audit();
|
|
5851
|
-
import
|
|
5852
|
-
import
|
|
5853
|
-
import
|
|
6687
|
+
import fs11 from "fs";
|
|
6688
|
+
import os10 from "os";
|
|
6689
|
+
import path13 from "path";
|
|
5854
6690
|
async function initNode9SaaS(toolName, args, creds, meta, riskMetadata, agentPolicy, forceReview) {
|
|
5855
6691
|
const controller = new AbortController();
|
|
5856
6692
|
const timeout = setTimeout(() => controller.abort(), 1e4);
|
|
@@ -5858,10 +6694,10 @@ async function initNode9SaaS(toolName, args, creds, meta, riskMetadata, agentPol
|
|
|
5858
6694
|
let ciContext;
|
|
5859
6695
|
if (process.env.CI) {
|
|
5860
6696
|
try {
|
|
5861
|
-
const ciContextPath =
|
|
5862
|
-
const stats =
|
|
6697
|
+
const ciContextPath = path13.join(os10.homedir(), ".node9", "ci-context.json");
|
|
6698
|
+
const stats = fs11.statSync(ciContextPath);
|
|
5863
6699
|
if (stats.size > 1e4) throw new Error("ci-context.json exceeds 10 KB");
|
|
5864
|
-
const raw =
|
|
6700
|
+
const raw = fs11.readFileSync(ciContextPath, "utf8");
|
|
5865
6701
|
const parsed = JSON.parse(raw);
|
|
5866
6702
|
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
5867
6703
|
throw new Error("ci-context.json is not a plain object");
|
|
@@ -5894,9 +6730,9 @@ async function initNode9SaaS(toolName, args, creds, meta, riskMetadata, agentPol
|
|
|
5894
6730
|
context: {
|
|
5895
6731
|
agent: meta?.agent,
|
|
5896
6732
|
mcpServer: meta?.mcpServer,
|
|
5897
|
-
hostname:
|
|
6733
|
+
hostname: os10.hostname(),
|
|
5898
6734
|
cwd: process.cwd(),
|
|
5899
|
-
platform:
|
|
6735
|
+
platform: os10.platform()
|
|
5900
6736
|
},
|
|
5901
6737
|
...riskMetadata && { riskMetadata },
|
|
5902
6738
|
...ciContext && { ciContext },
|
|
@@ -5960,14 +6796,14 @@ async function resolveNode9SaaS(requestId, creds, approved, decidedBy) {
|
|
|
5960
6796
|
});
|
|
5961
6797
|
clearTimeout(timer);
|
|
5962
6798
|
if (!res.ok) {
|
|
5963
|
-
|
|
6799
|
+
fs11.appendFileSync(
|
|
5964
6800
|
HOOK_DEBUG_LOG,
|
|
5965
6801
|
`[resolve-cloud] PATCH ${resolveUrl} \u2192 HTTP ${res.status}
|
|
5966
6802
|
`
|
|
5967
6803
|
);
|
|
5968
6804
|
}
|
|
5969
6805
|
} catch (err) {
|
|
5970
|
-
|
|
6806
|
+
fs11.appendFileSync(
|
|
5971
6807
|
HOOK_DEBUG_LOG,
|
|
5972
6808
|
`[resolve-cloud] PATCH failed for ${requestId}: ${err.message}
|
|
5973
6809
|
`
|
|
@@ -5976,16 +6812,16 @@ async function resolveNode9SaaS(requestId, creds, approved, decidedBy) {
|
|
|
5976
6812
|
}
|
|
5977
6813
|
|
|
5978
6814
|
// src/loop-detector.ts
|
|
5979
|
-
import
|
|
5980
|
-
import
|
|
5981
|
-
import
|
|
6815
|
+
import fs12 from "fs";
|
|
6816
|
+
import path14 from "path";
|
|
6817
|
+
import os11 from "os";
|
|
5982
6818
|
function loopStateFile() {
|
|
5983
|
-
return
|
|
6819
|
+
return path14.join(os11.homedir(), ".node9", "loop-state.json");
|
|
5984
6820
|
}
|
|
5985
6821
|
function readState() {
|
|
5986
6822
|
try {
|
|
5987
|
-
if (!
|
|
5988
|
-
const raw =
|
|
6823
|
+
if (!fs12.existsSync(loopStateFile())) return [];
|
|
6824
|
+
const raw = fs12.readFileSync(loopStateFile(), "utf-8");
|
|
5989
6825
|
const parsed = JSON.parse(raw);
|
|
5990
6826
|
if (!Array.isArray(parsed)) return [];
|
|
5991
6827
|
return parsed;
|
|
@@ -5994,11 +6830,11 @@ function readState() {
|
|
|
5994
6830
|
}
|
|
5995
6831
|
}
|
|
5996
6832
|
function writeState(records) {
|
|
5997
|
-
const dir =
|
|
5998
|
-
if (!
|
|
5999
|
-
const tmpPath = `${loopStateFile()}.${
|
|
6000
|
-
|
|
6001
|
-
|
|
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());
|
|
6002
6838
|
}
|
|
6003
6839
|
function recordAndCheck(tool, args, threshold = 3, windowMs = 12e4) {
|
|
6004
6840
|
try {
|
|
@@ -6010,44 +6846,6 @@ function recordAndCheck(tool, args, threshold = 3, windowMs = 12e4) {
|
|
|
6010
6846
|
}
|
|
6011
6847
|
}
|
|
6012
6848
|
|
|
6013
|
-
// src/shields/jail.ts
|
|
6014
|
-
import fs11 from "fs";
|
|
6015
|
-
import os10 from "os";
|
|
6016
|
-
import path13 from "path";
|
|
6017
|
-
var USER_JAIL_SHIELD = "user-jail";
|
|
6018
|
-
function jailStorePath() {
|
|
6019
|
-
return path13.join(os10.homedir(), ".node9", "jail-paths.json");
|
|
6020
|
-
}
|
|
6021
|
-
function readJailPaths() {
|
|
6022
|
-
let text;
|
|
6023
|
-
try {
|
|
6024
|
-
text = fs11.readFileSync(jailStorePath(), "utf8");
|
|
6025
|
-
} catch (err) {
|
|
6026
|
-
if (err.code === "ENOENT") return [];
|
|
6027
|
-
throw err;
|
|
6028
|
-
}
|
|
6029
|
-
let parsed;
|
|
6030
|
-
try {
|
|
6031
|
-
parsed = JSON.parse(text);
|
|
6032
|
-
} catch {
|
|
6033
|
-
throw new Error(`${jailStorePath()} is not valid JSON \u2014 fix it before changing the jail.`);
|
|
6034
|
-
}
|
|
6035
|
-
if (!Array.isArray(parsed.paths)) return [];
|
|
6036
|
-
return parsed.paths.filter(
|
|
6037
|
-
(p) => !!p && typeof p.path === "string" && (p.verdict === "block" || p.verdict === "review")
|
|
6038
|
-
);
|
|
6039
|
-
}
|
|
6040
|
-
function findJailedPath(candidate) {
|
|
6041
|
-
return findJailedPathIn(candidate, readJailPaths());
|
|
6042
|
-
}
|
|
6043
|
-
function findJailedPathIn(candidate, paths) {
|
|
6044
|
-
if (!candidate) return null;
|
|
6045
|
-
for (const entry of paths) {
|
|
6046
|
-
if (pathMatchesFragment(candidate, entry.path)) return entry;
|
|
6047
|
-
}
|
|
6048
|
-
return null;
|
|
6049
|
-
}
|
|
6050
|
-
|
|
6051
6849
|
// src/auth/orchestrator.ts
|
|
6052
6850
|
var WRITE_TOOLS = /* @__PURE__ */ new Set([
|
|
6053
6851
|
"write",
|
|
@@ -6233,6 +7031,53 @@ async function _authorizeHeadlessCore(toolName, args, metaArg, options) {
|
|
|
6233
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.`;
|
|
6234
7032
|
}
|
|
6235
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
|
+
}
|
|
6236
7081
|
if (config.policy.dlp.enabled && (!isIgnoredTool2(toolName) || config.policy.dlp.scanIgnoredTools)) {
|
|
6237
7082
|
const argsObj = args && typeof args === "object" && !Array.isArray(args) ? args : {};
|
|
6238
7083
|
const filePath = String(argsObj.file_path ?? argsObj.path ?? argsObj.filename ?? "");
|
|
@@ -6888,6 +7733,20 @@ async function authorizeAction(toolName, args) {
|
|
|
6888
7733
|
const result = await authorizeHeadless(toolName, args);
|
|
6889
7734
|
return result.approved;
|
|
6890
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
|
+
}
|
|
6891
7750
|
|
|
6892
7751
|
// src/index.ts
|
|
6893
7752
|
function protect(toolName, fn) {
|