@node9/policy-engine 2.13.0 → 2.14.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 +58 -3
- package/dist/index.d.ts +58 -3
- package/dist/index.js +417 -94
- package/dist/index.mjs +411 -94
- package/package.json +1 -1
package/dist/index.mjs
CHANGED
|
@@ -717,13 +717,32 @@ var DLP_PATTERNS_GLOBAL = DLP_PATTERNS.map(
|
|
|
717
717
|
})
|
|
718
718
|
);
|
|
719
719
|
var SENSITIVE_PATH_PATTERNS = [
|
|
720
|
-
/[/\\]\.ssh[/\\]/i,
|
|
721
|
-
/[/\\]\.aws[/\\]/i,
|
|
720
|
+
/[/\\]\.ssh([/\\]|$)/i,
|
|
721
|
+
/[/\\]\.aws([/\\]|$)/i,
|
|
722
722
|
/[/\\]\.config[/\\]gcloud[/\\]/i,
|
|
723
723
|
/[/\\]\.azure[/\\]/i,
|
|
724
724
|
/[/\\]\.kube[/\\]config$/i,
|
|
725
|
-
|
|
726
|
-
// .
|
|
725
|
+
// ⚠️ ONE SEMANTIC, FOUR COPIES. This is the AST tier's `.env` rule verbatim
|
|
726
|
+
// (shell/index.ts SENSITIVE_PATH_RULES), whose reasoning is documented there:
|
|
727
|
+
// structural suffix chain rather than a hand-written list, `example|sample|
|
|
728
|
+
// template` exempt because a fixture stays a fixture whatever follows, and
|
|
729
|
+
// `.test` anchored because `test` names an ENVIRONMENT -- `.env.test` is the
|
|
730
|
+
// committed template, `.env.test.local` is gitignored and holds real values.
|
|
731
|
+
//
|
|
732
|
+
// It was previously `[/\\]\.env($|\.)` with NO exemptions, so `Read .env.example`
|
|
733
|
+
// blocked while `cat .env.example` allowed: the same file, opposite verdicts,
|
|
734
|
+
// decided only by which tool asked. See src/__tests__/jail-both-doors.test.ts,
|
|
735
|
+
// which is the contract that now holds these copies in step, and stage 5 of
|
|
736
|
+
// doc/credential-jail-architecture.md, which replaces them with one generated
|
|
737
|
+
// source.
|
|
738
|
+
// ⚠️ The `.local` branch comes FIRST and takes no exemption. A fixture stays a
|
|
739
|
+
// fixture whatever follows it -- `.env.example.md` is documentation -- but
|
|
740
|
+
// `.env.example.local` is gitignored by the `.env*.local` convention and holds
|
|
741
|
+
// real values, exactly the reasoning that anchors `(?!\.test$)` rather than
|
|
742
|
+
// using `\b`. Without this branch the fixture exemption also bought a two-step
|
|
743
|
+
// bypass: `cp .env .env.sample`, then read the copy.
|
|
744
|
+
/[/\\]\.env(?![\w-])(?:[\w.-]*\.local$|(?!\.(?:example|sample|template)\b)(?!\.test$)[\w.-]*$)/i,
|
|
745
|
+
// .env + any suffix chain; fixtures exempt unless .local
|
|
727
746
|
/[/\\]\.git-credentials$/i,
|
|
728
747
|
/[/\\]\.npmrc$/i,
|
|
729
748
|
/[/\\]\.docker[/\\]config\.json$/i,
|
|
@@ -1282,6 +1301,10 @@ var FS_READ_TOOLS = /* @__PURE__ */ new Set([
|
|
|
1282
1301
|
"od",
|
|
1283
1302
|
"xxd",
|
|
1284
1303
|
"hexdump",
|
|
1304
|
+
// Emits the file's bytes, re-encoded, so it is a read by the set's own test
|
|
1305
|
+
// ("does it emit file contents"). Absent until 2026-09-10, which is why
|
|
1306
|
+
// `base64 ~/.ssh/id_rsa` printed a private key with no verdict.
|
|
1307
|
+
"base64",
|
|
1285
1308
|
"strings",
|
|
1286
1309
|
"sort",
|
|
1287
1310
|
"uniq",
|
|
@@ -1289,8 +1312,56 @@ var FS_READ_TOOLS = /* @__PURE__ */ new Set([
|
|
|
1289
1312
|
"nl",
|
|
1290
1313
|
"dd"
|
|
1291
1314
|
]);
|
|
1315
|
+
var SCP_VALUE_FLAGS = ["i", "F", "o", "c", "S", "P", "J", "D", "W", "l"];
|
|
1316
|
+
var RSYNC_SKIP = [
|
|
1317
|
+
"e",
|
|
1318
|
+
"--rsh",
|
|
1319
|
+
"--exclude",
|
|
1320
|
+
"--exclude-from",
|
|
1321
|
+
"--include",
|
|
1322
|
+
"--include-from",
|
|
1323
|
+
"--files-from",
|
|
1324
|
+
"f",
|
|
1325
|
+
"--filter"
|
|
1326
|
+
];
|
|
1327
|
+
var COPY_VERBS = {
|
|
1328
|
+
cp: { source: "allButLast", targetDirFlag: true },
|
|
1329
|
+
mv: { source: "allButLast", targetDirFlag: true },
|
|
1330
|
+
install: { source: "allButLast", targetDirFlag: true },
|
|
1331
|
+
ln: { source: "first", targetDirFlag: true },
|
|
1332
|
+
scp: { source: "allButLast", skipFlags: SCP_VALUE_FLAGS },
|
|
1333
|
+
rsync: { source: "allButLast", skipFlags: RSYNC_SKIP },
|
|
1334
|
+
tar: {
|
|
1335
|
+
source: "archive",
|
|
1336
|
+
archive: "tar",
|
|
1337
|
+
skipFlags: ["f", "X", "T", "--file", "--exclude", "--exclude-from", "--files-from"]
|
|
1338
|
+
},
|
|
1339
|
+
zip: { source: "archive", archive: "zip", skipFlags: ["x", "i", "--exclude", "--include"] },
|
|
1340
|
+
ar: { source: "archive", archive: "ar" },
|
|
1341
|
+
"7z": { source: "archive", archive: "7z", skipFlags: ["x", "--exclude"] },
|
|
1342
|
+
gzip: { source: "all" },
|
|
1343
|
+
bzip2: { source: "all" },
|
|
1344
|
+
xz: { source: "all" },
|
|
1345
|
+
"docker cp": { source: "allButLast" },
|
|
1346
|
+
"kubectl cp": { source: "allButLast" },
|
|
1347
|
+
"gsutil cp": { source: "allButLast" },
|
|
1348
|
+
"gsutil rsync": { source: "allButLast" },
|
|
1349
|
+
"rclone copy": { source: "allButLast" },
|
|
1350
|
+
"rclone sync": { source: "allButLast" },
|
|
1351
|
+
"aws s3 cp": { source: "allButLast" },
|
|
1352
|
+
"aws s3 mv": { source: "allButLast" },
|
|
1353
|
+
"aws s3 sync": { source: "allButLast" },
|
|
1354
|
+
"gcloud storage cp": { source: "allButLast" },
|
|
1355
|
+
"az storage blob upload": { source: "flagOperand", sourceFlags: ["f", "--file"] }
|
|
1356
|
+
};
|
|
1357
|
+
var TAR_MODE_WORD = /^[a-zA-Z]+$/;
|
|
1358
|
+
var COPY_VERB_HEADS = new Set(Object.keys(COPY_VERBS).map((k) => k.split(" ")[0]));
|
|
1292
1359
|
var FS_OP_PRESCREEN_RE = new RegExp(
|
|
1293
|
-
|
|
1360
|
+
// A quote is a separator too: `eval "cat X"` and `sh -c 'cat X'` put the
|
|
1361
|
+
// reader right after `"` / `'`, and without these two characters the
|
|
1362
|
+
// prescreen rejected every string-wrapped read before the parser ran.
|
|
1363
|
+
// Found 2026-09-11 by instrumenting the walk -- no CallExpr was ever visited.
|
|
1364
|
+
`(?:^|[\\s|;&("'\`\\n/])(?:rm|${[...FS_READ_TOOLS, ...COPY_VERB_HEADS].map((t) => t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|")})\\b|(?<!<)<(?!<)`
|
|
1294
1365
|
);
|
|
1295
1366
|
var HOME_CACHE_ALLOWLIST = [
|
|
1296
1367
|
".cache",
|
|
@@ -1311,12 +1382,12 @@ var SENSITIVE_PATH_RULES = [
|
|
|
1311
1382
|
{
|
|
1312
1383
|
rule: "shield:project-jail:block-read-ssh",
|
|
1313
1384
|
reason: "Reading SSH private keys is blocked by project-jail shield",
|
|
1314
|
-
match: (p) => /(
|
|
1385
|
+
match: (p) => /([\\/]\.ssh[\\/]|^\.ssh[\\/]|^(?:[~/]|[A-Za-z]:).*[\\/]\.ssh$)/i.test(p)
|
|
1315
1386
|
},
|
|
1316
1387
|
{
|
|
1317
1388
|
rule: "shield:project-jail:block-read-aws",
|
|
1318
1389
|
reason: "Reading AWS credentials is blocked by project-jail shield",
|
|
1319
|
-
match: (p) => /(
|
|
1390
|
+
match: (p) => /([\\/]\.aws[\\/]|^\.aws[\\/]|^(?:[~/]|[A-Za-z]:).*[\\/]\.aws$)/i.test(p)
|
|
1320
1391
|
},
|
|
1321
1392
|
{
|
|
1322
1393
|
// Mirrors the JSON shield's `.env` pattern (project-jail.json's
|
|
@@ -1360,7 +1431,9 @@ var SENSITIVE_PATH_RULES = [
|
|
|
1360
1431
|
// symmetry — silently exempts every `.env.test.*` file.
|
|
1361
1432
|
//
|
|
1362
1433
|
// shields.test.ts:983-995 is the canonical contract; keep both in step.
|
|
1363
|
-
match: (p) => /(?:^|[\\/])\.env(?![\w-])(?!\.(?:example|sample|template)\b)(?!\.test$)[\w.-]
|
|
1434
|
+
match: (p) => /(?:^|[\\/])\.env(?![\w-])(?:[\w.-]*\.local$|(?!\.(?:example|sample|template)\b)(?!\.test$)[\w.-]*$)/i.test(
|
|
1435
|
+
p
|
|
1436
|
+
)
|
|
1364
1437
|
},
|
|
1365
1438
|
{
|
|
1366
1439
|
// verdict: 'review' (not 'block') is a deliberate design choice
|
|
@@ -1571,15 +1644,14 @@ function listOps() {
|
|
|
1571
1644
|
return _listOps;
|
|
1572
1645
|
}
|
|
1573
1646
|
var WRAPPER_TAKES_TARGET = /* @__PURE__ */ new Set(["chroot"]);
|
|
1647
|
+
var FIND_EXEC_FLAGS = /* @__PURE__ */ new Set(["-exec", "-execdir", "-ok", "-okdir"]);
|
|
1574
1648
|
var INTERP_LEADING_TARGET = /* @__PURE__ */ new Set(["su"]);
|
|
1575
1649
|
function unwrapCommandHead(words) {
|
|
1576
1650
|
let i = 0;
|
|
1577
1651
|
while (i < words.length) {
|
|
1578
1652
|
const head = (words[i] ?? "").toLowerCase().split("/").pop() ?? "";
|
|
1579
1653
|
if (head === "find") {
|
|
1580
|
-
const x = words.findIndex(
|
|
1581
|
-
(w, k) => k > i && (w === "-exec" || w === "-execdir" || w === "-ok")
|
|
1582
|
-
);
|
|
1654
|
+
const x = words.findIndex((w, k) => k > i && w !== null && FIND_EXEC_FLAGS.has(w));
|
|
1583
1655
|
if (x < 0) break;
|
|
1584
1656
|
i = x + 1;
|
|
1585
1657
|
continue;
|
|
@@ -1601,7 +1673,11 @@ function unwrapCommandHead(words) {
|
|
|
1601
1673
|
if (t.startsWith("-")) {
|
|
1602
1674
|
i++;
|
|
1603
1675
|
const nxt = words[i];
|
|
1604
|
-
if (nxt != null && !nxt.startsWith("-") && !INLINE_INTERPRETER.test(nxt.split("/").pop() ?? "") && !COMMAND_WRAPPERS.has(nxt.toLowerCase()) && !RUNNER_WRAPPERS.has(nxt.toLowerCase())
|
|
1676
|
+
if (nxt != null && !nxt.startsWith("-") && !INLINE_INTERPRETER.test(nxt.split("/").pop() ?? "") && !COMMAND_WRAPPERS.has(nxt.toLowerCase()) && !RUNNER_WRAPPERS.has(nxt.toLowerCase()) && // A reader is a command, never a flag's operand: `env - cat X`,
|
|
1677
|
+
// `stdbuf -o0 cat X`, `ionice -c3 cat X`. Without this the head was
|
|
1678
|
+
// swallowed and the jail needed a looser fallback whose cost was a
|
|
1679
|
+
// false positive on `sudo echo cat X`.
|
|
1680
|
+
!FS_READ_TOOLS.has(nxt.split("/").pop()?.toLowerCase() ?? ""))
|
|
1605
1681
|
i++;
|
|
1606
1682
|
continue;
|
|
1607
1683
|
}
|
|
@@ -1720,38 +1796,43 @@ function isProtectedHomePath(rawPath) {
|
|
|
1720
1796
|
}
|
|
1721
1797
|
return true;
|
|
1722
1798
|
}
|
|
1723
|
-
function
|
|
1724
|
-
const
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
const
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
if (t === "Lit") s += (p.Value ?? "").replace(/\\(.)/g, "$1");
|
|
1732
|
-
else if (t === "SglQuoted") s += p.Value ?? "";
|
|
1733
|
-
else if (t === "DblQuoted") {
|
|
1734
|
-
const inner = p.Parts || [];
|
|
1735
|
-
if (!inner.every((ip) => syntax.NodeType(ip) === "Lit")) return null;
|
|
1736
|
-
s += inner.map((ip) => ip.Value ?? "").join("");
|
|
1737
|
-
} else {
|
|
1738
|
-
return null;
|
|
1739
|
-
}
|
|
1799
|
+
function positionedArgs(words, from = 1, to = words.length) {
|
|
1800
|
+
const out = [];
|
|
1801
|
+
let afterFlag = null;
|
|
1802
|
+
for (let i = from; i < to; i++) {
|
|
1803
|
+
const v = words[i];
|
|
1804
|
+
if (v === null) {
|
|
1805
|
+
afterFlag = null;
|
|
1806
|
+
continue;
|
|
1740
1807
|
}
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
const v = litFromWord(args[i]);
|
|
1748
|
-
if (v === null) continue;
|
|
1749
|
-
if (v.startsWith("-")) flags.push(v);
|
|
1750
|
-
else paths.push(v);
|
|
1808
|
+
if (v.startsWith("-")) {
|
|
1809
|
+
afterFlag = v;
|
|
1810
|
+
continue;
|
|
1811
|
+
}
|
|
1812
|
+
out.push({ value: v, index: out.length, argv: i, afterFlag });
|
|
1813
|
+
afterFlag = null;
|
|
1751
1814
|
}
|
|
1752
|
-
return
|
|
1815
|
+
return out;
|
|
1753
1816
|
}
|
|
1754
|
-
|
|
1817
|
+
function extractLiteralArgs(callExpr) {
|
|
1818
|
+
const rawArgs = callExpr.Args || [];
|
|
1819
|
+
if (rawArgs.length === 0) return { name: "", flags: [], paths: [], words: [], args: [] };
|
|
1820
|
+
const words = rawArgs.map((a) => resolveWordLiteral(a));
|
|
1821
|
+
const name = baseWord(words[0]);
|
|
1822
|
+
const flags = words.slice(1).filter((w) => w !== null && w.startsWith("-"));
|
|
1823
|
+
const args = positionedArgs(words);
|
|
1824
|
+
return { name, flags, paths: args.map((a) => a.value), words, args };
|
|
1825
|
+
}
|
|
1826
|
+
var NET_BINARIES = /* @__PURE__ */ new Set([
|
|
1827
|
+
"curl",
|
|
1828
|
+
"wget",
|
|
1829
|
+
"scp",
|
|
1830
|
+
"ssh",
|
|
1831
|
+
"nc",
|
|
1832
|
+
"ncat",
|
|
1833
|
+
"netcat",
|
|
1834
|
+
"rsync"
|
|
1835
|
+
]);
|
|
1755
1836
|
var VALUE_FLAGS = {
|
|
1756
1837
|
curl: /* @__PURE__ */ new Set([
|
|
1757
1838
|
"-d",
|
|
@@ -2036,6 +2117,9 @@ function deriveRedirOp(sample) {
|
|
|
2036
2117
|
}
|
|
2037
2118
|
}
|
|
2038
2119
|
var REDIR_TRUNCATE_OPS = /* @__PURE__ */ new Set([deriveRedirOp(">_f")]);
|
|
2120
|
+
var REDIR_FILE_IN_OPS = new Set(
|
|
2121
|
+
[deriveRedirOp("cat < f"), deriveRedirOp("cat <> f")].filter((op) => op >= 0)
|
|
2122
|
+
);
|
|
2039
2123
|
var REDIR_HEREDOC_OPS = /* @__PURE__ */ new Set([
|
|
2040
2124
|
deriveRedirOp("cat <<X\nX"),
|
|
2041
2125
|
deriveRedirOp("cat <<-X\nX")
|
|
@@ -2100,16 +2184,21 @@ function isRmCreatedInCommandCleanup(command) {
|
|
|
2100
2184
|
}
|
|
2101
2185
|
return sawRm && ok;
|
|
2102
2186
|
}
|
|
2103
|
-
function analyzeFsOperationImpl(command) {
|
|
2187
|
+
function analyzeFsOperationImpl(command, depth = 0) {
|
|
2104
2188
|
const f = parseShared(command);
|
|
2105
2189
|
if (f === PARSE_FAIL) return null;
|
|
2106
2190
|
let result = null;
|
|
2107
2191
|
try {
|
|
2108
2192
|
syntax.Walk(f, (node) => {
|
|
2109
|
-
if (!node || result) return false;
|
|
2193
|
+
if (!node || result?.verdict === "block") return false;
|
|
2110
2194
|
const n = node;
|
|
2111
|
-
|
|
2112
|
-
|
|
2195
|
+
const nodeType = syntax.NodeType(n);
|
|
2196
|
+
if (nodeType === "Stmt") {
|
|
2197
|
+
result = stricter(result, jailedRedirectRead(n));
|
|
2198
|
+
return result?.verdict !== "block";
|
|
2199
|
+
}
|
|
2200
|
+
if (nodeType !== "CallExpr") return true;
|
|
2201
|
+
const { name, flags, paths, words } = extractLiteralArgs(n);
|
|
2113
2202
|
if (!name) return true;
|
|
2114
2203
|
if (name === "rm") {
|
|
2115
2204
|
const flagStr = flags.join("").toLowerCase();
|
|
@@ -2138,21 +2227,27 @@ function analyzeFsOperationImpl(command) {
|
|
|
2138
2227
|
}
|
|
2139
2228
|
}
|
|
2140
2229
|
}
|
|
2141
|
-
if (
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
reason: sp.reason,
|
|
2149
|
-
path: p
|
|
2150
|
-
};
|
|
2151
|
-
return false;
|
|
2152
|
-
}
|
|
2230
|
+
if (depth < 1) {
|
|
2231
|
+
const payload = literalShellPayload(words, name);
|
|
2232
|
+
if (payload !== null) {
|
|
2233
|
+
const inner = analyzeFsOperationImpl(payload, depth + 1);
|
|
2234
|
+
if (inner) {
|
|
2235
|
+
result = inner;
|
|
2236
|
+
return false;
|
|
2153
2237
|
}
|
|
2238
|
+
return true;
|
|
2239
|
+
}
|
|
2240
|
+
}
|
|
2241
|
+
const readPaths = FS_READ_TOOLS.has(name) ? paths : wrappedReadPaths(words, name);
|
|
2242
|
+
if (readPaths) {
|
|
2243
|
+
for (const p of readPaths) {
|
|
2244
|
+
result = stricter(result, matchSensitivePath2(p));
|
|
2245
|
+
if (result?.verdict === "block") return false;
|
|
2154
2246
|
}
|
|
2155
2247
|
}
|
|
2248
|
+
for (const p of copySourcePaths(words)) {
|
|
2249
|
+
result = stricter(result, copyVerdictOf(matchSensitivePath2(p)));
|
|
2250
|
+
}
|
|
2156
2251
|
return true;
|
|
2157
2252
|
});
|
|
2158
2253
|
return result;
|
|
@@ -2160,6 +2255,209 @@ function analyzeFsOperationImpl(command) {
|
|
|
2160
2255
|
return null;
|
|
2161
2256
|
}
|
|
2162
2257
|
}
|
|
2258
|
+
function stricter(a, b) {
|
|
2259
|
+
if (!a) return b;
|
|
2260
|
+
if (!b) return a;
|
|
2261
|
+
return b.verdict === "block" && a.verdict !== "block" ? b : a;
|
|
2262
|
+
}
|
|
2263
|
+
function flagInfo(w) {
|
|
2264
|
+
if (w.startsWith("--")) {
|
|
2265
|
+
const eq = w.indexOf("=");
|
|
2266
|
+
return eq < 0 ? { letter: null, long: w, attached: null } : { letter: null, long: w.slice(0, eq), attached: w.slice(eq + 1) };
|
|
2267
|
+
}
|
|
2268
|
+
const m = /^-([a-zA-Z]+)(.*)$/.exec(w);
|
|
2269
|
+
if (!m) return { letter: null, long: null, attached: null };
|
|
2270
|
+
return { letter: m[1][m[1].length - 1], long: null, attached: m[2] === "" ? null : m[2] };
|
|
2271
|
+
}
|
|
2272
|
+
function flagIs(w, names) {
|
|
2273
|
+
if (w === null) return false;
|
|
2274
|
+
const f = flagInfo(w);
|
|
2275
|
+
return names.some((n) => n.startsWith("--") ? f.long === n : f.letter === n);
|
|
2276
|
+
}
|
|
2277
|
+
function operandOf(a, names) {
|
|
2278
|
+
if (!names || a.afterFlag === null) return false;
|
|
2279
|
+
return flagIs(a.afterFlag, names) && flagInfo(a.afterFlag).attached === null;
|
|
2280
|
+
}
|
|
2281
|
+
function resolveCopyShape(words, h) {
|
|
2282
|
+
const verb = baseWord(words[h]);
|
|
2283
|
+
if (!verb) return null;
|
|
2284
|
+
const direct = COPY_VERBS[verb];
|
|
2285
|
+
if (direct) return { shape: direct, last: h };
|
|
2286
|
+
const slots = positionedArgs(words, h + 1);
|
|
2287
|
+
for (let i = 0; i < slots.length; i++) {
|
|
2288
|
+
for (let n = 3; n >= 1; n--) {
|
|
2289
|
+
const part = slots.slice(i, i + n);
|
|
2290
|
+
if (part.length < n) continue;
|
|
2291
|
+
const key = [verb, ...part.map((a) => a.value.toLowerCase())].join(" ");
|
|
2292
|
+
const shape = COPY_VERBS[key];
|
|
2293
|
+
if (shape) return { shape, last: part[n - 1].argv };
|
|
2294
|
+
}
|
|
2295
|
+
if (slots[i].afterFlag === null) return null;
|
|
2296
|
+
}
|
|
2297
|
+
return null;
|
|
2298
|
+
}
|
|
2299
|
+
var FIND_OPTIONS = /* @__PURE__ */ new Set(["-H", "-L", "-P"]);
|
|
2300
|
+
function findStartPoints(words, h) {
|
|
2301
|
+
const k = words.findIndex((w, i) => i > h && w !== null && FIND_EXEC_FLAGS.has(w));
|
|
2302
|
+
if (k < 0) return { k, starts: [] };
|
|
2303
|
+
const firstPredicate = words.findIndex(
|
|
2304
|
+
(w, i) => i > h && w !== null && w.startsWith("-") && !FIND_OPTIONS.has(w)
|
|
2305
|
+
);
|
|
2306
|
+
const end = firstPredicate > h ? firstPredicate : k;
|
|
2307
|
+
return { k, starts: positionalAfter(words, h + 1, end) };
|
|
2308
|
+
}
|
|
2309
|
+
function copySourcePaths(words) {
|
|
2310
|
+
const h = unwrapCommandHead(words);
|
|
2311
|
+
const fi = words.findIndex((w, i) => i <= h && baseWord(w) === "find");
|
|
2312
|
+
if (fi >= 0) {
|
|
2313
|
+
const { k, starts } = findStartPoints(words, fi);
|
|
2314
|
+
if (k < 0) return [];
|
|
2315
|
+
const action = unwrapCommandHead(words.slice(k + 1));
|
|
2316
|
+
return resolveCopyShape(words.slice(k + 1), action) ? starts : [];
|
|
2317
|
+
}
|
|
2318
|
+
if (!COPY_VERB_HEADS.has(baseWord(words[h]))) return [];
|
|
2319
|
+
const r = resolveCopyShape(words, h);
|
|
2320
|
+
if (!r) return [];
|
|
2321
|
+
const { shape, last } = r;
|
|
2322
|
+
const args = positionedArgs(words, last + 1);
|
|
2323
|
+
const tail = words.slice(last + 1);
|
|
2324
|
+
const skipped = (a) => operandOf(a, shape.skipFlags);
|
|
2325
|
+
const targetDir = shape.targetDirFlag === true && tail.some((w) => w !== null && w.startsWith("-") && flagIs(w, ["t", "--target-directory"]));
|
|
2326
|
+
const targetOperand = (a) => targetDir && operandOf(a, ["t", "--target-directory"]);
|
|
2327
|
+
const lastOperand = [...tail].reverse().find((w) => w === null || !w.startsWith("-"));
|
|
2328
|
+
const dynamicDest = lastOperand === null;
|
|
2329
|
+
const destIsLastOperand = (shape.source === "allButLast" || shape.source === "first") && !targetDir && !dynamicDest;
|
|
2330
|
+
if (destIsLastOperand && typeof lastOperand === "string" && matchSensitivePath2(lastOperand))
|
|
2331
|
+
return [];
|
|
2332
|
+
let src;
|
|
2333
|
+
switch (shape.source) {
|
|
2334
|
+
case "all":
|
|
2335
|
+
src = args;
|
|
2336
|
+
break;
|
|
2337
|
+
case "first":
|
|
2338
|
+
src = targetDir ? args : args.slice(0, 1);
|
|
2339
|
+
break;
|
|
2340
|
+
case "flagOperand": {
|
|
2341
|
+
const inline = tail.filter((w) => w !== null && w.startsWith("--")).map((w) => flagInfo(w)).filter((f) => f.attached !== null && (shape.sourceFlags ?? []).includes(f.long ?? "")).map((f) => f.attached);
|
|
2342
|
+
return [
|
|
2343
|
+
...args.filter(
|
|
2344
|
+
(a) => flagIs(a.afterFlag, shape.sourceFlags ?? []) && flagInfo(a.afterFlag).attached === null
|
|
2345
|
+
).map((a) => a.value),
|
|
2346
|
+
...inline
|
|
2347
|
+
];
|
|
2348
|
+
}
|
|
2349
|
+
case "archive":
|
|
2350
|
+
src = archiveInputs(shape.archive, args, tail);
|
|
2351
|
+
break;
|
|
2352
|
+
case "allButLast":
|
|
2353
|
+
src = targetDir || dynamicDest ? args : args.slice(0, -1);
|
|
2354
|
+
break;
|
|
2355
|
+
}
|
|
2356
|
+
return src.filter((a) => !skipped(a) && !targetOperand(a)).map((a) => a.value);
|
|
2357
|
+
}
|
|
2358
|
+
function archiveInputs(kind, args, tail) {
|
|
2359
|
+
const first = args[0];
|
|
2360
|
+
const bareKey = first && first.afterFlag === null && TAR_MODE_WORD.test(first.value);
|
|
2361
|
+
if (kind === "tar") {
|
|
2362
|
+
const flagsText = tail.filter((w) => w !== null && w.startsWith("-")).join(" ");
|
|
2363
|
+
const mode = (bareKey ? first.value : "") + flagsText;
|
|
2364
|
+
const extracting = /x|t/.test(bareKey ? first.value.replace(/f/g, "") : "") || /(^|\s)-[a-zA-Z]*[xt]|--extract|--list|--get/.test(flagsText);
|
|
2365
|
+
const writing = /[cruA]/.test(bareKey ? first.value : "") || /(^|\s)-[a-zA-Z]*[cruA]|--create|--append|--update|--concatenate/.test(flagsText);
|
|
2366
|
+
if (extracting && !writing) return [];
|
|
2367
|
+
void mode;
|
|
2368
|
+
let i = 0;
|
|
2369
|
+
if (bareKey) {
|
|
2370
|
+
i = 1;
|
|
2371
|
+
const next = args[1];
|
|
2372
|
+
if (first.value.includes("f") && next && next.afterFlag === null) i = 2;
|
|
2373
|
+
}
|
|
2374
|
+
return args.slice(i);
|
|
2375
|
+
}
|
|
2376
|
+
if (kind === "zip") return first && first.afterFlag === "-" ? args : args.slice(1);
|
|
2377
|
+
if (kind === "ar") return bareKey ? args.slice(2) : args.slice(1);
|
|
2378
|
+
return args.slice(2);
|
|
2379
|
+
}
|
|
2380
|
+
function sampleCopyCommand(verb, src) {
|
|
2381
|
+
const shape = COPY_VERBS[verb];
|
|
2382
|
+
if (!shape) throw new Error(`not a copy verb: ${verb}`);
|
|
2383
|
+
const remote = /^(scp|rsync|aws |gsutil|gcloud|rclone|docker|kubectl)/.test(verb);
|
|
2384
|
+
switch (shape.source) {
|
|
2385
|
+
case "first":
|
|
2386
|
+
return `${verb} -s ${src} /tmp/n9-link`;
|
|
2387
|
+
case "all":
|
|
2388
|
+
return `${verb} -c ${src} > /tmp/n9-out`;
|
|
2389
|
+
case "flagOperand":
|
|
2390
|
+
return `${verb} -f ${src} -c n9`;
|
|
2391
|
+
case "archive":
|
|
2392
|
+
return shape.archive === "tar" ? `tar czf /tmp/n9-out.tgz ${src}` : shape.archive === "zip" ? `zip -r /tmp/n9-out.zip ${src}` : shape.archive === "ar" ? `ar rc /tmp/n9-out.a ${src}` : `7z a /tmp/n9-out.7z ${src}`;
|
|
2393
|
+
case "allButLast":
|
|
2394
|
+
return `${verb} ${src} ${remote ? verb.startsWith("scp") || verb === "rsync" ? "user@host.invalid:/tmp/" : verb.startsWith("docker") || verb.startsWith("kubectl") ? "ctr:/tmp/" : "remote:/n9/" : "/tmp/n9-copy"}`;
|
|
2395
|
+
}
|
|
2396
|
+
}
|
|
2397
|
+
var COPY_RULE_OF = {
|
|
2398
|
+
"shield:project-jail:block-read-ssh": "shield:project-jail:review-copy-ssh",
|
|
2399
|
+
"shield:project-jail:block-read-aws": "shield:project-jail:review-copy-aws",
|
|
2400
|
+
"shield:project-jail:block-read-env": "shield:project-jail:review-copy-env",
|
|
2401
|
+
"shield:project-jail:review-read-credentials": "shield:project-jail:review-copy-credentials"
|
|
2402
|
+
};
|
|
2403
|
+
function copyVerdictOf(hit) {
|
|
2404
|
+
if (!hit) return null;
|
|
2405
|
+
const ruleName = COPY_RULE_OF[hit.ruleName];
|
|
2406
|
+
if (!ruleName) return null;
|
|
2407
|
+
return {
|
|
2408
|
+
ruleName,
|
|
2409
|
+
verdict: "review",
|
|
2410
|
+
reason: `Copying ${hit.path} moves a credential out of its jail (project-jail shield)`,
|
|
2411
|
+
path: hit.path
|
|
2412
|
+
};
|
|
2413
|
+
}
|
|
2414
|
+
function matchSensitivePath2(p) {
|
|
2415
|
+
for (const sp of SENSITIVE_PATH_RULES) {
|
|
2416
|
+
if (sp.match(p))
|
|
2417
|
+
return { ruleName: sp.rule, verdict: sp.verdict ?? "block", reason: sp.reason, path: p };
|
|
2418
|
+
}
|
|
2419
|
+
return null;
|
|
2420
|
+
}
|
|
2421
|
+
function baseWord(w) {
|
|
2422
|
+
return (w ?? "").split("/").pop()?.toLowerCase() ?? "";
|
|
2423
|
+
}
|
|
2424
|
+
var isReaderWord = (w) => w !== null && FS_READ_TOOLS.has(baseWord(w));
|
|
2425
|
+
var positionalAfter = (words, from, to = words.length) => positionedArgs(words, from, to).map((a) => a.value);
|
|
2426
|
+
function wrappedReadPaths(words, name) {
|
|
2427
|
+
if (name === "find") {
|
|
2428
|
+
const { k, starts } = findStartPoints(words, 0);
|
|
2429
|
+
return k > 0 && isReaderWord(words[k + 1] ?? null) ? starts : null;
|
|
2430
|
+
}
|
|
2431
|
+
if (!COMMAND_WRAPPERS.has(name) && !RUNNER_WRAPPERS.has(name)) return null;
|
|
2432
|
+
const h = unwrapCommandHead(words);
|
|
2433
|
+
return h > 0 && isReaderWord(words[h] ?? null) ? positionalAfter(words, h + 1) : null;
|
|
2434
|
+
}
|
|
2435
|
+
function literalShellPayload(words, name) {
|
|
2436
|
+
const h = COMMAND_WRAPPERS.has(name) || RUNNER_WRAPPERS.has(name) ? unwrapCommandHead(words) : 0;
|
|
2437
|
+
const head = (words[h] ?? "").split("/").pop()?.toLowerCase() ?? "";
|
|
2438
|
+
if (head === "eval") {
|
|
2439
|
+
const rest = words.slice(h + 1);
|
|
2440
|
+
if (rest.length === 0 || rest.some((w) => w === null)) return null;
|
|
2441
|
+
return rest.join(" ");
|
|
2442
|
+
}
|
|
2443
|
+
if (SHELL_INTERPRETERS.has(head)) {
|
|
2444
|
+
const c = words.findIndex((w, i) => i > h && w !== null && isInlineCodeFlag(head, w));
|
|
2445
|
+
if (c < 0) return null;
|
|
2446
|
+
return words[c + 1] ?? null;
|
|
2447
|
+
}
|
|
2448
|
+
return null;
|
|
2449
|
+
}
|
|
2450
|
+
function jailedRedirectRead(stmt) {
|
|
2451
|
+
const redirs = stmt.Redirs || [];
|
|
2452
|
+
for (const r of redirs) {
|
|
2453
|
+
if (!r || !REDIR_FILE_IN_OPS.has(r.Op)) continue;
|
|
2454
|
+
const p = resolveWordLiteral(r.Word);
|
|
2455
|
+
if (p === null || p === "") continue;
|
|
2456
|
+
const hit = matchSensitivePath2(p);
|
|
2457
|
+
if (hit) return hit;
|
|
2458
|
+
}
|
|
2459
|
+
return null;
|
|
2460
|
+
}
|
|
2163
2461
|
function analyzeShellCommand(command) {
|
|
2164
2462
|
const actions = [];
|
|
2165
2463
|
const paths = [];
|
|
@@ -2296,21 +2594,7 @@ function evaluateEgress(dests, policy) {
|
|
|
2296
2594
|
}
|
|
2297
2595
|
|
|
2298
2596
|
// src/policy/pipe-chain.ts
|
|
2299
|
-
var SOURCE_COMMANDS = /* @__PURE__ */ new Set([
|
|
2300
|
-
"cat",
|
|
2301
|
-
"head",
|
|
2302
|
-
"tail",
|
|
2303
|
-
"grep",
|
|
2304
|
-
"awk",
|
|
2305
|
-
"sed",
|
|
2306
|
-
"cut",
|
|
2307
|
-
"sort",
|
|
2308
|
-
"tee",
|
|
2309
|
-
"less",
|
|
2310
|
-
"more",
|
|
2311
|
-
"strings",
|
|
2312
|
-
"xxd"
|
|
2313
|
-
]);
|
|
2597
|
+
var SOURCE_COMMANDS = /* @__PURE__ */ new Set([...FS_READ_TOOLS, "tee"]);
|
|
2314
2598
|
var SINK_COMMANDS = /* @__PURE__ */ new Set([
|
|
2315
2599
|
"curl",
|
|
2316
2600
|
"wget",
|
|
@@ -2341,16 +2625,25 @@ var OBFUSCATORS = /* @__PURE__ */ new Set([
|
|
|
2341
2625
|
"node"
|
|
2342
2626
|
]);
|
|
2343
2627
|
var SENSITIVE_PATTERNS = [
|
|
2344
|
-
/
|
|
2345
|
-
|
|
2628
|
+
// Kept in step with the AST tier and dlp/ -- see jail-both-doors.test.ts.
|
|
2629
|
+
/(?:^|\/)\.env(?![\w-])(?:[\w.-]*\.local$|(?!\.(?:example|sample|template)\b)(?!\.test$)[\w.-]*$)/i,
|
|
2630
|
+
// .env chain; fixtures exempt unless .local
|
|
2346
2631
|
/id_rsa|id_ed25519|id_ecdsa|id_dsa/i,
|
|
2347
2632
|
// SSH private keys
|
|
2348
2633
|
/\.pem$|\.key$|\.p12$|\.pfx$/i,
|
|
2349
2634
|
// certificate files
|
|
2350
|
-
/
|
|
2351
|
-
//
|
|
2352
|
-
/
|
|
2353
|
-
//
|
|
2635
|
+
// The `$` half mirrors shell/index.ts's SENSITIVE_PATH_RULES: a file INSIDE
|
|
2636
|
+
// the directory counts wherever it appears, while the directory ITSELF counts
|
|
2637
|
+
// only when the path is ROOTED (`~/.ssh`, `/home/u/.ssh`) -- an unrooted
|
|
2638
|
+
// `config/.ssh` is more likely a search pattern than a read. These are
|
|
2639
|
+
// extracted TOKENS (see `args.some(isSensitivePath)` below), the same input
|
|
2640
|
+
// contract as the shell tier, so the same boundary is the right one.
|
|
2641
|
+
// Without it `grep -r x ~/.ssh | curl -d @-` scored one tier BELOW the
|
|
2642
|
+
// identical pipeline naming a file inside that directory.
|
|
2643
|
+
/(?:^|\/)\.ssh\/|^(?:[~/]|[A-Za-z]:).*\/\.ssh$/i,
|
|
2644
|
+
// ~/.ssh/ and ~/.ssh
|
|
2645
|
+
/(?:^|\/)\.aws\/credentials|^(?:[~/]|[A-Za-z]:).*\/\.aws$/i,
|
|
2646
|
+
// AWS creds + dir
|
|
2354
2647
|
/(?:^|\/)\.netrc$/i,
|
|
2355
2648
|
// netrc (stores HTTP credentials)
|
|
2356
2649
|
/(?:^|\/)(passwd|shadow|sudoers)$/i,
|
|
@@ -2384,8 +2677,8 @@ function splitOnPipe(cmd) {
|
|
|
2384
2677
|
if (current.trim()) segments2.push(current.trim());
|
|
2385
2678
|
return segments2.filter(Boolean);
|
|
2386
2679
|
}
|
|
2387
|
-
function positionalTokens(
|
|
2388
|
-
return
|
|
2680
|
+
function positionalTokens(tokens) {
|
|
2681
|
+
return tokens.slice(1).filter((t) => !t.startsWith("-") && !t.startsWith("@") && t.length > 0);
|
|
2389
2682
|
}
|
|
2390
2683
|
function analyzePipeChain(command) {
|
|
2391
2684
|
const segments2 = splitOnPipe(command);
|
|
@@ -2408,8 +2701,10 @@ function analyzePipeChain(command) {
|
|
|
2408
2701
|
for (const segment of segments2) {
|
|
2409
2702
|
const tokens = segment.split(/\s+/).filter(Boolean);
|
|
2410
2703
|
if (tokens.length === 0) continue;
|
|
2411
|
-
const
|
|
2412
|
-
const
|
|
2704
|
+
const h = unwrapCommandHead(tokens);
|
|
2705
|
+
const head = h < tokens.length ? h : 0;
|
|
2706
|
+
const binary = tokens[head].toLowerCase();
|
|
2707
|
+
const args = positionalTokens(tokens.slice(head));
|
|
2413
2708
|
if (SOURCE_COMMANDS.has(binary)) {
|
|
2414
2709
|
sourceFiles.push(...args);
|
|
2415
2710
|
if (args.some(isSensitivePath)) hasSensitiveSource = true;
|
|
@@ -3019,6 +3314,7 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
|
|
|
3019
3314
|
const shellShaped = isBashTool(toolName) || inspectsShellCommand(toolName, config.policy.toolInspection);
|
|
3020
3315
|
const shellShapedCommand = shellShaped ? isBashTool(toolName) && args && typeof args === "object" ? typeof args.command === "string" ? args.command : null : extractShellCommand(toolName, args, config.policy.toolInspection) : null;
|
|
3021
3316
|
const bashCommand = agent !== "Terminal" ? shellShapedCommand : null;
|
|
3317
|
+
let pendingAstReview;
|
|
3022
3318
|
if (bashCommand !== null) {
|
|
3023
3319
|
const pipeVerdict = pipeChainVerdict(
|
|
3024
3320
|
bashCommand,
|
|
@@ -3030,7 +3326,7 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
|
|
|
3030
3326
|
if (fsVerdict) {
|
|
3031
3327
|
const isShieldRule = fsVerdict.ruleName.startsWith("shield:");
|
|
3032
3328
|
const labelPrefix = isShieldRule ? "project-jail (AST)" : "Node9 (AST)";
|
|
3033
|
-
|
|
3329
|
+
const astVerdict = {
|
|
3034
3330
|
decision: fsVerdict.verdict,
|
|
3035
3331
|
blockedByLabel: `${labelPrefix}: ${fsVerdict.ruleName}`,
|
|
3036
3332
|
reason: fsVerdict.reason,
|
|
@@ -3038,6 +3334,8 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
|
|
|
3038
3334
|
ruleName: fsVerdict.ruleName,
|
|
3039
3335
|
ruleDescription: fsVerdict.reason
|
|
3040
3336
|
};
|
|
3337
|
+
if (fsVerdict.verdict === "block") return astVerdict;
|
|
3338
|
+
pendingAstReview = astVerdict;
|
|
3041
3339
|
}
|
|
3042
3340
|
const sqlAction = resolveCheck(config.policy.commandChecks?.sqlDdl);
|
|
3043
3341
|
const sqlVerdict = sqlAction === "off" ? null : analyzeSqlDestructive(bashCommand);
|
|
@@ -3080,7 +3378,7 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
|
|
|
3080
3378
|
const matchedRule = resolvePinned(matches);
|
|
3081
3379
|
if (matchedRule) {
|
|
3082
3380
|
if (matchedRule.verdict === "allow")
|
|
3083
|
-
return { decision: "allow", ruleName: matchedRule.name ?? matchedRule.tool };
|
|
3381
|
+
return pendingAstReview ?? { decision: "allow", ruleName: matchedRule.name ?? matchedRule.tool };
|
|
3084
3382
|
return {
|
|
3085
3383
|
decision: matchedRule.verdict,
|
|
3086
3384
|
blockedByLabel: `Smart Rule: ${matchedRule.name ?? matchedRule.tool}`,
|
|
@@ -3107,6 +3405,7 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
|
|
|
3107
3405
|
allTokens = analyzed.allTokens;
|
|
3108
3406
|
pathTokens = analyzed.paths;
|
|
3109
3407
|
const candidates = [];
|
|
3408
|
+
if (pendingAstReview) candidates.push(pendingAstReview);
|
|
3110
3409
|
const evalVerdict = detectDangerousShellExec(shellCommand);
|
|
3111
3410
|
if (evalVerdict === "block") {
|
|
3112
3411
|
return {
|
|
@@ -3852,7 +4151,7 @@ var project_jail_default = {
|
|
|
3852
4151
|
{
|
|
3853
4152
|
field: "command",
|
|
3854
4153
|
op: "matches",
|
|
3855
|
-
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[\\/\\\\]",
|
|
4154
|
+
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|base64|sort|uniq|tac|nl|dd)\\s+.*?\\.ssh[\\/\\\\]",
|
|
3856
4155
|
flags: "i"
|
|
3857
4156
|
}
|
|
3858
4157
|
],
|
|
@@ -3866,7 +4165,7 @@ var project_jail_default = {
|
|
|
3866
4165
|
{
|
|
3867
4166
|
field: "command",
|
|
3868
4167
|
op: "matches",
|
|
3869
|
-
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[\\/\\\\]",
|
|
4168
|
+
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|base64|sort|uniq|tac|nl|dd)\\s+.*?\\.aws[\\/\\\\]",
|
|
3870
4169
|
flags: "i"
|
|
3871
4170
|
}
|
|
3872
4171
|
],
|
|
@@ -3880,7 +4179,7 @@ var project_jail_default = {
|
|
|
3880
4179
|
{
|
|
3881
4180
|
field: "command",
|
|
3882
4181
|
op: "matches",
|
|
3883
|
-
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|$|[;&|>)<])",
|
|
4182
|
+
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|base64|sort|uniq|tac|nl|dd)\\s+.*?\\.env(\\.(local|production|staging|development|production\\.local|staging\\.local|development\\.local))?(?=\\s|$|[;&|>)<])",
|
|
3884
4183
|
flags: "i"
|
|
3885
4184
|
}
|
|
3886
4185
|
],
|
|
@@ -3894,7 +4193,7 @@ var project_jail_default = {
|
|
|
3894
4193
|
{
|
|
3895
4194
|
field: "command",
|
|
3896
4195
|
op: "matches",
|
|
3897
|
-
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)",
|
|
4196
|
+
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|base64|sort|uniq|tac|nl|dd)\\s+.*(credentials\\.json|\\.netrc|\\.npmrc|\\.docker[\\/\\\\]config\\.json|gcloud[\\/\\\\]credentials)",
|
|
3898
4197
|
flags: "i"
|
|
3899
4198
|
}
|
|
3900
4199
|
],
|
|
@@ -3908,7 +4207,7 @@ var project_jail_default = {
|
|
|
3908
4207
|
{
|
|
3909
4208
|
field: "file_path",
|
|
3910
4209
|
op: "matches",
|
|
3911
|
-
value: "(
|
|
4210
|
+
value: "([\\/\\\\]\\.ssh([\\/\\\\]|$)|^\\.ssh[\\/\\\\])",
|
|
3912
4211
|
flags: "i"
|
|
3913
4212
|
}
|
|
3914
4213
|
],
|
|
@@ -3922,7 +4221,7 @@ var project_jail_default = {
|
|
|
3922
4221
|
{
|
|
3923
4222
|
field: "file_path",
|
|
3924
4223
|
op: "matches",
|
|
3925
|
-
value: "(
|
|
4224
|
+
value: "([\\/\\\\]\\.aws([\\/\\\\]|$)|^\\.aws[\\/\\\\])",
|
|
3926
4225
|
flags: "i"
|
|
3927
4226
|
}
|
|
3928
4227
|
],
|
|
@@ -3936,7 +4235,7 @@ var project_jail_default = {
|
|
|
3936
4235
|
{
|
|
3937
4236
|
field: "file_path",
|
|
3938
4237
|
op: "matches",
|
|
3939
|
-
value: "(^|[\\/\\\\])\\.env(
|
|
4238
|
+
value: "(^|[\\/\\\\])\\.env(?![\\w-])(?:[\\w.-]*\\.local$|(?!\\.(example|sample|template)\\b)(?!\\.test$)[\\w.-]*$)",
|
|
3940
4239
|
flags: "i"
|
|
3941
4240
|
}
|
|
3942
4241
|
],
|
|
@@ -4213,6 +4512,12 @@ function classifyRuleSeverity(name, verdict) {
|
|
|
4213
4512
|
"read-ssh",
|
|
4214
4513
|
"read-gcp",
|
|
4215
4514
|
"read-cred",
|
|
4515
|
+
// Stage 4 (2026-09-11): a copy of a credential file scores like a READ of it --
|
|
4516
|
+
// read-ssh/aws/cred are critical, so copy-ssh/aws/cred are; read-env is high
|
|
4517
|
+
// (below), so copy-env joins the high list, not this one (/code-review).
|
|
4518
|
+
"copy-ssh",
|
|
4519
|
+
"copy-aws",
|
|
4520
|
+
"copy-cred",
|
|
4216
4521
|
"delete-repo",
|
|
4217
4522
|
"helm-uninstall",
|
|
4218
4523
|
"drop-table",
|
|
@@ -4225,6 +4530,7 @@ function classifyRuleSeverity(name, verdict) {
|
|
|
4225
4530
|
];
|
|
4226
4531
|
if (criticalPatterns.some((p) => n.includes(p))) return "critical";
|
|
4227
4532
|
const highPatterns = [
|
|
4533
|
+
"copy-env",
|
|
4228
4534
|
"force-push",
|
|
4229
4535
|
"force_push",
|
|
4230
4536
|
"git-destructive",
|
|
@@ -4242,6 +4548,11 @@ function narrativeRuleLabel(name) {
|
|
|
4242
4548
|
const map = {
|
|
4243
4549
|
"read-aws": "AWS credentials read",
|
|
4244
4550
|
"read-ssh": "SSH private key read",
|
|
4551
|
+
// Stage 4 copy twins, so `scan --narrative` prints a label, not a raw slug.
|
|
4552
|
+
"copy-ssh": "SSH private key copied out",
|
|
4553
|
+
"copy-aws": "AWS credentials copied out",
|
|
4554
|
+
"copy-env": ".env file copied out",
|
|
4555
|
+
"copy-cred": "credential file copied out",
|
|
4245
4556
|
"read-gcp": "GCP credentials read",
|
|
4246
4557
|
"read-cred": "credential file read",
|
|
4247
4558
|
"delete-repo": "GitHub repository deletion",
|
|
@@ -4379,7 +4690,7 @@ function summarizeBlast(result, opts = {}) {
|
|
|
4379
4690
|
// src/scan/destructive-regex.ts
|
|
4380
4691
|
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;
|
|
4381
4692
|
var PRIVILEGE_ESCALATION_RE = /\bchmod\s+(0?777|\+x)\b|\bchown\s+root\b/i;
|
|
4382
|
-
var SENSITIVE_PATH_RE =
|
|
4693
|
+
var SENSITIVE_PATH_RE = /[\\/]\.aws(?:[\\/]|$)|^\.aws[\\/]|[\\/]\.ssh(?:[\\/]|$)|^\.ssh[\\/]|(?:^|[\\/])\.env(?![\w-])(?:[\w.-]*\.local$|(?!\.(?:example|sample|template)\b)(?!\.test$)[\w.-]*$)|\.config\/gcloud\/credentials\.db\b|\.docker\/config\.json\b|\.netrc\b|\.npmrc\b|\.node9\/credentials\.json\b/i;
|
|
4383
4694
|
var FILE_TOOLS = /* @__PURE__ */ new Set([
|
|
4384
4695
|
"read",
|
|
4385
4696
|
"read_file",
|
|
@@ -4639,8 +4950,8 @@ function matchCanaryArgs(args, values) {
|
|
|
4639
4950
|
|
|
4640
4951
|
// src/scan/canonical.ts
|
|
4641
4952
|
var LONG_OUTPUT_THRESHOLD_BYTES = 100 * 1024;
|
|
4642
|
-
var CANONICAL_EXTRACTOR_VERSION = "canonical-
|
|
4643
|
-
var CANONICAL_EXTRACTOR_HASH = "
|
|
4953
|
+
var CANONICAL_EXTRACTOR_VERSION = "canonical-v14";
|
|
4954
|
+
var CANONICAL_EXTRACTOR_HASH = "8b5729fe236a195b";
|
|
4644
4955
|
var DEDUPE_PREVIEW_LEN = 120;
|
|
4645
4956
|
function extractCanonicalFindings(call, ctx) {
|
|
4646
4957
|
const out = [];
|
|
@@ -5015,6 +5326,8 @@ export {
|
|
|
5015
5326
|
CANARY_MIN_LENGTH,
|
|
5016
5327
|
CANONICAL_EXTRACTOR_HASH,
|
|
5017
5328
|
CANONICAL_EXTRACTOR_VERSION,
|
|
5329
|
+
COMMAND_WRAPPERS,
|
|
5330
|
+
COPY_VERBS,
|
|
5018
5331
|
COST_PER_LOOP_ITER_USD,
|
|
5019
5332
|
DEFAULT_EGRESS_ALLOWLIST,
|
|
5020
5333
|
DESTINATION_ARGS,
|
|
@@ -5028,6 +5341,7 @@ export {
|
|
|
5028
5341
|
LONG_OUTPUT_THRESHOLD_BYTES,
|
|
5029
5342
|
LOOP_MAX_RECORDS,
|
|
5030
5343
|
LOOP_THRESHOLD_FOR_WASTE,
|
|
5344
|
+
NET_BINARIES,
|
|
5031
5345
|
PRIVILEGE_ESCALATION_RE,
|
|
5032
5346
|
REALTIME_PII_PATTERNS,
|
|
5033
5347
|
SCAN_SIGNAL_WEIGHTS,
|
|
@@ -5083,9 +5397,11 @@ export {
|
|
|
5083
5397
|
normalizeIpLiteral,
|
|
5084
5398
|
parseAllSshHostsFromCommand,
|
|
5085
5399
|
parseDestHost,
|
|
5400
|
+
positionedArgs,
|
|
5086
5401
|
previewArgs,
|
|
5087
5402
|
redactText,
|
|
5088
5403
|
resolvePinned,
|
|
5404
|
+
sampleCopyCommand,
|
|
5089
5405
|
scanArgs,
|
|
5090
5406
|
scanInjection,
|
|
5091
5407
|
scanText,
|
|
@@ -5098,6 +5414,7 @@ export {
|
|
|
5098
5414
|
toScanFinding,
|
|
5099
5415
|
toolMatchesRule,
|
|
5100
5416
|
truncateBlastPath,
|
|
5417
|
+
unwrapCommandHead,
|
|
5101
5418
|
validateOverrides,
|
|
5102
5419
|
validateRegex,
|
|
5103
5420
|
validateShieldDefinition
|