@node9/policy-engine 1.4.0 → 1.26.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.mts +226 -15
- package/dist/index.d.ts +226 -15
- package/dist/index.js +886 -47
- package/dist/index.mjs +868 -47
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -30,15 +30,25 @@ 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,
|
|
34
38
|
COST_PER_LOOP_ITER_USD: () => COST_PER_LOOP_ITER_USD,
|
|
39
|
+
DESTRUCTIVE_OP_RE: () => DESTRUCTIVE_OP_RE,
|
|
35
40
|
DLP_PATTERNS: () => DLP_PATTERNS,
|
|
36
41
|
ENGINE_VERSION: () => ENGINE_VERSION,
|
|
42
|
+
FILE_TOOLS: () => FILE_TOOLS,
|
|
37
43
|
FLAGS_WITH_VALUES: () => FLAGS_WITH_VALUES,
|
|
44
|
+
LONG_OUTPUT_THRESHOLD_BYTES: () => LONG_OUTPUT_THRESHOLD_BYTES,
|
|
38
45
|
LOOP_MAX_RECORDS: () => LOOP_MAX_RECORDS,
|
|
39
46
|
LOOP_THRESHOLD_FOR_WASTE: () => LOOP_THRESHOLD_FOR_WASTE,
|
|
47
|
+
PRIVILEGE_ESCALATION_RE: () => PRIVILEGE_ESCALATION_RE,
|
|
40
48
|
SCAN_SIGNAL_WEIGHTS: () => SCAN_SIGNAL_WEIGHTS,
|
|
49
|
+
SENSITIVE_PATH_RE: () => SENSITIVE_PATH_RE,
|
|
41
50
|
SENSITIVE_PATH_REGEXES: () => SENSITIVE_PATH_REGEXES,
|
|
51
|
+
analyzeFsOperation: () => analyzeFsOperation,
|
|
42
52
|
analyzePipeChain: () => analyzePipeChain,
|
|
43
53
|
analyzeShellCommand: () => analyzeShellCommand,
|
|
44
54
|
checkDangerousSql: () => checkDangerousSql,
|
|
@@ -49,29 +59,37 @@ __export(src_exports, {
|
|
|
49
59
|
computeBlendedSecurityScore: () => computeBlendedSecurityScore,
|
|
50
60
|
computeScanScore: () => computeScanScore,
|
|
51
61
|
computeSecurityScore: () => computeSecurityScore,
|
|
62
|
+
dedupeCanonicalFindings: () => dedupeCanonicalFindings,
|
|
52
63
|
detectDangerousEval: () => detectDangerousEval,
|
|
53
64
|
detectDangerousShellExec: () => detectDangerousShellExec,
|
|
65
|
+
detectPii: () => detectPii,
|
|
54
66
|
evaluateLoopWindow: () => evaluateLoopWindow,
|
|
55
67
|
evaluatePolicy: () => evaluatePolicy,
|
|
56
68
|
evaluateSmartConditions: () => evaluateSmartConditions,
|
|
57
69
|
extractAllSshHosts: () => extractAllSshHosts,
|
|
70
|
+
extractCanonicalFindings: () => extractCanonicalFindings,
|
|
58
71
|
extractNetworkTargets: () => extractNetworkTargets,
|
|
59
72
|
extractPositionalArgs: () => extractPositionalArgs,
|
|
73
|
+
extractSessionLevelFindings: () => extractSessionLevelFindings,
|
|
60
74
|
getCompiledRegex: () => getCompiledRegex,
|
|
61
75
|
getNestedValue: () => getNestedValue,
|
|
76
|
+
isBashTool: () => isBashTool,
|
|
62
77
|
isIgnoredTool: () => isIgnoredTool,
|
|
78
|
+
isProtectedHomePath: () => isProtectedHomePath,
|
|
63
79
|
isShieldVerdict: () => isShieldVerdict,
|
|
64
80
|
matchSensitivePath: () => matchSensitivePath,
|
|
65
81
|
matchesPattern: () => matchesPattern,
|
|
66
82
|
narrativeRuleLabel: () => narrativeRuleLabel,
|
|
67
83
|
normalizeCommandForPolicy: () => normalizeCommandForPolicy,
|
|
68
84
|
parseAllSshHostsFromCommand: () => parseAllSshHostsFromCommand,
|
|
85
|
+
previewArgs: () => previewArgs,
|
|
69
86
|
redactText: () => redactText,
|
|
70
87
|
scanArgs: () => scanArgs,
|
|
71
88
|
scanText: () => scanText,
|
|
72
89
|
sensitivePathMatch: () => sensitivePathMatch,
|
|
73
90
|
summarizeBlast: () => summarizeBlast,
|
|
74
91
|
summarizeScan: () => summarizeScan,
|
|
92
|
+
toScanFinding: () => toScanFinding,
|
|
75
93
|
truncateBlastPath: () => truncateBlastPath,
|
|
76
94
|
validateOverrides: () => validateOverrides,
|
|
77
95
|
validateRegex: () => validateRegex,
|
|
@@ -507,6 +525,42 @@ var DLP_PATTERNS = [
|
|
|
507
525
|
regex: /\bAGE-SECRET-KEY-1[QPZRY9X8GF2TVDW0S3JNLH]{58}\b/,
|
|
508
526
|
severity: "block",
|
|
509
527
|
keywords: ["age-secret-key-"]
|
|
528
|
+
},
|
|
529
|
+
// ── Database connection strings ───────────────────────────────────────────
|
|
530
|
+
// Universal <scheme>://[user]:<password>@<host> shape. Covers the gap
|
|
531
|
+
// vendor-prefix patterns (AWS / GitHub / Stripe / …) leave open. Matches
|
|
532
|
+
// the whole URL so maskSecret produces `<scheme>...:****@...<host>` —
|
|
533
|
+
// the password value never appears in the redacted sample.
|
|
534
|
+
//
|
|
535
|
+
// Schemes covered: redis, rediss (TLS), postgres, postgresql,
|
|
536
|
+
// mongodb, mongodb+srv, mysql, mariadb, amqp, amqps, kafka,
|
|
537
|
+
// clickhouse, cassandra. HTTP(S) / FTP / SSH are intentionally
|
|
538
|
+
// excluded — they're not database URLs and adding them would
|
|
539
|
+
// create false positives on every basic-auth URL in the wild.
|
|
540
|
+
//
|
|
541
|
+
// Requires `:password@` (4+ char password) so user-only URLs like
|
|
542
|
+
// `redis://user@host` don't match. Stopwords ('your', '${', '<your',
|
|
543
|
+
// 'placeholder', 'changeme', etc.) keep doc/README scans clean.
|
|
544
|
+
{
|
|
545
|
+
name: "Database Connection String",
|
|
546
|
+
regex: /\b(redis|rediss|postgres|postgresql|mongodb|mongodb\+srv|mysql|mariadb|amqp|amqps|kafka|clickhouse|cassandra):\/\/[^:/\s@]*:[^@\s]{4,}@[^\s/]+/,
|
|
547
|
+
severity: "block",
|
|
548
|
+
keywords: [
|
|
549
|
+
"redis://",
|
|
550
|
+
"rediss://",
|
|
551
|
+
"postgres://",
|
|
552
|
+
"postgresql://",
|
|
553
|
+
"mongodb://",
|
|
554
|
+
"mongodb+srv://",
|
|
555
|
+
"mysql://",
|
|
556
|
+
"mariadb://",
|
|
557
|
+
"amqp://",
|
|
558
|
+
"amqps://",
|
|
559
|
+
"kafka://",
|
|
560
|
+
"clickhouse://",
|
|
561
|
+
"cassandra://"
|
|
562
|
+
],
|
|
563
|
+
minEntropy: 3
|
|
510
564
|
}
|
|
511
565
|
];
|
|
512
566
|
var DLP_PATTERNS_GLOBAL = DLP_PATTERNS.map(
|
|
@@ -702,9 +756,70 @@ var MESSAGE_FLAGS = /* @__PURE__ */ new Set([
|
|
|
702
756
|
]);
|
|
703
757
|
var SHELL_INTERPRETERS = /* @__PURE__ */ new Set(["bash", "sh", "zsh", "fish", "dash", "ksh"]);
|
|
704
758
|
var DOWNLOAD_CMDS = /* @__PURE__ */ new Set(["curl", "wget"]);
|
|
759
|
+
function isCatHeredocOrLit(part) {
|
|
760
|
+
if (!part) return false;
|
|
761
|
+
const t = syntax.NodeType(part);
|
|
762
|
+
if (t === "Lit") return true;
|
|
763
|
+
if (t !== "CmdSubst") return false;
|
|
764
|
+
const stmts = part.Stmts || [];
|
|
765
|
+
if (stmts.length !== 1) return false;
|
|
766
|
+
const stmt = stmts[0];
|
|
767
|
+
const redirs = stmt.Redirs || stmt.Cmd?.Redirs || [];
|
|
768
|
+
const hasHeredoc = redirs.some((r) => r && r.Hdoc);
|
|
769
|
+
if (!hasHeredoc) return false;
|
|
770
|
+
const cmd = stmt.Cmd;
|
|
771
|
+
if (!cmd || syntax.NodeType(cmd) !== "CallExpr") return false;
|
|
772
|
+
const firstArg = cmd.Args?.[0]?.Parts || [];
|
|
773
|
+
if (firstArg.length !== 1 || syntax.NodeType(firstArg[0]) !== "Lit") return false;
|
|
774
|
+
return (firstArg[0].Value || "").toLowerCase() === "cat";
|
|
775
|
+
}
|
|
776
|
+
var NORMALIZE_CACHE_MAX = 5e3;
|
|
777
|
+
var normalizeCache = /* @__PURE__ */ new Map();
|
|
778
|
+
var AST_CACHE_MAX = 5e3;
|
|
779
|
+
var astCache = /* @__PURE__ */ new Map();
|
|
780
|
+
var PARSE_FAIL = /* @__PURE__ */ Symbol("parse-fail");
|
|
781
|
+
function parseShared(command) {
|
|
782
|
+
const cached = astCache.get(command);
|
|
783
|
+
if (cached !== void 0) {
|
|
784
|
+
astCache.delete(command);
|
|
785
|
+
astCache.set(command, cached);
|
|
786
|
+
return cached;
|
|
787
|
+
}
|
|
788
|
+
let parsed;
|
|
789
|
+
try {
|
|
790
|
+
parsed = sharedParser.Parse(command, "cmd");
|
|
791
|
+
} catch {
|
|
792
|
+
parsed = PARSE_FAIL;
|
|
793
|
+
}
|
|
794
|
+
if (astCache.size >= AST_CACHE_MAX) {
|
|
795
|
+
const oldest = astCache.keys().next().value;
|
|
796
|
+
if (oldest !== void 0) astCache.delete(oldest);
|
|
797
|
+
}
|
|
798
|
+
astCache.set(command, parsed);
|
|
799
|
+
return parsed;
|
|
800
|
+
}
|
|
801
|
+
function cachedNormalize(command, compute) {
|
|
802
|
+
const hit = normalizeCache.get(command);
|
|
803
|
+
if (hit !== void 0) {
|
|
804
|
+
normalizeCache.delete(command);
|
|
805
|
+
normalizeCache.set(command, hit);
|
|
806
|
+
return hit;
|
|
807
|
+
}
|
|
808
|
+
const result = compute();
|
|
809
|
+
if (normalizeCache.size >= NORMALIZE_CACHE_MAX) {
|
|
810
|
+
const oldest = normalizeCache.keys().next().value;
|
|
811
|
+
if (oldest !== void 0) normalizeCache.delete(oldest);
|
|
812
|
+
}
|
|
813
|
+
normalizeCache.set(command, result);
|
|
814
|
+
return result;
|
|
815
|
+
}
|
|
705
816
|
function normalizeCommandForPolicy(command) {
|
|
817
|
+
return cachedNormalize(command, () => normalizeCommandForPolicyImpl(command));
|
|
818
|
+
}
|
|
819
|
+
function normalizeCommandForPolicyImpl(command) {
|
|
820
|
+
const f = parseShared(command);
|
|
821
|
+
if (f === PARSE_FAIL) return command;
|
|
706
822
|
try {
|
|
707
|
-
const f = sharedParser.Parse(command, "cmd");
|
|
708
823
|
const strips = [];
|
|
709
824
|
syntax.Walk(f, (node) => {
|
|
710
825
|
if (!node) return false;
|
|
@@ -726,7 +841,11 @@ function normalizeCommandForPolicy(command) {
|
|
|
726
841
|
} else if (nt === "DblQuoted") {
|
|
727
842
|
const innerParts = quotedNode.Parts || [];
|
|
728
843
|
const allLit = innerParts.length === 0 || innerParts.every((p) => syntax.NodeType(p) === "Lit");
|
|
729
|
-
if (allLit)
|
|
844
|
+
if (allLit) {
|
|
845
|
+
strips.push([next.Pos().Offset(), next.End().Offset()]);
|
|
846
|
+
} else if (innerParts.every((p) => isCatHeredocOrLit(p))) {
|
|
847
|
+
strips.push([next.Pos().Offset(), next.End().Offset()]);
|
|
848
|
+
}
|
|
730
849
|
}
|
|
731
850
|
}
|
|
732
851
|
return true;
|
|
@@ -795,6 +914,242 @@ function detectDangerousShellExec(command) {
|
|
|
795
914
|
}
|
|
796
915
|
}
|
|
797
916
|
var detectDangerousEval = detectDangerousShellExec;
|
|
917
|
+
var FS_READ_TOOLS = /* @__PURE__ */ new Set([
|
|
918
|
+
"cat",
|
|
919
|
+
"less",
|
|
920
|
+
"head",
|
|
921
|
+
"tail",
|
|
922
|
+
"bat",
|
|
923
|
+
"more",
|
|
924
|
+
"open",
|
|
925
|
+
"print",
|
|
926
|
+
"nano",
|
|
927
|
+
"vim",
|
|
928
|
+
"vi",
|
|
929
|
+
"emacs",
|
|
930
|
+
"code",
|
|
931
|
+
"type"
|
|
932
|
+
]);
|
|
933
|
+
var FS_OP_PRESCREEN_RE = /(?:^|[\s|;&(`\n])(?:rm|cat|less|head|tail|bat|more|open|print|nano|vim|vi|emacs|code|type)\b/;
|
|
934
|
+
var HOME_CACHE_ALLOWLIST = [
|
|
935
|
+
".cache",
|
|
936
|
+
".npm/_npx",
|
|
937
|
+
".npm/_cacache",
|
|
938
|
+
".cargo/registry",
|
|
939
|
+
".gradle/caches",
|
|
940
|
+
".gradle/.tmp",
|
|
941
|
+
".m2/repository",
|
|
942
|
+
".pnpm-store",
|
|
943
|
+
".yarn/cache",
|
|
944
|
+
".yarn/.cache",
|
|
945
|
+
".cache/pip",
|
|
946
|
+
".local/share/Trash",
|
|
947
|
+
".rustup/downloads"
|
|
948
|
+
];
|
|
949
|
+
var SENSITIVE_PATH_RULES = [
|
|
950
|
+
{
|
|
951
|
+
rule: "shield:project-jail:block-read-ssh",
|
|
952
|
+
reason: "Reading SSH private keys is blocked by project-jail shield",
|
|
953
|
+
match: (p) => /(^|[\\/])\.ssh[\\/]/i.test(p)
|
|
954
|
+
},
|
|
955
|
+
{
|
|
956
|
+
rule: "shield:project-jail:block-read-aws",
|
|
957
|
+
reason: "Reading AWS credentials is blocked by project-jail shield",
|
|
958
|
+
match: (p) => /(^|[\\/])\.aws[\\/]/i.test(p)
|
|
959
|
+
},
|
|
960
|
+
{
|
|
961
|
+
// Mirrors the JSON shield's `.env` pattern (project-jail.json's
|
|
962
|
+
// block-read-env-any-tool) so the AST FS-op path catches the
|
|
963
|
+
// same set the regex shield does — including Next.js / Vite's
|
|
964
|
+
// `.env.<env>.local` double-suffix overrides which are commonly
|
|
965
|
+
// gitignored AND commonly contain real secrets.
|
|
966
|
+
//
|
|
967
|
+
// Intentional non-matches (dev fixtures): .env.example, .env.sample,
|
|
968
|
+
// .env.template, .env.test, .envrc. See shields.test.ts:983-995
|
|
969
|
+
// for the canonical test-asserted contract.
|
|
970
|
+
rule: "shield:project-jail:block-read-env",
|
|
971
|
+
reason: "Reading .env files is blocked by project-jail shield",
|
|
972
|
+
match: (p) => /(?:^|[\\/])\.env(?:\.(?:local|production|staging|development|production\.local|staging\.local|development\.local))?$/i.test(
|
|
973
|
+
p
|
|
974
|
+
)
|
|
975
|
+
},
|
|
976
|
+
{
|
|
977
|
+
// verdict: 'review' (not 'block') is a deliberate design choice
|
|
978
|
+
// documented in commit 29327a8. SSH keys and AWS credentials are
|
|
979
|
+
// cryptographic material with no legitimate read use-case for
|
|
980
|
+
// an AI agent → hard `block`. But .netrc / .npmrc / .docker /
|
|
981
|
+
// .kube / gcloud are CONFIG files that hold tokens AND have
|
|
982
|
+
// legitimate diagnostic reads ("which registry am I configured
|
|
983
|
+
// for", "what cluster am I on"). Hard-blocking those creates
|
|
984
|
+
// friction without much safety win because the review gate
|
|
985
|
+
// still catches genuine exfiltration attempts.
|
|
986
|
+
//
|
|
987
|
+
// The review gate FAILS CLOSED on timeout (daemon.approvalTimeoutMs
|
|
988
|
+
// returns a deny verdict via the orchestrator's timeout branch),
|
|
989
|
+
// so a stuck or unattended approval does NOT silently grant
|
|
990
|
+
// credential access. If the threat model demands strict block,
|
|
991
|
+
// a future per-shield strict-mode toggle is the right fix —
|
|
992
|
+
// not a regex-level upgrade here.
|
|
993
|
+
rule: "shield:project-jail:review-read-credentials",
|
|
994
|
+
reason: "Reading credential files requires approval (project-jail shield)",
|
|
995
|
+
verdict: "review",
|
|
996
|
+
match: (p) => (
|
|
997
|
+
// .kube/config holds Kubernetes cluster credentials and was
|
|
998
|
+
// flagged as missing by the node9-pr-agent review (the comment
|
|
999
|
+
// above mentioned .kube but the regex didn't include it — a
|
|
1000
|
+
// textbook code-comment vs code drift). The JSON shield's
|
|
1001
|
+
// review-read-credentials-any-tool already had it. Now aligned.
|
|
1002
|
+
/(?:credentials\.json|\.netrc|\.npmrc|\.docker[\\/]config\.json|gcloud[\\/]credentials|\.kube[\\/]config)$/i.test(
|
|
1003
|
+
p
|
|
1004
|
+
)
|
|
1005
|
+
)
|
|
1006
|
+
}
|
|
1007
|
+
];
|
|
1008
|
+
var BASH_TOOL_NAMES = /* @__PURE__ */ new Set([
|
|
1009
|
+
"bash",
|
|
1010
|
+
"execute_bash",
|
|
1011
|
+
"run_shell_command",
|
|
1012
|
+
"shell",
|
|
1013
|
+
"exec_command"
|
|
1014
|
+
]);
|
|
1015
|
+
function isBashTool(toolName) {
|
|
1016
|
+
return BASH_TOOL_NAMES.has(toolName.toLowerCase());
|
|
1017
|
+
}
|
|
1018
|
+
var AST_FS_REGEX_RULES = /* @__PURE__ */ new Set([
|
|
1019
|
+
"block-rm-rf-home",
|
|
1020
|
+
"shield:project-jail:block-read-ssh",
|
|
1021
|
+
"shield:project-jail:block-read-aws",
|
|
1022
|
+
"shield:project-jail:block-read-env",
|
|
1023
|
+
"shield:project-jail:review-read-credentials"
|
|
1024
|
+
]);
|
|
1025
|
+
function isProtectedHomePath(rawPath) {
|
|
1026
|
+
let p = rawPath.replace(/^\$HOME[\\/]?|^\$\{HOME\}[\\/]?/, "~/");
|
|
1027
|
+
let underHome = false;
|
|
1028
|
+
if (p === "~" || p.startsWith("~/") || p.startsWith("~\\")) {
|
|
1029
|
+
p = p.replace(/^~[\\/]?/, "");
|
|
1030
|
+
underHome = true;
|
|
1031
|
+
} else if (/^\/home\/[^/]+/.test(p) || /^\/root(\/|$)/.test(p)) {
|
|
1032
|
+
p = p.replace(/^\/home\/[^/]+[\\/]?|^\/root[\\/]?/, "");
|
|
1033
|
+
underHome = true;
|
|
1034
|
+
}
|
|
1035
|
+
if (!underHome) return false;
|
|
1036
|
+
if (p === "" || p === "." || p === "./") return true;
|
|
1037
|
+
for (const safe of HOME_CACHE_ALLOWLIST) {
|
|
1038
|
+
if (p === safe || p.startsWith(safe + "/") || p.startsWith(safe + "\\")) {
|
|
1039
|
+
return false;
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
return true;
|
|
1043
|
+
}
|
|
1044
|
+
function extractLiteralArgs(callExpr) {
|
|
1045
|
+
const args = callExpr.Args || [];
|
|
1046
|
+
if (args.length === 0) return { name: "", flags: [], paths: [] };
|
|
1047
|
+
const litFromWord = (w) => {
|
|
1048
|
+
const parts = w?.Parts || [];
|
|
1049
|
+
let s = "";
|
|
1050
|
+
for (const p of parts) {
|
|
1051
|
+
const t = syntax.NodeType(p);
|
|
1052
|
+
if (t === "Lit") s += (p.Value ?? "").replace(/\\(.)/g, "$1");
|
|
1053
|
+
else if (t === "SglQuoted") s += p.Value ?? "";
|
|
1054
|
+
else if (t === "DblQuoted") {
|
|
1055
|
+
const inner = p.Parts || [];
|
|
1056
|
+
if (!inner.every((ip) => syntax.NodeType(ip) === "Lit")) return null;
|
|
1057
|
+
s += inner.map((ip) => ip.Value ?? "").join("");
|
|
1058
|
+
} else {
|
|
1059
|
+
return null;
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
return s;
|
|
1063
|
+
};
|
|
1064
|
+
const name = (litFromWord(args[0]) || "").toLowerCase();
|
|
1065
|
+
const flags = [];
|
|
1066
|
+
const paths = [];
|
|
1067
|
+
for (let i = 1; i < args.length; i++) {
|
|
1068
|
+
const v = litFromWord(args[i]);
|
|
1069
|
+
if (v === null) continue;
|
|
1070
|
+
if (v.startsWith("-")) flags.push(v);
|
|
1071
|
+
else paths.push(v);
|
|
1072
|
+
}
|
|
1073
|
+
return { name, flags, paths };
|
|
1074
|
+
}
|
|
1075
|
+
var FS_OP_CACHE_MAX = 5e3;
|
|
1076
|
+
var fsOpCache = /* @__PURE__ */ new Map();
|
|
1077
|
+
function analyzeFsOperation(command) {
|
|
1078
|
+
if (!FS_OP_PRESCREEN_RE.test(command)) return null;
|
|
1079
|
+
if (fsOpCache.has(command)) {
|
|
1080
|
+
const hit = fsOpCache.get(command) ?? null;
|
|
1081
|
+
fsOpCache.delete(command);
|
|
1082
|
+
fsOpCache.set(command, hit);
|
|
1083
|
+
return hit;
|
|
1084
|
+
}
|
|
1085
|
+
const computed = analyzeFsOperationImpl(command);
|
|
1086
|
+
if (fsOpCache.size >= FS_OP_CACHE_MAX) {
|
|
1087
|
+
const oldest = fsOpCache.keys().next().value;
|
|
1088
|
+
if (oldest !== void 0) fsOpCache.delete(oldest);
|
|
1089
|
+
}
|
|
1090
|
+
fsOpCache.set(command, computed);
|
|
1091
|
+
return computed;
|
|
1092
|
+
}
|
|
1093
|
+
function analyzeFsOperationImpl(command) {
|
|
1094
|
+
const f = parseShared(command);
|
|
1095
|
+
if (f === PARSE_FAIL) return null;
|
|
1096
|
+
let result = null;
|
|
1097
|
+
try {
|
|
1098
|
+
syntax.Walk(f, (node) => {
|
|
1099
|
+
if (!node || result) return false;
|
|
1100
|
+
const n = node;
|
|
1101
|
+
if (syntax.NodeType(n) !== "CallExpr") return true;
|
|
1102
|
+
const { name, flags, paths } = extractLiteralArgs(n);
|
|
1103
|
+
if (!name) return true;
|
|
1104
|
+
if (name === "rm") {
|
|
1105
|
+
const flagStr = flags.join("").toLowerCase();
|
|
1106
|
+
const hasR = /[r]/.test(flagStr) || flags.includes("--recursive");
|
|
1107
|
+
const hasF = /[f]/.test(flagStr) || flags.includes("--force");
|
|
1108
|
+
if (hasR && hasF) {
|
|
1109
|
+
for (const p of paths) {
|
|
1110
|
+
if (isProtectedHomePath(p)) {
|
|
1111
|
+
result = {
|
|
1112
|
+
ruleName: "block-rm-rf-home",
|
|
1113
|
+
verdict: "block",
|
|
1114
|
+
reason: "Recursive delete of home directory is irreversible",
|
|
1115
|
+
path: p
|
|
1116
|
+
};
|
|
1117
|
+
return false;
|
|
1118
|
+
}
|
|
1119
|
+
if (p === "/" || /^\/+$/.test(p)) {
|
|
1120
|
+
result = {
|
|
1121
|
+
ruleName: "block-rm-rf-home",
|
|
1122
|
+
verdict: "block",
|
|
1123
|
+
reason: "Recursive delete of root is catastrophic",
|
|
1124
|
+
path: p
|
|
1125
|
+
};
|
|
1126
|
+
return false;
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
if (FS_READ_TOOLS.has(name)) {
|
|
1132
|
+
for (const p of paths) {
|
|
1133
|
+
for (const sp of SENSITIVE_PATH_RULES) {
|
|
1134
|
+
if (sp.match(p)) {
|
|
1135
|
+
result = {
|
|
1136
|
+
ruleName: sp.rule,
|
|
1137
|
+
verdict: sp.verdict ?? "block",
|
|
1138
|
+
reason: sp.reason,
|
|
1139
|
+
path: p
|
|
1140
|
+
};
|
|
1141
|
+
return false;
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
return true;
|
|
1147
|
+
});
|
|
1148
|
+
return result;
|
|
1149
|
+
} catch {
|
|
1150
|
+
return null;
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
798
1153
|
function analyzeShellCommand(command) {
|
|
799
1154
|
const actions = [];
|
|
800
1155
|
const paths = [];
|
|
@@ -1234,10 +1589,18 @@ function getNestedValue(obj, path) {
|
|
|
1234
1589
|
function evaluateSmartConditions(args, rule) {
|
|
1235
1590
|
if (!rule.conditions || rule.conditions.length === 0) return true;
|
|
1236
1591
|
const mode = rule.conditionMode ?? "all";
|
|
1592
|
+
const fieldCache = /* @__PURE__ */ new Map();
|
|
1593
|
+
const resolveField = (field) => {
|
|
1594
|
+
if (fieldCache.has(field)) return fieldCache.get(field) ?? null;
|
|
1595
|
+
const rawVal = getNestedValue(args, field);
|
|
1596
|
+
const rawStr = rawVal !== null && rawVal !== void 0 ? String(rawVal) : null;
|
|
1597
|
+
const stripped = field === "command" && rawStr !== null ? normalizeCommandForPolicy(rawStr) : rawStr;
|
|
1598
|
+
const val = stripped !== null ? stripped.replace(/\s+/g, " ").trim() : null;
|
|
1599
|
+
fieldCache.set(field, val);
|
|
1600
|
+
return val;
|
|
1601
|
+
};
|
|
1237
1602
|
const results = rule.conditions.map((cond) => {
|
|
1238
|
-
const
|
|
1239
|
-
const normalized = rawVal !== null && rawVal !== void 0 ? String(rawVal).replace(/\s+/g, " ").trim() : null;
|
|
1240
|
-
const val = cond.field === "command" && normalized !== null ? normalizeCommandForPolicy(normalized) : normalized;
|
|
1603
|
+
const val = resolveField(cond.field);
|
|
1241
1604
|
switch (cond.op) {
|
|
1242
1605
|
case "exists":
|
|
1243
1606
|
return val !== null && val !== "";
|
|
@@ -1300,6 +1663,43 @@ function checkDangerousSql(sql) {
|
|
|
1300
1663
|
return "UPDATE without WHERE \u2014 updates every row";
|
|
1301
1664
|
return null;
|
|
1302
1665
|
}
|
|
1666
|
+
function pipeChainVerdict(command, isTrustedHost) {
|
|
1667
|
+
const pipeAnalysis = analyzePipeChain(command);
|
|
1668
|
+
if (!pipeAnalysis.isPipeline) return null;
|
|
1669
|
+
if (pipeAnalysis.risk !== "critical" && pipeAnalysis.risk !== "high") return null;
|
|
1670
|
+
const sinks = pipeAnalysis.sinkTargets;
|
|
1671
|
+
const allTrusted = sinks.length > 0 && sinks.every((host) => isTrustedHost ? isTrustedHost(host) : false);
|
|
1672
|
+
if (pipeAnalysis.risk === "critical") {
|
|
1673
|
+
if (allTrusted) {
|
|
1674
|
+
return {
|
|
1675
|
+
decision: "review",
|
|
1676
|
+
blockedByLabel: "Node9: Pipe-Chain to Trusted Host (obfuscated)",
|
|
1677
|
+
reason: `Obfuscated pipe to trusted host(s): ${sinks.join(", ")} \u2014 requires approval`,
|
|
1678
|
+
tier: 3
|
|
1679
|
+
};
|
|
1680
|
+
}
|
|
1681
|
+
return {
|
|
1682
|
+
decision: "block",
|
|
1683
|
+
blockedByLabel: "Node9: Pipe-Chain Exfiltration (critical)",
|
|
1684
|
+
reason: `Sensitive file piped through obfuscator to network sink: ${pipeAnalysis.sourceFiles.join(", ")} \u2192 ${sinks.join(", ")}`,
|
|
1685
|
+
tier: 3
|
|
1686
|
+
};
|
|
1687
|
+
}
|
|
1688
|
+
if (allTrusted) {
|
|
1689
|
+
return {
|
|
1690
|
+
decision: "allow",
|
|
1691
|
+
blockedByLabel: "Node9: Pipe-Chain to Trusted Host",
|
|
1692
|
+
reason: `Sensitive file piped to trusted host(s): ${sinks.join(", ")}`,
|
|
1693
|
+
tier: 3
|
|
1694
|
+
};
|
|
1695
|
+
}
|
|
1696
|
+
return {
|
|
1697
|
+
decision: "review",
|
|
1698
|
+
blockedByLabel: "Node9: Pipe-Chain Exfiltration (high)",
|
|
1699
|
+
reason: `Sensitive file piped to network sink: ${pipeAnalysis.sourceFiles.join(", ")} \u2192 ${sinks.join(", ")}`,
|
|
1700
|
+
tier: 3
|
|
1701
|
+
};
|
|
1702
|
+
}
|
|
1303
1703
|
async function evaluatePolicy(config, toolName, args, context = {}, hooks = {}) {
|
|
1304
1704
|
const { agent, cwd, activeEnvironment } = context;
|
|
1305
1705
|
const { checkProvenance, isTrustedHost } = hooks;
|
|
@@ -1315,9 +1715,27 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
|
|
|
1315
1715
|
}
|
|
1316
1716
|
}
|
|
1317
1717
|
if (wouldBeIgnored) return { decision: "allow" };
|
|
1718
|
+
const bashCommand = agent !== "Terminal" && isBashTool(toolName) && args && typeof args === "object" ? typeof args.command === "string" ? args.command : null : null;
|
|
1719
|
+
if (bashCommand !== null) {
|
|
1720
|
+
const pipeVerdict = pipeChainVerdict(bashCommand, isTrustedHost);
|
|
1721
|
+
if (pipeVerdict) return pipeVerdict;
|
|
1722
|
+
const fsVerdict = analyzeFsOperation(bashCommand);
|
|
1723
|
+
if (fsVerdict) {
|
|
1724
|
+
const isShieldRule = fsVerdict.ruleName.startsWith("shield:");
|
|
1725
|
+
const labelPrefix = isShieldRule ? "project-jail (AST)" : "Node9 (AST)";
|
|
1726
|
+
return {
|
|
1727
|
+
decision: fsVerdict.verdict,
|
|
1728
|
+
blockedByLabel: `${labelPrefix}: ${fsVerdict.ruleName}`,
|
|
1729
|
+
reason: fsVerdict.reason,
|
|
1730
|
+
tier: 2,
|
|
1731
|
+
ruleName: fsVerdict.ruleName,
|
|
1732
|
+
ruleDescription: fsVerdict.reason
|
|
1733
|
+
};
|
|
1734
|
+
}
|
|
1735
|
+
}
|
|
1318
1736
|
if (config.policy.smartRules.length > 0) {
|
|
1319
1737
|
const matchedRule = config.policy.smartRules.find(
|
|
1320
|
-
(rule) => matchesPattern(toolName, rule.tool) && evaluateSmartConditions(args, rule)
|
|
1738
|
+
(rule) => matchesPattern(toolName, rule.tool) && !(bashCommand !== null && rule.name && AST_FS_REGEX_RULES.has(rule.name)) && evaluateSmartConditions(args, rule)
|
|
1321
1739
|
);
|
|
1322
1740
|
if (matchedRule) {
|
|
1323
1741
|
if (matchedRule.verdict === "allow")
|
|
@@ -1375,41 +1793,8 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
|
|
|
1375
1793
|
tier: 3
|
|
1376
1794
|
};
|
|
1377
1795
|
}
|
|
1378
|
-
const
|
|
1379
|
-
if (
|
|
1380
|
-
const sinks = pipeAnalysis.sinkTargets;
|
|
1381
|
-
const allTrusted = sinks.length > 0 && sinks.every((host) => isTrustedHost ? isTrustedHost(host) : false);
|
|
1382
|
-
if (pipeAnalysis.risk === "critical") {
|
|
1383
|
-
if (allTrusted) {
|
|
1384
|
-
return {
|
|
1385
|
-
decision: "review",
|
|
1386
|
-
blockedByLabel: "Node9: Pipe-Chain to Trusted Host (obfuscated)",
|
|
1387
|
-
reason: `Obfuscated pipe to trusted host(s): ${sinks.join(", ")} \u2014 requires approval`,
|
|
1388
|
-
tier: 3
|
|
1389
|
-
};
|
|
1390
|
-
}
|
|
1391
|
-
return {
|
|
1392
|
-
decision: "block",
|
|
1393
|
-
blockedByLabel: "Node9: Pipe-Chain Exfiltration (critical)",
|
|
1394
|
-
reason: `Sensitive file piped through obfuscator to network sink: ${pipeAnalysis.sourceFiles.join(", ")} \u2192 ${sinks.join(", ")}`,
|
|
1395
|
-
tier: 3
|
|
1396
|
-
};
|
|
1397
|
-
}
|
|
1398
|
-
if (allTrusted) {
|
|
1399
|
-
return {
|
|
1400
|
-
decision: "allow",
|
|
1401
|
-
blockedByLabel: "Node9: Pipe-Chain to Trusted Host",
|
|
1402
|
-
reason: `Sensitive file piped to trusted host(s): ${sinks.join(", ")}`,
|
|
1403
|
-
tier: 3
|
|
1404
|
-
};
|
|
1405
|
-
}
|
|
1406
|
-
return {
|
|
1407
|
-
decision: "review",
|
|
1408
|
-
blockedByLabel: "Node9: Pipe-Chain Exfiltration (high)",
|
|
1409
|
-
reason: `Sensitive file piped to network sink: ${pipeAnalysis.sourceFiles.join(", ")} \u2192 ${sinks.join(", ")}`,
|
|
1410
|
-
tier: 3
|
|
1411
|
-
};
|
|
1412
|
-
}
|
|
1796
|
+
const ptVerdict = pipeChainVerdict(shellCommand, isTrustedHost);
|
|
1797
|
+
if (ptVerdict) return ptVerdict;
|
|
1413
1798
|
const firstToken = analyzed.actions[0] ?? "";
|
|
1414
1799
|
if (["ssh", "scp", "rsync"].includes(firstToken)) {
|
|
1415
1800
|
const rawTokens = shellCommand.trim().split(/\s+/);
|
|
@@ -2086,7 +2471,7 @@ var project_jail_default = {
|
|
|
2086
2471
|
{
|
|
2087
2472
|
field: "command",
|
|
2088
2473
|
op: "matches",
|
|
2089
|
-
value: "(cat|less|head|tail|bat|more|open|print|nano|vim|vi|emacs|code|type)\\s
|
|
2474
|
+
value: "(cat|less|head|tail|bat|more|open|print|nano|vim|vi|emacs|code|type|grep|egrep|fgrep|rg|ag|ack|awk|gawk|sed|cut|tr|jq|yq|od|xxd|hexdump|strings|sort|uniq|tac|nl|dd)\\s+.*?\\.ssh[\\/\\\\]",
|
|
2090
2475
|
flags: "i"
|
|
2091
2476
|
}
|
|
2092
2477
|
],
|
|
@@ -2100,7 +2485,7 @@ var project_jail_default = {
|
|
|
2100
2485
|
{
|
|
2101
2486
|
field: "command",
|
|
2102
2487
|
op: "matches",
|
|
2103
|
-
value: "(cat|less|head|tail|bat|more|open|print|nano|vim|vi|emacs|code|type)\\s
|
|
2488
|
+
value: "(cat|less|head|tail|bat|more|open|print|nano|vim|vi|emacs|code|type|grep|egrep|fgrep|rg|ag|ack|awk|gawk|sed|cut|tr|jq|yq|od|xxd|hexdump|strings|sort|uniq|tac|nl|dd)\\s+.*?\\.aws[\\/\\\\]",
|
|
2104
2489
|
flags: "i"
|
|
2105
2490
|
}
|
|
2106
2491
|
],
|
|
@@ -2114,7 +2499,7 @@ var project_jail_default = {
|
|
|
2114
2499
|
{
|
|
2115
2500
|
field: "command",
|
|
2116
2501
|
op: "matches",
|
|
2117
|
-
value: "(cat|less|head|tail|bat|more|open|print|nano|vim|vi|emacs|code|type)\\s
|
|
2502
|
+
value: "(cat|less|head|tail|bat|more|open|print|nano|vim|vi|emacs|code|type|grep|egrep|fgrep|rg|ag|ack|awk|gawk|sed|cut|tr|jq|yq|od|xxd|hexdump|strings|sort|uniq|tac|nl|dd)\\s+.*?\\.env(\\.(local|production|staging|development|production\\.local|staging\\.local|development\\.local))?(?=\\s|$|[;&|>)<])",
|
|
2118
2503
|
flags: "i"
|
|
2119
2504
|
}
|
|
2120
2505
|
],
|
|
@@ -2122,18 +2507,74 @@ var project_jail_default = {
|
|
|
2122
2507
|
reason: "Reading .env files is blocked by project-jail shield"
|
|
2123
2508
|
},
|
|
2124
2509
|
{
|
|
2125
|
-
name: "shield:project-jail:
|
|
2510
|
+
name: "shield:project-jail:review-read-credentials",
|
|
2126
2511
|
tool: "bash",
|
|
2127
2512
|
conditions: [
|
|
2128
2513
|
{
|
|
2129
2514
|
field: "command",
|
|
2130
2515
|
op: "matches",
|
|
2131
|
-
value: "(cat|less|head|tail|bat|more|open|print|nano|vim|vi|emacs|code|type)\\s+.*(credentials\\.json|\\.netrc|\\.npmrc|\\.docker[\\/\\\\]config\\.json|gcloud[\\/\\\\]credentials)",
|
|
2516
|
+
value: "(cat|less|head|tail|bat|more|open|print|nano|vim|vi|emacs|code|type|grep|egrep|fgrep|rg|ag|ack|awk|gawk|sed|cut|tr|jq|yq|od|xxd|hexdump|strings|sort|uniq|tac|nl|dd)\\s+.*(credentials\\.json|\\.netrc|\\.npmrc|\\.docker[\\/\\\\]config\\.json|gcloud[\\/\\\\]credentials)",
|
|
2517
|
+
flags: "i"
|
|
2518
|
+
}
|
|
2519
|
+
],
|
|
2520
|
+
verdict: "review",
|
|
2521
|
+
reason: "Reading credential files requires approval (project-jail shield)"
|
|
2522
|
+
},
|
|
2523
|
+
{
|
|
2524
|
+
name: "shield:project-jail:block-read-ssh-any-tool",
|
|
2525
|
+
tool: "*",
|
|
2526
|
+
conditions: [
|
|
2527
|
+
{
|
|
2528
|
+
field: "file_path",
|
|
2529
|
+
op: "matches",
|
|
2530
|
+
value: "(^|[\\/\\\\])\\.ssh[\\/\\\\]",
|
|
2531
|
+
flags: "i"
|
|
2532
|
+
}
|
|
2533
|
+
],
|
|
2534
|
+
verdict: "block",
|
|
2535
|
+
reason: "Reading SSH private keys is blocked by project-jail shield"
|
|
2536
|
+
},
|
|
2537
|
+
{
|
|
2538
|
+
name: "shield:project-jail:block-read-aws-any-tool",
|
|
2539
|
+
tool: "*",
|
|
2540
|
+
conditions: [
|
|
2541
|
+
{
|
|
2542
|
+
field: "file_path",
|
|
2543
|
+
op: "matches",
|
|
2544
|
+
value: "(^|[\\/\\\\])\\.aws[\\/\\\\]",
|
|
2545
|
+
flags: "i"
|
|
2546
|
+
}
|
|
2547
|
+
],
|
|
2548
|
+
verdict: "block",
|
|
2549
|
+
reason: "Reading AWS credentials is blocked by project-jail shield"
|
|
2550
|
+
},
|
|
2551
|
+
{
|
|
2552
|
+
name: "shield:project-jail:block-read-env-any-tool",
|
|
2553
|
+
tool: "*",
|
|
2554
|
+
conditions: [
|
|
2555
|
+
{
|
|
2556
|
+
field: "file_path",
|
|
2557
|
+
op: "matches",
|
|
2558
|
+
value: "(^|[\\/\\\\])\\.env(\\.(local|production|staging|development|production\\.local|staging\\.local|development\\.local))?$",
|
|
2132
2559
|
flags: "i"
|
|
2133
2560
|
}
|
|
2134
2561
|
],
|
|
2135
2562
|
verdict: "block",
|
|
2136
|
-
reason: "Reading
|
|
2563
|
+
reason: "Reading .env files is blocked by project-jail shield"
|
|
2564
|
+
},
|
|
2565
|
+
{
|
|
2566
|
+
name: "shield:project-jail:review-read-credentials-any-tool",
|
|
2567
|
+
tool: "*",
|
|
2568
|
+
conditions: [
|
|
2569
|
+
{
|
|
2570
|
+
field: "file_path",
|
|
2571
|
+
op: "matches",
|
|
2572
|
+
value: ".*(credentials\\.json|\\.netrc|\\.npmrc|\\.docker[\\/\\\\]config\\.json|gcloud[\\/\\\\]credentials|\\.kube[\\/\\\\]config)",
|
|
2573
|
+
flags: "i"
|
|
2574
|
+
}
|
|
2575
|
+
],
|
|
2576
|
+
verdict: "review",
|
|
2577
|
+
reason: "Reading credential files requires approval (project-jail shield)"
|
|
2137
2578
|
}
|
|
2138
2579
|
],
|
|
2139
2580
|
dangerousWords: []
|
|
@@ -2542,19 +2983,409 @@ function summarizeBlast(result, opts = {}) {
|
|
|
2542
2983
|
};
|
|
2543
2984
|
}
|
|
2544
2985
|
|
|
2986
|
+
// src/scan/destructive-regex.ts
|
|
2987
|
+
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;
|
|
2988
|
+
var PRIVILEGE_ESCALATION_RE = /\bchmod\s+(0?777|\+x)\b|\bchown\s+root\b/i;
|
|
2989
|
+
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;
|
|
2990
|
+
var FILE_TOOLS = /* @__PURE__ */ new Set([
|
|
2991
|
+
"read",
|
|
2992
|
+
"read_file",
|
|
2993
|
+
"edit",
|
|
2994
|
+
"edit_file",
|
|
2995
|
+
"write",
|
|
2996
|
+
"write_file",
|
|
2997
|
+
"multiedit",
|
|
2998
|
+
"grep",
|
|
2999
|
+
"grep_search",
|
|
3000
|
+
"glob",
|
|
3001
|
+
"list_files"
|
|
3002
|
+
]);
|
|
3003
|
+
|
|
3004
|
+
// src/scan/pii.ts
|
|
3005
|
+
var PII_EMAIL_RE = /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/;
|
|
3006
|
+
var PII_SSN_RE = /\b\d{3}-\d{2}-\d{4}\b/;
|
|
3007
|
+
var PII_PHONE_RE = /\b(?:\+?1[-.\s]?)?\(?\d{3}\)?[-.\s]\d{3}[-.\s]\d{4}\b/;
|
|
3008
|
+
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/;
|
|
3009
|
+
function detectPii(text) {
|
|
3010
|
+
const found = /* @__PURE__ */ new Set();
|
|
3011
|
+
if (/@/.test(text) && PII_EMAIL_RE.test(text)) found.add("Email");
|
|
3012
|
+
if (/-/.test(text) && PII_SSN_RE.test(text)) found.add("SSN");
|
|
3013
|
+
if (PII_PHONE_RE.test(text)) found.add("Phone");
|
|
3014
|
+
if (PII_CC_RE.test(text)) found.add("Credit Card");
|
|
3015
|
+
return [...found];
|
|
3016
|
+
}
|
|
3017
|
+
|
|
3018
|
+
// src/scan/canonical.ts
|
|
3019
|
+
var LONG_OUTPUT_THRESHOLD_BYTES = 100 * 1024;
|
|
3020
|
+
var CANONICAL_EXTRACTOR_VERSION = "canonical-v4";
|
|
3021
|
+
var CANONICAL_EXTRACTOR_HASH = "64a6a63a27f4646f";
|
|
3022
|
+
var DEDUPE_PREVIEW_LEN = 120;
|
|
3023
|
+
function extractCanonicalFindings(call, ctx) {
|
|
3024
|
+
const out = [];
|
|
3025
|
+
const ts = call.timestamp;
|
|
3026
|
+
const toolNameLower = call.toolName.toLowerCase();
|
|
3027
|
+
const command = typeof call.args.command === "string" ? call.args.command : null;
|
|
3028
|
+
const isBash = isBashTool(call.toolName) && command !== null;
|
|
3029
|
+
if (call.outputBytes !== void 0 && call.outputBytes > LONG_OUTPUT_THRESHOLD_BYTES) {
|
|
3030
|
+
out.push(
|
|
3031
|
+
makeFinding({
|
|
3032
|
+
type: "long-output-redacted",
|
|
3033
|
+
ruleName: "long-output-redacted",
|
|
3034
|
+
verdict: "review",
|
|
3035
|
+
severity: "medium",
|
|
3036
|
+
reason: `Tool output exceeded ${LONG_OUTPUT_THRESHOLD_BYTES} bytes and was redacted`,
|
|
3037
|
+
toolName: call.toolName,
|
|
3038
|
+
ctx,
|
|
3039
|
+
ts,
|
|
3040
|
+
sourceType: "engine"
|
|
3041
|
+
})
|
|
3042
|
+
);
|
|
3043
|
+
}
|
|
3044
|
+
if (ctx.dlpEnabled) {
|
|
3045
|
+
const dlp = scanArgs(call.args);
|
|
3046
|
+
if (dlp) {
|
|
3047
|
+
out.push(
|
|
3048
|
+
makeFinding({
|
|
3049
|
+
type: "dlp",
|
|
3050
|
+
ruleName: `dlp:${dlp.patternName}`,
|
|
3051
|
+
patternName: dlp.patternName,
|
|
3052
|
+
verdict: dlp.severity === "block" ? "block" : "review",
|
|
3053
|
+
severity: dlp.severity === "block" ? "critical" : "medium",
|
|
3054
|
+
reason: `${dlp.patternName} detected in ${dlp.fieldPath}`,
|
|
3055
|
+
toolName: call.toolName,
|
|
3056
|
+
ctx,
|
|
3057
|
+
ts,
|
|
3058
|
+
sourceType: "engine",
|
|
3059
|
+
input: call.args,
|
|
3060
|
+
redactedSample: dlp.redactedSample
|
|
3061
|
+
})
|
|
3062
|
+
);
|
|
3063
|
+
}
|
|
3064
|
+
}
|
|
3065
|
+
for (const value of stringValues(call.args)) {
|
|
3066
|
+
const piiHits = detectPii(value);
|
|
3067
|
+
for (const pattern of piiHits) {
|
|
3068
|
+
out.push(
|
|
3069
|
+
makeFinding({
|
|
3070
|
+
type: "pii",
|
|
3071
|
+
ruleName: `pii:${pattern.toLowerCase().replace(/\s+/g, "-")}`,
|
|
3072
|
+
patternName: pattern,
|
|
3073
|
+
verdict: "review",
|
|
3074
|
+
severity: "medium",
|
|
3075
|
+
reason: `${pattern} pattern detected in tool input`,
|
|
3076
|
+
toolName: call.toolName,
|
|
3077
|
+
ctx,
|
|
3078
|
+
ts,
|
|
3079
|
+
sourceType: "engine"
|
|
3080
|
+
})
|
|
3081
|
+
);
|
|
3082
|
+
}
|
|
3083
|
+
}
|
|
3084
|
+
if (FILE_TOOLS.has(toolNameLower)) {
|
|
3085
|
+
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 || "";
|
|
3086
|
+
if (filePath && SENSITIVE_PATH_RE.test(filePath)) {
|
|
3087
|
+
out.push(
|
|
3088
|
+
makeFinding({
|
|
3089
|
+
type: "sensitive-file-read",
|
|
3090
|
+
ruleName: "sensitive-file-read",
|
|
3091
|
+
verdict: "review",
|
|
3092
|
+
severity: "critical",
|
|
3093
|
+
reason: `Sensitive file path read via ${call.toolName}`,
|
|
3094
|
+
toolName: call.toolName,
|
|
3095
|
+
ctx,
|
|
3096
|
+
ts,
|
|
3097
|
+
sourceType: "engine",
|
|
3098
|
+
subjectPath: filePath
|
|
3099
|
+
})
|
|
3100
|
+
);
|
|
3101
|
+
}
|
|
3102
|
+
}
|
|
3103
|
+
if (!isBash || command === null) {
|
|
3104
|
+
return out;
|
|
3105
|
+
}
|
|
3106
|
+
const fsVerdict = analyzeFsOperation(command);
|
|
3107
|
+
if (fsVerdict) {
|
|
3108
|
+
const isShield = fsVerdict.ruleName.startsWith("shield:");
|
|
3109
|
+
out.push(
|
|
3110
|
+
makeFinding({
|
|
3111
|
+
type: "ast-fs-op",
|
|
3112
|
+
ruleName: fsVerdict.ruleName,
|
|
3113
|
+
verdict: fsVerdict.verdict,
|
|
3114
|
+
severity: classifyRuleSeverity(fsVerdict.ruleName, fsVerdict.verdict),
|
|
3115
|
+
reason: fsVerdict.reason,
|
|
3116
|
+
toolName: call.toolName,
|
|
3117
|
+
ctx,
|
|
3118
|
+
ts,
|
|
3119
|
+
sourceType: isShield ? "shield" : "engine",
|
|
3120
|
+
shieldLabel: isShield ? "project-jail (AST)" : "Node9 (AST)",
|
|
3121
|
+
subjectPath: fsVerdict.path,
|
|
3122
|
+
input: call.args
|
|
3123
|
+
})
|
|
3124
|
+
);
|
|
3125
|
+
}
|
|
3126
|
+
for (const source of ctx.rules) {
|
|
3127
|
+
const r = source.rule;
|
|
3128
|
+
if (r.verdict === "allow") continue;
|
|
3129
|
+
if (r.tool && !matchesPattern(toolNameLower, r.tool)) continue;
|
|
3130
|
+
if (r.name && AST_FS_REGEX_RULES.has(r.name)) continue;
|
|
3131
|
+
if (!evaluateSmartConditions(call.args, r)) continue;
|
|
3132
|
+
out.push(
|
|
3133
|
+
makeFinding({
|
|
3134
|
+
type: "smart-rule",
|
|
3135
|
+
ruleName: r.name ?? r.tool,
|
|
3136
|
+
verdict: r.verdict === "block" ? "block" : "review",
|
|
3137
|
+
severity: classifyRuleSeverity(r.name ?? r.tool, r.verdict),
|
|
3138
|
+
reason: r.reason ?? `Smart rule ${r.name ?? r.tool} fired`,
|
|
3139
|
+
toolName: call.toolName,
|
|
3140
|
+
ctx,
|
|
3141
|
+
ts,
|
|
3142
|
+
sourceType: source.sourceType,
|
|
3143
|
+
shieldLabel: source.shieldLabel,
|
|
3144
|
+
input: call.args
|
|
3145
|
+
})
|
|
3146
|
+
);
|
|
3147
|
+
break;
|
|
3148
|
+
}
|
|
3149
|
+
const evalVerdict = detectDangerousShellExec(command);
|
|
3150
|
+
if (evalVerdict) {
|
|
3151
|
+
out.push(
|
|
3152
|
+
makeFinding({
|
|
3153
|
+
type: "eval-of-remote",
|
|
3154
|
+
ruleName: "eval-of-remote",
|
|
3155
|
+
verdict: evalVerdict,
|
|
3156
|
+
severity: classifyRuleSeverity("eval-remote", evalVerdict),
|
|
3157
|
+
reason: evalVerdict === "block" ? "Eval of remote download is a near-certain supply-chain attack" : "Eval of dynamic content (variable / subshell) requires approval",
|
|
3158
|
+
toolName: call.toolName,
|
|
3159
|
+
ctx,
|
|
3160
|
+
ts,
|
|
3161
|
+
sourceType: "engine",
|
|
3162
|
+
input: call.args
|
|
3163
|
+
})
|
|
3164
|
+
);
|
|
3165
|
+
}
|
|
3166
|
+
const pipe = analyzePipeChain(command);
|
|
3167
|
+
if (pipe.isPipeline && pipe.risk === "critical") {
|
|
3168
|
+
out.push(
|
|
3169
|
+
makeFinding({
|
|
3170
|
+
type: "pipe-to-shell",
|
|
3171
|
+
ruleName: "pipe-to-shell",
|
|
3172
|
+
verdict: "block",
|
|
3173
|
+
severity: "critical",
|
|
3174
|
+
reason: `Sensitive file piped through obfuscator to network sink: ${pipe.sourceFiles.join(", ")} \u2192 ${pipe.sinkTargets.join(", ")}`,
|
|
3175
|
+
toolName: call.toolName,
|
|
3176
|
+
ctx,
|
|
3177
|
+
ts,
|
|
3178
|
+
sourceType: "engine",
|
|
3179
|
+
input: call.args
|
|
3180
|
+
})
|
|
3181
|
+
);
|
|
3182
|
+
}
|
|
3183
|
+
if (DESTRUCTIVE_OP_RE.test(command)) {
|
|
3184
|
+
out.push(
|
|
3185
|
+
makeFinding({
|
|
3186
|
+
type: "destructive-op",
|
|
3187
|
+
ruleName: "destructive-op",
|
|
3188
|
+
verdict: "review",
|
|
3189
|
+
severity: "high",
|
|
3190
|
+
reason: "Destructive operation pattern detected",
|
|
3191
|
+
toolName: call.toolName,
|
|
3192
|
+
ctx,
|
|
3193
|
+
ts,
|
|
3194
|
+
sourceType: "engine",
|
|
3195
|
+
input: call.args
|
|
3196
|
+
})
|
|
3197
|
+
);
|
|
3198
|
+
}
|
|
3199
|
+
const ast = analyzeShellCommand(command);
|
|
3200
|
+
const sudoVariant = ast.actions.includes("sudo") || ast.actions.includes("su");
|
|
3201
|
+
const chmodVariant = ast.actions.includes("chmod") && (ast.allTokens.includes("777") || ast.allTokens.includes("0777") || ast.allTokens.includes("+x"));
|
|
3202
|
+
const chownVariant = ast.actions.includes("chown") && ast.allTokens.includes("root");
|
|
3203
|
+
if (sudoVariant || chmodVariant || chownVariant) {
|
|
3204
|
+
out.push(
|
|
3205
|
+
makeFinding({
|
|
3206
|
+
type: "privilege-escalation",
|
|
3207
|
+
ruleName: "privilege-escalation",
|
|
3208
|
+
verdict: "review",
|
|
3209
|
+
severity: "high",
|
|
3210
|
+
reason: "Privilege-escalation pattern detected",
|
|
3211
|
+
toolName: call.toolName,
|
|
3212
|
+
ctx,
|
|
3213
|
+
ts,
|
|
3214
|
+
sourceType: "engine",
|
|
3215
|
+
input: call.args
|
|
3216
|
+
})
|
|
3217
|
+
);
|
|
3218
|
+
}
|
|
3219
|
+
return out;
|
|
3220
|
+
}
|
|
3221
|
+
function extractSessionLevelFindings(calls, ctx) {
|
|
3222
|
+
if (!ctx.loopDetection.enabled || calls.length === 0) return [];
|
|
3223
|
+
const out = [];
|
|
3224
|
+
const seenLoopKeys = /* @__PURE__ */ new Set();
|
|
3225
|
+
const ONE_YEAR_MS = 365 * 24 * 60 * 60 * 1e3;
|
|
3226
|
+
const windowMs = ctx.loopDetection.windowSeconds <= 0 ? ONE_YEAR_MS : ctx.loopDetection.windowSeconds * 1e3;
|
|
3227
|
+
let records = [];
|
|
3228
|
+
let syntheticTs = 0;
|
|
3229
|
+
for (let i = 0; i < calls.length; i++) {
|
|
3230
|
+
const call = calls[i];
|
|
3231
|
+
const parsed = new Date(call.timestamp).getTime();
|
|
3232
|
+
const now = Number.isFinite(parsed) ? parsed : ++syntheticTs;
|
|
3233
|
+
const verdict = evaluateLoopWindow(
|
|
3234
|
+
records,
|
|
3235
|
+
call.toolName,
|
|
3236
|
+
call.args,
|
|
3237
|
+
ctx.loopDetection.threshold,
|
|
3238
|
+
windowMs,
|
|
3239
|
+
now
|
|
3240
|
+
);
|
|
3241
|
+
records = verdict.nextRecords;
|
|
3242
|
+
if (!verdict.looping) continue;
|
|
3243
|
+
const last = records[records.length - 1];
|
|
3244
|
+
const key = `${last.t}|${last.h}`;
|
|
3245
|
+
if (seenLoopKeys.has(key)) continue;
|
|
3246
|
+
seenLoopKeys.add(key);
|
|
3247
|
+
out.push({
|
|
3248
|
+
type: "loop",
|
|
3249
|
+
ruleName: "loop",
|
|
3250
|
+
verdict: "review",
|
|
3251
|
+
severity: "medium",
|
|
3252
|
+
reason: `Tool called ${verdict.count} times with identical args within window`,
|
|
3253
|
+
toolName: call.toolName,
|
|
3254
|
+
agent: ctx.agent,
|
|
3255
|
+
sessionId: ctx.sessionId,
|
|
3256
|
+
project: ctx.project,
|
|
3257
|
+
lineIndex: call.lineIndex,
|
|
3258
|
+
sourceType: "engine",
|
|
3259
|
+
firstSeenAt: call.timestamp,
|
|
3260
|
+
lastSeenAt: call.timestamp,
|
|
3261
|
+
occurrenceCount: 1,
|
|
3262
|
+
loopCount: verdict.count,
|
|
3263
|
+
loopKind: "loop",
|
|
3264
|
+
commandPreview: previewArgs(call.args, DEDUPE_PREVIEW_LEN),
|
|
3265
|
+
costUsd: verdict.count * COST_PER_LOOP_ITER_USD
|
|
3266
|
+
});
|
|
3267
|
+
}
|
|
3268
|
+
return out;
|
|
3269
|
+
}
|
|
3270
|
+
function dedupeCanonicalFindings(findings) {
|
|
3271
|
+
const merged = /* @__PURE__ */ new Map();
|
|
3272
|
+
for (const f of findings) {
|
|
3273
|
+
const inputPreview = f.input ? previewArgs(f.input, DEDUPE_PREVIEW_LEN) : "";
|
|
3274
|
+
const key = `${f.type}|${f.ruleName}|${inputPreview}|${f.project}|${f.agent}`;
|
|
3275
|
+
const prev = merged.get(key);
|
|
3276
|
+
if (!prev) {
|
|
3277
|
+
merged.set(key, { ...f });
|
|
3278
|
+
continue;
|
|
3279
|
+
}
|
|
3280
|
+
prev.occurrenceCount += f.occurrenceCount;
|
|
3281
|
+
if (f.firstSeenAt && (!prev.firstSeenAt || f.firstSeenAt < prev.firstSeenAt)) {
|
|
3282
|
+
prev.firstSeenAt = f.firstSeenAt;
|
|
3283
|
+
}
|
|
3284
|
+
if (f.lastSeenAt && f.lastSeenAt > prev.lastSeenAt) {
|
|
3285
|
+
prev.lastSeenAt = f.lastSeenAt;
|
|
3286
|
+
}
|
|
3287
|
+
if (f.costUsd !== void 0) {
|
|
3288
|
+
prev.costUsd = (prev.costUsd ?? 0) + f.costUsd;
|
|
3289
|
+
}
|
|
3290
|
+
if (f.loopCount !== void 0) {
|
|
3291
|
+
prev.loopCount = (prev.loopCount ?? 0) + f.loopCount;
|
|
3292
|
+
}
|
|
3293
|
+
}
|
|
3294
|
+
return [...merged.values()];
|
|
3295
|
+
}
|
|
3296
|
+
function toScanFinding(c) {
|
|
3297
|
+
const typeMap = {
|
|
3298
|
+
"smart-rule": null,
|
|
3299
|
+
"ast-fs-op": null,
|
|
3300
|
+
dlp: "dlp",
|
|
3301
|
+
pii: "pii",
|
|
3302
|
+
"sensitive-file-read": "sensitive-file-read",
|
|
3303
|
+
"privilege-escalation": "privilege-escalation",
|
|
3304
|
+
"destructive-op": "destructive-op",
|
|
3305
|
+
"pipe-to-shell": "pipe-to-shell",
|
|
3306
|
+
"eval-of-remote": "eval-of-remote",
|
|
3307
|
+
loop: "loop",
|
|
3308
|
+
"long-output-redacted": "long-output-redacted"
|
|
3309
|
+
};
|
|
3310
|
+
const sfType = typeMap[c.type];
|
|
3311
|
+
if (sfType === null) return null;
|
|
3312
|
+
return {
|
|
3313
|
+
sessionId: c.sessionId,
|
|
3314
|
+
type: sfType,
|
|
3315
|
+
...c.patternName && { patternName: c.patternName },
|
|
3316
|
+
lineIndex: c.lineIndex
|
|
3317
|
+
};
|
|
3318
|
+
}
|
|
3319
|
+
var TERMINAL_ESCAPE_RE = (
|
|
3320
|
+
// eslint-disable-next-line no-control-regex
|
|
3321
|
+
/\x1b\[[0-9;?]*[A-Za-z]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[@-_]|[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g
|
|
3322
|
+
);
|
|
3323
|
+
function previewArgs(input, max) {
|
|
3324
|
+
const cmd = input.command ?? input.query ?? input.file_path ?? JSON.stringify(input);
|
|
3325
|
+
const s = String(cmd).replace(TERMINAL_ESCAPE_RE, "").replace(/\s+/g, " ").trim();
|
|
3326
|
+
return s.length > max ? s.slice(0, max - 1) + "\u2026" : s;
|
|
3327
|
+
}
|
|
3328
|
+
function makeFinding(args) {
|
|
3329
|
+
const f = {
|
|
3330
|
+
type: args.type,
|
|
3331
|
+
ruleName: args.ruleName,
|
|
3332
|
+
verdict: args.verdict,
|
|
3333
|
+
severity: args.severity,
|
|
3334
|
+
reason: args.reason,
|
|
3335
|
+
toolName: args.toolName,
|
|
3336
|
+
agent: args.ctx.agent,
|
|
3337
|
+
sessionId: args.ctx.sessionId,
|
|
3338
|
+
project: args.ctx.project,
|
|
3339
|
+
lineIndex: args.ctx.lineIndex,
|
|
3340
|
+
sourceType: args.sourceType,
|
|
3341
|
+
firstSeenAt: args.ts,
|
|
3342
|
+
lastSeenAt: args.ts,
|
|
3343
|
+
occurrenceCount: 1
|
|
3344
|
+
};
|
|
3345
|
+
if (args.shieldLabel) f.shieldLabel = args.shieldLabel;
|
|
3346
|
+
if (args.subjectPath) f.subjectPath = args.subjectPath;
|
|
3347
|
+
if (args.input) f.input = args.input;
|
|
3348
|
+
if (args.patternName) f.patternName = args.patternName;
|
|
3349
|
+
if (args.redactedSample) f.redactedSample = args.redactedSample;
|
|
3350
|
+
return f;
|
|
3351
|
+
}
|
|
3352
|
+
function* stringValues(obj, depth = 0) {
|
|
3353
|
+
if (depth > 6) return;
|
|
3354
|
+
if (typeof obj === "string") {
|
|
3355
|
+
if (obj.length > 0) yield obj;
|
|
3356
|
+
return;
|
|
3357
|
+
}
|
|
3358
|
+
if (!obj || typeof obj !== "object") return;
|
|
3359
|
+
if (Array.isArray(obj)) {
|
|
3360
|
+
for (const v of obj) yield* stringValues(v, depth + 1);
|
|
3361
|
+
return;
|
|
3362
|
+
}
|
|
3363
|
+
for (const v of Object.values(obj)) yield* stringValues(v, depth + 1);
|
|
3364
|
+
}
|
|
3365
|
+
|
|
2545
3366
|
// src/index.ts
|
|
2546
3367
|
var ENGINE_VERSION = "1.4.0";
|
|
2547
3368
|
// Annotate the CommonJS export names for ESM import in node:
|
|
2548
3369
|
0 && (module.exports = {
|
|
3370
|
+
AST_FS_REGEX_RULES,
|
|
3371
|
+
BASH_TOOL_NAMES,
|
|
2549
3372
|
BUILTIN_SHIELDS,
|
|
3373
|
+
CANONICAL_EXTRACTOR_HASH,
|
|
3374
|
+
CANONICAL_EXTRACTOR_VERSION,
|
|
2550
3375
|
COST_PER_LOOP_ITER_USD,
|
|
3376
|
+
DESTRUCTIVE_OP_RE,
|
|
2551
3377
|
DLP_PATTERNS,
|
|
2552
3378
|
ENGINE_VERSION,
|
|
3379
|
+
FILE_TOOLS,
|
|
2553
3380
|
FLAGS_WITH_VALUES,
|
|
3381
|
+
LONG_OUTPUT_THRESHOLD_BYTES,
|
|
2554
3382
|
LOOP_MAX_RECORDS,
|
|
2555
3383
|
LOOP_THRESHOLD_FOR_WASTE,
|
|
3384
|
+
PRIVILEGE_ESCALATION_RE,
|
|
2556
3385
|
SCAN_SIGNAL_WEIGHTS,
|
|
3386
|
+
SENSITIVE_PATH_RE,
|
|
2557
3387
|
SENSITIVE_PATH_REGEXES,
|
|
3388
|
+
analyzeFsOperation,
|
|
2558
3389
|
analyzePipeChain,
|
|
2559
3390
|
analyzeShellCommand,
|
|
2560
3391
|
checkDangerousSql,
|
|
@@ -2565,29 +3396,37 @@ var ENGINE_VERSION = "1.4.0";
|
|
|
2565
3396
|
computeBlendedSecurityScore,
|
|
2566
3397
|
computeScanScore,
|
|
2567
3398
|
computeSecurityScore,
|
|
3399
|
+
dedupeCanonicalFindings,
|
|
2568
3400
|
detectDangerousEval,
|
|
2569
3401
|
detectDangerousShellExec,
|
|
3402
|
+
detectPii,
|
|
2570
3403
|
evaluateLoopWindow,
|
|
2571
3404
|
evaluatePolicy,
|
|
2572
3405
|
evaluateSmartConditions,
|
|
2573
3406
|
extractAllSshHosts,
|
|
3407
|
+
extractCanonicalFindings,
|
|
2574
3408
|
extractNetworkTargets,
|
|
2575
3409
|
extractPositionalArgs,
|
|
3410
|
+
extractSessionLevelFindings,
|
|
2576
3411
|
getCompiledRegex,
|
|
2577
3412
|
getNestedValue,
|
|
3413
|
+
isBashTool,
|
|
2578
3414
|
isIgnoredTool,
|
|
3415
|
+
isProtectedHomePath,
|
|
2579
3416
|
isShieldVerdict,
|
|
2580
3417
|
matchSensitivePath,
|
|
2581
3418
|
matchesPattern,
|
|
2582
3419
|
narrativeRuleLabel,
|
|
2583
3420
|
normalizeCommandForPolicy,
|
|
2584
3421
|
parseAllSshHostsFromCommand,
|
|
3422
|
+
previewArgs,
|
|
2585
3423
|
redactText,
|
|
2586
3424
|
scanArgs,
|
|
2587
3425
|
scanText,
|
|
2588
3426
|
sensitivePathMatch,
|
|
2589
3427
|
summarizeBlast,
|
|
2590
3428
|
summarizeScan,
|
|
3429
|
+
toScanFinding,
|
|
2591
3430
|
truncateBlastPath,
|
|
2592
3431
|
validateOverrides,
|
|
2593
3432
|
validateRegex,
|