@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.js
CHANGED
|
@@ -33,6 +33,7 @@ __export(src_exports, {
|
|
|
33
33
|
AST_FS_REGEX_RULES: () => AST_FS_REGEX_RULES,
|
|
34
34
|
BASH_TOOL_NAMES: () => BASH_TOOL_NAMES,
|
|
35
35
|
BUILTIN_SHIELDS: () => BUILTIN_SHIELDS,
|
|
36
|
+
CANARY_MIN_LENGTH: () => CANARY_MIN_LENGTH,
|
|
36
37
|
CANONICAL_EXTRACTOR_HASH: () => CANONICAL_EXTRACTOR_HASH,
|
|
37
38
|
CANONICAL_EXTRACTOR_VERSION: () => CANONICAL_EXTRACTOR_VERSION,
|
|
38
39
|
COST_PER_LOOP_ITER_USD: () => COST_PER_LOOP_ITER_USD,
|
|
@@ -52,6 +53,7 @@ __export(src_exports, {
|
|
|
52
53
|
SCAN_SIGNAL_WEIGHTS: () => SCAN_SIGNAL_WEIGHTS,
|
|
53
54
|
SENSITIVE_PATH_RE: () => SENSITIVE_PATH_RE,
|
|
54
55
|
SENSITIVE_PATH_REGEXES: () => SENSITIVE_PATH_REGEXES,
|
|
56
|
+
SSRF_MAX_HOST: () => SSRF_MAX_HOST,
|
|
55
57
|
analyzeFsOperation: () => analyzeFsOperation,
|
|
56
58
|
analyzePipeChain: () => analyzePipeChain,
|
|
57
59
|
analyzeShellCommand: () => analyzeShellCommand,
|
|
@@ -59,6 +61,7 @@ __export(src_exports, {
|
|
|
59
61
|
classifyAuditEntry: () => classifyAuditEntry,
|
|
60
62
|
classifyRuleSeverity: () => classifyRuleSeverity,
|
|
61
63
|
classifyScanSignal: () => classifyScanSignal,
|
|
64
|
+
classifySsrf: () => classifySsrf,
|
|
62
65
|
computeAgentDeviceScore: () => computeAgentDeviceScore,
|
|
63
66
|
computeArgsHash: () => computeArgsHash,
|
|
64
67
|
computeBlendedSecurityScore: () => computeBlendedSecurityScore,
|
|
@@ -79,6 +82,7 @@ __export(src_exports, {
|
|
|
79
82
|
extractNetworkTargets: () => extractNetworkTargets,
|
|
80
83
|
extractPositionalArgs: () => extractPositionalArgs,
|
|
81
84
|
extractSessionLevelFindings: () => extractSessionLevelFindings,
|
|
85
|
+
extractShellDestTokens: () => extractShellDestTokens,
|
|
82
86
|
extractShellDestinations: () => extractShellDestinations,
|
|
83
87
|
getCompiledRegex: () => getCompiledRegex,
|
|
84
88
|
getNestedValue: () => getNestedValue,
|
|
@@ -89,10 +93,13 @@ __export(src_exports, {
|
|
|
89
93
|
isProtectedHomePath: () => isProtectedHomePath,
|
|
90
94
|
isShellShapedTool: () => isShellShapedTool,
|
|
91
95
|
isShieldVerdict: () => isShieldVerdict,
|
|
96
|
+
matchCanary: () => matchCanary,
|
|
97
|
+
matchCanaryArgs: () => matchCanaryArgs,
|
|
92
98
|
matchSensitivePath: () => matchSensitivePath,
|
|
93
99
|
matchesPattern: () => matchesPattern,
|
|
94
100
|
narrativeRuleLabel: () => narrativeRuleLabel,
|
|
95
101
|
normalizeCommandForPolicy: () => normalizeCommandForPolicy,
|
|
102
|
+
normalizeIpLiteral: () => normalizeIpLiteral,
|
|
96
103
|
parseAllSshHostsFromCommand: () => parseAllSshHostsFromCommand,
|
|
97
104
|
parseDestHost: () => parseDestHost,
|
|
98
105
|
previewArgs: () => previewArgs,
|
|
@@ -102,6 +109,7 @@ __export(src_exports, {
|
|
|
102
109
|
scanInjection: () => scanInjection,
|
|
103
110
|
scanText: () => scanText,
|
|
104
111
|
sensitivePathMatch: () => sensitivePathMatch,
|
|
112
|
+
ssrfFloor: () => ssrfFloor,
|
|
105
113
|
summarizeBlast: () => summarizeBlast,
|
|
106
114
|
summarizeScan: () => summarizeScan,
|
|
107
115
|
toScanFinding: () => toScanFinding,
|
|
@@ -116,6 +124,175 @@ module.exports = __toCommonJS(src_exports);
|
|
|
116
124
|
// src/dlp/index.ts
|
|
117
125
|
var import_safe_regex2 = __toESM(require("safe-regex2"));
|
|
118
126
|
|
|
127
|
+
// src/scan/checksums.ts
|
|
128
|
+
var import_crypto = require("crypto");
|
|
129
|
+
function validateLuhn(digits) {
|
|
130
|
+
if (!/^\d+$/.test(digits)) return false;
|
|
131
|
+
if (digits.length < 12) return false;
|
|
132
|
+
if (!/[1-9]/.test(digits)) return false;
|
|
133
|
+
let sum = 0;
|
|
134
|
+
let double = false;
|
|
135
|
+
for (let i = digits.length - 1; i >= 0; i--) {
|
|
136
|
+
let d = digits.charCodeAt(i) - 48;
|
|
137
|
+
if (double) {
|
|
138
|
+
d *= 2;
|
|
139
|
+
if (d > 9) d -= 9;
|
|
140
|
+
}
|
|
141
|
+
sum += d;
|
|
142
|
+
double = !double;
|
|
143
|
+
}
|
|
144
|
+
return sum % 10 === 0;
|
|
145
|
+
}
|
|
146
|
+
var IBAN_LENGTH = {
|
|
147
|
+
AD: 24,
|
|
148
|
+
AE: 23,
|
|
149
|
+
AL: 28,
|
|
150
|
+
AT: 20,
|
|
151
|
+
AZ: 28,
|
|
152
|
+
BA: 20,
|
|
153
|
+
BE: 16,
|
|
154
|
+
BG: 22,
|
|
155
|
+
BH: 22,
|
|
156
|
+
BI: 27,
|
|
157
|
+
BR: 29,
|
|
158
|
+
BY: 28,
|
|
159
|
+
CH: 21,
|
|
160
|
+
CR: 22,
|
|
161
|
+
CY: 28,
|
|
162
|
+
CZ: 24,
|
|
163
|
+
DE: 22,
|
|
164
|
+
DJ: 27,
|
|
165
|
+
DK: 18,
|
|
166
|
+
DO: 28,
|
|
167
|
+
EE: 20,
|
|
168
|
+
EG: 29,
|
|
169
|
+
ES: 24,
|
|
170
|
+
FI: 18,
|
|
171
|
+
FK: 18,
|
|
172
|
+
FO: 18,
|
|
173
|
+
FR: 27,
|
|
174
|
+
GB: 22,
|
|
175
|
+
GE: 22,
|
|
176
|
+
GI: 23,
|
|
177
|
+
GL: 18,
|
|
178
|
+
GR: 27,
|
|
179
|
+
GT: 28,
|
|
180
|
+
HN: 28,
|
|
181
|
+
HR: 21,
|
|
182
|
+
HU: 28,
|
|
183
|
+
IE: 22,
|
|
184
|
+
IL: 23,
|
|
185
|
+
IQ: 23,
|
|
186
|
+
IS: 26,
|
|
187
|
+
IT: 27,
|
|
188
|
+
JO: 30,
|
|
189
|
+
KW: 30,
|
|
190
|
+
KZ: 20,
|
|
191
|
+
LB: 28,
|
|
192
|
+
LC: 32,
|
|
193
|
+
LI: 21,
|
|
194
|
+
LT: 20,
|
|
195
|
+
LU: 20,
|
|
196
|
+
LV: 21,
|
|
197
|
+
LY: 25,
|
|
198
|
+
MC: 27,
|
|
199
|
+
MD: 24,
|
|
200
|
+
ME: 22,
|
|
201
|
+
MK: 19,
|
|
202
|
+
MN: 20,
|
|
203
|
+
MR: 27,
|
|
204
|
+
MT: 31,
|
|
205
|
+
MU: 30,
|
|
206
|
+
NI: 28,
|
|
207
|
+
NL: 18,
|
|
208
|
+
NO: 15,
|
|
209
|
+
OM: 23,
|
|
210
|
+
PK: 24,
|
|
211
|
+
PL: 28,
|
|
212
|
+
PS: 29,
|
|
213
|
+
PT: 25,
|
|
214
|
+
QA: 29,
|
|
215
|
+
RO: 24,
|
|
216
|
+
RS: 22,
|
|
217
|
+
RU: 33,
|
|
218
|
+
SA: 24,
|
|
219
|
+
SC: 31,
|
|
220
|
+
SD: 18,
|
|
221
|
+
SE: 24,
|
|
222
|
+
SI: 19,
|
|
223
|
+
SK: 24,
|
|
224
|
+
SM: 27,
|
|
225
|
+
SN: 28,
|
|
226
|
+
SO: 23,
|
|
227
|
+
ST: 25,
|
|
228
|
+
SV: 28,
|
|
229
|
+
TL: 23,
|
|
230
|
+
TN: 24,
|
|
231
|
+
TR: 26,
|
|
232
|
+
UA: 29,
|
|
233
|
+
VA: 22,
|
|
234
|
+
VG: 24,
|
|
235
|
+
XK: 20,
|
|
236
|
+
YE: 30
|
|
237
|
+
};
|
|
238
|
+
function validateIban(raw) {
|
|
239
|
+
const s = raw.replace(/[ -]/g, "").toUpperCase();
|
|
240
|
+
if (!/^[A-Z]{2}\d{2}/.test(s)) return false;
|
|
241
|
+
const want = IBAN_LENGTH[s.slice(0, 2)];
|
|
242
|
+
if (want === void 0 || s.length < want) return false;
|
|
243
|
+
const iban = s.slice(0, want);
|
|
244
|
+
if (!/^[A-Z0-9]+$/.test(iban)) return false;
|
|
245
|
+
const rearranged = iban.slice(4) + iban.slice(0, 4);
|
|
246
|
+
let mod = 0;
|
|
247
|
+
for (const ch of rearranged) {
|
|
248
|
+
const code = ch.charCodeAt(0);
|
|
249
|
+
const digits = code >= 65 ? String(code - 55) : ch;
|
|
250
|
+
for (const d of digits) mod = (mod * 10 + (d.charCodeAt(0) - 48)) % 97;
|
|
251
|
+
}
|
|
252
|
+
return mod === 1;
|
|
253
|
+
}
|
|
254
|
+
var B58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
|
|
255
|
+
var B58_INDEX = Object.fromEntries(
|
|
256
|
+
[...B58].map((c, i) => [c, i])
|
|
257
|
+
);
|
|
258
|
+
function validateBase58Check(s) {
|
|
259
|
+
if (!s) return null;
|
|
260
|
+
let n = 0n;
|
|
261
|
+
for (const c of s) {
|
|
262
|
+
const v = B58_INDEX[c];
|
|
263
|
+
if (v === void 0) return null;
|
|
264
|
+
n = n * 58n + BigInt(v);
|
|
265
|
+
}
|
|
266
|
+
let hex = n.toString(16);
|
|
267
|
+
if (hex.length % 2) hex = "0" + hex;
|
|
268
|
+
let zeros = 0;
|
|
269
|
+
for (const c of s) {
|
|
270
|
+
if (c !== "1") break;
|
|
271
|
+
zeros++;
|
|
272
|
+
}
|
|
273
|
+
const bytes = Buffer.concat([
|
|
274
|
+
Buffer.alloc(zeros),
|
|
275
|
+
n === 0n ? Buffer.alloc(0) : Buffer.from(hex, "hex")
|
|
276
|
+
]);
|
|
277
|
+
if (bytes.length < 5) return null;
|
|
278
|
+
const body = bytes.subarray(0, bytes.length - 4);
|
|
279
|
+
const check = bytes.subarray(bytes.length - 4);
|
|
280
|
+
const h = (0, import_crypto.createHash)("sha256").update((0, import_crypto.createHash)("sha256").update(body).digest()).digest();
|
|
281
|
+
return h.subarray(0, 4).equals(check) ? body : null;
|
|
282
|
+
}
|
|
283
|
+
function validateWif(s) {
|
|
284
|
+
const p = validateBase58Check(s);
|
|
285
|
+
if (!p || p[0] !== 128) return false;
|
|
286
|
+
return p.length === 33 || p.length === 34 && p[33] === 1;
|
|
287
|
+
}
|
|
288
|
+
var XPRV_VERSIONS = /* @__PURE__ */ new Set([76066276, 77428856, 78791436]);
|
|
289
|
+
function validateXprv(s) {
|
|
290
|
+
const p = validateBase58Check(s);
|
|
291
|
+
if (!p || p.length !== 78) return false;
|
|
292
|
+
const version = p.readUInt32BE(0);
|
|
293
|
+
return XPRV_VERSIONS.has(version) && p[45] === 0;
|
|
294
|
+
}
|
|
295
|
+
|
|
119
296
|
// src/dlp/injection.ts
|
|
120
297
|
var MAX = 1e5;
|
|
121
298
|
var UNTRUSTED_TOOLS = /\b(web_?fetch|web_?search|fetch|curl|wget|browser|http_get|read_url|open_url)\b/i;
|
|
@@ -360,6 +537,27 @@ var DLP_PATTERNS = [
|
|
|
360
537
|
severity: "block",
|
|
361
538
|
keywords: ["sg."]
|
|
362
539
|
},
|
|
540
|
+
// ── Cryptocurrency private keys (base58check-validated) ───────────────────
|
|
541
|
+
// Both are anchored with \b on each side: unanchored, `[KL][base58]{51}`
|
|
542
|
+
// matches INSIDE any longer base58 blob (an xprv, a Solana keypair, a
|
|
543
|
+
// Monero address). Lookbehind fails safe-regex2; \b is the house style
|
|
544
|
+
// (see the card regexes). Mainnet only, matching validateWif / validateXprv;
|
|
545
|
+
// testnet (WIF 0xEF, tprv) is deferred. Cost was measured: the WIF regex
|
|
546
|
+
// runs on every string (first keyword-less pattern) at 0.024 ms per 100 KB
|
|
547
|
+
// of prose, so no prefilter is warranted.
|
|
548
|
+
{
|
|
549
|
+
name: "Bitcoin WIF Private Key",
|
|
550
|
+
regex: /\b(?:5[1-9A-HJ-NP-Za-km-z]{50}|[KL][1-9A-HJ-NP-Za-km-z]{51})\b/,
|
|
551
|
+
severity: "block",
|
|
552
|
+
validate: validateWif
|
|
553
|
+
},
|
|
554
|
+
{
|
|
555
|
+
name: "Extended Private Key",
|
|
556
|
+
regex: /\b[xyz]prv[1-9A-HJ-NP-Za-km-z]{107}\b/,
|
|
557
|
+
severity: "block",
|
|
558
|
+
keywords: ["xprv", "yprv", "zprv"],
|
|
559
|
+
validate: validateXprv
|
|
560
|
+
},
|
|
363
561
|
// ── Private keys (PEM) ────────────────────────────────────────────────────
|
|
364
562
|
{
|
|
365
563
|
name: "Private Key (PEM)",
|
|
@@ -724,6 +922,33 @@ var DLP_SCAN_LIMITS = {
|
|
|
724
922
|
/** Max nesting depth walked; anything deeper is NOT scanned. */
|
|
725
923
|
maxDepth: MAX_DEPTH
|
|
726
924
|
};
|
|
925
|
+
function suppressed(pattern, raw) {
|
|
926
|
+
if (pattern.validate) {
|
|
927
|
+
let ok;
|
|
928
|
+
try {
|
|
929
|
+
ok = pattern.validate(raw);
|
|
930
|
+
} catch {
|
|
931
|
+
ok = true;
|
|
932
|
+
}
|
|
933
|
+
return !ok;
|
|
934
|
+
}
|
|
935
|
+
if (DLP_STOPWORDS.some((sw) => raw.toLowerCase().includes(sw))) return true;
|
|
936
|
+
if (pattern.minEntropy !== void 0 && shannonEntropy(raw) < pattern.minEntropy) return true;
|
|
937
|
+
return false;
|
|
938
|
+
}
|
|
939
|
+
function firstAcceptedMatch(pattern, text) {
|
|
940
|
+
const flags = pattern.regex.flags.includes("g") ? pattern.regex.flags : pattern.regex.flags + "g";
|
|
941
|
+
const re = new RegExp(pattern.regex.source, flags);
|
|
942
|
+
let m;
|
|
943
|
+
while ((m = re.exec(text)) !== null) {
|
|
944
|
+
if (m[0].length === 0) {
|
|
945
|
+
re.lastIndex = m.index + 1;
|
|
946
|
+
continue;
|
|
947
|
+
}
|
|
948
|
+
if (!suppressed(pattern, m[0])) return m[0];
|
|
949
|
+
}
|
|
950
|
+
return null;
|
|
951
|
+
}
|
|
727
952
|
function scanArgs(args, depth = 0, fieldPath = "args") {
|
|
728
953
|
if (depth > MAX_DEPTH || args === null || args === void 0) return null;
|
|
729
954
|
if (Array.isArray(args)) {
|
|
@@ -748,18 +973,16 @@ function scanArgs(args, depth = 0, fieldPath = "args") {
|
|
|
748
973
|
if (pattern.keywords && !pattern.keywords.some((kw) => textLower.includes(kw.toLowerCase()))) {
|
|
749
974
|
continue;
|
|
750
975
|
}
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
};
|
|
762
|
-
}
|
|
976
|
+
const raw = firstAcceptedMatch(pattern, text);
|
|
977
|
+
if (raw === null) continue;
|
|
978
|
+
const severity = pattern.contextBoost && assignmentCtx ? "block" : pattern.severity;
|
|
979
|
+
return {
|
|
980
|
+
patternName: pattern.name,
|
|
981
|
+
fieldPath,
|
|
982
|
+
// Mask the ACCEPTED token, not the first regex hit in the field.
|
|
983
|
+
redactedSample: maskSecret(raw, pattern.regex),
|
|
984
|
+
severity
|
|
985
|
+
};
|
|
763
986
|
}
|
|
764
987
|
if (text.length < MAX_JSON_PARSE_BYTES) {
|
|
765
988
|
const trimmed = text.trim();
|
|
@@ -782,17 +1005,14 @@ function scanText(text) {
|
|
|
782
1005
|
if (pattern.keywords && !pattern.keywords.some((kw) => tLower.includes(kw.toLowerCase()))) {
|
|
783
1006
|
continue;
|
|
784
1007
|
}
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
severity: pattern.severity
|
|
794
|
-
};
|
|
795
|
-
}
|
|
1008
|
+
const raw = firstAcceptedMatch(pattern, t);
|
|
1009
|
+
if (raw === null) continue;
|
|
1010
|
+
return {
|
|
1011
|
+
patternName: pattern.name,
|
|
1012
|
+
fieldPath: "response-text",
|
|
1013
|
+
redactedSample: maskSecret(raw, pattern.regex),
|
|
1014
|
+
severity: pattern.severity
|
|
1015
|
+
};
|
|
796
1016
|
}
|
|
797
1017
|
return null;
|
|
798
1018
|
}
|
|
@@ -806,9 +1026,7 @@ function redactText(text) {
|
|
|
806
1026
|
continue;
|
|
807
1027
|
}
|
|
808
1028
|
result = result.replace(globalRegex, (match) => {
|
|
809
|
-
if (
|
|
810
|
-
if (pattern.minEntropy !== void 0 && shannonEntropy(match) < pattern.minEntropy)
|
|
811
|
-
return match;
|
|
1029
|
+
if (suppressed(pattern, match)) return match;
|
|
812
1030
|
if (!found.includes(pattern.name)) found.push(pattern.name);
|
|
813
1031
|
return `[node9-redacted:${pattern.name}]`;
|
|
814
1032
|
});
|
|
@@ -876,11 +1094,11 @@ function matchesPattern(text, patterns) {
|
|
|
876
1094
|
var FORBIDDEN_PATH_SEGMENTS = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype"]);
|
|
877
1095
|
function getNestedValue(obj, path) {
|
|
878
1096
|
if (!obj || typeof obj !== "object") return null;
|
|
879
|
-
const
|
|
880
|
-
for (const seg of
|
|
1097
|
+
const segments2 = path.split(".");
|
|
1098
|
+
for (const seg of segments2) {
|
|
881
1099
|
if (FORBIDDEN_PATH_SEGMENTS.has(seg)) return null;
|
|
882
1100
|
}
|
|
883
|
-
return
|
|
1101
|
+
return segments2.reduce((prev, curr) => prev?.[curr], obj);
|
|
884
1102
|
}
|
|
885
1103
|
function evaluateSmartConditions(args, rule) {
|
|
886
1104
|
if (!rule.conditions || rule.conditions.length === 0) return true;
|
|
@@ -1851,6 +2069,50 @@ function extractShellDestinations(command) {
|
|
|
1851
2069
|
}
|
|
1852
2070
|
return out;
|
|
1853
2071
|
}
|
|
2072
|
+
function extractShellDestTokens(command) {
|
|
2073
|
+
const f = parseShared(command);
|
|
2074
|
+
if (f === PARSE_FAIL) return [];
|
|
2075
|
+
const out = [];
|
|
2076
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2077
|
+
try {
|
|
2078
|
+
syntax.Walk(f, (node) => {
|
|
2079
|
+
if (!node) return false;
|
|
2080
|
+
const n = node;
|
|
2081
|
+
if (syntax.NodeType(n) !== "CallExpr") return true;
|
|
2082
|
+
const callArgs = n.Args || [];
|
|
2083
|
+
if (callArgs.length === 0) return true;
|
|
2084
|
+
const name = (resolveWordLiteral(callArgs[0]) || "").toLowerCase();
|
|
2085
|
+
if (!NET_BINARIES.has(name)) return true;
|
|
2086
|
+
const rest = callArgs.slice(1).map((a) => resolveWordLiteral(a));
|
|
2087
|
+
for (const raw of destTokensForBinary(name, rest)) {
|
|
2088
|
+
if (!raw) continue;
|
|
2089
|
+
let tok = raw.trim();
|
|
2090
|
+
const scheme = /^[a-z][a-z0-9+.-]*:\/\//i.exec(tok);
|
|
2091
|
+
const hasScheme = scheme !== null;
|
|
2092
|
+
if (hasScheme) tok = tok.slice(scheme[0].length);
|
|
2093
|
+
tok = tok.split(/[/?#]/)[0];
|
|
2094
|
+
const at = tok.lastIndexOf("@");
|
|
2095
|
+
if (at >= 0) tok = tok.slice(at + 1);
|
|
2096
|
+
if (tok.startsWith("[")) {
|
|
2097
|
+
const close = tok.indexOf("]");
|
|
2098
|
+
if (close > 0) tok = tok.slice(0, close + 1);
|
|
2099
|
+
} else {
|
|
2100
|
+
tok = tok.split(":")[0];
|
|
2101
|
+
}
|
|
2102
|
+
if (!tok) continue;
|
|
2103
|
+
if (!hasScheme && /^\d+$/.test(tok) && Number(tok) < 16777216) continue;
|
|
2104
|
+
const key = `${name}:${tok}`;
|
|
2105
|
+
if (seen.has(key)) continue;
|
|
2106
|
+
seen.add(key);
|
|
2107
|
+
out.push({ token: tok, binary: name });
|
|
2108
|
+
}
|
|
2109
|
+
return true;
|
|
2110
|
+
});
|
|
2111
|
+
} catch {
|
|
2112
|
+
return out;
|
|
2113
|
+
}
|
|
2114
|
+
return out;
|
|
2115
|
+
}
|
|
1854
2116
|
var FS_OP_CACHE_MAX = 5e3;
|
|
1855
2117
|
var fsOpCache = /* @__PURE__ */ new Map();
|
|
1856
2118
|
function analyzeFsOperation(command) {
|
|
@@ -2055,8 +2317,8 @@ function analyzeShellCommand(command) {
|
|
|
2055
2317
|
if (allTokens.length === 0) {
|
|
2056
2318
|
const normalized = command.replace(/\\(.)/g, "$1");
|
|
2057
2319
|
const sanitized = normalized.replace(/["'<>]/g, " ");
|
|
2058
|
-
const
|
|
2059
|
-
|
|
2320
|
+
const segments2 = sanitized.split(/[|;&]|\$\(|\)|`/);
|
|
2321
|
+
segments2.forEach((segment) => {
|
|
2060
2322
|
const tokens = segment.trim().split(/\s+/).filter(Boolean);
|
|
2061
2323
|
if (tokens.length > 0) {
|
|
2062
2324
|
const action = tokens[0].toLowerCase();
|
|
@@ -2075,6 +2337,10 @@ function analyzeShellCommand(command) {
|
|
|
2075
2337
|
|
|
2076
2338
|
// src/egress/index.ts
|
|
2077
2339
|
var DEFAULT_EGRESS_ALLOWLIST = [
|
|
2340
|
+
// node9's own control plane (api, app, dev-api, staging and the apex).
|
|
2341
|
+
// Without it, turning egress on asks the user to approve node9 itself.
|
|
2342
|
+
// A user `deny` entry still wins over this list, see evaluateEgress.
|
|
2343
|
+
"*.node9.ai",
|
|
2078
2344
|
"*.github.com",
|
|
2079
2345
|
"*.githubusercontent.com",
|
|
2080
2346
|
"*.npmjs.org",
|
|
@@ -2219,7 +2485,7 @@ function isSensitivePath(p) {
|
|
|
2219
2485
|
return SENSITIVE_PATTERNS.some((re) => re.test(p));
|
|
2220
2486
|
}
|
|
2221
2487
|
function splitOnPipe(cmd) {
|
|
2222
|
-
const
|
|
2488
|
+
const segments2 = [];
|
|
2223
2489
|
let current = "";
|
|
2224
2490
|
let inSingle = false;
|
|
2225
2491
|
let inDouble = false;
|
|
@@ -2232,21 +2498,21 @@ function splitOnPipe(cmd) {
|
|
|
2232
2498
|
inDouble = !inDouble;
|
|
2233
2499
|
current += ch;
|
|
2234
2500
|
} else if (ch === "|" && !inSingle && !inDouble && cmd[i + 1] !== "|" && (i === 0 || cmd[i - 1] !== "|")) {
|
|
2235
|
-
|
|
2501
|
+
segments2.push(current.trim());
|
|
2236
2502
|
current = "";
|
|
2237
2503
|
} else {
|
|
2238
2504
|
current += ch;
|
|
2239
2505
|
}
|
|
2240
2506
|
}
|
|
2241
|
-
if (current.trim())
|
|
2242
|
-
return
|
|
2507
|
+
if (current.trim()) segments2.push(current.trim());
|
|
2508
|
+
return segments2.filter(Boolean);
|
|
2243
2509
|
}
|
|
2244
2510
|
function positionalTokens(segment) {
|
|
2245
2511
|
return segment.split(/\s+/).slice(1).filter((t) => !t.startsWith("-") && !t.startsWith("@") && t.length > 0);
|
|
2246
2512
|
}
|
|
2247
2513
|
function analyzePipeChain(command) {
|
|
2248
|
-
const
|
|
2249
|
-
if (
|
|
2514
|
+
const segments2 = splitOnPipe(command);
|
|
2515
|
+
if (segments2.length < 2) {
|
|
2250
2516
|
return {
|
|
2251
2517
|
isPipeline: false,
|
|
2252
2518
|
hasSensitiveSource: false,
|
|
@@ -2262,7 +2528,7 @@ function analyzePipeChain(command) {
|
|
|
2262
2528
|
let hasSensitiveSource = false;
|
|
2263
2529
|
let hasExternalSink = false;
|
|
2264
2530
|
let hasObfuscation = false;
|
|
2265
|
-
for (const segment of
|
|
2531
|
+
for (const segment of segments2) {
|
|
2266
2532
|
const tokens = segment.split(/\s+/).filter(Boolean);
|
|
2267
2533
|
if (tokens.length === 0) continue;
|
|
2268
2534
|
const binary = tokens[0].toLowerCase();
|
|
@@ -2302,8 +2568,8 @@ function analyzePipeChain(command) {
|
|
|
2302
2568
|
|
|
2303
2569
|
// src/policy/flag-tables.ts
|
|
2304
2570
|
function basename(p) {
|
|
2305
|
-
const
|
|
2306
|
-
return
|
|
2571
|
+
const segments2 = p.split(/[\\/]/);
|
|
2572
|
+
return segments2[segments2.length - 1] || "";
|
|
2307
2573
|
}
|
|
2308
2574
|
var FLAGS_WITH_VALUES = {
|
|
2309
2575
|
curl: /* @__PURE__ */ new Set([
|
|
@@ -2473,6 +2739,206 @@ function parseAllSshHostsFromCommand(command) {
|
|
|
2473
2739
|
return extractAllSshHosts(tokens.slice(1));
|
|
2474
2740
|
}
|
|
2475
2741
|
|
|
2742
|
+
// src/egress/ssrf.ts
|
|
2743
|
+
var SSRF_MAX_HOST = 253;
|
|
2744
|
+
function parseComponent(s) {
|
|
2745
|
+
if (!s) return null;
|
|
2746
|
+
if (/^0[xX][0-9a-fA-F]+$/.test(s)) return parseInt(s.slice(2), 16);
|
|
2747
|
+
if (s === "0") return 0;
|
|
2748
|
+
if (/^0[0-7]+$/.test(s)) return parseInt(s.slice(1), 8);
|
|
2749
|
+
if (/^[1-9][0-9]*$/.test(s)) return Number(s);
|
|
2750
|
+
return null;
|
|
2751
|
+
}
|
|
2752
|
+
function parseIpv4(input) {
|
|
2753
|
+
let s = input;
|
|
2754
|
+
if (s.endsWith(".")) s = s.slice(0, -1);
|
|
2755
|
+
if (!s) return null;
|
|
2756
|
+
const parts = s.split(".");
|
|
2757
|
+
if (parts.length > 4) return null;
|
|
2758
|
+
const vals = [];
|
|
2759
|
+
for (const p of parts) {
|
|
2760
|
+
const v = parseComponent(p);
|
|
2761
|
+
if (v === null || !Number.isFinite(v) || v < 0) return null;
|
|
2762
|
+
vals.push(v);
|
|
2763
|
+
}
|
|
2764
|
+
const n = vals.length;
|
|
2765
|
+
for (let i = 0; i < n - 1; i++) if (vals[i] > 255) return null;
|
|
2766
|
+
const last = vals[n - 1];
|
|
2767
|
+
const remainingBytes = 4 - (n - 1);
|
|
2768
|
+
const limit = Math.pow(256, remainingBytes);
|
|
2769
|
+
if (last >= limit) return null;
|
|
2770
|
+
let value = last;
|
|
2771
|
+
for (let i = 0; i < n - 1; i++) value += vals[i] * Math.pow(256, 3 - i);
|
|
2772
|
+
if (value > 4294967295) return null;
|
|
2773
|
+
return [value >>> 24 & 255, value >>> 16 & 255, value >>> 8 & 255, value & 255].join(".");
|
|
2774
|
+
}
|
|
2775
|
+
function expandIpv6(input) {
|
|
2776
|
+
const s = input.toLowerCase();
|
|
2777
|
+
if (!/^[0-9a-f:.]+$/.test(s)) return null;
|
|
2778
|
+
if ((s.match(/::/g) ?? []).length > 1) return null;
|
|
2779
|
+
let head = s;
|
|
2780
|
+
let tailV4 = null;
|
|
2781
|
+
const lastColon = s.lastIndexOf(":");
|
|
2782
|
+
const afterLast = s.slice(lastColon + 1);
|
|
2783
|
+
if (afterLast.includes(".")) {
|
|
2784
|
+
const dotted = parseIpv4(afterLast);
|
|
2785
|
+
if (!dotted) return null;
|
|
2786
|
+
const o = dotted.split(".").map(Number);
|
|
2787
|
+
tailV4 = [o[0] << 8 | o[1], o[2] << 8 | o[3]];
|
|
2788
|
+
head = s.slice(0, lastColon + 1) + "0";
|
|
2789
|
+
}
|
|
2790
|
+
const [lhs, rhs] = head.includes("::") ? head.split("::") : [head, null];
|
|
2791
|
+
const toGroups = (part) => {
|
|
2792
|
+
if (!part) return [];
|
|
2793
|
+
const out = [];
|
|
2794
|
+
for (const g of part.split(":")) {
|
|
2795
|
+
if (!/^[0-9a-f]{1,4}$/.test(g)) return null;
|
|
2796
|
+
out.push(parseInt(g, 16));
|
|
2797
|
+
}
|
|
2798
|
+
return out;
|
|
2799
|
+
};
|
|
2800
|
+
const left = toGroups(lhs);
|
|
2801
|
+
if (left === null) return null;
|
|
2802
|
+
let right = [];
|
|
2803
|
+
if (rhs !== null) {
|
|
2804
|
+
const r = toGroups(rhs);
|
|
2805
|
+
if (r === null) return null;
|
|
2806
|
+
right = r;
|
|
2807
|
+
}
|
|
2808
|
+
if (tailV4) {
|
|
2809
|
+
if (rhs !== null) right = right.slice(0, -1).concat(tailV4);
|
|
2810
|
+
else left.splice(left.length - 1, 1, ...tailV4);
|
|
2811
|
+
}
|
|
2812
|
+
const groups = rhs === null ? left : left.concat(new Array(8 - left.length - right.length).fill(0), right);
|
|
2813
|
+
if (rhs === null && groups.length !== 8) return null;
|
|
2814
|
+
if (rhs !== null && left.length + right.length > 8) return null;
|
|
2815
|
+
if (groups.length !== 8) return null;
|
|
2816
|
+
return groups;
|
|
2817
|
+
}
|
|
2818
|
+
function compressIpv6(g) {
|
|
2819
|
+
let bestStart = -1;
|
|
2820
|
+
let bestLen = 0;
|
|
2821
|
+
let i = 0;
|
|
2822
|
+
while (i < 8) {
|
|
2823
|
+
if (g[i] !== 0) {
|
|
2824
|
+
i++;
|
|
2825
|
+
continue;
|
|
2826
|
+
}
|
|
2827
|
+
let j = i;
|
|
2828
|
+
while (j < 8 && g[j] === 0) j++;
|
|
2829
|
+
if (j - i > bestLen) {
|
|
2830
|
+
bestLen = j - i;
|
|
2831
|
+
bestStart = i;
|
|
2832
|
+
}
|
|
2833
|
+
i = j;
|
|
2834
|
+
}
|
|
2835
|
+
const hex = g.map((x) => x.toString(16));
|
|
2836
|
+
if (bestLen < 2) return hex.join(":");
|
|
2837
|
+
return hex.slice(0, bestStart).join(":") + "::" + hex.slice(bestStart + bestLen).join(":");
|
|
2838
|
+
}
|
|
2839
|
+
function normalizeIpLiteral(host) {
|
|
2840
|
+
try {
|
|
2841
|
+
if (typeof host !== "string") return null;
|
|
2842
|
+
let s = host.trim();
|
|
2843
|
+
if (!s || s.length > SSRF_MAX_HOST) return null;
|
|
2844
|
+
if (s.startsWith("[") && s.endsWith("]")) s = s.slice(1, -1);
|
|
2845
|
+
const zone = s.indexOf("%");
|
|
2846
|
+
if (zone >= 0) s = s.slice(0, zone);
|
|
2847
|
+
if (!s) return null;
|
|
2848
|
+
if (s.includes(":")) {
|
|
2849
|
+
const g = expandIpv6(s);
|
|
2850
|
+
if (!g) return null;
|
|
2851
|
+
const mapped = g.slice(0, 5).every((x) => x === 0) && g[5] === 65535;
|
|
2852
|
+
if (mapped) {
|
|
2853
|
+
return [g[6] >> 8 & 255, g[6] & 255, g[7] >> 8 & 255, g[7] & 255].join(".");
|
|
2854
|
+
}
|
|
2855
|
+
return compressIpv6(g);
|
|
2856
|
+
}
|
|
2857
|
+
return parseIpv4(s);
|
|
2858
|
+
} catch {
|
|
2859
|
+
return null;
|
|
2860
|
+
}
|
|
2861
|
+
}
|
|
2862
|
+
var METADATA_ADDRESSES = /* @__PURE__ */ new Set([
|
|
2863
|
+
"169.254.169.254",
|
|
2864
|
+
// AWS / Azure / DigitalOcean / OpenStack IMDS
|
|
2865
|
+
"169.254.170.2",
|
|
2866
|
+
// AWS ECS task role
|
|
2867
|
+
"168.63.129.16",
|
|
2868
|
+
// Azure WireServer
|
|
2869
|
+
"fd00:ec2::254"
|
|
2870
|
+
// AWS IMDS over IPv6 (inside fc00::/7, which is NOT a tier)
|
|
2871
|
+
]);
|
|
2872
|
+
var METADATA_HOSTNAMES = /* @__PURE__ */ new Set(["metadata.google.internal", "metadata.goog", "metadata"]);
|
|
2873
|
+
var v4Octets = (a) => {
|
|
2874
|
+
const p = a.split(".");
|
|
2875
|
+
return p.length === 4 ? p.map(Number) : null;
|
|
2876
|
+
};
|
|
2877
|
+
function classifySsrf(host) {
|
|
2878
|
+
try {
|
|
2879
|
+
if (typeof host !== "string" || !host) return null;
|
|
2880
|
+
const lower = host.trim().toLowerCase().replace(/\.$/, "");
|
|
2881
|
+
const ip = normalizeIpLiteral(host);
|
|
2882
|
+
if (ip === null) {
|
|
2883
|
+
return METADATA_HOSTNAMES.has(lower) ? { tier: "metadata", overridable: false, kind: "hostname" } : null;
|
|
2884
|
+
}
|
|
2885
|
+
const hit = (tier, overridable) => ({
|
|
2886
|
+
tier,
|
|
2887
|
+
overridable,
|
|
2888
|
+
kind: "address",
|
|
2889
|
+
normalized: ip
|
|
2890
|
+
});
|
|
2891
|
+
if (METADATA_ADDRESSES.has(ip)) return hit("metadata", false);
|
|
2892
|
+
const o = v4Octets(ip);
|
|
2893
|
+
if (o) {
|
|
2894
|
+
if (o[0] === 0 && o[1] === 0 && o[2] === 0 && o[3] === 0) return hit("unspecified", false);
|
|
2895
|
+
if (o[0] === 169 && o[1] === 254) return hit("link-local", false);
|
|
2896
|
+
if (o[0] >= 224 && o[0] <= 239) return hit("multicast", false);
|
|
2897
|
+
if (o[0] === 100 && o[1] >= 64 && o[1] <= 127) return hit("cgnat", true);
|
|
2898
|
+
if (o[0] === 127) return hit("private", true);
|
|
2899
|
+
if (o[0] === 10) return hit("private", true);
|
|
2900
|
+
if (o[0] === 192 && o[1] === 168) return hit("private", true);
|
|
2901
|
+
if (o[0] === 172 && o[1] >= 16 && o[1] <= 31) return hit("private", true);
|
|
2902
|
+
return null;
|
|
2903
|
+
}
|
|
2904
|
+
const g = expandIpv6(ip);
|
|
2905
|
+
if (!g) return null;
|
|
2906
|
+
if (g.every((x) => x === 0)) return hit("unspecified", false);
|
|
2907
|
+
if ((g[0] & 65472) === 65152) return hit("link-local", false);
|
|
2908
|
+
if ((g[0] & 65280) === 65280) return hit("multicast", false);
|
|
2909
|
+
if (g.slice(0, 7).every((x) => x === 0) && g[7] === 1) return hit("private", true);
|
|
2910
|
+
return null;
|
|
2911
|
+
} catch {
|
|
2912
|
+
return null;
|
|
2913
|
+
}
|
|
2914
|
+
}
|
|
2915
|
+
var TIER_REASON = {
|
|
2916
|
+
metadata: "a cloud instance-metadata endpoint, the classic credential-theft target",
|
|
2917
|
+
"link-local": "a link-local address",
|
|
2918
|
+
multicast: "a multicast address",
|
|
2919
|
+
unspecified: "the unspecified address",
|
|
2920
|
+
cgnat: "a carrier-grade NAT address",
|
|
2921
|
+
private: "a loopback or private address"
|
|
2922
|
+
};
|
|
2923
|
+
function ssrfFloor(tokens, opts = {}) {
|
|
2924
|
+
const exempt = new Set(
|
|
2925
|
+
(opts.ssrfAllow ?? []).map((e) => normalizeIpLiteral(e) ?? e.trim().toLowerCase())
|
|
2926
|
+
);
|
|
2927
|
+
for (const { token, binary } of tokens) {
|
|
2928
|
+
const m = classifySsrf(token);
|
|
2929
|
+
if (!m) continue;
|
|
2930
|
+
if (m.tier === "private" && !opts.ssrfStrict) continue;
|
|
2931
|
+
if (m.overridable && m.normalized && exempt.has(m.normalized)) continue;
|
|
2932
|
+
return {
|
|
2933
|
+
...m,
|
|
2934
|
+
host: token,
|
|
2935
|
+
binary,
|
|
2936
|
+
reason: `Blocked: ${token} is ${TIER_REASON[m.tier]}` + (m.normalized && m.normalized !== token ? ` (${m.normalized})` : "") + (m.overridable ? "." : ". This address cannot be allowlisted.")
|
|
2937
|
+
};
|
|
2938
|
+
}
|
|
2939
|
+
return null;
|
|
2940
|
+
}
|
|
2941
|
+
|
|
2476
2942
|
// src/policy/index.ts
|
|
2477
2943
|
function resolveCheck(v) {
|
|
2478
2944
|
return v === "off" || v === "block" ? v : "review";
|
|
@@ -2715,6 +3181,22 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
|
|
|
2715
3181
|
}
|
|
2716
3182
|
const builtin = strictestVerdict(candidates);
|
|
2717
3183
|
if (builtin) return builtin;
|
|
3184
|
+
{
|
|
3185
|
+
const ssrf = ssrfFloor(extractShellDestTokens(shellCommand), {
|
|
3186
|
+
ssrfAllow: config.policy.egress?.ssrfAllow,
|
|
3187
|
+
ssrfStrict: config.policy.egress?.ssrfStrict
|
|
3188
|
+
});
|
|
3189
|
+
if (ssrf) {
|
|
3190
|
+
return {
|
|
3191
|
+
decision: "block",
|
|
3192
|
+
blockedByLabel: "\u{1F310} Node9 Egress (Protected Address)",
|
|
3193
|
+
reason: ssrf.reason,
|
|
3194
|
+
ruleName: `ssrf:${ssrf.tier}:${ssrf.binary}:${ssrf.host}`,
|
|
3195
|
+
ruleDescription: ssrf.reason,
|
|
3196
|
+
tier: 3
|
|
3197
|
+
};
|
|
3198
|
+
}
|
|
3199
|
+
}
|
|
2718
3200
|
if (config.policy.egress?.enabled) {
|
|
2719
3201
|
const dests = extractShellDestinations(shellCommand);
|
|
2720
3202
|
if (dests.length > 0) {
|
|
@@ -3666,11 +4148,11 @@ function assertBuiltinShieldRegexesAreSafe() {
|
|
|
3666
4148
|
assertBuiltinShieldRegexesAreSafe();
|
|
3667
4149
|
|
|
3668
4150
|
// src/loop/index.ts
|
|
3669
|
-
var
|
|
4151
|
+
var import_crypto2 = __toESM(require("crypto"));
|
|
3670
4152
|
var LOOP_MAX_RECORDS = 500;
|
|
3671
4153
|
function computeArgsHash(args) {
|
|
3672
4154
|
const str = JSON.stringify(args ?? "");
|
|
3673
|
-
return
|
|
4155
|
+
return import_crypto2.default.createHash("sha256").update(str).digest("hex").slice(0, 16);
|
|
3674
4156
|
}
|
|
3675
4157
|
function evaluateLoopWindow(records, tool, args, threshold, windowMs, now) {
|
|
3676
4158
|
const hash = computeArgsHash(args);
|
|
@@ -3953,34 +4435,249 @@ var FILE_TOOLS = /* @__PURE__ */ new Set([
|
|
|
3953
4435
|
var PII_EMAIL_RE = /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/;
|
|
3954
4436
|
var PII_SSN_RE = /\b\d{3}-\d{2}-\d{4}\b/;
|
|
3955
4437
|
var PII_PHONE_RE = /\b(?:\+?1[-.\s]?)?\(?\d{3}\)?[-.\s]\d{3}[-.\s]\d{4}\b/;
|
|
3956
|
-
var
|
|
4438
|
+
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/;
|
|
4439
|
+
var PII_CC15_RE = /\b3[47]\d{2}[-\s]?\d{6}[-\s]?\d{5}\b/;
|
|
4440
|
+
var PII_IBAN_RE = /\b[A-Z]{2}\d{2}(?:[A-Z0-9]|[ -][A-Z0-9]){11,30}\b/;
|
|
4441
|
+
function hasValidCard(text) {
|
|
4442
|
+
for (const base of [PII_CC16_RE, PII_CC15_RE]) {
|
|
4443
|
+
const re = new RegExp(base.source, "g");
|
|
4444
|
+
let m;
|
|
4445
|
+
while ((m = re.exec(text)) !== null) {
|
|
4446
|
+
if (validateLuhn(m[0].replace(/\D/g, ""))) return true;
|
|
4447
|
+
re.lastIndex = m.index + 1;
|
|
4448
|
+
}
|
|
4449
|
+
}
|
|
4450
|
+
return false;
|
|
4451
|
+
}
|
|
4452
|
+
function hasValidIban(text) {
|
|
4453
|
+
const re = new RegExp(PII_IBAN_RE.source, "g");
|
|
4454
|
+
let m;
|
|
4455
|
+
while ((m = re.exec(text)) !== null) {
|
|
4456
|
+
if (validateIban(m[0])) return true;
|
|
4457
|
+
re.lastIndex = m.index + 1;
|
|
4458
|
+
}
|
|
4459
|
+
return false;
|
|
4460
|
+
}
|
|
3957
4461
|
function detectPii(text) {
|
|
3958
4462
|
const found = /* @__PURE__ */ new Set();
|
|
3959
4463
|
if (/@/.test(text) && PII_EMAIL_RE.test(text)) found.add("Email");
|
|
3960
4464
|
if (/-/.test(text) && PII_SSN_RE.test(text)) found.add("SSN");
|
|
3961
4465
|
if (PII_PHONE_RE.test(text)) found.add("Phone");
|
|
3962
|
-
if (
|
|
4466
|
+
if (hasValidCard(text)) found.add("Credit Card");
|
|
4467
|
+
if (hasValidIban(text)) found.add("IBAN");
|
|
3963
4468
|
return [...found];
|
|
3964
4469
|
}
|
|
3965
|
-
var REALTIME_PII_PATTERNS = ["SSN", "Credit Card"];
|
|
4470
|
+
var REALTIME_PII_PATTERNS = ["SSN", "Credit Card", "IBAN"];
|
|
3966
4471
|
var MAX_PII_SCAN_BYTES = 1e5;
|
|
4472
|
+
function* stringLeaves(v, depth = 0) {
|
|
4473
|
+
if (depth > 6) return;
|
|
4474
|
+
if (typeof v === "string") {
|
|
4475
|
+
if (v.length > 0) yield v;
|
|
4476
|
+
return;
|
|
4477
|
+
}
|
|
4478
|
+
if (typeof v === "number") {
|
|
4479
|
+
if (Number.isFinite(v)) yield String(v);
|
|
4480
|
+
return;
|
|
4481
|
+
}
|
|
4482
|
+
if (!v || typeof v !== "object") return;
|
|
4483
|
+
if (Array.isArray(v)) {
|
|
4484
|
+
for (const x of v) yield* stringLeaves(x, depth + 1);
|
|
4485
|
+
return;
|
|
4486
|
+
}
|
|
4487
|
+
for (const x of Object.values(v)) yield* stringLeaves(x, depth + 1);
|
|
4488
|
+
}
|
|
3967
4489
|
function detectArgsPii(args) {
|
|
3968
4490
|
if (args === null || args === void 0) return [];
|
|
3969
|
-
|
|
4491
|
+
const found = /* @__PURE__ */ new Set();
|
|
4492
|
+
let budget = MAX_PII_SCAN_BYTES;
|
|
3970
4493
|
try {
|
|
3971
|
-
|
|
4494
|
+
for (const leaf of stringLeaves(args)) {
|
|
4495
|
+
if (budget <= 0) break;
|
|
4496
|
+
const t = leaf.length > budget ? leaf.slice(0, budget) : leaf;
|
|
4497
|
+
budget -= t.length;
|
|
4498
|
+
for (const p of detectPii(t)) {
|
|
4499
|
+
if (REALTIME_PII_PATTERNS.includes(p)) found.add(p);
|
|
4500
|
+
}
|
|
4501
|
+
}
|
|
3972
4502
|
} catch {
|
|
3973
4503
|
return [];
|
|
3974
4504
|
}
|
|
3975
|
-
|
|
3976
|
-
|
|
3977
|
-
|
|
4505
|
+
return [...found];
|
|
4506
|
+
}
|
|
4507
|
+
|
|
4508
|
+
// src/dlp/canary.ts
|
|
4509
|
+
var CANARY_MIN_LENGTH = 16;
|
|
4510
|
+
var MAX_TEXT = 1e5;
|
|
4511
|
+
var MAX_DEPTH2 = 6;
|
|
4512
|
+
var MAX_JSON_PARSE = 1e4;
|
|
4513
|
+
var URL_DEPTH = 4;
|
|
4514
|
+
var B64_DEPTH = 3;
|
|
4515
|
+
var MIN_SEGMENT = 16;
|
|
4516
|
+
var SEPARATORS = /[./\\?&= \t\n\r:;,\-_@%+#]/g;
|
|
4517
|
+
var stripSeparators = (s) => s.replace(SEPARATORS, "");
|
|
4518
|
+
function percentDecodeOnce(s) {
|
|
4519
|
+
try {
|
|
4520
|
+
return decodeURIComponent(s);
|
|
4521
|
+
} catch {
|
|
4522
|
+
return s.replace(/%([0-9A-Fa-f]{2})/g, (_, h) => String.fromCharCode(parseInt(h, 16)));
|
|
4523
|
+
}
|
|
4524
|
+
}
|
|
4525
|
+
function segments(s, alphabet) {
|
|
4526
|
+
const out = /* @__PURE__ */ new Set();
|
|
4527
|
+
if (alphabet.test(s)) out.add(s);
|
|
4528
|
+
for (const seg of s.split(/[?&\s"'<>]+/)) {
|
|
4529
|
+
if (seg.length >= MIN_SEGMENT && alphabet.test(seg)) out.add(seg);
|
|
4530
|
+
for (const part of seg.split("=")) {
|
|
4531
|
+
if (part.length >= MIN_SEGMENT && alphabet.test(part)) out.add(part);
|
|
4532
|
+
}
|
|
4533
|
+
}
|
|
4534
|
+
return [...out];
|
|
4535
|
+
}
|
|
4536
|
+
var looksText = (s) => s.length > 0 && !/[\x00-\x08\x0b\x0c\x0e-\x1f]/.test(s);
|
|
4537
|
+
var CANARY_DECODERS = {
|
|
4538
|
+
url: (s) => {
|
|
4539
|
+
const out = [];
|
|
4540
|
+
let cur = s;
|
|
4541
|
+
for (let i = 0; i < URL_DEPTH; i++) {
|
|
4542
|
+
const d = percentDecodeOnce(cur);
|
|
4543
|
+
if (d === cur) break;
|
|
4544
|
+
out.push(d);
|
|
4545
|
+
cur = d;
|
|
4546
|
+
}
|
|
4547
|
+
return out;
|
|
4548
|
+
},
|
|
4549
|
+
base64: (s) => {
|
|
4550
|
+
const out = [];
|
|
4551
|
+
let frontier = segments(s, /^[A-Za-z0-9+/\-_=]+$/);
|
|
4552
|
+
for (let depth = 0; depth < B64_DEPTH && frontier.length; depth++) {
|
|
4553
|
+
const next = [];
|
|
4554
|
+
for (const c of frontier) {
|
|
4555
|
+
const d = Buffer.from(c, "base64").toString("utf8");
|
|
4556
|
+
if (!looksText(d) || d.length < CANARY_MIN_LENGTH) continue;
|
|
4557
|
+
out.push(d);
|
|
4558
|
+
next.push(...segments(d, /^[A-Za-z0-9+/\-_=]+$/));
|
|
4559
|
+
}
|
|
4560
|
+
frontier = next;
|
|
4561
|
+
}
|
|
4562
|
+
return out;
|
|
4563
|
+
},
|
|
4564
|
+
hex: (s) => {
|
|
4565
|
+
const out = [];
|
|
4566
|
+
for (const c of segments(s, /^[0-9A-Fa-f]+$/)) {
|
|
4567
|
+
if (c.length % 2 !== 0 || c.length < CANARY_MIN_LENGTH * 2) continue;
|
|
4568
|
+
const d = Buffer.from(c, "hex").toString("utf8");
|
|
4569
|
+
if (looksText(d)) out.push(d);
|
|
4570
|
+
}
|
|
4571
|
+
return out;
|
|
4572
|
+
},
|
|
4573
|
+
separators: (s) => [stripSeparators(s)]
|
|
4574
|
+
};
|
|
4575
|
+
function lowestOffset(cands, needles, stripped) {
|
|
4576
|
+
let best = null;
|
|
4577
|
+
for (const c of cands) {
|
|
4578
|
+
for (const n of needles) {
|
|
4579
|
+
const off = c.indexOf(stripped ? n.stripped : n.raw);
|
|
4580
|
+
if (off >= 0 && (best === null || off < best.off)) best = { off, v: n.v };
|
|
4581
|
+
}
|
|
4582
|
+
}
|
|
4583
|
+
return best?.v ?? null;
|
|
4584
|
+
}
|
|
4585
|
+
var VIEWS = [
|
|
4586
|
+
{ view: "url-decoded", decoder: "url", stripped: false },
|
|
4587
|
+
{ view: "base64-decoded", decoder: "base64", stripped: false },
|
|
4588
|
+
{ view: "hex-decoded", decoder: "hex", stripped: false },
|
|
4589
|
+
{ view: "separators-stripped", decoder: "separators", stripped: true }
|
|
4590
|
+
];
|
|
4591
|
+
function prepare(values) {
|
|
4592
|
+
const out = [];
|
|
4593
|
+
for (const v of values) {
|
|
4594
|
+
if (typeof v.value !== "string" || v.value.length < CANARY_MIN_LENGTH) {
|
|
4595
|
+
console.error(
|
|
4596
|
+
`[node9 engine] canary ${v.id}: value shorter than ${CANARY_MIN_LENGTH}, skipped`
|
|
4597
|
+
);
|
|
4598
|
+
continue;
|
|
4599
|
+
}
|
|
4600
|
+
out.push({ v, raw: v.value, stripped: stripSeparators(v.value) });
|
|
4601
|
+
}
|
|
4602
|
+
return out;
|
|
4603
|
+
}
|
|
4604
|
+
function matchPrepared(text, needles) {
|
|
4605
|
+
if (!text || needles.length === 0) return null;
|
|
4606
|
+
const t = text.length > MAX_TEXT ? text.slice(0, MAX_TEXT) : text;
|
|
4607
|
+
const raw = lowestOffset([t], needles, false);
|
|
4608
|
+
if (raw) return { v: raw, view: "raw" };
|
|
4609
|
+
for (const { view, decoder, stripped } of VIEWS) {
|
|
4610
|
+
let cands;
|
|
4611
|
+
try {
|
|
4612
|
+
cands = CANARY_DECODERS[decoder](t);
|
|
4613
|
+
} catch (e) {
|
|
4614
|
+
console.error(
|
|
4615
|
+
`[node9 engine] canary view ${view} failed, skipped:`,
|
|
4616
|
+
e instanceof Error ? e.message : String(e)
|
|
4617
|
+
);
|
|
4618
|
+
continue;
|
|
4619
|
+
}
|
|
4620
|
+
const hit = lowestOffset(cands, needles, stripped);
|
|
4621
|
+
if (hit) return { v: hit, view };
|
|
4622
|
+
}
|
|
4623
|
+
return null;
|
|
4624
|
+
}
|
|
4625
|
+
function matchCanary(text, values) {
|
|
4626
|
+
if (!text || values.length === 0) return null;
|
|
4627
|
+
const hit = matchPrepared(text, prepare(values));
|
|
4628
|
+
return hit ? { id: hit.v.id, view: hit.view, retired: Boolean(hit.v.retired) } : null;
|
|
4629
|
+
}
|
|
4630
|
+
function matchCanaryArgs(args, values) {
|
|
4631
|
+
if (values.length === 0) return null;
|
|
4632
|
+
const needles = prepare(values);
|
|
4633
|
+
if (needles.length === 0) return null;
|
|
4634
|
+
const walk = (v, depth, fieldPath) => {
|
|
4635
|
+
if (depth > MAX_DEPTH2) return null;
|
|
4636
|
+
if (typeof v === "string") {
|
|
4637
|
+
const hit = matchPrepared(v, needles);
|
|
4638
|
+
if (hit) return { id: hit.v.id, view: hit.view, fieldPath, retired: Boolean(hit.v.retired) };
|
|
4639
|
+
if (v.length < MAX_JSON_PARSE) {
|
|
4640
|
+
const trimmed = v.trim();
|
|
4641
|
+
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
|
|
4642
|
+
try {
|
|
4643
|
+
return walk(JSON.parse(v), depth + 1, fieldPath);
|
|
4644
|
+
} catch {
|
|
4645
|
+
}
|
|
4646
|
+
}
|
|
4647
|
+
}
|
|
4648
|
+
return null;
|
|
4649
|
+
}
|
|
4650
|
+
if (typeof v === "number" && Number.isFinite(v)) return walk(String(v), depth, fieldPath);
|
|
4651
|
+
if (Array.isArray(v)) {
|
|
4652
|
+
for (let i = 0; i < v.length; i++) {
|
|
4653
|
+
const h = walk(v[i], depth + 1, `${fieldPath}[${i}]`);
|
|
4654
|
+
if (h) return h;
|
|
4655
|
+
}
|
|
4656
|
+
return null;
|
|
4657
|
+
}
|
|
4658
|
+
if (v && typeof v === "object") {
|
|
4659
|
+
for (const [k, child] of Object.entries(v)) {
|
|
4660
|
+
const h = walk(child, depth + 1, fieldPath ? `${fieldPath}.${k}` : k);
|
|
4661
|
+
if (h) return h;
|
|
4662
|
+
}
|
|
4663
|
+
}
|
|
4664
|
+
return null;
|
|
4665
|
+
};
|
|
4666
|
+
try {
|
|
4667
|
+
return walk(args, 0, "");
|
|
4668
|
+
} catch (e) {
|
|
4669
|
+
console.error(
|
|
4670
|
+
"[node9 engine] canary args walk failed:",
|
|
4671
|
+
e instanceof Error ? e.message : String(e)
|
|
4672
|
+
);
|
|
4673
|
+
return null;
|
|
4674
|
+
}
|
|
3978
4675
|
}
|
|
3979
4676
|
|
|
3980
4677
|
// src/scan/canonical.ts
|
|
3981
4678
|
var LONG_OUTPUT_THRESHOLD_BYTES = 100 * 1024;
|
|
3982
|
-
var CANONICAL_EXTRACTOR_VERSION = "canonical-
|
|
3983
|
-
var CANONICAL_EXTRACTOR_HASH = "
|
|
4679
|
+
var CANONICAL_EXTRACTOR_VERSION = "canonical-v10";
|
|
4680
|
+
var CANONICAL_EXTRACTOR_HASH = "5c786cc174281e51";
|
|
3984
4681
|
var DEDUPE_PREVIEW_LEN = 120;
|
|
3985
4682
|
function extractCanonicalFindings(call, ctx) {
|
|
3986
4683
|
const out = [];
|
|
@@ -4024,6 +4721,28 @@ function extractCanonicalFindings(call, ctx) {
|
|
|
4024
4721
|
);
|
|
4025
4722
|
}
|
|
4026
4723
|
}
|
|
4724
|
+
if (ctx.canaryValues && ctx.canaryValues.length > 0) {
|
|
4725
|
+
const hit = matchCanaryArgs(call.args, ctx.canaryValues);
|
|
4726
|
+
if (hit) {
|
|
4727
|
+
const v = ctx.canaryValues.find((x) => x.id === hit.id);
|
|
4728
|
+
out.push(
|
|
4729
|
+
makeFinding({
|
|
4730
|
+
type: "canary",
|
|
4731
|
+
ruleName: `canary:${v?.kind ?? "unknown"}`,
|
|
4732
|
+
patternName: "Decoy credential",
|
|
4733
|
+
verdict: "block",
|
|
4734
|
+
severity: "critical",
|
|
4735
|
+
reason: `Decoy credential planted at ${v?.path ?? "a decoy file"} appeared in ${call.toolName} args (${hit.view})`,
|
|
4736
|
+
toolName: call.toolName,
|
|
4737
|
+
ctx,
|
|
4738
|
+
ts,
|
|
4739
|
+
sourceType: "engine"
|
|
4740
|
+
// No `input`: makeFinding stores it verbatim and a finding must never
|
|
4741
|
+
// carry the value (E14). The wire never copies input anyway.
|
|
4742
|
+
})
|
|
4743
|
+
);
|
|
4744
|
+
}
|
|
4745
|
+
}
|
|
4027
4746
|
for (const value of stringValues(call.args)) {
|
|
4028
4747
|
const piiHits = detectPii(value);
|
|
4029
4748
|
for (const pattern of piiHits) {
|
|
@@ -4256,6 +4975,9 @@ function toScanFinding(c) {
|
|
|
4256
4975
|
"smart-rule": null,
|
|
4257
4976
|
"ast-fs-op": null,
|
|
4258
4977
|
dlp: "dlp",
|
|
4978
|
+
// Ships under the dlp rollup with patternName 'Decoy credential' and a
|
|
4979
|
+
// canary:<kind> ruleName until the SaaS wire type gains its own value.
|
|
4980
|
+
canary: "dlp",
|
|
4259
4981
|
pii: "pii",
|
|
4260
4982
|
"sensitive-file-read": "sensitive-file-read",
|
|
4261
4983
|
"privilege-escalation": "privilege-escalation",
|
|
@@ -4328,6 +5050,7 @@ var ENGINE_VERSION = "1.4.0";
|
|
|
4328
5050
|
AST_FS_REGEX_RULES,
|
|
4329
5051
|
BASH_TOOL_NAMES,
|
|
4330
5052
|
BUILTIN_SHIELDS,
|
|
5053
|
+
CANARY_MIN_LENGTH,
|
|
4331
5054
|
CANONICAL_EXTRACTOR_HASH,
|
|
4332
5055
|
CANONICAL_EXTRACTOR_VERSION,
|
|
4333
5056
|
COST_PER_LOOP_ITER_USD,
|
|
@@ -4347,6 +5070,7 @@ var ENGINE_VERSION = "1.4.0";
|
|
|
4347
5070
|
SCAN_SIGNAL_WEIGHTS,
|
|
4348
5071
|
SENSITIVE_PATH_RE,
|
|
4349
5072
|
SENSITIVE_PATH_REGEXES,
|
|
5073
|
+
SSRF_MAX_HOST,
|
|
4350
5074
|
analyzeFsOperation,
|
|
4351
5075
|
analyzePipeChain,
|
|
4352
5076
|
analyzeShellCommand,
|
|
@@ -4354,6 +5078,7 @@ var ENGINE_VERSION = "1.4.0";
|
|
|
4354
5078
|
classifyAuditEntry,
|
|
4355
5079
|
classifyRuleSeverity,
|
|
4356
5080
|
classifyScanSignal,
|
|
5081
|
+
classifySsrf,
|
|
4357
5082
|
computeAgentDeviceScore,
|
|
4358
5083
|
computeArgsHash,
|
|
4359
5084
|
computeBlendedSecurityScore,
|
|
@@ -4374,6 +5099,7 @@ var ENGINE_VERSION = "1.4.0";
|
|
|
4374
5099
|
extractNetworkTargets,
|
|
4375
5100
|
extractPositionalArgs,
|
|
4376
5101
|
extractSessionLevelFindings,
|
|
5102
|
+
extractShellDestTokens,
|
|
4377
5103
|
extractShellDestinations,
|
|
4378
5104
|
getCompiledRegex,
|
|
4379
5105
|
getNestedValue,
|
|
@@ -4384,10 +5110,13 @@ var ENGINE_VERSION = "1.4.0";
|
|
|
4384
5110
|
isProtectedHomePath,
|
|
4385
5111
|
isShellShapedTool,
|
|
4386
5112
|
isShieldVerdict,
|
|
5113
|
+
matchCanary,
|
|
5114
|
+
matchCanaryArgs,
|
|
4387
5115
|
matchSensitivePath,
|
|
4388
5116
|
matchesPattern,
|
|
4389
5117
|
narrativeRuleLabel,
|
|
4390
5118
|
normalizeCommandForPolicy,
|
|
5119
|
+
normalizeIpLiteral,
|
|
4391
5120
|
parseAllSshHostsFromCommand,
|
|
4392
5121
|
parseDestHost,
|
|
4393
5122
|
previewArgs,
|
|
@@ -4397,6 +5126,7 @@ var ENGINE_VERSION = "1.4.0";
|
|
|
4397
5126
|
scanInjection,
|
|
4398
5127
|
scanText,
|
|
4399
5128
|
sensitivePathMatch,
|
|
5129
|
+
ssrfFloor,
|
|
4400
5130
|
summarizeBlast,
|
|
4401
5131
|
summarizeScan,
|
|
4402
5132
|
toScanFinding,
|