(commands: SimpleCommand[])
| 2211 | * content. Returns the first failure or {ok: true}. |
| 2212 | */ |
| 2213 | export function checkSemantics(commands: SimpleCommand[]): SemanticCheckResult { |
| 2214 | for (const cmd of commands) { |
| 2215 | // Strip safe wrapper commands (nohup, time, timeout N, nice -n N) so |
| 2216 | // `nohup eval "..."` and `timeout 5 jq 'system(...)'` are checked |
| 2217 | // against the wrapped command, not the wrapper. Inlined here to avoid |
| 2218 | // circular import with bashPermissions.ts. |
| 2219 | let a = cmd.argv |
| 2220 | for (;;) { |
| 2221 | if (a[0] === 'time' || a[0] === 'nohup') { |
| 2222 | a = a.slice(1) |
| 2223 | } else if (a[0] === 'timeout') { |
| 2224 | // `timeout 5`, `timeout 5s`, `timeout 5.5`, plus optional GNU flags |
| 2225 | // preceding the duration. Long: --foreground, --kill-after=N, |
| 2226 | // --signal=SIG, --preserve-status. Short: -k DUR, -s SIG, -v (also |
| 2227 | // fused: -k5, -sTERM). |
| 2228 | // SECURITY (SAST Mar 2026): the previous loop only skipped `--long` |
| 2229 | // flags, so `timeout -k 5 10 eval ...` broke out with name='timeout' |
| 2230 | // and the wrapped eval was never checked. Now handle known short |
| 2231 | // flags AND fail closed on any unrecognized flag — an unknown flag |
| 2232 | // means we can't locate the wrapped command, so we must not silently |
| 2233 | // fall through to name='timeout'. |
| 2234 | let i = 1 |
| 2235 | while (i < a.length) { |
| 2236 | const arg = a[i]! |
| 2237 | if ( |
| 2238 | arg === '--foreground' || |
| 2239 | arg === '--preserve-status' || |
| 2240 | arg === '--verbose' |
| 2241 | ) { |
| 2242 | i++ // known no-value long flags |
| 2243 | } else if (/^--(?:kill-after|signal)=[A-Za-z0-9_.+-]+$/.test(arg)) { |
| 2244 | i++ // --kill-after=5, --signal=TERM (value fused with =) |
| 2245 | } else if ( |
| 2246 | (arg === '--kill-after' || arg === '--signal') && |
| 2247 | a[i + 1] && |
| 2248 | /^[A-Za-z0-9_.+-]+$/.test(a[i + 1]!) |
| 2249 | ) { |
| 2250 | i += 2 // --kill-after 5, --signal TERM (space-separated) |
| 2251 | } else if (arg.startsWith('--')) { |
| 2252 | // Unknown long flag, OR --kill-after/--signal with non-allowlisted |
| 2253 | // value (e.g. placeholder from $() substitution). Fail closed. |
| 2254 | return { |
| 2255 | ok: false, |
| 2256 | reason: `timeout with ${arg} flag cannot be statically analyzed`, |
| 2257 | } |
| 2258 | } else if (arg === '-v') { |
| 2259 | i++ // --verbose, no argument |
| 2260 | } else if ( |
| 2261 | (arg === '-k' || arg === '-s') && |
| 2262 | a[i + 1] && |
| 2263 | /^[A-Za-z0-9_.+-]+$/.test(a[i + 1]!) |
| 2264 | ) { |
| 2265 | i += 2 // -k DURATION / -s SIGNAL — separate value |
| 2266 | } else if (/^-[ks][A-Za-z0-9_.+-]+$/.test(arg)) { |
| 2267 | i++ // fused: -k5, -sTERM |
| 2268 | } else if (arg.startsWith('-')) { |
| 2269 | // Unknown flag OR -k/-s with non-allowlisted value — can't locate |
| 2270 | // wrapped cmd. Reject, don't fall through to name='timeout'. |
no test coverage detected