diff --git a/src/core/ignore/RooIgnoreController.ts b/src/core/ignore/RooIgnoreController.ts index fda6c37175..cfcee6f8e1 100644 --- a/src/core/ignore/RooIgnoreController.ts +++ b/src/core/ignore/RooIgnoreController.ts @@ -114,44 +114,134 @@ export class RooIgnoreController { return undefined } - // Split command into parts and get the base command - const parts = command.trim().split(/\s+/) - const baseCommand = parts[0].toLowerCase() - - // Commands that read file contents - const fileReadingCommands = [ - // Unix commands - "cat", - "less", - "more", - "head", - "tail", - "grep", - "awk", - "sed", - // PowerShell commands and aliases - "get-content", - "gc", - "type", - "select-string", - "sls", + // First, check for shell redirections and command substitutions that could read files + // These patterns can bypass simple command parsing + const dangerousPatterns = [ + // Input redirection: < file, |;&]+)/g, + // Command substitution: $(cat file), `cat file` + /\$\([^)]*\b(cat|head|tail|less|more|grep|awk|sed|type|gc|get-content)\s+([^\s)]+)[^)]*\)/gi, + /`[^`]*\b(cat|head|tail|less|more|grep|awk|sed|type|gc|get-content)\s+([^\s`]+)[^`]*`/gi, + // Process substitution: <(cat file) + /<\([^)]*\b(cat|head|tail|less|more|grep|awk|sed|type|gc|get-content)\s+([^\s)]+)[^)]*\)/gi, + // Here documents/strings that might reference files + /<<|;&]+)/g, ] - if (fileReadingCommands.includes(baseCommand)) { - // Check each argument that could be a file path - for (let i = 1; i < parts.length; i++) { - const arg = parts[i] - // Skip command flags/options (both Unix and PowerShell style) - if (arg.startsWith("-") || arg.startsWith("/")) { - continue + for (const pattern of dangerousPatterns) { + const matches = command.matchAll(pattern) + for (const match of matches) { + // Get the potential file path from the match + // Different patterns have the file path at different indices + const potentialPaths = [match[1], match[2], match[3]].filter(Boolean) + for (const filePath of potentialPaths) { + if (filePath && !this.validateAccess(filePath)) { + return filePath + } } - // Ignore PowerShell parameter names - if (arg.includes(":")) { - continue + } + } + + // Check for piped commands that might expose file contents + // e.g., echo "$(cat file)" or echo `cat file` + const pipelineCommands = command.split(/[|;&]/).map((cmd) => cmd.trim()) + + for (const pipeCmd of pipelineCommands) { + // Split command into parts and get the base command + const parts = pipeCmd.split(/\s+/) + if (parts.length === 0) continue + + const baseCommand = parts[0].toLowerCase() + + // Commands that read file contents + const fileReadingCommands = [ + // Unix commands + "cat", + "less", + "more", + "head", + "tail", + "grep", + "awk", + "sed", + "nl", + "tac", + "rev", + "cut", + "paste", + "sort", + "uniq", + "comm", + "diff", + "cmp", + "od", + "hexdump", + "xxd", + "strings", + "file", + // Additional Unix utilities + "zcat", + "zless", + "zmore", + "bzcat", + "xzcat", + "view", + // PowerShell commands and aliases + "get-content", + "gc", + "type", + "select-string", + "sls", + // Windows commands + "findstr", + "find", + "fc", + ] + + if (fileReadingCommands.includes(baseCommand)) { + // Check each argument that could be a file path + for (let i = 1; i < parts.length; i++) { + const arg = parts[i] + // Skip command flags/options (both Unix and PowerShell style) + if (arg.startsWith("-") || arg.startsWith("/")) { + continue + } + // Ignore PowerShell parameter names + if (arg.includes(":") && i > 0 && parts[i - 1].startsWith("-")) { + continue + } + // Skip empty arguments + if (!arg) { + continue + } + // Remove quotes if present + const cleanArg = arg.replace(/^["']|["']$/g, "") + // Validate file access + if (!this.validateAccess(cleanArg)) { + return cleanArg + } } - // Validate file access - if (!this.validateAccess(arg)) { - return arg + } + + // Also check for commands that might read files indirectly + // e.g., xargs cat, find -exec cat, etc. + if (baseCommand === "xargs" || baseCommand === "find") { + // Look for file-reading commands in the arguments + const argsStr = parts.slice(1).join(" ") + for (const readCmd of fileReadingCommands) { + if (argsStr.includes(readCmd)) { + // Try to extract file paths from find patterns or xargs input + // This is complex, so we'll check common patterns + const filePatterns = argsStr.match(/(?:name|path)\s+["']?([^"'\s]+)["']?/gi) + if (filePatterns) { + for (const pattern of filePatterns) { + const filePath = pattern.replace(/(?:name|path)\s+["']?([^"'\s]+)["']?/i, "$1") + if (!this.validateAccess(filePath)) { + return filePath + } + } + } + } } } } diff --git a/src/core/ignore/__tests__/RooIgnoreController.spec.ts b/src/core/ignore/__tests__/RooIgnoreController.spec.ts index 3fa7914ee3..37ca2c7285 100644 --- a/src/core/ignore/__tests__/RooIgnoreController.spec.ts +++ b/src/core/ignore/__tests__/RooIgnoreController.spec.ts @@ -275,6 +275,100 @@ describe("RooIgnoreController", () => { expect(controller.validateCommand("npm install")).toBeUndefined() }) + /** + * Tests validation of shell redirections and command substitutions + */ + it("should block shell redirections that read ignored files", () => { + // Input redirection + expect(controller.validateCommand("wc -l < node_modules/package.json")).toBe("node_modules/package.json") + expect(controller.validateCommand("sort < secrets/api-keys.json")).toBe("secrets/api-keys.json") + expect(controller.validateCommand("grep pattern <.git/config")).toBe(".git/config") + + // Should allow non-ignored files + expect(controller.validateCommand("wc -l < README.md")).toBeUndefined() + }) + + it("should block command substitutions that read ignored files", () => { + // $() command substitution + expect(controller.validateCommand("echo $(cat node_modules/package.json)")).toBe( + "node_modules/package.json", + ) + expect(controller.validateCommand("result=$(head secrets/api-keys.json)")).toBe("secrets/api-keys.json") + + // Backtick command substitution + expect(controller.validateCommand("echo `cat .git/config`")).toBe(".git/config") + expect(controller.validateCommand("data=`tail error.log`")).toBe("error.log") + + // Process substitution + expect(controller.validateCommand("diff <(cat node_modules/index.js) file2")).toBe("node_modules/index.js") + + // Should allow non-ignored files + expect(controller.validateCommand("echo $(cat README.md)")).toBeUndefined() + }) + + it("should block piped commands that read ignored files", () => { + // Commands in pipelines + expect(controller.validateCommand("cat node_modules/package.json | grep version")).toBe( + "node_modules/package.json", + ) + expect(controller.validateCommand("echo test | tee secrets/output.log; cat secrets/output.log")).toBe( + "secrets/output.log", + ) + expect(controller.validateCommand("ls && head .git/config")).toBe(".git/config") + + // Should allow non-ignored files in pipelines + expect(controller.validateCommand("cat README.md | grep title")).toBeUndefined() + }) + + it("should detect additional file reading commands", () => { + // Additional Unix utilities + expect(controller.validateCommand("nl node_modules/package.json")).toBe("node_modules/package.json") + expect(controller.validateCommand("tac .git/config")).toBe(".git/config") + expect(controller.validateCommand("strings secrets/binary.dat")).toBe("secrets/binary.dat") + expect(controller.validateCommand("hexdump error.log")).toBe("error.log") + expect(controller.validateCommand("od -c node_modules/index.js")).toBe("node_modules/index.js") + + // Compressed file readers + expect(controller.validateCommand("zcat secrets/data.gz")).toBe("secrets/data.gz") + expect(controller.validateCommand("bzcat node_modules/archive.bz2")).toBe("node_modules/archive.bz2") + + // File comparison utilities + expect(controller.validateCommand("diff .git/config file2")).toBe(".git/config") + expect(controller.validateCommand("cmp secrets/key1 secrets/key2")).toBe("secrets/key1") + + // Windows/PowerShell commands + expect(controller.validateCommand("findstr pattern node_modules/package.json")).toBe( + "node_modules/package.json", + ) + expect(controller.validateCommand("fc .git/config file2")).toBe(".git/config") + }) + + it("should handle complex command patterns", () => { + // Commands with quotes + expect(controller.validateCommand('cat "node_modules/package.json"')).toBe("node_modules/package.json") + expect(controller.validateCommand("cat 'secrets/api-keys.json'")).toBe("secrets/api-keys.json") + + // Multiple files in one command + expect(controller.validateCommand("cat README.md node_modules/index.js")).toBe("node_modules/index.js") + + // Nested command substitutions + expect(controller.validateCommand("echo $(echo $(cat .git/config))")).toBe(".git/config") + + // Mixed patterns + expect(controller.validateCommand("cat < node_modules/data.txt | grep pattern")).toBe( + "node_modules/data.txt", + ) + }) + + it("should handle xargs and find commands that might read files", () => { + // xargs with file reading commands + expect(controller.validateCommand("find . -name '*.json' | xargs cat")).toBeUndefined() // Can't determine specific files + expect(controller.validateCommand("echo node_modules/package.json | xargs cat")).toBeUndefined() // File path is in stdin, not command + + // find with -exec + expect(controller.validateCommand("find . -name 'package.json' -exec cat {} \\;")).toBeUndefined() // Can't determine specific files + }) + /** * Tests behavior when no .rooignore exists */ @@ -287,6 +381,8 @@ describe("RooIgnoreController", () => { // All commands should be allowed expect(emptyController.validateCommand("cat node_modules/package.json")).toBeUndefined() expect(emptyController.validateCommand("grep pattern .git/config")).toBeUndefined() + expect(emptyController.validateCommand("echo $(cat secrets/file)")).toBeUndefined() + expect(emptyController.validateCommand("wc < .git/config")).toBeUndefined() }) })