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
25 changes: 14 additions & 11 deletions docs/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

echo -e "${Bold_White}"
Expand All @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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"
Expand All @@ -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" '
Expand Down
14 changes: 7 additions & 7 deletions scripts/install-hook.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"
Expand All @@ -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."
Expand All @@ -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

Expand All @@ -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
Expand Down
33 changes: 21 additions & 12 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -116,13 +123,13 @@ async function promptConfirmation(command: string, reason: string): Promise<bool
});

return new Promise((resolve) => {
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");
}
);
});
Expand All @@ -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}`
);
Expand All @@ -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;
}
}
Expand Down
5 changes: 3 additions & 2 deletions src/integrations/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,9 @@ function processGitStatusOutput(
const pathspecToOriginal = new Map<string, string>();
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);
}
}

Expand Down
28 changes: 18 additions & 10 deletions src/parser/command-checks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[],
Expand Down Expand Up @@ -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 <files>";
if (resolvedCmd === "rm" && targetFiles.length > 0) {
Expand All @@ -82,7 +88,8 @@ export function checkFindCommand(
remaining: ParsedEntry[],
blockedCommands: Set<string>
): 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 <files>" };
}

Expand All @@ -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,
Expand Down
56 changes: 30 additions & 26 deletions src/parser/rules/CoreAstRule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -80,6 +80,27 @@ export class CoreAstRule implements SecurityRule {
return null;
}

private resolveCmdName(entry: string, vars: Record<string, string>): 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();
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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);
Expand Down Expand Up @@ -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);
Expand All @@ -134,51 +153,36 @@ 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)) {
nextMustBeCommand = true;
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,
Expand Down
4 changes: 2 additions & 2 deletions src/parser/rules/RawThreatRule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
},
Expand Down
Loading