@node9/policy-engine 1.0.0 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.mts +550 -17
- package/dist/index.d.ts +550 -17
- package/dist/index.js +1160 -59
- package/dist/index.mjs +1129 -59
- package/package.json +2 -2
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 "****";
|
|
@@ -603,9 +621,70 @@ var MESSAGE_FLAGS = /* @__PURE__ */ new Set([
|
|
|
603
621
|
]);
|
|
604
622
|
var SHELL_INTERPRETERS = /* @__PURE__ */ new Set(["bash", "sh", "zsh", "fish", "dash", "ksh"]);
|
|
605
623
|
var DOWNLOAD_CMDS = /* @__PURE__ */ new Set(["curl", "wget"]);
|
|
624
|
+
function isCatHeredocOrLit(part) {
|
|
625
|
+
if (!part) return false;
|
|
626
|
+
const t = syntax.NodeType(part);
|
|
627
|
+
if (t === "Lit") return true;
|
|
628
|
+
if (t !== "CmdSubst") return false;
|
|
629
|
+
const stmts = part.Stmts || [];
|
|
630
|
+
if (stmts.length !== 1) return false;
|
|
631
|
+
const stmt = stmts[0];
|
|
632
|
+
const redirs = stmt.Redirs || stmt.Cmd?.Redirs || [];
|
|
633
|
+
const hasHeredoc = redirs.some((r) => r && r.Hdoc);
|
|
634
|
+
if (!hasHeredoc) return false;
|
|
635
|
+
const cmd = stmt.Cmd;
|
|
636
|
+
if (!cmd || syntax.NodeType(cmd) !== "CallExpr") return false;
|
|
637
|
+
const firstArg = cmd.Args?.[0]?.Parts || [];
|
|
638
|
+
if (firstArg.length !== 1 || syntax.NodeType(firstArg[0]) !== "Lit") return false;
|
|
639
|
+
return (firstArg[0].Value || "").toLowerCase() === "cat";
|
|
640
|
+
}
|
|
641
|
+
var NORMALIZE_CACHE_MAX = 5e3;
|
|
642
|
+
var normalizeCache = /* @__PURE__ */ new Map();
|
|
643
|
+
var AST_CACHE_MAX = 5e3;
|
|
644
|
+
var astCache = /* @__PURE__ */ new Map();
|
|
645
|
+
var PARSE_FAIL = /* @__PURE__ */ Symbol("parse-fail");
|
|
646
|
+
function parseShared(command) {
|
|
647
|
+
const cached = astCache.get(command);
|
|
648
|
+
if (cached !== void 0) {
|
|
649
|
+
astCache.delete(command);
|
|
650
|
+
astCache.set(command, cached);
|
|
651
|
+
return cached;
|
|
652
|
+
}
|
|
653
|
+
let parsed;
|
|
654
|
+
try {
|
|
655
|
+
parsed = sharedParser.Parse(command, "cmd");
|
|
656
|
+
} catch {
|
|
657
|
+
parsed = PARSE_FAIL;
|
|
658
|
+
}
|
|
659
|
+
if (astCache.size >= AST_CACHE_MAX) {
|
|
660
|
+
const oldest = astCache.keys().next().value;
|
|
661
|
+
if (oldest !== void 0) astCache.delete(oldest);
|
|
662
|
+
}
|
|
663
|
+
astCache.set(command, parsed);
|
|
664
|
+
return parsed;
|
|
665
|
+
}
|
|
666
|
+
function cachedNormalize(command, compute) {
|
|
667
|
+
const hit = normalizeCache.get(command);
|
|
668
|
+
if (hit !== void 0) {
|
|
669
|
+
normalizeCache.delete(command);
|
|
670
|
+
normalizeCache.set(command, hit);
|
|
671
|
+
return hit;
|
|
672
|
+
}
|
|
673
|
+
const result = compute();
|
|
674
|
+
if (normalizeCache.size >= NORMALIZE_CACHE_MAX) {
|
|
675
|
+
const oldest = normalizeCache.keys().next().value;
|
|
676
|
+
if (oldest !== void 0) normalizeCache.delete(oldest);
|
|
677
|
+
}
|
|
678
|
+
normalizeCache.set(command, result);
|
|
679
|
+
return result;
|
|
680
|
+
}
|
|
606
681
|
function normalizeCommandForPolicy(command) {
|
|
682
|
+
return cachedNormalize(command, () => normalizeCommandForPolicyImpl(command));
|
|
683
|
+
}
|
|
684
|
+
function normalizeCommandForPolicyImpl(command) {
|
|
685
|
+
const f = parseShared(command);
|
|
686
|
+
if (f === PARSE_FAIL) return command;
|
|
607
687
|
try {
|
|
608
|
-
const f = sharedParser.Parse(command, "cmd");
|
|
609
688
|
const strips = [];
|
|
610
689
|
syntax.Walk(f, (node) => {
|
|
611
690
|
if (!node) return false;
|
|
@@ -627,7 +706,11 @@ function normalizeCommandForPolicy(command) {
|
|
|
627
706
|
} else if (nt === "DblQuoted") {
|
|
628
707
|
const innerParts = quotedNode.Parts || [];
|
|
629
708
|
const allLit = innerParts.length === 0 || innerParts.every((p) => syntax.NodeType(p) === "Lit");
|
|
630
|
-
if (allLit)
|
|
709
|
+
if (allLit) {
|
|
710
|
+
strips.push([next.Pos().Offset(), next.End().Offset()]);
|
|
711
|
+
} else if (innerParts.every((p) => isCatHeredocOrLit(p))) {
|
|
712
|
+
strips.push([next.Pos().Offset(), next.End().Offset()]);
|
|
713
|
+
}
|
|
631
714
|
}
|
|
632
715
|
}
|
|
633
716
|
return true;
|
|
@@ -696,6 +779,242 @@ function detectDangerousShellExec(command) {
|
|
|
696
779
|
}
|
|
697
780
|
}
|
|
698
781
|
var detectDangerousEval = detectDangerousShellExec;
|
|
782
|
+
var FS_READ_TOOLS = /* @__PURE__ */ new Set([
|
|
783
|
+
"cat",
|
|
784
|
+
"less",
|
|
785
|
+
"head",
|
|
786
|
+
"tail",
|
|
787
|
+
"bat",
|
|
788
|
+
"more",
|
|
789
|
+
"open",
|
|
790
|
+
"print",
|
|
791
|
+
"nano",
|
|
792
|
+
"vim",
|
|
793
|
+
"vi",
|
|
794
|
+
"emacs",
|
|
795
|
+
"code",
|
|
796
|
+
"type"
|
|
797
|
+
]);
|
|
798
|
+
var FS_OP_PRESCREEN_RE = /(?:^|[\s|;&(`\n])(?:rm|cat|less|head|tail|bat|more|open|print|nano|vim|vi|emacs|code|type)\b/;
|
|
799
|
+
var HOME_CACHE_ALLOWLIST = [
|
|
800
|
+
".cache",
|
|
801
|
+
".npm/_npx",
|
|
802
|
+
".npm/_cacache",
|
|
803
|
+
".cargo/registry",
|
|
804
|
+
".gradle/caches",
|
|
805
|
+
".gradle/.tmp",
|
|
806
|
+
".m2/repository",
|
|
807
|
+
".pnpm-store",
|
|
808
|
+
".yarn/cache",
|
|
809
|
+
".yarn/.cache",
|
|
810
|
+
".cache/pip",
|
|
811
|
+
".local/share/Trash",
|
|
812
|
+
".rustup/downloads"
|
|
813
|
+
];
|
|
814
|
+
var SENSITIVE_PATH_RULES = [
|
|
815
|
+
{
|
|
816
|
+
rule: "shield:project-jail:block-read-ssh",
|
|
817
|
+
reason: "Reading SSH private keys is blocked by project-jail shield",
|
|
818
|
+
match: (p) => /(^|[\\/])\.ssh[\\/]/i.test(p)
|
|
819
|
+
},
|
|
820
|
+
{
|
|
821
|
+
rule: "shield:project-jail:block-read-aws",
|
|
822
|
+
reason: "Reading AWS credentials is blocked by project-jail shield",
|
|
823
|
+
match: (p) => /(^|[\\/])\.aws[\\/]/i.test(p)
|
|
824
|
+
},
|
|
825
|
+
{
|
|
826
|
+
// Mirrors the JSON shield's `.env` pattern (project-jail.json's
|
|
827
|
+
// review-read-env-any-tool) so the AST FS-op path catches the
|
|
828
|
+
// same set the regex shield does — including Next.js / Vite's
|
|
829
|
+
// `.env.<env>.local` double-suffix overrides which are commonly
|
|
830
|
+
// gitignored AND commonly contain real secrets.
|
|
831
|
+
//
|
|
832
|
+
// Intentional non-matches (dev fixtures): .env.example, .env.sample,
|
|
833
|
+
// .env.template, .env.test, .envrc. See shields.test.ts:983-995
|
|
834
|
+
// for the canonical test-asserted contract.
|
|
835
|
+
rule: "shield:project-jail:block-read-env",
|
|
836
|
+
reason: "Reading .env files is blocked by project-jail shield",
|
|
837
|
+
match: (p) => /(?:^|[\\/])\.env(?:\.(?:local|production|staging|development|production\.local|staging\.local|development\.local))?$/i.test(
|
|
838
|
+
p
|
|
839
|
+
)
|
|
840
|
+
},
|
|
841
|
+
{
|
|
842
|
+
// verdict: 'review' (not 'block') is a deliberate design choice
|
|
843
|
+
// documented in commit 29327a8. SSH keys and AWS credentials are
|
|
844
|
+
// cryptographic material with no legitimate read use-case for
|
|
845
|
+
// an AI agent → hard `block`. But .netrc / .npmrc / .docker /
|
|
846
|
+
// .kube / gcloud are CONFIG files that hold tokens AND have
|
|
847
|
+
// legitimate diagnostic reads ("which registry am I configured
|
|
848
|
+
// for", "what cluster am I on"). Hard-blocking those creates
|
|
849
|
+
// friction without much safety win because the review gate
|
|
850
|
+
// still catches genuine exfiltration attempts.
|
|
851
|
+
//
|
|
852
|
+
// The review gate FAILS CLOSED on timeout (daemon.approvalTimeoutMs
|
|
853
|
+
// returns a deny verdict via the orchestrator's timeout branch),
|
|
854
|
+
// so a stuck or unattended approval does NOT silently grant
|
|
855
|
+
// credential access. If the threat model demands strict block,
|
|
856
|
+
// a future per-shield strict-mode toggle is the right fix —
|
|
857
|
+
// not a regex-level upgrade here.
|
|
858
|
+
rule: "shield:project-jail:review-read-credentials",
|
|
859
|
+
reason: "Reading credential files requires approval (project-jail shield)",
|
|
860
|
+
verdict: "review",
|
|
861
|
+
match: (p) => (
|
|
862
|
+
// .kube/config holds Kubernetes cluster credentials and was
|
|
863
|
+
// flagged as missing by the node9-pr-agent review (the comment
|
|
864
|
+
// above mentioned .kube but the regex didn't include it — a
|
|
865
|
+
// textbook code-comment vs code drift). The JSON shield's
|
|
866
|
+
// review-read-credentials-any-tool already had it. Now aligned.
|
|
867
|
+
/(?:credentials\.json|\.netrc|\.npmrc|\.docker[\\/]config\.json|gcloud[\\/]credentials|\.kube[\\/]config)$/i.test(
|
|
868
|
+
p
|
|
869
|
+
)
|
|
870
|
+
)
|
|
871
|
+
}
|
|
872
|
+
];
|
|
873
|
+
var BASH_TOOL_NAMES = /* @__PURE__ */ new Set([
|
|
874
|
+
"bash",
|
|
875
|
+
"execute_bash",
|
|
876
|
+
"run_shell_command",
|
|
877
|
+
"shell",
|
|
878
|
+
"exec_command"
|
|
879
|
+
]);
|
|
880
|
+
function isBashTool(toolName) {
|
|
881
|
+
return BASH_TOOL_NAMES.has(toolName.toLowerCase());
|
|
882
|
+
}
|
|
883
|
+
var AST_FS_REGEX_RULES = /* @__PURE__ */ new Set([
|
|
884
|
+
"block-rm-rf-home",
|
|
885
|
+
"shield:project-jail:block-read-ssh",
|
|
886
|
+
"shield:project-jail:block-read-aws",
|
|
887
|
+
"shield:project-jail:block-read-env",
|
|
888
|
+
"shield:project-jail:review-read-credentials"
|
|
889
|
+
]);
|
|
890
|
+
function isProtectedHomePath(rawPath) {
|
|
891
|
+
let p = rawPath.replace(/^\$HOME[\\/]?|^\$\{HOME\}[\\/]?/, "~/");
|
|
892
|
+
let underHome = false;
|
|
893
|
+
if (p === "~" || p.startsWith("~/") || p.startsWith("~\\")) {
|
|
894
|
+
p = p.replace(/^~[\\/]?/, "");
|
|
895
|
+
underHome = true;
|
|
896
|
+
} else if (/^\/home\/[^/]+/.test(p) || /^\/root(\/|$)/.test(p)) {
|
|
897
|
+
p = p.replace(/^\/home\/[^/]+[\\/]?|^\/root[\\/]?/, "");
|
|
898
|
+
underHome = true;
|
|
899
|
+
}
|
|
900
|
+
if (!underHome) return false;
|
|
901
|
+
if (p === "" || p === "." || p === "./") return true;
|
|
902
|
+
for (const safe of HOME_CACHE_ALLOWLIST) {
|
|
903
|
+
if (p === safe || p.startsWith(safe + "/") || p.startsWith(safe + "\\")) {
|
|
904
|
+
return false;
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
return true;
|
|
908
|
+
}
|
|
909
|
+
function extractLiteralArgs(callExpr) {
|
|
910
|
+
const args = callExpr.Args || [];
|
|
911
|
+
if (args.length === 0) return { name: "", flags: [], paths: [] };
|
|
912
|
+
const litFromWord = (w) => {
|
|
913
|
+
const parts = w?.Parts || [];
|
|
914
|
+
let s = "";
|
|
915
|
+
for (const p of parts) {
|
|
916
|
+
const t = syntax.NodeType(p);
|
|
917
|
+
if (t === "Lit") s += (p.Value ?? "").replace(/\\(.)/g, "$1");
|
|
918
|
+
else if (t === "SglQuoted") s += p.Value ?? "";
|
|
919
|
+
else if (t === "DblQuoted") {
|
|
920
|
+
const inner = p.Parts || [];
|
|
921
|
+
if (!inner.every((ip) => syntax.NodeType(ip) === "Lit")) return null;
|
|
922
|
+
s += inner.map((ip) => ip.Value ?? "").join("");
|
|
923
|
+
} else {
|
|
924
|
+
return null;
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
return s;
|
|
928
|
+
};
|
|
929
|
+
const name = (litFromWord(args[0]) || "").toLowerCase();
|
|
930
|
+
const flags = [];
|
|
931
|
+
const paths = [];
|
|
932
|
+
for (let i = 1; i < args.length; i++) {
|
|
933
|
+
const v = litFromWord(args[i]);
|
|
934
|
+
if (v === null) continue;
|
|
935
|
+
if (v.startsWith("-")) flags.push(v);
|
|
936
|
+
else paths.push(v);
|
|
937
|
+
}
|
|
938
|
+
return { name, flags, paths };
|
|
939
|
+
}
|
|
940
|
+
var FS_OP_CACHE_MAX = 5e3;
|
|
941
|
+
var fsOpCache = /* @__PURE__ */ new Map();
|
|
942
|
+
function analyzeFsOperation(command) {
|
|
943
|
+
if (!FS_OP_PRESCREEN_RE.test(command)) return null;
|
|
944
|
+
if (fsOpCache.has(command)) {
|
|
945
|
+
const hit = fsOpCache.get(command) ?? null;
|
|
946
|
+
fsOpCache.delete(command);
|
|
947
|
+
fsOpCache.set(command, hit);
|
|
948
|
+
return hit;
|
|
949
|
+
}
|
|
950
|
+
const computed = analyzeFsOperationImpl(command);
|
|
951
|
+
if (fsOpCache.size >= FS_OP_CACHE_MAX) {
|
|
952
|
+
const oldest = fsOpCache.keys().next().value;
|
|
953
|
+
if (oldest !== void 0) fsOpCache.delete(oldest);
|
|
954
|
+
}
|
|
955
|
+
fsOpCache.set(command, computed);
|
|
956
|
+
return computed;
|
|
957
|
+
}
|
|
958
|
+
function analyzeFsOperationImpl(command) {
|
|
959
|
+
const f = parseShared(command);
|
|
960
|
+
if (f === PARSE_FAIL) return null;
|
|
961
|
+
let result = null;
|
|
962
|
+
try {
|
|
963
|
+
syntax.Walk(f, (node) => {
|
|
964
|
+
if (!node || result) return false;
|
|
965
|
+
const n = node;
|
|
966
|
+
if (syntax.NodeType(n) !== "CallExpr") return true;
|
|
967
|
+
const { name, flags, paths } = extractLiteralArgs(n);
|
|
968
|
+
if (!name) return true;
|
|
969
|
+
if (name === "rm") {
|
|
970
|
+
const flagStr = flags.join("").toLowerCase();
|
|
971
|
+
const hasR = /[r]/.test(flagStr) || flags.includes("--recursive");
|
|
972
|
+
const hasF = /[f]/.test(flagStr) || flags.includes("--force");
|
|
973
|
+
if (hasR && hasF) {
|
|
974
|
+
for (const p of paths) {
|
|
975
|
+
if (isProtectedHomePath(p)) {
|
|
976
|
+
result = {
|
|
977
|
+
ruleName: "block-rm-rf-home",
|
|
978
|
+
verdict: "block",
|
|
979
|
+
reason: "Recursive delete of home directory is irreversible",
|
|
980
|
+
path: p
|
|
981
|
+
};
|
|
982
|
+
return false;
|
|
983
|
+
}
|
|
984
|
+
if (p === "/" || /^\/+$/.test(p)) {
|
|
985
|
+
result = {
|
|
986
|
+
ruleName: "block-rm-rf-home",
|
|
987
|
+
verdict: "block",
|
|
988
|
+
reason: "Recursive delete of root is catastrophic",
|
|
989
|
+
path: p
|
|
990
|
+
};
|
|
991
|
+
return false;
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
if (FS_READ_TOOLS.has(name)) {
|
|
997
|
+
for (const p of paths) {
|
|
998
|
+
for (const sp of SENSITIVE_PATH_RULES) {
|
|
999
|
+
if (sp.match(p)) {
|
|
1000
|
+
result = {
|
|
1001
|
+
ruleName: sp.rule,
|
|
1002
|
+
verdict: sp.verdict ?? "block",
|
|
1003
|
+
reason: sp.reason,
|
|
1004
|
+
path: p
|
|
1005
|
+
};
|
|
1006
|
+
return false;
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
return true;
|
|
1012
|
+
});
|
|
1013
|
+
return result;
|
|
1014
|
+
} catch {
|
|
1015
|
+
return null;
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
699
1018
|
function analyzeShellCommand(command) {
|
|
700
1019
|
const actions = [];
|
|
701
1020
|
const paths = [];
|
|
@@ -1073,7 +1392,7 @@ function parseAllSshHostsFromCommand(command) {
|
|
|
1073
1392
|
import pm from "picomatch";
|
|
1074
1393
|
|
|
1075
1394
|
// src/utils/regex.ts
|
|
1076
|
-
import
|
|
1395
|
+
import safeRegex2 from "safe-regex2";
|
|
1077
1396
|
var MAX_REGEX_LENGTH = 100;
|
|
1078
1397
|
var REGEX_CACHE_MAX = 500;
|
|
1079
1398
|
var regexCache = /* @__PURE__ */ new Map();
|
|
@@ -1086,7 +1405,7 @@ function validateRegex(pattern) {
|
|
|
1086
1405
|
return `Invalid regex syntax: ${e.message}`;
|
|
1087
1406
|
}
|
|
1088
1407
|
if (/\\\d+[*+{]/.test(pattern)) return "Quantified backreferences are forbidden (ReDoS risk)";
|
|
1089
|
-
if (!
|
|
1408
|
+
if (!safeRegex2(pattern)) return "Pattern rejected: potential ReDoS vulnerability detected";
|
|
1090
1409
|
return null;
|
|
1091
1410
|
}
|
|
1092
1411
|
function getCompiledRegex(pattern, flags = "") {
|
|
@@ -1123,17 +1442,30 @@ function matchesPattern(text, patterns) {
|
|
|
1123
1442
|
const withoutDotSlash = text.replace(/^\.\//, "");
|
|
1124
1443
|
return isMatch(withoutDotSlash) || isMatch(`./${withoutDotSlash}`);
|
|
1125
1444
|
}
|
|
1445
|
+
var FORBIDDEN_PATH_SEGMENTS = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype"]);
|
|
1126
1446
|
function getNestedValue(obj, path) {
|
|
1127
1447
|
if (!obj || typeof obj !== "object") return null;
|
|
1128
|
-
|
|
1448
|
+
const segments = path.split(".");
|
|
1449
|
+
for (const seg of segments) {
|
|
1450
|
+
if (FORBIDDEN_PATH_SEGMENTS.has(seg)) return null;
|
|
1451
|
+
}
|
|
1452
|
+
return segments.reduce((prev, curr) => prev?.[curr], obj);
|
|
1129
1453
|
}
|
|
1130
1454
|
function evaluateSmartConditions(args, rule) {
|
|
1131
1455
|
if (!rule.conditions || rule.conditions.length === 0) return true;
|
|
1132
1456
|
const mode = rule.conditionMode ?? "all";
|
|
1457
|
+
const fieldCache = /* @__PURE__ */ new Map();
|
|
1458
|
+
const resolveField = (field) => {
|
|
1459
|
+
if (fieldCache.has(field)) return fieldCache.get(field) ?? null;
|
|
1460
|
+
const rawVal = getNestedValue(args, field);
|
|
1461
|
+
const rawStr = rawVal !== null && rawVal !== void 0 ? String(rawVal) : null;
|
|
1462
|
+
const stripped = field === "command" && rawStr !== null ? normalizeCommandForPolicy(rawStr) : rawStr;
|
|
1463
|
+
const val = stripped !== null ? stripped.replace(/\s+/g, " ").trim() : null;
|
|
1464
|
+
fieldCache.set(field, val);
|
|
1465
|
+
return val;
|
|
1466
|
+
};
|
|
1133
1467
|
const results = rule.conditions.map((cond) => {
|
|
1134
|
-
const
|
|
1135
|
-
const normalized = rawVal !== null && rawVal !== void 0 ? String(rawVal).replace(/\s+/g, " ").trim() : null;
|
|
1136
|
-
const val = cond.field === "command" && normalized !== null ? normalizeCommandForPolicy(normalized) : normalized;
|
|
1468
|
+
const val = resolveField(cond.field);
|
|
1137
1469
|
switch (cond.op) {
|
|
1138
1470
|
case "exists":
|
|
1139
1471
|
return val !== null && val !== "";
|
|
@@ -1196,6 +1528,43 @@ function checkDangerousSql(sql) {
|
|
|
1196
1528
|
return "UPDATE without WHERE \u2014 updates every row";
|
|
1197
1529
|
return null;
|
|
1198
1530
|
}
|
|
1531
|
+
function pipeChainVerdict(command, isTrustedHost) {
|
|
1532
|
+
const pipeAnalysis = analyzePipeChain(command);
|
|
1533
|
+
if (!pipeAnalysis.isPipeline) return null;
|
|
1534
|
+
if (pipeAnalysis.risk !== "critical" && pipeAnalysis.risk !== "high") return null;
|
|
1535
|
+
const sinks = pipeAnalysis.sinkTargets;
|
|
1536
|
+
const allTrusted = sinks.length > 0 && sinks.every((host) => isTrustedHost ? isTrustedHost(host) : false);
|
|
1537
|
+
if (pipeAnalysis.risk === "critical") {
|
|
1538
|
+
if (allTrusted) {
|
|
1539
|
+
return {
|
|
1540
|
+
decision: "review",
|
|
1541
|
+
blockedByLabel: "Node9: Pipe-Chain to Trusted Host (obfuscated)",
|
|
1542
|
+
reason: `Obfuscated pipe to trusted host(s): ${sinks.join(", ")} \u2014 requires approval`,
|
|
1543
|
+
tier: 3
|
|
1544
|
+
};
|
|
1545
|
+
}
|
|
1546
|
+
return {
|
|
1547
|
+
decision: "block",
|
|
1548
|
+
blockedByLabel: "Node9: Pipe-Chain Exfiltration (critical)",
|
|
1549
|
+
reason: `Sensitive file piped through obfuscator to network sink: ${pipeAnalysis.sourceFiles.join(", ")} \u2192 ${sinks.join(", ")}`,
|
|
1550
|
+
tier: 3
|
|
1551
|
+
};
|
|
1552
|
+
}
|
|
1553
|
+
if (allTrusted) {
|
|
1554
|
+
return {
|
|
1555
|
+
decision: "allow",
|
|
1556
|
+
blockedByLabel: "Node9: Pipe-Chain to Trusted Host",
|
|
1557
|
+
reason: `Sensitive file piped to trusted host(s): ${sinks.join(", ")}`,
|
|
1558
|
+
tier: 3
|
|
1559
|
+
};
|
|
1560
|
+
}
|
|
1561
|
+
return {
|
|
1562
|
+
decision: "review",
|
|
1563
|
+
blockedByLabel: "Node9: Pipe-Chain Exfiltration (high)",
|
|
1564
|
+
reason: `Sensitive file piped to network sink: ${pipeAnalysis.sourceFiles.join(", ")} \u2192 ${sinks.join(", ")}`,
|
|
1565
|
+
tier: 3
|
|
1566
|
+
};
|
|
1567
|
+
}
|
|
1199
1568
|
async function evaluatePolicy(config, toolName, args, context = {}, hooks = {}) {
|
|
1200
1569
|
const { agent, cwd, activeEnvironment } = context;
|
|
1201
1570
|
const { checkProvenance, isTrustedHost } = hooks;
|
|
@@ -1211,9 +1580,27 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
|
|
|
1211
1580
|
}
|
|
1212
1581
|
}
|
|
1213
1582
|
if (wouldBeIgnored) return { decision: "allow" };
|
|
1583
|
+
const bashCommand = agent !== "Terminal" && isBashTool(toolName) && args && typeof args === "object" ? typeof args.command === "string" ? args.command : null : null;
|
|
1584
|
+
if (bashCommand !== null) {
|
|
1585
|
+
const pipeVerdict = pipeChainVerdict(bashCommand, isTrustedHost);
|
|
1586
|
+
if (pipeVerdict) return pipeVerdict;
|
|
1587
|
+
const fsVerdict = analyzeFsOperation(bashCommand);
|
|
1588
|
+
if (fsVerdict) {
|
|
1589
|
+
const isShieldRule = fsVerdict.ruleName.startsWith("shield:");
|
|
1590
|
+
const labelPrefix = isShieldRule ? "project-jail (AST)" : "Node9 (AST)";
|
|
1591
|
+
return {
|
|
1592
|
+
decision: fsVerdict.verdict,
|
|
1593
|
+
blockedByLabel: `${labelPrefix}: ${fsVerdict.ruleName}`,
|
|
1594
|
+
reason: fsVerdict.reason,
|
|
1595
|
+
tier: 2,
|
|
1596
|
+
ruleName: fsVerdict.ruleName,
|
|
1597
|
+
ruleDescription: fsVerdict.reason
|
|
1598
|
+
};
|
|
1599
|
+
}
|
|
1600
|
+
}
|
|
1214
1601
|
if (config.policy.smartRules.length > 0) {
|
|
1215
1602
|
const matchedRule = config.policy.smartRules.find(
|
|
1216
|
-
(rule) => matchesPattern(toolName, rule.tool) && evaluateSmartConditions(args, rule)
|
|
1603
|
+
(rule) => matchesPattern(toolName, rule.tool) && !(bashCommand !== null && rule.name && AST_FS_REGEX_RULES.has(rule.name)) && evaluateSmartConditions(args, rule)
|
|
1217
1604
|
);
|
|
1218
1605
|
if (matchedRule) {
|
|
1219
1606
|
if (matchedRule.verdict === "allow")
|
|
@@ -1271,41 +1658,8 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
|
|
|
1271
1658
|
tier: 3
|
|
1272
1659
|
};
|
|
1273
1660
|
}
|
|
1274
|
-
const
|
|
1275
|
-
if (
|
|
1276
|
-
const sinks = pipeAnalysis.sinkTargets;
|
|
1277
|
-
const allTrusted = sinks.length > 0 && sinks.every((host) => isTrustedHost ? isTrustedHost(host) : false);
|
|
1278
|
-
if (pipeAnalysis.risk === "critical") {
|
|
1279
|
-
if (allTrusted) {
|
|
1280
|
-
return {
|
|
1281
|
-
decision: "review",
|
|
1282
|
-
blockedByLabel: "Node9: Pipe-Chain to Trusted Host (obfuscated)",
|
|
1283
|
-
reason: `Obfuscated pipe to trusted host(s): ${sinks.join(", ")} \u2014 requires approval`,
|
|
1284
|
-
tier: 3
|
|
1285
|
-
};
|
|
1286
|
-
}
|
|
1287
|
-
return {
|
|
1288
|
-
decision: "block",
|
|
1289
|
-
blockedByLabel: "Node9: Pipe-Chain Exfiltration (critical)",
|
|
1290
|
-
reason: `Sensitive file piped through obfuscator to network sink: ${pipeAnalysis.sourceFiles.join(", ")} \u2192 ${sinks.join(", ")}`,
|
|
1291
|
-
tier: 3
|
|
1292
|
-
};
|
|
1293
|
-
}
|
|
1294
|
-
if (allTrusted) {
|
|
1295
|
-
return {
|
|
1296
|
-
decision: "allow",
|
|
1297
|
-
blockedByLabel: "Node9: Pipe-Chain to Trusted Host",
|
|
1298
|
-
reason: `Sensitive file piped to trusted host(s): ${sinks.join(", ")}`,
|
|
1299
|
-
tier: 3
|
|
1300
|
-
};
|
|
1301
|
-
}
|
|
1302
|
-
return {
|
|
1303
|
-
decision: "review",
|
|
1304
|
-
blockedByLabel: "Node9: Pipe-Chain Exfiltration (high)",
|
|
1305
|
-
reason: `Sensitive file piped to network sink: ${pipeAnalysis.sourceFiles.join(", ")} \u2192 ${sinks.join(", ")}`,
|
|
1306
|
-
tier: 3
|
|
1307
|
-
};
|
|
1308
|
-
}
|
|
1661
|
+
const ptVerdict = pipeChainVerdict(shellCommand, isTrustedHost);
|
|
1662
|
+
if (ptVerdict) return ptVerdict;
|
|
1309
1663
|
const firstToken = analyzed.actions[0] ?? "";
|
|
1310
1664
|
if (["ssh", "scp", "rsync"].includes(firstToken)) {
|
|
1311
1665
|
const rawTokens = shellCommand.trim().split(/\s+/);
|
|
@@ -1411,6 +1765,9 @@ function isIgnoredTool(toolName, config) {
|
|
|
1411
1765
|
return matchesPattern(toolName, config.policy.ignoredTools);
|
|
1412
1766
|
}
|
|
1413
1767
|
|
|
1768
|
+
// src/shields/index.ts
|
|
1769
|
+
import safeRegex3 from "safe-regex2";
|
|
1770
|
+
|
|
1414
1771
|
// src/shields/builtin/aws.json
|
|
1415
1772
|
var aws_default = {
|
|
1416
1773
|
name: "aws",
|
|
@@ -1842,15 +2199,6 @@ var k8s_default = {
|
|
|
1842
2199
|
dangerousWords: []
|
|
1843
2200
|
};
|
|
1844
2201
|
|
|
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
2202
|
// src/shields/builtin/mongodb.json
|
|
1855
2203
|
var mongodb_default = {
|
|
1856
2204
|
name: "mongodb",
|
|
@@ -1988,7 +2336,7 @@ var project_jail_default = {
|
|
|
1988
2336
|
{
|
|
1989
2337
|
field: "command",
|
|
1990
2338
|
op: "matches",
|
|
1991
|
-
value: "(cat|less|head|tail|bat|more|open|print|nano|vim|vi|emacs|code|type)\\s
|
|
2339
|
+
value: "(cat|less|head|tail|bat|more|open|print|nano|vim|vi|emacs|code|type)\\s+.*?\\.ssh[\\/\\\\]",
|
|
1992
2340
|
flags: "i"
|
|
1993
2341
|
}
|
|
1994
2342
|
],
|
|
@@ -2002,7 +2350,7 @@ var project_jail_default = {
|
|
|
2002
2350
|
{
|
|
2003
2351
|
field: "command",
|
|
2004
2352
|
op: "matches",
|
|
2005
|
-
value: "(cat|less|head|tail|bat|more|open|print|nano|vim|vi|emacs|code|type)\\s
|
|
2353
|
+
value: "(cat|less|head|tail|bat|more|open|print|nano|vim|vi|emacs|code|type)\\s+.*?\\.aws[\\/\\\\]",
|
|
2006
2354
|
flags: "i"
|
|
2007
2355
|
}
|
|
2008
2356
|
],
|
|
@@ -2024,7 +2372,7 @@ var project_jail_default = {
|
|
|
2024
2372
|
reason: "Reading .env files is blocked by project-jail shield"
|
|
2025
2373
|
},
|
|
2026
2374
|
{
|
|
2027
|
-
name: "shield:project-jail:
|
|
2375
|
+
name: "shield:project-jail:review-read-credentials",
|
|
2028
2376
|
tool: "bash",
|
|
2029
2377
|
conditions: [
|
|
2030
2378
|
{
|
|
@@ -2034,8 +2382,64 @@ var project_jail_default = {
|
|
|
2034
2382
|
flags: "i"
|
|
2035
2383
|
}
|
|
2036
2384
|
],
|
|
2385
|
+
verdict: "review",
|
|
2386
|
+
reason: "Reading credential files requires approval (project-jail shield)"
|
|
2387
|
+
},
|
|
2388
|
+
{
|
|
2389
|
+
name: "shield:project-jail:block-read-ssh-any-tool",
|
|
2390
|
+
tool: "*",
|
|
2391
|
+
conditions: [
|
|
2392
|
+
{
|
|
2393
|
+
field: "file_path",
|
|
2394
|
+
op: "matches",
|
|
2395
|
+
value: "(^|[\\/\\\\])\\.ssh[\\/\\\\]",
|
|
2396
|
+
flags: "i"
|
|
2397
|
+
}
|
|
2398
|
+
],
|
|
2037
2399
|
verdict: "block",
|
|
2038
|
-
reason: "Reading
|
|
2400
|
+
reason: "Reading SSH private keys is blocked by project-jail shield"
|
|
2401
|
+
},
|
|
2402
|
+
{
|
|
2403
|
+
name: "shield:project-jail:block-read-aws-any-tool",
|
|
2404
|
+
tool: "*",
|
|
2405
|
+
conditions: [
|
|
2406
|
+
{
|
|
2407
|
+
field: "file_path",
|
|
2408
|
+
op: "matches",
|
|
2409
|
+
value: "(^|[\\/\\\\])\\.aws[\\/\\\\]",
|
|
2410
|
+
flags: "i"
|
|
2411
|
+
}
|
|
2412
|
+
],
|
|
2413
|
+
verdict: "block",
|
|
2414
|
+
reason: "Reading AWS credentials is blocked by project-jail shield"
|
|
2415
|
+
},
|
|
2416
|
+
{
|
|
2417
|
+
name: "shield:project-jail:review-read-env-any-tool",
|
|
2418
|
+
tool: "*",
|
|
2419
|
+
conditions: [
|
|
2420
|
+
{
|
|
2421
|
+
field: "file_path",
|
|
2422
|
+
op: "matches",
|
|
2423
|
+
value: "(^|[\\/\\\\])\\.env(\\.(local|production|staging|development|production\\.local|staging\\.local|development\\.local))?$",
|
|
2424
|
+
flags: "i"
|
|
2425
|
+
}
|
|
2426
|
+
],
|
|
2427
|
+
verdict: "review",
|
|
2428
|
+
reason: "Reading .env files requires approval (project-jail shield)"
|
|
2429
|
+
},
|
|
2430
|
+
{
|
|
2431
|
+
name: "shield:project-jail:review-read-credentials-any-tool",
|
|
2432
|
+
tool: "*",
|
|
2433
|
+
conditions: [
|
|
2434
|
+
{
|
|
2435
|
+
field: "file_path",
|
|
2436
|
+
op: "matches",
|
|
2437
|
+
value: ".*(credentials\\.json|\\.netrc|\\.npmrc|\\.docker[\\/\\\\]config\\.json|gcloud[\\/\\\\]credentials|\\.kube[\\/\\\\]config)",
|
|
2438
|
+
flags: "i"
|
|
2439
|
+
}
|
|
2440
|
+
],
|
|
2441
|
+
verdict: "review",
|
|
2442
|
+
reason: "Reading credential files requires approval (project-jail shield)"
|
|
2039
2443
|
}
|
|
2040
2444
|
],
|
|
2041
2445
|
dangerousWords: []
|
|
@@ -2165,12 +2569,29 @@ var BUILTIN_SHIELDS = {
|
|
|
2165
2569
|
[filesystem_default.name]: filesystem_default,
|
|
2166
2570
|
[github_default.name]: github_default,
|
|
2167
2571
|
[k8s_default.name]: k8s_default,
|
|
2168
|
-
[mcp_tool_gating_default.name]: mcp_tool_gating_default,
|
|
2169
2572
|
[mongodb_default.name]: mongodb_default,
|
|
2170
2573
|
[postgres_default.name]: postgres_default,
|
|
2171
2574
|
[project_jail_default.name]: project_jail_default,
|
|
2172
2575
|
[redis_default.name]: redis_default
|
|
2173
2576
|
};
|
|
2577
|
+
function assertBuiltinShieldRegexesAreSafe() {
|
|
2578
|
+
for (const shield of Object.values(BUILTIN_SHIELDS)) {
|
|
2579
|
+
for (const rule of shield.smartRules) {
|
|
2580
|
+
const conditions = rule.conditions ?? [];
|
|
2581
|
+
for (const cond of conditions) {
|
|
2582
|
+
if (cond.op !== "matches" && cond.op !== "notMatches") continue;
|
|
2583
|
+
const pattern = cond.value;
|
|
2584
|
+
if (!pattern) continue;
|
|
2585
|
+
if (!safeRegex3(pattern)) {
|
|
2586
|
+
throw new Error(
|
|
2587
|
+
`[node9 engine] Shield '${shield.name}' rule '${rule.name ?? rule.tool}' has unsafe regex: ${pattern}`
|
|
2588
|
+
);
|
|
2589
|
+
}
|
|
2590
|
+
}
|
|
2591
|
+
}
|
|
2592
|
+
}
|
|
2593
|
+
}
|
|
2594
|
+
assertBuiltinShieldRegexesAreSafe();
|
|
2174
2595
|
|
|
2175
2596
|
// src/loop/index.ts
|
|
2176
2597
|
import crypto from "crypto";
|
|
@@ -2189,39 +2610,688 @@ function evaluateLoopWindow(records, tool, args, threshold, windowMs, now) {
|
|
|
2189
2610
|
return { nextRecords, count, looping: count >= threshold };
|
|
2190
2611
|
}
|
|
2191
2612
|
|
|
2613
|
+
// src/scan/index.ts
|
|
2614
|
+
function emptySignals() {
|
|
2615
|
+
return {
|
|
2616
|
+
dlpFindings: 0,
|
|
2617
|
+
piiFindings: 0,
|
|
2618
|
+
sensitiveFileReads: 0,
|
|
2619
|
+
privilegeEscalation: 0,
|
|
2620
|
+
networkExfil: 0,
|
|
2621
|
+
pipeToShell: 0,
|
|
2622
|
+
evalOfRemote: 0,
|
|
2623
|
+
destructiveOps: 0,
|
|
2624
|
+
loops: 0,
|
|
2625
|
+
longOutputRedactions: 0
|
|
2626
|
+
};
|
|
2627
|
+
}
|
|
2628
|
+
var FINDING_TO_SIGNAL = {
|
|
2629
|
+
dlp: "dlpFindings",
|
|
2630
|
+
pii: "piiFindings",
|
|
2631
|
+
"sensitive-file-read": "sensitiveFileReads",
|
|
2632
|
+
"privilege-escalation": "privilegeEscalation",
|
|
2633
|
+
"network-exfil": "networkExfil",
|
|
2634
|
+
"pipe-to-shell": "pipeToShell",
|
|
2635
|
+
"eval-of-remote": "evalOfRemote",
|
|
2636
|
+
"destructive-op": "destructiveOps",
|
|
2637
|
+
loop: "loops",
|
|
2638
|
+
"long-output-redacted": "longOutputRedactions"
|
|
2639
|
+
};
|
|
2640
|
+
var SCAN_SIGNAL_WEIGHTS = {
|
|
2641
|
+
dlpFindings: 30,
|
|
2642
|
+
piiFindings: 10,
|
|
2643
|
+
sensitiveFileReads: 20,
|
|
2644
|
+
privilegeEscalation: 15,
|
|
2645
|
+
networkExfil: 25,
|
|
2646
|
+
pipeToShell: 30,
|
|
2647
|
+
evalOfRemote: 30,
|
|
2648
|
+
destructiveOps: 15,
|
|
2649
|
+
loops: 3,
|
|
2650
|
+
longOutputRedactions: 1
|
|
2651
|
+
};
|
|
2652
|
+
function computeScanScore(signals) {
|
|
2653
|
+
let deduction = 0;
|
|
2654
|
+
for (const key of Object.keys(signals)) {
|
|
2655
|
+
deduction += signals[key] * SCAN_SIGNAL_WEIGHTS[key];
|
|
2656
|
+
}
|
|
2657
|
+
return Math.max(0, Math.min(100, 100 - deduction));
|
|
2658
|
+
}
|
|
2659
|
+
var LOOP_THRESHOLD_FOR_WASTE = 3;
|
|
2660
|
+
var COST_PER_LOOP_ITER_USD = 6e-3;
|
|
2661
|
+
function summarizeScan(findings, opts = {}) {
|
|
2662
|
+
const totalToolCalls = opts.totalToolCalls ?? 0;
|
|
2663
|
+
const topN = opts.topN ?? 10;
|
|
2664
|
+
const signals = emptySignals();
|
|
2665
|
+
const sessionIds = /* @__PURE__ */ new Set();
|
|
2666
|
+
const patternCounts = /* @__PURE__ */ new Map();
|
|
2667
|
+
for (const f of findings) {
|
|
2668
|
+
sessionIds.add(f.sessionId);
|
|
2669
|
+
const key = FINDING_TO_SIGNAL[f.type];
|
|
2670
|
+
signals[key]++;
|
|
2671
|
+
if (f.patternName) {
|
|
2672
|
+
patternCounts.set(f.patternName, (patternCounts.get(f.patternName) ?? 0) + 1);
|
|
2673
|
+
}
|
|
2674
|
+
}
|
|
2675
|
+
const topPatterns = [...patternCounts.entries()].sort((a, b) => {
|
|
2676
|
+
if (b[1] !== a[1]) return b[1] - a[1];
|
|
2677
|
+
return a[0].localeCompare(b[0]);
|
|
2678
|
+
}).slice(0, topN).map(([patternName, count]) => ({ patternName, count }));
|
|
2679
|
+
return {
|
|
2680
|
+
totalSessions: sessionIds.size,
|
|
2681
|
+
totalToolCalls,
|
|
2682
|
+
signals,
|
|
2683
|
+
topPatterns,
|
|
2684
|
+
score: computeScanScore(signals)
|
|
2685
|
+
};
|
|
2686
|
+
}
|
|
2687
|
+
|
|
2688
|
+
// src/severity/index.ts
|
|
2689
|
+
function classifyRuleSeverity(name, verdict) {
|
|
2690
|
+
const n = name.toLowerCase();
|
|
2691
|
+
const criticalPatterns = [
|
|
2692
|
+
"rm-rf",
|
|
2693
|
+
"eval-remote",
|
|
2694
|
+
"eval-curl",
|
|
2695
|
+
"read-aws",
|
|
2696
|
+
"read-ssh",
|
|
2697
|
+
"read-gcp",
|
|
2698
|
+
"read-cred",
|
|
2699
|
+
"delete-repo",
|
|
2700
|
+
"helm-uninstall",
|
|
2701
|
+
"drop-table",
|
|
2702
|
+
"drop-database",
|
|
2703
|
+
"drop-collection",
|
|
2704
|
+
"truncate",
|
|
2705
|
+
"flushall",
|
|
2706
|
+
"flushdb",
|
|
2707
|
+
"pipe-shell"
|
|
2708
|
+
];
|
|
2709
|
+
if (criticalPatterns.some((p) => n.includes(p))) return "critical";
|
|
2710
|
+
const highPatterns = [
|
|
2711
|
+
"force-push",
|
|
2712
|
+
"force_push",
|
|
2713
|
+
"git-destructive",
|
|
2714
|
+
"reset-hard",
|
|
2715
|
+
"rebase",
|
|
2716
|
+
"delete-branch",
|
|
2717
|
+
"delete-remote"
|
|
2718
|
+
];
|
|
2719
|
+
if (highPatterns.some((p) => n.includes(p))) return "high";
|
|
2720
|
+
if (verdict === "block") return "high";
|
|
2721
|
+
return "medium";
|
|
2722
|
+
}
|
|
2723
|
+
function narrativeRuleLabel(name) {
|
|
2724
|
+
const stripped = stripRulePrefixes(name);
|
|
2725
|
+
const map = {
|
|
2726
|
+
"read-aws": "AWS credentials read",
|
|
2727
|
+
"read-ssh": "SSH private key read",
|
|
2728
|
+
"read-gcp": "GCP credentials read",
|
|
2729
|
+
"read-cred": "credential file read",
|
|
2730
|
+
"delete-repo": "GitHub repository deletion",
|
|
2731
|
+
"helm-uninstall": "helm uninstall",
|
|
2732
|
+
"rm-rf-home": "rm -rf on home directory",
|
|
2733
|
+
"rm-rf": "rm -rf",
|
|
2734
|
+
"eval-remote": "eval of remote download",
|
|
2735
|
+
"eval-curl": "eval of curl output",
|
|
2736
|
+
"pipe-shell": "curl | bash",
|
|
2737
|
+
"drop-table": "DROP TABLE",
|
|
2738
|
+
"drop-database": "DROP DATABASE",
|
|
2739
|
+
"drop-collection": "DROP COLLECTION",
|
|
2740
|
+
truncate: "TRUNCATE",
|
|
2741
|
+
flushall: "Redis FLUSHALL",
|
|
2742
|
+
flushdb: "Redis FLUSHDB",
|
|
2743
|
+
"force-push": "force pushes",
|
|
2744
|
+
force_push: "force pushes",
|
|
2745
|
+
"reset-hard": "git reset --hard",
|
|
2746
|
+
"git-destructive": "destructive git operations",
|
|
2747
|
+
"delete-branch": "branch deletion",
|
|
2748
|
+
"delete-remote": "remote deletion",
|
|
2749
|
+
rebase: "git rebase",
|
|
2750
|
+
rm: "rm calls",
|
|
2751
|
+
sudo: "sudo calls",
|
|
2752
|
+
"eval-dynamic": "dynamic eval",
|
|
2753
|
+
"config-set": "Redis CONFIG SET"
|
|
2754
|
+
};
|
|
2755
|
+
for (const [key, label] of Object.entries(map)) {
|
|
2756
|
+
if (stripped.includes(key)) return label;
|
|
2757
|
+
}
|
|
2758
|
+
return stripped;
|
|
2759
|
+
}
|
|
2760
|
+
function stripRulePrefixes(name) {
|
|
2761
|
+
let n = name.toLowerCase();
|
|
2762
|
+
if (n.startsWith("org:")) n = n.slice(4);
|
|
2763
|
+
const shieldMatch = /^shield:[^:]+:(.+)$/.exec(n);
|
|
2764
|
+
if (shieldMatch) n = shieldMatch[1];
|
|
2765
|
+
n = n.replace(/^(block|review|allow)-/, "");
|
|
2766
|
+
return n;
|
|
2767
|
+
}
|
|
2768
|
+
function classifyAuditEntry(entry) {
|
|
2769
|
+
const ruleName = entry.riskMetadata?.ruleName;
|
|
2770
|
+
if (typeof ruleName === "string" && ruleName.length > 0) {
|
|
2771
|
+
const verdict = entry.action === "AUTO_BLOCKED" || entry.action === "DENIED" ? "block" : entry.action === "APPROVED" || entry.action === "AUTO_ALLOWED" ? "allow" : "review";
|
|
2772
|
+
return classifyRuleSeverity(ruleName, verdict);
|
|
2773
|
+
}
|
|
2774
|
+
const cb = entry.checkedBy ?? "";
|
|
2775
|
+
if (cb === "dlp-block" || cb.startsWith("dlp-saas:")) return "critical";
|
|
2776
|
+
if (cb.startsWith("eval-saas") || cb === "pipe-chain-saas:critical") {
|
|
2777
|
+
return "critical";
|
|
2778
|
+
}
|
|
2779
|
+
if (cb === "loop-detected") return "medium";
|
|
2780
|
+
const isBlocked = entry.action === "AUTO_BLOCKED" || entry.action === "DENIED";
|
|
2781
|
+
if (isBlocked) return "high";
|
|
2782
|
+
return null;
|
|
2783
|
+
}
|
|
2784
|
+
function computeSecurityScore(opts) {
|
|
2785
|
+
const { critical, high, medium, total } = opts;
|
|
2786
|
+
if (total === 0) return { score: 100, tier: "good" };
|
|
2787
|
+
const criticalRate = critical / total;
|
|
2788
|
+
const highRate = high / total;
|
|
2789
|
+
const mediumRate = medium / total;
|
|
2790
|
+
const deduction = Math.min(criticalRate * 3e3, 60) + Math.min(highRate * 500, 30) + Math.min(mediumRate * 100, 15);
|
|
2791
|
+
const score = Math.max(0, Math.min(100, Math.round(100 - deduction)));
|
|
2792
|
+
const tier = score >= 80 ? "good" : score >= 50 ? "at-risk" : "critical";
|
|
2793
|
+
return { score, tier };
|
|
2794
|
+
}
|
|
2795
|
+
function classifyScanSignal(key) {
|
|
2796
|
+
const w = SCAN_SIGNAL_WEIGHTS[key];
|
|
2797
|
+
if (w >= 25) return "critical";
|
|
2798
|
+
if (w >= 11) return "high";
|
|
2799
|
+
return "medium";
|
|
2800
|
+
}
|
|
2801
|
+
function computeBlendedSecurityScore(opts) {
|
|
2802
|
+
const { audit, scan } = opts;
|
|
2803
|
+
let critical = audit.critical;
|
|
2804
|
+
let high = audit.high;
|
|
2805
|
+
let medium = audit.medium;
|
|
2806
|
+
let total = audit.total;
|
|
2807
|
+
if (scan) {
|
|
2808
|
+
let scanFindingSum = 0;
|
|
2809
|
+
for (const key of Object.keys(scan.signals)) {
|
|
2810
|
+
const count = scan.signals[key];
|
|
2811
|
+
if (count <= 0) continue;
|
|
2812
|
+
const tier = classifyScanSignal(key);
|
|
2813
|
+
if (tier === "critical") critical += count;
|
|
2814
|
+
else if (tier === "high") high += count;
|
|
2815
|
+
else medium += count;
|
|
2816
|
+
scanFindingSum += count;
|
|
2817
|
+
}
|
|
2818
|
+
const scanContribution = Math.max(scan.totalToolCalls ?? 0, scanFindingSum);
|
|
2819
|
+
total += scanContribution;
|
|
2820
|
+
}
|
|
2821
|
+
return computeSecurityScore({ critical, high, medium, total });
|
|
2822
|
+
}
|
|
2823
|
+
|
|
2824
|
+
// src/blast/index.ts
|
|
2825
|
+
function truncateBlastPath(full) {
|
|
2826
|
+
if (!full) return "";
|
|
2827
|
+
const cleaned = full.replace(/[/\\]+$/, "");
|
|
2828
|
+
const parts = cleaned.split(/[/\\]+/).filter((p) => p.length > 0);
|
|
2829
|
+
if (parts.length <= 2) {
|
|
2830
|
+
return cleaned.startsWith("~") && !cleaned.startsWith("~/") ? cleaned : cleaned.startsWith("~/") ? cleaned : parts.join("/");
|
|
2831
|
+
}
|
|
2832
|
+
return parts.slice(-2).join("/");
|
|
2833
|
+
}
|
|
2834
|
+
function summarizeBlast(result, opts = {}) {
|
|
2835
|
+
const topN = opts.topN ?? 5;
|
|
2836
|
+
const sorted = [...result.reachable].sort((a, b) => {
|
|
2837
|
+
if (b.score !== a.score) return b.score - a.score;
|
|
2838
|
+
return a.label.localeCompare(b.label);
|
|
2839
|
+
});
|
|
2840
|
+
return {
|
|
2841
|
+
score: result.score,
|
|
2842
|
+
exposureCount: result.reachable.length + result.envFindings.length,
|
|
2843
|
+
envExposureCount: result.envFindings.length,
|
|
2844
|
+
worstPaths: sorted.slice(0, topN).map((f) => ({
|
|
2845
|
+
path: truncateBlastPath(f.label),
|
|
2846
|
+
score: f.score
|
|
2847
|
+
}))
|
|
2848
|
+
};
|
|
2849
|
+
}
|
|
2850
|
+
|
|
2851
|
+
// src/scan/destructive-regex.ts
|
|
2852
|
+
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;
|
|
2853
|
+
var PRIVILEGE_ESCALATION_RE = /\bchmod\s+(0?777|\+x)\b|\bchown\s+root\b/i;
|
|
2854
|
+
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;
|
|
2855
|
+
var FILE_TOOLS = /* @__PURE__ */ new Set([
|
|
2856
|
+
"read",
|
|
2857
|
+
"read_file",
|
|
2858
|
+
"edit",
|
|
2859
|
+
"edit_file",
|
|
2860
|
+
"write",
|
|
2861
|
+
"write_file",
|
|
2862
|
+
"multiedit",
|
|
2863
|
+
"grep",
|
|
2864
|
+
"grep_search",
|
|
2865
|
+
"glob",
|
|
2866
|
+
"list_files"
|
|
2867
|
+
]);
|
|
2868
|
+
|
|
2869
|
+
// src/scan/pii.ts
|
|
2870
|
+
var PII_EMAIL_RE = /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/;
|
|
2871
|
+
var PII_SSN_RE = /\b\d{3}-\d{2}-\d{4}\b/;
|
|
2872
|
+
var PII_PHONE_RE = /\b(?:\+?1[-.\s]?)?\(?\d{3}\)?[-.\s]\d{3}[-.\s]\d{4}\b/;
|
|
2873
|
+
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/;
|
|
2874
|
+
function detectPii(text) {
|
|
2875
|
+
const found = /* @__PURE__ */ new Set();
|
|
2876
|
+
if (/@/.test(text) && PII_EMAIL_RE.test(text)) found.add("Email");
|
|
2877
|
+
if (/-/.test(text) && PII_SSN_RE.test(text)) found.add("SSN");
|
|
2878
|
+
if (PII_PHONE_RE.test(text)) found.add("Phone");
|
|
2879
|
+
if (PII_CC_RE.test(text)) found.add("Credit Card");
|
|
2880
|
+
return [...found];
|
|
2881
|
+
}
|
|
2882
|
+
|
|
2883
|
+
// src/scan/canonical.ts
|
|
2884
|
+
var LONG_OUTPUT_THRESHOLD_BYTES = 100 * 1024;
|
|
2885
|
+
var CANONICAL_EXTRACTOR_VERSION = "canonical-v4";
|
|
2886
|
+
var CANONICAL_EXTRACTOR_HASH = "64a6a63a27f4646f";
|
|
2887
|
+
var DEDUPE_PREVIEW_LEN = 120;
|
|
2888
|
+
function extractCanonicalFindings(call, ctx) {
|
|
2889
|
+
const out = [];
|
|
2890
|
+
const ts = call.timestamp;
|
|
2891
|
+
const toolNameLower = call.toolName.toLowerCase();
|
|
2892
|
+
const command = typeof call.args.command === "string" ? call.args.command : null;
|
|
2893
|
+
const isBash = isBashTool(call.toolName) && command !== null;
|
|
2894
|
+
if (call.outputBytes !== void 0 && call.outputBytes > LONG_OUTPUT_THRESHOLD_BYTES) {
|
|
2895
|
+
out.push(
|
|
2896
|
+
makeFinding({
|
|
2897
|
+
type: "long-output-redacted",
|
|
2898
|
+
ruleName: "long-output-redacted",
|
|
2899
|
+
verdict: "review",
|
|
2900
|
+
severity: "medium",
|
|
2901
|
+
reason: `Tool output exceeded ${LONG_OUTPUT_THRESHOLD_BYTES} bytes and was redacted`,
|
|
2902
|
+
toolName: call.toolName,
|
|
2903
|
+
ctx,
|
|
2904
|
+
ts,
|
|
2905
|
+
sourceType: "engine"
|
|
2906
|
+
})
|
|
2907
|
+
);
|
|
2908
|
+
}
|
|
2909
|
+
if (ctx.dlpEnabled) {
|
|
2910
|
+
const dlp = scanArgs(call.args);
|
|
2911
|
+
if (dlp) {
|
|
2912
|
+
out.push(
|
|
2913
|
+
makeFinding({
|
|
2914
|
+
type: "dlp",
|
|
2915
|
+
ruleName: `dlp:${dlp.patternName}`,
|
|
2916
|
+
patternName: dlp.patternName,
|
|
2917
|
+
verdict: dlp.severity === "block" ? "block" : "review",
|
|
2918
|
+
severity: dlp.severity === "block" ? "critical" : "medium",
|
|
2919
|
+
reason: `${dlp.patternName} detected in ${dlp.fieldPath}`,
|
|
2920
|
+
toolName: call.toolName,
|
|
2921
|
+
ctx,
|
|
2922
|
+
ts,
|
|
2923
|
+
sourceType: "engine",
|
|
2924
|
+
input: call.args,
|
|
2925
|
+
redactedSample: dlp.redactedSample
|
|
2926
|
+
})
|
|
2927
|
+
);
|
|
2928
|
+
}
|
|
2929
|
+
}
|
|
2930
|
+
for (const value of stringValues(call.args)) {
|
|
2931
|
+
const piiHits = detectPii(value);
|
|
2932
|
+
for (const pattern of piiHits) {
|
|
2933
|
+
out.push(
|
|
2934
|
+
makeFinding({
|
|
2935
|
+
type: "pii",
|
|
2936
|
+
ruleName: `pii:${pattern.toLowerCase().replace(/\s+/g, "-")}`,
|
|
2937
|
+
patternName: pattern,
|
|
2938
|
+
verdict: "review",
|
|
2939
|
+
severity: "medium",
|
|
2940
|
+
reason: `${pattern} pattern detected in tool input`,
|
|
2941
|
+
toolName: call.toolName,
|
|
2942
|
+
ctx,
|
|
2943
|
+
ts,
|
|
2944
|
+
sourceType: "engine"
|
|
2945
|
+
})
|
|
2946
|
+
);
|
|
2947
|
+
}
|
|
2948
|
+
}
|
|
2949
|
+
if (FILE_TOOLS.has(toolNameLower)) {
|
|
2950
|
+
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 || "";
|
|
2951
|
+
if (filePath && SENSITIVE_PATH_RE.test(filePath)) {
|
|
2952
|
+
out.push(
|
|
2953
|
+
makeFinding({
|
|
2954
|
+
type: "sensitive-file-read",
|
|
2955
|
+
ruleName: "sensitive-file-read",
|
|
2956
|
+
verdict: "review",
|
|
2957
|
+
severity: "critical",
|
|
2958
|
+
reason: `Sensitive file path read via ${call.toolName}`,
|
|
2959
|
+
toolName: call.toolName,
|
|
2960
|
+
ctx,
|
|
2961
|
+
ts,
|
|
2962
|
+
sourceType: "engine",
|
|
2963
|
+
subjectPath: filePath
|
|
2964
|
+
})
|
|
2965
|
+
);
|
|
2966
|
+
}
|
|
2967
|
+
}
|
|
2968
|
+
if (!isBash || command === null) {
|
|
2969
|
+
return out;
|
|
2970
|
+
}
|
|
2971
|
+
const fsVerdict = analyzeFsOperation(command);
|
|
2972
|
+
if (fsVerdict) {
|
|
2973
|
+
const isShield = fsVerdict.ruleName.startsWith("shield:");
|
|
2974
|
+
out.push(
|
|
2975
|
+
makeFinding({
|
|
2976
|
+
type: "ast-fs-op",
|
|
2977
|
+
ruleName: fsVerdict.ruleName,
|
|
2978
|
+
verdict: fsVerdict.verdict,
|
|
2979
|
+
severity: classifyRuleSeverity(fsVerdict.ruleName, fsVerdict.verdict),
|
|
2980
|
+
reason: fsVerdict.reason,
|
|
2981
|
+
toolName: call.toolName,
|
|
2982
|
+
ctx,
|
|
2983
|
+
ts,
|
|
2984
|
+
sourceType: isShield ? "shield" : "engine",
|
|
2985
|
+
shieldLabel: isShield ? "project-jail (AST)" : "Node9 (AST)",
|
|
2986
|
+
subjectPath: fsVerdict.path,
|
|
2987
|
+
input: call.args
|
|
2988
|
+
})
|
|
2989
|
+
);
|
|
2990
|
+
}
|
|
2991
|
+
for (const source of ctx.rules) {
|
|
2992
|
+
const r = source.rule;
|
|
2993
|
+
if (r.verdict === "allow") continue;
|
|
2994
|
+
if (r.tool && !matchesPattern(toolNameLower, r.tool)) continue;
|
|
2995
|
+
if (r.name && AST_FS_REGEX_RULES.has(r.name)) continue;
|
|
2996
|
+
if (!evaluateSmartConditions(call.args, r)) continue;
|
|
2997
|
+
out.push(
|
|
2998
|
+
makeFinding({
|
|
2999
|
+
type: "smart-rule",
|
|
3000
|
+
ruleName: r.name ?? r.tool,
|
|
3001
|
+
verdict: r.verdict === "block" ? "block" : "review",
|
|
3002
|
+
severity: classifyRuleSeverity(r.name ?? r.tool, r.verdict),
|
|
3003
|
+
reason: r.reason ?? `Smart rule ${r.name ?? r.tool} fired`,
|
|
3004
|
+
toolName: call.toolName,
|
|
3005
|
+
ctx,
|
|
3006
|
+
ts,
|
|
3007
|
+
sourceType: source.sourceType,
|
|
3008
|
+
shieldLabel: source.shieldLabel,
|
|
3009
|
+
input: call.args
|
|
3010
|
+
})
|
|
3011
|
+
);
|
|
3012
|
+
break;
|
|
3013
|
+
}
|
|
3014
|
+
const evalVerdict = detectDangerousShellExec(command);
|
|
3015
|
+
if (evalVerdict) {
|
|
3016
|
+
out.push(
|
|
3017
|
+
makeFinding({
|
|
3018
|
+
type: "eval-of-remote",
|
|
3019
|
+
ruleName: "eval-of-remote",
|
|
3020
|
+
verdict: evalVerdict,
|
|
3021
|
+
severity: classifyRuleSeverity("eval-remote", evalVerdict),
|
|
3022
|
+
reason: evalVerdict === "block" ? "Eval of remote download is a near-certain supply-chain attack" : "Eval of dynamic content (variable / subshell) requires approval",
|
|
3023
|
+
toolName: call.toolName,
|
|
3024
|
+
ctx,
|
|
3025
|
+
ts,
|
|
3026
|
+
sourceType: "engine",
|
|
3027
|
+
input: call.args
|
|
3028
|
+
})
|
|
3029
|
+
);
|
|
3030
|
+
}
|
|
3031
|
+
const pipe = analyzePipeChain(command);
|
|
3032
|
+
if (pipe.isPipeline && pipe.risk === "critical") {
|
|
3033
|
+
out.push(
|
|
3034
|
+
makeFinding({
|
|
3035
|
+
type: "pipe-to-shell",
|
|
3036
|
+
ruleName: "pipe-to-shell",
|
|
3037
|
+
verdict: "block",
|
|
3038
|
+
severity: "critical",
|
|
3039
|
+
reason: `Sensitive file piped through obfuscator to network sink: ${pipe.sourceFiles.join(", ")} \u2192 ${pipe.sinkTargets.join(", ")}`,
|
|
3040
|
+
toolName: call.toolName,
|
|
3041
|
+
ctx,
|
|
3042
|
+
ts,
|
|
3043
|
+
sourceType: "engine",
|
|
3044
|
+
input: call.args
|
|
3045
|
+
})
|
|
3046
|
+
);
|
|
3047
|
+
}
|
|
3048
|
+
if (DESTRUCTIVE_OP_RE.test(command)) {
|
|
3049
|
+
out.push(
|
|
3050
|
+
makeFinding({
|
|
3051
|
+
type: "destructive-op",
|
|
3052
|
+
ruleName: "destructive-op",
|
|
3053
|
+
verdict: "review",
|
|
3054
|
+
severity: "high",
|
|
3055
|
+
reason: "Destructive operation pattern detected",
|
|
3056
|
+
toolName: call.toolName,
|
|
3057
|
+
ctx,
|
|
3058
|
+
ts,
|
|
3059
|
+
sourceType: "engine",
|
|
3060
|
+
input: call.args
|
|
3061
|
+
})
|
|
3062
|
+
);
|
|
3063
|
+
}
|
|
3064
|
+
const ast = analyzeShellCommand(command);
|
|
3065
|
+
const sudoVariant = ast.actions.includes("sudo") || ast.actions.includes("su");
|
|
3066
|
+
const chmodVariant = ast.actions.includes("chmod") && (ast.allTokens.includes("777") || ast.allTokens.includes("0777") || ast.allTokens.includes("+x"));
|
|
3067
|
+
const chownVariant = ast.actions.includes("chown") && ast.allTokens.includes("root");
|
|
3068
|
+
if (sudoVariant || chmodVariant || chownVariant) {
|
|
3069
|
+
out.push(
|
|
3070
|
+
makeFinding({
|
|
3071
|
+
type: "privilege-escalation",
|
|
3072
|
+
ruleName: "privilege-escalation",
|
|
3073
|
+
verdict: "review",
|
|
3074
|
+
severity: "high",
|
|
3075
|
+
reason: "Privilege-escalation pattern detected",
|
|
3076
|
+
toolName: call.toolName,
|
|
3077
|
+
ctx,
|
|
3078
|
+
ts,
|
|
3079
|
+
sourceType: "engine",
|
|
3080
|
+
input: call.args
|
|
3081
|
+
})
|
|
3082
|
+
);
|
|
3083
|
+
}
|
|
3084
|
+
return out;
|
|
3085
|
+
}
|
|
3086
|
+
function extractSessionLevelFindings(calls, ctx) {
|
|
3087
|
+
if (!ctx.loopDetection.enabled || calls.length === 0) return [];
|
|
3088
|
+
const out = [];
|
|
3089
|
+
const seenLoopKeys = /* @__PURE__ */ new Set();
|
|
3090
|
+
const ONE_YEAR_MS = 365 * 24 * 60 * 60 * 1e3;
|
|
3091
|
+
const windowMs = ctx.loopDetection.windowSeconds <= 0 ? ONE_YEAR_MS : ctx.loopDetection.windowSeconds * 1e3;
|
|
3092
|
+
let records = [];
|
|
3093
|
+
let syntheticTs = 0;
|
|
3094
|
+
for (let i = 0; i < calls.length; i++) {
|
|
3095
|
+
const call = calls[i];
|
|
3096
|
+
const parsed = new Date(call.timestamp).getTime();
|
|
3097
|
+
const now = Number.isFinite(parsed) ? parsed : ++syntheticTs;
|
|
3098
|
+
const verdict = evaluateLoopWindow(
|
|
3099
|
+
records,
|
|
3100
|
+
call.toolName,
|
|
3101
|
+
call.args,
|
|
3102
|
+
ctx.loopDetection.threshold,
|
|
3103
|
+
windowMs,
|
|
3104
|
+
now
|
|
3105
|
+
);
|
|
3106
|
+
records = verdict.nextRecords;
|
|
3107
|
+
if (!verdict.looping) continue;
|
|
3108
|
+
const last = records[records.length - 1];
|
|
3109
|
+
const key = `${last.t}|${last.h}`;
|
|
3110
|
+
if (seenLoopKeys.has(key)) continue;
|
|
3111
|
+
seenLoopKeys.add(key);
|
|
3112
|
+
out.push({
|
|
3113
|
+
type: "loop",
|
|
3114
|
+
ruleName: "loop",
|
|
3115
|
+
verdict: "review",
|
|
3116
|
+
severity: "medium",
|
|
3117
|
+
reason: `Tool called ${verdict.count} times with identical args within window`,
|
|
3118
|
+
toolName: call.toolName,
|
|
3119
|
+
agent: ctx.agent,
|
|
3120
|
+
sessionId: ctx.sessionId,
|
|
3121
|
+
project: ctx.project,
|
|
3122
|
+
lineIndex: call.lineIndex,
|
|
3123
|
+
sourceType: "engine",
|
|
3124
|
+
firstSeenAt: call.timestamp,
|
|
3125
|
+
lastSeenAt: call.timestamp,
|
|
3126
|
+
occurrenceCount: 1,
|
|
3127
|
+
loopCount: verdict.count,
|
|
3128
|
+
loopKind: "loop",
|
|
3129
|
+
commandPreview: previewArgs(call.args, DEDUPE_PREVIEW_LEN),
|
|
3130
|
+
costUsd: verdict.count * COST_PER_LOOP_ITER_USD
|
|
3131
|
+
});
|
|
3132
|
+
}
|
|
3133
|
+
return out;
|
|
3134
|
+
}
|
|
3135
|
+
function dedupeCanonicalFindings(findings) {
|
|
3136
|
+
const merged = /* @__PURE__ */ new Map();
|
|
3137
|
+
for (const f of findings) {
|
|
3138
|
+
const inputPreview = f.input ? previewArgs(f.input, DEDUPE_PREVIEW_LEN) : "";
|
|
3139
|
+
const key = `${f.type}|${f.ruleName}|${inputPreview}|${f.project}|${f.agent}`;
|
|
3140
|
+
const prev = merged.get(key);
|
|
3141
|
+
if (!prev) {
|
|
3142
|
+
merged.set(key, { ...f });
|
|
3143
|
+
continue;
|
|
3144
|
+
}
|
|
3145
|
+
prev.occurrenceCount += f.occurrenceCount;
|
|
3146
|
+
if (f.firstSeenAt && (!prev.firstSeenAt || f.firstSeenAt < prev.firstSeenAt)) {
|
|
3147
|
+
prev.firstSeenAt = f.firstSeenAt;
|
|
3148
|
+
}
|
|
3149
|
+
if (f.lastSeenAt && f.lastSeenAt > prev.lastSeenAt) {
|
|
3150
|
+
prev.lastSeenAt = f.lastSeenAt;
|
|
3151
|
+
}
|
|
3152
|
+
if (f.costUsd !== void 0) {
|
|
3153
|
+
prev.costUsd = (prev.costUsd ?? 0) + f.costUsd;
|
|
3154
|
+
}
|
|
3155
|
+
if (f.loopCount !== void 0) {
|
|
3156
|
+
prev.loopCount = (prev.loopCount ?? 0) + f.loopCount;
|
|
3157
|
+
}
|
|
3158
|
+
}
|
|
3159
|
+
return [...merged.values()];
|
|
3160
|
+
}
|
|
3161
|
+
function toScanFinding(c) {
|
|
3162
|
+
const typeMap = {
|
|
3163
|
+
"smart-rule": null,
|
|
3164
|
+
"ast-fs-op": null,
|
|
3165
|
+
dlp: "dlp",
|
|
3166
|
+
pii: "pii",
|
|
3167
|
+
"sensitive-file-read": "sensitive-file-read",
|
|
3168
|
+
"privilege-escalation": "privilege-escalation",
|
|
3169
|
+
"destructive-op": "destructive-op",
|
|
3170
|
+
"pipe-to-shell": "pipe-to-shell",
|
|
3171
|
+
"eval-of-remote": "eval-of-remote",
|
|
3172
|
+
loop: "loop",
|
|
3173
|
+
"long-output-redacted": "long-output-redacted"
|
|
3174
|
+
};
|
|
3175
|
+
const sfType = typeMap[c.type];
|
|
3176
|
+
if (sfType === null) return null;
|
|
3177
|
+
return {
|
|
3178
|
+
sessionId: c.sessionId,
|
|
3179
|
+
type: sfType,
|
|
3180
|
+
...c.patternName && { patternName: c.patternName },
|
|
3181
|
+
lineIndex: c.lineIndex
|
|
3182
|
+
};
|
|
3183
|
+
}
|
|
3184
|
+
var TERMINAL_ESCAPE_RE = (
|
|
3185
|
+
// eslint-disable-next-line no-control-regex
|
|
3186
|
+
/\x1b\[[0-9;?]*[A-Za-z]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[@-_]|[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g
|
|
3187
|
+
);
|
|
3188
|
+
function previewArgs(input, max) {
|
|
3189
|
+
const cmd = input.command ?? input.query ?? input.file_path ?? JSON.stringify(input);
|
|
3190
|
+
const s = String(cmd).replace(TERMINAL_ESCAPE_RE, "").replace(/\s+/g, " ").trim();
|
|
3191
|
+
return s.length > max ? s.slice(0, max - 1) + "\u2026" : s;
|
|
3192
|
+
}
|
|
3193
|
+
function makeFinding(args) {
|
|
3194
|
+
const f = {
|
|
3195
|
+
type: args.type,
|
|
3196
|
+
ruleName: args.ruleName,
|
|
3197
|
+
verdict: args.verdict,
|
|
3198
|
+
severity: args.severity,
|
|
3199
|
+
reason: args.reason,
|
|
3200
|
+
toolName: args.toolName,
|
|
3201
|
+
agent: args.ctx.agent,
|
|
3202
|
+
sessionId: args.ctx.sessionId,
|
|
3203
|
+
project: args.ctx.project,
|
|
3204
|
+
lineIndex: args.ctx.lineIndex,
|
|
3205
|
+
sourceType: args.sourceType,
|
|
3206
|
+
firstSeenAt: args.ts,
|
|
3207
|
+
lastSeenAt: args.ts,
|
|
3208
|
+
occurrenceCount: 1
|
|
3209
|
+
};
|
|
3210
|
+
if (args.shieldLabel) f.shieldLabel = args.shieldLabel;
|
|
3211
|
+
if (args.subjectPath) f.subjectPath = args.subjectPath;
|
|
3212
|
+
if (args.input) f.input = args.input;
|
|
3213
|
+
if (args.patternName) f.patternName = args.patternName;
|
|
3214
|
+
if (args.redactedSample) f.redactedSample = args.redactedSample;
|
|
3215
|
+
return f;
|
|
3216
|
+
}
|
|
3217
|
+
function* stringValues(obj, depth = 0) {
|
|
3218
|
+
if (depth > 6) return;
|
|
3219
|
+
if (typeof obj === "string") {
|
|
3220
|
+
if (obj.length > 0) yield obj;
|
|
3221
|
+
return;
|
|
3222
|
+
}
|
|
3223
|
+
if (!obj || typeof obj !== "object") return;
|
|
3224
|
+
if (Array.isArray(obj)) {
|
|
3225
|
+
for (const v of obj) yield* stringValues(v, depth + 1);
|
|
3226
|
+
return;
|
|
3227
|
+
}
|
|
3228
|
+
for (const v of Object.values(obj)) yield* stringValues(v, depth + 1);
|
|
3229
|
+
}
|
|
3230
|
+
|
|
2192
3231
|
// src/index.ts
|
|
2193
|
-
var ENGINE_VERSION = "1.
|
|
3232
|
+
var ENGINE_VERSION = "1.4.0";
|
|
2194
3233
|
export {
|
|
3234
|
+
AST_FS_REGEX_RULES,
|
|
3235
|
+
BASH_TOOL_NAMES,
|
|
2195
3236
|
BUILTIN_SHIELDS,
|
|
3237
|
+
CANONICAL_EXTRACTOR_HASH,
|
|
3238
|
+
CANONICAL_EXTRACTOR_VERSION,
|
|
3239
|
+
COST_PER_LOOP_ITER_USD,
|
|
3240
|
+
DESTRUCTIVE_OP_RE,
|
|
2196
3241
|
DLP_PATTERNS,
|
|
2197
3242
|
ENGINE_VERSION,
|
|
3243
|
+
FILE_TOOLS,
|
|
2198
3244
|
FLAGS_WITH_VALUES,
|
|
3245
|
+
LONG_OUTPUT_THRESHOLD_BYTES,
|
|
2199
3246
|
LOOP_MAX_RECORDS,
|
|
3247
|
+
LOOP_THRESHOLD_FOR_WASTE,
|
|
3248
|
+
PRIVILEGE_ESCALATION_RE,
|
|
3249
|
+
SCAN_SIGNAL_WEIGHTS,
|
|
3250
|
+
SENSITIVE_PATH_RE,
|
|
2200
3251
|
SENSITIVE_PATH_REGEXES,
|
|
3252
|
+
analyzeFsOperation,
|
|
2201
3253
|
analyzePipeChain,
|
|
2202
3254
|
analyzeShellCommand,
|
|
2203
3255
|
checkDangerousSql,
|
|
3256
|
+
classifyAuditEntry,
|
|
3257
|
+
classifyRuleSeverity,
|
|
3258
|
+
classifyScanSignal,
|
|
2204
3259
|
computeArgsHash,
|
|
3260
|
+
computeBlendedSecurityScore,
|
|
3261
|
+
computeScanScore,
|
|
3262
|
+
computeSecurityScore,
|
|
3263
|
+
dedupeCanonicalFindings,
|
|
2205
3264
|
detectDangerousEval,
|
|
2206
3265
|
detectDangerousShellExec,
|
|
3266
|
+
detectPii,
|
|
2207
3267
|
evaluateLoopWindow,
|
|
2208
3268
|
evaluatePolicy,
|
|
2209
3269
|
evaluateSmartConditions,
|
|
2210
3270
|
extractAllSshHosts,
|
|
3271
|
+
extractCanonicalFindings,
|
|
2211
3272
|
extractNetworkTargets,
|
|
2212
3273
|
extractPositionalArgs,
|
|
3274
|
+
extractSessionLevelFindings,
|
|
2213
3275
|
getCompiledRegex,
|
|
2214
3276
|
getNestedValue,
|
|
3277
|
+
isBashTool,
|
|
2215
3278
|
isIgnoredTool,
|
|
3279
|
+
isProtectedHomePath,
|
|
2216
3280
|
isShieldVerdict,
|
|
2217
3281
|
matchSensitivePath,
|
|
2218
3282
|
matchesPattern,
|
|
3283
|
+
narrativeRuleLabel,
|
|
2219
3284
|
normalizeCommandForPolicy,
|
|
2220
3285
|
parseAllSshHostsFromCommand,
|
|
3286
|
+
previewArgs,
|
|
2221
3287
|
redactText,
|
|
2222
3288
|
scanArgs,
|
|
2223
3289
|
scanText,
|
|
2224
3290
|
sensitivePathMatch,
|
|
3291
|
+
summarizeBlast,
|
|
3292
|
+
summarizeScan,
|
|
3293
|
+
toScanFinding,
|
|
3294
|
+
truncateBlastPath,
|
|
2225
3295
|
validateOverrides,
|
|
2226
3296
|
validateRegex,
|
|
2227
3297
|
validateShieldDefinition
|