@node9/policy-engine 1.67.1 → 1.67.2
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 +41 -3
- package/dist/index.d.ts +41 -3
- package/dist/index.js +411 -187
- package/dist/index.mjs +408 -187
- package/package.json +1 -1
package/dist/index.mjs
CHANGED
|
@@ -696,6 +696,139 @@ function redactText(text) {
|
|
|
696
696
|
|
|
697
697
|
// src/shell/index.ts
|
|
698
698
|
import mvdanSh from "mvdan-sh";
|
|
699
|
+
|
|
700
|
+
// src/rules/index.ts
|
|
701
|
+
import pm from "picomatch";
|
|
702
|
+
|
|
703
|
+
// src/utils/regex.ts
|
|
704
|
+
import safeRegex2 from "safe-regex2";
|
|
705
|
+
var MAX_REGEX_LENGTH = 256;
|
|
706
|
+
var REGEX_CACHE_MAX = 500;
|
|
707
|
+
var regexCache = /* @__PURE__ */ new Map();
|
|
708
|
+
function validateRegex(pattern) {
|
|
709
|
+
if (!pattern) return "Pattern is required";
|
|
710
|
+
if (pattern.length > MAX_REGEX_LENGTH) return `Pattern exceeds max length of ${MAX_REGEX_LENGTH}`;
|
|
711
|
+
try {
|
|
712
|
+
new RegExp(pattern);
|
|
713
|
+
} catch (e) {
|
|
714
|
+
return `Invalid regex syntax: ${e.message}`;
|
|
715
|
+
}
|
|
716
|
+
if (/\\\d+[*+{]/.test(pattern)) return "Quantified backreferences are forbidden (ReDoS risk)";
|
|
717
|
+
if (!safeRegex2(pattern)) return "Pattern rejected: potential ReDoS vulnerability detected";
|
|
718
|
+
return null;
|
|
719
|
+
}
|
|
720
|
+
function getCompiledRegex(pattern, flags = "") {
|
|
721
|
+
if (flags && !/^[gimsuy]+$/.test(flags)) return null;
|
|
722
|
+
const key = `${pattern}\0${flags}`;
|
|
723
|
+
if (regexCache.has(key)) {
|
|
724
|
+
const cached = regexCache.get(key);
|
|
725
|
+
regexCache.delete(key);
|
|
726
|
+
regexCache.set(key, cached);
|
|
727
|
+
return cached;
|
|
728
|
+
}
|
|
729
|
+
if (validateRegex(pattern) !== null) return null;
|
|
730
|
+
try {
|
|
731
|
+
const re = new RegExp(pattern, flags);
|
|
732
|
+
if (regexCache.size >= REGEX_CACHE_MAX) {
|
|
733
|
+
const oldest = regexCache.keys().next().value;
|
|
734
|
+
if (oldest) regexCache.delete(oldest);
|
|
735
|
+
}
|
|
736
|
+
regexCache.set(key, re);
|
|
737
|
+
return re;
|
|
738
|
+
} catch {
|
|
739
|
+
return null;
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
// src/rules/index.ts
|
|
744
|
+
function matchesPattern(text, patterns) {
|
|
745
|
+
const p = Array.isArray(patterns) ? patterns : [patterns];
|
|
746
|
+
if (p.length === 0) return false;
|
|
747
|
+
const isMatch = pm(p, { nocase: true, dot: true });
|
|
748
|
+
const target = text.toLowerCase();
|
|
749
|
+
const directMatch = isMatch(target);
|
|
750
|
+
if (directMatch) return true;
|
|
751
|
+
const withoutDotSlash = text.replace(/^\.\//, "");
|
|
752
|
+
return isMatch(withoutDotSlash) || isMatch(`./${withoutDotSlash}`);
|
|
753
|
+
}
|
|
754
|
+
var FORBIDDEN_PATH_SEGMENTS = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype"]);
|
|
755
|
+
function getNestedValue(obj, path) {
|
|
756
|
+
if (!obj || typeof obj !== "object") return null;
|
|
757
|
+
const segments = path.split(".");
|
|
758
|
+
for (const seg of segments) {
|
|
759
|
+
if (FORBIDDEN_PATH_SEGMENTS.has(seg)) return null;
|
|
760
|
+
}
|
|
761
|
+
return segments.reduce((prev, curr) => prev?.[curr], obj);
|
|
762
|
+
}
|
|
763
|
+
function evaluateSmartConditions(args, rule) {
|
|
764
|
+
if (!rule.conditions || rule.conditions.length === 0) return true;
|
|
765
|
+
const mode = rule.conditionMode ?? "all";
|
|
766
|
+
const fieldCache = /* @__PURE__ */ new Map();
|
|
767
|
+
const resolveField = (field) => {
|
|
768
|
+
if (fieldCache.has(field)) return fieldCache.get(field) ?? null;
|
|
769
|
+
const rawVal = getNestedValue(args, field);
|
|
770
|
+
const rawStr = rawVal !== null && rawVal !== void 0 ? String(rawVal) : null;
|
|
771
|
+
const stripped = field === "command" && rawStr !== null ? normalizeCommandForPolicy(rawStr) : rawStr;
|
|
772
|
+
const val = stripped !== null ? stripped.replace(/\s+/g, " ").trim() : null;
|
|
773
|
+
fieldCache.set(field, val);
|
|
774
|
+
return val;
|
|
775
|
+
};
|
|
776
|
+
const readingsCache = /* @__PURE__ */ new Map();
|
|
777
|
+
const resolveFieldReadings = (field) => {
|
|
778
|
+
const cached = readingsCache.get(field);
|
|
779
|
+
if (cached) return cached;
|
|
780
|
+
const primary = resolveField(field);
|
|
781
|
+
if (primary === null) {
|
|
782
|
+
readingsCache.set(field, []);
|
|
783
|
+
return [];
|
|
784
|
+
}
|
|
785
|
+
let out = [primary];
|
|
786
|
+
if (field === "command") {
|
|
787
|
+
const raw = getNestedValue(args, field);
|
|
788
|
+
if (typeof raw === "string") {
|
|
789
|
+
const collapsed = commandReadings(raw).map((r) => r.replace(/\s+/g, " ").trim());
|
|
790
|
+
out = [.../* @__PURE__ */ new Set([primary, ...collapsed])];
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
readingsCache.set(field, out);
|
|
794
|
+
return out;
|
|
795
|
+
};
|
|
796
|
+
const results = rule.conditions.map((cond) => {
|
|
797
|
+
const val = resolveField(cond.field);
|
|
798
|
+
switch (cond.op) {
|
|
799
|
+
case "exists":
|
|
800
|
+
return val !== null && val !== "";
|
|
801
|
+
case "notExists":
|
|
802
|
+
return val === null || val === "";
|
|
803
|
+
case "contains":
|
|
804
|
+
return val !== null && cond.value ? val.includes(cond.value) : false;
|
|
805
|
+
case "notContains":
|
|
806
|
+
return val !== null && cond.value ? !val.includes(cond.value) : true;
|
|
807
|
+
case "matches": {
|
|
808
|
+
if (val === null || !cond.value) return false;
|
|
809
|
+
const reM = getCompiledRegex(cond.value, cond.flags ?? "");
|
|
810
|
+
if (!reM) return false;
|
|
811
|
+
return resolveFieldReadings(cond.field).some((v) => reM.test(v));
|
|
812
|
+
}
|
|
813
|
+
case "notMatches": {
|
|
814
|
+
if (!cond.value) return false;
|
|
815
|
+
if (val === null) return true;
|
|
816
|
+
const reN = getCompiledRegex(cond.value, cond.flags ?? "");
|
|
817
|
+
if (!reN) return false;
|
|
818
|
+
return !resolveFieldReadings(cond.field).some((v) => reN.test(v));
|
|
819
|
+
}
|
|
820
|
+
case "matchesGlob":
|
|
821
|
+
return val !== null && cond.value ? pm.isMatch(val, cond.value) : false;
|
|
822
|
+
case "notMatchesGlob":
|
|
823
|
+
return val !== null && cond.value ? !pm.isMatch(val, cond.value) : false;
|
|
824
|
+
default:
|
|
825
|
+
return false;
|
|
826
|
+
}
|
|
827
|
+
});
|
|
828
|
+
return mode === "any" ? results.some((r) => r) : results.every((r) => r);
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
// src/shell/index.ts
|
|
699
832
|
var { syntax } = mvdanSh;
|
|
700
833
|
var sharedParser = syntax.NewParser();
|
|
701
834
|
var MESSAGE_FLAGS = /* @__PURE__ */ new Set([
|
|
@@ -768,14 +901,22 @@ function cachedNormalize(command, compute) {
|
|
|
768
901
|
return result;
|
|
769
902
|
}
|
|
770
903
|
function normalizeCommandForPolicy(command) {
|
|
904
|
+
return commandReadingsImpl(command).posix;
|
|
905
|
+
}
|
|
906
|
+
function commandReadings(command) {
|
|
907
|
+
const r = commandReadingsImpl(command);
|
|
908
|
+
return r.separator === r.posix ? [r.posix] : [r.posix, r.separator];
|
|
909
|
+
}
|
|
910
|
+
function commandReadingsImpl(command) {
|
|
771
911
|
return cachedNormalize(command, () => normalizeCommandForPolicyImpl(command));
|
|
772
912
|
}
|
|
773
913
|
function normalizeCommandForPolicyImpl(command) {
|
|
774
914
|
const f = parseShared(command);
|
|
775
|
-
if (f === PARSE_FAIL) return command;
|
|
915
|
+
if (f === PARSE_FAIL) return { posix: command, separator: command };
|
|
776
916
|
try {
|
|
777
917
|
const strips = [];
|
|
778
918
|
const rewrites = [];
|
|
919
|
+
const quoteOnlyRewrites = [];
|
|
779
920
|
const msgSpans = /* @__PURE__ */ new Set();
|
|
780
921
|
syntax.Walk(f, (node) => {
|
|
781
922
|
if (!node) return false;
|
|
@@ -820,22 +961,23 @@ function normalizeCommandForPolicyImpl(command) {
|
|
|
820
961
|
if (resolved === source) continue;
|
|
821
962
|
if (resolved === "" || /\s/.test(resolved)) continue;
|
|
822
963
|
rewrites.push([s, e, resolved]);
|
|
964
|
+
const quoteOnly = source.replace(/['"]/g, "");
|
|
965
|
+
if (quoteOnly !== source) quoteOnlyRewrites.push([s, e, quoteOnly]);
|
|
823
966
|
}
|
|
824
967
|
return true;
|
|
825
968
|
});
|
|
826
|
-
const
|
|
827
|
-
|
|
828
|
-
...
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
}
|
|
836
|
-
return result;
|
|
969
|
+
const stripEdits = strips.map(([s, e]) => [s, e, '""']);
|
|
970
|
+
const apply = (extra) => {
|
|
971
|
+
const edits = [...stripEdits, ...extra];
|
|
972
|
+
if (edits.length === 0) return command;
|
|
973
|
+
edits.sort((a, b) => b[0] - a[0]);
|
|
974
|
+
let out = command;
|
|
975
|
+
for (const [s, e, rep] of edits) out = out.slice(0, s) + rep + out.slice(e);
|
|
976
|
+
return out;
|
|
977
|
+
};
|
|
978
|
+
return { posix: apply(rewrites), separator: apply(quoteOnlyRewrites) };
|
|
837
979
|
} catch {
|
|
838
|
-
return command;
|
|
980
|
+
return { posix: command, separator: command };
|
|
839
981
|
}
|
|
840
982
|
}
|
|
841
983
|
function scanArgsForDynamicExec(args, startIdx) {
|
|
@@ -1077,6 +1219,208 @@ function chmodHasOpenPermMode(command) {
|
|
|
1077
1219
|
}
|
|
1078
1220
|
return found;
|
|
1079
1221
|
}
|
|
1222
|
+
function isShellShapedTool(toolName, toolInspection) {
|
|
1223
|
+
if (isBashTool(toolName)) return true;
|
|
1224
|
+
if (!toolInspection) return false;
|
|
1225
|
+
const pattern = Object.keys(toolInspection).find((p) => matchesPattern(toolName, p));
|
|
1226
|
+
return pattern !== void 0 && toolInspection[pattern] === "command";
|
|
1227
|
+
}
|
|
1228
|
+
function toolMatchesRule(toolName, ruleTool, toolInspection) {
|
|
1229
|
+
if (!ruleTool) return true;
|
|
1230
|
+
if (matchesPattern(toolName, ruleTool)) return true;
|
|
1231
|
+
return isShellShapedTool(toolName, toolInspection) && matchesPattern("bash", ruleTool);
|
|
1232
|
+
}
|
|
1233
|
+
var INLINE_INTERPRETER = /^(python[\d.]*|perl|ruby|node|tsx|ts-node|php|lua|deno|bun|pwsh|powershell(?:\.exe)?|osascript|rscript|irb|bash|sh|zsh|script|su)$/i;
|
|
1234
|
+
var RUNNER_WRAPPERS = /* @__PURE__ */ new Set([
|
|
1235
|
+
"uv",
|
|
1236
|
+
"uvx",
|
|
1237
|
+
"poetry",
|
|
1238
|
+
"pipenv",
|
|
1239
|
+
"pdm",
|
|
1240
|
+
"rye",
|
|
1241
|
+
"hatch",
|
|
1242
|
+
"conda",
|
|
1243
|
+
"mamba",
|
|
1244
|
+
"micromamba",
|
|
1245
|
+
"npx",
|
|
1246
|
+
"pnpm",
|
|
1247
|
+
"yarn",
|
|
1248
|
+
"bunx",
|
|
1249
|
+
"watch",
|
|
1250
|
+
"strace",
|
|
1251
|
+
"ltrace",
|
|
1252
|
+
"chroot",
|
|
1253
|
+
"unshare",
|
|
1254
|
+
"runuser"
|
|
1255
|
+
]);
|
|
1256
|
+
function isInlineCodeFlag(interp, w) {
|
|
1257
|
+
const lw = w.toLowerCase();
|
|
1258
|
+
if (lw === "--eval" || lw === "--command" || lw === "--print") return true;
|
|
1259
|
+
if (/^(pwsh|powershell)/.test(interp)) return /^-(c|command|e|ec|enc|encodedcommand)$/i.test(lw);
|
|
1260
|
+
if (!w.startsWith("-") || w.startsWith("--")) return false;
|
|
1261
|
+
const codeLetters = /^(perl|ruby|lua|bun|osascript|rscript|irb)$/.test(interp) ? "e" : /^(node|tsx|ts-node)$/.test(interp) ? "ep" : interp === "php" ? "r" : "c";
|
|
1262
|
+
const body = lw.slice(1);
|
|
1263
|
+
const cutAt = /^(perl|ruby|node|tsx|ts-node)$/.test(interp) ? /[mirw]|[^a-z0-9]/ : /[^a-z0-9]/;
|
|
1264
|
+
const cut = body.search(cutAt);
|
|
1265
|
+
const bundle = cut >= 0 ? body.slice(0, cut) : body;
|
|
1266
|
+
return [...codeLetters].some((l) => bundle.includes(l));
|
|
1267
|
+
}
|
|
1268
|
+
var _redirStdinOps = null;
|
|
1269
|
+
function redirStdinOps() {
|
|
1270
|
+
if (_redirStdinOps) return _redirStdinOps;
|
|
1271
|
+
_redirStdinOps = new Set(
|
|
1272
|
+
[
|
|
1273
|
+
deriveRedirOp("cat <<X\nX"),
|
|
1274
|
+
deriveRedirOp("cat <<-X\nX"),
|
|
1275
|
+
deriveRedirOp("cat < f"),
|
|
1276
|
+
deriveRedirOp("cat <<< x")
|
|
1277
|
+
].filter((op) => op >= 0)
|
|
1278
|
+
);
|
|
1279
|
+
return _redirStdinOps;
|
|
1280
|
+
}
|
|
1281
|
+
function deriveBinaryOp(sample) {
|
|
1282
|
+
try {
|
|
1283
|
+
const f = sharedParser.Parse(sample, "cmd");
|
|
1284
|
+
let op = -1;
|
|
1285
|
+
syntax.Walk(f, (node) => {
|
|
1286
|
+
const n = node;
|
|
1287
|
+
if (n && syntax.NodeType(n) === "BinaryCmd" && op < 0) op = n.Op;
|
|
1288
|
+
return true;
|
|
1289
|
+
});
|
|
1290
|
+
return op;
|
|
1291
|
+
} catch {
|
|
1292
|
+
return -1;
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1295
|
+
var _listOps = null;
|
|
1296
|
+
function listOps() {
|
|
1297
|
+
if (_listOps) return _listOps;
|
|
1298
|
+
_listOps = new Set(
|
|
1299
|
+
[deriveBinaryOp("a && b"), deriveBinaryOp("a || b")].filter((o) => o >= 0)
|
|
1300
|
+
);
|
|
1301
|
+
return _listOps;
|
|
1302
|
+
}
|
|
1303
|
+
var WRAPPER_TAKES_TARGET = /* @__PURE__ */ new Set(["chroot"]);
|
|
1304
|
+
var INTERP_LEADING_TARGET = /* @__PURE__ */ new Set(["su"]);
|
|
1305
|
+
function unwrapCommandHead(words) {
|
|
1306
|
+
let i = 0;
|
|
1307
|
+
while (i < words.length) {
|
|
1308
|
+
const head = (words[i] ?? "").toLowerCase().split("/").pop() ?? "";
|
|
1309
|
+
if (head === "find") {
|
|
1310
|
+
const x = words.findIndex(
|
|
1311
|
+
(w, k) => k > i && (w === "-exec" || w === "-execdir" || w === "-ok")
|
|
1312
|
+
);
|
|
1313
|
+
if (x < 0) break;
|
|
1314
|
+
i = x + 1;
|
|
1315
|
+
continue;
|
|
1316
|
+
}
|
|
1317
|
+
if (!COMMAND_WRAPPERS.has(head) && !RUNNER_WRAPPERS.has(head)) break;
|
|
1318
|
+
i++;
|
|
1319
|
+
let targetConsumed = false;
|
|
1320
|
+
while (i < words.length) {
|
|
1321
|
+
const t = words[i];
|
|
1322
|
+
if (t === null) {
|
|
1323
|
+
i++;
|
|
1324
|
+
continue;
|
|
1325
|
+
}
|
|
1326
|
+
const lt = t.toLowerCase();
|
|
1327
|
+
if (/^[A-Za-z_]\w*=/.test(t)) {
|
|
1328
|
+
i++;
|
|
1329
|
+
continue;
|
|
1330
|
+
}
|
|
1331
|
+
if (t.startsWith("-")) {
|
|
1332
|
+
i++;
|
|
1333
|
+
const nxt = words[i];
|
|
1334
|
+
if (nxt != null && !nxt.startsWith("-") && !INLINE_INTERPRETER.test(nxt.split("/").pop() ?? "") && !COMMAND_WRAPPERS.has(nxt.toLowerCase()) && !RUNNER_WRAPPERS.has(nxt.toLowerCase()))
|
|
1335
|
+
i++;
|
|
1336
|
+
continue;
|
|
1337
|
+
}
|
|
1338
|
+
if (lt === "run" || lt === "exec" || lt === "dlx" || /^\d+(\.\d+)?[smhd]?$/.test(lt)) {
|
|
1339
|
+
i++;
|
|
1340
|
+
continue;
|
|
1341
|
+
}
|
|
1342
|
+
if (!targetConsumed && WRAPPER_TAKES_TARGET.has(head)) {
|
|
1343
|
+
targetConsumed = true;
|
|
1344
|
+
i++;
|
|
1345
|
+
continue;
|
|
1346
|
+
}
|
|
1347
|
+
break;
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
return i;
|
|
1351
|
+
}
|
|
1352
|
+
function inlineExecStmt(stmt, pipeFed) {
|
|
1353
|
+
const cmd = stmt?.Cmd;
|
|
1354
|
+
if (!cmd || syntax.NodeType(cmd) !== "CallExpr") return false;
|
|
1355
|
+
const words = (cmd.Args || []).map((a) => resolveWordLiteral(a));
|
|
1356
|
+
if (words.length === 0) return false;
|
|
1357
|
+
const headIdx = unwrapCommandHead(words);
|
|
1358
|
+
const rawHead = words[headIdx];
|
|
1359
|
+
if (rawHead == null) return false;
|
|
1360
|
+
const interp = (rawHead.split("/").pop() ?? "").toLowerCase();
|
|
1361
|
+
if (!INLINE_INTERPRETER.test(interp)) return false;
|
|
1362
|
+
let args = words.slice(headIdx + 1);
|
|
1363
|
+
if (interp === "deno" && (args[0] ?? "").toLowerCase() === "eval") return true;
|
|
1364
|
+
if (INTERP_LEADING_TARGET.has(interp)) {
|
|
1365
|
+
const firstFlag = args.findIndex((a) => a == null || a.startsWith("-"));
|
|
1366
|
+
args = firstFlag >= 0 ? args.slice(firstFlag) : [];
|
|
1367
|
+
}
|
|
1368
|
+
let positionals = 0;
|
|
1369
|
+
let selectedProgram = false;
|
|
1370
|
+
for (const a of args) {
|
|
1371
|
+
if (a == null) {
|
|
1372
|
+
positionals++;
|
|
1373
|
+
selectedProgram = true;
|
|
1374
|
+
continue;
|
|
1375
|
+
}
|
|
1376
|
+
if (!selectedProgram && isInlineCodeFlag(interp, a)) return true;
|
|
1377
|
+
if (a === "-m") {
|
|
1378
|
+
selectedProgram = true;
|
|
1379
|
+
continue;
|
|
1380
|
+
}
|
|
1381
|
+
if (a === "-" && !selectedProgram) return true;
|
|
1382
|
+
if (!a.startsWith("-")) {
|
|
1383
|
+
positionals++;
|
|
1384
|
+
selectedProgram = true;
|
|
1385
|
+
}
|
|
1386
|
+
}
|
|
1387
|
+
const redirs = stmt.Redirs || cmd.Redirs || [];
|
|
1388
|
+
const stdinFed = redirs.some((r) => r && redirStdinOps().has(r.Op));
|
|
1389
|
+
if (positionals === 0 && (stdinFed || pipeFed)) {
|
|
1390
|
+
if (!/^(bash|sh|zsh)$/i.test(interp)) return true;
|
|
1391
|
+
}
|
|
1392
|
+
return false;
|
|
1393
|
+
}
|
|
1394
|
+
function detectInlineExec(command) {
|
|
1395
|
+
const f = parseShared(command);
|
|
1396
|
+
if (f === PARSE_FAIL) {
|
|
1397
|
+
return /(^|[|;&]|&&)\s*(?:[\w./-]*\/)?(python[\d.]*|perl|ruby|node|php|lua|deno|bun|pwsh|osascript|rscript|bash|sh|zsh)\s+-{1,2}[a-z]*[ceEr]/i.test(
|
|
1398
|
+
command
|
|
1399
|
+
);
|
|
1400
|
+
}
|
|
1401
|
+
let found = false;
|
|
1402
|
+
try {
|
|
1403
|
+
syntax.Walk(f, (node) => {
|
|
1404
|
+
if (!node || found) return false;
|
|
1405
|
+
const n = node;
|
|
1406
|
+
const t = syntax.NodeType(n);
|
|
1407
|
+
if (t === "BinaryCmd" && n.Y && !listOps().has(n.Op)) {
|
|
1408
|
+
if (inlineExecStmt(n.Y, true)) {
|
|
1409
|
+
found = true;
|
|
1410
|
+
return false;
|
|
1411
|
+
}
|
|
1412
|
+
}
|
|
1413
|
+
if (t === "Stmt" && inlineExecStmt(n, false)) {
|
|
1414
|
+
found = true;
|
|
1415
|
+
return false;
|
|
1416
|
+
}
|
|
1417
|
+
return true;
|
|
1418
|
+
});
|
|
1419
|
+
} catch {
|
|
1420
|
+
return found;
|
|
1421
|
+
}
|
|
1422
|
+
return found;
|
|
1423
|
+
}
|
|
1080
1424
|
function analyzeChmod777(command) {
|
|
1081
1425
|
if (!/chmod/i.test(command.replace(/[\\'"]/g, ""))) return null;
|
|
1082
1426
|
if (!chmodHasOpenPermMode(command)) return null;
|
|
@@ -1954,117 +2298,6 @@ function parseAllSshHostsFromCommand(command) {
|
|
|
1954
2298
|
return extractAllSshHosts(tokens.slice(1));
|
|
1955
2299
|
}
|
|
1956
2300
|
|
|
1957
|
-
// src/rules/index.ts
|
|
1958
|
-
import pm from "picomatch";
|
|
1959
|
-
|
|
1960
|
-
// src/utils/regex.ts
|
|
1961
|
-
import safeRegex2 from "safe-regex2";
|
|
1962
|
-
var MAX_REGEX_LENGTH = 100;
|
|
1963
|
-
var REGEX_CACHE_MAX = 500;
|
|
1964
|
-
var regexCache = /* @__PURE__ */ new Map();
|
|
1965
|
-
function validateRegex(pattern) {
|
|
1966
|
-
if (!pattern) return "Pattern is required";
|
|
1967
|
-
if (pattern.length > MAX_REGEX_LENGTH) return `Pattern exceeds max length of ${MAX_REGEX_LENGTH}`;
|
|
1968
|
-
try {
|
|
1969
|
-
new RegExp(pattern);
|
|
1970
|
-
} catch (e) {
|
|
1971
|
-
return `Invalid regex syntax: ${e.message}`;
|
|
1972
|
-
}
|
|
1973
|
-
if (/\\\d+[*+{]/.test(pattern)) return "Quantified backreferences are forbidden (ReDoS risk)";
|
|
1974
|
-
if (!safeRegex2(pattern)) return "Pattern rejected: potential ReDoS vulnerability detected";
|
|
1975
|
-
return null;
|
|
1976
|
-
}
|
|
1977
|
-
function getCompiledRegex(pattern, flags = "") {
|
|
1978
|
-
if (flags && !/^[gimsuy]+$/.test(flags)) return null;
|
|
1979
|
-
const key = `${pattern}\0${flags}`;
|
|
1980
|
-
if (regexCache.has(key)) {
|
|
1981
|
-
const cached = regexCache.get(key);
|
|
1982
|
-
regexCache.delete(key);
|
|
1983
|
-
regexCache.set(key, cached);
|
|
1984
|
-
return cached;
|
|
1985
|
-
}
|
|
1986
|
-
if (validateRegex(pattern) !== null) return null;
|
|
1987
|
-
try {
|
|
1988
|
-
const re = new RegExp(pattern, flags);
|
|
1989
|
-
if (regexCache.size >= REGEX_CACHE_MAX) {
|
|
1990
|
-
const oldest = regexCache.keys().next().value;
|
|
1991
|
-
if (oldest) regexCache.delete(oldest);
|
|
1992
|
-
}
|
|
1993
|
-
regexCache.set(key, re);
|
|
1994
|
-
return re;
|
|
1995
|
-
} catch {
|
|
1996
|
-
return null;
|
|
1997
|
-
}
|
|
1998
|
-
}
|
|
1999
|
-
|
|
2000
|
-
// src/rules/index.ts
|
|
2001
|
-
function matchesPattern(text, patterns) {
|
|
2002
|
-
const p = Array.isArray(patterns) ? patterns : [patterns];
|
|
2003
|
-
if (p.length === 0) return false;
|
|
2004
|
-
const isMatch = pm(p, { nocase: true, dot: true });
|
|
2005
|
-
const target = text.toLowerCase();
|
|
2006
|
-
const directMatch = isMatch(target);
|
|
2007
|
-
if (directMatch) return true;
|
|
2008
|
-
const withoutDotSlash = text.replace(/^\.\//, "");
|
|
2009
|
-
return isMatch(withoutDotSlash) || isMatch(`./${withoutDotSlash}`);
|
|
2010
|
-
}
|
|
2011
|
-
var FORBIDDEN_PATH_SEGMENTS = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype"]);
|
|
2012
|
-
function getNestedValue(obj, path) {
|
|
2013
|
-
if (!obj || typeof obj !== "object") return null;
|
|
2014
|
-
const segments = path.split(".");
|
|
2015
|
-
for (const seg of segments) {
|
|
2016
|
-
if (FORBIDDEN_PATH_SEGMENTS.has(seg)) return null;
|
|
2017
|
-
}
|
|
2018
|
-
return segments.reduce((prev, curr) => prev?.[curr], obj);
|
|
2019
|
-
}
|
|
2020
|
-
function evaluateSmartConditions(args, rule) {
|
|
2021
|
-
if (!rule.conditions || rule.conditions.length === 0) return true;
|
|
2022
|
-
const mode = rule.conditionMode ?? "all";
|
|
2023
|
-
const fieldCache = /* @__PURE__ */ new Map();
|
|
2024
|
-
const resolveField = (field) => {
|
|
2025
|
-
if (fieldCache.has(field)) return fieldCache.get(field) ?? null;
|
|
2026
|
-
const rawVal = getNestedValue(args, field);
|
|
2027
|
-
const rawStr = rawVal !== null && rawVal !== void 0 ? String(rawVal) : null;
|
|
2028
|
-
const stripped = field === "command" && rawStr !== null ? normalizeCommandForPolicy(rawStr) : rawStr;
|
|
2029
|
-
const val = stripped !== null ? stripped.replace(/\s+/g, " ").trim() : null;
|
|
2030
|
-
fieldCache.set(field, val);
|
|
2031
|
-
return val;
|
|
2032
|
-
};
|
|
2033
|
-
const results = rule.conditions.map((cond) => {
|
|
2034
|
-
const val = resolveField(cond.field);
|
|
2035
|
-
switch (cond.op) {
|
|
2036
|
-
case "exists":
|
|
2037
|
-
return val !== null && val !== "";
|
|
2038
|
-
case "notExists":
|
|
2039
|
-
return val === null || val === "";
|
|
2040
|
-
case "contains":
|
|
2041
|
-
return val !== null && cond.value ? val.includes(cond.value) : false;
|
|
2042
|
-
case "notContains":
|
|
2043
|
-
return val !== null && cond.value ? !val.includes(cond.value) : true;
|
|
2044
|
-
case "matches": {
|
|
2045
|
-
if (val === null || !cond.value) return false;
|
|
2046
|
-
const reM = getCompiledRegex(cond.value, cond.flags ?? "");
|
|
2047
|
-
if (!reM) return false;
|
|
2048
|
-
return reM.test(val);
|
|
2049
|
-
}
|
|
2050
|
-
case "notMatches": {
|
|
2051
|
-
if (!cond.value) return false;
|
|
2052
|
-
if (val === null) return true;
|
|
2053
|
-
const reN = getCompiledRegex(cond.value, cond.flags ?? "");
|
|
2054
|
-
if (!reN) return false;
|
|
2055
|
-
return !reN.test(val);
|
|
2056
|
-
}
|
|
2057
|
-
case "matchesGlob":
|
|
2058
|
-
return val !== null && cond.value ? pm.isMatch(val, cond.value) : false;
|
|
2059
|
-
case "notMatchesGlob":
|
|
2060
|
-
return val !== null && cond.value ? !pm.isMatch(val, cond.value) : false;
|
|
2061
|
-
default:
|
|
2062
|
-
return false;
|
|
2063
|
-
}
|
|
2064
|
-
});
|
|
2065
|
-
return mode === "any" ? results.some((r) => r) : results.every((r) => r);
|
|
2066
|
-
}
|
|
2067
|
-
|
|
2068
2301
|
// src/policy/index.ts
|
|
2069
2302
|
function resolveCheck(v) {
|
|
2070
2303
|
return v === "off" || v === "block" ? v : "review";
|
|
@@ -2072,41 +2305,6 @@ function resolveCheck(v) {
|
|
|
2072
2305
|
function resolveCheckTight(v) {
|
|
2073
2306
|
return v === "block" ? "block" : "review";
|
|
2074
2307
|
}
|
|
2075
|
-
var INLINE_INTERP = /^(python[\d.]*|bash|sh|zsh|perl|ruby|node|php|lua)$/i;
|
|
2076
|
-
var INLINE_SHELL = /^(bash|sh|zsh)$/i;
|
|
2077
|
-
function detectInlineExec(command) {
|
|
2078
|
-
const pipeFed = command.includes("|");
|
|
2079
|
-
const segments = command.split(/\|\||&&|;|\||\n|\$\(|`|\(/);
|
|
2080
|
-
for (const rawSeg of segments) {
|
|
2081
|
-
const tokens = rawSeg.trim().split(/\s+/).filter(Boolean);
|
|
2082
|
-
let i = 0;
|
|
2083
|
-
while (i < tokens.length && /^[A-Za-z_]\w*=/.test(tokens[i])) i++;
|
|
2084
|
-
if (i >= tokens.length) continue;
|
|
2085
|
-
const base = tokens[i].split("/").pop() ?? tokens[i];
|
|
2086
|
-
if (!INLINE_INTERP.test(base)) continue;
|
|
2087
|
-
const args = tokens.slice(i + 1);
|
|
2088
|
-
let hadRedirect = false;
|
|
2089
|
-
const positionals = [];
|
|
2090
|
-
for (let j = 0; j < args.length; j++) {
|
|
2091
|
-
const a = args[j];
|
|
2092
|
-
if (a === "-") return true;
|
|
2093
|
-
if (a.startsWith("<")) {
|
|
2094
|
-
hadRedirect = true;
|
|
2095
|
-
if (a === "<" || a === "<<") j++;
|
|
2096
|
-
continue;
|
|
2097
|
-
}
|
|
2098
|
-
if (a.startsWith("-")) {
|
|
2099
|
-
if (/^-(c|e|eval)$/i.test(a)) return true;
|
|
2100
|
-
continue;
|
|
2101
|
-
}
|
|
2102
|
-
positionals.push(a);
|
|
2103
|
-
}
|
|
2104
|
-
if (positionals.length === 0 && (hadRedirect || pipeFed) && !INLINE_SHELL.test(base)) {
|
|
2105
|
-
return true;
|
|
2106
|
-
}
|
|
2107
|
-
}
|
|
2108
|
-
return false;
|
|
2109
|
-
}
|
|
2110
2308
|
var VERDICT_RANK = {
|
|
2111
2309
|
allow: 0,
|
|
2112
2310
|
review: 1,
|
|
@@ -2120,6 +2318,12 @@ function resolvePinned(matches) {
|
|
|
2120
2318
|
(best, r) => VERDICT_RANK[r.verdict] > VERDICT_RANK[best.verdict] ? r : best
|
|
2121
2319
|
);
|
|
2122
2320
|
}
|
|
2321
|
+
function strictestVerdict(candidates) {
|
|
2322
|
+
if (candidates.length === 0) return void 0;
|
|
2323
|
+
return candidates.reduce(
|
|
2324
|
+
(best, c) => VERDICT_RANK[c.decision] > VERDICT_RANK[best.decision] ? c : best
|
|
2325
|
+
);
|
|
2326
|
+
}
|
|
2123
2327
|
function tokenize2(toolName) {
|
|
2124
2328
|
return toolName.toLowerCase().split(/[_.\-\s]+/).filter(Boolean);
|
|
2125
2329
|
}
|
|
@@ -2131,6 +2335,11 @@ function extractShellCommand(toolName, args, toolInspection) {
|
|
|
2131
2335
|
const value = getNestedValue(args, fieldPath);
|
|
2132
2336
|
return typeof value === "string" ? value : null;
|
|
2133
2337
|
}
|
|
2338
|
+
function inspectsShellCommand(toolName, toolInspection) {
|
|
2339
|
+
const patterns = Object.keys(toolInspection);
|
|
2340
|
+
const matchingPattern = patterns.find((p) => matchesPattern(toolName, p));
|
|
2341
|
+
return matchingPattern !== void 0 && toolInspection[matchingPattern] === "command";
|
|
2342
|
+
}
|
|
2134
2343
|
function isSqlTool(toolName, toolInspection) {
|
|
2135
2344
|
const patterns = Object.keys(toolInspection);
|
|
2136
2345
|
const matchingPattern = patterns.find((p) => matchesPattern(toolName, p));
|
|
@@ -2202,8 +2411,10 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
|
|
|
2202
2411
|
};
|
|
2203
2412
|
}
|
|
2204
2413
|
}
|
|
2205
|
-
if (wouldBeIgnored) return { decision: "allow" };
|
|
2206
|
-
const
|
|
2414
|
+
if (wouldBeIgnored && !context.skipIgnoredFastPath) return { decision: "allow" };
|
|
2415
|
+
const shellShaped = isBashTool(toolName) || inspectsShellCommand(toolName, config.policy.toolInspection);
|
|
2416
|
+
const shellShapedCommand = shellShaped ? isBashTool(toolName) && args && typeof args === "object" ? typeof args.command === "string" ? args.command : null : extractShellCommand(toolName, args, config.policy.toolInspection) : null;
|
|
2417
|
+
const bashCommand = agent !== "Terminal" ? shellShapedCommand : null;
|
|
2207
2418
|
if (bashCommand !== null) {
|
|
2208
2419
|
const pipeVerdict = pipeChainVerdict(
|
|
2209
2420
|
bashCommand,
|
|
@@ -2253,8 +2464,14 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
|
|
|
2253
2464
|
}
|
|
2254
2465
|
if (config.policy.smartRules.length > 0) {
|
|
2255
2466
|
const rmCleanupWaiver = bashCommand !== null && isRmCreatedInCommandCleanup(bashCommand);
|
|
2467
|
+
const astSuppressed = (rule) => {
|
|
2468
|
+
if (bashCommand === null || !rule.name || !AST_FS_REGEX_RULES.has(rule.name)) return false;
|
|
2469
|
+
const knob = rule.name === "review-drop-truncate-shell" ? resolveCheck(config.policy.commandChecks?.sqlDdl) : rule.name === "shield:filesystem:review-chmod-777" ? resolveCheck(config.policy.commandChecks?.chmod) : void 0;
|
|
2470
|
+
if (knob === "off" && rule.pinned) return false;
|
|
2471
|
+
return true;
|
|
2472
|
+
};
|
|
2256
2473
|
const matches = config.policy.smartRules.filter(
|
|
2257
|
-
(rule) =>
|
|
2474
|
+
(rule) => toolMatchesRule(toolName, rule.tool, config.policy.toolInspection) && !astSuppressed(rule) && !(rmCleanupWaiver && rule.name === "review-rm" && rule.verdict === "review") && evaluateSmartConditions(args, rule)
|
|
2258
2475
|
);
|
|
2259
2476
|
const matchedRule = resolvePinned(matches);
|
|
2260
2477
|
if (matchedRule) {
|
|
@@ -2285,15 +2502,7 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
|
|
|
2285
2502
|
const analyzed = analyzeShellCommand(shellCommand);
|
|
2286
2503
|
allTokens = analyzed.allTokens;
|
|
2287
2504
|
pathTokens = analyzed.paths;
|
|
2288
|
-
const
|
|
2289
|
-
if (inlineAction !== "off" && detectInlineExec(shellCommand)) {
|
|
2290
|
-
return {
|
|
2291
|
-
decision: inlineAction === "block" ? "block" : "review",
|
|
2292
|
-
blockedByLabel: "Node9 Standard (Inline Execution)",
|
|
2293
|
-
ruleDescription: "The AI is running code directly from the command line. Review the full script below before allowing it to execute.",
|
|
2294
|
-
tier: 3
|
|
2295
|
-
};
|
|
2296
|
-
}
|
|
2505
|
+
const candidates = [];
|
|
2297
2506
|
const evalVerdict = detectDangerousShellExec(shellCommand);
|
|
2298
2507
|
if (evalVerdict === "block") {
|
|
2299
2508
|
return {
|
|
@@ -2304,24 +2513,33 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
|
|
|
2304
2513
|
tier: 3
|
|
2305
2514
|
};
|
|
2306
2515
|
}
|
|
2516
|
+
const ptVerdict = pipeChainVerdict(
|
|
2517
|
+
shellCommand,
|
|
2518
|
+
isTrustedHost,
|
|
2519
|
+
resolveCheckTight(config.policy.commandChecks?.pipeChainHigh)
|
|
2520
|
+
);
|
|
2521
|
+
if (ptVerdict?.decision === "allow") return ptVerdict;
|
|
2522
|
+
if (ptVerdict) candidates.push(ptVerdict);
|
|
2523
|
+
const inlineAction = resolveCheck(config.policy.commandChecks?.inlineExec);
|
|
2524
|
+
if (inlineAction !== "off" && detectInlineExec(shellCommand)) {
|
|
2525
|
+
candidates.push({
|
|
2526
|
+
decision: inlineAction === "block" ? "block" : "review",
|
|
2527
|
+
blockedByLabel: "Node9 Standard (Inline Execution)",
|
|
2528
|
+
ruleDescription: "The AI is running code directly from the command line. Review the full script below before allowing it to execute.",
|
|
2529
|
+
tier: 3
|
|
2530
|
+
});
|
|
2531
|
+
}
|
|
2307
2532
|
if (evalVerdict === "review") {
|
|
2308
|
-
|
|
2309
|
-
// Class B tighten-only: commandChecks.evalDynamic may upgrade to
|
|
2310
|
-
// block but can never turn this off (eval-remote above is Class A —
|
|
2311
|
-
// no knob at all).
|
|
2533
|
+
candidates.push({
|
|
2312
2534
|
decision: resolveCheckTight(config.policy.commandChecks?.evalDynamic),
|
|
2313
2535
|
blockedByLabel: "Node9: Eval Dynamic Content",
|
|
2314
2536
|
reason: "eval of dynamic content (variable or subshell expansion) requires approval",
|
|
2315
2537
|
ruleDescription: "The AI is running a command that includes a variable or subshell expansion. The actual command executed at runtime may differ from what is shown here.",
|
|
2316
2538
|
tier: 3
|
|
2317
|
-
};
|
|
2539
|
+
});
|
|
2318
2540
|
}
|
|
2319
|
-
const
|
|
2320
|
-
|
|
2321
|
-
isTrustedHost,
|
|
2322
|
-
resolveCheckTight(config.policy.commandChecks?.pipeChainHigh)
|
|
2323
|
-
);
|
|
2324
|
-
if (ptVerdict) return ptVerdict;
|
|
2541
|
+
const builtin = strictestVerdict(candidates);
|
|
2542
|
+
if (builtin) return builtin;
|
|
2325
2543
|
if (config.policy.egress?.enabled) {
|
|
2326
2544
|
const dests = extractShellDestinations(shellCommand);
|
|
2327
2545
|
if (dests.length > 0) {
|
|
@@ -3586,15 +3804,15 @@ function detectArgsPii(args) {
|
|
|
3586
3804
|
|
|
3587
3805
|
// src/scan/canonical.ts
|
|
3588
3806
|
var LONG_OUTPUT_THRESHOLD_BYTES = 100 * 1024;
|
|
3589
|
-
var CANONICAL_EXTRACTOR_VERSION = "canonical-
|
|
3590
|
-
var CANONICAL_EXTRACTOR_HASH = "
|
|
3807
|
+
var CANONICAL_EXTRACTOR_VERSION = "canonical-v8";
|
|
3808
|
+
var CANONICAL_EXTRACTOR_HASH = "80f40f974263b281";
|
|
3591
3809
|
var DEDUPE_PREVIEW_LEN = 120;
|
|
3592
3810
|
function extractCanonicalFindings(call, ctx) {
|
|
3593
3811
|
const out = [];
|
|
3594
3812
|
const ts = call.timestamp;
|
|
3595
3813
|
const toolNameLower = call.toolName.toLowerCase();
|
|
3596
3814
|
const command = typeof call.args.command === "string" ? call.args.command : null;
|
|
3597
|
-
const
|
|
3815
|
+
const isShell = isShellShapedTool(call.toolName, ctx.toolInspection) && command !== null;
|
|
3598
3816
|
if (call.outputBytes !== void 0 && call.outputBytes > LONG_OUTPUT_THRESHOLD_BYTES) {
|
|
3599
3817
|
out.push(
|
|
3600
3818
|
makeFinding({
|
|
@@ -3669,7 +3887,7 @@ function extractCanonicalFindings(call, ctx) {
|
|
|
3669
3887
|
);
|
|
3670
3888
|
}
|
|
3671
3889
|
}
|
|
3672
|
-
if (!
|
|
3890
|
+
if (!isShell || command === null) {
|
|
3673
3891
|
return out;
|
|
3674
3892
|
}
|
|
3675
3893
|
const fsVerdict = analyzeFsOperation(command);
|
|
@@ -3695,7 +3913,7 @@ function extractCanonicalFindings(call, ctx) {
|
|
|
3695
3913
|
for (const source of ctx.rules) {
|
|
3696
3914
|
const r = source.rule;
|
|
3697
3915
|
if (r.verdict === "allow") continue;
|
|
3698
|
-
if (
|
|
3916
|
+
if (!toolMatchesRule(toolNameLower, r.tool, ctx.toolInspection)) continue;
|
|
3699
3917
|
if (r.name && AST_FS_REGEX_RULES.has(r.name)) continue;
|
|
3700
3918
|
if (!evaluateSmartConditions(call.args, r)) continue;
|
|
3701
3919
|
out.push(
|
|
@@ -3971,6 +4189,7 @@ export {
|
|
|
3971
4189
|
detectArgsPii,
|
|
3972
4190
|
detectDangerousEval,
|
|
3973
4191
|
detectDangerousShellExec,
|
|
4192
|
+
detectInlineExec,
|
|
3974
4193
|
detectPii,
|
|
3975
4194
|
evaluateEgress,
|
|
3976
4195
|
evaluateLoopWindow,
|
|
@@ -3989,6 +4208,7 @@ export {
|
|
|
3989
4208
|
isIgnoredTool,
|
|
3990
4209
|
isPrivateHost,
|
|
3991
4210
|
isProtectedHomePath,
|
|
4211
|
+
isShellShapedTool,
|
|
3992
4212
|
isShieldVerdict,
|
|
3993
4213
|
matchSensitivePath,
|
|
3994
4214
|
matchesPattern,
|
|
@@ -4006,6 +4226,7 @@ export {
|
|
|
4006
4226
|
summarizeBlast,
|
|
4007
4227
|
summarizeScan,
|
|
4008
4228
|
toScanFinding,
|
|
4229
|
+
toolMatchesRule,
|
|
4009
4230
|
truncateBlastPath,
|
|
4010
4231
|
validateOverrides,
|
|
4011
4232
|
validateRegex,
|