@node9/policy-engine 1.4.0 → 1.26.3
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 +226 -15
- package/dist/index.d.ts +226 -15
- package/dist/index.js +886 -47
- package/dist/index.mjs +868 -47
- package/package.json +1 -1
package/dist/index.mjs
CHANGED
|
@@ -426,6 +426,42 @@ var DLP_PATTERNS = [
|
|
|
426
426
|
regex: /\bAGE-SECRET-KEY-1[QPZRY9X8GF2TVDW0S3JNLH]{58}\b/,
|
|
427
427
|
severity: "block",
|
|
428
428
|
keywords: ["age-secret-key-"]
|
|
429
|
+
},
|
|
430
|
+
// ── Database connection strings ───────────────────────────────────────────
|
|
431
|
+
// Universal <scheme>://[user]:<password>@<host> shape. Covers the gap
|
|
432
|
+
// vendor-prefix patterns (AWS / GitHub / Stripe / …) leave open. Matches
|
|
433
|
+
// the whole URL so maskSecret produces `<scheme>...:****@...<host>` —
|
|
434
|
+
// the password value never appears in the redacted sample.
|
|
435
|
+
//
|
|
436
|
+
// Schemes covered: redis, rediss (TLS), postgres, postgresql,
|
|
437
|
+
// mongodb, mongodb+srv, mysql, mariadb, amqp, amqps, kafka,
|
|
438
|
+
// clickhouse, cassandra. HTTP(S) / FTP / SSH are intentionally
|
|
439
|
+
// excluded — they're not database URLs and adding them would
|
|
440
|
+
// create false positives on every basic-auth URL in the wild.
|
|
441
|
+
//
|
|
442
|
+
// Requires `:password@` (4+ char password) so user-only URLs like
|
|
443
|
+
// `redis://user@host` don't match. Stopwords ('your', '${', '<your',
|
|
444
|
+
// 'placeholder', 'changeme', etc.) keep doc/README scans clean.
|
|
445
|
+
{
|
|
446
|
+
name: "Database Connection String",
|
|
447
|
+
regex: /\b(redis|rediss|postgres|postgresql|mongodb|mongodb\+srv|mysql|mariadb|amqp|amqps|kafka|clickhouse|cassandra):\/\/[^:/\s@]*:[^@\s]{4,}@[^\s/]+/,
|
|
448
|
+
severity: "block",
|
|
449
|
+
keywords: [
|
|
450
|
+
"redis://",
|
|
451
|
+
"rediss://",
|
|
452
|
+
"postgres://",
|
|
453
|
+
"postgresql://",
|
|
454
|
+
"mongodb://",
|
|
455
|
+
"mongodb+srv://",
|
|
456
|
+
"mysql://",
|
|
457
|
+
"mariadb://",
|
|
458
|
+
"amqp://",
|
|
459
|
+
"amqps://",
|
|
460
|
+
"kafka://",
|
|
461
|
+
"clickhouse://",
|
|
462
|
+
"cassandra://"
|
|
463
|
+
],
|
|
464
|
+
minEntropy: 3
|
|
429
465
|
}
|
|
430
466
|
];
|
|
431
467
|
var DLP_PATTERNS_GLOBAL = DLP_PATTERNS.map(
|
|
@@ -621,9 +657,70 @@ var MESSAGE_FLAGS = /* @__PURE__ */ new Set([
|
|
|
621
657
|
]);
|
|
622
658
|
var SHELL_INTERPRETERS = /* @__PURE__ */ new Set(["bash", "sh", "zsh", "fish", "dash", "ksh"]);
|
|
623
659
|
var DOWNLOAD_CMDS = /* @__PURE__ */ new Set(["curl", "wget"]);
|
|
660
|
+
function isCatHeredocOrLit(part) {
|
|
661
|
+
if (!part) return false;
|
|
662
|
+
const t = syntax.NodeType(part);
|
|
663
|
+
if (t === "Lit") return true;
|
|
664
|
+
if (t !== "CmdSubst") return false;
|
|
665
|
+
const stmts = part.Stmts || [];
|
|
666
|
+
if (stmts.length !== 1) return false;
|
|
667
|
+
const stmt = stmts[0];
|
|
668
|
+
const redirs = stmt.Redirs || stmt.Cmd?.Redirs || [];
|
|
669
|
+
const hasHeredoc = redirs.some((r) => r && r.Hdoc);
|
|
670
|
+
if (!hasHeredoc) return false;
|
|
671
|
+
const cmd = stmt.Cmd;
|
|
672
|
+
if (!cmd || syntax.NodeType(cmd) !== "CallExpr") return false;
|
|
673
|
+
const firstArg = cmd.Args?.[0]?.Parts || [];
|
|
674
|
+
if (firstArg.length !== 1 || syntax.NodeType(firstArg[0]) !== "Lit") return false;
|
|
675
|
+
return (firstArg[0].Value || "").toLowerCase() === "cat";
|
|
676
|
+
}
|
|
677
|
+
var NORMALIZE_CACHE_MAX = 5e3;
|
|
678
|
+
var normalizeCache = /* @__PURE__ */ new Map();
|
|
679
|
+
var AST_CACHE_MAX = 5e3;
|
|
680
|
+
var astCache = /* @__PURE__ */ new Map();
|
|
681
|
+
var PARSE_FAIL = /* @__PURE__ */ Symbol("parse-fail");
|
|
682
|
+
function parseShared(command) {
|
|
683
|
+
const cached = astCache.get(command);
|
|
684
|
+
if (cached !== void 0) {
|
|
685
|
+
astCache.delete(command);
|
|
686
|
+
astCache.set(command, cached);
|
|
687
|
+
return cached;
|
|
688
|
+
}
|
|
689
|
+
let parsed;
|
|
690
|
+
try {
|
|
691
|
+
parsed = sharedParser.Parse(command, "cmd");
|
|
692
|
+
} catch {
|
|
693
|
+
parsed = PARSE_FAIL;
|
|
694
|
+
}
|
|
695
|
+
if (astCache.size >= AST_CACHE_MAX) {
|
|
696
|
+
const oldest = astCache.keys().next().value;
|
|
697
|
+
if (oldest !== void 0) astCache.delete(oldest);
|
|
698
|
+
}
|
|
699
|
+
astCache.set(command, parsed);
|
|
700
|
+
return parsed;
|
|
701
|
+
}
|
|
702
|
+
function cachedNormalize(command, compute) {
|
|
703
|
+
const hit = normalizeCache.get(command);
|
|
704
|
+
if (hit !== void 0) {
|
|
705
|
+
normalizeCache.delete(command);
|
|
706
|
+
normalizeCache.set(command, hit);
|
|
707
|
+
return hit;
|
|
708
|
+
}
|
|
709
|
+
const result = compute();
|
|
710
|
+
if (normalizeCache.size >= NORMALIZE_CACHE_MAX) {
|
|
711
|
+
const oldest = normalizeCache.keys().next().value;
|
|
712
|
+
if (oldest !== void 0) normalizeCache.delete(oldest);
|
|
713
|
+
}
|
|
714
|
+
normalizeCache.set(command, result);
|
|
715
|
+
return result;
|
|
716
|
+
}
|
|
624
717
|
function normalizeCommandForPolicy(command) {
|
|
718
|
+
return cachedNormalize(command, () => normalizeCommandForPolicyImpl(command));
|
|
719
|
+
}
|
|
720
|
+
function normalizeCommandForPolicyImpl(command) {
|
|
721
|
+
const f = parseShared(command);
|
|
722
|
+
if (f === PARSE_FAIL) return command;
|
|
625
723
|
try {
|
|
626
|
-
const f = sharedParser.Parse(command, "cmd");
|
|
627
724
|
const strips = [];
|
|
628
725
|
syntax.Walk(f, (node) => {
|
|
629
726
|
if (!node) return false;
|
|
@@ -645,7 +742,11 @@ function normalizeCommandForPolicy(command) {
|
|
|
645
742
|
} else if (nt === "DblQuoted") {
|
|
646
743
|
const innerParts = quotedNode.Parts || [];
|
|
647
744
|
const allLit = innerParts.length === 0 || innerParts.every((p) => syntax.NodeType(p) === "Lit");
|
|
648
|
-
if (allLit)
|
|
745
|
+
if (allLit) {
|
|
746
|
+
strips.push([next.Pos().Offset(), next.End().Offset()]);
|
|
747
|
+
} else if (innerParts.every((p) => isCatHeredocOrLit(p))) {
|
|
748
|
+
strips.push([next.Pos().Offset(), next.End().Offset()]);
|
|
749
|
+
}
|
|
649
750
|
}
|
|
650
751
|
}
|
|
651
752
|
return true;
|
|
@@ -714,6 +815,242 @@ function detectDangerousShellExec(command) {
|
|
|
714
815
|
}
|
|
715
816
|
}
|
|
716
817
|
var detectDangerousEval = detectDangerousShellExec;
|
|
818
|
+
var FS_READ_TOOLS = /* @__PURE__ */ new Set([
|
|
819
|
+
"cat",
|
|
820
|
+
"less",
|
|
821
|
+
"head",
|
|
822
|
+
"tail",
|
|
823
|
+
"bat",
|
|
824
|
+
"more",
|
|
825
|
+
"open",
|
|
826
|
+
"print",
|
|
827
|
+
"nano",
|
|
828
|
+
"vim",
|
|
829
|
+
"vi",
|
|
830
|
+
"emacs",
|
|
831
|
+
"code",
|
|
832
|
+
"type"
|
|
833
|
+
]);
|
|
834
|
+
var FS_OP_PRESCREEN_RE = /(?:^|[\s|;&(`\n])(?:rm|cat|less|head|tail|bat|more|open|print|nano|vim|vi|emacs|code|type)\b/;
|
|
835
|
+
var HOME_CACHE_ALLOWLIST = [
|
|
836
|
+
".cache",
|
|
837
|
+
".npm/_npx",
|
|
838
|
+
".npm/_cacache",
|
|
839
|
+
".cargo/registry",
|
|
840
|
+
".gradle/caches",
|
|
841
|
+
".gradle/.tmp",
|
|
842
|
+
".m2/repository",
|
|
843
|
+
".pnpm-store",
|
|
844
|
+
".yarn/cache",
|
|
845
|
+
".yarn/.cache",
|
|
846
|
+
".cache/pip",
|
|
847
|
+
".local/share/Trash",
|
|
848
|
+
".rustup/downloads"
|
|
849
|
+
];
|
|
850
|
+
var SENSITIVE_PATH_RULES = [
|
|
851
|
+
{
|
|
852
|
+
rule: "shield:project-jail:block-read-ssh",
|
|
853
|
+
reason: "Reading SSH private keys is blocked by project-jail shield",
|
|
854
|
+
match: (p) => /(^|[\\/])\.ssh[\\/]/i.test(p)
|
|
855
|
+
},
|
|
856
|
+
{
|
|
857
|
+
rule: "shield:project-jail:block-read-aws",
|
|
858
|
+
reason: "Reading AWS credentials is blocked by project-jail shield",
|
|
859
|
+
match: (p) => /(^|[\\/])\.aws[\\/]/i.test(p)
|
|
860
|
+
},
|
|
861
|
+
{
|
|
862
|
+
// Mirrors the JSON shield's `.env` pattern (project-jail.json's
|
|
863
|
+
// block-read-env-any-tool) so the AST FS-op path catches the
|
|
864
|
+
// same set the regex shield does — including Next.js / Vite's
|
|
865
|
+
// `.env.<env>.local` double-suffix overrides which are commonly
|
|
866
|
+
// gitignored AND commonly contain real secrets.
|
|
867
|
+
//
|
|
868
|
+
// Intentional non-matches (dev fixtures): .env.example, .env.sample,
|
|
869
|
+
// .env.template, .env.test, .envrc. See shields.test.ts:983-995
|
|
870
|
+
// for the canonical test-asserted contract.
|
|
871
|
+
rule: "shield:project-jail:block-read-env",
|
|
872
|
+
reason: "Reading .env files is blocked by project-jail shield",
|
|
873
|
+
match: (p) => /(?:^|[\\/])\.env(?:\.(?:local|production|staging|development|production\.local|staging\.local|development\.local))?$/i.test(
|
|
874
|
+
p
|
|
875
|
+
)
|
|
876
|
+
},
|
|
877
|
+
{
|
|
878
|
+
// verdict: 'review' (not 'block') is a deliberate design choice
|
|
879
|
+
// documented in commit 29327a8. SSH keys and AWS credentials are
|
|
880
|
+
// cryptographic material with no legitimate read use-case for
|
|
881
|
+
// an AI agent → hard `block`. But .netrc / .npmrc / .docker /
|
|
882
|
+
// .kube / gcloud are CONFIG files that hold tokens AND have
|
|
883
|
+
// legitimate diagnostic reads ("which registry am I configured
|
|
884
|
+
// for", "what cluster am I on"). Hard-blocking those creates
|
|
885
|
+
// friction without much safety win because the review gate
|
|
886
|
+
// still catches genuine exfiltration attempts.
|
|
887
|
+
//
|
|
888
|
+
// The review gate FAILS CLOSED on timeout (daemon.approvalTimeoutMs
|
|
889
|
+
// returns a deny verdict via the orchestrator's timeout branch),
|
|
890
|
+
// so a stuck or unattended approval does NOT silently grant
|
|
891
|
+
// credential access. If the threat model demands strict block,
|
|
892
|
+
// a future per-shield strict-mode toggle is the right fix —
|
|
893
|
+
// not a regex-level upgrade here.
|
|
894
|
+
rule: "shield:project-jail:review-read-credentials",
|
|
895
|
+
reason: "Reading credential files requires approval (project-jail shield)",
|
|
896
|
+
verdict: "review",
|
|
897
|
+
match: (p) => (
|
|
898
|
+
// .kube/config holds Kubernetes cluster credentials and was
|
|
899
|
+
// flagged as missing by the node9-pr-agent review (the comment
|
|
900
|
+
// above mentioned .kube but the regex didn't include it — a
|
|
901
|
+
// textbook code-comment vs code drift). The JSON shield's
|
|
902
|
+
// review-read-credentials-any-tool already had it. Now aligned.
|
|
903
|
+
/(?:credentials\.json|\.netrc|\.npmrc|\.docker[\\/]config\.json|gcloud[\\/]credentials|\.kube[\\/]config)$/i.test(
|
|
904
|
+
p
|
|
905
|
+
)
|
|
906
|
+
)
|
|
907
|
+
}
|
|
908
|
+
];
|
|
909
|
+
var BASH_TOOL_NAMES = /* @__PURE__ */ new Set([
|
|
910
|
+
"bash",
|
|
911
|
+
"execute_bash",
|
|
912
|
+
"run_shell_command",
|
|
913
|
+
"shell",
|
|
914
|
+
"exec_command"
|
|
915
|
+
]);
|
|
916
|
+
function isBashTool(toolName) {
|
|
917
|
+
return BASH_TOOL_NAMES.has(toolName.toLowerCase());
|
|
918
|
+
}
|
|
919
|
+
var AST_FS_REGEX_RULES = /* @__PURE__ */ new Set([
|
|
920
|
+
"block-rm-rf-home",
|
|
921
|
+
"shield:project-jail:block-read-ssh",
|
|
922
|
+
"shield:project-jail:block-read-aws",
|
|
923
|
+
"shield:project-jail:block-read-env",
|
|
924
|
+
"shield:project-jail:review-read-credentials"
|
|
925
|
+
]);
|
|
926
|
+
function isProtectedHomePath(rawPath) {
|
|
927
|
+
let p = rawPath.replace(/^\$HOME[\\/]?|^\$\{HOME\}[\\/]?/, "~/");
|
|
928
|
+
let underHome = false;
|
|
929
|
+
if (p === "~" || p.startsWith("~/") || p.startsWith("~\\")) {
|
|
930
|
+
p = p.replace(/^~[\\/]?/, "");
|
|
931
|
+
underHome = true;
|
|
932
|
+
} else if (/^\/home\/[^/]+/.test(p) || /^\/root(\/|$)/.test(p)) {
|
|
933
|
+
p = p.replace(/^\/home\/[^/]+[\\/]?|^\/root[\\/]?/, "");
|
|
934
|
+
underHome = true;
|
|
935
|
+
}
|
|
936
|
+
if (!underHome) return false;
|
|
937
|
+
if (p === "" || p === "." || p === "./") return true;
|
|
938
|
+
for (const safe of HOME_CACHE_ALLOWLIST) {
|
|
939
|
+
if (p === safe || p.startsWith(safe + "/") || p.startsWith(safe + "\\")) {
|
|
940
|
+
return false;
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
return true;
|
|
944
|
+
}
|
|
945
|
+
function extractLiteralArgs(callExpr) {
|
|
946
|
+
const args = callExpr.Args || [];
|
|
947
|
+
if (args.length === 0) return { name: "", flags: [], paths: [] };
|
|
948
|
+
const litFromWord = (w) => {
|
|
949
|
+
const parts = w?.Parts || [];
|
|
950
|
+
let s = "";
|
|
951
|
+
for (const p of parts) {
|
|
952
|
+
const t = syntax.NodeType(p);
|
|
953
|
+
if (t === "Lit") s += (p.Value ?? "").replace(/\\(.)/g, "$1");
|
|
954
|
+
else if (t === "SglQuoted") s += p.Value ?? "";
|
|
955
|
+
else if (t === "DblQuoted") {
|
|
956
|
+
const inner = p.Parts || [];
|
|
957
|
+
if (!inner.every((ip) => syntax.NodeType(ip) === "Lit")) return null;
|
|
958
|
+
s += inner.map((ip) => ip.Value ?? "").join("");
|
|
959
|
+
} else {
|
|
960
|
+
return null;
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
return s;
|
|
964
|
+
};
|
|
965
|
+
const name = (litFromWord(args[0]) || "").toLowerCase();
|
|
966
|
+
const flags = [];
|
|
967
|
+
const paths = [];
|
|
968
|
+
for (let i = 1; i < args.length; i++) {
|
|
969
|
+
const v = litFromWord(args[i]);
|
|
970
|
+
if (v === null) continue;
|
|
971
|
+
if (v.startsWith("-")) flags.push(v);
|
|
972
|
+
else paths.push(v);
|
|
973
|
+
}
|
|
974
|
+
return { name, flags, paths };
|
|
975
|
+
}
|
|
976
|
+
var FS_OP_CACHE_MAX = 5e3;
|
|
977
|
+
var fsOpCache = /* @__PURE__ */ new Map();
|
|
978
|
+
function analyzeFsOperation(command) {
|
|
979
|
+
if (!FS_OP_PRESCREEN_RE.test(command)) return null;
|
|
980
|
+
if (fsOpCache.has(command)) {
|
|
981
|
+
const hit = fsOpCache.get(command) ?? null;
|
|
982
|
+
fsOpCache.delete(command);
|
|
983
|
+
fsOpCache.set(command, hit);
|
|
984
|
+
return hit;
|
|
985
|
+
}
|
|
986
|
+
const computed = analyzeFsOperationImpl(command);
|
|
987
|
+
if (fsOpCache.size >= FS_OP_CACHE_MAX) {
|
|
988
|
+
const oldest = fsOpCache.keys().next().value;
|
|
989
|
+
if (oldest !== void 0) fsOpCache.delete(oldest);
|
|
990
|
+
}
|
|
991
|
+
fsOpCache.set(command, computed);
|
|
992
|
+
return computed;
|
|
993
|
+
}
|
|
994
|
+
function analyzeFsOperationImpl(command) {
|
|
995
|
+
const f = parseShared(command);
|
|
996
|
+
if (f === PARSE_FAIL) return null;
|
|
997
|
+
let result = null;
|
|
998
|
+
try {
|
|
999
|
+
syntax.Walk(f, (node) => {
|
|
1000
|
+
if (!node || result) return false;
|
|
1001
|
+
const n = node;
|
|
1002
|
+
if (syntax.NodeType(n) !== "CallExpr") return true;
|
|
1003
|
+
const { name, flags, paths } = extractLiteralArgs(n);
|
|
1004
|
+
if (!name) return true;
|
|
1005
|
+
if (name === "rm") {
|
|
1006
|
+
const flagStr = flags.join("").toLowerCase();
|
|
1007
|
+
const hasR = /[r]/.test(flagStr) || flags.includes("--recursive");
|
|
1008
|
+
const hasF = /[f]/.test(flagStr) || flags.includes("--force");
|
|
1009
|
+
if (hasR && hasF) {
|
|
1010
|
+
for (const p of paths) {
|
|
1011
|
+
if (isProtectedHomePath(p)) {
|
|
1012
|
+
result = {
|
|
1013
|
+
ruleName: "block-rm-rf-home",
|
|
1014
|
+
verdict: "block",
|
|
1015
|
+
reason: "Recursive delete of home directory is irreversible",
|
|
1016
|
+
path: p
|
|
1017
|
+
};
|
|
1018
|
+
return false;
|
|
1019
|
+
}
|
|
1020
|
+
if (p === "/" || /^\/+$/.test(p)) {
|
|
1021
|
+
result = {
|
|
1022
|
+
ruleName: "block-rm-rf-home",
|
|
1023
|
+
verdict: "block",
|
|
1024
|
+
reason: "Recursive delete of root is catastrophic",
|
|
1025
|
+
path: p
|
|
1026
|
+
};
|
|
1027
|
+
return false;
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
if (FS_READ_TOOLS.has(name)) {
|
|
1033
|
+
for (const p of paths) {
|
|
1034
|
+
for (const sp of SENSITIVE_PATH_RULES) {
|
|
1035
|
+
if (sp.match(p)) {
|
|
1036
|
+
result = {
|
|
1037
|
+
ruleName: sp.rule,
|
|
1038
|
+
verdict: sp.verdict ?? "block",
|
|
1039
|
+
reason: sp.reason,
|
|
1040
|
+
path: p
|
|
1041
|
+
};
|
|
1042
|
+
return false;
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
return true;
|
|
1048
|
+
});
|
|
1049
|
+
return result;
|
|
1050
|
+
} catch {
|
|
1051
|
+
return null;
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
717
1054
|
function analyzeShellCommand(command) {
|
|
718
1055
|
const actions = [];
|
|
719
1056
|
const paths = [];
|
|
@@ -1153,10 +1490,18 @@ function getNestedValue(obj, path) {
|
|
|
1153
1490
|
function evaluateSmartConditions(args, rule) {
|
|
1154
1491
|
if (!rule.conditions || rule.conditions.length === 0) return true;
|
|
1155
1492
|
const mode = rule.conditionMode ?? "all";
|
|
1493
|
+
const fieldCache = /* @__PURE__ */ new Map();
|
|
1494
|
+
const resolveField = (field) => {
|
|
1495
|
+
if (fieldCache.has(field)) return fieldCache.get(field) ?? null;
|
|
1496
|
+
const rawVal = getNestedValue(args, field);
|
|
1497
|
+
const rawStr = rawVal !== null && rawVal !== void 0 ? String(rawVal) : null;
|
|
1498
|
+
const stripped = field === "command" && rawStr !== null ? normalizeCommandForPolicy(rawStr) : rawStr;
|
|
1499
|
+
const val = stripped !== null ? stripped.replace(/\s+/g, " ").trim() : null;
|
|
1500
|
+
fieldCache.set(field, val);
|
|
1501
|
+
return val;
|
|
1502
|
+
};
|
|
1156
1503
|
const results = rule.conditions.map((cond) => {
|
|
1157
|
-
const
|
|
1158
|
-
const normalized = rawVal !== null && rawVal !== void 0 ? String(rawVal).replace(/\s+/g, " ").trim() : null;
|
|
1159
|
-
const val = cond.field === "command" && normalized !== null ? normalizeCommandForPolicy(normalized) : normalized;
|
|
1504
|
+
const val = resolveField(cond.field);
|
|
1160
1505
|
switch (cond.op) {
|
|
1161
1506
|
case "exists":
|
|
1162
1507
|
return val !== null && val !== "";
|
|
@@ -1219,6 +1564,43 @@ function checkDangerousSql(sql) {
|
|
|
1219
1564
|
return "UPDATE without WHERE \u2014 updates every row";
|
|
1220
1565
|
return null;
|
|
1221
1566
|
}
|
|
1567
|
+
function pipeChainVerdict(command, isTrustedHost) {
|
|
1568
|
+
const pipeAnalysis = analyzePipeChain(command);
|
|
1569
|
+
if (!pipeAnalysis.isPipeline) return null;
|
|
1570
|
+
if (pipeAnalysis.risk !== "critical" && pipeAnalysis.risk !== "high") return null;
|
|
1571
|
+
const sinks = pipeAnalysis.sinkTargets;
|
|
1572
|
+
const allTrusted = sinks.length > 0 && sinks.every((host) => isTrustedHost ? isTrustedHost(host) : false);
|
|
1573
|
+
if (pipeAnalysis.risk === "critical") {
|
|
1574
|
+
if (allTrusted) {
|
|
1575
|
+
return {
|
|
1576
|
+
decision: "review",
|
|
1577
|
+
blockedByLabel: "Node9: Pipe-Chain to Trusted Host (obfuscated)",
|
|
1578
|
+
reason: `Obfuscated pipe to trusted host(s): ${sinks.join(", ")} \u2014 requires approval`,
|
|
1579
|
+
tier: 3
|
|
1580
|
+
};
|
|
1581
|
+
}
|
|
1582
|
+
return {
|
|
1583
|
+
decision: "block",
|
|
1584
|
+
blockedByLabel: "Node9: Pipe-Chain Exfiltration (critical)",
|
|
1585
|
+
reason: `Sensitive file piped through obfuscator to network sink: ${pipeAnalysis.sourceFiles.join(", ")} \u2192 ${sinks.join(", ")}`,
|
|
1586
|
+
tier: 3
|
|
1587
|
+
};
|
|
1588
|
+
}
|
|
1589
|
+
if (allTrusted) {
|
|
1590
|
+
return {
|
|
1591
|
+
decision: "allow",
|
|
1592
|
+
blockedByLabel: "Node9: Pipe-Chain to Trusted Host",
|
|
1593
|
+
reason: `Sensitive file piped to trusted host(s): ${sinks.join(", ")}`,
|
|
1594
|
+
tier: 3
|
|
1595
|
+
};
|
|
1596
|
+
}
|
|
1597
|
+
return {
|
|
1598
|
+
decision: "review",
|
|
1599
|
+
blockedByLabel: "Node9: Pipe-Chain Exfiltration (high)",
|
|
1600
|
+
reason: `Sensitive file piped to network sink: ${pipeAnalysis.sourceFiles.join(", ")} \u2192 ${sinks.join(", ")}`,
|
|
1601
|
+
tier: 3
|
|
1602
|
+
};
|
|
1603
|
+
}
|
|
1222
1604
|
async function evaluatePolicy(config, toolName, args, context = {}, hooks = {}) {
|
|
1223
1605
|
const { agent, cwd, activeEnvironment } = context;
|
|
1224
1606
|
const { checkProvenance, isTrustedHost } = hooks;
|
|
@@ -1234,9 +1616,27 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
|
|
|
1234
1616
|
}
|
|
1235
1617
|
}
|
|
1236
1618
|
if (wouldBeIgnored) return { decision: "allow" };
|
|
1619
|
+
const bashCommand = agent !== "Terminal" && isBashTool(toolName) && args && typeof args === "object" ? typeof args.command === "string" ? args.command : null : null;
|
|
1620
|
+
if (bashCommand !== null) {
|
|
1621
|
+
const pipeVerdict = pipeChainVerdict(bashCommand, isTrustedHost);
|
|
1622
|
+
if (pipeVerdict) return pipeVerdict;
|
|
1623
|
+
const fsVerdict = analyzeFsOperation(bashCommand);
|
|
1624
|
+
if (fsVerdict) {
|
|
1625
|
+
const isShieldRule = fsVerdict.ruleName.startsWith("shield:");
|
|
1626
|
+
const labelPrefix = isShieldRule ? "project-jail (AST)" : "Node9 (AST)";
|
|
1627
|
+
return {
|
|
1628
|
+
decision: fsVerdict.verdict,
|
|
1629
|
+
blockedByLabel: `${labelPrefix}: ${fsVerdict.ruleName}`,
|
|
1630
|
+
reason: fsVerdict.reason,
|
|
1631
|
+
tier: 2,
|
|
1632
|
+
ruleName: fsVerdict.ruleName,
|
|
1633
|
+
ruleDescription: fsVerdict.reason
|
|
1634
|
+
};
|
|
1635
|
+
}
|
|
1636
|
+
}
|
|
1237
1637
|
if (config.policy.smartRules.length > 0) {
|
|
1238
1638
|
const matchedRule = config.policy.smartRules.find(
|
|
1239
|
-
(rule) => matchesPattern(toolName, rule.tool) && evaluateSmartConditions(args, rule)
|
|
1639
|
+
(rule) => matchesPattern(toolName, rule.tool) && !(bashCommand !== null && rule.name && AST_FS_REGEX_RULES.has(rule.name)) && evaluateSmartConditions(args, rule)
|
|
1240
1640
|
);
|
|
1241
1641
|
if (matchedRule) {
|
|
1242
1642
|
if (matchedRule.verdict === "allow")
|
|
@@ -1294,41 +1694,8 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
|
|
|
1294
1694
|
tier: 3
|
|
1295
1695
|
};
|
|
1296
1696
|
}
|
|
1297
|
-
const
|
|
1298
|
-
if (
|
|
1299
|
-
const sinks = pipeAnalysis.sinkTargets;
|
|
1300
|
-
const allTrusted = sinks.length > 0 && sinks.every((host) => isTrustedHost ? isTrustedHost(host) : false);
|
|
1301
|
-
if (pipeAnalysis.risk === "critical") {
|
|
1302
|
-
if (allTrusted) {
|
|
1303
|
-
return {
|
|
1304
|
-
decision: "review",
|
|
1305
|
-
blockedByLabel: "Node9: Pipe-Chain to Trusted Host (obfuscated)",
|
|
1306
|
-
reason: `Obfuscated pipe to trusted host(s): ${sinks.join(", ")} \u2014 requires approval`,
|
|
1307
|
-
tier: 3
|
|
1308
|
-
};
|
|
1309
|
-
}
|
|
1310
|
-
return {
|
|
1311
|
-
decision: "block",
|
|
1312
|
-
blockedByLabel: "Node9: Pipe-Chain Exfiltration (critical)",
|
|
1313
|
-
reason: `Sensitive file piped through obfuscator to network sink: ${pipeAnalysis.sourceFiles.join(", ")} \u2192 ${sinks.join(", ")}`,
|
|
1314
|
-
tier: 3
|
|
1315
|
-
};
|
|
1316
|
-
}
|
|
1317
|
-
if (allTrusted) {
|
|
1318
|
-
return {
|
|
1319
|
-
decision: "allow",
|
|
1320
|
-
blockedByLabel: "Node9: Pipe-Chain to Trusted Host",
|
|
1321
|
-
reason: `Sensitive file piped to trusted host(s): ${sinks.join(", ")}`,
|
|
1322
|
-
tier: 3
|
|
1323
|
-
};
|
|
1324
|
-
}
|
|
1325
|
-
return {
|
|
1326
|
-
decision: "review",
|
|
1327
|
-
blockedByLabel: "Node9: Pipe-Chain Exfiltration (high)",
|
|
1328
|
-
reason: `Sensitive file piped to network sink: ${pipeAnalysis.sourceFiles.join(", ")} \u2192 ${sinks.join(", ")}`,
|
|
1329
|
-
tier: 3
|
|
1330
|
-
};
|
|
1331
|
-
}
|
|
1697
|
+
const ptVerdict = pipeChainVerdict(shellCommand, isTrustedHost);
|
|
1698
|
+
if (ptVerdict) return ptVerdict;
|
|
1332
1699
|
const firstToken = analyzed.actions[0] ?? "";
|
|
1333
1700
|
if (["ssh", "scp", "rsync"].includes(firstToken)) {
|
|
1334
1701
|
const rawTokens = shellCommand.trim().split(/\s+/);
|
|
@@ -2005,7 +2372,7 @@ var project_jail_default = {
|
|
|
2005
2372
|
{
|
|
2006
2373
|
field: "command",
|
|
2007
2374
|
op: "matches",
|
|
2008
|
-
value: "(cat|less|head|tail|bat|more|open|print|nano|vim|vi|emacs|code|type)\\s
|
|
2375
|
+
value: "(cat|less|head|tail|bat|more|open|print|nano|vim|vi|emacs|code|type|grep|egrep|fgrep|rg|ag|ack|awk|gawk|sed|cut|tr|jq|yq|od|xxd|hexdump|strings|sort|uniq|tac|nl|dd)\\s+.*?\\.ssh[\\/\\\\]",
|
|
2009
2376
|
flags: "i"
|
|
2010
2377
|
}
|
|
2011
2378
|
],
|
|
@@ -2019,7 +2386,7 @@ var project_jail_default = {
|
|
|
2019
2386
|
{
|
|
2020
2387
|
field: "command",
|
|
2021
2388
|
op: "matches",
|
|
2022
|
-
value: "(cat|less|head|tail|bat|more|open|print|nano|vim|vi|emacs|code|type)\\s
|
|
2389
|
+
value: "(cat|less|head|tail|bat|more|open|print|nano|vim|vi|emacs|code|type|grep|egrep|fgrep|rg|ag|ack|awk|gawk|sed|cut|tr|jq|yq|od|xxd|hexdump|strings|sort|uniq|tac|nl|dd)\\s+.*?\\.aws[\\/\\\\]",
|
|
2023
2390
|
flags: "i"
|
|
2024
2391
|
}
|
|
2025
2392
|
],
|
|
@@ -2033,7 +2400,7 @@ var project_jail_default = {
|
|
|
2033
2400
|
{
|
|
2034
2401
|
field: "command",
|
|
2035
2402
|
op: "matches",
|
|
2036
|
-
value: "(cat|less|head|tail|bat|more|open|print|nano|vim|vi|emacs|code|type)\\s
|
|
2403
|
+
value: "(cat|less|head|tail|bat|more|open|print|nano|vim|vi|emacs|code|type|grep|egrep|fgrep|rg|ag|ack|awk|gawk|sed|cut|tr|jq|yq|od|xxd|hexdump|strings|sort|uniq|tac|nl|dd)\\s+.*?\\.env(\\.(local|production|staging|development|production\\.local|staging\\.local|development\\.local))?(?=\\s|$|[;&|>)<])",
|
|
2037
2404
|
flags: "i"
|
|
2038
2405
|
}
|
|
2039
2406
|
],
|
|
@@ -2041,18 +2408,74 @@ var project_jail_default = {
|
|
|
2041
2408
|
reason: "Reading .env files is blocked by project-jail shield"
|
|
2042
2409
|
},
|
|
2043
2410
|
{
|
|
2044
|
-
name: "shield:project-jail:
|
|
2411
|
+
name: "shield:project-jail:review-read-credentials",
|
|
2045
2412
|
tool: "bash",
|
|
2046
2413
|
conditions: [
|
|
2047
2414
|
{
|
|
2048
2415
|
field: "command",
|
|
2049
2416
|
op: "matches",
|
|
2050
|
-
value: "(cat|less|head|tail|bat|more|open|print|nano|vim|vi|emacs|code|type)\\s+.*(credentials\\.json|\\.netrc|\\.npmrc|\\.docker[\\/\\\\]config\\.json|gcloud[\\/\\\\]credentials)",
|
|
2417
|
+
value: "(cat|less|head|tail|bat|more|open|print|nano|vim|vi|emacs|code|type|grep|egrep|fgrep|rg|ag|ack|awk|gawk|sed|cut|tr|jq|yq|od|xxd|hexdump|strings|sort|uniq|tac|nl|dd)\\s+.*(credentials\\.json|\\.netrc|\\.npmrc|\\.docker[\\/\\\\]config\\.json|gcloud[\\/\\\\]credentials)",
|
|
2418
|
+
flags: "i"
|
|
2419
|
+
}
|
|
2420
|
+
],
|
|
2421
|
+
verdict: "review",
|
|
2422
|
+
reason: "Reading credential files requires approval (project-jail shield)"
|
|
2423
|
+
},
|
|
2424
|
+
{
|
|
2425
|
+
name: "shield:project-jail:block-read-ssh-any-tool",
|
|
2426
|
+
tool: "*",
|
|
2427
|
+
conditions: [
|
|
2428
|
+
{
|
|
2429
|
+
field: "file_path",
|
|
2430
|
+
op: "matches",
|
|
2431
|
+
value: "(^|[\\/\\\\])\\.ssh[\\/\\\\]",
|
|
2432
|
+
flags: "i"
|
|
2433
|
+
}
|
|
2434
|
+
],
|
|
2435
|
+
verdict: "block",
|
|
2436
|
+
reason: "Reading SSH private keys is blocked by project-jail shield"
|
|
2437
|
+
},
|
|
2438
|
+
{
|
|
2439
|
+
name: "shield:project-jail:block-read-aws-any-tool",
|
|
2440
|
+
tool: "*",
|
|
2441
|
+
conditions: [
|
|
2442
|
+
{
|
|
2443
|
+
field: "file_path",
|
|
2444
|
+
op: "matches",
|
|
2445
|
+
value: "(^|[\\/\\\\])\\.aws[\\/\\\\]",
|
|
2446
|
+
flags: "i"
|
|
2447
|
+
}
|
|
2448
|
+
],
|
|
2449
|
+
verdict: "block",
|
|
2450
|
+
reason: "Reading AWS credentials is blocked by project-jail shield"
|
|
2451
|
+
},
|
|
2452
|
+
{
|
|
2453
|
+
name: "shield:project-jail:block-read-env-any-tool",
|
|
2454
|
+
tool: "*",
|
|
2455
|
+
conditions: [
|
|
2456
|
+
{
|
|
2457
|
+
field: "file_path",
|
|
2458
|
+
op: "matches",
|
|
2459
|
+
value: "(^|[\\/\\\\])\\.env(\\.(local|production|staging|development|production\\.local|staging\\.local|development\\.local))?$",
|
|
2051
2460
|
flags: "i"
|
|
2052
2461
|
}
|
|
2053
2462
|
],
|
|
2054
2463
|
verdict: "block",
|
|
2055
|
-
reason: "Reading
|
|
2464
|
+
reason: "Reading .env files is blocked by project-jail shield"
|
|
2465
|
+
},
|
|
2466
|
+
{
|
|
2467
|
+
name: "shield:project-jail:review-read-credentials-any-tool",
|
|
2468
|
+
tool: "*",
|
|
2469
|
+
conditions: [
|
|
2470
|
+
{
|
|
2471
|
+
field: "file_path",
|
|
2472
|
+
op: "matches",
|
|
2473
|
+
value: ".*(credentials\\.json|\\.netrc|\\.npmrc|\\.docker[\\/\\\\]config\\.json|gcloud[\\/\\\\]credentials|\\.kube[\\/\\\\]config)",
|
|
2474
|
+
flags: "i"
|
|
2475
|
+
}
|
|
2476
|
+
],
|
|
2477
|
+
verdict: "review",
|
|
2478
|
+
reason: "Reading credential files requires approval (project-jail shield)"
|
|
2056
2479
|
}
|
|
2057
2480
|
],
|
|
2058
2481
|
dangerousWords: []
|
|
@@ -2461,18 +2884,408 @@ function summarizeBlast(result, opts = {}) {
|
|
|
2461
2884
|
};
|
|
2462
2885
|
}
|
|
2463
2886
|
|
|
2887
|
+
// src/scan/destructive-regex.ts
|
|
2888
|
+
var DESTRUCTIVE_OP_RE = /\brm\s+-[rRf]+\b|\bDROP\s+(TABLE|DATABASE|COLLECTION|SCHEMA)\b|\bTRUNCATE\s+TABLE\b|\bgit\s+push\s+(--force|-f)\b|\bFLUSHALL\b|\bFLUSHDB\b|\bkubectl\s+delete\b|\bhelm\s+uninstall\b/i;
|
|
2889
|
+
var PRIVILEGE_ESCALATION_RE = /\bchmod\s+(0?777|\+x)\b|\bchown\s+root\b/i;
|
|
2890
|
+
var SENSITIVE_PATH_RE = /\.aws\/(credentials|config)\b|\.ssh\/(id_rsa|id_ed25519|id_ecdsa|id_dsa)\b|\.env(\.|$|\b)|\.config\/gcloud\/credentials\.db\b|\.docker\/config\.json\b|\.netrc\b|\.npmrc\b|\.node9\/credentials\.json\b/i;
|
|
2891
|
+
var FILE_TOOLS = /* @__PURE__ */ new Set([
|
|
2892
|
+
"read",
|
|
2893
|
+
"read_file",
|
|
2894
|
+
"edit",
|
|
2895
|
+
"edit_file",
|
|
2896
|
+
"write",
|
|
2897
|
+
"write_file",
|
|
2898
|
+
"multiedit",
|
|
2899
|
+
"grep",
|
|
2900
|
+
"grep_search",
|
|
2901
|
+
"glob",
|
|
2902
|
+
"list_files"
|
|
2903
|
+
]);
|
|
2904
|
+
|
|
2905
|
+
// src/scan/pii.ts
|
|
2906
|
+
var PII_EMAIL_RE = /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/;
|
|
2907
|
+
var PII_SSN_RE = /\b\d{3}-\d{2}-\d{4}\b/;
|
|
2908
|
+
var PII_PHONE_RE = /\b(?:\+?1[-.\s]?)?\(?\d{3}\)?[-.\s]\d{3}[-.\s]\d{4}\b/;
|
|
2909
|
+
var PII_CC_RE = /\b(?:4\d{3}|5[1-5]\d{2}|3[47]\d{2}|6\d{3})[-\s]?\d{4}[-\s]?\d{4}[-\s]?\d{4}\b/;
|
|
2910
|
+
function detectPii(text) {
|
|
2911
|
+
const found = /* @__PURE__ */ new Set();
|
|
2912
|
+
if (/@/.test(text) && PII_EMAIL_RE.test(text)) found.add("Email");
|
|
2913
|
+
if (/-/.test(text) && PII_SSN_RE.test(text)) found.add("SSN");
|
|
2914
|
+
if (PII_PHONE_RE.test(text)) found.add("Phone");
|
|
2915
|
+
if (PII_CC_RE.test(text)) found.add("Credit Card");
|
|
2916
|
+
return [...found];
|
|
2917
|
+
}
|
|
2918
|
+
|
|
2919
|
+
// src/scan/canonical.ts
|
|
2920
|
+
var LONG_OUTPUT_THRESHOLD_BYTES = 100 * 1024;
|
|
2921
|
+
var CANONICAL_EXTRACTOR_VERSION = "canonical-v4";
|
|
2922
|
+
var CANONICAL_EXTRACTOR_HASH = "64a6a63a27f4646f";
|
|
2923
|
+
var DEDUPE_PREVIEW_LEN = 120;
|
|
2924
|
+
function extractCanonicalFindings(call, ctx) {
|
|
2925
|
+
const out = [];
|
|
2926
|
+
const ts = call.timestamp;
|
|
2927
|
+
const toolNameLower = call.toolName.toLowerCase();
|
|
2928
|
+
const command = typeof call.args.command === "string" ? call.args.command : null;
|
|
2929
|
+
const isBash = isBashTool(call.toolName) && command !== null;
|
|
2930
|
+
if (call.outputBytes !== void 0 && call.outputBytes > LONG_OUTPUT_THRESHOLD_BYTES) {
|
|
2931
|
+
out.push(
|
|
2932
|
+
makeFinding({
|
|
2933
|
+
type: "long-output-redacted",
|
|
2934
|
+
ruleName: "long-output-redacted",
|
|
2935
|
+
verdict: "review",
|
|
2936
|
+
severity: "medium",
|
|
2937
|
+
reason: `Tool output exceeded ${LONG_OUTPUT_THRESHOLD_BYTES} bytes and was redacted`,
|
|
2938
|
+
toolName: call.toolName,
|
|
2939
|
+
ctx,
|
|
2940
|
+
ts,
|
|
2941
|
+
sourceType: "engine"
|
|
2942
|
+
})
|
|
2943
|
+
);
|
|
2944
|
+
}
|
|
2945
|
+
if (ctx.dlpEnabled) {
|
|
2946
|
+
const dlp = scanArgs(call.args);
|
|
2947
|
+
if (dlp) {
|
|
2948
|
+
out.push(
|
|
2949
|
+
makeFinding({
|
|
2950
|
+
type: "dlp",
|
|
2951
|
+
ruleName: `dlp:${dlp.patternName}`,
|
|
2952
|
+
patternName: dlp.patternName,
|
|
2953
|
+
verdict: dlp.severity === "block" ? "block" : "review",
|
|
2954
|
+
severity: dlp.severity === "block" ? "critical" : "medium",
|
|
2955
|
+
reason: `${dlp.patternName} detected in ${dlp.fieldPath}`,
|
|
2956
|
+
toolName: call.toolName,
|
|
2957
|
+
ctx,
|
|
2958
|
+
ts,
|
|
2959
|
+
sourceType: "engine",
|
|
2960
|
+
input: call.args,
|
|
2961
|
+
redactedSample: dlp.redactedSample
|
|
2962
|
+
})
|
|
2963
|
+
);
|
|
2964
|
+
}
|
|
2965
|
+
}
|
|
2966
|
+
for (const value of stringValues(call.args)) {
|
|
2967
|
+
const piiHits = detectPii(value);
|
|
2968
|
+
for (const pattern of piiHits) {
|
|
2969
|
+
out.push(
|
|
2970
|
+
makeFinding({
|
|
2971
|
+
type: "pii",
|
|
2972
|
+
ruleName: `pii:${pattern.toLowerCase().replace(/\s+/g, "-")}`,
|
|
2973
|
+
patternName: pattern,
|
|
2974
|
+
verdict: "review",
|
|
2975
|
+
severity: "medium",
|
|
2976
|
+
reason: `${pattern} pattern detected in tool input`,
|
|
2977
|
+
toolName: call.toolName,
|
|
2978
|
+
ctx,
|
|
2979
|
+
ts,
|
|
2980
|
+
sourceType: "engine"
|
|
2981
|
+
})
|
|
2982
|
+
);
|
|
2983
|
+
}
|
|
2984
|
+
}
|
|
2985
|
+
if (FILE_TOOLS.has(toolNameLower)) {
|
|
2986
|
+
const filePath = typeof call.args.file_path === "string" && call.args.file_path || typeof call.args.path === "string" && call.args.path || typeof call.args.pattern === "string" && call.args.pattern || "";
|
|
2987
|
+
if (filePath && SENSITIVE_PATH_RE.test(filePath)) {
|
|
2988
|
+
out.push(
|
|
2989
|
+
makeFinding({
|
|
2990
|
+
type: "sensitive-file-read",
|
|
2991
|
+
ruleName: "sensitive-file-read",
|
|
2992
|
+
verdict: "review",
|
|
2993
|
+
severity: "critical",
|
|
2994
|
+
reason: `Sensitive file path read via ${call.toolName}`,
|
|
2995
|
+
toolName: call.toolName,
|
|
2996
|
+
ctx,
|
|
2997
|
+
ts,
|
|
2998
|
+
sourceType: "engine",
|
|
2999
|
+
subjectPath: filePath
|
|
3000
|
+
})
|
|
3001
|
+
);
|
|
3002
|
+
}
|
|
3003
|
+
}
|
|
3004
|
+
if (!isBash || command === null) {
|
|
3005
|
+
return out;
|
|
3006
|
+
}
|
|
3007
|
+
const fsVerdict = analyzeFsOperation(command);
|
|
3008
|
+
if (fsVerdict) {
|
|
3009
|
+
const isShield = fsVerdict.ruleName.startsWith("shield:");
|
|
3010
|
+
out.push(
|
|
3011
|
+
makeFinding({
|
|
3012
|
+
type: "ast-fs-op",
|
|
3013
|
+
ruleName: fsVerdict.ruleName,
|
|
3014
|
+
verdict: fsVerdict.verdict,
|
|
3015
|
+
severity: classifyRuleSeverity(fsVerdict.ruleName, fsVerdict.verdict),
|
|
3016
|
+
reason: fsVerdict.reason,
|
|
3017
|
+
toolName: call.toolName,
|
|
3018
|
+
ctx,
|
|
3019
|
+
ts,
|
|
3020
|
+
sourceType: isShield ? "shield" : "engine",
|
|
3021
|
+
shieldLabel: isShield ? "project-jail (AST)" : "Node9 (AST)",
|
|
3022
|
+
subjectPath: fsVerdict.path,
|
|
3023
|
+
input: call.args
|
|
3024
|
+
})
|
|
3025
|
+
);
|
|
3026
|
+
}
|
|
3027
|
+
for (const source of ctx.rules) {
|
|
3028
|
+
const r = source.rule;
|
|
3029
|
+
if (r.verdict === "allow") continue;
|
|
3030
|
+
if (r.tool && !matchesPattern(toolNameLower, r.tool)) continue;
|
|
3031
|
+
if (r.name && AST_FS_REGEX_RULES.has(r.name)) continue;
|
|
3032
|
+
if (!evaluateSmartConditions(call.args, r)) continue;
|
|
3033
|
+
out.push(
|
|
3034
|
+
makeFinding({
|
|
3035
|
+
type: "smart-rule",
|
|
3036
|
+
ruleName: r.name ?? r.tool,
|
|
3037
|
+
verdict: r.verdict === "block" ? "block" : "review",
|
|
3038
|
+
severity: classifyRuleSeverity(r.name ?? r.tool, r.verdict),
|
|
3039
|
+
reason: r.reason ?? `Smart rule ${r.name ?? r.tool} fired`,
|
|
3040
|
+
toolName: call.toolName,
|
|
3041
|
+
ctx,
|
|
3042
|
+
ts,
|
|
3043
|
+
sourceType: source.sourceType,
|
|
3044
|
+
shieldLabel: source.shieldLabel,
|
|
3045
|
+
input: call.args
|
|
3046
|
+
})
|
|
3047
|
+
);
|
|
3048
|
+
break;
|
|
3049
|
+
}
|
|
3050
|
+
const evalVerdict = detectDangerousShellExec(command);
|
|
3051
|
+
if (evalVerdict) {
|
|
3052
|
+
out.push(
|
|
3053
|
+
makeFinding({
|
|
3054
|
+
type: "eval-of-remote",
|
|
3055
|
+
ruleName: "eval-of-remote",
|
|
3056
|
+
verdict: evalVerdict,
|
|
3057
|
+
severity: classifyRuleSeverity("eval-remote", evalVerdict),
|
|
3058
|
+
reason: evalVerdict === "block" ? "Eval of remote download is a near-certain supply-chain attack" : "Eval of dynamic content (variable / subshell) requires approval",
|
|
3059
|
+
toolName: call.toolName,
|
|
3060
|
+
ctx,
|
|
3061
|
+
ts,
|
|
3062
|
+
sourceType: "engine",
|
|
3063
|
+
input: call.args
|
|
3064
|
+
})
|
|
3065
|
+
);
|
|
3066
|
+
}
|
|
3067
|
+
const pipe = analyzePipeChain(command);
|
|
3068
|
+
if (pipe.isPipeline && pipe.risk === "critical") {
|
|
3069
|
+
out.push(
|
|
3070
|
+
makeFinding({
|
|
3071
|
+
type: "pipe-to-shell",
|
|
3072
|
+
ruleName: "pipe-to-shell",
|
|
3073
|
+
verdict: "block",
|
|
3074
|
+
severity: "critical",
|
|
3075
|
+
reason: `Sensitive file piped through obfuscator to network sink: ${pipe.sourceFiles.join(", ")} \u2192 ${pipe.sinkTargets.join(", ")}`,
|
|
3076
|
+
toolName: call.toolName,
|
|
3077
|
+
ctx,
|
|
3078
|
+
ts,
|
|
3079
|
+
sourceType: "engine",
|
|
3080
|
+
input: call.args
|
|
3081
|
+
})
|
|
3082
|
+
);
|
|
3083
|
+
}
|
|
3084
|
+
if (DESTRUCTIVE_OP_RE.test(command)) {
|
|
3085
|
+
out.push(
|
|
3086
|
+
makeFinding({
|
|
3087
|
+
type: "destructive-op",
|
|
3088
|
+
ruleName: "destructive-op",
|
|
3089
|
+
verdict: "review",
|
|
3090
|
+
severity: "high",
|
|
3091
|
+
reason: "Destructive operation pattern detected",
|
|
3092
|
+
toolName: call.toolName,
|
|
3093
|
+
ctx,
|
|
3094
|
+
ts,
|
|
3095
|
+
sourceType: "engine",
|
|
3096
|
+
input: call.args
|
|
3097
|
+
})
|
|
3098
|
+
);
|
|
3099
|
+
}
|
|
3100
|
+
const ast = analyzeShellCommand(command);
|
|
3101
|
+
const sudoVariant = ast.actions.includes("sudo") || ast.actions.includes("su");
|
|
3102
|
+
const chmodVariant = ast.actions.includes("chmod") && (ast.allTokens.includes("777") || ast.allTokens.includes("0777") || ast.allTokens.includes("+x"));
|
|
3103
|
+
const chownVariant = ast.actions.includes("chown") && ast.allTokens.includes("root");
|
|
3104
|
+
if (sudoVariant || chmodVariant || chownVariant) {
|
|
3105
|
+
out.push(
|
|
3106
|
+
makeFinding({
|
|
3107
|
+
type: "privilege-escalation",
|
|
3108
|
+
ruleName: "privilege-escalation",
|
|
3109
|
+
verdict: "review",
|
|
3110
|
+
severity: "high",
|
|
3111
|
+
reason: "Privilege-escalation pattern detected",
|
|
3112
|
+
toolName: call.toolName,
|
|
3113
|
+
ctx,
|
|
3114
|
+
ts,
|
|
3115
|
+
sourceType: "engine",
|
|
3116
|
+
input: call.args
|
|
3117
|
+
})
|
|
3118
|
+
);
|
|
3119
|
+
}
|
|
3120
|
+
return out;
|
|
3121
|
+
}
|
|
3122
|
+
function extractSessionLevelFindings(calls, ctx) {
|
|
3123
|
+
if (!ctx.loopDetection.enabled || calls.length === 0) return [];
|
|
3124
|
+
const out = [];
|
|
3125
|
+
const seenLoopKeys = /* @__PURE__ */ new Set();
|
|
3126
|
+
const ONE_YEAR_MS = 365 * 24 * 60 * 60 * 1e3;
|
|
3127
|
+
const windowMs = ctx.loopDetection.windowSeconds <= 0 ? ONE_YEAR_MS : ctx.loopDetection.windowSeconds * 1e3;
|
|
3128
|
+
let records = [];
|
|
3129
|
+
let syntheticTs = 0;
|
|
3130
|
+
for (let i = 0; i < calls.length; i++) {
|
|
3131
|
+
const call = calls[i];
|
|
3132
|
+
const parsed = new Date(call.timestamp).getTime();
|
|
3133
|
+
const now = Number.isFinite(parsed) ? parsed : ++syntheticTs;
|
|
3134
|
+
const verdict = evaluateLoopWindow(
|
|
3135
|
+
records,
|
|
3136
|
+
call.toolName,
|
|
3137
|
+
call.args,
|
|
3138
|
+
ctx.loopDetection.threshold,
|
|
3139
|
+
windowMs,
|
|
3140
|
+
now
|
|
3141
|
+
);
|
|
3142
|
+
records = verdict.nextRecords;
|
|
3143
|
+
if (!verdict.looping) continue;
|
|
3144
|
+
const last = records[records.length - 1];
|
|
3145
|
+
const key = `${last.t}|${last.h}`;
|
|
3146
|
+
if (seenLoopKeys.has(key)) continue;
|
|
3147
|
+
seenLoopKeys.add(key);
|
|
3148
|
+
out.push({
|
|
3149
|
+
type: "loop",
|
|
3150
|
+
ruleName: "loop",
|
|
3151
|
+
verdict: "review",
|
|
3152
|
+
severity: "medium",
|
|
3153
|
+
reason: `Tool called ${verdict.count} times with identical args within window`,
|
|
3154
|
+
toolName: call.toolName,
|
|
3155
|
+
agent: ctx.agent,
|
|
3156
|
+
sessionId: ctx.sessionId,
|
|
3157
|
+
project: ctx.project,
|
|
3158
|
+
lineIndex: call.lineIndex,
|
|
3159
|
+
sourceType: "engine",
|
|
3160
|
+
firstSeenAt: call.timestamp,
|
|
3161
|
+
lastSeenAt: call.timestamp,
|
|
3162
|
+
occurrenceCount: 1,
|
|
3163
|
+
loopCount: verdict.count,
|
|
3164
|
+
loopKind: "loop",
|
|
3165
|
+
commandPreview: previewArgs(call.args, DEDUPE_PREVIEW_LEN),
|
|
3166
|
+
costUsd: verdict.count * COST_PER_LOOP_ITER_USD
|
|
3167
|
+
});
|
|
3168
|
+
}
|
|
3169
|
+
return out;
|
|
3170
|
+
}
|
|
3171
|
+
function dedupeCanonicalFindings(findings) {
|
|
3172
|
+
const merged = /* @__PURE__ */ new Map();
|
|
3173
|
+
for (const f of findings) {
|
|
3174
|
+
const inputPreview = f.input ? previewArgs(f.input, DEDUPE_PREVIEW_LEN) : "";
|
|
3175
|
+
const key = `${f.type}|${f.ruleName}|${inputPreview}|${f.project}|${f.agent}`;
|
|
3176
|
+
const prev = merged.get(key);
|
|
3177
|
+
if (!prev) {
|
|
3178
|
+
merged.set(key, { ...f });
|
|
3179
|
+
continue;
|
|
3180
|
+
}
|
|
3181
|
+
prev.occurrenceCount += f.occurrenceCount;
|
|
3182
|
+
if (f.firstSeenAt && (!prev.firstSeenAt || f.firstSeenAt < prev.firstSeenAt)) {
|
|
3183
|
+
prev.firstSeenAt = f.firstSeenAt;
|
|
3184
|
+
}
|
|
3185
|
+
if (f.lastSeenAt && f.lastSeenAt > prev.lastSeenAt) {
|
|
3186
|
+
prev.lastSeenAt = f.lastSeenAt;
|
|
3187
|
+
}
|
|
3188
|
+
if (f.costUsd !== void 0) {
|
|
3189
|
+
prev.costUsd = (prev.costUsd ?? 0) + f.costUsd;
|
|
3190
|
+
}
|
|
3191
|
+
if (f.loopCount !== void 0) {
|
|
3192
|
+
prev.loopCount = (prev.loopCount ?? 0) + f.loopCount;
|
|
3193
|
+
}
|
|
3194
|
+
}
|
|
3195
|
+
return [...merged.values()];
|
|
3196
|
+
}
|
|
3197
|
+
function toScanFinding(c) {
|
|
3198
|
+
const typeMap = {
|
|
3199
|
+
"smart-rule": null,
|
|
3200
|
+
"ast-fs-op": null,
|
|
3201
|
+
dlp: "dlp",
|
|
3202
|
+
pii: "pii",
|
|
3203
|
+
"sensitive-file-read": "sensitive-file-read",
|
|
3204
|
+
"privilege-escalation": "privilege-escalation",
|
|
3205
|
+
"destructive-op": "destructive-op",
|
|
3206
|
+
"pipe-to-shell": "pipe-to-shell",
|
|
3207
|
+
"eval-of-remote": "eval-of-remote",
|
|
3208
|
+
loop: "loop",
|
|
3209
|
+
"long-output-redacted": "long-output-redacted"
|
|
3210
|
+
};
|
|
3211
|
+
const sfType = typeMap[c.type];
|
|
3212
|
+
if (sfType === null) return null;
|
|
3213
|
+
return {
|
|
3214
|
+
sessionId: c.sessionId,
|
|
3215
|
+
type: sfType,
|
|
3216
|
+
...c.patternName && { patternName: c.patternName },
|
|
3217
|
+
lineIndex: c.lineIndex
|
|
3218
|
+
};
|
|
3219
|
+
}
|
|
3220
|
+
var TERMINAL_ESCAPE_RE = (
|
|
3221
|
+
// eslint-disable-next-line no-control-regex
|
|
3222
|
+
/\x1b\[[0-9;?]*[A-Za-z]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[@-_]|[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g
|
|
3223
|
+
);
|
|
3224
|
+
function previewArgs(input, max) {
|
|
3225
|
+
const cmd = input.command ?? input.query ?? input.file_path ?? JSON.stringify(input);
|
|
3226
|
+
const s = String(cmd).replace(TERMINAL_ESCAPE_RE, "").replace(/\s+/g, " ").trim();
|
|
3227
|
+
return s.length > max ? s.slice(0, max - 1) + "\u2026" : s;
|
|
3228
|
+
}
|
|
3229
|
+
function makeFinding(args) {
|
|
3230
|
+
const f = {
|
|
3231
|
+
type: args.type,
|
|
3232
|
+
ruleName: args.ruleName,
|
|
3233
|
+
verdict: args.verdict,
|
|
3234
|
+
severity: args.severity,
|
|
3235
|
+
reason: args.reason,
|
|
3236
|
+
toolName: args.toolName,
|
|
3237
|
+
agent: args.ctx.agent,
|
|
3238
|
+
sessionId: args.ctx.sessionId,
|
|
3239
|
+
project: args.ctx.project,
|
|
3240
|
+
lineIndex: args.ctx.lineIndex,
|
|
3241
|
+
sourceType: args.sourceType,
|
|
3242
|
+
firstSeenAt: args.ts,
|
|
3243
|
+
lastSeenAt: args.ts,
|
|
3244
|
+
occurrenceCount: 1
|
|
3245
|
+
};
|
|
3246
|
+
if (args.shieldLabel) f.shieldLabel = args.shieldLabel;
|
|
3247
|
+
if (args.subjectPath) f.subjectPath = args.subjectPath;
|
|
3248
|
+
if (args.input) f.input = args.input;
|
|
3249
|
+
if (args.patternName) f.patternName = args.patternName;
|
|
3250
|
+
if (args.redactedSample) f.redactedSample = args.redactedSample;
|
|
3251
|
+
return f;
|
|
3252
|
+
}
|
|
3253
|
+
function* stringValues(obj, depth = 0) {
|
|
3254
|
+
if (depth > 6) return;
|
|
3255
|
+
if (typeof obj === "string") {
|
|
3256
|
+
if (obj.length > 0) yield obj;
|
|
3257
|
+
return;
|
|
3258
|
+
}
|
|
3259
|
+
if (!obj || typeof obj !== "object") return;
|
|
3260
|
+
if (Array.isArray(obj)) {
|
|
3261
|
+
for (const v of obj) yield* stringValues(v, depth + 1);
|
|
3262
|
+
return;
|
|
3263
|
+
}
|
|
3264
|
+
for (const v of Object.values(obj)) yield* stringValues(v, depth + 1);
|
|
3265
|
+
}
|
|
3266
|
+
|
|
2464
3267
|
// src/index.ts
|
|
2465
3268
|
var ENGINE_VERSION = "1.4.0";
|
|
2466
3269
|
export {
|
|
3270
|
+
AST_FS_REGEX_RULES,
|
|
3271
|
+
BASH_TOOL_NAMES,
|
|
2467
3272
|
BUILTIN_SHIELDS,
|
|
3273
|
+
CANONICAL_EXTRACTOR_HASH,
|
|
3274
|
+
CANONICAL_EXTRACTOR_VERSION,
|
|
2468
3275
|
COST_PER_LOOP_ITER_USD,
|
|
3276
|
+
DESTRUCTIVE_OP_RE,
|
|
2469
3277
|
DLP_PATTERNS,
|
|
2470
3278
|
ENGINE_VERSION,
|
|
3279
|
+
FILE_TOOLS,
|
|
2471
3280
|
FLAGS_WITH_VALUES,
|
|
3281
|
+
LONG_OUTPUT_THRESHOLD_BYTES,
|
|
2472
3282
|
LOOP_MAX_RECORDS,
|
|
2473
3283
|
LOOP_THRESHOLD_FOR_WASTE,
|
|
3284
|
+
PRIVILEGE_ESCALATION_RE,
|
|
2474
3285
|
SCAN_SIGNAL_WEIGHTS,
|
|
3286
|
+
SENSITIVE_PATH_RE,
|
|
2475
3287
|
SENSITIVE_PATH_REGEXES,
|
|
3288
|
+
analyzeFsOperation,
|
|
2476
3289
|
analyzePipeChain,
|
|
2477
3290
|
analyzeShellCommand,
|
|
2478
3291
|
checkDangerousSql,
|
|
@@ -2483,29 +3296,37 @@ export {
|
|
|
2483
3296
|
computeBlendedSecurityScore,
|
|
2484
3297
|
computeScanScore,
|
|
2485
3298
|
computeSecurityScore,
|
|
3299
|
+
dedupeCanonicalFindings,
|
|
2486
3300
|
detectDangerousEval,
|
|
2487
3301
|
detectDangerousShellExec,
|
|
3302
|
+
detectPii,
|
|
2488
3303
|
evaluateLoopWindow,
|
|
2489
3304
|
evaluatePolicy,
|
|
2490
3305
|
evaluateSmartConditions,
|
|
2491
3306
|
extractAllSshHosts,
|
|
3307
|
+
extractCanonicalFindings,
|
|
2492
3308
|
extractNetworkTargets,
|
|
2493
3309
|
extractPositionalArgs,
|
|
3310
|
+
extractSessionLevelFindings,
|
|
2494
3311
|
getCompiledRegex,
|
|
2495
3312
|
getNestedValue,
|
|
3313
|
+
isBashTool,
|
|
2496
3314
|
isIgnoredTool,
|
|
3315
|
+
isProtectedHomePath,
|
|
2497
3316
|
isShieldVerdict,
|
|
2498
3317
|
matchSensitivePath,
|
|
2499
3318
|
matchesPattern,
|
|
2500
3319
|
narrativeRuleLabel,
|
|
2501
3320
|
normalizeCommandForPolicy,
|
|
2502
3321
|
parseAllSshHostsFromCommand,
|
|
3322
|
+
previewArgs,
|
|
2503
3323
|
redactText,
|
|
2504
3324
|
scanArgs,
|
|
2505
3325
|
scanText,
|
|
2506
3326
|
sensitivePathMatch,
|
|
2507
3327
|
summarizeBlast,
|
|
2508
3328
|
summarizeScan,
|
|
3329
|
+
toScanFinding,
|
|
2509
3330
|
truncateBlastPath,
|
|
2510
3331
|
validateOverrides,
|
|
2511
3332
|
validateRegex,
|