| 1 | package hook |
| 2 | |
| 3 | import ( |
| 4 | "strings" |
| 5 | |
| 6 | "reasonix/internal/shellparse" |
| 7 | ) |
| 8 | |
| 9 | // NormalizeCommand repairs narrow classes of copied JSON-escaped hook commands. |
| 10 | // It is intentionally conservative: hook commands can be security controls, so |
| 11 | // only commands that were already malformed and match a known hook shape are |
| 12 | // rewritten. |
| 13 | func NormalizeCommand(command string) string { |
| 14 | if fixed, ok := normalizeStaticNodeEval(command); ok { |
| 15 | return fixed |
| 16 | } |
| 17 | if fixed, ok := normalizeEscapedNodeEval(command); ok { |
| 18 | return fixed |
| 19 | } |
| 20 | if fixed, ok := normalizeEscapedPowerShellFile(command); ok { |
| 21 | return fixed |
| 22 | } |
| 23 | return command |
| 24 | } |
| 25 | |
| 26 | func normalizeStaticNodeEval(command string) (string, bool) { |
| 27 | node, flag, script, ok := repairableNodeEvalArgs(command) |
| 28 | if !ok { |
| 29 | return "", false |
| 30 | } |
| 31 | return renderNodeEvalCommand(node, flag, script), true |
| 32 | } |
| 33 | |
| 34 | func directNodeEvalArgs(command string) (string, string, string, bool) { |
| 35 | fields, malformed := shellparse.StaticFields(command) |
| 36 | if malformed == "" && len(fields) == 3 && isNodeCommand(fields[0]) && isNodeEvalFlag(fields[1]) { |
| 37 | script, ok := repairQuotedNodeEvalScript(fields[2]) |
| 38 | if ok { |
| 39 | return fields[0], fields[1], script, true |
| 40 | } |
| 41 | if isHookStdinNodeEval(fields[2]) { |
| 42 | return fields[0], fields[1], fields[2], true |
| 43 | } |
| 44 | } |
| 45 | node, flag, script, ok := escapedNodeEvalArgs(command) |
| 46 | return node, flag, script, ok |
| 47 | } |
| 48 | |
| 49 | func repairableNodeEvalArgs(command string) (string, string, string, bool) { |
| 50 | fields, malformed := shellparse.StaticFields(command) |
| 51 | if malformed == "" && len(fields) == 3 && isNodeCommand(fields[0]) && isNodeEvalFlag(fields[1]) { |
| 52 | script, ok := repairQuotedNodeEvalScript(fields[2]) |
| 53 | if ok { |
| 54 | return fields[0], fields[1], script, true |
| 55 | } |
| 56 | } |
| 57 | return escapedNodeEvalArgs(command) |
| 58 | } |
| 59 | |
| 60 | func normalizeEscapedPowerShellFile(command string) (string, bool) { |
| 61 | trimmed := strings.TrimSpace(command) |
| 62 | _, spans, ok := repairablePowerShellFileParse(trimmed) |
| 63 | if !ok || len(spans) == 0 { |
| 64 | return "", false |
| 65 | } |
| 66 | // Replace only the escaped-quote sequences found during parsing, so |
| 67 | // well-formed sibling arguments keep their bytes verbatim. |
| 68 | var b strings.Builder |
| 69 | prev := 0 |
| 70 | for _, span := range spans { |
| 71 | b.WriteString(trimmed[prev:span[0]]) |
| 72 | b.WriteByte('"') |
| 73 | prev = span[1] |
| 74 | } |
| 75 | b.WriteString(trimmed[prev:]) |
| 76 | return b.String(), true |
| 77 | } |
| 78 | |
| 79 | func repairablePowerShellFileArgs(command string) (string, []string, bool) { |
| 80 | fields, _, ok := repairablePowerShellFileParse(command) |
| 81 | if !ok { |
| 82 | return "", nil, false |
| 83 | } |
| 84 | return fields[0], fields[1:], true |
| 85 | } |
| 86 | |
| 87 | func repairablePowerShellFileParse(command string) ([]string, [][2]int, bool) { |
| 88 | fields, repaired, spans, ok := parseSimpleHookCommandFields(command) |
| 89 | if !ok || len(fields) < 3 || !isPowerShellCommand(fields[0]) { |
| 90 | return nil, nil, false |
| 91 | } |
| 92 | fileIdx := powerShellFileFlagIndex(fields) |
| 93 | if fileIdx < 0 || fileIdx+1 >= len(fields) { |
| 94 | return nil, nil, false |
| 95 | } |
| 96 | if !powerShellFileRepairApplies(repaired, fileIdx) { |
| 97 | return nil, nil, false |
| 98 | } |
| 99 | return fields, spans, true |
| 100 | } |
| 101 | |
| 102 | func powerShellFileRepairApplies(repaired []bool, fileIdx int) bool { |
| 103 | for i, ok := range repaired { |
| 104 | if !ok { |
| 105 | continue |
| 106 | } |
| 107 | if i == 0 || i > fileIdx { |
| 108 | return true |
| 109 | } |
| 110 | } |
| 111 | return false |
| 112 | } |
| 113 | |
| 114 | func powerShellFileFlagIndex(fields []string) int { |
| 115 | for i := 1; i < len(fields); i++ { |
| 116 | if strings.EqualFold(fields[i], "-File") { |
| 117 | return i |
| 118 | } |
| 119 | } |
| 120 | return -1 |
| 121 | } |
| 122 | |
| 123 | func parseSimpleHookCommandFields(command string) ([]string, []bool, [][2]int, bool) { |
| 124 | // A newline is a shell command separator, not argument whitespace; leave |
| 125 | // multi-command strings alone like other compound commands. |
| 126 | if strings.ContainsAny(command, "\n\r") { |
| 127 | return nil, nil, nil, false |
| 128 | } |
| 129 | s := strings.TrimSpace(command) |
| 130 | fields := []string{} |
| 131 | repaired := []bool{} |
| 132 | spans := [][2]int{} |
| 133 | for i := 0; i < len(s); { |
| 134 | for i < len(s) && isShellWhitespace(s[i]) { |
| 135 | i++ |
| 136 | } |
| 137 | if i >= len(s) { |
| 138 | break |
| 139 | } |
| 140 | var b strings.Builder |
| 141 | fieldStarted := false |
| 142 | fieldRepaired := false |
| 143 | var quote byte |
| 144 | escapedQuote := false |
| 145 | for i < len(s) { |
| 146 | c := s[i] |
| 147 | if quote == 0 { |
| 148 | if isShellWhitespace(c) { |
| 149 | break |
| 150 | } |
| 151 | if isShellControl(c) { |
| 152 | return nil, nil, nil, false |
| 153 | } |
| 154 | if n := escapedShellQuoteLen(s, i); n > 0 { |
| 155 | quote = '"' |
| 156 | escapedQuote = true |
| 157 | fieldStarted = true |
| 158 | fieldRepaired = true |
| 159 | spans = append(spans, [2]int{i, i + n}) |
| 160 | i += n |
| 161 | continue |
| 162 | } |
| 163 | if c == '"' || c == '\'' { |
| 164 | quote = c |
| 165 | escapedQuote = false |
| 166 | fieldStarted = true |
| 167 | i++ |
| 168 | continue |
| 169 | } |
| 170 | b.WriteByte(c) |
| 171 | fieldStarted = true |
| 172 | i++ |
| 173 | continue |
| 174 | } |
| 175 | if escapedQuote { |
| 176 | if n := escapedShellQuoteLen(s, i); n > 0 { |
| 177 | quote = 0 |
| 178 | escapedQuote = false |
| 179 | fieldRepaired = true |
| 180 | spans = append(spans, [2]int{i, i + n}) |
| 181 | i += n |
| 182 | continue |
| 183 | } |
| 184 | b.WriteByte(c) |
| 185 | i++ |
| 186 | continue |
| 187 | } |
| 188 | if c == quote { |
| 189 | quote = 0 |
| 190 | i++ |
| 191 | continue |
| 192 | } |
| 193 | if quote == '"' && c == '\\' && i+1 < len(s) && isDoubleQuoteEscapedByte(s[i+1]) { |
| 194 | b.WriteByte(s[i+1]) |
| 195 | i += 2 |
| 196 | continue |
| 197 | } |
| 198 | b.WriteByte(c) |
| 199 | i++ |
| 200 | } |
| 201 | if quote != 0 { |
| 202 | return nil, nil, nil, false |
| 203 | } |
| 204 | if !fieldStarted { |
| 205 | return nil, nil, nil, false |
| 206 | } |
| 207 | fields = append(fields, b.String()) |
| 208 | repaired = append(repaired, fieldRepaired) |
| 209 | for i < len(s) && isShellWhitespace(s[i]) { |
| 210 | i++ |
| 211 | } |
| 212 | } |
| 213 | return fields, repaired, spans, len(fields) > 0 |
| 214 | } |
| 215 | |
| 216 | func escapedShellQuoteLen(s string, i int) int { |
| 217 | j := i |
| 218 | for j < len(s) && s[j] == '\\' { |
| 219 | j++ |
| 220 | } |
| 221 | if j == i || j >= len(s) || s[j] != '"' { |
| 222 | return 0 |
| 223 | } |
| 224 | return j - i + 1 |
| 225 | } |
| 226 | |
| 227 | func isShellWhitespace(c byte) bool { |
| 228 | return c == ' ' || c == '\t' |
| 229 | } |
| 230 | |
| 231 | func isShellControl(c byte) bool { |
| 232 | switch c { |
| 233 | case '&', '|', ';', '<', '>': |
| 234 | return true |
| 235 | default: |
| 236 | return false |
| 237 | } |
| 238 | } |
| 239 | |
| 240 | func isDoubleQuoteEscapedByte(c byte) bool { |
| 241 | switch c { |
| 242 | case '"', '\\', '$', '`', '\n': |
| 243 | return true |
| 244 | default: |
| 245 | return false |
| 246 | } |
| 247 | } |
| 248 | |
| 249 | func normalizeEscapedNodeEval(command string) (string, bool) { |
| 250 | node, flag, script, ok := escapedNodeEvalArgs(command) |
| 251 | if !ok { |
| 252 | return "", false |
| 253 | } |
| 254 | return renderNodeEvalCommand(node, flag, script), true |
| 255 | } |
| 256 | |
| 257 | func escapedNodeEvalArgs(command string) (string, string, string, bool) { |
| 258 | command = strings.TrimSpace(command) |
| 259 | for _, prefix := range []struct { |
| 260 | raw string |
| 261 | node string |
| 262 | flag string |
| 263 | }{ |
| 264 | {raw: `node -e `, node: "node", flag: "-e"}, |
| 265 | {raw: `node --eval `, node: "node", flag: "--eval"}, |
| 266 | {raw: `node.exe -e `, node: "node.exe", flag: "-e"}, |
| 267 | {raw: `node.exe --eval `, node: "node.exe", flag: "--eval"}, |
| 268 | } { |
| 269 | rest, ok := strings.CutPrefix(command, prefix.raw) |
| 270 | if !ok { |
| 271 | continue |
| 272 | } |
| 273 | script, ok := trimEscapedQuotes(strings.TrimSpace(rest)) |
| 274 | if !ok || !isHookStdinNodeEval(script) { |
| 275 | return "", "", "", false |
| 276 | } |
| 277 | return prefix.node, prefix.flag, script, true |
| 278 | } |
| 279 | return "", "", "", false |
| 280 | } |
| 281 | |
| 282 | func repairQuotedNodeEvalScript(script string) (string, bool) { |
| 283 | for _, trim := range []func(string) (string, bool){ |
| 284 | trimDoubleQuotes, |
| 285 | trimEscapedQuotes, |
| 286 | trimBackslashEscapedQuotes, |
| 287 | } { |
| 288 | if candidate, ok := trim(strings.TrimSpace(script)); ok && isHookStdinNodeEval(candidate) { |
| 289 | return candidate, true |
| 290 | } |
| 291 | } |
| 292 | return "", false |
| 293 | } |
| 294 | |
| 295 | func trimDoubleQuotes(s string) (string, bool) { |
| 296 | if len(s) < 2 || s[0] != '"' || s[len(s)-1] != '"' { |
| 297 | return "", false |
| 298 | } |
| 299 | return s[1 : len(s)-1], true |
| 300 | } |
| 301 | |
| 302 | func trimEscapedQuotes(s string) (string, bool) { |
| 303 | if len(s) < 4 || !strings.HasPrefix(s, `\"`) || !strings.HasSuffix(s, `\"`) { |
| 304 | return "", false |
| 305 | } |
| 306 | return unescapeJSONStyleQuotes(s[2 : len(s)-2]), true |
| 307 | } |
| 308 | |
| 309 | func trimBackslashEscapedQuotes(s string) (string, bool) { |
| 310 | if len(s) < 6 || !strings.HasPrefix(s, `\\"`) || !strings.HasSuffix(s, `\\"`) { |
| 311 | return "", false |
| 312 | } |
| 313 | return unescapeJSONStyleQuotes(s[3 : len(s)-3]), true |
| 314 | } |
| 315 | |
| 316 | func unescapeJSONStyleQuotes(s string) string { |
| 317 | return strings.ReplaceAll(s, `\"`, `"`) |
| 318 | } |
| 319 | |
| 320 | func isHookStdinNodeEval(script string) bool { |
| 321 | script = strings.TrimSpace(script) |
| 322 | if script == "" { |
| 323 | return false |
| 324 | } |
| 325 | if !startsLikeJSStatement(script) { |
| 326 | return false |
| 327 | } |
| 328 | return strings.Contains(script, "JSON.parse") && |
| 329 | (strings.Contains(script, "readFileSync(0") || strings.Contains(script, "readFileSync( 0") || strings.Contains(script, "process.stdin")) |
| 330 | } |
| 331 | |
| 332 | func startsLikeJSStatement(script string) bool { |
| 333 | for _, prefix := range []string{ |
| 334 | "const ", "const\t", "let ", "let\t", "var ", "var\t", |
| 335 | "import ", "require(", "if ", "if(", "try ", "(async ", "async ", |
| 336 | } { |
| 337 | if strings.HasPrefix(script, prefix) { |
| 338 | return true |
| 339 | } |
| 340 | } |
| 341 | return false |
| 342 | } |
| 343 | |
| 344 | func isNodeCommand(command string) bool { |
| 345 | base := strings.ToLower(shellparse.WordBase(command)) |
| 346 | return base == "node" || base == "node.exe" |
| 347 | } |
| 348 | |
| 349 | func isPowerShellCommand(command string) bool { |
| 350 | base := strings.ToLower(command) |
| 351 | if i := strings.LastIndexAny(base, `/\`); i >= 0 { |
| 352 | base = base[i+1:] |
| 353 | } |
| 354 | return base == "powershell" || base == "powershell.exe" || base == "pwsh" || base == "pwsh.exe" |
| 355 | } |
| 356 | |
| 357 | func isNodeEvalFlag(flag string) bool { |
| 358 | return flag == "-e" || flag == "--eval" |
| 359 | } |
| 360 | |
| 361 | func renderNodeEvalCommand(node, flag, script string) string { |
| 362 | return shellField(node) + " " + shellField(flag) + " " + shellDoubleQuote(script) |
| 363 | } |
| 364 | |
| 365 | func shellField(s string) string { |
| 366 | if s != "" { |
| 367 | safe := true |
| 368 | for _, r := range s { |
| 369 | if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || |
| 370 | r == '_' || r == '-' || r == '.' || r == '/' || r == ':' || r == '\\' { |
| 371 | continue |
| 372 | } |
| 373 | safe = false |
| 374 | break |
| 375 | } |
| 376 | if safe { |
| 377 | return s |
| 378 | } |
| 379 | } |
| 380 | return shellDoubleQuote(s) |
| 381 | } |
| 382 | |
| 383 | func shellDoubleQuote(s string) string { |
| 384 | var b strings.Builder |
| 385 | b.Grow(len(s) + 2) |
| 386 | b.WriteByte('"') |
| 387 | for _, r := range s { |
| 388 | switch r { |
| 389 | case '\\', '"', '$', '`': |
| 390 | b.WriteByte('\\') |
| 391 | } |
| 392 | b.WriteRune(r) |
| 393 | } |
| 394 | b.WriteByte('"') |
| 395 | return b.String() |
| 396 | } |
| 397 |