@node9/policy-engine 2.13.1 → 2.14.1
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 +95 -3
- package/dist/index.d.ts +95 -3
- package/dist/index.js +832 -30
- package/dist/index.mjs +826 -30
- package/package.json +1 -1
package/dist/index.mjs
CHANGED
|
@@ -1121,6 +1121,19 @@ function parseShared(command) {
|
|
|
1121
1121
|
astCache.set(command, parsed);
|
|
1122
1122
|
return parsed;
|
|
1123
1123
|
}
|
|
1124
|
+
function byteOffsetToCharIndex(command) {
|
|
1125
|
+
if (!/[^\u0000-\u007F]/.test(command)) return null;
|
|
1126
|
+
const map = /* @__PURE__ */ new Map();
|
|
1127
|
+
let byte = 0;
|
|
1128
|
+
for (let i = 0; i < command.length; ) {
|
|
1129
|
+
map.set(byte, i);
|
|
1130
|
+
const cp = command.codePointAt(i);
|
|
1131
|
+
byte += cp < 128 ? 1 : cp < 2048 ? 2 : cp < 65536 ? 3 : 4;
|
|
1132
|
+
i += cp > 65535 ? 2 : 1;
|
|
1133
|
+
}
|
|
1134
|
+
map.set(byte, command.length);
|
|
1135
|
+
return (b) => map.get(b) ?? -1;
|
|
1136
|
+
}
|
|
1124
1137
|
function cachedNormalize(command, compute) {
|
|
1125
1138
|
const hit = normalizeCache.get(command);
|
|
1126
1139
|
if (hit !== void 0) {
|
|
@@ -1150,6 +1163,8 @@ function normalizeCommandForPolicyImpl(command) {
|
|
|
1150
1163
|
const f = parseShared(command);
|
|
1151
1164
|
if (f === PARSE_FAIL) return { posix: command, separator: command };
|
|
1152
1165
|
try {
|
|
1166
|
+
const toCharIndex = byteOffsetToCharIndex(command);
|
|
1167
|
+
const at = (byteOffset) => toCharIndex === null ? byteOffset : toCharIndex(byteOffset);
|
|
1153
1168
|
const strips = [];
|
|
1154
1169
|
const rewrites = [];
|
|
1155
1170
|
const quoteOnlyRewrites = [];
|
|
@@ -1170,8 +1185,9 @@ function normalizeCommandForPolicyImpl(command) {
|
|
|
1170
1185
|
const quotedNode = nextParts[0];
|
|
1171
1186
|
const nt = syntax.NodeType(quotedNode);
|
|
1172
1187
|
const markStrip = () => {
|
|
1173
|
-
const s = next.Pos().Offset();
|
|
1174
|
-
const e = next.End().Offset();
|
|
1188
|
+
const s = at(next.Pos().Offset());
|
|
1189
|
+
const e = at(next.End().Offset());
|
|
1190
|
+
if (s < 0 || e < 0) return;
|
|
1175
1191
|
strips.push([s, e]);
|
|
1176
1192
|
msgSpans.add(`${s}:${e}`);
|
|
1177
1193
|
};
|
|
@@ -1188,8 +1204,9 @@ function normalizeCommandForPolicyImpl(command) {
|
|
|
1188
1204
|
}
|
|
1189
1205
|
}
|
|
1190
1206
|
for (const arg of args) {
|
|
1191
|
-
const s = arg.Pos().Offset();
|
|
1192
|
-
const e = arg.End().Offset();
|
|
1207
|
+
const s = at(arg.Pos().Offset());
|
|
1208
|
+
const e = at(arg.End().Offset());
|
|
1209
|
+
if (s < 0 || e < 0) continue;
|
|
1193
1210
|
if (msgSpans.has(`${s}:${e}`)) continue;
|
|
1194
1211
|
const resolved = resolveWordLiteral(arg);
|
|
1195
1212
|
if (resolved === null) continue;
|
|
@@ -1312,12 +1329,410 @@ var FS_READ_TOOLS = /* @__PURE__ */ new Set([
|
|
|
1312
1329
|
"nl",
|
|
1313
1330
|
"dd"
|
|
1314
1331
|
]);
|
|
1332
|
+
var GREP_SHAPE = {
|
|
1333
|
+
takesValue: /* @__PURE__ */ new Set([
|
|
1334
|
+
"-A",
|
|
1335
|
+
"-B",
|
|
1336
|
+
"-C",
|
|
1337
|
+
"-D",
|
|
1338
|
+
"-d",
|
|
1339
|
+
"-e",
|
|
1340
|
+
"-f",
|
|
1341
|
+
"-m",
|
|
1342
|
+
"--after-context",
|
|
1343
|
+
"--before-context",
|
|
1344
|
+
"--binary-files",
|
|
1345
|
+
"--context",
|
|
1346
|
+
"--devices",
|
|
1347
|
+
"--directories",
|
|
1348
|
+
"--exclude",
|
|
1349
|
+
"--exclude-dir",
|
|
1350
|
+
"--exclude-from",
|
|
1351
|
+
"--file",
|
|
1352
|
+
"--include",
|
|
1353
|
+
"--label",
|
|
1354
|
+
"--max-count",
|
|
1355
|
+
"--regexp"
|
|
1356
|
+
]),
|
|
1357
|
+
noValue: /* @__PURE__ */ new Set([
|
|
1358
|
+
"-E",
|
|
1359
|
+
"-F",
|
|
1360
|
+
"-G",
|
|
1361
|
+
"-P",
|
|
1362
|
+
"-i",
|
|
1363
|
+
"-y",
|
|
1364
|
+
"-v",
|
|
1365
|
+
"-V",
|
|
1366
|
+
"-w",
|
|
1367
|
+
"-x",
|
|
1368
|
+
"-c",
|
|
1369
|
+
"-l",
|
|
1370
|
+
"-L",
|
|
1371
|
+
"-o",
|
|
1372
|
+
"-q",
|
|
1373
|
+
"-s",
|
|
1374
|
+
"-b",
|
|
1375
|
+
"-H",
|
|
1376
|
+
"-h",
|
|
1377
|
+
"-n",
|
|
1378
|
+
"-T",
|
|
1379
|
+
"-Z",
|
|
1380
|
+
"-z",
|
|
1381
|
+
"-R",
|
|
1382
|
+
"-r",
|
|
1383
|
+
"-U",
|
|
1384
|
+
"-u",
|
|
1385
|
+
"-I",
|
|
1386
|
+
"-a",
|
|
1387
|
+
"--basic-regexp",
|
|
1388
|
+
"--binary",
|
|
1389
|
+
"--byte-offset",
|
|
1390
|
+
"--color",
|
|
1391
|
+
"--colour",
|
|
1392
|
+
"--count",
|
|
1393
|
+
"--dereference-recursive",
|
|
1394
|
+
"--extended-regexp",
|
|
1395
|
+
"--files-with-matches",
|
|
1396
|
+
"--files-without-match",
|
|
1397
|
+
"--fixed-strings",
|
|
1398
|
+
"--help",
|
|
1399
|
+
"--ignore-case",
|
|
1400
|
+
"--initial-tab",
|
|
1401
|
+
"--invert-match",
|
|
1402
|
+
"--line-buffered",
|
|
1403
|
+
"--line-number",
|
|
1404
|
+
"--line-regexp",
|
|
1405
|
+
"--no-filename",
|
|
1406
|
+
"--no-group-separator",
|
|
1407
|
+
"--no-ignore-case",
|
|
1408
|
+
"--no-messages",
|
|
1409
|
+
"--null",
|
|
1410
|
+
"--null-data",
|
|
1411
|
+
"--only-matching",
|
|
1412
|
+
"--perl-regexp",
|
|
1413
|
+
"--quiet",
|
|
1414
|
+
"--recursive",
|
|
1415
|
+
"--silent",
|
|
1416
|
+
"--text",
|
|
1417
|
+
"--version",
|
|
1418
|
+
"--with-filename",
|
|
1419
|
+
"--word-regexp"
|
|
1420
|
+
]),
|
|
1421
|
+
patternFlags: /* @__PURE__ */ new Set(["-e", "--regexp"]),
|
|
1422
|
+
noPatternFlags: /* @__PURE__ */ new Set(["-f", "--file"])
|
|
1423
|
+
};
|
|
1424
|
+
var PATTERN_VERBS = {
|
|
1425
|
+
grep: GREP_SHAPE,
|
|
1426
|
+
// /usr/bin/egrep and /usr/bin/fgrep are 41-byte shell wrappers that exec
|
|
1427
|
+
// `grep -E` and `grep -F`. Read in full, not assumed.
|
|
1428
|
+
egrep: GREP_SHAPE,
|
|
1429
|
+
fgrep: GREP_SHAPE,
|
|
1430
|
+
// ripgrep 14.1.1, complete from its own --help. An earlier comment here said rg
|
|
1431
|
+
// was not installed on the measuring machine; it is, and that stale claim is why
|
|
1432
|
+
// this table shipped incomplete for two rounds, blocking ordinary searches like
|
|
1433
|
+
// `rg --sort-files .env src` (/code-review round 4).
|
|
1434
|
+
rg: {
|
|
1435
|
+
takesValue: /* @__PURE__ */ new Set([
|
|
1436
|
+
"-A",
|
|
1437
|
+
"-B",
|
|
1438
|
+
"-C",
|
|
1439
|
+
"-d",
|
|
1440
|
+
"-E",
|
|
1441
|
+
"-e",
|
|
1442
|
+
"-f",
|
|
1443
|
+
"-g",
|
|
1444
|
+
"-j",
|
|
1445
|
+
"-M",
|
|
1446
|
+
"-m",
|
|
1447
|
+
"-r",
|
|
1448
|
+
"-t",
|
|
1449
|
+
"-T",
|
|
1450
|
+
"--after-context",
|
|
1451
|
+
"--before-context",
|
|
1452
|
+
"--color",
|
|
1453
|
+
"--colors",
|
|
1454
|
+
"--context",
|
|
1455
|
+
"--context-separator",
|
|
1456
|
+
"--dfa-size-limit",
|
|
1457
|
+
"--encoding",
|
|
1458
|
+
"--engine",
|
|
1459
|
+
"--field-context-separator",
|
|
1460
|
+
"--field-match-separator",
|
|
1461
|
+
"--file",
|
|
1462
|
+
"--generate",
|
|
1463
|
+
"--glob",
|
|
1464
|
+
"--hostname-bin",
|
|
1465
|
+
"--hyperlink-format",
|
|
1466
|
+
"--iglob",
|
|
1467
|
+
"--ignore-file",
|
|
1468
|
+
"--max-columns",
|
|
1469
|
+
"--max-count",
|
|
1470
|
+
"--max-depth",
|
|
1471
|
+
// An ALIAS of --max-depth, and it takes a value. The round-5 extraction read
|
|
1472
|
+
// alias lines as plain switches and swept it into noValue, which hard-blocked
|
|
1473
|
+
// `rg --maxdepth 2 .env src` while `--max-depth 2` ran (/code-review round 7).
|
|
1474
|
+
"--maxdepth",
|
|
1475
|
+
"--max-filesize",
|
|
1476
|
+
"--path-separator",
|
|
1477
|
+
"--pre",
|
|
1478
|
+
"--pre-glob",
|
|
1479
|
+
"--regexp",
|
|
1480
|
+
"--regex-size-limit",
|
|
1481
|
+
"--replace",
|
|
1482
|
+
"--sort",
|
|
1483
|
+
"--sortr",
|
|
1484
|
+
"--threads",
|
|
1485
|
+
"--type",
|
|
1486
|
+
"--type-add",
|
|
1487
|
+
"--type-clear",
|
|
1488
|
+
"--type-not"
|
|
1489
|
+
]),
|
|
1490
|
+
noValue: /* @__PURE__ */ new Set([
|
|
1491
|
+
"-.",
|
|
1492
|
+
"-0",
|
|
1493
|
+
"-a",
|
|
1494
|
+
"-b",
|
|
1495
|
+
"-c",
|
|
1496
|
+
"-F",
|
|
1497
|
+
"-h",
|
|
1498
|
+
"-H",
|
|
1499
|
+
"-i",
|
|
1500
|
+
"-I",
|
|
1501
|
+
"-l",
|
|
1502
|
+
"-L",
|
|
1503
|
+
"-n",
|
|
1504
|
+
"-N",
|
|
1505
|
+
"-o",
|
|
1506
|
+
"-p",
|
|
1507
|
+
"-P",
|
|
1508
|
+
"-q",
|
|
1509
|
+
"-s",
|
|
1510
|
+
"-S",
|
|
1511
|
+
"-u",
|
|
1512
|
+
"-U",
|
|
1513
|
+
"-v",
|
|
1514
|
+
"-V",
|
|
1515
|
+
"-w",
|
|
1516
|
+
"-x",
|
|
1517
|
+
"-z",
|
|
1518
|
+
"--auto-hybrid-regex",
|
|
1519
|
+
"--binary",
|
|
1520
|
+
"--block-buffered",
|
|
1521
|
+
"--byte-offset",
|
|
1522
|
+
"--case-sensitive",
|
|
1523
|
+
"--column",
|
|
1524
|
+
"--count",
|
|
1525
|
+
"--count-matches",
|
|
1526
|
+
"--crlf",
|
|
1527
|
+
"--debug",
|
|
1528
|
+
"--files",
|
|
1529
|
+
"--files-with-matches",
|
|
1530
|
+
"--files-without-match",
|
|
1531
|
+
"--fixed-strings",
|
|
1532
|
+
"--follow",
|
|
1533
|
+
"--glob-case-insensitive",
|
|
1534
|
+
"--heading",
|
|
1535
|
+
"--help",
|
|
1536
|
+
"--hidden",
|
|
1537
|
+
"--ignore-case",
|
|
1538
|
+
"--ignore-file-case-insensitive",
|
|
1539
|
+
"--include-zero",
|
|
1540
|
+
"--invert-match",
|
|
1541
|
+
"--json",
|
|
1542
|
+
"--line-buffered",
|
|
1543
|
+
"--line-number",
|
|
1544
|
+
"--line-regexp",
|
|
1545
|
+
"--max-columns-preview",
|
|
1546
|
+
"--mmap",
|
|
1547
|
+
"--multiline",
|
|
1548
|
+
"--multiline-dotall",
|
|
1549
|
+
"--no-column",
|
|
1550
|
+
"--no-config",
|
|
1551
|
+
"--no-context-separator",
|
|
1552
|
+
"--no-encoding",
|
|
1553
|
+
"--no-filename",
|
|
1554
|
+
"--no-ignore",
|
|
1555
|
+
"--no-ignore-dot",
|
|
1556
|
+
"--no-ignore-exclude",
|
|
1557
|
+
"--no-ignore-files",
|
|
1558
|
+
"--no-ignore-global",
|
|
1559
|
+
"--no-ignore-messages",
|
|
1560
|
+
"--no-ignore-parent",
|
|
1561
|
+
"--no-ignore-vcs",
|
|
1562
|
+
"--no-line-number",
|
|
1563
|
+
"--no-messages",
|
|
1564
|
+
"--no-pcre2-unicode",
|
|
1565
|
+
"--no-pre",
|
|
1566
|
+
"--no-require-git",
|
|
1567
|
+
"--no-unicode",
|
|
1568
|
+
"--null",
|
|
1569
|
+
"--null-data",
|
|
1570
|
+
"--one-file-system",
|
|
1571
|
+
"--only-matching",
|
|
1572
|
+
"--passthru",
|
|
1573
|
+
"--pcre2",
|
|
1574
|
+
"--pcre2-version",
|
|
1575
|
+
"--pretty",
|
|
1576
|
+
"--print0",
|
|
1577
|
+
"--quiet",
|
|
1578
|
+
"--search-zip",
|
|
1579
|
+
"--smart-case",
|
|
1580
|
+
"--sort-files",
|
|
1581
|
+
"--stats",
|
|
1582
|
+
"--stop-on-nonmatch",
|
|
1583
|
+
"--text",
|
|
1584
|
+
"--trace",
|
|
1585
|
+
"--trim",
|
|
1586
|
+
"--type-list",
|
|
1587
|
+
"--unrestricted",
|
|
1588
|
+
"--version",
|
|
1589
|
+
"--vimgrep",
|
|
1590
|
+
"--with-filename",
|
|
1591
|
+
"--word-regexp",
|
|
1592
|
+
// The COMPLETE remainder of `rg --help`, added after /code-review round 5
|
|
1593
|
+
// found 40 documented switches in neither set. The table is now the whole
|
|
1594
|
+
// option list: `rg --help` yields 149 long options, 35 of them value-taking.
|
|
1595
|
+
"--ignore",
|
|
1596
|
+
"--ignore-dot",
|
|
1597
|
+
"--ignore-exclude",
|
|
1598
|
+
"--ignore-files",
|
|
1599
|
+
"--ignore-global",
|
|
1600
|
+
"--ignore-messages",
|
|
1601
|
+
"--ignore-parent",
|
|
1602
|
+
"--ignore-vcs",
|
|
1603
|
+
"--messages",
|
|
1604
|
+
"--no-auto-hybrid-regex",
|
|
1605
|
+
"--no-binary",
|
|
1606
|
+
"--no-block-buffered",
|
|
1607
|
+
"--no-byte-offset",
|
|
1608
|
+
"--no-crlf",
|
|
1609
|
+
"--no-fixed-strings",
|
|
1610
|
+
"--no-follow",
|
|
1611
|
+
"--no-glob-case-insensitive",
|
|
1612
|
+
"--no-heading",
|
|
1613
|
+
"--no-hidden",
|
|
1614
|
+
"--no-ignore-file-case-insensitive",
|
|
1615
|
+
"--no-include-zero",
|
|
1616
|
+
"--no-invert-match",
|
|
1617
|
+
"--no-json",
|
|
1618
|
+
"--no-line-buffered",
|
|
1619
|
+
"--no-max-columns-preview",
|
|
1620
|
+
"--no-mmap",
|
|
1621
|
+
"--no-multiline",
|
|
1622
|
+
"--no-multiline-dotall",
|
|
1623
|
+
"--no-one-file-system",
|
|
1624
|
+
"--no-pcre2",
|
|
1625
|
+
"--no-search-zip",
|
|
1626
|
+
"--no-sort-files",
|
|
1627
|
+
"--no-stats",
|
|
1628
|
+
"--no-text",
|
|
1629
|
+
"--no-trim",
|
|
1630
|
+
"--passthrough",
|
|
1631
|
+
"--pcre2-unicode",
|
|
1632
|
+
"--require-git",
|
|
1633
|
+
"--unicode"
|
|
1634
|
+
]),
|
|
1635
|
+
patternFlags: /* @__PURE__ */ new Set(["-e", "--regexp"]),
|
|
1636
|
+
// `--files` and `--type-list` list or enumerate without a pattern. Founder
|
|
1637
|
+
// decision 2026-09-13: `rg --files ~/.ssh` stays BLOCKED, which this achieves
|
|
1638
|
+
// by leaving the directory in the judged list.
|
|
1639
|
+
noPatternFlags: /* @__PURE__ */ new Set(["-f", "--file", "--files", "--type-list", "--pcre2-version"])
|
|
1640
|
+
}
|
|
1641
|
+
};
|
|
1642
|
+
var PATTERN_VERB_NAMES = Object.keys(PATTERN_VERBS);
|
|
1643
|
+
var patternShapeOf = (verb) => PATTERN_VERBS[verb];
|
|
1644
|
+
var fileOperandFlagsOf = (verb) => FILE_OPERAND_FLAGS[verb];
|
|
1645
|
+
var GREP_FILE_OPERANDS = /* @__PURE__ */ new Set(["-f", "--file", "--exclude-from", "--include"]);
|
|
1646
|
+
var READER_VALUE_LETTERS = {
|
|
1647
|
+
awk: ["F", "v", "f"],
|
|
1648
|
+
gawk: ["F", "v", "f", "e", "E", "i", "l", "o", "p", "D"],
|
|
1649
|
+
sed: ["e", "f", "i", "l"],
|
|
1650
|
+
sort: ["C", "k", "o", "S", "t", "T"]
|
|
1651
|
+
};
|
|
1652
|
+
var FILE_OPERAND_FLAGS = {
|
|
1653
|
+
grep: GREP_FILE_OPERANDS,
|
|
1654
|
+
egrep: GREP_FILE_OPERANDS,
|
|
1655
|
+
fgrep: GREP_FILE_OPERANDS,
|
|
1656
|
+
// rg and gawk are NOT installed on the measuring machine: these two rows are
|
|
1657
|
+
// from the shipped documentation (ripgrep `-f/--file`, `--ignore-file`; gawk
|
|
1658
|
+
// `-f/--file`) and are marked as such in the spec.
|
|
1659
|
+
// `-g/--glob/--iglob` name which files ripgrep SEARCHES, so an operand naming
|
|
1660
|
+
// a credential makes rg open it -- the same reason grep's `--include` is here.
|
|
1661
|
+
// Measured (/code-review round 3): `rg --hidden -g .env AWS tree` opened .env
|
|
1662
|
+
// and printed its contents.
|
|
1663
|
+
rg: /* @__PURE__ */ new Set(["-f", "--file", "--ignore-file", "-g", "--glob", "--iglob"]),
|
|
1664
|
+
sed: /* @__PURE__ */ new Set(["-f", "--file"]),
|
|
1665
|
+
awk: /* @__PURE__ */ new Set(["-f", "--file"]),
|
|
1666
|
+
gawk: /* @__PURE__ */ new Set(["-f", "--file"]),
|
|
1667
|
+
sort: /* @__PURE__ */ new Set(["--files0-from"])
|
|
1668
|
+
};
|
|
1669
|
+
var SCP_VALUE_FLAGS = ["i", "F", "o", "c", "S", "P", "J", "D", "W", "l"];
|
|
1670
|
+
var TAR_VALUE_LETTERS = ["b", "C", "f", "F", "g", "H", "I", "K", "L", "N", "T", "V", "X"];
|
|
1671
|
+
var ZIP_VALUE_LETTERS = ["b", "n", "P", "t", "s", "O", "x", "i"];
|
|
1672
|
+
var RSYNC_VALUE_LETTERS = ["e", "f", "T", "B", "M"];
|
|
1673
|
+
var RSYNC_SKIP = [
|
|
1674
|
+
"e",
|
|
1675
|
+
"--rsh",
|
|
1676
|
+
"--exclude",
|
|
1677
|
+
"--exclude-from",
|
|
1678
|
+
"--include",
|
|
1679
|
+
"--include-from",
|
|
1680
|
+
"--files-from",
|
|
1681
|
+
"f",
|
|
1682
|
+
"--filter"
|
|
1683
|
+
];
|
|
1684
|
+
var CP_VALUE_LETTERS = ["S", "t"];
|
|
1685
|
+
var INSTALL_VALUE_LETTERS = ["S", "t", "g", "m", "o"];
|
|
1686
|
+
var COPY_VERBS = {
|
|
1687
|
+
cp: { source: "allButLast", targetDirFlag: true, valueLetters: CP_VALUE_LETTERS },
|
|
1688
|
+
mv: { source: "allButLast", targetDirFlag: true, valueLetters: CP_VALUE_LETTERS },
|
|
1689
|
+
install: { source: "allButLast", targetDirFlag: true, valueLetters: INSTALL_VALUE_LETTERS },
|
|
1690
|
+
ln: { source: "first", targetDirFlag: true, valueLetters: CP_VALUE_LETTERS },
|
|
1691
|
+
scp: { source: "allButLast", skipFlags: SCP_VALUE_FLAGS, valueLetters: SCP_VALUE_FLAGS },
|
|
1692
|
+
rsync: { source: "allButLast", skipFlags: RSYNC_SKIP, valueLetters: RSYNC_VALUE_LETTERS },
|
|
1693
|
+
tar: {
|
|
1694
|
+
source: "archive",
|
|
1695
|
+
archive: "tar",
|
|
1696
|
+
skipFlags: ["f", "X", "T", "--file", "--exclude", "--exclude-from", "--files-from"],
|
|
1697
|
+
valueLetters: TAR_VALUE_LETTERS
|
|
1698
|
+
},
|
|
1699
|
+
zip: {
|
|
1700
|
+
source: "archive",
|
|
1701
|
+
archive: "zip",
|
|
1702
|
+
skipFlags: ["x", "i", "--exclude", "--include"],
|
|
1703
|
+
valueLetters: ZIP_VALUE_LETTERS
|
|
1704
|
+
},
|
|
1705
|
+
ar: { source: "archive", archive: "ar" },
|
|
1706
|
+
// 7z switches are INLINE only (`-mx9`, `-px`, `-x!pat`), so no following word is
|
|
1707
|
+
// ever a switch's operand -- which is why the short `x` is NOT a skipFlag here
|
|
1708
|
+
// and valueLetters is empty. Keeping the short `x` made `7z a out.7z -mx KEY`
|
|
1709
|
+
// drop the credential as an exclusion operand (/code-review round 8), and the
|
|
1710
|
+
// derived "every skipped letter is a value letter" row in jail-copy.spec.ts is
|
|
1711
|
+
// what keeps the two tables honest about it.
|
|
1712
|
+
"7z": { source: "archive", archive: "7z", skipFlags: ["--exclude"], valueLetters: [] },
|
|
1713
|
+
gzip: { source: "all" },
|
|
1714
|
+
bzip2: { source: "all" },
|
|
1715
|
+
xz: { source: "all" },
|
|
1716
|
+
"docker cp": { source: "allButLast" },
|
|
1717
|
+
"kubectl cp": { source: "allButLast" },
|
|
1718
|
+
"gsutil cp": { source: "allButLast" },
|
|
1719
|
+
"gsutil rsync": { source: "allButLast" },
|
|
1720
|
+
"rclone copy": { source: "allButLast" },
|
|
1721
|
+
"rclone sync": { source: "allButLast" },
|
|
1722
|
+
"aws s3 cp": { source: "allButLast" },
|
|
1723
|
+
"aws s3 mv": { source: "allButLast" },
|
|
1724
|
+
"aws s3 sync": { source: "allButLast" },
|
|
1725
|
+
"gcloud storage cp": { source: "allButLast" },
|
|
1726
|
+
"az storage blob upload": { source: "flagOperand", sourceFlags: ["f", "--file"] }
|
|
1727
|
+
};
|
|
1728
|
+
var TAR_MODE_WORD = /^[a-zA-Z]+$/;
|
|
1729
|
+
var COPY_VERB_HEADS = new Set(Object.keys(COPY_VERBS).map((k) => k.split(" ")[0]));
|
|
1315
1730
|
var FS_OP_PRESCREEN_RE = new RegExp(
|
|
1316
1731
|
// A quote is a separator too: `eval "cat X"` and `sh -c 'cat X'` put the
|
|
1317
1732
|
// reader right after `"` / `'`, and without these two characters the
|
|
1318
1733
|
// prescreen rejected every string-wrapped read before the parser ran.
|
|
1319
1734
|
// Found 2026-09-11 by instrumenting the walk -- no CallExpr was ever visited.
|
|
1320
|
-
`(?:^|[\\s|;&("'\`\\n])(?:rm|${[...FS_READ_TOOLS].map((t) => t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|")})\\b|(?<!<)<(?!<)`
|
|
1735
|
+
`(?:^|[\\s|;&("'\`\\n/])(?:rm|${[...FS_READ_TOOLS, ...COPY_VERB_HEADS].map((t) => t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|")})\\b|(?<!<)<(?!<)`
|
|
1321
1736
|
);
|
|
1322
1737
|
var HOME_CACHE_ALLOWLIST = [
|
|
1323
1738
|
".cache",
|
|
@@ -1752,20 +2167,32 @@ function isProtectedHomePath(rawPath) {
|
|
|
1752
2167
|
}
|
|
1753
2168
|
return true;
|
|
1754
2169
|
}
|
|
1755
|
-
function
|
|
1756
|
-
const
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
const name = (words[0] ?? "").toLowerCase();
|
|
1760
|
-
const flags = [];
|
|
1761
|
-
const paths = [];
|
|
1762
|
-
for (let i = 1; i < words.length; i++) {
|
|
2170
|
+
function positionedArgs(words, from = 1, to = words.length) {
|
|
2171
|
+
const out = [];
|
|
2172
|
+
let afterFlag = null;
|
|
2173
|
+
for (let i = from; i < to; i++) {
|
|
1763
2174
|
const v = words[i];
|
|
1764
|
-
if (v === null)
|
|
1765
|
-
|
|
1766
|
-
|
|
2175
|
+
if (v === null) {
|
|
2176
|
+
afterFlag = null;
|
|
2177
|
+
continue;
|
|
2178
|
+
}
|
|
2179
|
+
if (v.startsWith("-")) {
|
|
2180
|
+
afterFlag = v;
|
|
2181
|
+
continue;
|
|
2182
|
+
}
|
|
2183
|
+
out.push({ value: v, index: out.length, argv: i, afterFlag });
|
|
2184
|
+
afterFlag = null;
|
|
1767
2185
|
}
|
|
1768
|
-
return
|
|
2186
|
+
return out;
|
|
2187
|
+
}
|
|
2188
|
+
function extractLiteralArgs(callExpr) {
|
|
2189
|
+
const rawArgs = callExpr.Args || [];
|
|
2190
|
+
if (rawArgs.length === 0) return { name: "", flags: [], paths: [], words: [], args: [] };
|
|
2191
|
+
const words = rawArgs.map((a) => resolveWordLiteral(a));
|
|
2192
|
+
const name = baseWord(words[0]);
|
|
2193
|
+
const flags = words.slice(1).filter((w) => w !== null && w.startsWith("-"));
|
|
2194
|
+
const args = positionedArgs(words);
|
|
2195
|
+
return { name, flags, paths: args.map((a) => a.value), words, args };
|
|
1769
2196
|
}
|
|
1770
2197
|
var NET_BINARIES = /* @__PURE__ */ new Set([
|
|
1771
2198
|
"curl",
|
|
@@ -2142,7 +2569,7 @@ function analyzeFsOperationImpl(command, depth = 0) {
|
|
|
2142
2569
|
return result?.verdict !== "block";
|
|
2143
2570
|
}
|
|
2144
2571
|
if (nodeType !== "CallExpr") return true;
|
|
2145
|
-
const { name, flags, paths, words } = extractLiteralArgs(n);
|
|
2572
|
+
const { name, flags, paths, words, args } = extractLiteralArgs(n);
|
|
2146
2573
|
if (!name) return true;
|
|
2147
2574
|
if (name === "rm") {
|
|
2148
2575
|
const flagStr = flags.join("").toLowerCase();
|
|
@@ -2182,13 +2609,16 @@ function analyzeFsOperationImpl(command, depth = 0) {
|
|
|
2182
2609
|
return true;
|
|
2183
2610
|
}
|
|
2184
2611
|
}
|
|
2185
|
-
const readPaths = FS_READ_TOOLS.has(name) ?
|
|
2612
|
+
const readPaths = FS_READ_TOOLS.has(name) ? [...readTargets(name, args, flags, words, 1), ...flagOperandFiles(name, words, 1)] : wrappedReadPaths(words, name);
|
|
2186
2613
|
if (readPaths) {
|
|
2187
2614
|
for (const p of readPaths) {
|
|
2188
2615
|
result = stricter(result, matchSensitivePath2(p));
|
|
2189
2616
|
if (result?.verdict === "block") return false;
|
|
2190
2617
|
}
|
|
2191
2618
|
}
|
|
2619
|
+
for (const p of copySourcePaths(words)) {
|
|
2620
|
+
result = stricter(result, copyVerdictOf(matchSensitivePath2(p)));
|
|
2621
|
+
}
|
|
2192
2622
|
return true;
|
|
2193
2623
|
});
|
|
2194
2624
|
return result;
|
|
@@ -2201,6 +2631,208 @@ function stricter(a, b) {
|
|
|
2201
2631
|
if (!b) return a;
|
|
2202
2632
|
return b.verdict === "block" && a.verdict !== "block" ? b : a;
|
|
2203
2633
|
}
|
|
2634
|
+
function flagInfo(w) {
|
|
2635
|
+
if (w.startsWith("--")) {
|
|
2636
|
+
const eq = w.indexOf("=");
|
|
2637
|
+
return eq < 0 ? { letter: null, long: w, attached: null } : { letter: null, long: w.slice(0, eq), attached: w.slice(eq + 1) };
|
|
2638
|
+
}
|
|
2639
|
+
const m = /^-([a-zA-Z]+)(.*)$/.exec(w);
|
|
2640
|
+
if (!m) return { letter: null, long: null, attached: null };
|
|
2641
|
+
return { letter: m[1][m[1].length - 1], long: null, attached: m[2] === "" ? null : m[2] };
|
|
2642
|
+
}
|
|
2643
|
+
function flagIs(w, names) {
|
|
2644
|
+
if (w === null) return false;
|
|
2645
|
+
const f = flagInfo(w);
|
|
2646
|
+
return names.some((n) => n.startsWith("--") ? f.long === n : f.letter === n);
|
|
2647
|
+
}
|
|
2648
|
+
function operandOf(a, names, valueLetters) {
|
|
2649
|
+
if (!names || a.afterFlag === null) return false;
|
|
2650
|
+
const w = a.afterFlag;
|
|
2651
|
+
if (!w.startsWith("--") && valueLetters) {
|
|
2652
|
+
const letters = w.slice(1);
|
|
2653
|
+
for (let i = 0; i < letters.length; i++) {
|
|
2654
|
+
if (!valueLetters.includes(letters[i])) continue;
|
|
2655
|
+
return i === letters.length - 1 && names.includes(letters[i]);
|
|
2656
|
+
}
|
|
2657
|
+
return false;
|
|
2658
|
+
}
|
|
2659
|
+
if (w.startsWith("--") && !w.includes("=")) {
|
|
2660
|
+
const longs = names.filter((n) => n.startsWith("--"));
|
|
2661
|
+
if (longs.some((n) => n === w || w.length >= 3 && n.startsWith(w))) return true;
|
|
2662
|
+
}
|
|
2663
|
+
return flagIs(w, names) && flagInfo(w).attached === null;
|
|
2664
|
+
}
|
|
2665
|
+
function resolveCopyShape(words, h) {
|
|
2666
|
+
const verb = baseWord(words[h]);
|
|
2667
|
+
if (!verb) return null;
|
|
2668
|
+
const direct = COPY_VERBS[verb];
|
|
2669
|
+
if (direct) return { shape: direct, last: h };
|
|
2670
|
+
const slots = positionedArgs(words, h + 1);
|
|
2671
|
+
for (let i = 0; i < slots.length; i++) {
|
|
2672
|
+
for (let n = 3; n >= 1; n--) {
|
|
2673
|
+
const part = slots.slice(i, i + n);
|
|
2674
|
+
if (part.length < n) continue;
|
|
2675
|
+
const key = [verb, ...part.map((a) => a.value.toLowerCase())].join(" ");
|
|
2676
|
+
const shape = COPY_VERBS[key];
|
|
2677
|
+
if (shape) return { shape, last: part[n - 1].argv };
|
|
2678
|
+
}
|
|
2679
|
+
if (slots[i].afterFlag === null) return null;
|
|
2680
|
+
}
|
|
2681
|
+
return null;
|
|
2682
|
+
}
|
|
2683
|
+
var FIND_OPTIONS = /* @__PURE__ */ new Set(["-H", "-L", "-P"]);
|
|
2684
|
+
function findStartPoints(words, h) {
|
|
2685
|
+
const k = words.findIndex((w, i) => i > h && w !== null && FIND_EXEC_FLAGS.has(w));
|
|
2686
|
+
if (k < 0) return { k, starts: [] };
|
|
2687
|
+
const firstPredicate = words.findIndex(
|
|
2688
|
+
(w, i) => i > h && w !== null && w !== "--" && w.startsWith("-") && !FIND_OPTIONS.has(w)
|
|
2689
|
+
);
|
|
2690
|
+
const end = firstPredicate > h ? firstPredicate : k;
|
|
2691
|
+
return { k, starts: positionalAfter(words, h + 1, end) };
|
|
2692
|
+
}
|
|
2693
|
+
function copySourcePaths(words) {
|
|
2694
|
+
const h = unwrapCommandHead(words);
|
|
2695
|
+
const fi = words.findIndex((w, i) => i <= h && baseWord(w) === "find");
|
|
2696
|
+
if (fi >= 0) {
|
|
2697
|
+
const { k, starts } = findStartPoints(words, fi);
|
|
2698
|
+
if (k < 0) return [];
|
|
2699
|
+
const action = unwrapCommandHead(words.slice(k + 1));
|
|
2700
|
+
return resolveCopyShape(words.slice(k + 1), action) ? starts : [];
|
|
2701
|
+
}
|
|
2702
|
+
if (!COPY_VERB_HEADS.has(baseWord(words[h]))) return [];
|
|
2703
|
+
const r = resolveCopyShape(words, h);
|
|
2704
|
+
if (!r) return [];
|
|
2705
|
+
const { shape, last } = r;
|
|
2706
|
+
const args = positionedArgs(words, last + 1);
|
|
2707
|
+
const tail = words.slice(last + 1);
|
|
2708
|
+
const copyOptionsEnd = tail.findIndex((w) => w === "--");
|
|
2709
|
+
const copyPastOptions = (a) => copyOptionsEnd >= 0 && a.argv > last + 1 + copyOptionsEnd;
|
|
2710
|
+
const skipped = (a) => !copyPastOptions(a) && operandOf(a, shape.skipFlags, shape.valueLetters);
|
|
2711
|
+
const firstValueLetter = (w) => {
|
|
2712
|
+
const letters = w.slice(1);
|
|
2713
|
+
for (let i = 0; i < letters.length; i++) {
|
|
2714
|
+
if ((shape.valueLetters ?? []).includes(letters[i]))
|
|
2715
|
+
return { letter: letters[i], last: i === letters.length - 1 };
|
|
2716
|
+
}
|
|
2717
|
+
return null;
|
|
2718
|
+
};
|
|
2719
|
+
const isTargetDirFlag = (w) => {
|
|
2720
|
+
if (w.startsWith("--")) {
|
|
2721
|
+
const name = w.includes("=") ? w.slice(0, w.indexOf("=")) : w;
|
|
2722
|
+
return name.length >= 3 && "--target-directory".startsWith(name);
|
|
2723
|
+
}
|
|
2724
|
+
return firstValueLetter(w)?.letter === "t";
|
|
2725
|
+
};
|
|
2726
|
+
const targetDir = shape.targetDirFlag === true && tail.some((w) => w !== null && w.startsWith("-") && isTargetDirFlag(w));
|
|
2727
|
+
const targetTakesNextWord = (w) => {
|
|
2728
|
+
if (w.startsWith("--"))
|
|
2729
|
+
return !w.includes("=") && w.length >= 3 && "--target-directory".startsWith(w);
|
|
2730
|
+
const f = firstValueLetter(w);
|
|
2731
|
+
return f !== null && f.letter === "t" && f.last;
|
|
2732
|
+
};
|
|
2733
|
+
const targetOperand = (a) => targetDir && a.afterFlag !== null && !copyPastOptions(a) && targetTakesNextWord(a.afterFlag);
|
|
2734
|
+
const lastOperand = [...tail].reverse().find((w) => w === null || !w.startsWith("-"));
|
|
2735
|
+
const dynamicDest = lastOperand === null;
|
|
2736
|
+
const destIsLastOperand = (shape.source === "allButLast" || shape.source === "first") && !targetDir && !dynamicDest;
|
|
2737
|
+
let src;
|
|
2738
|
+
switch (shape.source) {
|
|
2739
|
+
case "all":
|
|
2740
|
+
src = args;
|
|
2741
|
+
break;
|
|
2742
|
+
case "first":
|
|
2743
|
+
src = targetDir ? args : args.slice(0, 1);
|
|
2744
|
+
break;
|
|
2745
|
+
case "flagOperand": {
|
|
2746
|
+
const namesSource = (f) => {
|
|
2747
|
+
const names = shape.sourceFlags ?? [];
|
|
2748
|
+
if (f.long !== null)
|
|
2749
|
+
return names.some(
|
|
2750
|
+
(n) => n.startsWith("--") && (n === f.long || f.long.length >= 3 && n.startsWith(f.long))
|
|
2751
|
+
);
|
|
2752
|
+
return f.letter !== null && names.includes(f.letter);
|
|
2753
|
+
};
|
|
2754
|
+
const inline = tail.filter((w) => w !== null && w.startsWith("-")).map((w) => flagInfo(w)).filter((f) => f.attached !== null && namesSource(f)).map((f) => f.attached);
|
|
2755
|
+
return [
|
|
2756
|
+
...args.filter((a) => !copyPastOptions(a) && operandOf(a, shape.sourceFlags, shape.valueLetters)).map((a) => a.value),
|
|
2757
|
+
...inline
|
|
2758
|
+
];
|
|
2759
|
+
}
|
|
2760
|
+
case "archive":
|
|
2761
|
+
src = archiveInputs(shape.archive, args, tail);
|
|
2762
|
+
break;
|
|
2763
|
+
case "allButLast":
|
|
2764
|
+
src = targetDir || dynamicDest ? args : args.slice(0, -1);
|
|
2765
|
+
break;
|
|
2766
|
+
}
|
|
2767
|
+
const sources = src.filter((a) => !skipped(a) && !targetOperand(a)).map((a) => a.value);
|
|
2768
|
+
if (destIsLastOperand && typeof lastOperand === "string" && matchSensitivePath2(lastOperand)) {
|
|
2769
|
+
const jailedSources = sources.filter((p) => matchSensitivePath2(p));
|
|
2770
|
+
const dirOf = (p) => /[\\/]/.test(p) ? p.replace(/[\\/][^\\/]*$/, "") : "";
|
|
2771
|
+
if (jailedSources.length === 0) return [];
|
|
2772
|
+
if (jailedSources.every((p) => dirOf(p) === dirOf(lastOperand))) return [];
|
|
2773
|
+
}
|
|
2774
|
+
return sources;
|
|
2775
|
+
}
|
|
2776
|
+
function archiveInputs(kind, args, tail) {
|
|
2777
|
+
const first = args[0];
|
|
2778
|
+
const bareKey = first && first.afterFlag === null && TAR_MODE_WORD.test(first.value);
|
|
2779
|
+
if (kind === "tar") {
|
|
2780
|
+
const flagsText = tail.filter((w) => w !== null && w.startsWith("-")).join(" ");
|
|
2781
|
+
const mode = (bareKey ? first.value : "") + flagsText;
|
|
2782
|
+
const extracting = /x|t/.test(bareKey ? first.value.replace(/f/g, "") : "") || /(^|\s)-[a-zA-Z]*[xt]|--extract|--list|--get/.test(flagsText);
|
|
2783
|
+
const writing = /[cruA]/.test(bareKey ? first.value : "") || /(^|\s)-[a-zA-Z]*[cruA]|--create|--append|--update|--concatenate/.test(flagsText);
|
|
2784
|
+
if (extracting && !writing) return [];
|
|
2785
|
+
void mode;
|
|
2786
|
+
if (!bareKey) return args;
|
|
2787
|
+
let i = 1;
|
|
2788
|
+
const fromDirs = [];
|
|
2789
|
+
for (const ch of first.value) {
|
|
2790
|
+
if (!TAR_VALUE_LETTERS.includes(ch)) continue;
|
|
2791
|
+
const operand = args[i];
|
|
2792
|
+
if (!operand || operand.afterFlag !== null) break;
|
|
2793
|
+
i += 1;
|
|
2794
|
+
if (ch === "C") fromDirs.push(operand);
|
|
2795
|
+
}
|
|
2796
|
+
return [...fromDirs, ...args.slice(i)];
|
|
2797
|
+
}
|
|
2798
|
+
if (kind === "zip") return first && first.afterFlag === "-" ? args : args.slice(1);
|
|
2799
|
+
if (kind === "ar") return bareKey ? args.slice(2) : args.slice(1);
|
|
2800
|
+
return args.slice(2);
|
|
2801
|
+
}
|
|
2802
|
+
function sampleCopyCommand(verb, src) {
|
|
2803
|
+
const shape = COPY_VERBS[verb];
|
|
2804
|
+
if (!shape) throw new Error(`not a copy verb: ${verb}`);
|
|
2805
|
+
const remote = /^(scp|rsync|aws |gsutil|gcloud|rclone|docker|kubectl)/.test(verb);
|
|
2806
|
+
switch (shape.source) {
|
|
2807
|
+
case "first":
|
|
2808
|
+
return `${verb} -s ${src} /tmp/n9-link`;
|
|
2809
|
+
case "all":
|
|
2810
|
+
return `${verb} -c ${src} > /tmp/n9-out`;
|
|
2811
|
+
case "flagOperand":
|
|
2812
|
+
return `${verb} -f ${src} -c n9`;
|
|
2813
|
+
case "archive":
|
|
2814
|
+
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}`;
|
|
2815
|
+
case "allButLast":
|
|
2816
|
+
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"}`;
|
|
2817
|
+
}
|
|
2818
|
+
}
|
|
2819
|
+
var COPY_RULE_OF = {
|
|
2820
|
+
"shield:project-jail:block-read-ssh": "shield:project-jail:review-copy-ssh",
|
|
2821
|
+
"shield:project-jail:block-read-aws": "shield:project-jail:review-copy-aws",
|
|
2822
|
+
"shield:project-jail:block-read-env": "shield:project-jail:review-copy-env",
|
|
2823
|
+
"shield:project-jail:review-read-credentials": "shield:project-jail:review-copy-credentials"
|
|
2824
|
+
};
|
|
2825
|
+
function copyVerdictOf(hit) {
|
|
2826
|
+
if (!hit) return null;
|
|
2827
|
+
const ruleName = COPY_RULE_OF[hit.ruleName];
|
|
2828
|
+
if (!ruleName) return null;
|
|
2829
|
+
return {
|
|
2830
|
+
ruleName,
|
|
2831
|
+
verdict: "review",
|
|
2832
|
+
reason: `Copying ${hit.path} moves a credential out of its jail (project-jail shield)`,
|
|
2833
|
+
path: hit.path
|
|
2834
|
+
};
|
|
2835
|
+
}
|
|
2204
2836
|
function matchSensitivePath2(p) {
|
|
2205
2837
|
for (const sp of SENSITIVE_PATH_RULES) {
|
|
2206
2838
|
if (sp.match(p))
|
|
@@ -2208,18 +2840,160 @@ function matchSensitivePath2(p) {
|
|
|
2208
2840
|
}
|
|
2209
2841
|
return null;
|
|
2210
2842
|
}
|
|
2211
|
-
|
|
2212
|
-
|
|
2843
|
+
function baseWord(w) {
|
|
2844
|
+
return (w ?? "").split("/").pop()?.toLowerCase() ?? "";
|
|
2845
|
+
}
|
|
2846
|
+
var isReaderWord = (w) => w !== null && FS_READ_TOOLS.has(baseWord(w));
|
|
2847
|
+
var positionalAfter = (words, from, to = words.length) => positionedArgs(words, from, to).map((a) => a.value);
|
|
2848
|
+
function namesFlag(name, candidates, known) {
|
|
2849
|
+
if (candidates.has(name)) return true;
|
|
2850
|
+
if (!name.startsWith("--") || name.length < 3) return false;
|
|
2851
|
+
if (known?.has(name)) return false;
|
|
2852
|
+
for (const c of candidates) if (c.startsWith(name)) return true;
|
|
2853
|
+
return false;
|
|
2854
|
+
}
|
|
2855
|
+
function knownLongFlags(verb) {
|
|
2856
|
+
const out = /* @__PURE__ */ new Set();
|
|
2857
|
+
const shape = PATTERN_VERBS[verb];
|
|
2858
|
+
if (shape) {
|
|
2859
|
+
for (const set of [shape.takesValue, shape.noValue, shape.patternFlags, shape.noPatternFlags])
|
|
2860
|
+
for (const f of set) if (f.startsWith("--")) out.add(f);
|
|
2861
|
+
}
|
|
2862
|
+
for (const f of FILE_OPERAND_FLAGS[verb] ?? []) if (f.startsWith("--")) out.add(f);
|
|
2863
|
+
return out;
|
|
2864
|
+
}
|
|
2865
|
+
function flagNamesOf(token, shape) {
|
|
2866
|
+
if (token.startsWith("--")) {
|
|
2867
|
+
const eq = token.indexOf("=");
|
|
2868
|
+
return [eq > 0 ? token.slice(0, eq) : token];
|
|
2869
|
+
}
|
|
2870
|
+
const out = [];
|
|
2871
|
+
for (const c of token.slice(1)) {
|
|
2872
|
+
const name = `-${c}`;
|
|
2873
|
+
out.push(name);
|
|
2874
|
+
if (shape?.takesValue.has(name)) break;
|
|
2875
|
+
}
|
|
2876
|
+
return out;
|
|
2877
|
+
}
|
|
2878
|
+
var NONE = { kind: "none" };
|
|
2879
|
+
var UNKNOWN = { kind: "unknown" };
|
|
2880
|
+
function flagEffect(token, shape, known) {
|
|
2881
|
+
if (token === "--") return NONE;
|
|
2882
|
+
if (/^-+$/.test(token)) return UNKNOWN;
|
|
2883
|
+
if (/^-\d+$/.test(token)) return NONE;
|
|
2884
|
+
if (token.startsWith("--")) {
|
|
2885
|
+
if (token.includes("=")) return NONE;
|
|
2886
|
+
const takes = namesFlag(token, shape.takesValue, known);
|
|
2887
|
+
const none = namesFlag(token, shape.noValue, known);
|
|
2888
|
+
if (takes && none) return UNKNOWN;
|
|
2889
|
+
if (takes) return { kind: "takes", flag: token };
|
|
2890
|
+
if (none) return NONE;
|
|
2891
|
+
return UNKNOWN;
|
|
2892
|
+
}
|
|
2893
|
+
const letters = token.slice(1);
|
|
2894
|
+
if (!letters) return NONE;
|
|
2895
|
+
for (let i = 0; i < letters.length; i++) {
|
|
2896
|
+
const name = `-${letters[i]}`;
|
|
2897
|
+
if (shape.takesValue.has(name)) {
|
|
2898
|
+
return i === letters.length - 1 ? { kind: "takes", flag: name } : NONE;
|
|
2899
|
+
}
|
|
2900
|
+
if (!shape.noValue.has(name)) return UNKNOWN;
|
|
2901
|
+
}
|
|
2902
|
+
return NONE;
|
|
2903
|
+
}
|
|
2904
|
+
function readTargets(verb, args, flags, words = [], from = 1) {
|
|
2905
|
+
const shape = PATTERN_VERBS[verb];
|
|
2906
|
+
if (!shape) return args.map((a) => a.value);
|
|
2907
|
+
const known = knownLongFlags(verb);
|
|
2908
|
+
const names = flags.flatMap((f) => flagNamesOf(f, shape));
|
|
2909
|
+
const patternElsewhere = names.some(
|
|
2910
|
+
(n) => namesFlag(n, shape.patternFlags, known) || namesFlag(n, shape.noPatternFlags, known)
|
|
2911
|
+
);
|
|
2912
|
+
const excused = /* @__PURE__ */ new Set();
|
|
2913
|
+
const fileFlags = FILE_OPERAND_FLAGS[verb];
|
|
2914
|
+
const optionsEnd = words.findIndex((w, i) => i >= from && w === "--");
|
|
2915
|
+
const pastOptions = (a) => optionsEnd >= 0 && a.argv > optionsEnd;
|
|
2916
|
+
for (const a of args) {
|
|
2917
|
+
if (a.afterFlag === null || pastOptions(a)) continue;
|
|
2918
|
+
const e = flagEffect(a.afterFlag, shape, known);
|
|
2919
|
+
if (e.kind !== "takes") continue;
|
|
2920
|
+
if (fileFlags && namesFlag(e.flag, fileFlags, known)) continue;
|
|
2921
|
+
excused.add(a);
|
|
2922
|
+
}
|
|
2923
|
+
const patternArgv = (() => {
|
|
2924
|
+
for (let i = from; i < words.length; i++) {
|
|
2925
|
+
const w = words[i];
|
|
2926
|
+
if (w === null) return -1;
|
|
2927
|
+
if (w === "--") return i + 1;
|
|
2928
|
+
if (w.startsWith("-")) {
|
|
2929
|
+
const e = flagEffect(w, shape, known);
|
|
2930
|
+
if (e.kind === "takes") i += 1;
|
|
2931
|
+
else if (e.kind === "unknown") return -1;
|
|
2932
|
+
continue;
|
|
2933
|
+
}
|
|
2934
|
+
return i;
|
|
2935
|
+
}
|
|
2936
|
+
return -1;
|
|
2937
|
+
})();
|
|
2938
|
+
if (!patternElsewhere && patternArgv >= 0) {
|
|
2939
|
+
const a = args.find((x) => x.argv === patternArgv);
|
|
2940
|
+
if (a) excused.add(a);
|
|
2941
|
+
}
|
|
2942
|
+
return args.filter((a) => !excused.has(a)).map((a) => a.value);
|
|
2943
|
+
}
|
|
2944
|
+
function flagOperandFiles(verb, words, from) {
|
|
2945
|
+
const flags = FILE_OPERAND_FLAGS[verb];
|
|
2946
|
+
if (!flags) return [];
|
|
2947
|
+
const known = knownLongFlags(verb);
|
|
2948
|
+
const out = [];
|
|
2949
|
+
for (let i = from; i < words.length; i++) {
|
|
2950
|
+
const w = words[i];
|
|
2951
|
+
if (w === null || !w.startsWith("-") || w === "--") continue;
|
|
2952
|
+
if (w.startsWith("--")) {
|
|
2953
|
+
const eq = w.indexOf("=");
|
|
2954
|
+
if (eq <= 0) continue;
|
|
2955
|
+
const name = w.slice(0, eq);
|
|
2956
|
+
const value = w.slice(eq + 1);
|
|
2957
|
+
if (!value) continue;
|
|
2958
|
+
if (namesFlag(name, flags, known)) {
|
|
2959
|
+
out.push(value);
|
|
2960
|
+
continue;
|
|
2961
|
+
}
|
|
2962
|
+
const shape2 = PATTERN_VERBS[verb];
|
|
2963
|
+
if (!shape2) continue;
|
|
2964
|
+
const recognised = namesFlag(name, shape2.takesValue, known) || namesFlag(name, shape2.noValue, known) || namesFlag(name, shape2.patternFlags, known) || namesFlag(name, shape2.noPatternFlags, known);
|
|
2965
|
+
if (!recognised) out.push(value);
|
|
2966
|
+
continue;
|
|
2967
|
+
}
|
|
2968
|
+
const shape = PATTERN_VERBS[verb];
|
|
2969
|
+
const letters = w.slice(1);
|
|
2970
|
+
for (let j = 0; j < letters.length; j++) {
|
|
2971
|
+
const name = `-${letters[j]}`;
|
|
2972
|
+
const isFileFlag = flags.has(name);
|
|
2973
|
+
const argTaking = isFileFlag || (shape?.takesValue.has(name) ?? false) || (READER_VALUE_LETTERS[verb] ?? []).includes(letters[j]);
|
|
2974
|
+
if (!argTaking) continue;
|
|
2975
|
+
const attached = letters.slice(j + 1);
|
|
2976
|
+
if (isFileFlag && attached) out.push(attached);
|
|
2977
|
+
break;
|
|
2978
|
+
}
|
|
2979
|
+
}
|
|
2980
|
+
return out;
|
|
2981
|
+
}
|
|
2213
2982
|
function wrappedReadPaths(words, name) {
|
|
2214
2983
|
if (name === "find") {
|
|
2215
|
-
const k = words
|
|
2216
|
-
|
|
2217
|
-
const firstPredicate = words.findIndex((w, i) => i > 0 && w !== null && w.startsWith("-"));
|
|
2218
|
-
return positionalAfter(words, 1, firstPredicate > 0 ? firstPredicate : k);
|
|
2984
|
+
const { k, starts } = findStartPoints(words, 0);
|
|
2985
|
+
return k > 0 && isReaderWord(words[k + 1] ?? null) ? starts : null;
|
|
2219
2986
|
}
|
|
2220
2987
|
if (!COMMAND_WRAPPERS.has(name) && !RUNNER_WRAPPERS.has(name)) return null;
|
|
2221
2988
|
const h = unwrapCommandHead(words);
|
|
2222
|
-
|
|
2989
|
+
if (h <= 0 || !isReaderWord(words[h] ?? null)) return null;
|
|
2990
|
+
const head = baseWord(words[h]);
|
|
2991
|
+
const rest = words.slice(h + 1);
|
|
2992
|
+
const restFlags = rest.filter((w) => w !== null && w.startsWith("-"));
|
|
2993
|
+
return [
|
|
2994
|
+
...readTargets(head, positionedArgs(words, h + 1), restFlags, words, h + 1),
|
|
2995
|
+
...flagOperandFiles(head, words, h + 1)
|
|
2996
|
+
];
|
|
2223
2997
|
}
|
|
2224
2998
|
function literalShellPayload(words, name) {
|
|
2225
2999
|
const h = COMMAND_WRAPPERS.has(name) || RUNNER_WRAPPERS.has(name) ? unwrapCommandHead(words) : 0;
|
|
@@ -3103,6 +3877,7 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
|
|
|
3103
3877
|
const shellShaped = isBashTool(toolName) || inspectsShellCommand(toolName, config.policy.toolInspection);
|
|
3104
3878
|
const shellShapedCommand = shellShaped ? isBashTool(toolName) && args && typeof args === "object" ? typeof args.command === "string" ? args.command : null : extractShellCommand(toolName, args, config.policy.toolInspection) : null;
|
|
3105
3879
|
const bashCommand = agent !== "Terminal" ? shellShapedCommand : null;
|
|
3880
|
+
let pendingAstReview;
|
|
3106
3881
|
if (bashCommand !== null) {
|
|
3107
3882
|
const pipeVerdict = pipeChainVerdict(
|
|
3108
3883
|
bashCommand,
|
|
@@ -3114,7 +3889,7 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
|
|
|
3114
3889
|
if (fsVerdict) {
|
|
3115
3890
|
const isShieldRule = fsVerdict.ruleName.startsWith("shield:");
|
|
3116
3891
|
const labelPrefix = isShieldRule ? "project-jail (AST)" : "Node9 (AST)";
|
|
3117
|
-
|
|
3892
|
+
const astVerdict = {
|
|
3118
3893
|
decision: fsVerdict.verdict,
|
|
3119
3894
|
blockedByLabel: `${labelPrefix}: ${fsVerdict.ruleName}`,
|
|
3120
3895
|
reason: fsVerdict.reason,
|
|
@@ -3122,6 +3897,8 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
|
|
|
3122
3897
|
ruleName: fsVerdict.ruleName,
|
|
3123
3898
|
ruleDescription: fsVerdict.reason
|
|
3124
3899
|
};
|
|
3900
|
+
if (fsVerdict.verdict === "block") return astVerdict;
|
|
3901
|
+
pendingAstReview = astVerdict;
|
|
3125
3902
|
}
|
|
3126
3903
|
const sqlAction = resolveCheck(config.policy.commandChecks?.sqlDdl);
|
|
3127
3904
|
const sqlVerdict = sqlAction === "off" ? null : analyzeSqlDestructive(bashCommand);
|
|
@@ -3164,7 +3941,7 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
|
|
|
3164
3941
|
const matchedRule = resolvePinned(matches);
|
|
3165
3942
|
if (matchedRule) {
|
|
3166
3943
|
if (matchedRule.verdict === "allow")
|
|
3167
|
-
return { decision: "allow", ruleName: matchedRule.name ?? matchedRule.tool };
|
|
3944
|
+
return pendingAstReview ?? { decision: "allow", ruleName: matchedRule.name ?? matchedRule.tool };
|
|
3168
3945
|
return {
|
|
3169
3946
|
decision: matchedRule.verdict,
|
|
3170
3947
|
blockedByLabel: `Smart Rule: ${matchedRule.name ?? matchedRule.tool}`,
|
|
@@ -3191,6 +3968,7 @@ async function evaluatePolicy(config, toolName, args, context = {}, hooks = {})
|
|
|
3191
3968
|
allTokens = analyzed.allTokens;
|
|
3192
3969
|
pathTokens = analyzed.paths;
|
|
3193
3970
|
const candidates = [];
|
|
3971
|
+
if (pendingAstReview) candidates.push(pendingAstReview);
|
|
3194
3972
|
const evalVerdict = detectDangerousShellExec(shellCommand);
|
|
3195
3973
|
if (evalVerdict === "block") {
|
|
3196
3974
|
return {
|
|
@@ -4297,6 +5075,12 @@ function classifyRuleSeverity(name, verdict) {
|
|
|
4297
5075
|
"read-ssh",
|
|
4298
5076
|
"read-gcp",
|
|
4299
5077
|
"read-cred",
|
|
5078
|
+
// Stage 4 (2026-09-11): a copy of a credential file scores like a READ of it --
|
|
5079
|
+
// read-ssh/aws/cred are critical, so copy-ssh/aws/cred are; read-env is high
|
|
5080
|
+
// (below), so copy-env joins the high list, not this one (/code-review).
|
|
5081
|
+
"copy-ssh",
|
|
5082
|
+
"copy-aws",
|
|
5083
|
+
"copy-cred",
|
|
4300
5084
|
"delete-repo",
|
|
4301
5085
|
"helm-uninstall",
|
|
4302
5086
|
"drop-table",
|
|
@@ -4309,6 +5093,7 @@ function classifyRuleSeverity(name, verdict) {
|
|
|
4309
5093
|
];
|
|
4310
5094
|
if (criticalPatterns.some((p) => n.includes(p))) return "critical";
|
|
4311
5095
|
const highPatterns = [
|
|
5096
|
+
"copy-env",
|
|
4312
5097
|
"force-push",
|
|
4313
5098
|
"force_push",
|
|
4314
5099
|
"git-destructive",
|
|
@@ -4326,6 +5111,11 @@ function narrativeRuleLabel(name) {
|
|
|
4326
5111
|
const map = {
|
|
4327
5112
|
"read-aws": "AWS credentials read",
|
|
4328
5113
|
"read-ssh": "SSH private key read",
|
|
5114
|
+
// Stage 4 copy twins, so `scan --narrative` prints a label, not a raw slug.
|
|
5115
|
+
"copy-ssh": "SSH private key copied out",
|
|
5116
|
+
"copy-aws": "AWS credentials copied out",
|
|
5117
|
+
"copy-env": ".env file copied out",
|
|
5118
|
+
"copy-cred": "credential file copied out",
|
|
4329
5119
|
"read-gcp": "GCP credentials read",
|
|
4330
5120
|
"read-cred": "credential file read",
|
|
4331
5121
|
"delete-repo": "GitHub repository deletion",
|
|
@@ -4723,8 +5513,8 @@ function matchCanaryArgs(args, values) {
|
|
|
4723
5513
|
|
|
4724
5514
|
// src/scan/canonical.ts
|
|
4725
5515
|
var LONG_OUTPUT_THRESHOLD_BYTES = 100 * 1024;
|
|
4726
|
-
var CANONICAL_EXTRACTOR_VERSION = "canonical-
|
|
4727
|
-
var CANONICAL_EXTRACTOR_HASH = "
|
|
5516
|
+
var CANONICAL_EXTRACTOR_VERSION = "canonical-v15";
|
|
5517
|
+
var CANONICAL_EXTRACTOR_HASH = "5dad4c8f07121f6c";
|
|
4728
5518
|
var DEDUPE_PREVIEW_LEN = 120;
|
|
4729
5519
|
function extractCanonicalFindings(call, ctx) {
|
|
4730
5520
|
const out = [];
|
|
@@ -5100,6 +5890,7 @@ export {
|
|
|
5100
5890
|
CANONICAL_EXTRACTOR_HASH,
|
|
5101
5891
|
CANONICAL_EXTRACTOR_VERSION,
|
|
5102
5892
|
COMMAND_WRAPPERS,
|
|
5893
|
+
COPY_VERBS,
|
|
5103
5894
|
COST_PER_LOOP_ITER_USD,
|
|
5104
5895
|
DEFAULT_EGRESS_ALLOWLIST,
|
|
5105
5896
|
DESTINATION_ARGS,
|
|
@@ -5114,6 +5905,7 @@ export {
|
|
|
5114
5905
|
LOOP_MAX_RECORDS,
|
|
5115
5906
|
LOOP_THRESHOLD_FOR_WASTE,
|
|
5116
5907
|
NET_BINARIES,
|
|
5908
|
+
PATTERN_VERB_NAMES,
|
|
5117
5909
|
PRIVILEGE_ESCALATION_RE,
|
|
5118
5910
|
REALTIME_PII_PATTERNS,
|
|
5119
5911
|
SCAN_SIGNAL_WEIGHTS,
|
|
@@ -5150,6 +5942,7 @@ export {
|
|
|
5150
5942
|
extractSessionLevelFindings,
|
|
5151
5943
|
extractShellDestTokens,
|
|
5152
5944
|
extractShellDestinations,
|
|
5945
|
+
fileOperandFlagsOf,
|
|
5153
5946
|
getCompiledRegex,
|
|
5154
5947
|
getNestedValue,
|
|
5155
5948
|
hostMatches,
|
|
@@ -5169,9 +5962,12 @@ export {
|
|
|
5169
5962
|
normalizeIpLiteral,
|
|
5170
5963
|
parseAllSshHostsFromCommand,
|
|
5171
5964
|
parseDestHost,
|
|
5965
|
+
patternShapeOf,
|
|
5966
|
+
positionedArgs,
|
|
5172
5967
|
previewArgs,
|
|
5173
5968
|
redactText,
|
|
5174
5969
|
resolvePinned,
|
|
5970
|
+
sampleCopyCommand,
|
|
5175
5971
|
scanArgs,
|
|
5176
5972
|
scanInjection,
|
|
5177
5973
|
scanText,
|