Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 29 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---

Expand All @@ -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'`?
Expand Down Expand Up @@ -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.
Expand Down
236 changes: 41 additions & 195 deletions src/cli.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
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";
import { writeShellContextSnapshot, parseTypeOutput, ShellContextSnapshot } from "./shell-context";
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 {
Expand Down Expand Up @@ -71,33 +72,6 @@ function parseCsvArg(value: string | undefined): string[] {
.filter(Boolean);
}

function hasBypassPrefix(command: string): boolean {
try {
const tokens = parse(command) as Array<string | { op: string }>;
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");
}
Expand Down Expand Up @@ -137,36 +111,36 @@ async function promptConfirmation(command: string, reason: string): Promise<bool

async function checkAndAuditCommand(command: string, config: any, source: "check" | "paste" | "stdin"): Promise<boolean> {
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<void> {
Expand Down Expand Up @@ -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<void> {
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);
}

Expand All @@ -445,7 +291,7 @@ export async function main(): Promise<void> {
const args = process.argv.slice(2);
const config = getConfiguration();

if (process.env.SHELLSHIELD_SKIP === "1") {
if (isBypassEnabled(process.env.SHELLSHIELD_SKIP)) {
process.exit(0);
}

Expand Down
13 changes: 6 additions & 7 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Comment on lines +12 to +13

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

# First, let's verify the actual code in src/config.ts
cat -n src/config.ts | head -20

Repository: hevlyo/ShellShield

Length of output: 1123


🏁 Script executed:

# Check the Zod version
cat package.json | grep -A2 -B2 "zod"

Repository: hevlyo/ShellShield

Length of output: 136


🏁 Script executed:

# Test Number.parseInt vs Number behavior
node <<'EOF'
console.log("=== Number.parseInt tests ===");
console.log('Number.parseInt("3.5", 10):', Number.parseInt("3.5", 10));
console.log('Number.parseInt("10abc", 10):', Number.parseInt("10abc", 10));
console.log('Number.parseInt("invalid", 10):', Number.parseInt("invalid", 10));

console.log("\n=== Number() tests ===");
console.log('Number("3.5"):', Number("3.5"));
console.log('Number("10abc"):', Number("10abc"));
console.log('Number("invalid"):', Number("invalid"));
console.log('Number("10"):', Number("10"));
EOF

Repository: hevlyo/ShellShield

Length of output: 294


🏁 Script executed:

# Test Zod validation behavior with NaN and int()
node <<'EOF'
const { z } = require('zod');

const schema = z.number().int().positive();

console.log("=== Testing Zod int() validation ===");
try {
  console.log("schema.parse(10):", schema.parse(10));
} catch (e) {
  console.log("schema.parse(10) error:", e.message);
}

try {
  console.log("schema.parse(NaN):", schema.parse(NaN));
} catch (e) {
  console.log("schema.parse(NaN) error:", e.message);
}

try {
  console.log("schema.parse(3.5):", schema.parse(3.5));
} catch (e) {
  console.log("schema.parse(3.5) error:", e.message);
}
EOF

Repository: hevlyo/ShellShield

Length of output: 895


🏁 Script executed:

# Install zod and test Zod validation behavior
cd /tmp && npm init -y > /dev/null 2>&1 && npm install zod > /dev/null 2>&1 && node <<'EOF'
const { z } = require('zod');

const schemaInt = z.number().int().positive();
const schemaMin = z.number().int().min(0);

console.log("=== Testing Zod number().int() with various inputs ===");

// Test with integer
try {
  const result = schemaInt.parse(10);
  console.log("✓ schemaInt.parse(10):", result);
} catch (e) {
  console.log("✗ schemaInt.parse(10) error:", e.message);
}

// Test with float
try {
  const result = schemaInt.parse(3.5);
  console.log("✓ schemaInt.parse(3.5):", result);
} catch (e) {
  console.log("✗ schemaInt.parse(3.5) error:", e.message);
}

// Test with NaN
try {
  const result = schemaInt.parse(NaN);
  console.log("✓ schemaInt.parse(NaN):", result);
} catch (e) {
  console.log("✗ schemaInt.parse(NaN) error:", e.message);
}

console.log("\n=== Testing current implementation (parseInt) ===");
const preprocessIntSchema = z.preprocess(
  (val) => typeof val === "string" ? Number.parseInt(val, 10) : val,
  schemaInt
);

try {
  const result = preprocessIntSchema.parse("3.5");
  console.log("✓ preprocessIntSchema.parse('3.5'):", result, " (BUG: should reject)");
} catch (e) {
  console.log("✗ preprocessIntSchema.parse('3.5') error:", e.message);
}

try {
  const result = preprocessIntSchema.parse("10abc");
  console.log("✓ preprocessIntSchema.parse('10abc'):", result, " (BUG: should reject)");
} catch (e) {
  console.log("✗ preprocessIntSchema.parse('10abc') error:", e.message);
}

console.log("\n=== Testing proposed fix (Number) ===");
const preprocessNumberSchema = z.preprocess(
  (val) => typeof val === "string" ? Number(val) : val,
  schemaInt
);

try {
  const result = preprocessNumberSchema.parse("3.5");
  console.log("✓ preprocessNumberSchema.parse('3.5'):", result);
} catch (e) {
  console.log("✗ preprocessNumberSchema.parse('3.5') error:", e.message);
}

try {
  const result = preprocessNumberSchema.parse("10abc");
  console.log("✓ preprocessNumberSchema.parse('10abc'):", result);
} catch (e) {
  console.log("✗ preprocessNumberSchema.parse('10abc') error:", e.message);
}

try {
  const result = preprocessNumberSchema.parse("10");
  console.log("✓ preprocessNumberSchema.parse('10'):", result);
} catch (e) {
  console.log("✗ preprocessNumberSchema.parse('10') error:", e.message);
}

console.log("\n=== Testing z.coerce.number() alternative (Zod 4) ===");
const coerceSchema = z.coerce.number().int().positive();

try {
  const result = coerceSchema.parse("3.5");
  console.log("✓ coerceSchema.parse('3.5'):", result);
} catch (e) {
  console.log("✗ coerceSchema.parse('3.5') error:", e.message);
}

try {
  const result = coerceSchema.parse("10abc");
  console.log("✓ coerceSchema.parse('10abc'):", result);
} catch (e) {
  console.log("✗ coerceSchema.parse('10abc') error:", e.message);
}

try {
  const result = coerceSchema.parse("10");
  console.log("✓ coerceSchema.parse('10'):", result);
} catch (e) {
  console.log("✗ coerceSchema.parse('10') error:", e.message);
}
EOF

Repository: hevlyo/ShellShield

Length of output: 1705


Replace Number.parseInt with Number() to reject malformed config values.

Number.parseInt silently truncates decimals ("3.5"3) and accepts partial numeric strings ("10abc"10), bypassing the int() validation on lines 12–13. Use Number() instead so invalid strings become NaN and fail validation.

✅ Suggested fix
-  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(),
+  threshold: z.preprocess((val) => typeof val === "string" ? Number(val) : val, z.number().int().positive()).optional(),
+  maxSubshellDepth: z.preprocess((val) => typeof val === "string" ? Number(val) : val, z.number().int().min(0)).optional(),
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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(),
threshold: z.preprocess((val) => typeof val === "string" ? Number(val) : val, z.number().int().positive()).optional(),
maxSubshellDepth: z.preprocess((val) => typeof val === "string" ? Number(val) : val, z.number().int().min(0)).optional(),
🤖 Prompt for AI Agents
In `@src/config.ts` around lines 12 - 13, Replace Number.parseInt with Number in
the z.preprocess callbacks for the threshold and maxSubshellDepth schema entries
so malformed numeric strings become NaN and fail the z.number().int()/min
validation; specifically update the preprocess functions used by the threshold
and maxSubshellDepth properties to convert string inputs with Number(...)
instead of Number.parseInt(...) while leaving non-string values unchanged.

contextPath: z.string().min(1).optional(),
mode: z.enum(["enforce", "permissive", "interactive"]).optional(),
customRules: z
Expand Down Expand Up @@ -101,12 +101,11 @@ export function getConfiguration(): Config {
const allowed = fileConfig.allowed || new Set<string>();
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") ||
Expand Down
Loading