@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.js
CHANGED
|
@@ -30,36 +30,67 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
30
30
|
// src/index.ts
|
|
31
31
|
var src_exports = {};
|
|
32
32
|
__export(src_exports, {
|
|
33
|
+
AST_FS_REGEX_RULES: () => AST_FS_REGEX_RULES,
|
|
34
|
+
BASH_TOOL_NAMES: () => BASH_TOOL_NAMES,
|
|
33
35
|
BUILTIN_SHIELDS: () => BUILTIN_SHIELDS,
|
|
36
|
+
CANONICAL_EXTRACTOR_HASH: () => CANONICAL_EXTRACTOR_HASH,
|
|
37
|
+
CANONICAL_EXTRACTOR_VERSION: () => CANONICAL_EXTRACTOR_VERSION,
|
|
38
|
+
COST_PER_LOOP_ITER_USD: () => COST_PER_LOOP_ITER_USD,
|
|
39
|
+
DESTRUCTIVE_OP_RE: () => DESTRUCTIVE_OP_RE,
|
|
34
40
|
DLP_PATTERNS: () => DLP_PATTERNS,
|
|
35
41
|
ENGINE_VERSION: () => ENGINE_VERSION,
|
|
42
|
+
FILE_TOOLS: () => FILE_TOOLS,
|
|
36
43
|
FLAGS_WITH_VALUES: () => FLAGS_WITH_VALUES,
|
|
44
|
+
LONG_OUTPUT_THRESHOLD_BYTES: () => LONG_OUTPUT_THRESHOLD_BYTES,
|
|
37
45
|
LOOP_MAX_RECORDS: () => LOOP_MAX_RECORDS,
|
|
46
|
+
LOOP_THRESHOLD_FOR_WASTE: () => LOOP_THRESHOLD_FOR_WASTE,
|
|
47
|
+
PRIVILEGE_ESCALATION_RE: () => PRIVILEGE_ESCALATION_RE,
|
|
48
|
+
SCAN_SIGNAL_WEIGHTS: () => SCAN_SIGNAL_WEIGHTS,
|
|
49
|
+
SENSITIVE_PATH_RE: () => SENSITIVE_PATH_RE,
|
|
38
50
|
SENSITIVE_PATH_REGEXES: () => SENSITIVE_PATH_REGEXES,
|
|
51
|
+
analyzeFsOperation: () => analyzeFsOperation,
|
|
39
52
|
analyzePipeChain: () => analyzePipeChain,
|
|
40
53
|
analyzeShellCommand: () => analyzeShellCommand,
|
|
41
54
|
checkDangerousSql: () => checkDangerousSql,
|
|
55
|
+
classifyAuditEntry: () => classifyAuditEntry,
|
|
56
|
+
classifyRuleSeverity: () => classifyRuleSeverity,
|
|
57
|
+
classifyScanSignal: () => classifyScanSignal,
|
|
42
58
|
computeArgsHash: () => computeArgsHash,
|
|
59
|
+
computeBlendedSecurityScore: () => computeBlendedSecurityScore,
|
|
60
|
+
computeScanScore: () => computeScanScore,
|
|
61
|
+
computeSecurityScore: () => computeSecurityScore,
|
|
62
|
+
dedupeCanonicalFindings: () => dedupeCanonicalFindings,
|
|
43
63
|
detectDangerousEval: () => detectDangerousEval,
|
|
44
64
|
detectDangerousShellExec: () => detectDangerousShellExec,
|
|
65
|
+
detectPii: () => detectPii,
|
|
45
66
|
evaluateLoopWindow: () => evaluateLoopWindow,
|
|
46
67
|
evaluatePolicy: () => evaluatePolicy,
|
|
47
68
|
evaluateSmartConditions: () => evaluateSmartConditions,
|
|
48
69
|
extractAllSshHosts: () => extractAllSshHosts,
|
|
70
|
+
extractCanonicalFindings: () => extractCanonicalFindings,
|
|
49
71
|
extractNetworkTargets: () => extractNetworkTargets,
|
|
50
72
|
extractPositionalArgs: () => extractPositionalArgs,
|
|
73
|
+
extractSessionLevelFindings: () => extractSessionLevelFindings,
|
|
51
74
|
getCompiledRegex: () => getCompiledRegex,
|
|
52
75
|
getNestedValue: () => getNestedValue,
|
|
76
|
+
isBashTool: () => isBashTool,
|
|
53
77
|
isIgnoredTool: () => isIgnoredTool,
|
|
78
|
+
isProtectedHomePath: () => isProtectedHomePath,
|
|
54
79
|
isShieldVerdict: () => isShieldVerdict,
|
|
55
80
|
matchSensitivePath: () => matchSensitivePath,
|
|
56
81
|
matchesPattern: () => matchesPattern,
|
|
82
|
+
narrativeRuleLabel: () => narrativeRuleLabel,
|
|
57
83
|
normalizeCommandForPolicy: () => normalizeCommandForPolicy,
|
|
58
84
|
parseAllSshHostsFromCommand: () => parseAllSshHostsFromCommand,
|
|
85
|
+
previewArgs: () => previewArgs,
|
|
59
86
|
redactText: () => redactText,
|
|
60
87
|
scanArgs: () => scanArgs,
|
|
61
88
|
scanText: () => scanText,
|
|
62
89
|
sensitivePathMatch: () => sensitivePathMatch,
|
|
90
|
+
summarizeBlast: () => summarizeBlast,
|
|
91
|
+
summarizeScan: () => summarizeScan,
|
|
92
|
+
toScanFinding: () => toScanFinding,
|
|
93
|
+
truncateBlastPath: () => truncateBlastPath,
|
|
63
94
|
validateOverrides: () => validateOverrides,
|
|
64
95
|
validateRegex: () => validateRegex,
|
|
65
96
|
validateShieldDefinition: () => validateShieldDefinition
|
|
@@ -67,6 +98,7 @@ __export(src_exports, {
|
|
|
67
98
|
module.exports = __toCommonJS(src_exports);
|
|
68
99
|
|
|
69
100
|
// src/dlp/index.ts
|
|
101
|
+
var import_safe_regex2 = __toESM(require("safe-regex2"));
|
|
70
102
|
var ASSIGNMENT_CONTEXT_RE = /\b(?:password|passwd|secret|token|api[_-]?key|auth(?:_key|_token)?|credential|private[_-]?key|access[_-]?key|client[_-]?secret)\s*[=:]\s*/i;
|
|
71
103
|
function isAssignmentContext(text) {
|
|
72
104
|
return ASSIGNMENT_CONTEXT_RE.test(text);
|
|
@@ -550,6 +582,23 @@ function sensitivePathMatch(originalPath) {
|
|
|
550
582
|
};
|
|
551
583
|
}
|
|
552
584
|
var SENSITIVE_PATH_REGEXES = SENSITIVE_PATH_PATTERNS;
|
|
585
|
+
function assertBuiltinPatternsAreSafe() {
|
|
586
|
+
for (const p of DLP_PATTERNS) {
|
|
587
|
+
if (!(0, import_safe_regex2.default)(p.regex.source)) {
|
|
588
|
+
throw new Error(
|
|
589
|
+
`[node9 engine] Builtin DLP pattern '${p.name}' is vulnerable to ReDoS: ${p.regex.source}`
|
|
590
|
+
);
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
for (const re of SENSITIVE_PATH_PATTERNS) {
|
|
594
|
+
if (!(0, import_safe_regex2.default)(re.source)) {
|
|
595
|
+
throw new Error(
|
|
596
|
+
`[node9 engine] Builtin sensitive-path pattern is vulnerable to ReDoS: ${re.source}`
|
|
597
|
+
);
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
assertBuiltinPatternsAreSafe();
|
|
553
602
|
function maskSecret(raw, pattern) {
|
|
554
603
|
const match = raw.match(pattern);
|
|
555
604
|
if (!match) return "****";
|
|
@@ -671,9 +720,70 @@ var MESSAGE_FLAGS = /* @__PURE__ */ new Set([
|
|
|
671
720
|
]);
|
|
672
721
|
var SHELL_INTERPRETERS = /* @__PURE__ */ new Set(["bash", "sh", "zsh", "fish", "dash", "ksh"]);
|
|
673
722
|
var DOWNLOAD_CMDS = /* @__PURE__ */ new Set(["curl", "wget"]);
|
|
723
|
+
function isCatHeredocOrLit(part) {
|
|
724
|
+
if (!part) return false;
|
|
725
|
+
const t = syntax.NodeType(part);
|
|
726
|
+
if (t === "Lit") return true;
|
|
727
|
+
if (t !== "CmdSubst") return false;
|
|
728
|
+
const stmts = part.Stmts || [];
|
|
729
|
+
if (stmts.length !== 1) return false;
|
|
730
|
+
const stmt = stmts[0];
|
|
731
|
+
const redirs = stmt.Redirs || stmt.Cmd?.Redirs || [];
|
|
732
|
+
const hasHeredoc = redirs.some((r) => r && r.Hdoc);
|
|
733
|
+
if (!hasHeredoc) return false;
|
|
734
|
+
const cmd = stmt.Cmd;
|
|
735
|
+
if (!cmd || syntax.NodeType(cmd) !== "CallExpr") return false;
|
|
736
|
+
const firstArg = cmd.Args?.[0]?.Parts || [];
|
|
737
|
+
if (firstArg.length !== 1 || syntax.NodeType(firstArg[0]) !== "Lit") return false;
|
|
738
|
+
return (firstArg[0].Value || "").toLowerCase() === "cat";
|
|
739
|
+
}
|
|
740
|
+
var NORMALIZE_CACHE_MAX = 5e3;
|
|
741
|
+
var normalizeCache = /* @__PURE__ */ new Map();
|
|
742
|
+
var AST_CACHE_MAX = 5e3;
|
|
743
|
+
var astCache = /* @__PURE__ */ new Map();
|
|
744
|
+
var PARSE_FAIL = /* @__PURE__ */ Symbol("parse-fail");
|
|
745
|
+
function parseShared(command) {
|
|
746
|
+
const cached = astCache.get(command);
|
|
747
|
+
if (cached !== void 0) {
|
|
748
|
+
astCache.delete(command);
|
|
749
|
+
astCache.set(command, cached);
|
|
750
|
+
return cached;
|
|
751
|
+
}
|
|
752
|
+
let parsed;
|
|
753
|
+
try {
|
|
754
|
+
parsed = sharedParser.Parse(command, "cmd");
|
|
755
|
+
} catch {
|
|
756
|
+
parsed = PARSE_FAIL;
|
|
757
|
+
}
|
|
758
|
+
if (astCache.size >= AST_CACHE_MAX) {
|
|
759
|
+
const oldest = astCache.keys().next().value;
|
|
760
|
+
if (oldest !== void 0) astCache.delete(oldest);
|
|
761
|
+
}
|
|
762
|
+
astCache.set(command, parsed);
|
|
763
|
+
return parsed;
|
|
764
|
+
}
|
|
765
|
+
function cachedNormalize(command, compute) {
|
|
766
|
+
const hit = normalizeCache.get(command);
|
|
767
|
+
if (hit !== void 0) {
|
|
768
|
+
normalizeCache.delete(command);
|
|
769
|
+
normalizeCache.set(command, hit);
|
|
770
|
+
return hit;
|
|
771
|
+
}
|
|
772
|
+
const result = compute();
|
|
773
|
+
if (normalizeCache.size >= NORMALIZE_CACHE_MAX) {
|
|
774
|
+
const oldest = normalizeCache.keys().next().value;
|
|
775
|
+
if (oldest !== void 0) normalizeCache.delete(oldest);
|
|
776
|
+
}
|
|
777
|
+
normalizeCache.set(command, result);
|
|
778
|
+
return result;
|
|
779
|
+
}
|
|
674
780
|
function normalizeCommandForPolicy(command) {
|
|
781
|
+
return cachedNormalize(command, () => normalizeCommandForPolicyImpl(command));
|
|
782
|
+
}
|
|
783
|
+
function normalizeCommandForPolicyImpl(command) {
|
|
784
|
+
const f = parseShared(command);
|
|
785
|
+
if (f === PARSE_FAIL) return command;
|
|
675
786
|
try {
|
|
676
|
-
const f = sharedParser.Parse(command, "cmd");
|
|
677
787
|
const strips = [];
|
|
678
788
|
syntax.Walk(f, (node) => {
|
|
679
789
|
if (!node) return false;
|
|
@@ -695,7 +805,11 @@ function normalizeCommandForPolicy(command) {
|
|
|
695
805
|
} else if (nt === "DblQuoted") {
|
|
696
806
|
const innerParts = quotedNode.Parts || [];
|
|
697
807
|
const allLit = innerParts.length === 0 || innerParts.every((p) => syntax.NodeType(p) === "Lit");
|
|
698
|
-
if (allLit)
|
|
808
|
+
if (allLit) {
|
|
809
|
+
strips.push([next.Pos().Offset(), next.End().Offset()]);
|
|
810
|
+
} else if (innerParts.every((p) => isCatHeredocOrLit(p))) {
|
|
811
|
+
strips.push([next.Pos().Offset(), next.End().Offset()]);
|
|
812
|
+
}
|
|
699
813
|
}
|
|
700
814
|
}
|
|
701
815
|
return true;
|
|
@@ -764,6 +878,242 @@ function detectDangerousShellExec(command) {
|
|
|
764
878
|
}
|
|
765
879
|
}
|
|
766
880
|
var detectDangerousEval = detectDangerousShellExec;
|
|
881
|
+
var FS_READ_TOOLS = /* @__PURE__ */ new Set([
|
|
882
|
+
"cat",
|
|
883
|
+
"less",
|
|
884
|
+
"head",
|
|
885
|
+
"tail",
|
|
886
|
+
"bat",
|
|
887
|
+
"more",
|
|
888
|
+
"open",
|
|
889
|
+
"print",
|
|
890
|
+
"nano",
|
|
891
|
+
"vim",
|
|
892
|
+
"vi",
|
|
893
|
+
"emacs",
|
|
894
|
+
"code",
|
|
895
|
+
"type"
|
|
896
|
+
]);
|
|
897
|
+
var FS_OP_PRESCREEN_RE = /(?:^|[\s|;&(`\n])(?:rm|cat|less|head|tail|bat|more|open|print|nano|vim|vi|emacs|code|type)\b/;
|
|
898
|
+
var HOME_CACHE_ALLOWLIST = [
|
|
899
|
+
".cache",
|
|
900
|
+
".npm/_npx",
|
|
901
|
+
".npm/_cacache",
|
|
902
|
+
".cargo/registry",
|
|
903
|
+
".gradle/caches",
|
|
904
|
+
".gradle/.tmp",
|
|
905
|
+
".m2/repository",
|
|
906
|
+
".pnpm-store",
|
|
907
|
+
".yarn/cache",
|
|
908
|
+
".yarn/.cache",
|
|
909
|
+
".cache/pip",
|
|
910
|
+
".local/share/Trash",
|
|
911
|
+
".rustup/downloads"
|
|
912
|
+
];
|
|
913
|
+
var SENSITIVE_PATH_RULES = [
|
|
914
|
+
{
|
|
915
|
+
rule: "shield:project-jail:block-read-ssh",
|
|
916
|
+
reason: "Reading SSH private keys is blocked by project-jail shield",
|
|
917
|
+
match: (p) => /(^|[\\/])\.ssh[\\/]/i.test(p)
|
|
918
|
+
},
|
|
919
|
+
{
|
|
920
|
+
rule: "shield:project-jail:block-read-aws",
|
|
921
|
+
reason: "Reading AWS credentials is blocked by project-jail shield",
|
|
922
|
+
match: (p) => /(^|[\\/])\.aws[\\/]/i.test(p)
|
|
923
|
+
},
|
|
924
|
+
{
|
|
925
|
+
// Mirrors the JSON shield's `.env` pattern (project-jail.json's
|
|
926
|
+
// review-read-env-any-tool) so the AST FS-op path catches the
|
|
927
|
+
// same set the regex shield does — including Next.js / Vite's
|
|
928
|
+
// `.env.<env>.local` double-suffix overrides which are commonly
|
|
929
|
+
// gitignored AND commonly contain real secrets.
|
|
930
|
+
//
|
|
931
|
+
// Intentional non-matches (dev fixtures): .env.example, .env.sample,
|
|
932
|
+
// .env.template, .env.test, .envrc. See shields.test.ts:983-995
|
|
933
|
+
// for the canonical test-asserted contract.
|
|
934
|
+
rule: "shield:project-jail:block-read-env",
|
|
935
|
+
reason: "Reading .env files is blocked by project-jail shield",
|
|
936
|
+
match: (p) => /(?:^|[\\/])\.env(?:\.(?:local|production|staging|development|production\.local|staging\.local|development\.local))?$/i.test(
|
|
937
|
+
p
|
|
938
|
+
)
|
|
939
|
+
},
|
|
940
|
+
{
|
|
941
|
+
// verdict: 'review' (not 'block') is a deliberate design choice
|
|
942
|
+
// documented in commit 29327a8. SSH keys and AWS credentials are
|
|
943
|
+
// cryptographic material with no legitimate read use-case for
|
|
944
|
+
// an AI agent → hard `block`. But .netrc / .npmrc / .docker /
|
|
945
|
+
// .kube / gcloud are CONFIG files that hold tokens AND have
|
|
946
|
+
// legitimate diagnostic reads ("which registry am I configured
|
|
947
|
+
// for", "what cluster am I on"). Hard-blocking those creates
|
|
948
|
+
// friction without much safety win because the review gate
|
|
949
|
+
// still catches genuine exfiltration attempts.
|
|
950
|
+
//
|
|
951
|
+
// The review gate FAILS CLOSED on timeout (daemon.approvalTimeoutMs
|
|
952
|
+
// returns a deny verdict via the orchestrator's timeout branch),
|
|
953
|
+
// so a stuck or unattended approval does NOT silently grant
|
|
954
|
+
// credential access. If the threat model demands strict block,
|
|
955
|
+
// a future per-shield strict-mode toggle is the right fix —
|
|
956
|
+
// not a regex-level upgrade here.
|
|
957
|
+
rule: "shield:project-jail:review-read-credentials",
|
|
958
|
+
reason: "Reading credential files requires approval (project-jail shield)",
|
|
959
|
+
verdict: "review",
|
|
960
|
+
match: (p) => (
|
|
961
|
+
// .kube/config holds Kubernetes cluster credentials and was
|
|
962
|
+
// flagged as missing by the node9-pr-agent review (the comment
|
|
963
|
+
// above mentioned .kube but the regex didn't include it — a
|
|
964
|
+
// textbook code-comment vs code drift). The JSON shield's
|
|
965
|
+
// review-read-credentials-any-tool already had it. Now aligned.
|
|
966
|
+
/(?:credentials\.json|\.netrc|\.npmrc|\.docker[\\/]config\.json|gcloud[\\/]credentials|\.kube[\\/]config)$/i.test(
|
|
967
|
+
p
|
|
968
|
+
)
|
|
969
|
+
)
|
|
970
|
+
}
|
|
971
|
+
];
|
|
972
|
+
var BASH_TOOL_NAMES = /* @__PURE__ */ new Set([
|
|
973
|
+
"bash",
|
|
974
|
+
"execute_bash",
|
|
975
|
+
"run_shell_command",
|
|
976
|
+
"shell",
|
|
977
|
+
"exec_command"
|
|
978
|
+
]);
|
|
979
|
+
function isBashTool(toolName) {
|
|
980
|
+
return BASH_TOOL_NAMES.has(toolName.toLowerCase());
|
|
981
|
+
}
|
|
982
|
+
var AST_FS_REGEX_RULES = /* @__PURE__ */ new Set([
|
|
983
|
+
"block-rm-rf-home",
|
|
984
|
+
"shield:project-jail:block-read-ssh",
|
|
985
|
+
"shield:project-jail:block-read-aws",
|
|
986
|
+
"shield:project-jail:block-read-env",
|
|
987
|
+
"shield:project-jail:review-read-credentials"
|
|
988
|
+
]);
|
|
989
|
+
function isProtectedHomePath(rawPath) {
|
|
990
|
+
let p = rawPath.replace(/^\$HOME[\\/]?|^\$\{HOME\}[\\/]?/, "~/");
|
|
991
|
+
let underHome = false;
|
|
992
|
+
if (p === "~" || p.startsWith("~/") || p.startsWith("~\\")) {
|
|
993
|
+
p = p.replace(/^~[\\/]?/, "");
|
|
994
|
+
underHome = true;
|
|
995
|
+
} else if (/^\/home\/[^/]+/.test(p) || /^\/root(\/|$)/.test(p)) {
|
|
996
|
+
p = p.replace(/^\/home\/[^/]+[\\/]?|^\/root[\\/]?/, "");
|
|
997
|
+
underHome = true;
|
|
998
|
+
}
|
|
999
|
+
if (!underHome) return false;
|
|
1000
|
+
if (p === "" || p === "." || p === "./") return true;
|
|
1001
|
+
for (const safe of HOME_CACHE_ALLOWLIST) {
|
|
1002
|
+
if (p === safe || p.startsWith(safe + "/") || p.startsWith(safe + "\\")) {
|
|
1003
|
+
return false;
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
return true;
|
|
1007
|
+
}
|
|
1008
|
+
function extractLiteralArgs(callExpr) {
|
|
1009
|
+
const args = callExpr.Args || [];
|
|
1010
|
+
if (args.length === 0) return { name: "", flags: [], paths: [] };
|
|
1011
|
+
const litFromWord = (w) => {
|
|
1012
|
+
const parts = w?.Parts || [];
|
|
1013
|
+
let s = "";
|
|
1014
|
+
for (const p of parts) {
|
|
1015
|
+
const t = syntax.NodeType(p);
|
|
1016
|
+
if (t === "Lit") s += (p.Value ?? "").replace(/\\(.)/g, "$1");
|
|
1017
|
+
else if (t === "SglQuoted") s += p.Value ?? "";
|
|
1018
|
+
else if (t === "DblQuoted") {
|
|
1019
|
+
const inner = p.Parts || [];
|
|
1020
|
+
if (!inner.every((ip) => syntax.NodeType(ip) === "Lit")) return null;
|
|
1021
|
+
s += inner.map((ip) => ip.Value ?? "").join("");
|
|
1022
|
+
} else {
|
|
1023
|
+
return null;
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
return s;
|
|
1027
|
+
};
|
|
1028
|
+
const name = (litFromWord(args[0]) || "").toLowerCase();
|
|
1029
|
+
const flags = [];
|
|
1030
|
+
const paths = [];
|
|
1031
|
+
for (let i = 1; i < args.length; i++) {
|
|
1032
|
+
const v = litFromWord(args[i]);
|
|
1033
|
+
if (v === null) continue;
|
|
1034
|
+
if (v.startsWith("-")) flags.push(v);
|
|
1035
|
+
else paths.push(v);
|
|
1036
|
+
}
|
|
1037
|
+
return { name, flags, paths };
|
|
1038
|
+
}
|
|
1039
|
+
var FS_OP_CACHE_MAX = 5e3;
|
|
1040
|
+
var fsOpCache = /* @__PURE__ */ new Map();
|
|
1041
|
+
function analyzeFsOperation(command) {
|
|
1042
|
+
if (!FS_OP_PRESCREEN_RE.test(command)) return null;
|
|
1043
|
+
if (fsOpCache.has(command)) {
|
|
1044
|
+
const hit = fsOpCache.get(command) ?? null;
|
|
1045
|
+
fsOpCache.delete(command);
|
|
1046
|
+
fsOpCache.set(command, hit);
|
|
1047
|
+
return hit;
|
|
1048
|
+
}
|
|
1049
|
+
const computed = analyzeFsOperationImpl(command);
|
|
1050
|
+
if (fsOpCache.size >= FS_OP_CACHE_MAX) {
|
|
1051
|
+
const oldest = fsOpCache.keys().next().value;
|
|
1052
|
+
if (oldest !== void 0) fsOpCache.delete(oldest);
|
|
1053
|
+
}
|
|
1054
|
+
fsOpCache.set(command, computed);
|
|
1055
|
+
return computed;
|
|
1056
|
+
}
|
|
1057
|
+
function analyzeFsOperationImpl(command) {
|
|
1058
|
+
const f = parseShared(command);
|
|
1059
|
+
if (f === PARSE_FAIL) return null;
|
|
1060
|
+
let result = null;
|
|
1061
|
+
try {
|
|
1062
|
+
syntax.Walk(f, (node) => {
|
|
1063
|
+
if (!node || result) return false;
|
|
1064
|
+
const n = node;
|
|
1065
|
+
if (syntax.NodeType(n) !== "CallExpr") return true;
|
|
1066
|
+
const { name, flags, paths } = extractLiteralArgs(n);
|
|
1067
|
+
if (!name) return true;
|
|
1068
|
+
if (name === "rm") {
|
|
1069
|
+
const flagStr = flags.join("").toLowerCase();
|
|
1070
|
+
const hasR = /[r]/.test(flagStr) || flags.includes("--recursive");
|
|
1071
|
+
const hasF = /[f]/.test(flagStr) || flags.includes("--force");
|
|
1072
|
+
if (hasR && hasF) {
|
|
1073
|
+
for (const p of paths) {
|
|
1074
|
+
if (isProtectedHomePath(p)) {
|
|
1075
|
+
result = {
|
|
1076
|
+
ruleName: "block-rm-rf-home",
|
|
1077
|
+
verdict: "block",
|
|
1078
|
+
reason: "Recursive delete of home directory is irreversible",
|
|
1079
|
+
path: p
|
|
1080
|
+
};
|
|
1081
|
+
return false;
|
|
1082
|
+
}
|
|
1083
|
+
if (p === "/" || /^\/+$/.test(p)) {
|
|
1084
|
+
result = {
|
|
1085
|
+
ruleName: "block-rm-rf-home",
|
|
1086
|
+
verdict: "block",
|
|
1087
|
+
reason: "Recursive delete of root is catastrophic",
|
|
1088
|
+
path: p
|
|
1089
|
+
};
|
|
1090
|
+
return false;
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
if (FS_READ_TOOLS.has(name)) {
|
|
1096
|
+
for (const p of paths) {
|
|
1097
|
+
for (const sp of SENSITIVE_PATH_RULES) {
|
|
1098
|
+
if (sp.match(p)) {
|
|
1099
|
+
result = {
|
|
1100
|
+
ruleName: sp.rule,
|
|
1101
|
+
verdict: sp.verdict ?? "block",
|
|
1102
|
+
reason: sp.reason,
|
|
1103
|
+
path: p
|
|
1104
|
+
};
|
|
1105
|
+
return false;
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
return true;
|
|
1111
|
+
});
|
|
1112
|
+
return result;
|
|
1113
|
+
} catch {
|
|
1114
|
+
return null;
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
767
1117
|
function analyzeShellCommand(command) {
|
|
768
1118
|
const actions = [];
|
|
769
1119
|
const paths = [];
|
|
@@ -1141,7 +1491,7 @@ function parseAllSshHostsFromCommand(command) {
|
|
|
1141
1491
|
var import_picomatch = __toESM(require("picomatch"));
|
|
1142
1492
|
|
|
1143
1493
|
// src/utils/regex.ts
|
|
1144
|
-
var
|
|
1494
|
+
var import_safe_regex22 = __toESM(require("safe-regex2"));
|
|
1145
1495
|
var MAX_REGEX_LENGTH = 100;
|
|
1146
1496
|
var REGEX_CACHE_MAX = 500;
|
|
1147
1497
|
var regexCache = /* @__PURE__ */ new Map();
|
|
@@ -1154,7 +1504,7 @@ function validateRegex(pattern) {
|
|
|
1154
1504
|
return `Invalid regex syntax: ${e.message}`;
|
|
1155
1505
|
}
|
|
1156
1506
|
if (/\\\d+[*+{]/.test(pattern)) return "Quantified backreferences are forbidden (ReDoS risk)";
|
|
1157
|
-
if (!(0,
|
|
1507
|
+
if (!(0, import_safe_regex22.default)(pattern)) return "Pattern rejected: potential ReDoS vulnerability detected";
|
|
1158
1508
|
return null;
|
|
1159
1509
|
}
|
|
1160
1510
|
function getCompiledRegex(pattern, flags = "") {
|
|
@@ -1191,17 +1541,30 @@ function matchesPattern(text, patterns) {
|
|
|
1191
1541
|
const withoutDotSlash = text.replace(/^\.\//, "");
|
|
1192
1542
|
return isMatch(withoutDotSlash) || isMatch(`./${withoutDotSlash}`);
|
|
1193
1543
|
}
|
|
1544
|
+
var FORBIDDEN_PATH_SEGMENTS = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype"]);
|
|
1194
1545
|
function getNestedValue(obj, path) {
|
|
1195
1546
|
if (!obj || typeof obj !== "object") return null;
|
|
1196
|
-
|
|
1547
|
+
const segments = path.split(".");
|
|
1548
|
+
for (const seg of segments) {
|
|
1549
|
+
if (FORBIDDEN_PATH_SEGMENTS.has(seg)) return null;
|
|
1550
|
+
}
|
|
1551
|
+
return segments.reduce((prev, curr) => prev?.[curr], obj);
|
|
1197
1552
|
}
|
|
1198
1553
|
function evaluateSmartConditions(args, rule) {
|
|
1199
1554
|
if (!rule.conditions || rule.conditions.length === 0) return true;
|
|
1200
1555
|
const mode = rule.conditionMode ?? "all";
|
|
1556
|
+
const fieldCache = /* @__PURE__ */ new Map();
|
|
1557
|
+
const resolveField = (field) => {
|
|
1558
|
+
if (fieldCache.has(field)) return fieldCache.get(field) ?? null;
|
|
1559
|
+
const rawVal = getNestedValue(args, field);
|
|
1560
|
+
const rawStr = rawVal !== null && rawVal !== void 0 ? String(rawVal) : null;
|
|
1561
|
+
const stripped = field === "command" && rawStr !== null ? normalizeCommandForPolicy(rawStr) : rawStr;
|
|
1562
|
+
const val = stripped !== null ? stripped.replace(/\s+/g, " ").trim() : null;
|
|
1563
|
+
fieldCache.set(field, val);
|
|
1564
|
+
return val;
|
|
1565
|
+
};
|
|
1201
1566
|
const results = rule.conditions.map((cond) => {
|
|
1202
|
-
const
|
|
1203
|
-
const normalized = rawVal !== null && rawVal !== void 0 ? String(rawVal).replace(/\s+/g, " ").trim() : null;
|
|
1204
|
-
const val = cond.field === "command" && normalized !== null ? normalizeCommandForPolicy(normalized) : normalized;
|
|
1567
|
+
const val = resolveField(cond.field);
|
|
1205
1568
|
switch (cond.op) {
|
|
1206
1569
|
case "exists":
|
|
1207
1570
|
return val !== null && val !== "";
|
|
@@ -1264,6 +1627,43 @@ function checkDangerousSql(sql) {
|
|
|
1264
1627
|
return "UPDATE without WHERE \u2014 updates every row";
|
|
1265
1628
|
return null;
|
|
1266
1629
|
}
|
|
1630
|
+
function pipeChainVerdict(command, isTrustedHost) {
|
|
1631
|
+
const pipeAnalysis = analyzePipeChain(command);
|
|
1632
|
+
if (!pipeAnalysis.isPipeline) return null;
|
|
1633
|
+
if (pipeAnalysis.risk !== "critical" && pipeAnalysis.risk !== "high") return null;
|
|
1634
|
+
const sinks = pipeAnalysis.sinkTargets;
|
|
1635
|
+
const allTrusted = sinks.length > 0 && sinks.every((host) => isTrustedHost ? isTrustedHost(host) : false);
|
|
1636
|
+
if (pipeAnalysis.risk === "critical") {
|
|
1637
|
+
if (allTrusted) {
|
|
1638
|
+
return {
|
|
1639
|
+
decision: "review",
|
|
1640
|
+
blockedByLabel: "Node9: Pipe-Chain to Trusted Host (obfuscated)",
|
|
1641
|
+
reason: `Obfuscated pipe to trusted host(s): ${sinks.join(", ")} \u2014 requires approval`,
|
|
1642
|
+
tier: 3
|
|
1643
|
+
};
|
|
1644
|
+
}
|
|
1645
|
+
return {
|
|
1646
|
+
decision: "block",
|
|
1647
|
+
blockedByLabel: "Node9: Pipe-Chain Exfiltration (critical)",
|
|
1648
|
+
reason: `Sensitive file piped through obfuscator to network sink: ${pipeAnalysis.sourceFiles.join(", ")} \u2192 ${sinks.join(", ")}`,
|
|
1649
|
+
tier: 3
|
|
1650
|
+
};
|
|
1651
|
+
}
|
|
1652
|
+
if (allTrusted) {
|
|
1653
|
+
return {
|
|
1654
|
+
decision: "allow",
|
|
1655
|
+
blockedByLabel: "Node9: Pipe-Chain to Trusted Host",
|
|
1656
|
+
reason: `Sensitive file piped to trusted host(s): ${sinks.join(", ")}`,
|
|
1657
|
+
tier: 3
|
|
1658
|
+
};
|
|
1659
|
+
}
|
|
1660
|
+
return {
|
|
1661
|
+
decision: "review",
|
|
1662
|
+
blockedByLabel: "Node9: Pipe-Chain Exfiltration (high)",
|
|
1663
|
+
reason: `Sensitive file piped to network sink: ${pipeAnalysis.sourceFiles.join(", ")} \u2192 ${sinks.join(", ")}`,
|
|
1664
|
+
tier: 3
|
|
1665
|
+
};
|
|
1666
|
+
}
|
|
1267
1667
|
async function evaluatePolicy(config, toolName, args, context = {}, hooks = {}) {
|
|
1268
1668
|
const { agent, cwd, activeEnvironment } = context;
|
|
1269
1669
|
const { checkProvenance, isTrustedHost } = hooks;
|
|
@@ -1279,9 +1679,27 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
|
|
|
1279
1679
|
}
|
|
1280
1680
|
}
|
|
1281
1681
|
if (wouldBeIgnored) return { decision: "allow" };
|
|
1682
|
+
const bashCommand = agent !== "Terminal" && isBashTool(toolName) && args && typeof args === "object" ? typeof args.command === "string" ? args.command : null : null;
|
|
1683
|
+
if (bashCommand !== null) {
|
|
1684
|
+
const pipeVerdict = pipeChainVerdict(bashCommand, isTrustedHost);
|
|
1685
|
+
if (pipeVerdict) return pipeVerdict;
|
|
1686
|
+
const fsVerdict = analyzeFsOperation(bashCommand);
|
|
1687
|
+
if (fsVerdict) {
|
|
1688
|
+
const isShieldRule = fsVerdict.ruleName.startsWith("shield:");
|
|
1689
|
+
const labelPrefix = isShieldRule ? "project-jail (AST)" : "Node9 (AST)";
|
|
1690
|
+
return {
|
|
1691
|
+
decision: fsVerdict.verdict,
|
|
1692
|
+
blockedByLabel: `${labelPrefix}: ${fsVerdict.ruleName}`,
|
|
1693
|
+
reason: fsVerdict.reason,
|
|
1694
|
+
tier: 2,
|
|
1695
|
+
ruleName: fsVerdict.ruleName,
|
|
1696
|
+
ruleDescription: fsVerdict.reason
|
|
1697
|
+
};
|
|
1698
|
+
}
|
|
1699
|
+
}
|
|
1282
1700
|
if (config.policy.smartRules.length > 0) {
|
|
1283
1701
|
const matchedRule = config.policy.smartRules.find(
|
|
1284
|
-
(rule) => matchesPattern(toolName, rule.tool) && evaluateSmartConditions(args, rule)
|
|
1702
|
+
(rule) => matchesPattern(toolName, rule.tool) && !(bashCommand !== null && rule.name && AST_FS_REGEX_RULES.has(rule.name)) && evaluateSmartConditions(args, rule)
|
|
1285
1703
|
);
|
|
1286
1704
|
if (matchedRule) {
|
|
1287
1705
|
if (matchedRule.verdict === "allow")
|
|
@@ -1339,41 +1757,8 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
|
|
|
1339
1757
|
tier: 3
|
|
1340
1758
|
};
|
|
1341
1759
|
}
|
|
1342
|
-
const
|
|
1343
|
-
if (
|
|
1344
|
-
const sinks = pipeAnalysis.sinkTargets;
|
|
1345
|
-
const allTrusted = sinks.length > 0 && sinks.every((host) => isTrustedHost ? isTrustedHost(host) : false);
|
|
1346
|
-
if (pipeAnalysis.risk === "critical") {
|
|
1347
|
-
if (allTrusted) {
|
|
1348
|
-
return {
|
|
1349
|
-
decision: "review",
|
|
1350
|
-
blockedByLabel: "Node9: Pipe-Chain to Trusted Host (obfuscated)",
|
|
1351
|
-
reason: `Obfuscated pipe to trusted host(s): ${sinks.join(", ")} \u2014 requires approval`,
|
|
1352
|
-
tier: 3
|
|
1353
|
-
};
|
|
1354
|
-
}
|
|
1355
|
-
return {
|
|
1356
|
-
decision: "block",
|
|
1357
|
-
blockedByLabel: "Node9: Pipe-Chain Exfiltration (critical)",
|
|
1358
|
-
reason: `Sensitive file piped through obfuscator to network sink: ${pipeAnalysis.sourceFiles.join(", ")} \u2192 ${sinks.join(", ")}`,
|
|
1359
|
-
tier: 3
|
|
1360
|
-
};
|
|
1361
|
-
}
|
|
1362
|
-
if (allTrusted) {
|
|
1363
|
-
return {
|
|
1364
|
-
decision: "allow",
|
|
1365
|
-
blockedByLabel: "Node9: Pipe-Chain to Trusted Host",
|
|
1366
|
-
reason: `Sensitive file piped to trusted host(s): ${sinks.join(", ")}`,
|
|
1367
|
-
tier: 3
|
|
1368
|
-
};
|
|
1369
|
-
}
|
|
1370
|
-
return {
|
|
1371
|
-
decision: "review",
|
|
1372
|
-
blockedByLabel: "Node9: Pipe-Chain Exfiltration (high)",
|
|
1373
|
-
reason: `Sensitive file piped to network sink: ${pipeAnalysis.sourceFiles.join(", ")} \u2192 ${sinks.join(", ")}`,
|
|
1374
|
-
tier: 3
|
|
1375
|
-
};
|
|
1376
|
-
}
|
|
1760
|
+
const ptVerdict = pipeChainVerdict(shellCommand, isTrustedHost);
|
|
1761
|
+
if (ptVerdict) return ptVerdict;
|
|
1377
1762
|
const firstToken = analyzed.actions[0] ?? "";
|
|
1378
1763
|
if (["ssh", "scp", "rsync"].includes(firstToken)) {
|
|
1379
1764
|
const rawTokens = shellCommand.trim().split(/\s+/);
|
|
@@ -1479,6 +1864,9 @@ function isIgnoredTool(toolName, config) {
|
|
|
1479
1864
|
return matchesPattern(toolName, config.policy.ignoredTools);
|
|
1480
1865
|
}
|
|
1481
1866
|
|
|
1867
|
+
// src/shields/index.ts
|
|
1868
|
+
var import_safe_regex23 = __toESM(require("safe-regex2"));
|
|
1869
|
+
|
|
1482
1870
|
// src/shields/builtin/aws.json
|
|
1483
1871
|
var aws_default = {
|
|
1484
1872
|
name: "aws",
|
|
@@ -1910,15 +2298,6 @@ var k8s_default = {
|
|
|
1910
2298
|
dangerousWords: []
|
|
1911
2299
|
};
|
|
1912
2300
|
|
|
1913
|
-
// src/shields/builtin/mcp-tool-gating.json
|
|
1914
|
-
var mcp_tool_gating_default = {
|
|
1915
|
-
name: "mcp-tool-gating",
|
|
1916
|
-
description: "Intercept MCP tool lists and require user approval before the agent can use any tools from a new server",
|
|
1917
|
-
aliases: ["mcp-gating", "mcp-tools"],
|
|
1918
|
-
smartRules: [],
|
|
1919
|
-
dangerousWords: []
|
|
1920
|
-
};
|
|
1921
|
-
|
|
1922
2301
|
// src/shields/builtin/mongodb.json
|
|
1923
2302
|
var mongodb_default = {
|
|
1924
2303
|
name: "mongodb",
|
|
@@ -2056,7 +2435,7 @@ var project_jail_default = {
|
|
|
2056
2435
|
{
|
|
2057
2436
|
field: "command",
|
|
2058
2437
|
op: "matches",
|
|
2059
|
-
value: "(cat|less|head|tail|bat|more|open|print|nano|vim|vi|emacs|code|type)\\s
|
|
2438
|
+
value: "(cat|less|head|tail|bat|more|open|print|nano|vim|vi|emacs|code|type)\\s+.*?\\.ssh[\\/\\\\]",
|
|
2060
2439
|
flags: "i"
|
|
2061
2440
|
}
|
|
2062
2441
|
],
|
|
@@ -2070,7 +2449,7 @@ var project_jail_default = {
|
|
|
2070
2449
|
{
|
|
2071
2450
|
field: "command",
|
|
2072
2451
|
op: "matches",
|
|
2073
|
-
value: "(cat|less|head|tail|bat|more|open|print|nano|vim|vi|emacs|code|type)\\s
|
|
2452
|
+
value: "(cat|less|head|tail|bat|more|open|print|nano|vim|vi|emacs|code|type)\\s+.*?\\.aws[\\/\\\\]",
|
|
2074
2453
|
flags: "i"
|
|
2075
2454
|
}
|
|
2076
2455
|
],
|
|
@@ -2092,7 +2471,7 @@ var project_jail_default = {
|
|
|
2092
2471
|
reason: "Reading .env files is blocked by project-jail shield"
|
|
2093
2472
|
},
|
|
2094
2473
|
{
|
|
2095
|
-
name: "shield:project-jail:
|
|
2474
|
+
name: "shield:project-jail:review-read-credentials",
|
|
2096
2475
|
tool: "bash",
|
|
2097
2476
|
conditions: [
|
|
2098
2477
|
{
|
|
@@ -2102,8 +2481,64 @@ var project_jail_default = {
|
|
|
2102
2481
|
flags: "i"
|
|
2103
2482
|
}
|
|
2104
2483
|
],
|
|
2484
|
+
verdict: "review",
|
|
2485
|
+
reason: "Reading credential files requires approval (project-jail shield)"
|
|
2486
|
+
},
|
|
2487
|
+
{
|
|
2488
|
+
name: "shield:project-jail:block-read-ssh-any-tool",
|
|
2489
|
+
tool: "*",
|
|
2490
|
+
conditions: [
|
|
2491
|
+
{
|
|
2492
|
+
field: "file_path",
|
|
2493
|
+
op: "matches",
|
|
2494
|
+
value: "(^|[\\/\\\\])\\.ssh[\\/\\\\]",
|
|
2495
|
+
flags: "i"
|
|
2496
|
+
}
|
|
2497
|
+
],
|
|
2105
2498
|
verdict: "block",
|
|
2106
|
-
reason: "Reading
|
|
2499
|
+
reason: "Reading SSH private keys is blocked by project-jail shield"
|
|
2500
|
+
},
|
|
2501
|
+
{
|
|
2502
|
+
name: "shield:project-jail:block-read-aws-any-tool",
|
|
2503
|
+
tool: "*",
|
|
2504
|
+
conditions: [
|
|
2505
|
+
{
|
|
2506
|
+
field: "file_path",
|
|
2507
|
+
op: "matches",
|
|
2508
|
+
value: "(^|[\\/\\\\])\\.aws[\\/\\\\]",
|
|
2509
|
+
flags: "i"
|
|
2510
|
+
}
|
|
2511
|
+
],
|
|
2512
|
+
verdict: "block",
|
|
2513
|
+
reason: "Reading AWS credentials is blocked by project-jail shield"
|
|
2514
|
+
},
|
|
2515
|
+
{
|
|
2516
|
+
name: "shield:project-jail:review-read-env-any-tool",
|
|
2517
|
+
tool: "*",
|
|
2518
|
+
conditions: [
|
|
2519
|
+
{
|
|
2520
|
+
field: "file_path",
|
|
2521
|
+
op: "matches",
|
|
2522
|
+
value: "(^|[\\/\\\\])\\.env(\\.(local|production|staging|development|production\\.local|staging\\.local|development\\.local))?$",
|
|
2523
|
+
flags: "i"
|
|
2524
|
+
}
|
|
2525
|
+
],
|
|
2526
|
+
verdict: "review",
|
|
2527
|
+
reason: "Reading .env files requires approval (project-jail shield)"
|
|
2528
|
+
},
|
|
2529
|
+
{
|
|
2530
|
+
name: "shield:project-jail:review-read-credentials-any-tool",
|
|
2531
|
+
tool: "*",
|
|
2532
|
+
conditions: [
|
|
2533
|
+
{
|
|
2534
|
+
field: "file_path",
|
|
2535
|
+
op: "matches",
|
|
2536
|
+
value: ".*(credentials\\.json|\\.netrc|\\.npmrc|\\.docker[\\/\\\\]config\\.json|gcloud[\\/\\\\]credentials|\\.kube[\\/\\\\]config)",
|
|
2537
|
+
flags: "i"
|
|
2538
|
+
}
|
|
2539
|
+
],
|
|
2540
|
+
verdict: "review",
|
|
2541
|
+
reason: "Reading credential files requires approval (project-jail shield)"
|
|
2107
2542
|
}
|
|
2108
2543
|
],
|
|
2109
2544
|
dangerousWords: []
|
|
@@ -2233,12 +2668,29 @@ var BUILTIN_SHIELDS = {
|
|
|
2233
2668
|
[filesystem_default.name]: filesystem_default,
|
|
2234
2669
|
[github_default.name]: github_default,
|
|
2235
2670
|
[k8s_default.name]: k8s_default,
|
|
2236
|
-
[mcp_tool_gating_default.name]: mcp_tool_gating_default,
|
|
2237
2671
|
[mongodb_default.name]: mongodb_default,
|
|
2238
2672
|
[postgres_default.name]: postgres_default,
|
|
2239
2673
|
[project_jail_default.name]: project_jail_default,
|
|
2240
2674
|
[redis_default.name]: redis_default
|
|
2241
2675
|
};
|
|
2676
|
+
function assertBuiltinShieldRegexesAreSafe() {
|
|
2677
|
+
for (const shield of Object.values(BUILTIN_SHIELDS)) {
|
|
2678
|
+
for (const rule of shield.smartRules) {
|
|
2679
|
+
const conditions = rule.conditions ?? [];
|
|
2680
|
+
for (const cond of conditions) {
|
|
2681
|
+
if (cond.op !== "matches" && cond.op !== "notMatches") continue;
|
|
2682
|
+
const pattern = cond.value;
|
|
2683
|
+
if (!pattern) continue;
|
|
2684
|
+
if (!(0, import_safe_regex23.default)(pattern)) {
|
|
2685
|
+
throw new Error(
|
|
2686
|
+
`[node9 engine] Shield '${shield.name}' rule '${rule.name ?? rule.tool}' has unsafe regex: ${pattern}`
|
|
2687
|
+
);
|
|
2688
|
+
}
|
|
2689
|
+
}
|
|
2690
|
+
}
|
|
2691
|
+
}
|
|
2692
|
+
}
|
|
2693
|
+
assertBuiltinShieldRegexesAreSafe();
|
|
2242
2694
|
|
|
2243
2695
|
// src/loop/index.ts
|
|
2244
2696
|
var import_crypto = __toESM(require("crypto"));
|
|
@@ -2257,40 +2709,689 @@ function evaluateLoopWindow(records, tool, args, threshold, windowMs, now) {
|
|
|
2257
2709
|
return { nextRecords, count, looping: count >= threshold };
|
|
2258
2710
|
}
|
|
2259
2711
|
|
|
2712
|
+
// src/scan/index.ts
|
|
2713
|
+
function emptySignals() {
|
|
2714
|
+
return {
|
|
2715
|
+
dlpFindings: 0,
|
|
2716
|
+
piiFindings: 0,
|
|
2717
|
+
sensitiveFileReads: 0,
|
|
2718
|
+
privilegeEscalation: 0,
|
|
2719
|
+
networkExfil: 0,
|
|
2720
|
+
pipeToShell: 0,
|
|
2721
|
+
evalOfRemote: 0,
|
|
2722
|
+
destructiveOps: 0,
|
|
2723
|
+
loops: 0,
|
|
2724
|
+
longOutputRedactions: 0
|
|
2725
|
+
};
|
|
2726
|
+
}
|
|
2727
|
+
var FINDING_TO_SIGNAL = {
|
|
2728
|
+
dlp: "dlpFindings",
|
|
2729
|
+
pii: "piiFindings",
|
|
2730
|
+
"sensitive-file-read": "sensitiveFileReads",
|
|
2731
|
+
"privilege-escalation": "privilegeEscalation",
|
|
2732
|
+
"network-exfil": "networkExfil",
|
|
2733
|
+
"pipe-to-shell": "pipeToShell",
|
|
2734
|
+
"eval-of-remote": "evalOfRemote",
|
|
2735
|
+
"destructive-op": "destructiveOps",
|
|
2736
|
+
loop: "loops",
|
|
2737
|
+
"long-output-redacted": "longOutputRedactions"
|
|
2738
|
+
};
|
|
2739
|
+
var SCAN_SIGNAL_WEIGHTS = {
|
|
2740
|
+
dlpFindings: 30,
|
|
2741
|
+
piiFindings: 10,
|
|
2742
|
+
sensitiveFileReads: 20,
|
|
2743
|
+
privilegeEscalation: 15,
|
|
2744
|
+
networkExfil: 25,
|
|
2745
|
+
pipeToShell: 30,
|
|
2746
|
+
evalOfRemote: 30,
|
|
2747
|
+
destructiveOps: 15,
|
|
2748
|
+
loops: 3,
|
|
2749
|
+
longOutputRedactions: 1
|
|
2750
|
+
};
|
|
2751
|
+
function computeScanScore(signals) {
|
|
2752
|
+
let deduction = 0;
|
|
2753
|
+
for (const key of Object.keys(signals)) {
|
|
2754
|
+
deduction += signals[key] * SCAN_SIGNAL_WEIGHTS[key];
|
|
2755
|
+
}
|
|
2756
|
+
return Math.max(0, Math.min(100, 100 - deduction));
|
|
2757
|
+
}
|
|
2758
|
+
var LOOP_THRESHOLD_FOR_WASTE = 3;
|
|
2759
|
+
var COST_PER_LOOP_ITER_USD = 6e-3;
|
|
2760
|
+
function summarizeScan(findings, opts = {}) {
|
|
2761
|
+
const totalToolCalls = opts.totalToolCalls ?? 0;
|
|
2762
|
+
const topN = opts.topN ?? 10;
|
|
2763
|
+
const signals = emptySignals();
|
|
2764
|
+
const sessionIds = /* @__PURE__ */ new Set();
|
|
2765
|
+
const patternCounts = /* @__PURE__ */ new Map();
|
|
2766
|
+
for (const f of findings) {
|
|
2767
|
+
sessionIds.add(f.sessionId);
|
|
2768
|
+
const key = FINDING_TO_SIGNAL[f.type];
|
|
2769
|
+
signals[key]++;
|
|
2770
|
+
if (f.patternName) {
|
|
2771
|
+
patternCounts.set(f.patternName, (patternCounts.get(f.patternName) ?? 0) + 1);
|
|
2772
|
+
}
|
|
2773
|
+
}
|
|
2774
|
+
const topPatterns = [...patternCounts.entries()].sort((a, b) => {
|
|
2775
|
+
if (b[1] !== a[1]) return b[1] - a[1];
|
|
2776
|
+
return a[0].localeCompare(b[0]);
|
|
2777
|
+
}).slice(0, topN).map(([patternName, count]) => ({ patternName, count }));
|
|
2778
|
+
return {
|
|
2779
|
+
totalSessions: sessionIds.size,
|
|
2780
|
+
totalToolCalls,
|
|
2781
|
+
signals,
|
|
2782
|
+
topPatterns,
|
|
2783
|
+
score: computeScanScore(signals)
|
|
2784
|
+
};
|
|
2785
|
+
}
|
|
2786
|
+
|
|
2787
|
+
// src/severity/index.ts
|
|
2788
|
+
function classifyRuleSeverity(name, verdict) {
|
|
2789
|
+
const n = name.toLowerCase();
|
|
2790
|
+
const criticalPatterns = [
|
|
2791
|
+
"rm-rf",
|
|
2792
|
+
"eval-remote",
|
|
2793
|
+
"eval-curl",
|
|
2794
|
+
"read-aws",
|
|
2795
|
+
"read-ssh",
|
|
2796
|
+
"read-gcp",
|
|
2797
|
+
"read-cred",
|
|
2798
|
+
"delete-repo",
|
|
2799
|
+
"helm-uninstall",
|
|
2800
|
+
"drop-table",
|
|
2801
|
+
"drop-database",
|
|
2802
|
+
"drop-collection",
|
|
2803
|
+
"truncate",
|
|
2804
|
+
"flushall",
|
|
2805
|
+
"flushdb",
|
|
2806
|
+
"pipe-shell"
|
|
2807
|
+
];
|
|
2808
|
+
if (criticalPatterns.some((p) => n.includes(p))) return "critical";
|
|
2809
|
+
const highPatterns = [
|
|
2810
|
+
"force-push",
|
|
2811
|
+
"force_push",
|
|
2812
|
+
"git-destructive",
|
|
2813
|
+
"reset-hard",
|
|
2814
|
+
"rebase",
|
|
2815
|
+
"delete-branch",
|
|
2816
|
+
"delete-remote"
|
|
2817
|
+
];
|
|
2818
|
+
if (highPatterns.some((p) => n.includes(p))) return "high";
|
|
2819
|
+
if (verdict === "block") return "high";
|
|
2820
|
+
return "medium";
|
|
2821
|
+
}
|
|
2822
|
+
function narrativeRuleLabel(name) {
|
|
2823
|
+
const stripped = stripRulePrefixes(name);
|
|
2824
|
+
const map = {
|
|
2825
|
+
"read-aws": "AWS credentials read",
|
|
2826
|
+
"read-ssh": "SSH private key read",
|
|
2827
|
+
"read-gcp": "GCP credentials read",
|
|
2828
|
+
"read-cred": "credential file read",
|
|
2829
|
+
"delete-repo": "GitHub repository deletion",
|
|
2830
|
+
"helm-uninstall": "helm uninstall",
|
|
2831
|
+
"rm-rf-home": "rm -rf on home directory",
|
|
2832
|
+
"rm-rf": "rm -rf",
|
|
2833
|
+
"eval-remote": "eval of remote download",
|
|
2834
|
+
"eval-curl": "eval of curl output",
|
|
2835
|
+
"pipe-shell": "curl | bash",
|
|
2836
|
+
"drop-table": "DROP TABLE",
|
|
2837
|
+
"drop-database": "DROP DATABASE",
|
|
2838
|
+
"drop-collection": "DROP COLLECTION",
|
|
2839
|
+
truncate: "TRUNCATE",
|
|
2840
|
+
flushall: "Redis FLUSHALL",
|
|
2841
|
+
flushdb: "Redis FLUSHDB",
|
|
2842
|
+
"force-push": "force pushes",
|
|
2843
|
+
force_push: "force pushes",
|
|
2844
|
+
"reset-hard": "git reset --hard",
|
|
2845
|
+
"git-destructive": "destructive git operations",
|
|
2846
|
+
"delete-branch": "branch deletion",
|
|
2847
|
+
"delete-remote": "remote deletion",
|
|
2848
|
+
rebase: "git rebase",
|
|
2849
|
+
rm: "rm calls",
|
|
2850
|
+
sudo: "sudo calls",
|
|
2851
|
+
"eval-dynamic": "dynamic eval",
|
|
2852
|
+
"config-set": "Redis CONFIG SET"
|
|
2853
|
+
};
|
|
2854
|
+
for (const [key, label] of Object.entries(map)) {
|
|
2855
|
+
if (stripped.includes(key)) return label;
|
|
2856
|
+
}
|
|
2857
|
+
return stripped;
|
|
2858
|
+
}
|
|
2859
|
+
function stripRulePrefixes(name) {
|
|
2860
|
+
let n = name.toLowerCase();
|
|
2861
|
+
if (n.startsWith("org:")) n = n.slice(4);
|
|
2862
|
+
const shieldMatch = /^shield:[^:]+:(.+)$/.exec(n);
|
|
2863
|
+
if (shieldMatch) n = shieldMatch[1];
|
|
2864
|
+
n = n.replace(/^(block|review|allow)-/, "");
|
|
2865
|
+
return n;
|
|
2866
|
+
}
|
|
2867
|
+
function classifyAuditEntry(entry) {
|
|
2868
|
+
const ruleName = entry.riskMetadata?.ruleName;
|
|
2869
|
+
if (typeof ruleName === "string" && ruleName.length > 0) {
|
|
2870
|
+
const verdict = entry.action === "AUTO_BLOCKED" || entry.action === "DENIED" ? "block" : entry.action === "APPROVED" || entry.action === "AUTO_ALLOWED" ? "allow" : "review";
|
|
2871
|
+
return classifyRuleSeverity(ruleName, verdict);
|
|
2872
|
+
}
|
|
2873
|
+
const cb = entry.checkedBy ?? "";
|
|
2874
|
+
if (cb === "dlp-block" || cb.startsWith("dlp-saas:")) return "critical";
|
|
2875
|
+
if (cb.startsWith("eval-saas") || cb === "pipe-chain-saas:critical") {
|
|
2876
|
+
return "critical";
|
|
2877
|
+
}
|
|
2878
|
+
if (cb === "loop-detected") return "medium";
|
|
2879
|
+
const isBlocked = entry.action === "AUTO_BLOCKED" || entry.action === "DENIED";
|
|
2880
|
+
if (isBlocked) return "high";
|
|
2881
|
+
return null;
|
|
2882
|
+
}
|
|
2883
|
+
function computeSecurityScore(opts) {
|
|
2884
|
+
const { critical, high, medium, total } = opts;
|
|
2885
|
+
if (total === 0) return { score: 100, tier: "good" };
|
|
2886
|
+
const criticalRate = critical / total;
|
|
2887
|
+
const highRate = high / total;
|
|
2888
|
+
const mediumRate = medium / total;
|
|
2889
|
+
const deduction = Math.min(criticalRate * 3e3, 60) + Math.min(highRate * 500, 30) + Math.min(mediumRate * 100, 15);
|
|
2890
|
+
const score = Math.max(0, Math.min(100, Math.round(100 - deduction)));
|
|
2891
|
+
const tier = score >= 80 ? "good" : score >= 50 ? "at-risk" : "critical";
|
|
2892
|
+
return { score, tier };
|
|
2893
|
+
}
|
|
2894
|
+
function classifyScanSignal(key) {
|
|
2895
|
+
const w = SCAN_SIGNAL_WEIGHTS[key];
|
|
2896
|
+
if (w >= 25) return "critical";
|
|
2897
|
+
if (w >= 11) return "high";
|
|
2898
|
+
return "medium";
|
|
2899
|
+
}
|
|
2900
|
+
function computeBlendedSecurityScore(opts) {
|
|
2901
|
+
const { audit, scan } = opts;
|
|
2902
|
+
let critical = audit.critical;
|
|
2903
|
+
let high = audit.high;
|
|
2904
|
+
let medium = audit.medium;
|
|
2905
|
+
let total = audit.total;
|
|
2906
|
+
if (scan) {
|
|
2907
|
+
let scanFindingSum = 0;
|
|
2908
|
+
for (const key of Object.keys(scan.signals)) {
|
|
2909
|
+
const count = scan.signals[key];
|
|
2910
|
+
if (count <= 0) continue;
|
|
2911
|
+
const tier = classifyScanSignal(key);
|
|
2912
|
+
if (tier === "critical") critical += count;
|
|
2913
|
+
else if (tier === "high") high += count;
|
|
2914
|
+
else medium += count;
|
|
2915
|
+
scanFindingSum += count;
|
|
2916
|
+
}
|
|
2917
|
+
const scanContribution = Math.max(scan.totalToolCalls ?? 0, scanFindingSum);
|
|
2918
|
+
total += scanContribution;
|
|
2919
|
+
}
|
|
2920
|
+
return computeSecurityScore({ critical, high, medium, total });
|
|
2921
|
+
}
|
|
2922
|
+
|
|
2923
|
+
// src/blast/index.ts
|
|
2924
|
+
function truncateBlastPath(full) {
|
|
2925
|
+
if (!full) return "";
|
|
2926
|
+
const cleaned = full.replace(/[/\\]+$/, "");
|
|
2927
|
+
const parts = cleaned.split(/[/\\]+/).filter((p) => p.length > 0);
|
|
2928
|
+
if (parts.length <= 2) {
|
|
2929
|
+
return cleaned.startsWith("~") && !cleaned.startsWith("~/") ? cleaned : cleaned.startsWith("~/") ? cleaned : parts.join("/");
|
|
2930
|
+
}
|
|
2931
|
+
return parts.slice(-2).join("/");
|
|
2932
|
+
}
|
|
2933
|
+
function summarizeBlast(result, opts = {}) {
|
|
2934
|
+
const topN = opts.topN ?? 5;
|
|
2935
|
+
const sorted = [...result.reachable].sort((a, b) => {
|
|
2936
|
+
if (b.score !== a.score) return b.score - a.score;
|
|
2937
|
+
return a.label.localeCompare(b.label);
|
|
2938
|
+
});
|
|
2939
|
+
return {
|
|
2940
|
+
score: result.score,
|
|
2941
|
+
exposureCount: result.reachable.length + result.envFindings.length,
|
|
2942
|
+
envExposureCount: result.envFindings.length,
|
|
2943
|
+
worstPaths: sorted.slice(0, topN).map((f) => ({
|
|
2944
|
+
path: truncateBlastPath(f.label),
|
|
2945
|
+
score: f.score
|
|
2946
|
+
}))
|
|
2947
|
+
};
|
|
2948
|
+
}
|
|
2949
|
+
|
|
2950
|
+
// src/scan/destructive-regex.ts
|
|
2951
|
+
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;
|
|
2952
|
+
var PRIVILEGE_ESCALATION_RE = /\bchmod\s+(0?777|\+x)\b|\bchown\s+root\b/i;
|
|
2953
|
+
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;
|
|
2954
|
+
var FILE_TOOLS = /* @__PURE__ */ new Set([
|
|
2955
|
+
"read",
|
|
2956
|
+
"read_file",
|
|
2957
|
+
"edit",
|
|
2958
|
+
"edit_file",
|
|
2959
|
+
"write",
|
|
2960
|
+
"write_file",
|
|
2961
|
+
"multiedit",
|
|
2962
|
+
"grep",
|
|
2963
|
+
"grep_search",
|
|
2964
|
+
"glob",
|
|
2965
|
+
"list_files"
|
|
2966
|
+
]);
|
|
2967
|
+
|
|
2968
|
+
// src/scan/pii.ts
|
|
2969
|
+
var PII_EMAIL_RE = /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/;
|
|
2970
|
+
var PII_SSN_RE = /\b\d{3}-\d{2}-\d{4}\b/;
|
|
2971
|
+
var PII_PHONE_RE = /\b(?:\+?1[-.\s]?)?\(?\d{3}\)?[-.\s]\d{3}[-.\s]\d{4}\b/;
|
|
2972
|
+
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/;
|
|
2973
|
+
function detectPii(text) {
|
|
2974
|
+
const found = /* @__PURE__ */ new Set();
|
|
2975
|
+
if (/@/.test(text) && PII_EMAIL_RE.test(text)) found.add("Email");
|
|
2976
|
+
if (/-/.test(text) && PII_SSN_RE.test(text)) found.add("SSN");
|
|
2977
|
+
if (PII_PHONE_RE.test(text)) found.add("Phone");
|
|
2978
|
+
if (PII_CC_RE.test(text)) found.add("Credit Card");
|
|
2979
|
+
return [...found];
|
|
2980
|
+
}
|
|
2981
|
+
|
|
2982
|
+
// src/scan/canonical.ts
|
|
2983
|
+
var LONG_OUTPUT_THRESHOLD_BYTES = 100 * 1024;
|
|
2984
|
+
var CANONICAL_EXTRACTOR_VERSION = "canonical-v4";
|
|
2985
|
+
var CANONICAL_EXTRACTOR_HASH = "64a6a63a27f4646f";
|
|
2986
|
+
var DEDUPE_PREVIEW_LEN = 120;
|
|
2987
|
+
function extractCanonicalFindings(call, ctx) {
|
|
2988
|
+
const out = [];
|
|
2989
|
+
const ts = call.timestamp;
|
|
2990
|
+
const toolNameLower = call.toolName.toLowerCase();
|
|
2991
|
+
const command = typeof call.args.command === "string" ? call.args.command : null;
|
|
2992
|
+
const isBash = isBashTool(call.toolName) && command !== null;
|
|
2993
|
+
if (call.outputBytes !== void 0 && call.outputBytes > LONG_OUTPUT_THRESHOLD_BYTES) {
|
|
2994
|
+
out.push(
|
|
2995
|
+
makeFinding({
|
|
2996
|
+
type: "long-output-redacted",
|
|
2997
|
+
ruleName: "long-output-redacted",
|
|
2998
|
+
verdict: "review",
|
|
2999
|
+
severity: "medium",
|
|
3000
|
+
reason: `Tool output exceeded ${LONG_OUTPUT_THRESHOLD_BYTES} bytes and was redacted`,
|
|
3001
|
+
toolName: call.toolName,
|
|
3002
|
+
ctx,
|
|
3003
|
+
ts,
|
|
3004
|
+
sourceType: "engine"
|
|
3005
|
+
})
|
|
3006
|
+
);
|
|
3007
|
+
}
|
|
3008
|
+
if (ctx.dlpEnabled) {
|
|
3009
|
+
const dlp = scanArgs(call.args);
|
|
3010
|
+
if (dlp) {
|
|
3011
|
+
out.push(
|
|
3012
|
+
makeFinding({
|
|
3013
|
+
type: "dlp",
|
|
3014
|
+
ruleName: `dlp:${dlp.patternName}`,
|
|
3015
|
+
patternName: dlp.patternName,
|
|
3016
|
+
verdict: dlp.severity === "block" ? "block" : "review",
|
|
3017
|
+
severity: dlp.severity === "block" ? "critical" : "medium",
|
|
3018
|
+
reason: `${dlp.patternName} detected in ${dlp.fieldPath}`,
|
|
3019
|
+
toolName: call.toolName,
|
|
3020
|
+
ctx,
|
|
3021
|
+
ts,
|
|
3022
|
+
sourceType: "engine",
|
|
3023
|
+
input: call.args,
|
|
3024
|
+
redactedSample: dlp.redactedSample
|
|
3025
|
+
})
|
|
3026
|
+
);
|
|
3027
|
+
}
|
|
3028
|
+
}
|
|
3029
|
+
for (const value of stringValues(call.args)) {
|
|
3030
|
+
const piiHits = detectPii(value);
|
|
3031
|
+
for (const pattern of piiHits) {
|
|
3032
|
+
out.push(
|
|
3033
|
+
makeFinding({
|
|
3034
|
+
type: "pii",
|
|
3035
|
+
ruleName: `pii:${pattern.toLowerCase().replace(/\s+/g, "-")}`,
|
|
3036
|
+
patternName: pattern,
|
|
3037
|
+
verdict: "review",
|
|
3038
|
+
severity: "medium",
|
|
3039
|
+
reason: `${pattern} pattern detected in tool input`,
|
|
3040
|
+
toolName: call.toolName,
|
|
3041
|
+
ctx,
|
|
3042
|
+
ts,
|
|
3043
|
+
sourceType: "engine"
|
|
3044
|
+
})
|
|
3045
|
+
);
|
|
3046
|
+
}
|
|
3047
|
+
}
|
|
3048
|
+
if (FILE_TOOLS.has(toolNameLower)) {
|
|
3049
|
+
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 || "";
|
|
3050
|
+
if (filePath && SENSITIVE_PATH_RE.test(filePath)) {
|
|
3051
|
+
out.push(
|
|
3052
|
+
makeFinding({
|
|
3053
|
+
type: "sensitive-file-read",
|
|
3054
|
+
ruleName: "sensitive-file-read",
|
|
3055
|
+
verdict: "review",
|
|
3056
|
+
severity: "critical",
|
|
3057
|
+
reason: `Sensitive file path read via ${call.toolName}`,
|
|
3058
|
+
toolName: call.toolName,
|
|
3059
|
+
ctx,
|
|
3060
|
+
ts,
|
|
3061
|
+
sourceType: "engine",
|
|
3062
|
+
subjectPath: filePath
|
|
3063
|
+
})
|
|
3064
|
+
);
|
|
3065
|
+
}
|
|
3066
|
+
}
|
|
3067
|
+
if (!isBash || command === null) {
|
|
3068
|
+
return out;
|
|
3069
|
+
}
|
|
3070
|
+
const fsVerdict = analyzeFsOperation(command);
|
|
3071
|
+
if (fsVerdict) {
|
|
3072
|
+
const isShield = fsVerdict.ruleName.startsWith("shield:");
|
|
3073
|
+
out.push(
|
|
3074
|
+
makeFinding({
|
|
3075
|
+
type: "ast-fs-op",
|
|
3076
|
+
ruleName: fsVerdict.ruleName,
|
|
3077
|
+
verdict: fsVerdict.verdict,
|
|
3078
|
+
severity: classifyRuleSeverity(fsVerdict.ruleName, fsVerdict.verdict),
|
|
3079
|
+
reason: fsVerdict.reason,
|
|
3080
|
+
toolName: call.toolName,
|
|
3081
|
+
ctx,
|
|
3082
|
+
ts,
|
|
3083
|
+
sourceType: isShield ? "shield" : "engine",
|
|
3084
|
+
shieldLabel: isShield ? "project-jail (AST)" : "Node9 (AST)",
|
|
3085
|
+
subjectPath: fsVerdict.path,
|
|
3086
|
+
input: call.args
|
|
3087
|
+
})
|
|
3088
|
+
);
|
|
3089
|
+
}
|
|
3090
|
+
for (const source of ctx.rules) {
|
|
3091
|
+
const r = source.rule;
|
|
3092
|
+
if (r.verdict === "allow") continue;
|
|
3093
|
+
if (r.tool && !matchesPattern(toolNameLower, r.tool)) continue;
|
|
3094
|
+
if (r.name && AST_FS_REGEX_RULES.has(r.name)) continue;
|
|
3095
|
+
if (!evaluateSmartConditions(call.args, r)) continue;
|
|
3096
|
+
out.push(
|
|
3097
|
+
makeFinding({
|
|
3098
|
+
type: "smart-rule",
|
|
3099
|
+
ruleName: r.name ?? r.tool,
|
|
3100
|
+
verdict: r.verdict === "block" ? "block" : "review",
|
|
3101
|
+
severity: classifyRuleSeverity(r.name ?? r.tool, r.verdict),
|
|
3102
|
+
reason: r.reason ?? `Smart rule ${r.name ?? r.tool} fired`,
|
|
3103
|
+
toolName: call.toolName,
|
|
3104
|
+
ctx,
|
|
3105
|
+
ts,
|
|
3106
|
+
sourceType: source.sourceType,
|
|
3107
|
+
shieldLabel: source.shieldLabel,
|
|
3108
|
+
input: call.args
|
|
3109
|
+
})
|
|
3110
|
+
);
|
|
3111
|
+
break;
|
|
3112
|
+
}
|
|
3113
|
+
const evalVerdict = detectDangerousShellExec(command);
|
|
3114
|
+
if (evalVerdict) {
|
|
3115
|
+
out.push(
|
|
3116
|
+
makeFinding({
|
|
3117
|
+
type: "eval-of-remote",
|
|
3118
|
+
ruleName: "eval-of-remote",
|
|
3119
|
+
verdict: evalVerdict,
|
|
3120
|
+
severity: classifyRuleSeverity("eval-remote", evalVerdict),
|
|
3121
|
+
reason: evalVerdict === "block" ? "Eval of remote download is a near-certain supply-chain attack" : "Eval of dynamic content (variable / subshell) requires approval",
|
|
3122
|
+
toolName: call.toolName,
|
|
3123
|
+
ctx,
|
|
3124
|
+
ts,
|
|
3125
|
+
sourceType: "engine",
|
|
3126
|
+
input: call.args
|
|
3127
|
+
})
|
|
3128
|
+
);
|
|
3129
|
+
}
|
|
3130
|
+
const pipe = analyzePipeChain(command);
|
|
3131
|
+
if (pipe.isPipeline && pipe.risk === "critical") {
|
|
3132
|
+
out.push(
|
|
3133
|
+
makeFinding({
|
|
3134
|
+
type: "pipe-to-shell",
|
|
3135
|
+
ruleName: "pipe-to-shell",
|
|
3136
|
+
verdict: "block",
|
|
3137
|
+
severity: "critical",
|
|
3138
|
+
reason: `Sensitive file piped through obfuscator to network sink: ${pipe.sourceFiles.join(", ")} \u2192 ${pipe.sinkTargets.join(", ")}`,
|
|
3139
|
+
toolName: call.toolName,
|
|
3140
|
+
ctx,
|
|
3141
|
+
ts,
|
|
3142
|
+
sourceType: "engine",
|
|
3143
|
+
input: call.args
|
|
3144
|
+
})
|
|
3145
|
+
);
|
|
3146
|
+
}
|
|
3147
|
+
if (DESTRUCTIVE_OP_RE.test(command)) {
|
|
3148
|
+
out.push(
|
|
3149
|
+
makeFinding({
|
|
3150
|
+
type: "destructive-op",
|
|
3151
|
+
ruleName: "destructive-op",
|
|
3152
|
+
verdict: "review",
|
|
3153
|
+
severity: "high",
|
|
3154
|
+
reason: "Destructive operation pattern detected",
|
|
3155
|
+
toolName: call.toolName,
|
|
3156
|
+
ctx,
|
|
3157
|
+
ts,
|
|
3158
|
+
sourceType: "engine",
|
|
3159
|
+
input: call.args
|
|
3160
|
+
})
|
|
3161
|
+
);
|
|
3162
|
+
}
|
|
3163
|
+
const ast = analyzeShellCommand(command);
|
|
3164
|
+
const sudoVariant = ast.actions.includes("sudo") || ast.actions.includes("su");
|
|
3165
|
+
const chmodVariant = ast.actions.includes("chmod") && (ast.allTokens.includes("777") || ast.allTokens.includes("0777") || ast.allTokens.includes("+x"));
|
|
3166
|
+
const chownVariant = ast.actions.includes("chown") && ast.allTokens.includes("root");
|
|
3167
|
+
if (sudoVariant || chmodVariant || chownVariant) {
|
|
3168
|
+
out.push(
|
|
3169
|
+
makeFinding({
|
|
3170
|
+
type: "privilege-escalation",
|
|
3171
|
+
ruleName: "privilege-escalation",
|
|
3172
|
+
verdict: "review",
|
|
3173
|
+
severity: "high",
|
|
3174
|
+
reason: "Privilege-escalation pattern detected",
|
|
3175
|
+
toolName: call.toolName,
|
|
3176
|
+
ctx,
|
|
3177
|
+
ts,
|
|
3178
|
+
sourceType: "engine",
|
|
3179
|
+
input: call.args
|
|
3180
|
+
})
|
|
3181
|
+
);
|
|
3182
|
+
}
|
|
3183
|
+
return out;
|
|
3184
|
+
}
|
|
3185
|
+
function extractSessionLevelFindings(calls, ctx) {
|
|
3186
|
+
if (!ctx.loopDetection.enabled || calls.length === 0) return [];
|
|
3187
|
+
const out = [];
|
|
3188
|
+
const seenLoopKeys = /* @__PURE__ */ new Set();
|
|
3189
|
+
const ONE_YEAR_MS = 365 * 24 * 60 * 60 * 1e3;
|
|
3190
|
+
const windowMs = ctx.loopDetection.windowSeconds <= 0 ? ONE_YEAR_MS : ctx.loopDetection.windowSeconds * 1e3;
|
|
3191
|
+
let records = [];
|
|
3192
|
+
let syntheticTs = 0;
|
|
3193
|
+
for (let i = 0; i < calls.length; i++) {
|
|
3194
|
+
const call = calls[i];
|
|
3195
|
+
const parsed = new Date(call.timestamp).getTime();
|
|
3196
|
+
const now = Number.isFinite(parsed) ? parsed : ++syntheticTs;
|
|
3197
|
+
const verdict = evaluateLoopWindow(
|
|
3198
|
+
records,
|
|
3199
|
+
call.toolName,
|
|
3200
|
+
call.args,
|
|
3201
|
+
ctx.loopDetection.threshold,
|
|
3202
|
+
windowMs,
|
|
3203
|
+
now
|
|
3204
|
+
);
|
|
3205
|
+
records = verdict.nextRecords;
|
|
3206
|
+
if (!verdict.looping) continue;
|
|
3207
|
+
const last = records[records.length - 1];
|
|
3208
|
+
const key = `${last.t}|${last.h}`;
|
|
3209
|
+
if (seenLoopKeys.has(key)) continue;
|
|
3210
|
+
seenLoopKeys.add(key);
|
|
3211
|
+
out.push({
|
|
3212
|
+
type: "loop",
|
|
3213
|
+
ruleName: "loop",
|
|
3214
|
+
verdict: "review",
|
|
3215
|
+
severity: "medium",
|
|
3216
|
+
reason: `Tool called ${verdict.count} times with identical args within window`,
|
|
3217
|
+
toolName: call.toolName,
|
|
3218
|
+
agent: ctx.agent,
|
|
3219
|
+
sessionId: ctx.sessionId,
|
|
3220
|
+
project: ctx.project,
|
|
3221
|
+
lineIndex: call.lineIndex,
|
|
3222
|
+
sourceType: "engine",
|
|
3223
|
+
firstSeenAt: call.timestamp,
|
|
3224
|
+
lastSeenAt: call.timestamp,
|
|
3225
|
+
occurrenceCount: 1,
|
|
3226
|
+
loopCount: verdict.count,
|
|
3227
|
+
loopKind: "loop",
|
|
3228
|
+
commandPreview: previewArgs(call.args, DEDUPE_PREVIEW_LEN),
|
|
3229
|
+
costUsd: verdict.count * COST_PER_LOOP_ITER_USD
|
|
3230
|
+
});
|
|
3231
|
+
}
|
|
3232
|
+
return out;
|
|
3233
|
+
}
|
|
3234
|
+
function dedupeCanonicalFindings(findings) {
|
|
3235
|
+
const merged = /* @__PURE__ */ new Map();
|
|
3236
|
+
for (const f of findings) {
|
|
3237
|
+
const inputPreview = f.input ? previewArgs(f.input, DEDUPE_PREVIEW_LEN) : "";
|
|
3238
|
+
const key = `${f.type}|${f.ruleName}|${inputPreview}|${f.project}|${f.agent}`;
|
|
3239
|
+
const prev = merged.get(key);
|
|
3240
|
+
if (!prev) {
|
|
3241
|
+
merged.set(key, { ...f });
|
|
3242
|
+
continue;
|
|
3243
|
+
}
|
|
3244
|
+
prev.occurrenceCount += f.occurrenceCount;
|
|
3245
|
+
if (f.firstSeenAt && (!prev.firstSeenAt || f.firstSeenAt < prev.firstSeenAt)) {
|
|
3246
|
+
prev.firstSeenAt = f.firstSeenAt;
|
|
3247
|
+
}
|
|
3248
|
+
if (f.lastSeenAt && f.lastSeenAt > prev.lastSeenAt) {
|
|
3249
|
+
prev.lastSeenAt = f.lastSeenAt;
|
|
3250
|
+
}
|
|
3251
|
+
if (f.costUsd !== void 0) {
|
|
3252
|
+
prev.costUsd = (prev.costUsd ?? 0) + f.costUsd;
|
|
3253
|
+
}
|
|
3254
|
+
if (f.loopCount !== void 0) {
|
|
3255
|
+
prev.loopCount = (prev.loopCount ?? 0) + f.loopCount;
|
|
3256
|
+
}
|
|
3257
|
+
}
|
|
3258
|
+
return [...merged.values()];
|
|
3259
|
+
}
|
|
3260
|
+
function toScanFinding(c) {
|
|
3261
|
+
const typeMap = {
|
|
3262
|
+
"smart-rule": null,
|
|
3263
|
+
"ast-fs-op": null,
|
|
3264
|
+
dlp: "dlp",
|
|
3265
|
+
pii: "pii",
|
|
3266
|
+
"sensitive-file-read": "sensitive-file-read",
|
|
3267
|
+
"privilege-escalation": "privilege-escalation",
|
|
3268
|
+
"destructive-op": "destructive-op",
|
|
3269
|
+
"pipe-to-shell": "pipe-to-shell",
|
|
3270
|
+
"eval-of-remote": "eval-of-remote",
|
|
3271
|
+
loop: "loop",
|
|
3272
|
+
"long-output-redacted": "long-output-redacted"
|
|
3273
|
+
};
|
|
3274
|
+
const sfType = typeMap[c.type];
|
|
3275
|
+
if (sfType === null) return null;
|
|
3276
|
+
return {
|
|
3277
|
+
sessionId: c.sessionId,
|
|
3278
|
+
type: sfType,
|
|
3279
|
+
...c.patternName && { patternName: c.patternName },
|
|
3280
|
+
lineIndex: c.lineIndex
|
|
3281
|
+
};
|
|
3282
|
+
}
|
|
3283
|
+
var TERMINAL_ESCAPE_RE = (
|
|
3284
|
+
// eslint-disable-next-line no-control-regex
|
|
3285
|
+
/\x1b\[[0-9;?]*[A-Za-z]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[@-_]|[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g
|
|
3286
|
+
);
|
|
3287
|
+
function previewArgs(input, max) {
|
|
3288
|
+
const cmd = input.command ?? input.query ?? input.file_path ?? JSON.stringify(input);
|
|
3289
|
+
const s = String(cmd).replace(TERMINAL_ESCAPE_RE, "").replace(/\s+/g, " ").trim();
|
|
3290
|
+
return s.length > max ? s.slice(0, max - 1) + "\u2026" : s;
|
|
3291
|
+
}
|
|
3292
|
+
function makeFinding(args) {
|
|
3293
|
+
const f = {
|
|
3294
|
+
type: args.type,
|
|
3295
|
+
ruleName: args.ruleName,
|
|
3296
|
+
verdict: args.verdict,
|
|
3297
|
+
severity: args.severity,
|
|
3298
|
+
reason: args.reason,
|
|
3299
|
+
toolName: args.toolName,
|
|
3300
|
+
agent: args.ctx.agent,
|
|
3301
|
+
sessionId: args.ctx.sessionId,
|
|
3302
|
+
project: args.ctx.project,
|
|
3303
|
+
lineIndex: args.ctx.lineIndex,
|
|
3304
|
+
sourceType: args.sourceType,
|
|
3305
|
+
firstSeenAt: args.ts,
|
|
3306
|
+
lastSeenAt: args.ts,
|
|
3307
|
+
occurrenceCount: 1
|
|
3308
|
+
};
|
|
3309
|
+
if (args.shieldLabel) f.shieldLabel = args.shieldLabel;
|
|
3310
|
+
if (args.subjectPath) f.subjectPath = args.subjectPath;
|
|
3311
|
+
if (args.input) f.input = args.input;
|
|
3312
|
+
if (args.patternName) f.patternName = args.patternName;
|
|
3313
|
+
if (args.redactedSample) f.redactedSample = args.redactedSample;
|
|
3314
|
+
return f;
|
|
3315
|
+
}
|
|
3316
|
+
function* stringValues(obj, depth = 0) {
|
|
3317
|
+
if (depth > 6) return;
|
|
3318
|
+
if (typeof obj === "string") {
|
|
3319
|
+
if (obj.length > 0) yield obj;
|
|
3320
|
+
return;
|
|
3321
|
+
}
|
|
3322
|
+
if (!obj || typeof obj !== "object") return;
|
|
3323
|
+
if (Array.isArray(obj)) {
|
|
3324
|
+
for (const v of obj) yield* stringValues(v, depth + 1);
|
|
3325
|
+
return;
|
|
3326
|
+
}
|
|
3327
|
+
for (const v of Object.values(obj)) yield* stringValues(v, depth + 1);
|
|
3328
|
+
}
|
|
3329
|
+
|
|
2260
3330
|
// src/index.ts
|
|
2261
|
-
var ENGINE_VERSION = "1.
|
|
3331
|
+
var ENGINE_VERSION = "1.4.0";
|
|
2262
3332
|
// Annotate the CommonJS export names for ESM import in node:
|
|
2263
3333
|
0 && (module.exports = {
|
|
3334
|
+
AST_FS_REGEX_RULES,
|
|
3335
|
+
BASH_TOOL_NAMES,
|
|
2264
3336
|
BUILTIN_SHIELDS,
|
|
3337
|
+
CANONICAL_EXTRACTOR_HASH,
|
|
3338
|
+
CANONICAL_EXTRACTOR_VERSION,
|
|
3339
|
+
COST_PER_LOOP_ITER_USD,
|
|
3340
|
+
DESTRUCTIVE_OP_RE,
|
|
2265
3341
|
DLP_PATTERNS,
|
|
2266
3342
|
ENGINE_VERSION,
|
|
3343
|
+
FILE_TOOLS,
|
|
2267
3344
|
FLAGS_WITH_VALUES,
|
|
3345
|
+
LONG_OUTPUT_THRESHOLD_BYTES,
|
|
2268
3346
|
LOOP_MAX_RECORDS,
|
|
3347
|
+
LOOP_THRESHOLD_FOR_WASTE,
|
|
3348
|
+
PRIVILEGE_ESCALATION_RE,
|
|
3349
|
+
SCAN_SIGNAL_WEIGHTS,
|
|
3350
|
+
SENSITIVE_PATH_RE,
|
|
2269
3351
|
SENSITIVE_PATH_REGEXES,
|
|
3352
|
+
analyzeFsOperation,
|
|
2270
3353
|
analyzePipeChain,
|
|
2271
3354
|
analyzeShellCommand,
|
|
2272
3355
|
checkDangerousSql,
|
|
3356
|
+
classifyAuditEntry,
|
|
3357
|
+
classifyRuleSeverity,
|
|
3358
|
+
classifyScanSignal,
|
|
2273
3359
|
computeArgsHash,
|
|
3360
|
+
computeBlendedSecurityScore,
|
|
3361
|
+
computeScanScore,
|
|
3362
|
+
computeSecurityScore,
|
|
3363
|
+
dedupeCanonicalFindings,
|
|
2274
3364
|
detectDangerousEval,
|
|
2275
3365
|
detectDangerousShellExec,
|
|
3366
|
+
detectPii,
|
|
2276
3367
|
evaluateLoopWindow,
|
|
2277
3368
|
evaluatePolicy,
|
|
2278
3369
|
evaluateSmartConditions,
|
|
2279
3370
|
extractAllSshHosts,
|
|
3371
|
+
extractCanonicalFindings,
|
|
2280
3372
|
extractNetworkTargets,
|
|
2281
3373
|
extractPositionalArgs,
|
|
3374
|
+
extractSessionLevelFindings,
|
|
2282
3375
|
getCompiledRegex,
|
|
2283
3376
|
getNestedValue,
|
|
3377
|
+
isBashTool,
|
|
2284
3378
|
isIgnoredTool,
|
|
3379
|
+
isProtectedHomePath,
|
|
2285
3380
|
isShieldVerdict,
|
|
2286
3381
|
matchSensitivePath,
|
|
2287
3382
|
matchesPattern,
|
|
3383
|
+
narrativeRuleLabel,
|
|
2288
3384
|
normalizeCommandForPolicy,
|
|
2289
3385
|
parseAllSshHostsFromCommand,
|
|
3386
|
+
previewArgs,
|
|
2290
3387
|
redactText,
|
|
2291
3388
|
scanArgs,
|
|
2292
3389
|
scanText,
|
|
2293
3390
|
sensitivePathMatch,
|
|
3391
|
+
summarizeBlast,
|
|
3392
|
+
summarizeScan,
|
|
3393
|
+
toScanFinding,
|
|
3394
|
+
truncateBlastPath,
|
|
2294
3395
|
validateOverrides,
|
|
2295
3396
|
validateRegex,
|
|
2296
3397
|
validateShieldDefinition
|