diff --git a/docs/install.sh b/docs/install.sh index 200ccb7..0157d1e 100644 --- a/docs/install.sh +++ b/docs/install.sh @@ -18,20 +18,23 @@ if [[ -t 1 ]]; then fi error() { - echo -e "${Red}error${Color_Off}:" "$@" >&2 + echo -e "${Red}error${Color_Off}:" "$*" >&2 exit 1 } info() { - echo -e "${Dim}$@ ${Color_Off}" + echo -e "${Dim}$* ${Color_Off}" + return 0 } info_bold() { - echo -e "${Bold_White}$@ ${Color_Off}" + echo -e "${Bold_White}$* ${Color_Off}" + return 0 } success() { - echo -e "${Green}$@ ${Color_Off}" + echo -e "${Green}$* ${Color_Off}" + return 0 } echo -e "${Bold_White}" @@ -42,10 +45,10 @@ echo -e "${Color_Off}" verify_checksum() { local script_path="$1" local expected="${SHELLSHIELD_INSTALL_SHA256:-}" - if [ -z "$expected" ]; then + if [[ -z "$expected" ]]; then error "Missing SHELLSHIELD_INSTALL_SHA256. Get the checksum from the README and retry." fi - if [ -z "$script_path" ] || [ ! -f "$script_path" ]; then + if [[ -z "$script_path" ]] || [[ ! -f "$script_path" ]]; then error "Installer must be run from a file to verify checksum. Download it first." fi @@ -77,11 +80,11 @@ success "āœ… Dependencies found." INSTALL_DIR="$HOME/.shellshield" info "Installing to ${INSTALL_DIR}..." -if [ -d "$INSTALL_DIR/.git" ]; then +if [[ -d "$INSTALL_DIR/.git" ]]; then info "Updating existing installation..." git -C "$INSTALL_DIR" fetch --quiet git -C "$INSTALL_DIR" reset --hard origin/main --quiet -elif [ -d "$INSTALL_DIR" ]; then +elif [[ -d "$INSTALL_DIR" ]]; then info "Directory exists but is not a git repo. Backing up..." mv "$INSTALL_DIR" "${INSTALL_DIR}.bak.$(date +%s)" git clone --depth 1 https://github.com/hevlyo/ShellShield.git "$INSTALL_DIR" --quiet @@ -98,7 +101,7 @@ bun install --production --no-save --force 2>"$tmp_log" install_status=$? set -e -if [ $install_status -ne 0 ]; then +if [[ $install_status -ne 0 ]]; then if grep -q "lockfile had changes" "$tmp_log"; then error "bun lockfile is frozen. Update bun and retry (bun --version)." fi @@ -118,7 +121,7 @@ case "$USER_SHELL" in PROFILE="$HOME/.zshrc" ;; bash) - if [ -f "$HOME/.bashrc" ]; then + if [[ -f "$HOME/.bashrc" ]]; then PROFILE="$HOME/.bashrc" else PROFILE="$HOME/.bash_profile" @@ -140,7 +143,7 @@ fi $HOOK_END " -if [ -f "$PROFILE" ]; then +if [[ -f "$PROFILE" ]]; then if grep -q "$HOOK_BEGIN" "$PROFILE"; then tmp_profile=$(mktemp) awk -v begin="$HOOK_BEGIN" -v end="$HOOK_END" ' diff --git a/scripts/install-hook.sh b/scripts/install-hook.sh index ad0832a..5e2ead1 100755 --- a/scripts/install-hook.sh +++ b/scripts/install-hook.sh @@ -6,7 +6,7 @@ echo "šŸ”’ Installing ShellShield Pre-commit Hook..." HOOK_DIR=".git/hooks" HOOK_FILE="$HOOK_DIR/pre-commit" -if [ ! -d ".git" ]; then +if [[ ! -d ".git" ]]; then echo "āŒ Not a git repository. Run 'git init' first." exit 1 fi @@ -17,11 +17,11 @@ cat > "$HOOK_FILE" << 'EOF' #!/bin/bash set -e -if [ "${SHELLSHIELD_SKIP:-}" = "1" ]; then +if [[ "${SHELLSHIELD_SKIP:-}" == "1" ]]; then exit 0 fi -if [ -x "$HOME/.bun/bin/bun" ]; then +if [[ -x "$HOME/.bun/bin/bun" ]]; then BUN_BIN="$HOME/.bun/bin/bun" elif command -v bun >/dev/null 2>&1; then BUN_BIN="bun" @@ -30,9 +30,9 @@ else exit 0 fi -if [ -f "./src/index.ts" ]; then +if [[ -f "./src/index.ts" ]]; then CLI="./src/index.ts" -elif [ -f "$HOME/.shellshield/src/index.ts" ]; then +elif [[ -f "$HOME/.shellshield/src/index.ts" ]]; then CLI="$HOME/.shellshield/src/index.ts" else echo "ShellShield pre-commit: shellshield not found; skipping." @@ -51,7 +51,7 @@ while IFS= read -r line; do if [[ "$line" == "+"* ]]; then candidate="${line#+}" - if [ -z "${candidate//[[:space:]]/}" ]; then + if [[ -z "${candidate//[[:space:]]/}" ]]; then continue fi @@ -65,7 +65,7 @@ while IFS= read -r line; do fi done < <(git diff --cached -U0 --no-color) -if [ "$has_issues" -ne 0 ]; then +if [[ "$has_issues" -ne 0 ]]; then echo "Commit blocked by ShellShield." echo "Use SHELLSHIELD_SKIP=1 git commit to bypass." exit 1 diff --git a/src/cli.ts b/src/cli.ts index f2d1910..e6370cf 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -32,15 +32,22 @@ function printDoctor(): void { const hasTrash = runProbe(["bash", "-lc", "command -v trash"]).ok; const hasGioTrash = runProbe(["bash", "-lc", "command -v gio"]).ok; - console.log("ShellShield Doctor"); - console.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); + const doctorHeader = "ShellShield Doctor"; + const doctorSeparator = "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"; + console.log(doctorHeader); + console.log(doctorSeparator); console.log(`Shell: ${shell || "(unknown)"}`); console.log(`Mode: ${process.env.SHELLSHIELD_MODE || "(default)"}`); console.log("\nSafer delete command:"); - if (hasTrashPut) console.log("- trash-put (recommended)"); - else if (hasTrash) console.log("- trash"); - else if (hasGioTrash) console.log("- gio trash"); - else console.log("- (none found) install trash-cli or use gio trash"); + if (hasTrashPut) { + console.log("- trash-put (recommended)"); + } else if (hasTrash) { + console.log("- trash"); + } else if (hasGioTrash) { + console.log("- gio trash"); + } else { + console.log("- (none found) install trash-cli or use gio trash"); + } if (shell) { const typeRm = runProbe(["bash", "-lc", `${shell} -ic 'type rm 2>/dev/null'`]).out; @@ -116,13 +123,13 @@ async function promptConfirmation(command: string, reason: string): Promise { + const promptMsg = `\nāš ļø ShellShield ALERT: ${reason}\n Command: ${command}\n Are you sure you want to execute this? [y/N] `; rl.question( - `\nāš ļø ShellShield ALERT: ${reason}\n` + - ` Command: ${command}\n` + - ` Are you sure you want to execute this? [y/N] `, + promptMsg, (answer) => { rl.close(); - resolve(answer.toLowerCase() === "y" || answer.toLowerCase() === "yes"); + const lower = answer.toLowerCase(); + resolve(lower === "y" || lower === "yes"); } ); }); @@ -136,8 +143,9 @@ async function checkAndAuditCommand(command: string, config: any, source: "check } if (config?.mode === "permissive") { + const warningHeader = `āš ļø ShellShield WARNING: Command '${command}' would be blocked in enforce mode.`; console.error( - `āš ļø ShellShield WARNING: Command '${command}' would be blocked in enforce mode.\n` + + `${warningHeader}\n` + `Reason: ${result.reason}\n` + `Suggestion: ${result.suggestion}` ); @@ -150,7 +158,8 @@ async function checkAndAuditCommand(command: string, config: any, source: "check if (confirmed) { logAudit(command, { ...result, blocked: false }, { source, mode: config?.mode, threshold: config?.threshold, decision: "approved" }); const msg = "Approved. Command will execute."; - console.error(process.stderr.isTTY ? `\x1b[32m${msg}\x1b[0m` : msg); + const tty = process.stderr.isTTY; + console.error(tty ? `\x1b[32m${msg}\x1b[0m` : msg); return true; } } diff --git a/src/integrations/git.ts b/src/integrations/git.ts index ea5f770..ac3c89e 100644 --- a/src/integrations/git.ts +++ b/src/integrations/git.ts @@ -9,8 +9,9 @@ function processGitStatusOutput( const pathspecToOriginal = new Map(); for (const entry of entries) { pathspecToOriginal.set(entry.pathspec, entry.original); - if (entry.pathspec.startsWith("./")) { - pathspecToOriginal.set(entry.pathspec.slice(2), entry.original); + const spec = entry.pathspec; + if (spec.startsWith("./")) { + pathspecToOriginal.set(spec.slice(2), entry.original); } } diff --git a/src/parser/command-checks.ts b/src/parser/command-checks.ts index c89c465..0d15746 100644 --- a/src/parser/command-checks.ts +++ b/src/parser/command-checks.ts @@ -8,6 +8,18 @@ interface BlockedContext { threshold: number; } +function checkGitIntegration(targetFiles: string[]): BlockResult | null { + const uncommitted = hasUncommittedChanges(targetFiles); + if (uncommitted.length > 0) { + return { + blocked: true, + reason: "UNCOMMITTED CHANGES DETECTED", + suggestion: `Commit changes to these files first: ${uncommitted.join(", ")}`, + }; + } + return null; +} + export function checkBlockedCommand( resolvedCmd: string, args: string[], @@ -57,14 +69,8 @@ export function checkBlockedCommand( }; } - const uncommitted = hasUncommittedChanges(targetFiles); - if (uncommitted.length > 0) { - return { - blocked: true, - reason: "UNCOMMITTED CHANGES DETECTED", - suggestion: `Commit changes to these files first: ${uncommitted.join(", ")}`, - }; - } + const gitCheck = checkGitIntegration(targetFiles); + if (gitCheck) return gitCheck; let suggestion = "trash "; if (resolvedCmd === "rm" && targetFiles.length > 0) { @@ -82,7 +88,8 @@ export function checkFindCommand( remaining: ParsedEntry[], blockedCommands: Set ): BlockResult | null { - if (remaining.some((entry) => typeof entry === "string" && entry.toLowerCase() === "-delete")) { + const hasDelete = remaining.some((entry) => typeof entry === "string" && entry.toLowerCase() === "-delete"); + if (hasDelete) { return { blocked: true, reason: "find -delete detected", suggestion: "trash " }; } @@ -92,7 +99,8 @@ export function checkFindCommand( if (execIdx !== -1 && execIdx + 1 < remaining.length) { const execCmd = remaining[execIdx + 1]; if (typeof execCmd === "string") { - const execName = execCmd.split("/").pop()?.toLowerCase() ?? ""; + const parts = execCmd.split("/"); + const execName = (parts.pop() ?? "").toLowerCase(); if (blockedCommands.has(execName)) { return { blocked: true, diff --git a/src/parser/rules/CoreAstRule.ts b/src/parser/rules/CoreAstRule.ts index 3e2a18b..e263553 100644 --- a/src/parser/rules/CoreAstRule.ts +++ b/src/parser/rules/CoreAstRule.ts @@ -58,7 +58,7 @@ export class CoreAstRule implements SecurityRule { return null; } - private checkProcessSubstitution(normalizedEntry: string, tokens: ParsedEntry[], i: number): BlockResult | null { + private checkBashSubshells(normalizedEntry: string, tokens: ParsedEntry[], i: number): BlockResult | null { if (normalizedEntry === "bash" || normalizedEntry === "sh" || normalizedEntry === "zsh") { const remaining = tokens.slice(i + 1); const hasSubstitution = remaining.some( @@ -80,6 +80,27 @@ export class CoreAstRule implements SecurityRule { return null; } + 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; + } + private checkShellContext(resolvedCmd: string, config: any): BlockResult | null { if (!config.blocked.has(resolvedCmd)) { const ctxEntry = getShellContextEntry(resolvedCmd); @@ -122,9 +143,7 @@ export class CoreAstRule implements SecurityRule { continue; } - if (typeof entry !== "string") { - continue; - } + if (typeof entry !== "string") continue; if (!nextMustBeCommand) { this.checkEnvironmentVariable(entry, vars); @@ -134,18 +153,16 @@ export class CoreAstRule implements SecurityRule { } 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.checkProcessSubstitution(normalizedEntry, tokens, i); + const subCheck = this.checkBashSubshells(normalizedEntry, tokens, i); if (subCheck) return subCheck; if (["sudo", "xargs", "command", "env"].includes(normalizedEntry)) { @@ -153,32 +170,19 @@ export class CoreAstRule implements SecurityRule { continue; } - if (normalizedEntry === "git" && i + 1 < tokens.length) { - const next = tokens[i + 1]; - if (typeof next === "string" && next.toLowerCase() === "rm") { - i++; - continue; - } - } - - const basenamePart = entry.split("/").pop() ?? ""; - const cmdName = entry.startsWith("\\") ? entry.slice(1) : basenamePart; - - let resolvedCmd = cmdName.toLowerCase(); - const resolvedVar = this.resolveVarToken(cmdName, vars); - if (resolvedVar) { - resolvedCmd = resolvedVar.split("/").pop()?.toLowerCase() ?? ""; + 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; - } + 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, diff --git a/src/parser/rules/RawThreatRule.ts b/src/parser/rules/RawThreatRule.ts index 1795d52..9f4d0a7 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(`(?:${this.interpreters.join("|")})\\s+(?:${this.commandFlags.join("|")})\\s+["']?\\$\\((curl|wget)\\b`, "i"), + pattern: new RegExp(String.raw`(?:${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(`(?:${this.interpreters.join("|")})\\s+(?:${this.commandFlags.join("|")})\\s+["']?\`(curl|wget)\\b`, "i"), + pattern: new RegExp(String.raw`(?:${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/shell-context.ts b/src/shell-context.ts index 65f47ce..9b6b1ed 100644 --- a/src/shell-context.ts +++ b/src/shell-context.ts @@ -131,7 +131,8 @@ export function findBlockedTokenInShellContext( ): string | null { const haystack = `${entry.expansion ?? ""}\n${entry.output}`; for (const b of blocked) { - const re = new RegExp(`\\b${escapeRegExp(b)}\\b`, "i"); + const pattern = String.raw`\b${escapeRegExp(b)}\b`; + const re = new RegExp(pattern, "i"); if (re.test(haystack)) return b; } return null; diff --git a/tests/__snapshots__/cli_snapshot.test.ts.snap b/tests/__snapshots__/cli_snapshot.test.ts.snap index da7ebb9..e5e09c6 100644 --- a/tests/__snapshots__/cli_snapshot.test.ts.snap +++ b/tests/__snapshots__/cli_snapshot.test.ts.snap @@ -1,17 +1,5 @@ // 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 -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 @@ -47,3 +35,15 @@ 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. +" +`;