@node9/policy-engine 1.0.0 → 1.4.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.mjs CHANGED
@@ -1,4 +1,5 @@
1
1
  // src/dlp/index.ts
2
+ import safeRegex from "safe-regex2";
2
3
  var ASSIGNMENT_CONTEXT_RE = /\b(?:password|passwd|secret|token|api[_-]?key|auth(?:_key|_token)?|credential|private[_-]?key|access[_-]?key|client[_-]?secret)\s*[=:]\s*/i;
3
4
  function isAssignmentContext(text) {
4
5
  return ASSIGNMENT_CONTEXT_RE.test(text);
@@ -482,6 +483,23 @@ function sensitivePathMatch(originalPath) {
482
483
  };
483
484
  }
484
485
  var SENSITIVE_PATH_REGEXES = SENSITIVE_PATH_PATTERNS;
486
+ function assertBuiltinPatternsAreSafe() {
487
+ for (const p of DLP_PATTERNS) {
488
+ if (!safeRegex(p.regex.source)) {
489
+ throw new Error(
490
+ `[node9 engine] Builtin DLP pattern '${p.name}' is vulnerable to ReDoS: ${p.regex.source}`
491
+ );
492
+ }
493
+ }
494
+ for (const re of SENSITIVE_PATH_PATTERNS) {
495
+ if (!safeRegex(re.source)) {
496
+ throw new Error(
497
+ `[node9 engine] Builtin sensitive-path pattern is vulnerable to ReDoS: ${re.source}`
498
+ );
499
+ }
500
+ }
501
+ }
502
+ assertBuiltinPatternsAreSafe();
485
503
  function maskSecret(raw, pattern) {
486
504
  const match = raw.match(pattern);
487
505
  if (!match) return "****";
@@ -1073,7 +1091,7 @@ function parseAllSshHostsFromCommand(command) {
1073
1091
  import pm from "picomatch";
1074
1092
 
1075
1093
  // src/utils/regex.ts
1076
- import safeRegex from "safe-regex2";
1094
+ import safeRegex2 from "safe-regex2";
1077
1095
  var MAX_REGEX_LENGTH = 100;
1078
1096
  var REGEX_CACHE_MAX = 500;
1079
1097
  var regexCache = /* @__PURE__ */ new Map();
@@ -1086,7 +1104,7 @@ function validateRegex(pattern) {
1086
1104
  return `Invalid regex syntax: ${e.message}`;
1087
1105
  }
1088
1106
  if (/\\\d+[*+{]/.test(pattern)) return "Quantified backreferences are forbidden (ReDoS risk)";
1089
- if (!safeRegex(pattern)) return "Pattern rejected: potential ReDoS vulnerability detected";
1107
+ if (!safeRegex2(pattern)) return "Pattern rejected: potential ReDoS vulnerability detected";
1090
1108
  return null;
1091
1109
  }
1092
1110
  function getCompiledRegex(pattern, flags = "") {
@@ -1123,9 +1141,14 @@ function matchesPattern(text, patterns) {
1123
1141
  const withoutDotSlash = text.replace(/^\.\//, "");
1124
1142
  return isMatch(withoutDotSlash) || isMatch(`./${withoutDotSlash}`);
1125
1143
  }
1144
+ var FORBIDDEN_PATH_SEGMENTS = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype"]);
1126
1145
  function getNestedValue(obj, path) {
1127
1146
  if (!obj || typeof obj !== "object") return null;
1128
- return path.split(".").reduce((prev, curr) => prev?.[curr], obj);
1147
+ const segments = path.split(".");
1148
+ for (const seg of segments) {
1149
+ if (FORBIDDEN_PATH_SEGMENTS.has(seg)) return null;
1150
+ }
1151
+ return segments.reduce((prev, curr) => prev?.[curr], obj);
1129
1152
  }
1130
1153
  function evaluateSmartConditions(args, rule) {
1131
1154
  if (!rule.conditions || rule.conditions.length === 0) return true;
@@ -1411,6 +1434,9 @@ function isIgnoredTool(toolName, config) {
1411
1434
  return matchesPattern(toolName, config.policy.ignoredTools);
1412
1435
  }
1413
1436
 
1437
+ // src/shields/index.ts
1438
+ import safeRegex3 from "safe-regex2";
1439
+
1414
1440
  // src/shields/builtin/aws.json
1415
1441
  var aws_default = {
1416
1442
  name: "aws",
@@ -1842,15 +1868,6 @@ var k8s_default = {
1842
1868
  dangerousWords: []
1843
1869
  };
1844
1870
 
1845
- // src/shields/builtin/mcp-tool-gating.json
1846
- var mcp_tool_gating_default = {
1847
- name: "mcp-tool-gating",
1848
- description: "Intercept MCP tool lists and require user approval before the agent can use any tools from a new server",
1849
- aliases: ["mcp-gating", "mcp-tools"],
1850
- smartRules: [],
1851
- dangerousWords: []
1852
- };
1853
-
1854
1871
  // src/shields/builtin/mongodb.json
1855
1872
  var mongodb_default = {
1856
1873
  name: "mongodb",
@@ -2165,12 +2182,29 @@ var BUILTIN_SHIELDS = {
2165
2182
  [filesystem_default.name]: filesystem_default,
2166
2183
  [github_default.name]: github_default,
2167
2184
  [k8s_default.name]: k8s_default,
2168
- [mcp_tool_gating_default.name]: mcp_tool_gating_default,
2169
2185
  [mongodb_default.name]: mongodb_default,
2170
2186
  [postgres_default.name]: postgres_default,
2171
2187
  [project_jail_default.name]: project_jail_default,
2172
2188
  [redis_default.name]: redis_default
2173
2189
  };
2190
+ function assertBuiltinShieldRegexesAreSafe() {
2191
+ for (const shield of Object.values(BUILTIN_SHIELDS)) {
2192
+ for (const rule of shield.smartRules) {
2193
+ const conditions = rule.conditions ?? [];
2194
+ for (const cond of conditions) {
2195
+ if (cond.op !== "matches" && cond.op !== "notMatches") continue;
2196
+ const pattern = cond.value;
2197
+ if (!pattern) continue;
2198
+ if (!safeRegex3(pattern)) {
2199
+ throw new Error(
2200
+ `[node9 engine] Shield '${shield.name}' rule '${rule.name ?? rule.tool}' has unsafe regex: ${pattern}`
2201
+ );
2202
+ }
2203
+ }
2204
+ }
2205
+ }
2206
+ }
2207
+ assertBuiltinShieldRegexesAreSafe();
2174
2208
 
2175
2209
  // src/loop/index.ts
2176
2210
  import crypto from "crypto";
@@ -2189,19 +2223,266 @@ function evaluateLoopWindow(records, tool, args, threshold, windowMs, now) {
2189
2223
  return { nextRecords, count, looping: count >= threshold };
2190
2224
  }
2191
2225
 
2226
+ // src/scan/index.ts
2227
+ function emptySignals() {
2228
+ return {
2229
+ dlpFindings: 0,
2230
+ piiFindings: 0,
2231
+ sensitiveFileReads: 0,
2232
+ privilegeEscalation: 0,
2233
+ networkExfil: 0,
2234
+ pipeToShell: 0,
2235
+ evalOfRemote: 0,
2236
+ destructiveOps: 0,
2237
+ loops: 0,
2238
+ longOutputRedactions: 0
2239
+ };
2240
+ }
2241
+ var FINDING_TO_SIGNAL = {
2242
+ dlp: "dlpFindings",
2243
+ pii: "piiFindings",
2244
+ "sensitive-file-read": "sensitiveFileReads",
2245
+ "privilege-escalation": "privilegeEscalation",
2246
+ "network-exfil": "networkExfil",
2247
+ "pipe-to-shell": "pipeToShell",
2248
+ "eval-of-remote": "evalOfRemote",
2249
+ "destructive-op": "destructiveOps",
2250
+ loop: "loops",
2251
+ "long-output-redacted": "longOutputRedactions"
2252
+ };
2253
+ var SCAN_SIGNAL_WEIGHTS = {
2254
+ dlpFindings: 30,
2255
+ piiFindings: 10,
2256
+ sensitiveFileReads: 20,
2257
+ privilegeEscalation: 15,
2258
+ networkExfil: 25,
2259
+ pipeToShell: 30,
2260
+ evalOfRemote: 30,
2261
+ destructiveOps: 15,
2262
+ loops: 3,
2263
+ longOutputRedactions: 1
2264
+ };
2265
+ function computeScanScore(signals) {
2266
+ let deduction = 0;
2267
+ for (const key of Object.keys(signals)) {
2268
+ deduction += signals[key] * SCAN_SIGNAL_WEIGHTS[key];
2269
+ }
2270
+ return Math.max(0, Math.min(100, 100 - deduction));
2271
+ }
2272
+ var LOOP_THRESHOLD_FOR_WASTE = 3;
2273
+ var COST_PER_LOOP_ITER_USD = 6e-3;
2274
+ function summarizeScan(findings, opts = {}) {
2275
+ const totalToolCalls = opts.totalToolCalls ?? 0;
2276
+ const topN = opts.topN ?? 10;
2277
+ const signals = emptySignals();
2278
+ const sessionIds = /* @__PURE__ */ new Set();
2279
+ const patternCounts = /* @__PURE__ */ new Map();
2280
+ for (const f of findings) {
2281
+ sessionIds.add(f.sessionId);
2282
+ const key = FINDING_TO_SIGNAL[f.type];
2283
+ signals[key]++;
2284
+ if (f.patternName) {
2285
+ patternCounts.set(f.patternName, (patternCounts.get(f.patternName) ?? 0) + 1);
2286
+ }
2287
+ }
2288
+ const topPatterns = [...patternCounts.entries()].sort((a, b) => {
2289
+ if (b[1] !== a[1]) return b[1] - a[1];
2290
+ return a[0].localeCompare(b[0]);
2291
+ }).slice(0, topN).map(([patternName, count]) => ({ patternName, count }));
2292
+ return {
2293
+ totalSessions: sessionIds.size,
2294
+ totalToolCalls,
2295
+ signals,
2296
+ topPatterns,
2297
+ score: computeScanScore(signals)
2298
+ };
2299
+ }
2300
+
2301
+ // src/severity/index.ts
2302
+ function classifyRuleSeverity(name, verdict) {
2303
+ const n = name.toLowerCase();
2304
+ const criticalPatterns = [
2305
+ "rm-rf",
2306
+ "eval-remote",
2307
+ "eval-curl",
2308
+ "read-aws",
2309
+ "read-ssh",
2310
+ "read-gcp",
2311
+ "read-cred",
2312
+ "delete-repo",
2313
+ "helm-uninstall",
2314
+ "drop-table",
2315
+ "drop-database",
2316
+ "drop-collection",
2317
+ "truncate",
2318
+ "flushall",
2319
+ "flushdb",
2320
+ "pipe-shell"
2321
+ ];
2322
+ if (criticalPatterns.some((p) => n.includes(p))) return "critical";
2323
+ const highPatterns = [
2324
+ "force-push",
2325
+ "force_push",
2326
+ "git-destructive",
2327
+ "reset-hard",
2328
+ "rebase",
2329
+ "delete-branch",
2330
+ "delete-remote"
2331
+ ];
2332
+ if (highPatterns.some((p) => n.includes(p))) return "high";
2333
+ if (verdict === "block") return "high";
2334
+ return "medium";
2335
+ }
2336
+ function narrativeRuleLabel(name) {
2337
+ const stripped = stripRulePrefixes(name);
2338
+ const map = {
2339
+ "read-aws": "AWS credentials read",
2340
+ "read-ssh": "SSH private key read",
2341
+ "read-gcp": "GCP credentials read",
2342
+ "read-cred": "credential file read",
2343
+ "delete-repo": "GitHub repository deletion",
2344
+ "helm-uninstall": "helm uninstall",
2345
+ "rm-rf-home": "rm -rf on home directory",
2346
+ "rm-rf": "rm -rf",
2347
+ "eval-remote": "eval of remote download",
2348
+ "eval-curl": "eval of curl output",
2349
+ "pipe-shell": "curl | bash",
2350
+ "drop-table": "DROP TABLE",
2351
+ "drop-database": "DROP DATABASE",
2352
+ "drop-collection": "DROP COLLECTION",
2353
+ truncate: "TRUNCATE",
2354
+ flushall: "Redis FLUSHALL",
2355
+ flushdb: "Redis FLUSHDB",
2356
+ "force-push": "force pushes",
2357
+ force_push: "force pushes",
2358
+ "reset-hard": "git reset --hard",
2359
+ "git-destructive": "destructive git operations",
2360
+ "delete-branch": "branch deletion",
2361
+ "delete-remote": "remote deletion",
2362
+ rebase: "git rebase",
2363
+ rm: "rm calls",
2364
+ sudo: "sudo calls",
2365
+ "eval-dynamic": "dynamic eval",
2366
+ "config-set": "Redis CONFIG SET"
2367
+ };
2368
+ for (const [key, label] of Object.entries(map)) {
2369
+ if (stripped.includes(key)) return label;
2370
+ }
2371
+ return stripped;
2372
+ }
2373
+ function stripRulePrefixes(name) {
2374
+ let n = name.toLowerCase();
2375
+ if (n.startsWith("org:")) n = n.slice(4);
2376
+ const shieldMatch = /^shield:[^:]+:(.+)$/.exec(n);
2377
+ if (shieldMatch) n = shieldMatch[1];
2378
+ n = n.replace(/^(block|review|allow)-/, "");
2379
+ return n;
2380
+ }
2381
+ function classifyAuditEntry(entry) {
2382
+ const ruleName = entry.riskMetadata?.ruleName;
2383
+ if (typeof ruleName === "string" && ruleName.length > 0) {
2384
+ const verdict = entry.action === "AUTO_BLOCKED" || entry.action === "DENIED" ? "block" : entry.action === "APPROVED" || entry.action === "AUTO_ALLOWED" ? "allow" : "review";
2385
+ return classifyRuleSeverity(ruleName, verdict);
2386
+ }
2387
+ const cb = entry.checkedBy ?? "";
2388
+ if (cb === "dlp-block" || cb.startsWith("dlp-saas:")) return "critical";
2389
+ if (cb.startsWith("eval-saas") || cb === "pipe-chain-saas:critical") {
2390
+ return "critical";
2391
+ }
2392
+ if (cb === "loop-detected") return "medium";
2393
+ const isBlocked = entry.action === "AUTO_BLOCKED" || entry.action === "DENIED";
2394
+ if (isBlocked) return "high";
2395
+ return null;
2396
+ }
2397
+ function computeSecurityScore(opts) {
2398
+ const { critical, high, medium, total } = opts;
2399
+ if (total === 0) return { score: 100, tier: "good" };
2400
+ const criticalRate = critical / total;
2401
+ const highRate = high / total;
2402
+ const mediumRate = medium / total;
2403
+ const deduction = Math.min(criticalRate * 3e3, 60) + Math.min(highRate * 500, 30) + Math.min(mediumRate * 100, 15);
2404
+ const score = Math.max(0, Math.min(100, Math.round(100 - deduction)));
2405
+ const tier = score >= 80 ? "good" : score >= 50 ? "at-risk" : "critical";
2406
+ return { score, tier };
2407
+ }
2408
+ function classifyScanSignal(key) {
2409
+ const w = SCAN_SIGNAL_WEIGHTS[key];
2410
+ if (w >= 25) return "critical";
2411
+ if (w >= 11) return "high";
2412
+ return "medium";
2413
+ }
2414
+ function computeBlendedSecurityScore(opts) {
2415
+ const { audit, scan } = opts;
2416
+ let critical = audit.critical;
2417
+ let high = audit.high;
2418
+ let medium = audit.medium;
2419
+ let total = audit.total;
2420
+ if (scan) {
2421
+ let scanFindingSum = 0;
2422
+ for (const key of Object.keys(scan.signals)) {
2423
+ const count = scan.signals[key];
2424
+ if (count <= 0) continue;
2425
+ const tier = classifyScanSignal(key);
2426
+ if (tier === "critical") critical += count;
2427
+ else if (tier === "high") high += count;
2428
+ else medium += count;
2429
+ scanFindingSum += count;
2430
+ }
2431
+ const scanContribution = Math.max(scan.totalToolCalls ?? 0, scanFindingSum);
2432
+ total += scanContribution;
2433
+ }
2434
+ return computeSecurityScore({ critical, high, medium, total });
2435
+ }
2436
+
2437
+ // src/blast/index.ts
2438
+ function truncateBlastPath(full) {
2439
+ if (!full) return "";
2440
+ const cleaned = full.replace(/[/\\]+$/, "");
2441
+ const parts = cleaned.split(/[/\\]+/).filter((p) => p.length > 0);
2442
+ if (parts.length <= 2) {
2443
+ return cleaned.startsWith("~") && !cleaned.startsWith("~/") ? cleaned : cleaned.startsWith("~/") ? cleaned : parts.join("/");
2444
+ }
2445
+ return parts.slice(-2).join("/");
2446
+ }
2447
+ function summarizeBlast(result, opts = {}) {
2448
+ const topN = opts.topN ?? 5;
2449
+ const sorted = [...result.reachable].sort((a, b) => {
2450
+ if (b.score !== a.score) return b.score - a.score;
2451
+ return a.label.localeCompare(b.label);
2452
+ });
2453
+ return {
2454
+ score: result.score,
2455
+ exposureCount: result.reachable.length + result.envFindings.length,
2456
+ envExposureCount: result.envFindings.length,
2457
+ worstPaths: sorted.slice(0, topN).map((f) => ({
2458
+ path: truncateBlastPath(f.label),
2459
+ score: f.score
2460
+ }))
2461
+ };
2462
+ }
2463
+
2192
2464
  // src/index.ts
2193
- var ENGINE_VERSION = "1.0.0";
2465
+ var ENGINE_VERSION = "1.4.0";
2194
2466
  export {
2195
2467
  BUILTIN_SHIELDS,
2468
+ COST_PER_LOOP_ITER_USD,
2196
2469
  DLP_PATTERNS,
2197
2470
  ENGINE_VERSION,
2198
2471
  FLAGS_WITH_VALUES,
2199
2472
  LOOP_MAX_RECORDS,
2473
+ LOOP_THRESHOLD_FOR_WASTE,
2474
+ SCAN_SIGNAL_WEIGHTS,
2200
2475
  SENSITIVE_PATH_REGEXES,
2201
2476
  analyzePipeChain,
2202
2477
  analyzeShellCommand,
2203
2478
  checkDangerousSql,
2479
+ classifyAuditEntry,
2480
+ classifyRuleSeverity,
2481
+ classifyScanSignal,
2204
2482
  computeArgsHash,
2483
+ computeBlendedSecurityScore,
2484
+ computeScanScore,
2485
+ computeSecurityScore,
2205
2486
  detectDangerousEval,
2206
2487
  detectDangerousShellExec,
2207
2488
  evaluateLoopWindow,
@@ -2216,12 +2497,16 @@ export {
2216
2497
  isShieldVerdict,
2217
2498
  matchSensitivePath,
2218
2499
  matchesPattern,
2500
+ narrativeRuleLabel,
2219
2501
  normalizeCommandForPolicy,
2220
2502
  parseAllSshHostsFromCommand,
2221
2503
  redactText,
2222
2504
  scanArgs,
2223
2505
  scanText,
2224
2506
  sensitivePathMatch,
2507
+ summarizeBlast,
2508
+ summarizeScan,
2509
+ truncateBlastPath,
2225
2510
  validateOverrides,
2226
2511
  validateRegex,
2227
2512
  validateShieldDefinition
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@node9/policy-engine",
3
- "version": "1.0.0",
3
+ "version": "1.4.0",
4
4
  "description": "Shared policy evaluation engine for node9 — DLP, smart rules, AST shell parsing, shields, loop detection. Pure functions, no I/O. Used by both node9-proxy and the node9 SaaS firewall.",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://node9.ai",
7
7
  "repository": {
8
8
  "type": "git",
9
- "url": "https://github.com/node9-ai/node9-proxy.git",
9
+ "url": "git+https://github.com/node9-ai/node9-proxy.git",
10
10
  "directory": "packages/policy-engine"
11
11
  },
12
12
  "keywords": [