@node9/policy-engine 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/index.d.mts +135 -13
- package/dist/index.d.ts +135 -13
- package/dist/index.js +782 -52
- package/dist/index.mjs +772 -50
- package/package.json +1 -1
package/dist/index.mjs
CHANGED
|
@@ -1,6 +1,175 @@
|
|
|
1
1
|
// src/dlp/index.ts
|
|
2
2
|
import safeRegex from "safe-regex2";
|
|
3
3
|
|
|
4
|
+
// src/scan/checksums.ts
|
|
5
|
+
import { createHash } from "crypto";
|
|
6
|
+
function validateLuhn(digits) {
|
|
7
|
+
if (!/^\d+$/.test(digits)) return false;
|
|
8
|
+
if (digits.length < 12) return false;
|
|
9
|
+
if (!/[1-9]/.test(digits)) return false;
|
|
10
|
+
let sum = 0;
|
|
11
|
+
let double = false;
|
|
12
|
+
for (let i = digits.length - 1; i >= 0; i--) {
|
|
13
|
+
let d = digits.charCodeAt(i) - 48;
|
|
14
|
+
if (double) {
|
|
15
|
+
d *= 2;
|
|
16
|
+
if (d > 9) d -= 9;
|
|
17
|
+
}
|
|
18
|
+
sum += d;
|
|
19
|
+
double = !double;
|
|
20
|
+
}
|
|
21
|
+
return sum % 10 === 0;
|
|
22
|
+
}
|
|
23
|
+
var IBAN_LENGTH = {
|
|
24
|
+
AD: 24,
|
|
25
|
+
AE: 23,
|
|
26
|
+
AL: 28,
|
|
27
|
+
AT: 20,
|
|
28
|
+
AZ: 28,
|
|
29
|
+
BA: 20,
|
|
30
|
+
BE: 16,
|
|
31
|
+
BG: 22,
|
|
32
|
+
BH: 22,
|
|
33
|
+
BI: 27,
|
|
34
|
+
BR: 29,
|
|
35
|
+
BY: 28,
|
|
36
|
+
CH: 21,
|
|
37
|
+
CR: 22,
|
|
38
|
+
CY: 28,
|
|
39
|
+
CZ: 24,
|
|
40
|
+
DE: 22,
|
|
41
|
+
DJ: 27,
|
|
42
|
+
DK: 18,
|
|
43
|
+
DO: 28,
|
|
44
|
+
EE: 20,
|
|
45
|
+
EG: 29,
|
|
46
|
+
ES: 24,
|
|
47
|
+
FI: 18,
|
|
48
|
+
FK: 18,
|
|
49
|
+
FO: 18,
|
|
50
|
+
FR: 27,
|
|
51
|
+
GB: 22,
|
|
52
|
+
GE: 22,
|
|
53
|
+
GI: 23,
|
|
54
|
+
GL: 18,
|
|
55
|
+
GR: 27,
|
|
56
|
+
GT: 28,
|
|
57
|
+
HN: 28,
|
|
58
|
+
HR: 21,
|
|
59
|
+
HU: 28,
|
|
60
|
+
IE: 22,
|
|
61
|
+
IL: 23,
|
|
62
|
+
IQ: 23,
|
|
63
|
+
IS: 26,
|
|
64
|
+
IT: 27,
|
|
65
|
+
JO: 30,
|
|
66
|
+
KW: 30,
|
|
67
|
+
KZ: 20,
|
|
68
|
+
LB: 28,
|
|
69
|
+
LC: 32,
|
|
70
|
+
LI: 21,
|
|
71
|
+
LT: 20,
|
|
72
|
+
LU: 20,
|
|
73
|
+
LV: 21,
|
|
74
|
+
LY: 25,
|
|
75
|
+
MC: 27,
|
|
76
|
+
MD: 24,
|
|
77
|
+
ME: 22,
|
|
78
|
+
MK: 19,
|
|
79
|
+
MN: 20,
|
|
80
|
+
MR: 27,
|
|
81
|
+
MT: 31,
|
|
82
|
+
MU: 30,
|
|
83
|
+
NI: 28,
|
|
84
|
+
NL: 18,
|
|
85
|
+
NO: 15,
|
|
86
|
+
OM: 23,
|
|
87
|
+
PK: 24,
|
|
88
|
+
PL: 28,
|
|
89
|
+
PS: 29,
|
|
90
|
+
PT: 25,
|
|
91
|
+
QA: 29,
|
|
92
|
+
RO: 24,
|
|
93
|
+
RS: 22,
|
|
94
|
+
RU: 33,
|
|
95
|
+
SA: 24,
|
|
96
|
+
SC: 31,
|
|
97
|
+
SD: 18,
|
|
98
|
+
SE: 24,
|
|
99
|
+
SI: 19,
|
|
100
|
+
SK: 24,
|
|
101
|
+
SM: 27,
|
|
102
|
+
SN: 28,
|
|
103
|
+
SO: 23,
|
|
104
|
+
ST: 25,
|
|
105
|
+
SV: 28,
|
|
106
|
+
TL: 23,
|
|
107
|
+
TN: 24,
|
|
108
|
+
TR: 26,
|
|
109
|
+
UA: 29,
|
|
110
|
+
VA: 22,
|
|
111
|
+
VG: 24,
|
|
112
|
+
XK: 20,
|
|
113
|
+
YE: 30
|
|
114
|
+
};
|
|
115
|
+
function validateIban(raw) {
|
|
116
|
+
const s = raw.replace(/[ -]/g, "").toUpperCase();
|
|
117
|
+
if (!/^[A-Z]{2}\d{2}/.test(s)) return false;
|
|
118
|
+
const want = IBAN_LENGTH[s.slice(0, 2)];
|
|
119
|
+
if (want === void 0 || s.length < want) return false;
|
|
120
|
+
const iban = s.slice(0, want);
|
|
121
|
+
if (!/^[A-Z0-9]+$/.test(iban)) return false;
|
|
122
|
+
const rearranged = iban.slice(4) + iban.slice(0, 4);
|
|
123
|
+
let mod = 0;
|
|
124
|
+
for (const ch of rearranged) {
|
|
125
|
+
const code = ch.charCodeAt(0);
|
|
126
|
+
const digits = code >= 65 ? String(code - 55) : ch;
|
|
127
|
+
for (const d of digits) mod = (mod * 10 + (d.charCodeAt(0) - 48)) % 97;
|
|
128
|
+
}
|
|
129
|
+
return mod === 1;
|
|
130
|
+
}
|
|
131
|
+
var B58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
|
|
132
|
+
var B58_INDEX = Object.fromEntries(
|
|
133
|
+
[...B58].map((c, i) => [c, i])
|
|
134
|
+
);
|
|
135
|
+
function validateBase58Check(s) {
|
|
136
|
+
if (!s) return null;
|
|
137
|
+
let n = 0n;
|
|
138
|
+
for (const c of s) {
|
|
139
|
+
const v = B58_INDEX[c];
|
|
140
|
+
if (v === void 0) return null;
|
|
141
|
+
n = n * 58n + BigInt(v);
|
|
142
|
+
}
|
|
143
|
+
let hex = n.toString(16);
|
|
144
|
+
if (hex.length % 2) hex = "0" + hex;
|
|
145
|
+
let zeros = 0;
|
|
146
|
+
for (const c of s) {
|
|
147
|
+
if (c !== "1") break;
|
|
148
|
+
zeros++;
|
|
149
|
+
}
|
|
150
|
+
const bytes = Buffer.concat([
|
|
151
|
+
Buffer.alloc(zeros),
|
|
152
|
+
n === 0n ? Buffer.alloc(0) : Buffer.from(hex, "hex")
|
|
153
|
+
]);
|
|
154
|
+
if (bytes.length < 5) return null;
|
|
155
|
+
const body = bytes.subarray(0, bytes.length - 4);
|
|
156
|
+
const check = bytes.subarray(bytes.length - 4);
|
|
157
|
+
const h = createHash("sha256").update(createHash("sha256").update(body).digest()).digest();
|
|
158
|
+
return h.subarray(0, 4).equals(check) ? body : null;
|
|
159
|
+
}
|
|
160
|
+
function validateWif(s) {
|
|
161
|
+
const p = validateBase58Check(s);
|
|
162
|
+
if (!p || p[0] !== 128) return false;
|
|
163
|
+
return p.length === 33 || p.length === 34 && p[33] === 1;
|
|
164
|
+
}
|
|
165
|
+
var XPRV_VERSIONS = /* @__PURE__ */ new Set([76066276, 77428856, 78791436]);
|
|
166
|
+
function validateXprv(s) {
|
|
167
|
+
const p = validateBase58Check(s);
|
|
168
|
+
if (!p || p.length !== 78) return false;
|
|
169
|
+
const version = p.readUInt32BE(0);
|
|
170
|
+
return XPRV_VERSIONS.has(version) && p[45] === 0;
|
|
171
|
+
}
|
|
172
|
+
|
|
4
173
|
// src/dlp/injection.ts
|
|
5
174
|
var MAX = 1e5;
|
|
6
175
|
var UNTRUSTED_TOOLS = /\b(web_?fetch|web_?search|fetch|curl|wget|browser|http_get|read_url|open_url)\b/i;
|
|
@@ -245,6 +414,27 @@ var DLP_PATTERNS = [
|
|
|
245
414
|
severity: "block",
|
|
246
415
|
keywords: ["sg."]
|
|
247
416
|
},
|
|
417
|
+
// ── Cryptocurrency private keys (base58check-validated) ───────────────────
|
|
418
|
+
// Both are anchored with \b on each side: unanchored, `[KL][base58]{51}`
|
|
419
|
+
// matches INSIDE any longer base58 blob (an xprv, a Solana keypair, a
|
|
420
|
+
// Monero address). Lookbehind fails safe-regex2; \b is the house style
|
|
421
|
+
// (see the card regexes). Mainnet only, matching validateWif / validateXprv;
|
|
422
|
+
// testnet (WIF 0xEF, tprv) is deferred. Cost was measured: the WIF regex
|
|
423
|
+
// runs on every string (first keyword-less pattern) at 0.024 ms per 100 KB
|
|
424
|
+
// of prose, so no prefilter is warranted.
|
|
425
|
+
{
|
|
426
|
+
name: "Bitcoin WIF Private Key",
|
|
427
|
+
regex: /\b(?:5[1-9A-HJ-NP-Za-km-z]{50}|[KL][1-9A-HJ-NP-Za-km-z]{51})\b/,
|
|
428
|
+
severity: "block",
|
|
429
|
+
validate: validateWif
|
|
430
|
+
},
|
|
431
|
+
{
|
|
432
|
+
name: "Extended Private Key",
|
|
433
|
+
regex: /\b[xyz]prv[1-9A-HJ-NP-Za-km-z]{107}\b/,
|
|
434
|
+
severity: "block",
|
|
435
|
+
keywords: ["xprv", "yprv", "zprv"],
|
|
436
|
+
validate: validateXprv
|
|
437
|
+
},
|
|
248
438
|
// ── Private keys (PEM) ────────────────────────────────────────────────────
|
|
249
439
|
{
|
|
250
440
|
name: "Private Key (PEM)",
|
|
@@ -609,6 +799,33 @@ var DLP_SCAN_LIMITS = {
|
|
|
609
799
|
/** Max nesting depth walked; anything deeper is NOT scanned. */
|
|
610
800
|
maxDepth: MAX_DEPTH
|
|
611
801
|
};
|
|
802
|
+
function suppressed(pattern, raw) {
|
|
803
|
+
if (pattern.validate) {
|
|
804
|
+
let ok;
|
|
805
|
+
try {
|
|
806
|
+
ok = pattern.validate(raw);
|
|
807
|
+
} catch {
|
|
808
|
+
ok = true;
|
|
809
|
+
}
|
|
810
|
+
return !ok;
|
|
811
|
+
}
|
|
812
|
+
if (DLP_STOPWORDS.some((sw) => raw.toLowerCase().includes(sw))) return true;
|
|
813
|
+
if (pattern.minEntropy !== void 0 && shannonEntropy(raw) < pattern.minEntropy) return true;
|
|
814
|
+
return false;
|
|
815
|
+
}
|
|
816
|
+
function firstAcceptedMatch(pattern, text) {
|
|
817
|
+
const flags = pattern.regex.flags.includes("g") ? pattern.regex.flags : pattern.regex.flags + "g";
|
|
818
|
+
const re = new RegExp(pattern.regex.source, flags);
|
|
819
|
+
let m;
|
|
820
|
+
while ((m = re.exec(text)) !== null) {
|
|
821
|
+
if (m[0].length === 0) {
|
|
822
|
+
re.lastIndex = m.index + 1;
|
|
823
|
+
continue;
|
|
824
|
+
}
|
|
825
|
+
if (!suppressed(pattern, m[0])) return m[0];
|
|
826
|
+
}
|
|
827
|
+
return null;
|
|
828
|
+
}
|
|
612
829
|
function scanArgs(args, depth = 0, fieldPath = "args") {
|
|
613
830
|
if (depth > MAX_DEPTH || args === null || args === void 0) return null;
|
|
614
831
|
if (Array.isArray(args)) {
|
|
@@ -633,18 +850,16 @@ function scanArgs(args, depth = 0, fieldPath = "args") {
|
|
|
633
850
|
if (pattern.keywords && !pattern.keywords.some((kw) => textLower.includes(kw.toLowerCase()))) {
|
|
634
851
|
continue;
|
|
635
852
|
}
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
};
|
|
647
|
-
}
|
|
853
|
+
const raw = firstAcceptedMatch(pattern, text);
|
|
854
|
+
if (raw === null) continue;
|
|
855
|
+
const severity = pattern.contextBoost && assignmentCtx ? "block" : pattern.severity;
|
|
856
|
+
return {
|
|
857
|
+
patternName: pattern.name,
|
|
858
|
+
fieldPath,
|
|
859
|
+
// Mask the ACCEPTED token, not the first regex hit in the field.
|
|
860
|
+
redactedSample: maskSecret(raw, pattern.regex),
|
|
861
|
+
severity
|
|
862
|
+
};
|
|
648
863
|
}
|
|
649
864
|
if (text.length < MAX_JSON_PARSE_BYTES) {
|
|
650
865
|
const trimmed = text.trim();
|
|
@@ -667,17 +882,14 @@ function scanText(text) {
|
|
|
667
882
|
if (pattern.keywords && !pattern.keywords.some((kw) => tLower.includes(kw.toLowerCase()))) {
|
|
668
883
|
continue;
|
|
669
884
|
}
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
severity: pattern.severity
|
|
679
|
-
};
|
|
680
|
-
}
|
|
885
|
+
const raw = firstAcceptedMatch(pattern, t);
|
|
886
|
+
if (raw === null) continue;
|
|
887
|
+
return {
|
|
888
|
+
patternName: pattern.name,
|
|
889
|
+
fieldPath: "response-text",
|
|
890
|
+
redactedSample: maskSecret(raw, pattern.regex),
|
|
891
|
+
severity: pattern.severity
|
|
892
|
+
};
|
|
681
893
|
}
|
|
682
894
|
return null;
|
|
683
895
|
}
|
|
@@ -691,9 +903,7 @@ function redactText(text) {
|
|
|
691
903
|
continue;
|
|
692
904
|
}
|
|
693
905
|
result = result.replace(globalRegex, (match) => {
|
|
694
|
-
if (
|
|
695
|
-
if (pattern.minEntropy !== void 0 && shannonEntropy(match) < pattern.minEntropy)
|
|
696
|
-
return match;
|
|
906
|
+
if (suppressed(pattern, match)) return match;
|
|
697
907
|
if (!found.includes(pattern.name)) found.push(pattern.name);
|
|
698
908
|
return `[node9-redacted:${pattern.name}]`;
|
|
699
909
|
});
|
|
@@ -761,11 +971,11 @@ function matchesPattern(text, patterns) {
|
|
|
761
971
|
var FORBIDDEN_PATH_SEGMENTS = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype"]);
|
|
762
972
|
function getNestedValue(obj, path) {
|
|
763
973
|
if (!obj || typeof obj !== "object") return null;
|
|
764
|
-
const
|
|
765
|
-
for (const seg of
|
|
974
|
+
const segments2 = path.split(".");
|
|
975
|
+
for (const seg of segments2) {
|
|
766
976
|
if (FORBIDDEN_PATH_SEGMENTS.has(seg)) return null;
|
|
767
977
|
}
|
|
768
|
-
return
|
|
978
|
+
return segments2.reduce((prev, curr) => prev?.[curr], obj);
|
|
769
979
|
}
|
|
770
980
|
function evaluateSmartConditions(args, rule) {
|
|
771
981
|
if (!rule.conditions || rule.conditions.length === 0) return true;
|
|
@@ -1736,6 +1946,50 @@ function extractShellDestinations(command) {
|
|
|
1736
1946
|
}
|
|
1737
1947
|
return out;
|
|
1738
1948
|
}
|
|
1949
|
+
function extractShellDestTokens(command) {
|
|
1950
|
+
const f = parseShared(command);
|
|
1951
|
+
if (f === PARSE_FAIL) return [];
|
|
1952
|
+
const out = [];
|
|
1953
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1954
|
+
try {
|
|
1955
|
+
syntax.Walk(f, (node) => {
|
|
1956
|
+
if (!node) return false;
|
|
1957
|
+
const n = node;
|
|
1958
|
+
if (syntax.NodeType(n) !== "CallExpr") return true;
|
|
1959
|
+
const callArgs = n.Args || [];
|
|
1960
|
+
if (callArgs.length === 0) return true;
|
|
1961
|
+
const name = (resolveWordLiteral(callArgs[0]) || "").toLowerCase();
|
|
1962
|
+
if (!NET_BINARIES.has(name)) return true;
|
|
1963
|
+
const rest = callArgs.slice(1).map((a) => resolveWordLiteral(a));
|
|
1964
|
+
for (const raw of destTokensForBinary(name, rest)) {
|
|
1965
|
+
if (!raw) continue;
|
|
1966
|
+
let tok = raw.trim();
|
|
1967
|
+
const scheme = /^[a-z][a-z0-9+.-]*:\/\//i.exec(tok);
|
|
1968
|
+
const hasScheme = scheme !== null;
|
|
1969
|
+
if (hasScheme) tok = tok.slice(scheme[0].length);
|
|
1970
|
+
tok = tok.split(/[/?#]/)[0];
|
|
1971
|
+
const at = tok.lastIndexOf("@");
|
|
1972
|
+
if (at >= 0) tok = tok.slice(at + 1);
|
|
1973
|
+
if (tok.startsWith("[")) {
|
|
1974
|
+
const close = tok.indexOf("]");
|
|
1975
|
+
if (close > 0) tok = tok.slice(0, close + 1);
|
|
1976
|
+
} else {
|
|
1977
|
+
tok = tok.split(":")[0];
|
|
1978
|
+
}
|
|
1979
|
+
if (!tok) continue;
|
|
1980
|
+
if (!hasScheme && /^\d+$/.test(tok) && Number(tok) < 16777216) continue;
|
|
1981
|
+
const key = `${name}:${tok}`;
|
|
1982
|
+
if (seen.has(key)) continue;
|
|
1983
|
+
seen.add(key);
|
|
1984
|
+
out.push({ token: tok, binary: name });
|
|
1985
|
+
}
|
|
1986
|
+
return true;
|
|
1987
|
+
});
|
|
1988
|
+
} catch {
|
|
1989
|
+
return out;
|
|
1990
|
+
}
|
|
1991
|
+
return out;
|
|
1992
|
+
}
|
|
1739
1993
|
var FS_OP_CACHE_MAX = 5e3;
|
|
1740
1994
|
var fsOpCache = /* @__PURE__ */ new Map();
|
|
1741
1995
|
function analyzeFsOperation(command) {
|
|
@@ -1940,8 +2194,8 @@ function analyzeShellCommand(command) {
|
|
|
1940
2194
|
if (allTokens.length === 0) {
|
|
1941
2195
|
const normalized = command.replace(/\\(.)/g, "$1");
|
|
1942
2196
|
const sanitized = normalized.replace(/["'<>]/g, " ");
|
|
1943
|
-
const
|
|
1944
|
-
|
|
2197
|
+
const segments2 = sanitized.split(/[|;&]|\$\(|\)|`/);
|
|
2198
|
+
segments2.forEach((segment) => {
|
|
1945
2199
|
const tokens = segment.trim().split(/\s+/).filter(Boolean);
|
|
1946
2200
|
if (tokens.length > 0) {
|
|
1947
2201
|
const action = tokens[0].toLowerCase();
|
|
@@ -1960,6 +2214,10 @@ function analyzeShellCommand(command) {
|
|
|
1960
2214
|
|
|
1961
2215
|
// src/egress/index.ts
|
|
1962
2216
|
var DEFAULT_EGRESS_ALLOWLIST = [
|
|
2217
|
+
// node9's own control plane (api, app, dev-api, staging and the apex).
|
|
2218
|
+
// Without it, turning egress on asks the user to approve node9 itself.
|
|
2219
|
+
// A user `deny` entry still wins over this list, see evaluateEgress.
|
|
2220
|
+
"*.node9.ai",
|
|
1963
2221
|
"*.github.com",
|
|
1964
2222
|
"*.githubusercontent.com",
|
|
1965
2223
|
"*.npmjs.org",
|
|
@@ -2104,7 +2362,7 @@ function isSensitivePath(p) {
|
|
|
2104
2362
|
return SENSITIVE_PATTERNS.some((re) => re.test(p));
|
|
2105
2363
|
}
|
|
2106
2364
|
function splitOnPipe(cmd) {
|
|
2107
|
-
const
|
|
2365
|
+
const segments2 = [];
|
|
2108
2366
|
let current = "";
|
|
2109
2367
|
let inSingle = false;
|
|
2110
2368
|
let inDouble = false;
|
|
@@ -2117,21 +2375,21 @@ function splitOnPipe(cmd) {
|
|
|
2117
2375
|
inDouble = !inDouble;
|
|
2118
2376
|
current += ch;
|
|
2119
2377
|
} else if (ch === "|" && !inSingle && !inDouble && cmd[i + 1] !== "|" && (i === 0 || cmd[i - 1] !== "|")) {
|
|
2120
|
-
|
|
2378
|
+
segments2.push(current.trim());
|
|
2121
2379
|
current = "";
|
|
2122
2380
|
} else {
|
|
2123
2381
|
current += ch;
|
|
2124
2382
|
}
|
|
2125
2383
|
}
|
|
2126
|
-
if (current.trim())
|
|
2127
|
-
return
|
|
2384
|
+
if (current.trim()) segments2.push(current.trim());
|
|
2385
|
+
return segments2.filter(Boolean);
|
|
2128
2386
|
}
|
|
2129
2387
|
function positionalTokens(segment) {
|
|
2130
2388
|
return segment.split(/\s+/).slice(1).filter((t) => !t.startsWith("-") && !t.startsWith("@") && t.length > 0);
|
|
2131
2389
|
}
|
|
2132
2390
|
function analyzePipeChain(command) {
|
|
2133
|
-
const
|
|
2134
|
-
if (
|
|
2391
|
+
const segments2 = splitOnPipe(command);
|
|
2392
|
+
if (segments2.length < 2) {
|
|
2135
2393
|
return {
|
|
2136
2394
|
isPipeline: false,
|
|
2137
2395
|
hasSensitiveSource: false,
|
|
@@ -2147,7 +2405,7 @@ function analyzePipeChain(command) {
|
|
|
2147
2405
|
let hasSensitiveSource = false;
|
|
2148
2406
|
let hasExternalSink = false;
|
|
2149
2407
|
let hasObfuscation = false;
|
|
2150
|
-
for (const segment of
|
|
2408
|
+
for (const segment of segments2) {
|
|
2151
2409
|
const tokens = segment.split(/\s+/).filter(Boolean);
|
|
2152
2410
|
if (tokens.length === 0) continue;
|
|
2153
2411
|
const binary = tokens[0].toLowerCase();
|
|
@@ -2187,8 +2445,8 @@ function analyzePipeChain(command) {
|
|
|
2187
2445
|
|
|
2188
2446
|
// src/policy/flag-tables.ts
|
|
2189
2447
|
function basename(p) {
|
|
2190
|
-
const
|
|
2191
|
-
return
|
|
2448
|
+
const segments2 = p.split(/[\\/]/);
|
|
2449
|
+
return segments2[segments2.length - 1] || "";
|
|
2192
2450
|
}
|
|
2193
2451
|
var FLAGS_WITH_VALUES = {
|
|
2194
2452
|
curl: /* @__PURE__ */ new Set([
|
|
@@ -2358,6 +2616,206 @@ function parseAllSshHostsFromCommand(command) {
|
|
|
2358
2616
|
return extractAllSshHosts(tokens.slice(1));
|
|
2359
2617
|
}
|
|
2360
2618
|
|
|
2619
|
+
// src/egress/ssrf.ts
|
|
2620
|
+
var SSRF_MAX_HOST = 253;
|
|
2621
|
+
function parseComponent(s) {
|
|
2622
|
+
if (!s) return null;
|
|
2623
|
+
if (/^0[xX][0-9a-fA-F]+$/.test(s)) return parseInt(s.slice(2), 16);
|
|
2624
|
+
if (s === "0") return 0;
|
|
2625
|
+
if (/^0[0-7]+$/.test(s)) return parseInt(s.slice(1), 8);
|
|
2626
|
+
if (/^[1-9][0-9]*$/.test(s)) return Number(s);
|
|
2627
|
+
return null;
|
|
2628
|
+
}
|
|
2629
|
+
function parseIpv4(input) {
|
|
2630
|
+
let s = input;
|
|
2631
|
+
if (s.endsWith(".")) s = s.slice(0, -1);
|
|
2632
|
+
if (!s) return null;
|
|
2633
|
+
const parts = s.split(".");
|
|
2634
|
+
if (parts.length > 4) return null;
|
|
2635
|
+
const vals = [];
|
|
2636
|
+
for (const p of parts) {
|
|
2637
|
+
const v = parseComponent(p);
|
|
2638
|
+
if (v === null || !Number.isFinite(v) || v < 0) return null;
|
|
2639
|
+
vals.push(v);
|
|
2640
|
+
}
|
|
2641
|
+
const n = vals.length;
|
|
2642
|
+
for (let i = 0; i < n - 1; i++) if (vals[i] > 255) return null;
|
|
2643
|
+
const last = vals[n - 1];
|
|
2644
|
+
const remainingBytes = 4 - (n - 1);
|
|
2645
|
+
const limit = Math.pow(256, remainingBytes);
|
|
2646
|
+
if (last >= limit) return null;
|
|
2647
|
+
let value = last;
|
|
2648
|
+
for (let i = 0; i < n - 1; i++) value += vals[i] * Math.pow(256, 3 - i);
|
|
2649
|
+
if (value > 4294967295) return null;
|
|
2650
|
+
return [value >>> 24 & 255, value >>> 16 & 255, value >>> 8 & 255, value & 255].join(".");
|
|
2651
|
+
}
|
|
2652
|
+
function expandIpv6(input) {
|
|
2653
|
+
const s = input.toLowerCase();
|
|
2654
|
+
if (!/^[0-9a-f:.]+$/.test(s)) return null;
|
|
2655
|
+
if ((s.match(/::/g) ?? []).length > 1) return null;
|
|
2656
|
+
let head = s;
|
|
2657
|
+
let tailV4 = null;
|
|
2658
|
+
const lastColon = s.lastIndexOf(":");
|
|
2659
|
+
const afterLast = s.slice(lastColon + 1);
|
|
2660
|
+
if (afterLast.includes(".")) {
|
|
2661
|
+
const dotted = parseIpv4(afterLast);
|
|
2662
|
+
if (!dotted) return null;
|
|
2663
|
+
const o = dotted.split(".").map(Number);
|
|
2664
|
+
tailV4 = [o[0] << 8 | o[1], o[2] << 8 | o[3]];
|
|
2665
|
+
head = s.slice(0, lastColon + 1) + "0";
|
|
2666
|
+
}
|
|
2667
|
+
const [lhs, rhs] = head.includes("::") ? head.split("::") : [head, null];
|
|
2668
|
+
const toGroups = (part) => {
|
|
2669
|
+
if (!part) return [];
|
|
2670
|
+
const out = [];
|
|
2671
|
+
for (const g of part.split(":")) {
|
|
2672
|
+
if (!/^[0-9a-f]{1,4}$/.test(g)) return null;
|
|
2673
|
+
out.push(parseInt(g, 16));
|
|
2674
|
+
}
|
|
2675
|
+
return out;
|
|
2676
|
+
};
|
|
2677
|
+
const left = toGroups(lhs);
|
|
2678
|
+
if (left === null) return null;
|
|
2679
|
+
let right = [];
|
|
2680
|
+
if (rhs !== null) {
|
|
2681
|
+
const r = toGroups(rhs);
|
|
2682
|
+
if (r === null) return null;
|
|
2683
|
+
right = r;
|
|
2684
|
+
}
|
|
2685
|
+
if (tailV4) {
|
|
2686
|
+
if (rhs !== null) right = right.slice(0, -1).concat(tailV4);
|
|
2687
|
+
else left.splice(left.length - 1, 1, ...tailV4);
|
|
2688
|
+
}
|
|
2689
|
+
const groups = rhs === null ? left : left.concat(new Array(8 - left.length - right.length).fill(0), right);
|
|
2690
|
+
if (rhs === null && groups.length !== 8) return null;
|
|
2691
|
+
if (rhs !== null && left.length + right.length > 8) return null;
|
|
2692
|
+
if (groups.length !== 8) return null;
|
|
2693
|
+
return groups;
|
|
2694
|
+
}
|
|
2695
|
+
function compressIpv6(g) {
|
|
2696
|
+
let bestStart = -1;
|
|
2697
|
+
let bestLen = 0;
|
|
2698
|
+
let i = 0;
|
|
2699
|
+
while (i < 8) {
|
|
2700
|
+
if (g[i] !== 0) {
|
|
2701
|
+
i++;
|
|
2702
|
+
continue;
|
|
2703
|
+
}
|
|
2704
|
+
let j = i;
|
|
2705
|
+
while (j < 8 && g[j] === 0) j++;
|
|
2706
|
+
if (j - i > bestLen) {
|
|
2707
|
+
bestLen = j - i;
|
|
2708
|
+
bestStart = i;
|
|
2709
|
+
}
|
|
2710
|
+
i = j;
|
|
2711
|
+
}
|
|
2712
|
+
const hex = g.map((x) => x.toString(16));
|
|
2713
|
+
if (bestLen < 2) return hex.join(":");
|
|
2714
|
+
return hex.slice(0, bestStart).join(":") + "::" + hex.slice(bestStart + bestLen).join(":");
|
|
2715
|
+
}
|
|
2716
|
+
function normalizeIpLiteral(host) {
|
|
2717
|
+
try {
|
|
2718
|
+
if (typeof host !== "string") return null;
|
|
2719
|
+
let s = host.trim();
|
|
2720
|
+
if (!s || s.length > SSRF_MAX_HOST) return null;
|
|
2721
|
+
if (s.startsWith("[") && s.endsWith("]")) s = s.slice(1, -1);
|
|
2722
|
+
const zone = s.indexOf("%");
|
|
2723
|
+
if (zone >= 0) s = s.slice(0, zone);
|
|
2724
|
+
if (!s) return null;
|
|
2725
|
+
if (s.includes(":")) {
|
|
2726
|
+
const g = expandIpv6(s);
|
|
2727
|
+
if (!g) return null;
|
|
2728
|
+
const mapped = g.slice(0, 5).every((x) => x === 0) && g[5] === 65535;
|
|
2729
|
+
if (mapped) {
|
|
2730
|
+
return [g[6] >> 8 & 255, g[6] & 255, g[7] >> 8 & 255, g[7] & 255].join(".");
|
|
2731
|
+
}
|
|
2732
|
+
return compressIpv6(g);
|
|
2733
|
+
}
|
|
2734
|
+
return parseIpv4(s);
|
|
2735
|
+
} catch {
|
|
2736
|
+
return null;
|
|
2737
|
+
}
|
|
2738
|
+
}
|
|
2739
|
+
var METADATA_ADDRESSES = /* @__PURE__ */ new Set([
|
|
2740
|
+
"169.254.169.254",
|
|
2741
|
+
// AWS / Azure / DigitalOcean / OpenStack IMDS
|
|
2742
|
+
"169.254.170.2",
|
|
2743
|
+
// AWS ECS task role
|
|
2744
|
+
"168.63.129.16",
|
|
2745
|
+
// Azure WireServer
|
|
2746
|
+
"fd00:ec2::254"
|
|
2747
|
+
// AWS IMDS over IPv6 (inside fc00::/7, which is NOT a tier)
|
|
2748
|
+
]);
|
|
2749
|
+
var METADATA_HOSTNAMES = /* @__PURE__ */ new Set(["metadata.google.internal", "metadata.goog", "metadata"]);
|
|
2750
|
+
var v4Octets = (a) => {
|
|
2751
|
+
const p = a.split(".");
|
|
2752
|
+
return p.length === 4 ? p.map(Number) : null;
|
|
2753
|
+
};
|
|
2754
|
+
function classifySsrf(host) {
|
|
2755
|
+
try {
|
|
2756
|
+
if (typeof host !== "string" || !host) return null;
|
|
2757
|
+
const lower = host.trim().toLowerCase().replace(/\.$/, "");
|
|
2758
|
+
const ip = normalizeIpLiteral(host);
|
|
2759
|
+
if (ip === null) {
|
|
2760
|
+
return METADATA_HOSTNAMES.has(lower) ? { tier: "metadata", overridable: false, kind: "hostname" } : null;
|
|
2761
|
+
}
|
|
2762
|
+
const hit = (tier, overridable) => ({
|
|
2763
|
+
tier,
|
|
2764
|
+
overridable,
|
|
2765
|
+
kind: "address",
|
|
2766
|
+
normalized: ip
|
|
2767
|
+
});
|
|
2768
|
+
if (METADATA_ADDRESSES.has(ip)) return hit("metadata", false);
|
|
2769
|
+
const o = v4Octets(ip);
|
|
2770
|
+
if (o) {
|
|
2771
|
+
if (o[0] === 0 && o[1] === 0 && o[2] === 0 && o[3] === 0) return hit("unspecified", false);
|
|
2772
|
+
if (o[0] === 169 && o[1] === 254) return hit("link-local", false);
|
|
2773
|
+
if (o[0] >= 224 && o[0] <= 239) return hit("multicast", false);
|
|
2774
|
+
if (o[0] === 100 && o[1] >= 64 && o[1] <= 127) return hit("cgnat", true);
|
|
2775
|
+
if (o[0] === 127) return hit("private", true);
|
|
2776
|
+
if (o[0] === 10) return hit("private", true);
|
|
2777
|
+
if (o[0] === 192 && o[1] === 168) return hit("private", true);
|
|
2778
|
+
if (o[0] === 172 && o[1] >= 16 && o[1] <= 31) return hit("private", true);
|
|
2779
|
+
return null;
|
|
2780
|
+
}
|
|
2781
|
+
const g = expandIpv6(ip);
|
|
2782
|
+
if (!g) return null;
|
|
2783
|
+
if (g.every((x) => x === 0)) return hit("unspecified", false);
|
|
2784
|
+
if ((g[0] & 65472) === 65152) return hit("link-local", false);
|
|
2785
|
+
if ((g[0] & 65280) === 65280) return hit("multicast", false);
|
|
2786
|
+
if (g.slice(0, 7).every((x) => x === 0) && g[7] === 1) return hit("private", true);
|
|
2787
|
+
return null;
|
|
2788
|
+
} catch {
|
|
2789
|
+
return null;
|
|
2790
|
+
}
|
|
2791
|
+
}
|
|
2792
|
+
var TIER_REASON = {
|
|
2793
|
+
metadata: "a cloud instance-metadata endpoint, the classic credential-theft target",
|
|
2794
|
+
"link-local": "a link-local address",
|
|
2795
|
+
multicast: "a multicast address",
|
|
2796
|
+
unspecified: "the unspecified address",
|
|
2797
|
+
cgnat: "a carrier-grade NAT address",
|
|
2798
|
+
private: "a loopback or private address"
|
|
2799
|
+
};
|
|
2800
|
+
function ssrfFloor(tokens, opts = {}) {
|
|
2801
|
+
const exempt = new Set(
|
|
2802
|
+
(opts.ssrfAllow ?? []).map((e) => normalizeIpLiteral(e) ?? e.trim().toLowerCase())
|
|
2803
|
+
);
|
|
2804
|
+
for (const { token, binary } of tokens) {
|
|
2805
|
+
const m = classifySsrf(token);
|
|
2806
|
+
if (!m) continue;
|
|
2807
|
+
if (m.tier === "private" && !opts.ssrfStrict) continue;
|
|
2808
|
+
if (m.overridable && m.normalized && exempt.has(m.normalized)) continue;
|
|
2809
|
+
return {
|
|
2810
|
+
...m,
|
|
2811
|
+
host: token,
|
|
2812
|
+
binary,
|
|
2813
|
+
reason: `Blocked: ${token} is ${TIER_REASON[m.tier]}` + (m.normalized && m.normalized !== token ? ` (${m.normalized})` : "") + (m.overridable ? "." : ". This address cannot be allowlisted.")
|
|
2814
|
+
};
|
|
2815
|
+
}
|
|
2816
|
+
return null;
|
|
2817
|
+
}
|
|
2818
|
+
|
|
2361
2819
|
// src/policy/index.ts
|
|
2362
2820
|
function resolveCheck(v) {
|
|
2363
2821
|
return v === "off" || v === "block" ? v : "review";
|
|
@@ -2600,6 +3058,22 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
|
|
|
2600
3058
|
}
|
|
2601
3059
|
const builtin = strictestVerdict(candidates);
|
|
2602
3060
|
if (builtin) return builtin;
|
|
3061
|
+
{
|
|
3062
|
+
const ssrf = ssrfFloor(extractShellDestTokens(shellCommand), {
|
|
3063
|
+
ssrfAllow: config.policy.egress?.ssrfAllow,
|
|
3064
|
+
ssrfStrict: config.policy.egress?.ssrfStrict
|
|
3065
|
+
});
|
|
3066
|
+
if (ssrf) {
|
|
3067
|
+
return {
|
|
3068
|
+
decision: "block",
|
|
3069
|
+
blockedByLabel: "\u{1F310} Node9 Egress (Protected Address)",
|
|
3070
|
+
reason: ssrf.reason,
|
|
3071
|
+
ruleName: `ssrf:${ssrf.tier}:${ssrf.binary}:${ssrf.host}`,
|
|
3072
|
+
ruleDescription: ssrf.reason,
|
|
3073
|
+
tier: 3
|
|
3074
|
+
};
|
|
3075
|
+
}
|
|
3076
|
+
}
|
|
2603
3077
|
if (config.policy.egress?.enabled) {
|
|
2604
3078
|
const dests = extractShellDestinations(shellCommand);
|
|
2605
3079
|
if (dests.length > 0) {
|
|
@@ -3838,34 +4312,249 @@ var FILE_TOOLS = /* @__PURE__ */ new Set([
|
|
|
3838
4312
|
var PII_EMAIL_RE = /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/;
|
|
3839
4313
|
var PII_SSN_RE = /\b\d{3}-\d{2}-\d{4}\b/;
|
|
3840
4314
|
var PII_PHONE_RE = /\b(?:\+?1[-.\s]?)?\(?\d{3}\)?[-.\s]\d{3}[-.\s]\d{4}\b/;
|
|
3841
|
-
var
|
|
4315
|
+
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/;
|
|
4316
|
+
var PII_CC15_RE = /\b3[47]\d{2}[-\s]?\d{6}[-\s]?\d{5}\b/;
|
|
4317
|
+
var PII_IBAN_RE = /\b[A-Z]{2}\d{2}(?:[A-Z0-9]|[ -][A-Z0-9]){11,30}\b/;
|
|
4318
|
+
function hasValidCard(text) {
|
|
4319
|
+
for (const base of [PII_CC16_RE, PII_CC15_RE]) {
|
|
4320
|
+
const re = new RegExp(base.source, "g");
|
|
4321
|
+
let m;
|
|
4322
|
+
while ((m = re.exec(text)) !== null) {
|
|
4323
|
+
if (validateLuhn(m[0].replace(/\D/g, ""))) return true;
|
|
4324
|
+
re.lastIndex = m.index + 1;
|
|
4325
|
+
}
|
|
4326
|
+
}
|
|
4327
|
+
return false;
|
|
4328
|
+
}
|
|
4329
|
+
function hasValidIban(text) {
|
|
4330
|
+
const re = new RegExp(PII_IBAN_RE.source, "g");
|
|
4331
|
+
let m;
|
|
4332
|
+
while ((m = re.exec(text)) !== null) {
|
|
4333
|
+
if (validateIban(m[0])) return true;
|
|
4334
|
+
re.lastIndex = m.index + 1;
|
|
4335
|
+
}
|
|
4336
|
+
return false;
|
|
4337
|
+
}
|
|
3842
4338
|
function detectPii(text) {
|
|
3843
4339
|
const found = /* @__PURE__ */ new Set();
|
|
3844
4340
|
if (/@/.test(text) && PII_EMAIL_RE.test(text)) found.add("Email");
|
|
3845
4341
|
if (/-/.test(text) && PII_SSN_RE.test(text)) found.add("SSN");
|
|
3846
4342
|
if (PII_PHONE_RE.test(text)) found.add("Phone");
|
|
3847
|
-
if (
|
|
4343
|
+
if (hasValidCard(text)) found.add("Credit Card");
|
|
4344
|
+
if (hasValidIban(text)) found.add("IBAN");
|
|
3848
4345
|
return [...found];
|
|
3849
4346
|
}
|
|
3850
|
-
var REALTIME_PII_PATTERNS = ["SSN", "Credit Card"];
|
|
4347
|
+
var REALTIME_PII_PATTERNS = ["SSN", "Credit Card", "IBAN"];
|
|
3851
4348
|
var MAX_PII_SCAN_BYTES = 1e5;
|
|
4349
|
+
function* stringLeaves(v, depth = 0) {
|
|
4350
|
+
if (depth > 6) return;
|
|
4351
|
+
if (typeof v === "string") {
|
|
4352
|
+
if (v.length > 0) yield v;
|
|
4353
|
+
return;
|
|
4354
|
+
}
|
|
4355
|
+
if (typeof v === "number") {
|
|
4356
|
+
if (Number.isFinite(v)) yield String(v);
|
|
4357
|
+
return;
|
|
4358
|
+
}
|
|
4359
|
+
if (!v || typeof v !== "object") return;
|
|
4360
|
+
if (Array.isArray(v)) {
|
|
4361
|
+
for (const x of v) yield* stringLeaves(x, depth + 1);
|
|
4362
|
+
return;
|
|
4363
|
+
}
|
|
4364
|
+
for (const x of Object.values(v)) yield* stringLeaves(x, depth + 1);
|
|
4365
|
+
}
|
|
3852
4366
|
function detectArgsPii(args) {
|
|
3853
4367
|
if (args === null || args === void 0) return [];
|
|
3854
|
-
|
|
4368
|
+
const found = /* @__PURE__ */ new Set();
|
|
4369
|
+
let budget = MAX_PII_SCAN_BYTES;
|
|
3855
4370
|
try {
|
|
3856
|
-
|
|
4371
|
+
for (const leaf of stringLeaves(args)) {
|
|
4372
|
+
if (budget <= 0) break;
|
|
4373
|
+
const t = leaf.length > budget ? leaf.slice(0, budget) : leaf;
|
|
4374
|
+
budget -= t.length;
|
|
4375
|
+
for (const p of detectPii(t)) {
|
|
4376
|
+
if (REALTIME_PII_PATTERNS.includes(p)) found.add(p);
|
|
4377
|
+
}
|
|
4378
|
+
}
|
|
3857
4379
|
} catch {
|
|
3858
4380
|
return [];
|
|
3859
4381
|
}
|
|
3860
|
-
|
|
3861
|
-
|
|
3862
|
-
|
|
4382
|
+
return [...found];
|
|
4383
|
+
}
|
|
4384
|
+
|
|
4385
|
+
// src/dlp/canary.ts
|
|
4386
|
+
var CANARY_MIN_LENGTH = 16;
|
|
4387
|
+
var MAX_TEXT = 1e5;
|
|
4388
|
+
var MAX_DEPTH2 = 6;
|
|
4389
|
+
var MAX_JSON_PARSE = 1e4;
|
|
4390
|
+
var URL_DEPTH = 4;
|
|
4391
|
+
var B64_DEPTH = 3;
|
|
4392
|
+
var MIN_SEGMENT = 16;
|
|
4393
|
+
var SEPARATORS = /[./\\?&= \t\n\r:;,\-_@%+#]/g;
|
|
4394
|
+
var stripSeparators = (s) => s.replace(SEPARATORS, "");
|
|
4395
|
+
function percentDecodeOnce(s) {
|
|
4396
|
+
try {
|
|
4397
|
+
return decodeURIComponent(s);
|
|
4398
|
+
} catch {
|
|
4399
|
+
return s.replace(/%([0-9A-Fa-f]{2})/g, (_, h) => String.fromCharCode(parseInt(h, 16)));
|
|
4400
|
+
}
|
|
4401
|
+
}
|
|
4402
|
+
function segments(s, alphabet) {
|
|
4403
|
+
const out = /* @__PURE__ */ new Set();
|
|
4404
|
+
if (alphabet.test(s)) out.add(s);
|
|
4405
|
+
for (const seg of s.split(/[?&\s"'<>]+/)) {
|
|
4406
|
+
if (seg.length >= MIN_SEGMENT && alphabet.test(seg)) out.add(seg);
|
|
4407
|
+
for (const part of seg.split("=")) {
|
|
4408
|
+
if (part.length >= MIN_SEGMENT && alphabet.test(part)) out.add(part);
|
|
4409
|
+
}
|
|
4410
|
+
}
|
|
4411
|
+
return [...out];
|
|
4412
|
+
}
|
|
4413
|
+
var looksText = (s) => s.length > 0 && !/[\x00-\x08\x0b\x0c\x0e-\x1f]/.test(s);
|
|
4414
|
+
var CANARY_DECODERS = {
|
|
4415
|
+
url: (s) => {
|
|
4416
|
+
const out = [];
|
|
4417
|
+
let cur = s;
|
|
4418
|
+
for (let i = 0; i < URL_DEPTH; i++) {
|
|
4419
|
+
const d = percentDecodeOnce(cur);
|
|
4420
|
+
if (d === cur) break;
|
|
4421
|
+
out.push(d);
|
|
4422
|
+
cur = d;
|
|
4423
|
+
}
|
|
4424
|
+
return out;
|
|
4425
|
+
},
|
|
4426
|
+
base64: (s) => {
|
|
4427
|
+
const out = [];
|
|
4428
|
+
let frontier = segments(s, /^[A-Za-z0-9+/\-_=]+$/);
|
|
4429
|
+
for (let depth = 0; depth < B64_DEPTH && frontier.length; depth++) {
|
|
4430
|
+
const next = [];
|
|
4431
|
+
for (const c of frontier) {
|
|
4432
|
+
const d = Buffer.from(c, "base64").toString("utf8");
|
|
4433
|
+
if (!looksText(d) || d.length < CANARY_MIN_LENGTH) continue;
|
|
4434
|
+
out.push(d);
|
|
4435
|
+
next.push(...segments(d, /^[A-Za-z0-9+/\-_=]+$/));
|
|
4436
|
+
}
|
|
4437
|
+
frontier = next;
|
|
4438
|
+
}
|
|
4439
|
+
return out;
|
|
4440
|
+
},
|
|
4441
|
+
hex: (s) => {
|
|
4442
|
+
const out = [];
|
|
4443
|
+
for (const c of segments(s, /^[0-9A-Fa-f]+$/)) {
|
|
4444
|
+
if (c.length % 2 !== 0 || c.length < CANARY_MIN_LENGTH * 2) continue;
|
|
4445
|
+
const d = Buffer.from(c, "hex").toString("utf8");
|
|
4446
|
+
if (looksText(d)) out.push(d);
|
|
4447
|
+
}
|
|
4448
|
+
return out;
|
|
4449
|
+
},
|
|
4450
|
+
separators: (s) => [stripSeparators(s)]
|
|
4451
|
+
};
|
|
4452
|
+
function lowestOffset(cands, needles, stripped) {
|
|
4453
|
+
let best = null;
|
|
4454
|
+
for (const c of cands) {
|
|
4455
|
+
for (const n of needles) {
|
|
4456
|
+
const off = c.indexOf(stripped ? n.stripped : n.raw);
|
|
4457
|
+
if (off >= 0 && (best === null || off < best.off)) best = { off, v: n.v };
|
|
4458
|
+
}
|
|
4459
|
+
}
|
|
4460
|
+
return best?.v ?? null;
|
|
4461
|
+
}
|
|
4462
|
+
var VIEWS = [
|
|
4463
|
+
{ view: "url-decoded", decoder: "url", stripped: false },
|
|
4464
|
+
{ view: "base64-decoded", decoder: "base64", stripped: false },
|
|
4465
|
+
{ view: "hex-decoded", decoder: "hex", stripped: false },
|
|
4466
|
+
{ view: "separators-stripped", decoder: "separators", stripped: true }
|
|
4467
|
+
];
|
|
4468
|
+
function prepare(values) {
|
|
4469
|
+
const out = [];
|
|
4470
|
+
for (const v of values) {
|
|
4471
|
+
if (typeof v.value !== "string" || v.value.length < CANARY_MIN_LENGTH) {
|
|
4472
|
+
console.error(
|
|
4473
|
+
`[node9 engine] canary ${v.id}: value shorter than ${CANARY_MIN_LENGTH}, skipped`
|
|
4474
|
+
);
|
|
4475
|
+
continue;
|
|
4476
|
+
}
|
|
4477
|
+
out.push({ v, raw: v.value, stripped: stripSeparators(v.value) });
|
|
4478
|
+
}
|
|
4479
|
+
return out;
|
|
4480
|
+
}
|
|
4481
|
+
function matchPrepared(text, needles) {
|
|
4482
|
+
if (!text || needles.length === 0) return null;
|
|
4483
|
+
const t = text.length > MAX_TEXT ? text.slice(0, MAX_TEXT) : text;
|
|
4484
|
+
const raw = lowestOffset([t], needles, false);
|
|
4485
|
+
if (raw) return { v: raw, view: "raw" };
|
|
4486
|
+
for (const { view, decoder, stripped } of VIEWS) {
|
|
4487
|
+
let cands;
|
|
4488
|
+
try {
|
|
4489
|
+
cands = CANARY_DECODERS[decoder](t);
|
|
4490
|
+
} catch (e) {
|
|
4491
|
+
console.error(
|
|
4492
|
+
`[node9 engine] canary view ${view} failed, skipped:`,
|
|
4493
|
+
e instanceof Error ? e.message : String(e)
|
|
4494
|
+
);
|
|
4495
|
+
continue;
|
|
4496
|
+
}
|
|
4497
|
+
const hit = lowestOffset(cands, needles, stripped);
|
|
4498
|
+
if (hit) return { v: hit, view };
|
|
4499
|
+
}
|
|
4500
|
+
return null;
|
|
4501
|
+
}
|
|
4502
|
+
function matchCanary(text, values) {
|
|
4503
|
+
if (!text || values.length === 0) return null;
|
|
4504
|
+
const hit = matchPrepared(text, prepare(values));
|
|
4505
|
+
return hit ? { id: hit.v.id, view: hit.view, retired: Boolean(hit.v.retired) } : null;
|
|
4506
|
+
}
|
|
4507
|
+
function matchCanaryArgs(args, values) {
|
|
4508
|
+
if (values.length === 0) return null;
|
|
4509
|
+
const needles = prepare(values);
|
|
4510
|
+
if (needles.length === 0) return null;
|
|
4511
|
+
const walk = (v, depth, fieldPath) => {
|
|
4512
|
+
if (depth > MAX_DEPTH2) return null;
|
|
4513
|
+
if (typeof v === "string") {
|
|
4514
|
+
const hit = matchPrepared(v, needles);
|
|
4515
|
+
if (hit) return { id: hit.v.id, view: hit.view, fieldPath, retired: Boolean(hit.v.retired) };
|
|
4516
|
+
if (v.length < MAX_JSON_PARSE) {
|
|
4517
|
+
const trimmed = v.trim();
|
|
4518
|
+
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
|
|
4519
|
+
try {
|
|
4520
|
+
return walk(JSON.parse(v), depth + 1, fieldPath);
|
|
4521
|
+
} catch {
|
|
4522
|
+
}
|
|
4523
|
+
}
|
|
4524
|
+
}
|
|
4525
|
+
return null;
|
|
4526
|
+
}
|
|
4527
|
+
if (typeof v === "number" && Number.isFinite(v)) return walk(String(v), depth, fieldPath);
|
|
4528
|
+
if (Array.isArray(v)) {
|
|
4529
|
+
for (let i = 0; i < v.length; i++) {
|
|
4530
|
+
const h = walk(v[i], depth + 1, `${fieldPath}[${i}]`);
|
|
4531
|
+
if (h) return h;
|
|
4532
|
+
}
|
|
4533
|
+
return null;
|
|
4534
|
+
}
|
|
4535
|
+
if (v && typeof v === "object") {
|
|
4536
|
+
for (const [k, child] of Object.entries(v)) {
|
|
4537
|
+
const h = walk(child, depth + 1, fieldPath ? `${fieldPath}.${k}` : k);
|
|
4538
|
+
if (h) return h;
|
|
4539
|
+
}
|
|
4540
|
+
}
|
|
4541
|
+
return null;
|
|
4542
|
+
};
|
|
4543
|
+
try {
|
|
4544
|
+
return walk(args, 0, "");
|
|
4545
|
+
} catch (e) {
|
|
4546
|
+
console.error(
|
|
4547
|
+
"[node9 engine] canary args walk failed:",
|
|
4548
|
+
e instanceof Error ? e.message : String(e)
|
|
4549
|
+
);
|
|
4550
|
+
return null;
|
|
4551
|
+
}
|
|
3863
4552
|
}
|
|
3864
4553
|
|
|
3865
4554
|
// src/scan/canonical.ts
|
|
3866
4555
|
var LONG_OUTPUT_THRESHOLD_BYTES = 100 * 1024;
|
|
3867
|
-
var CANONICAL_EXTRACTOR_VERSION = "canonical-
|
|
3868
|
-
var CANONICAL_EXTRACTOR_HASH = "
|
|
4556
|
+
var CANONICAL_EXTRACTOR_VERSION = "canonical-v10";
|
|
4557
|
+
var CANONICAL_EXTRACTOR_HASH = "5c786cc174281e51";
|
|
3869
4558
|
var DEDUPE_PREVIEW_LEN = 120;
|
|
3870
4559
|
function extractCanonicalFindings(call, ctx) {
|
|
3871
4560
|
const out = [];
|
|
@@ -3909,6 +4598,28 @@ function extractCanonicalFindings(call, ctx) {
|
|
|
3909
4598
|
);
|
|
3910
4599
|
}
|
|
3911
4600
|
}
|
|
4601
|
+
if (ctx.canaryValues && ctx.canaryValues.length > 0) {
|
|
4602
|
+
const hit = matchCanaryArgs(call.args, ctx.canaryValues);
|
|
4603
|
+
if (hit) {
|
|
4604
|
+
const v = ctx.canaryValues.find((x) => x.id === hit.id);
|
|
4605
|
+
out.push(
|
|
4606
|
+
makeFinding({
|
|
4607
|
+
type: "canary",
|
|
4608
|
+
ruleName: `canary:${v?.kind ?? "unknown"}`,
|
|
4609
|
+
patternName: "Decoy credential",
|
|
4610
|
+
verdict: "block",
|
|
4611
|
+
severity: "critical",
|
|
4612
|
+
reason: `Decoy credential planted at ${v?.path ?? "a decoy file"} appeared in ${call.toolName} args (${hit.view})`,
|
|
4613
|
+
toolName: call.toolName,
|
|
4614
|
+
ctx,
|
|
4615
|
+
ts,
|
|
4616
|
+
sourceType: "engine"
|
|
4617
|
+
// No `input`: makeFinding stores it verbatim and a finding must never
|
|
4618
|
+
// carry the value (E14). The wire never copies input anyway.
|
|
4619
|
+
})
|
|
4620
|
+
);
|
|
4621
|
+
}
|
|
4622
|
+
}
|
|
3912
4623
|
for (const value of stringValues(call.args)) {
|
|
3913
4624
|
const piiHits = detectPii(value);
|
|
3914
4625
|
for (const pattern of piiHits) {
|
|
@@ -4141,6 +4852,9 @@ function toScanFinding(c) {
|
|
|
4141
4852
|
"smart-rule": null,
|
|
4142
4853
|
"ast-fs-op": null,
|
|
4143
4854
|
dlp: "dlp",
|
|
4855
|
+
// Ships under the dlp rollup with patternName 'Decoy credential' and a
|
|
4856
|
+
// canary:<kind> ruleName until the SaaS wire type gains its own value.
|
|
4857
|
+
canary: "dlp",
|
|
4144
4858
|
pii: "pii",
|
|
4145
4859
|
"sensitive-file-read": "sensitive-file-read",
|
|
4146
4860
|
"privilege-escalation": "privilege-escalation",
|
|
@@ -4212,6 +4926,7 @@ export {
|
|
|
4212
4926
|
AST_FS_REGEX_RULES,
|
|
4213
4927
|
BASH_TOOL_NAMES,
|
|
4214
4928
|
BUILTIN_SHIELDS,
|
|
4929
|
+
CANARY_MIN_LENGTH,
|
|
4215
4930
|
CANONICAL_EXTRACTOR_HASH,
|
|
4216
4931
|
CANONICAL_EXTRACTOR_VERSION,
|
|
4217
4932
|
COST_PER_LOOP_ITER_USD,
|
|
@@ -4231,6 +4946,7 @@ export {
|
|
|
4231
4946
|
SCAN_SIGNAL_WEIGHTS,
|
|
4232
4947
|
SENSITIVE_PATH_RE,
|
|
4233
4948
|
SENSITIVE_PATH_REGEXES,
|
|
4949
|
+
SSRF_MAX_HOST,
|
|
4234
4950
|
analyzeFsOperation,
|
|
4235
4951
|
analyzePipeChain,
|
|
4236
4952
|
analyzeShellCommand,
|
|
@@ -4238,6 +4954,7 @@ export {
|
|
|
4238
4954
|
classifyAuditEntry,
|
|
4239
4955
|
classifyRuleSeverity,
|
|
4240
4956
|
classifyScanSignal,
|
|
4957
|
+
classifySsrf,
|
|
4241
4958
|
computeAgentDeviceScore,
|
|
4242
4959
|
computeArgsHash,
|
|
4243
4960
|
computeBlendedSecurityScore,
|
|
@@ -4258,6 +4975,7 @@ export {
|
|
|
4258
4975
|
extractNetworkTargets,
|
|
4259
4976
|
extractPositionalArgs,
|
|
4260
4977
|
extractSessionLevelFindings,
|
|
4978
|
+
extractShellDestTokens,
|
|
4261
4979
|
extractShellDestinations,
|
|
4262
4980
|
getCompiledRegex,
|
|
4263
4981
|
getNestedValue,
|
|
@@ -4268,10 +4986,13 @@ export {
|
|
|
4268
4986
|
isProtectedHomePath,
|
|
4269
4987
|
isShellShapedTool,
|
|
4270
4988
|
isShieldVerdict,
|
|
4989
|
+
matchCanary,
|
|
4990
|
+
matchCanaryArgs,
|
|
4271
4991
|
matchSensitivePath,
|
|
4272
4992
|
matchesPattern,
|
|
4273
4993
|
narrativeRuleLabel,
|
|
4274
4994
|
normalizeCommandForPolicy,
|
|
4995
|
+
normalizeIpLiteral,
|
|
4275
4996
|
parseAllSshHostsFromCommand,
|
|
4276
4997
|
parseDestHost,
|
|
4277
4998
|
previewArgs,
|
|
@@ -4281,6 +5002,7 @@ export {
|
|
|
4281
5002
|
scanInjection,
|
|
4282
5003
|
scanText,
|
|
4283
5004
|
sensitivePathMatch,
|
|
5005
|
+
ssrfFloor,
|
|
4284
5006
|
summarizeBlast,
|
|
4285
5007
|
summarizeScan,
|
|
4286
5008
|
toScanFinding,
|