@lannguyensi/harness 0.53.0 → 0.55.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/CHANGELOG.md +70 -1
- package/dist/cli/delegate/index.d.ts +15 -2
- package/dist/cli/delegate/index.js +128 -4
- package/dist/cli/delegate/index.js.map +1 -1
- package/dist/cli/doctor/bypass-without-auto-approve.js +10 -4
- package/dist/cli/doctor/bypass-without-auto-approve.js.map +1 -1
- package/dist/cli/dry-run.js +32 -1
- package/dist/cli/dry-run.js.map +1 -1
- package/dist/cli/index.js +13 -5
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/init/profiles.js +89 -8
- package/dist/cli/init/profiles.js.map +1 -1
- package/dist/cli/init/templates.js +163 -3
- package/dist/cli/init/templates.js.map +1 -1
- package/dist/cli/pack/hook-pre-tool-use.js +217 -95
- package/dist/cli/pack/hook-pre-tool-use.js.map +1 -1
- package/dist/cli/record/index.d.ts +8 -0
- package/dist/cli/record/index.js +55 -7
- package/dist/cli/record/index.js.map +1 -1
- package/dist/cli/validate/checks.js +61 -9
- package/dist/cli/validate/checks.js.map +1 -1
- package/dist/io/extract.d.ts +29 -0
- package/dist/io/extract.js +36 -0
- package/dist/io/extract.js.map +1 -1
- package/dist/io/read-regular-file.d.ts +33 -2
- package/dist/io/read-regular-file.js +41 -8
- package/dist/io/read-regular-file.js.map +1 -1
- package/dist/policies/index.d.ts +1 -1
- package/dist/policies/index.js +1 -1
- package/dist/policies/index.js.map +1 -1
- package/dist/policy-packs/builtin/understanding-before-execution/delegation-markers.d.ts +71 -4
- package/dist/policy-packs/builtin/understanding-before-execution/delegation-markers.js +75 -4
- package/dist/policy-packs/builtin/understanding-before-execution/delegation-markers.js.map +1 -1
- package/dist/policy-packs/builtin/understanding-before-execution/index.d.ts +1 -1
- package/dist/policy-packs/builtin/understanding-before-execution/index.js +1 -1
- package/dist/policy-packs/builtin/understanding-before-execution/index.js.map +1 -1
- package/dist/policy-packs/builtin/understanding-before-execution/permission-mode-observations.js +9 -3
- package/dist/policy-packs/builtin/understanding-before-execution/permission-mode-observations.js.map +1 -1
- package/dist/runtime/destructive-shell-floor.d.ts +19 -0
- package/dist/runtime/destructive-shell-floor.js +597 -0
- package/dist/runtime/destructive-shell-floor.js.map +1 -0
- package/dist/runtime/intercept.d.ts +10 -7
- package/dist/runtime/intercept.js +108 -9
- package/dist/runtime/intercept.js.map +1 -1
- package/dist/runtime/read-only-bash.d.ts +54 -0
- package/dist/runtime/read-only-bash.js +775 -7
- package/dist/runtime/read-only-bash.js.map +1 -1
- package/dist/runtime/risk-classifier.js +72 -13
- package/dist/runtime/risk-classifier.js.map +1 -1
- package/dist/runtime/when-eval.d.ts +18 -5
- package/dist/runtime/when-eval.js +64 -11
- package/dist/runtime/when-eval.js.map +1 -1
- package/dist/runtime/workflow-policies.d.ts +43 -1
- package/dist/runtime/workflow-policies.js +158 -1
- package/dist/runtime/workflow-policies.js.map +1 -1
- package/dist/schema/extract.d.ts +4 -0
- package/dist/schema/extract.js +64 -1
- package/dist/schema/extract.js.map +1 -1
- package/dist/schema/index.d.ts +13 -0
- package/dist/schema/policies.d.ts +19 -0
- package/dist/schema/policies.js +17 -1
- package/dist/schema/policies.js.map +1 -1
- package/package.json +1 -1
|
@@ -617,17 +617,25 @@ const VERSION_OR_HELP_FLAGS = new Set([
|
|
|
617
617
|
]);
|
|
618
618
|
/**
|
|
619
619
|
* True when `trimmed` contains a shell chaining (`;`, `&`, `|`),
|
|
620
|
-
* redirection (`<`, `>`),
|
|
621
|
-
*
|
|
622
|
-
*
|
|
623
|
-
*
|
|
624
|
-
*
|
|
625
|
-
*
|
|
626
|
-
*
|
|
620
|
+
* redirection (`<`, `>`), command-substitution (backtick, `$(`), or a
|
|
621
|
+
* bare carriage return that could hide a write (or, for `curl`'s `-H`
|
|
622
|
+
* values, a header-injection newline a lenient parser folds on) behind
|
|
623
|
+
* an otherwise-provable read. Shared by `isReadOnlyBashCommand` and
|
|
624
|
+
* `isReadOnlyKubectlCommand` below (extracted, task da823721, to keep
|
|
625
|
+
* their identical preamble from being flagged as new copy-paste by the
|
|
626
|
+
* duplication gate), by `floorArgv` (`isReadOnlySedCommand`'s own
|
|
627
|
+
* preamble -- `floorArgv`'s only caller since D-013 removed the curl
|
|
628
|
+
* floor it used to also serve), and directly by `isReadOnlyCurlCommand`
|
|
629
|
+
* (task fdaad781, round 2:
|
|
630
|
+
* added `\r` after a measured bare-CR header-injection reaching curl's
|
|
631
|
+
* wire, see docs/risk-gate.md's curl section). NOT reused by
|
|
632
|
+
* `isReadOnlyBashPipeline`, which deliberately admits a bare `|` (see
|
|
633
|
+
* its own doc comment) and so needs a narrower reject set.
|
|
627
634
|
*/
|
|
628
635
|
function hasUnsafeShellMetachar(trimmed) {
|
|
629
636
|
return (/[;&|<>]/.test(trimmed) ||
|
|
630
637
|
trimmed.includes("\n") ||
|
|
638
|
+
trimmed.includes("\r") ||
|
|
631
639
|
trimmed.includes("`") ||
|
|
632
640
|
trimmed.includes("$("));
|
|
633
641
|
}
|
|
@@ -974,6 +982,766 @@ export function isReadOnlyKubectlCommand(command) {
|
|
|
974
982
|
}
|
|
975
983
|
return true;
|
|
976
984
|
}
|
|
985
|
+
// ---------------------------------------------------------------------------
|
|
986
|
+
// `sed` read-only floor, Risk-Classifier ONLY (task 2929c5b7).
|
|
987
|
+
//
|
|
988
|
+
// Placed here beside `isReadOnlyKubectlCommand` above, and wired ONLY at
|
|
989
|
+
// `risk-classifier.ts`'s built-in floor, for the same reason that floor is:
|
|
990
|
+
// `isReadOnlyBashCommand` is consumed DIRECTLY by two other gates, and both
|
|
991
|
+
// short-circuit on it before running their own checks. The
|
|
992
|
+
// solution-acceptance write-guard (`src/cli/pack/hook-solution-acceptance-
|
|
993
|
+
// writeguard.ts`) returns `blocked: false` for anything
|
|
994
|
+
// `isReadOnlyBashCommand` accepts BEFORE it ever looks at whether the
|
|
995
|
+
// command references the protected verdict directory. Teaching the shared
|
|
996
|
+
// predicate about `sed` therefore widens that guard: review round 2 of this
|
|
997
|
+
// task measured `sed 's/a/b/w <verdict-dir>/marker.json' f` and
|
|
998
|
+
// `sed -n 'w <verdict-dir>/marker.json' f` going BLOCKED -> ALLOWED. This
|
|
999
|
+
// predicate must therefore stay OUT of `classifyTokens`; the write-guard
|
|
1000
|
+
// pins in `tests/cli/pack-hook-solution-acceptance-writeguard.test.ts`
|
|
1001
|
+
// fail if that is ever undone.
|
|
1002
|
+
//
|
|
1003
|
+
// NO `curl` FLOOR EXISTS, deliberately (decision D-013, this run). Rounds
|
|
1004
|
+
// 2 and 3 both shipped one and both leaked: round 2's write-flag denylist
|
|
1005
|
+
// missed `-o`/`-O`/`-D`/`-c`/`-K`, and round 3's flag ALLOWLIST still
|
|
1006
|
+
// admitted `-w`/`--write-out`, whose `%output{FILE}` directive writes a
|
|
1007
|
+
// local file since curl 8.3.0 (measured on curl 8.7.1). The recurring
|
|
1008
|
+
// class is the premise, not the list: deciding a curl invocation is inert
|
|
1009
|
+
// requires knowing every curl flag's write capability across curl
|
|
1010
|
+
// versions, and `-H @file` shows the value side has the same problem.
|
|
1011
|
+
// `curl` therefore stays UNCLASSIFIED, like `ssh` and `node -e`, and rides
|
|
1012
|
+
// the `when:` evaluator's fallback: approval-gated in a production
|
|
1013
|
+
// context, never hard-blocked, never floored. Its write-capable spellings
|
|
1014
|
+
// are raised to `high` by `destructive-shell-floor.ts` instead, which only
|
|
1015
|
+
// has to recognise capability it CAN name. See docs/risk-gate.md's
|
|
1016
|
+
// "Unclassified actions and the fail-close rule".
|
|
1017
|
+
//
|
|
1018
|
+
// Design: ALLOWLIST, fail closed (decision D-012, this run). Every token
|
|
1019
|
+
// after the head must be recognised: a known read-only flag, a known
|
|
1020
|
+
// flag's value, or a plain operand. An unknown flag, an unknown short-flag
|
|
1021
|
+
// cluster character, a missing flag value, or anything this module cannot
|
|
1022
|
+
// name forfeits the floor. That is the opposite of the round-2 shape
|
|
1023
|
+
// (a denylist of write flags, where every flag nobody had enumerated
|
|
1024
|
+
// silently passed).
|
|
1025
|
+
//
|
|
1026
|
+
// Forfeiting is cheap: the command simply stays unclassified, which the
|
|
1027
|
+
// `when:` evaluator treats as risk-bearing at the "high" rung (approval-
|
|
1028
|
+
// gated, never hard-blocked at `severity_at_least: critical`). Under-
|
|
1029
|
+
// blocking is not cheap. Over-block a read, never under-block a write.
|
|
1030
|
+
//
|
|
1031
|
+
// `decodeShellWord` is used here on the PERMISSIVE side (to EXEMPT a
|
|
1032
|
+
// command), which `shell-word.ts`'s module header explicitly calls out as
|
|
1033
|
+
// outside its `raw || decoded` direction rule. That is safe here BY
|
|
1034
|
+
// CONSTRUCTION rather than by argument: (1) the decoder returns the RAW
|
|
1035
|
+
// token unchanged for anything it cannot resolve, and a raw token with
|
|
1036
|
+
// quoting still in it does not match any allowlist entry, so an incomplete
|
|
1037
|
+
// decode forfeits the floor; (2) a token whose RAW form is not a flag but
|
|
1038
|
+
// whose DECODED form is (`"-o"`), or vice versa, is rejected outright
|
|
1039
|
+
// rather than classified under either reading.
|
|
1040
|
+
/**
|
|
1041
|
+
* Characters whose UNQUOTED appearance in a token means the shell rewrites
|
|
1042
|
+
* that token into text this classifier never sees, and, critically, can
|
|
1043
|
+
* rewrite ONE token into SEVERAL argv words, which is how a write flag
|
|
1044
|
+
* gets past a token-shape allowlist: `sed $FLAGS f` with
|
|
1045
|
+
* `FLAGS='-i'`, `sed -n 1p {-i,x}` (brace expansion), or a glob whose
|
|
1046
|
+
* match starts with `-`. `$` and a backtick also cover command
|
|
1047
|
+
* substitution that the `hasUnsafeShellMetachar` preamble does not already
|
|
1048
|
+
* refuse (`$(`, a backtick and every chaining metacharacter are refused
|
|
1049
|
+
* there first).
|
|
1050
|
+
*
|
|
1051
|
+
* Quoting is honoured: inside `'single quotes'` every one of these is
|
|
1052
|
+
* literal, and inside `"double quotes"` the glob/brace characters are
|
|
1053
|
+
* literal while `$` and a backtick stay live. That is what keeps
|
|
1054
|
+
* `sed -n '$p' f` on the floor while `sed -n "/$X/p" f` forfeits it.
|
|
1055
|
+
* Quote your script to keep the floor.
|
|
1056
|
+
*/
|
|
1057
|
+
const LIVE_EXPANSION_CHARS = new Set([
|
|
1058
|
+
"$", "`", "{", "}", "*", "?", "[", "]",
|
|
1059
|
+
]);
|
|
1060
|
+
/** Glob/brace characters, literal inside double quotes; `$`/backtick are not. */
|
|
1061
|
+
const DOUBLE_QUOTE_INERT_EXPANSION_CHARS = new Set([
|
|
1062
|
+
"{", "}", "*", "?", "[", "]",
|
|
1063
|
+
]);
|
|
1064
|
+
/**
|
|
1065
|
+
* True when `token` contains a shell expansion (see `LIVE_EXPANSION_CHARS`)
|
|
1066
|
+
* that is NOT quoted away. An unterminated quote run (the `'A:` / `b'` pair
|
|
1067
|
+
* a whitespace tokenizer produces for `-H 'A: b'`) is treated as quoted to
|
|
1068
|
+
* its end, which is what bash does across the whitespace this tokenizer
|
|
1069
|
+
* split on.
|
|
1070
|
+
*/
|
|
1071
|
+
function hasLiveShellExpansion(token) {
|
|
1072
|
+
let quote = null;
|
|
1073
|
+
for (let i = 0; i < token.length; i += 1) {
|
|
1074
|
+
const ch = token[i];
|
|
1075
|
+
if (quote !== "'" && ch === "\\") {
|
|
1076
|
+
i += 1; // backslash escapes the next character outside single quotes
|
|
1077
|
+
continue;
|
|
1078
|
+
}
|
|
1079
|
+
if (quote === null && (ch === "'" || ch === '"')) {
|
|
1080
|
+
quote = ch;
|
|
1081
|
+
continue;
|
|
1082
|
+
}
|
|
1083
|
+
if (quote !== null && ch === quote) {
|
|
1084
|
+
quote = null;
|
|
1085
|
+
continue;
|
|
1086
|
+
}
|
|
1087
|
+
if (quote === "'")
|
|
1088
|
+
continue;
|
|
1089
|
+
if (quote === '"' && DOUBLE_QUOTE_INERT_EXPANSION_CHARS.has(ch))
|
|
1090
|
+
continue;
|
|
1091
|
+
if (LIVE_EXPANSION_CHARS.has(ch))
|
|
1092
|
+
return true;
|
|
1093
|
+
}
|
|
1094
|
+
return false;
|
|
1095
|
+
}
|
|
1096
|
+
/**
|
|
1097
|
+
* Splits a metachar-cleared command into `[head, ...rest]` and applies the
|
|
1098
|
+
* two preconditions the floor below needs: the head must be the exact
|
|
1099
|
+
* binary name (no path prefix, the same tightness as
|
|
1100
|
+
* `isReadOnlyKubectlCommand`'s `tokens[0] !== "kubectl"`), and no token may
|
|
1101
|
+
* carry a live shell expansion. Returns `null` when the command is not a
|
|
1102
|
+
* floorable invocation of `bin`.
|
|
1103
|
+
*
|
|
1104
|
+
* Parameterised by `bin` rather than hard-coded to `sed`: it was shared
|
|
1105
|
+
* with the `curl` floor D-013 removed, and the parameter keeps the head
|
|
1106
|
+
* check honest (an exact match, not a prefix) for whichever binary a
|
|
1107
|
+
* future floor names.
|
|
1108
|
+
*/
|
|
1109
|
+
function floorArgv(command, bin) {
|
|
1110
|
+
const trimmed = command.trim();
|
|
1111
|
+
if (trimmed === "")
|
|
1112
|
+
return null;
|
|
1113
|
+
if (hasUnsafeShellMetachar(trimmed))
|
|
1114
|
+
return null;
|
|
1115
|
+
const tokens = trimmed.split(/\s+/);
|
|
1116
|
+
if (tokens[0] !== bin)
|
|
1117
|
+
return null;
|
|
1118
|
+
const rest = tokens.slice(1);
|
|
1119
|
+
if (rest.length === 0)
|
|
1120
|
+
return null;
|
|
1121
|
+
if (rest.some(hasLiveShellExpansion))
|
|
1122
|
+
return null;
|
|
1123
|
+
return rest;
|
|
1124
|
+
}
|
|
1125
|
+
const CLUSTER_FORFEIT = { kind: "forfeit" };
|
|
1126
|
+
/**
|
|
1127
|
+
* Walk a single-dash short-flag cluster CHARACTER BY CHARACTER, which is
|
|
1128
|
+
* the whole point of the floor's short-flag handling: `-ni f` must forfeit
|
|
1129
|
+
* on the `i` even though it starts with an allowlisted `n`.
|
|
1130
|
+
*
|
|
1131
|
+
* Every character must be in `noValueChars` until the first character
|
|
1132
|
+
* `isValueChar` claims. That character consumes the REST of the cluster as
|
|
1133
|
+
* its glued value, or `nextToken` when nothing is glued, and `onValue`
|
|
1134
|
+
* decides whether the value keeps the floor. `onValue` returning `false`,
|
|
1135
|
+
* an empty cluster, or an unrecognised character all forfeit.
|
|
1136
|
+
*
|
|
1137
|
+
* `isReadOnlySedCommand` is its only caller since D-013 removed the `curl`
|
|
1138
|
+
* floor. Kept as its own function rather than inlined: it is the
|
|
1139
|
+
* security-relevant half of the sed floor and is exercised directly by the
|
|
1140
|
+
* cluster cases in tests/runtime/read-only-floors.test.ts.
|
|
1141
|
+
*/
|
|
1142
|
+
function walkShortFlagCluster(chars, noValueChars, isValueChar, onValue, nextToken) {
|
|
1143
|
+
if (chars.length === 0)
|
|
1144
|
+
return CLUSTER_FORFEIT;
|
|
1145
|
+
for (let j = 0; j < chars.length; j += 1) {
|
|
1146
|
+
const c = chars[j];
|
|
1147
|
+
if (isValueChar(c)) {
|
|
1148
|
+
const glued = chars.slice(j + 1);
|
|
1149
|
+
if (!onValue(c, glued, nextToken))
|
|
1150
|
+
return CLUSTER_FORFEIT;
|
|
1151
|
+
return { kind: "ok", consumedNext: glued.length === 0 };
|
|
1152
|
+
}
|
|
1153
|
+
if (!noValueChars.has(c))
|
|
1154
|
+
return CLUSTER_FORFEIT;
|
|
1155
|
+
}
|
|
1156
|
+
return { kind: "ok", consumedNext: false };
|
|
1157
|
+
}
|
|
1158
|
+
// --- sed -------------------------------------------------------------------
|
|
1159
|
+
/** `sed` flags that take no value and cannot write. */
|
|
1160
|
+
const SED_READ_ONLY_LONG_FLAGS = new Set([
|
|
1161
|
+
"--quiet", "--silent", "--regexp-extended", "--separate",
|
|
1162
|
+
"--null-data", "--unbuffered", "--posix",
|
|
1163
|
+
]);
|
|
1164
|
+
/** `sed` flags whose value is a SCRIPT (scanned by `sedScriptIsReadOnly`). */
|
|
1165
|
+
const SED_SCRIPT_LONG_FLAGS = new Set(["--expression"]);
|
|
1166
|
+
/** Short-flag cluster characters that take no value and cannot write. */
|
|
1167
|
+
const SED_READ_ONLY_SHORT_CHARS = new Set([
|
|
1168
|
+
"n", "E", "r", "s", "z", "u",
|
|
1169
|
+
]);
|
|
1170
|
+
/** Short-flag cluster characters whose value is a SCRIPT. */
|
|
1171
|
+
const SED_SCRIPT_SHORT_CHARS = new Set(["e"]);
|
|
1172
|
+
/**
|
|
1173
|
+
* `sed` read-only floor for the Risk Classifier ONLY.
|
|
1174
|
+
*
|
|
1175
|
+
* CORRECTS A FALSE PREMISE this task shipped in round 1 ("without `-i`,
|
|
1176
|
+
* sed only writes to stdout"). It does not: sed's `w FILE` command and the
|
|
1177
|
+
* `s///w FILE` substitution flag write an operator-named file with no
|
|
1178
|
+
* `-i`, no redirection and no shell metacharacter, and `-f SCRIPTFILE`
|
|
1179
|
+
* executes a script this classifier never reads. GNU sed's `e` command and
|
|
1180
|
+
* `s///e` flag additionally execute a shell command. All four are refused
|
|
1181
|
+
* here.
|
|
1182
|
+
*
|
|
1183
|
+
* `true` requires ALL of: the head is exactly `sed`; no token carries a
|
|
1184
|
+
* live shell expansion; every flag is drawn from the read-only allowlist
|
|
1185
|
+
* above (so `-i` in every spelling: `-i`, `-i.bak`, `-ni`,
|
|
1186
|
+
* `--in-place`, `--in-place=SUFFIX`, and `-f`/`--file` forfeit by never
|
|
1187
|
+
* being ON it, not by being enumerated as write flags); and every script
|
|
1188
|
+
* (each `-e`/`--expression` value, or the first positional operand when no
|
|
1189
|
+
* `-e` was given) parses as a read-only sed program under
|
|
1190
|
+
* `sedScriptIsReadOnly`.
|
|
1191
|
+
*/
|
|
1192
|
+
export function isReadOnlySedCommand(command) {
|
|
1193
|
+
const rest = floorArgv(command, "sed");
|
|
1194
|
+
if (rest === null)
|
|
1195
|
+
return false;
|
|
1196
|
+
const scripts = [];
|
|
1197
|
+
const positionals = [];
|
|
1198
|
+
let optionsEnded = false;
|
|
1199
|
+
for (let i = 0; i < rest.length; i += 1) {
|
|
1200
|
+
const raw = rest[i];
|
|
1201
|
+
const decoded = decodeShellWord(raw);
|
|
1202
|
+
if (optionsEnded) {
|
|
1203
|
+
positionals.push(decoded);
|
|
1204
|
+
continue;
|
|
1205
|
+
}
|
|
1206
|
+
const rawIsFlag = raw.startsWith("-") && raw !== "-";
|
|
1207
|
+
const decodedIsFlag = decoded.startsWith("-") && decoded !== "-";
|
|
1208
|
+
// A token that is a flag under exactly one of the two readings is
|
|
1209
|
+
// unclassifiable: `"-i"` reaches sed as `-i` while looking like an
|
|
1210
|
+
// operand. Fail closed rather than pick a reading.
|
|
1211
|
+
if (rawIsFlag !== decodedIsFlag)
|
|
1212
|
+
return false;
|
|
1213
|
+
if (!rawIsFlag) {
|
|
1214
|
+
positionals.push(decoded);
|
|
1215
|
+
continue;
|
|
1216
|
+
}
|
|
1217
|
+
if (decoded === "--") {
|
|
1218
|
+
optionsEnded = true;
|
|
1219
|
+
continue;
|
|
1220
|
+
}
|
|
1221
|
+
if (decoded.startsWith("--")) {
|
|
1222
|
+
const eq = decoded.indexOf("=");
|
|
1223
|
+
const name = eq === -1 ? decoded : decoded.slice(0, eq);
|
|
1224
|
+
if (SED_SCRIPT_LONG_FLAGS.has(name)) {
|
|
1225
|
+
if (eq !== -1) {
|
|
1226
|
+
scripts.push(decoded.slice(eq + 1));
|
|
1227
|
+
continue;
|
|
1228
|
+
}
|
|
1229
|
+
i += 1;
|
|
1230
|
+
if (i >= rest.length)
|
|
1231
|
+
return false; // missing value: fail closed
|
|
1232
|
+
scripts.push(decodeShellWord(rest[i]));
|
|
1233
|
+
continue;
|
|
1234
|
+
}
|
|
1235
|
+
if (eq === -1 && SED_READ_ONLY_LONG_FLAGS.has(name))
|
|
1236
|
+
continue;
|
|
1237
|
+
return false;
|
|
1238
|
+
}
|
|
1239
|
+
// Short-flag cluster, decomposed CHARACTER BY CHARACTER: every
|
|
1240
|
+
// character must be allowlisted, not just the first.
|
|
1241
|
+
const walk = walkShortFlagCluster(decoded.slice(1), SED_READ_ONLY_SHORT_CHARS, (c) => SED_SCRIPT_SHORT_CHARS.has(c), (_c, glued, nextToken) => {
|
|
1242
|
+
if (glued.length > 0) {
|
|
1243
|
+
scripts.push(glued);
|
|
1244
|
+
return true;
|
|
1245
|
+
}
|
|
1246
|
+
if (nextToken === undefined)
|
|
1247
|
+
return false; // missing value: fail closed
|
|
1248
|
+
scripts.push(decodeShellWord(nextToken));
|
|
1249
|
+
return true;
|
|
1250
|
+
}, rest[i + 1]);
|
|
1251
|
+
if (walk.kind === "forfeit")
|
|
1252
|
+
return false;
|
|
1253
|
+
if (walk.consumedNext)
|
|
1254
|
+
i += 1;
|
|
1255
|
+
}
|
|
1256
|
+
// With no `-e`, sed's FIRST positional operand is the script; the rest
|
|
1257
|
+
// are input files (read-only by construction: an operand that looked
|
|
1258
|
+
// like a flag under either reading was already refused above).
|
|
1259
|
+
if (scripts.length === 0) {
|
|
1260
|
+
const first = positionals[0];
|
|
1261
|
+
if (first === undefined)
|
|
1262
|
+
return false; // bare `sed`: nothing to prove
|
|
1263
|
+
scripts.push(first);
|
|
1264
|
+
}
|
|
1265
|
+
return scripts.every(sedScriptIsReadOnly);
|
|
1266
|
+
}
|
|
1267
|
+
/**
|
|
1268
|
+
* sed script commands that consume no argument and cannot write: print,
|
|
1269
|
+
* delete, hold/pattern-space moves, line number, quit, zap, filename.
|
|
1270
|
+
* `l`, `q` and `Q` accept an optional numeric argument, handled below.
|
|
1271
|
+
*
|
|
1272
|
+
* Everything NOT in this set (plus `s`, `y`, and the branch/label
|
|
1273
|
+
* commands handled explicitly) forfeits the floor: `w` and `W` (write a
|
|
1274
|
+
* file), `e` (execute a shell command), `r`/`R` (read a file), `a`/`i`/`c`
|
|
1275
|
+
* (text whose syntax swallows the rest of the script), `#` (comment), and
|
|
1276
|
+
* any command a future sed adds.
|
|
1277
|
+
*/
|
|
1278
|
+
const SED_SAFE_NO_ARG_COMMANDS = new Set([
|
|
1279
|
+
"p", "P", "d", "D", "n", "N", "h", "H", "g", "G", "x", "l", "=", "q", "Q", "z", "F",
|
|
1280
|
+
]);
|
|
1281
|
+
/** Branch/label commands: control flow only, no I/O. */
|
|
1282
|
+
const SED_LABEL_COMMANDS = new Set(["b", "t", "T", ":"]);
|
|
1283
|
+
/** `s///` flags that cannot write or execute. `w` and `e` are NOT here. */
|
|
1284
|
+
const SED_SAFE_SUBSTITUTION_FLAGS = new Set([
|
|
1285
|
+
"g", "p", "i", "I", "m", "M",
|
|
1286
|
+
]);
|
|
1287
|
+
/**
|
|
1288
|
+
* `true` when `script` parses, end to end, as a sed program built only
|
|
1289
|
+
* from the safe command set above. A deliberately small recursive-descent
|
|
1290
|
+
* scan rather than a substring search for `w`: a "forfeit on any letter
|
|
1291
|
+
* `w`" rule would also forfeit `/warning/p`, and a substring search cannot
|
|
1292
|
+
* see the `e` command at all. Anything the scan does not fully understand
|
|
1293
|
+
* (an unterminated regex, a custom `\cREGEXc` address delimiter, a
|
|
1294
|
+
* command not in the sets above, a trailing argument where a separator
|
|
1295
|
+
* must be) returns `false`, so the failure mode is a forfeited floor,
|
|
1296
|
+
* never an accepted write.
|
|
1297
|
+
*/
|
|
1298
|
+
function sedScriptIsReadOnly(script) {
|
|
1299
|
+
const n = script.length;
|
|
1300
|
+
let i = 0;
|
|
1301
|
+
let statements = 0;
|
|
1302
|
+
while (i < n) {
|
|
1303
|
+
const lead = script[i];
|
|
1304
|
+
if (lead === ";" || lead === " " || lead === "\t" || lead === "\n" || lead === "}") {
|
|
1305
|
+
i += 1;
|
|
1306
|
+
continue;
|
|
1307
|
+
}
|
|
1308
|
+
const afterFirst = readSedAddress(script, i);
|
|
1309
|
+
if (afterFirst === -1)
|
|
1310
|
+
return false;
|
|
1311
|
+
i = afterFirst;
|
|
1312
|
+
if (script[i] === ",") {
|
|
1313
|
+
i += 1;
|
|
1314
|
+
const afterSecond = readSedAddress(script, i);
|
|
1315
|
+
if (afterSecond === -1 || afterSecond === i)
|
|
1316
|
+
return false; // second address required
|
|
1317
|
+
i = afterSecond;
|
|
1318
|
+
}
|
|
1319
|
+
while (script[i] === "!")
|
|
1320
|
+
i += 1;
|
|
1321
|
+
while (script[i] === " " || script[i] === "\t")
|
|
1322
|
+
i += 1;
|
|
1323
|
+
const cmd = script[i];
|
|
1324
|
+
if (cmd === undefined)
|
|
1325
|
+
return false; // address with no command
|
|
1326
|
+
i += 1;
|
|
1327
|
+
if (cmd === "{")
|
|
1328
|
+
continue; // block open; the block's statements follow
|
|
1329
|
+
if (SED_SAFE_NO_ARG_COMMANDS.has(cmd)) {
|
|
1330
|
+
while (i < n && script[i] === " ")
|
|
1331
|
+
i += 1;
|
|
1332
|
+
while (i < n && script[i] >= "0" && script[i] <= "9")
|
|
1333
|
+
i += 1; // `l N`, `q N`
|
|
1334
|
+
}
|
|
1335
|
+
else if (cmd === "s" || cmd === "y") {
|
|
1336
|
+
const after = readSedTwoPartCommand(script, i, cmd === "s");
|
|
1337
|
+
if (after === -1)
|
|
1338
|
+
return false;
|
|
1339
|
+
i = after;
|
|
1340
|
+
}
|
|
1341
|
+
else if (SED_LABEL_COMMANDS.has(cmd)) {
|
|
1342
|
+
while (i < n && script[i] === " ")
|
|
1343
|
+
i += 1;
|
|
1344
|
+
while (i < n && /[A-Za-z0-9_]/.test(script[i]))
|
|
1345
|
+
i += 1;
|
|
1346
|
+
}
|
|
1347
|
+
else {
|
|
1348
|
+
return false; // w, W, e, r, R, a, i, c, #, v, and anything unknown
|
|
1349
|
+
}
|
|
1350
|
+
statements += 1;
|
|
1351
|
+
while (i < n && (script[i] === " " || script[i] === "\t"))
|
|
1352
|
+
i += 1;
|
|
1353
|
+
if (i < n) {
|
|
1354
|
+
const sep = script[i];
|
|
1355
|
+
// A command must be followed by a separator. `s/a/b/w file` lands
|
|
1356
|
+
// HERE, on the `w`, because `w` is not an accepted `s///` flag.
|
|
1357
|
+
if (sep !== ";" && sep !== "}" && sep !== "{" && sep !== "\n")
|
|
1358
|
+
return false;
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
return statements > 0;
|
|
1362
|
+
}
|
|
1363
|
+
/**
|
|
1364
|
+
* Reads one sed ADDRESS starting at `start`. Returns the index after it,
|
|
1365
|
+
* `start` itself when no address is present (an address is optional), or
|
|
1366
|
+
* `-1` when the address is malformed or uses a form this scan refuses
|
|
1367
|
+
* (`\cREGEXc`, an unterminated `/regex/`).
|
|
1368
|
+
*/
|
|
1369
|
+
function readSedAddress(s, start) {
|
|
1370
|
+
let i = start;
|
|
1371
|
+
const c = s[i];
|
|
1372
|
+
if (c === undefined)
|
|
1373
|
+
return i;
|
|
1374
|
+
if (c === "$")
|
|
1375
|
+
return i + 1;
|
|
1376
|
+
if (c === "\\")
|
|
1377
|
+
return -1; // custom address delimiter: fail closed
|
|
1378
|
+
if (c === "+" || c === "~") {
|
|
1379
|
+
i += 1;
|
|
1380
|
+
const digitsFrom = i;
|
|
1381
|
+
while (i < s.length && s[i] >= "0" && s[i] <= "9")
|
|
1382
|
+
i += 1;
|
|
1383
|
+
return i > digitsFrom ? i : -1;
|
|
1384
|
+
}
|
|
1385
|
+
if (c >= "0" && c <= "9") {
|
|
1386
|
+
while (i < s.length && s[i] >= "0" && s[i] <= "9")
|
|
1387
|
+
i += 1;
|
|
1388
|
+
if (s[i] === "~") {
|
|
1389
|
+
i += 1;
|
|
1390
|
+
const digitsFrom = i;
|
|
1391
|
+
while (i < s.length && s[i] >= "0" && s[i] <= "9")
|
|
1392
|
+
i += 1;
|
|
1393
|
+
if (i === digitsFrom)
|
|
1394
|
+
return -1;
|
|
1395
|
+
}
|
|
1396
|
+
return i;
|
|
1397
|
+
}
|
|
1398
|
+
if (c === "/") {
|
|
1399
|
+
i += 1;
|
|
1400
|
+
let closed = false;
|
|
1401
|
+
while (i < s.length) {
|
|
1402
|
+
const ch = s[i];
|
|
1403
|
+
if (ch === "\\") {
|
|
1404
|
+
i += 2;
|
|
1405
|
+
continue;
|
|
1406
|
+
}
|
|
1407
|
+
if (ch === "/") {
|
|
1408
|
+
i += 1;
|
|
1409
|
+
closed = true;
|
|
1410
|
+
break;
|
|
1411
|
+
}
|
|
1412
|
+
i += 1;
|
|
1413
|
+
}
|
|
1414
|
+
if (!closed)
|
|
1415
|
+
return -1;
|
|
1416
|
+
while (s[i] === "I" || s[i] === "M")
|
|
1417
|
+
i += 1;
|
|
1418
|
+
return i;
|
|
1419
|
+
}
|
|
1420
|
+
return i; // no address
|
|
1421
|
+
}
|
|
1422
|
+
/**
|
|
1423
|
+
* Reads an `s<D>regex<D>replacement<D>[flags]` or `y<D>a<D>b<D>` body
|
|
1424
|
+
* starting at the delimiter. Returns the index after it, or `-1` when the
|
|
1425
|
+
* body is malformed or (for `s`) carries a flag outside
|
|
1426
|
+
* `SED_SAFE_SUBSTITUTION_FLAGS`, which is exactly how `s/a/b/w FILE` and
|
|
1427
|
+
* `s/a/b/e` forfeit the floor.
|
|
1428
|
+
*/
|
|
1429
|
+
function readSedTwoPartCommand(s, start, isSubstitution) {
|
|
1430
|
+
let i = start;
|
|
1431
|
+
const delim = s[i];
|
|
1432
|
+
if (delim === undefined || delim === "\\" || delim === "\n" || delim === " ")
|
|
1433
|
+
return -1;
|
|
1434
|
+
i += 1;
|
|
1435
|
+
for (let part = 0; part < 2; part += 1) {
|
|
1436
|
+
let closed = false;
|
|
1437
|
+
while (i < s.length) {
|
|
1438
|
+
const ch = s[i];
|
|
1439
|
+
if (ch === "\\") {
|
|
1440
|
+
i += 2;
|
|
1441
|
+
continue;
|
|
1442
|
+
}
|
|
1443
|
+
if (ch === delim) {
|
|
1444
|
+
i += 1;
|
|
1445
|
+
closed = true;
|
|
1446
|
+
break;
|
|
1447
|
+
}
|
|
1448
|
+
i += 1;
|
|
1449
|
+
}
|
|
1450
|
+
if (!closed)
|
|
1451
|
+
return -1;
|
|
1452
|
+
}
|
|
1453
|
+
if (!isSubstitution)
|
|
1454
|
+
return i;
|
|
1455
|
+
while (i < s.length) {
|
|
1456
|
+
const ch = s[i];
|
|
1457
|
+
if (ch >= "0" && ch <= "9") {
|
|
1458
|
+
i += 1;
|
|
1459
|
+
continue;
|
|
1460
|
+
}
|
|
1461
|
+
if (SED_SAFE_SUBSTITUTION_FLAGS.has(ch)) {
|
|
1462
|
+
i += 1;
|
|
1463
|
+
continue;
|
|
1464
|
+
}
|
|
1465
|
+
break;
|
|
1466
|
+
}
|
|
1467
|
+
return i;
|
|
1468
|
+
}
|
|
1469
|
+
/**
|
|
1470
|
+
* Word separator `splitCurlWords` honors: space and tab only, not JS
|
|
1471
|
+
* `\s` (which also matches NBSP, vertical tab, form feed, ideographic
|
|
1472
|
+
* space, and line terminators). Round 2 (task fdaad781) narrowed this
|
|
1473
|
+
* from `\s`: a non-ASCII or exotic whitespace character bash does not
|
|
1474
|
+
* treat as IFS -- an NBSP between `curl` and `-q`, say -- must not glue
|
|
1475
|
+
* two argv words into one here the way bash never would. `\n`/`\r`
|
|
1476
|
+
* never reach this splitter: `isReadOnlyCurlCommand`'s
|
|
1477
|
+
* `hasUnsafeShellMetachar` guard refuses both for the whole command
|
|
1478
|
+
* first.
|
|
1479
|
+
*/
|
|
1480
|
+
const CURL_WORD_SEPARATOR_RE = /[ \t]/;
|
|
1481
|
+
/**
|
|
1482
|
+
* Quote-aware word splitter built ONLY for `isReadOnlyCurlCommand`. Unlike
|
|
1483
|
+
* every other floor in this file, which treats `trimmed.split(/\s+/)` as
|
|
1484
|
+
* "the" tokenizer, this floor cannot: that split tears a quoted value
|
|
1485
|
+
* containing a space (`-H 'Accept: application/json'`) into the two raw
|
|
1486
|
+
* fragments `'Accept:` and `application/json'`, which this floor's own
|
|
1487
|
+
* positive fixtures require to parse as ONE quoted header value.
|
|
1488
|
+
*
|
|
1489
|
+
* Supports single and double quotes only; no backslash-escape or
|
|
1490
|
+
* expansion handling of any kind. Fails closed (`null`) on anything it
|
|
1491
|
+
* cannot cleanly resolve: an unterminated quote, or a bare unquoted
|
|
1492
|
+
* backslash outside any quote (this splitter has no escape model for it,
|
|
1493
|
+
* so refusing the whole command is the safe answer, not guessing one).
|
|
1494
|
+
* A word built from more than one quoted/unquoted run glued together with
|
|
1495
|
+
* no separating whitespace comes back `composite: true` rather than being
|
|
1496
|
+
* rejected outright here: it is a real, distinct shell word (bash would
|
|
1497
|
+
* concatenate the runs into one argv entry), just one this floor's shape
|
|
1498
|
+
* never allows, so the caller forfeits on it explicitly.
|
|
1499
|
+
*/
|
|
1500
|
+
function splitCurlWords(s) {
|
|
1501
|
+
const words = [];
|
|
1502
|
+
const n = s.length;
|
|
1503
|
+
let i = 0;
|
|
1504
|
+
while (i < n) {
|
|
1505
|
+
while (i < n && CURL_WORD_SEPARATOR_RE.test(s[i]))
|
|
1506
|
+
i += 1;
|
|
1507
|
+
if (i >= n)
|
|
1508
|
+
break;
|
|
1509
|
+
const runs = [];
|
|
1510
|
+
while (i < n && !CURL_WORD_SEPARATOR_RE.test(s[i])) {
|
|
1511
|
+
const ch = s[i];
|
|
1512
|
+
if (ch === "'" || ch === '"') {
|
|
1513
|
+
const close = s.indexOf(ch, i + 1);
|
|
1514
|
+
if (close === -1)
|
|
1515
|
+
return null; // unterminated quote: fail closed
|
|
1516
|
+
runs.push({ quote: ch === "'" ? "single" : "double", text: s.slice(i + 1, close) });
|
|
1517
|
+
i = close + 1;
|
|
1518
|
+
continue;
|
|
1519
|
+
}
|
|
1520
|
+
if (ch === "\\")
|
|
1521
|
+
return null; // unmodelled escape: fail closed
|
|
1522
|
+
const start = i;
|
|
1523
|
+
while (i < n &&
|
|
1524
|
+
!CURL_WORD_SEPARATOR_RE.test(s[i]) &&
|
|
1525
|
+
s[i] !== "'" &&
|
|
1526
|
+
s[i] !== '"' &&
|
|
1527
|
+
s[i] !== "\\") {
|
|
1528
|
+
i += 1;
|
|
1529
|
+
}
|
|
1530
|
+
runs.push({ quote: "none", text: s.slice(start, i) });
|
|
1531
|
+
}
|
|
1532
|
+
if (runs.length === 0)
|
|
1533
|
+
continue;
|
|
1534
|
+
if (runs.length === 1) {
|
|
1535
|
+
words.push({ content: runs[0].text, quote: runs[0].quote, composite: false });
|
|
1536
|
+
}
|
|
1537
|
+
else {
|
|
1538
|
+
words.push({ content: "", quote: "none", composite: true });
|
|
1539
|
+
}
|
|
1540
|
+
}
|
|
1541
|
+
return words;
|
|
1542
|
+
}
|
|
1543
|
+
/**
|
|
1544
|
+
* curl short-flag cluster characters that take no value: no flag on
|
|
1545
|
+
* this list names a file, and the only method any of them selects is
|
|
1546
|
+
* HEAD (`-I`), which is read-only (`-s -S -f -I`, in any combination).
|
|
1547
|
+
* Round 2 (task fdaad781) dropped `-L` (`--location`) and `-k`
|
|
1548
|
+
* (`--insecure`) from this set: `-L` follows a redirect and forwards a
|
|
1549
|
+
* custom `-H` header cross-host over plaintext; `-k` is a deliberate
|
|
1550
|
+
* TLS-verification bypass that should stay approval-gated. Both now
|
|
1551
|
+
* forfeit the shape. See docs/risk-gate.md's curl section for the
|
|
1552
|
+
* measurement behind the `-L` finding.
|
|
1553
|
+
*/
|
|
1554
|
+
const CURL_NO_VALUE_SHORT_CHARS = new Set(["s", "S", "f", "I"]);
|
|
1555
|
+
/** curl long flags that take no value: no flag on this list names a file, and the only method any of them selects is HEAD (`--head`), which is read-only. See `CURL_NO_VALUE_SHORT_CHARS` for why `--location` and `--insecure` are not on it. */
|
|
1556
|
+
const CURL_NO_VALUE_LONG_FLAGS = new Set([
|
|
1557
|
+
"--silent", "--show-error", "--fail", "--head",
|
|
1558
|
+
]);
|
|
1559
|
+
/**
|
|
1560
|
+
* curl long flags whose value is admitted ONLY in the separate-token
|
|
1561
|
+
* form (`--header VALUE`, never `--header=VALUE`, checked and rejected
|
|
1562
|
+
* before this map is consulted). Value maps to the same key as the
|
|
1563
|
+
* matching short flag below, so a repeat count is shared across spellings.
|
|
1564
|
+
*/
|
|
1565
|
+
const CURL_VALUE_LONG_FLAGS = new Map([
|
|
1566
|
+
["--header", "header"],
|
|
1567
|
+
["--max-time", "maxtime"],
|
|
1568
|
+
["--connect-timeout", "connecttimeout"],
|
|
1569
|
+
]);
|
|
1570
|
+
/**
|
|
1571
|
+
* Shape for the ONE operand this floor admits: `https://`, a host made
|
|
1572
|
+
* of letters, digits, `.` and `-` only, an optional `:PORT`, and an
|
|
1573
|
+
* optional path with none of curl's globbing characters (`{}[]`), no
|
|
1574
|
+
* whitespace, backslash, backtick or `$`. No userinfo (`user:pw@`) is
|
|
1575
|
+
* reachable through this shape: the host class has no `:`/`@`, so
|
|
1576
|
+
* `https://user:pw@x` cannot match end-to-end. Round 2 (task fdaad781)
|
|
1577
|
+
* admits `?` and `*` in the path (previously excluded): the operand is
|
|
1578
|
+
* single-quoted, so the shell cannot glob on either character, and
|
|
1579
|
+
* curl's own URL-globbing syntax uses only `{}`/`[]`, never `?`/`*`,
|
|
1580
|
+
* so admitting them widens no shape gap and closes the most common
|
|
1581
|
+
* single-parameter read-only query-string spelling this floor used to
|
|
1582
|
+
* forfeit (a MULTI-parameter query string joined by `&` still forfeits
|
|
1583
|
+
* even single-quoted -- see docs/risk-gate.md's Residuals for why that
|
|
1584
|
+
* stays out of scope). See docs/risk-gate.md's curl section for the
|
|
1585
|
+
* measurement behind admitting `?`/`*`. Do not "clean up" further
|
|
1586
|
+
* without re-deriving it.
|
|
1587
|
+
*/
|
|
1588
|
+
const CURL_SHAPE_URL_RE = /^https:\/\/[A-Za-z0-9.-]+(:[0-9]+)?(\/[^'\s{}[\]\\`$]*)?$/;
|
|
1589
|
+
/**
|
|
1590
|
+
* `true` when `value` is an admissible VALUE for the value-taking flag
|
|
1591
|
+
* `key` (`"header"` or one of the two timeout keys). `composite` always
|
|
1592
|
+
* forfeits (a glued flag+value pair like `-H'X: y'` never reaches a
|
|
1593
|
+
* legitimate value this way). A header value must be quoted (either
|
|
1594
|
+
* quote character), must not start with `@` after quote-stripping and
|
|
1595
|
+
* leading-whitespace trimming (a local-file read; the trim keeps the
|
|
1596
|
+
* ALLOW decision on the shape rather than on curl's own header parser,
|
|
1597
|
+
* round 4), and must carry no `$` or backtick. A bare `\n` or
|
|
1598
|
+
* `\r` can never reach here: `isReadOnlyCurlCommand`'s whole-command
|
|
1599
|
+
* `hasUnsafeShellMetachar` guard already refuses both for the entire
|
|
1600
|
+
* command before any word is split out, so this function forfeits on
|
|
1601
|
+
* neither character itself (round 3, task fdaad781: they were dropped
|
|
1602
|
+
* from this regex as dead, unreachable code; see docs/risk-gate.md's
|
|
1603
|
+
* curl section for the measurement that motivated the `\r` guard in
|
|
1604
|
+
* the first place). A timeout value must be a bare non-negative
|
|
1605
|
+
* integer, `^[0-9]+$` exactly.
|
|
1606
|
+
*/
|
|
1607
|
+
function isAllowedCurlFlagValue(key, value) {
|
|
1608
|
+
if (value.composite)
|
|
1609
|
+
return false;
|
|
1610
|
+
if (key === "header") {
|
|
1611
|
+
if (value.quote === "none")
|
|
1612
|
+
return false;
|
|
1613
|
+
if (value.content.trimStart().startsWith("@"))
|
|
1614
|
+
return false;
|
|
1615
|
+
return !/[\x60$]/.test(value.content);
|
|
1616
|
+
}
|
|
1617
|
+
return /^[0-9]+$/.test(value.content);
|
|
1618
|
+
}
|
|
1619
|
+
/**
|
|
1620
|
+
* Consume a value-taking flag's value at `words[i + 1]` (the shape
|
|
1621
|
+
* requires the separate-token form): validates it and enforces "never
|
|
1622
|
+
* repeated" for `key`, updating `valueFlagCounts` in place. Returns the
|
|
1623
|
+
* loop index to resume at (`i + 1`, the value's own index, since the
|
|
1624
|
+
* `for` loop's own `i += 1` then advances past it), or `null` to forfeit
|
|
1625
|
+
* the whole command. Shared by both value-taking-flag call sites in
|
|
1626
|
+
* `isReadOnlyCurlCommand` (the long-flag branch and the standalone
|
|
1627
|
+
* `-H`/`-m` branch) so the repeat-count bookkeeping has one home.
|
|
1628
|
+
*/
|
|
1629
|
+
function consumeCurlFlagValue(words, i, key, valueFlagCounts) {
|
|
1630
|
+
const value = words[i + 1];
|
|
1631
|
+
if (value === undefined || !isAllowedCurlFlagValue(key, value))
|
|
1632
|
+
return null;
|
|
1633
|
+
const count = (valueFlagCounts.get(key) ?? 0) + 1;
|
|
1634
|
+
if (count > 1)
|
|
1635
|
+
return null; // repeated value-taking flag: forfeit
|
|
1636
|
+
valueFlagCounts.set(key, count);
|
|
1637
|
+
return i + 1;
|
|
1638
|
+
}
|
|
1639
|
+
/**
|
|
1640
|
+
* curl read-only SHAPE floor for the Risk Classifier ONLY (task fdaad781,
|
|
1641
|
+
* decision D-026, round 2 hardening). Composes exactly like the sed and
|
|
1642
|
+
* kubectl floors above: it never sinks below an operator classifier,
|
|
1643
|
+
* and only the DESTRUCTIVE floor (evaluated first in
|
|
1644
|
+
* `risk-classifier.ts`) can raise above it. This comment states only
|
|
1645
|
+
* the grammar; see docs/risk-gate.md's "curl read-only SHAPE floor"
|
|
1646
|
+
* section for the full history (D-013's two leaked flag lists), the
|
|
1647
|
+
* round-2 changes (mandatory `-q`, dropped `-L`/`-k`, the `\r`/query-
|
|
1648
|
+
* string fixes), the residuals, and the operator escape hatch.
|
|
1649
|
+
*
|
|
1650
|
+
* `true` requires ALL of: no shell chaining, redirection, substitution,
|
|
1651
|
+
* or background `&` anywhere in the whole command (the same whole-string
|
|
1652
|
+
* guard every other floor in this file uses); the head word is exactly
|
|
1653
|
+
* `curl` (unquoted, not composite -- no path prefix, no `env`/`command`/
|
|
1654
|
+
* `sudo`/other wrapper, those are other heads and forfeit here); the
|
|
1655
|
+
* SECOND word is exactly `-q` or `--disable` (unquoted, not composite,
|
|
1656
|
+
* not inside a cluster, not merely present later -- curl only honors it
|
|
1657
|
+
* as the first argument, and skipping it leaves `$CURL_HOME/.curlrc` /
|
|
1658
|
+
* `$XDG_CONFIG_HOME/curlrc` / `~/.curlrc` free to set `output`,
|
|
1659
|
+
* `request`, `data`, or `upload-file` underneath every other spelling
|
|
1660
|
+
* this shape would otherwise accept -- see docs/risk-gate.md's curl
|
|
1661
|
+
* section for the measurement); every remaining word is either a flag
|
|
1662
|
+
* drawn from the closed allowlists above (no flag on this list names a
|
|
1663
|
+
* file, and the only method any of them selects is HEAD, which is
|
|
1664
|
+
* read-only; curl's config surface is closed off by the mandatory
|
|
1665
|
+
* `-q`; a value-taking flag consumed only in its separate-token form,
|
|
1666
|
+
* and never repeated) or the SINGLE operand, which must be a single-quoted,
|
|
1667
|
+
* unbroken word matching `CURL_SHAPE_URL_RE`. Any composite word, any
|
|
1668
|
+
* unknown flag, any `=`-glued flag, any second operand, or a missing
|
|
1669
|
+
* operand forfeits the whole command.
|
|
1670
|
+
*/
|
|
1671
|
+
export function isReadOnlyCurlCommand(command) {
|
|
1672
|
+
const trimmed = command.trim();
|
|
1673
|
+
if (trimmed === "")
|
|
1674
|
+
return false;
|
|
1675
|
+
if (hasUnsafeShellMetachar(trimmed))
|
|
1676
|
+
return false;
|
|
1677
|
+
const words = splitCurlWords(trimmed);
|
|
1678
|
+
if (words === null || words.length === 0)
|
|
1679
|
+
return false;
|
|
1680
|
+
const head = words[0];
|
|
1681
|
+
if (head.composite || head.quote !== "none" || head.content !== "curl")
|
|
1682
|
+
return false;
|
|
1683
|
+
// Mandatory `-q`/`--disable` as the SECOND word (curl only honors it
|
|
1684
|
+
// as the first argument): closes the curlrc auto-load residual, see
|
|
1685
|
+
// this function's docstring above.
|
|
1686
|
+
const disableWord = words[1];
|
|
1687
|
+
if (disableWord === undefined ||
|
|
1688
|
+
disableWord.composite ||
|
|
1689
|
+
disableWord.quote !== "none" ||
|
|
1690
|
+
(disableWord.content !== "-q" && disableWord.content !== "--disable")) {
|
|
1691
|
+
return false;
|
|
1692
|
+
}
|
|
1693
|
+
let urlWord = null;
|
|
1694
|
+
const valueFlagCounts = new Map();
|
|
1695
|
+
for (let i = 2; i < words.length; i += 1) {
|
|
1696
|
+
const w = words[i];
|
|
1697
|
+
if (w.composite)
|
|
1698
|
+
return false;
|
|
1699
|
+
if (w.quote === "none" && w.content.startsWith("-") && w.content !== "-") {
|
|
1700
|
+
const t = w.content;
|
|
1701
|
+
if (t.startsWith("--")) {
|
|
1702
|
+
if (t.includes("="))
|
|
1703
|
+
return false; // `--flag=value` glued form: forfeit
|
|
1704
|
+
if (CURL_NO_VALUE_LONG_FLAGS.has(t))
|
|
1705
|
+
continue;
|
|
1706
|
+
const key = CURL_VALUE_LONG_FLAGS.get(t);
|
|
1707
|
+
if (key === undefined)
|
|
1708
|
+
return false; // not on the closed list
|
|
1709
|
+
const next = consumeCurlFlagValue(words, i, key, valueFlagCounts);
|
|
1710
|
+
if (next === null)
|
|
1711
|
+
return false;
|
|
1712
|
+
i = next;
|
|
1713
|
+
continue;
|
|
1714
|
+
}
|
|
1715
|
+
if (t === "-H" || t === "-m") {
|
|
1716
|
+
const key = t === "-H" ? "header" : "maxtime";
|
|
1717
|
+
const next = consumeCurlFlagValue(words, i, key, valueFlagCounts);
|
|
1718
|
+
if (next === null)
|
|
1719
|
+
return false;
|
|
1720
|
+
i = next;
|
|
1721
|
+
continue;
|
|
1722
|
+
}
|
|
1723
|
+
// Any other short flag or cluster: every character must be a
|
|
1724
|
+
// no-value char. `-H`/`-m` are recognised only as a WHOLE token
|
|
1725
|
+
// above, never as a cluster member, so `-sH` and `-mS` forfeit here.
|
|
1726
|
+
const clusterChars = t.slice(1);
|
|
1727
|
+
if (clusterChars.length === 0)
|
|
1728
|
+
return false;
|
|
1729
|
+
if (![...clusterChars].every((c) => CURL_NO_VALUE_SHORT_CHARS.has(c)))
|
|
1730
|
+
return false;
|
|
1731
|
+
continue;
|
|
1732
|
+
}
|
|
1733
|
+
// Not a recognised flag token: the operand. A second operand (or a
|
|
1734
|
+
// quoted/mis-spelled flag that fell through to here) forfeits.
|
|
1735
|
+
if (urlWord !== null)
|
|
1736
|
+
return false;
|
|
1737
|
+
urlWord = w;
|
|
1738
|
+
}
|
|
1739
|
+
if (urlWord === null)
|
|
1740
|
+
return false;
|
|
1741
|
+
if (urlWord.composite || urlWord.quote !== "single")
|
|
1742
|
+
return false;
|
|
1743
|
+
return CURL_SHAPE_URL_RE.test(urlWord.content);
|
|
1744
|
+
}
|
|
977
1745
|
/**
|
|
978
1746
|
* Returns true when `t` (with any trailing `=VALUE` glue stripped) is a
|
|
979
1747
|
* GNU/BSD `getopt_long` ABBREVIATION of `fullFlag`: a prefix (including
|