-
Notifications
You must be signed in to change notification settings - Fork 2.6k
Update list of default allowed commands #7404
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,296 @@ | ||
| import { describe, it, expect, vi, beforeEach, afterEach } from "vitest" | ||
| import * as vscode from "vscode" | ||
| import * as fs from "fs/promises" | ||
| import * as path from "path" | ||
| import { migrateSettings } from "../migrateSettings" | ||
|
|
||
| // Mock vscode module | ||
| vi.mock("vscode", () => ({ | ||
| window: { | ||
| showInformationMessage: vi.fn(), | ||
| createOutputChannel: vi.fn(() => ({ | ||
| appendLine: vi.fn(), | ||
| })), | ||
| }, | ||
| commands: { | ||
| executeCommand: vi.fn(), | ||
| }, | ||
| })) | ||
|
|
||
| // Mock fs module | ||
| vi.mock("fs/promises") | ||
|
|
||
| // Mock fs utils | ||
| vi.mock("../fs", () => ({ | ||
| fileExistsAtPath: vi.fn(), | ||
| })) | ||
|
|
||
| // Mock yaml module | ||
| vi.mock("yaml", () => ({ | ||
| parse: vi.fn((content) => JSON.parse(content)), | ||
| stringify: vi.fn((obj) => JSON.stringify(obj, null, 2)), | ||
| })) | ||
|
|
||
| describe("migrateSettings", () => { | ||
| let mockContext: any | ||
| let mockOutputChannel: any | ||
| let mockGlobalState: Map<string, any> | ||
|
|
||
| beforeEach(() => { | ||
| // Reset all mocks | ||
| vi.clearAllMocks() | ||
|
|
||
| // Create a mock global state | ||
| mockGlobalState = new Map() | ||
|
|
||
| // Create mock output channel | ||
| mockOutputChannel = { | ||
| appendLine: vi.fn(), | ||
| } | ||
|
|
||
| // Create mock context | ||
| mockContext = { | ||
| globalState: { | ||
| get: vi.fn((key: string) => mockGlobalState.get(key)), | ||
| update: vi.fn(async (key: string, value: any) => { | ||
| mockGlobalState.set(key, value) | ||
| }), | ||
| }, | ||
| globalStorageUri: { | ||
| fsPath: "/mock/storage/path", | ||
| }, | ||
| } | ||
| }) | ||
|
|
||
| afterEach(() => { | ||
| vi.restoreAllMocks() | ||
| }) | ||
|
|
||
| describe("default commands migration", () => { | ||
| it("should only run migration once", async () => { | ||
| // Set up initial state with old default commands | ||
| const initialCommands = ["npm install", "npm test", "tsc", "git log"] | ||
| mockGlobalState.set("allowedCommands", initialCommands) | ||
|
|
||
| // Mock file system | ||
| const { fileExistsAtPath } = await import("../fs") | ||
| vi.mocked(fileExistsAtPath).mockResolvedValue(false) | ||
|
|
||
| // Run migration first time | ||
| await migrateSettings(mockContext, mockOutputChannel) | ||
|
|
||
| // Check that old default commands were removed | ||
| expect(mockGlobalState.get("allowedCommands")).toEqual(["git log"]) | ||
|
|
||
| // Check that migration was marked as complete | ||
| expect(mockContext.globalState.update).toHaveBeenCalledWith("defaultCommandsMigrationCompleted", true) | ||
|
|
||
| // Reset mocks but keep the migration flag | ||
| mockGlobalState.set("defaultCommandsMigrationCompleted", true) | ||
| mockGlobalState.set("allowedCommands", ["npm install", "npm test"]) | ||
| vi.mocked(mockContext.globalState.update).mockClear() | ||
|
|
||
| // Run migration again | ||
| await migrateSettings(mockContext, mockOutputChannel) | ||
|
|
||
| // Verify commands were NOT modified the second time | ||
| expect(mockGlobalState.get("allowedCommands")).toEqual(["npm install", "npm test"]) | ||
| expect(mockContext.globalState.update).not.toHaveBeenCalled() | ||
| }) | ||
|
|
||
| it("should remove npm install, npm test, and tsc from allowed commands", async () => { | ||
| // Set up initial state with old default commands | ||
| const initialCommands = ["git log", "npm install", "npm test", "tsc", "git diff", "echo hello"] | ||
| mockGlobalState.set("allowedCommands", initialCommands) | ||
|
|
||
| // Mock file system to indicate no settings directory exists | ||
| const { fileExistsAtPath } = await import("../fs") | ||
| vi.mocked(fileExistsAtPath).mockResolvedValue(false) | ||
|
|
||
| // Run migration | ||
| await migrateSettings(mockContext, mockOutputChannel) | ||
|
|
||
| // Check that old default commands were removed | ||
| const updatedCommands = mockGlobalState.get("allowedCommands") | ||
| expect(updatedCommands).toEqual(["git log", "git diff", "echo hello"]) | ||
|
|
||
| // Verify the update was called | ||
| expect(mockContext.globalState.update).toHaveBeenCalledWith("allowedCommands", [ | ||
| "git log", | ||
| "git diff", | ||
| "echo hello", | ||
| ]) | ||
|
|
||
| // Verify migration was marked as complete | ||
| expect(mockContext.globalState.update).toHaveBeenCalledWith("defaultCommandsMigrationCompleted", true) | ||
|
|
||
| // No notification should be shown | ||
| expect(vscode.window.showInformationMessage).not.toHaveBeenCalled() | ||
| }) | ||
|
|
||
| it("should not remove commands with arguments (only exact matches)", async () => { | ||
| // Set up initial state with commands that have arguments | ||
| const initialCommands = [ | ||
| "npm install express", | ||
| "npm test --coverage", | ||
| "tsc --watch", | ||
| "npm list", | ||
| "npm view", | ||
| "yarn list", | ||
| "git status", | ||
| ] | ||
| mockGlobalState.set("allowedCommands", initialCommands) | ||
|
|
||
| // Mock file system | ||
| const { fileExistsAtPath } = await import("../fs") | ||
| vi.mocked(fileExistsAtPath).mockResolvedValue(false) | ||
|
|
||
| // Run migration | ||
| await migrateSettings(mockContext, mockOutputChannel) | ||
|
|
||
| // Check that commands with arguments were NOT removed (only exact matches are removed) | ||
| const updatedCommands = mockGlobalState.get("allowedCommands") | ||
| expect(updatedCommands).toEqual([ | ||
| "npm install express", | ||
| "npm test --coverage", | ||
| "tsc --watch", | ||
| "npm list", | ||
| "npm view", | ||
| "yarn list", | ||
| "git status", | ||
| ]) | ||
|
|
||
| // Migration should still be marked as complete | ||
| expect(mockContext.globalState.update).toHaveBeenCalledWith("defaultCommandsMigrationCompleted", true) | ||
|
|
||
| // No notification should be shown | ||
| expect(vscode.window.showInformationMessage).not.toHaveBeenCalled() | ||
| }) | ||
|
|
||
| it("should handle case-insensitive matching", async () => { | ||
| // Set up initial state with mixed case commands | ||
| const initialCommands = ["NPM INSTALL", "Npm Test", "TSC", "git log"] | ||
| mockGlobalState.set("allowedCommands", initialCommands) | ||
|
|
||
| // Mock file system | ||
| const { fileExistsAtPath } = await import("../fs") | ||
| vi.mocked(fileExistsAtPath).mockResolvedValue(false) | ||
|
|
||
| // Run migration | ||
| await migrateSettings(mockContext, mockOutputChannel) | ||
|
|
||
| // Check that unsafe commands were removed regardless of case | ||
| const updatedCommands = mockGlobalState.get("allowedCommands") | ||
| expect(updatedCommands).toEqual(["git log"]) | ||
| }) | ||
|
|
||
| it("should not modify commands if no old defaults are present", async () => { | ||
| // Set up initial state with only safe commands | ||
| const initialCommands = ["git log", "git diff", "ls -la", "echo hello"] | ||
| mockGlobalState.set("allowedCommands", initialCommands) | ||
|
|
||
| // Mock file system | ||
| const { fileExistsAtPath } = await import("../fs") | ||
| vi.mocked(fileExistsAtPath).mockResolvedValue(false) | ||
|
|
||
| // Run migration | ||
| await migrateSettings(mockContext, mockOutputChannel) | ||
|
|
||
| // Check that commands remain unchanged | ||
| const updatedCommands = mockGlobalState.get("allowedCommands") | ||
| expect(updatedCommands).toEqual(initialCommands) | ||
|
|
||
| // Verify no notification was shown | ||
| expect(vscode.window.showInformationMessage).not.toHaveBeenCalled() | ||
|
|
||
| // Verify migration was still marked as complete | ||
| expect(mockContext.globalState.update).toHaveBeenCalledWith("defaultCommandsMigrationCompleted", true) | ||
| }) | ||
|
|
||
| it("should handle missing or invalid allowedCommands gracefully", async () => { | ||
| // Test with no allowedCommands set | ||
| const { fileExistsAtPath } = await import("../fs") | ||
| vi.mocked(fileExistsAtPath).mockResolvedValue(false) | ||
|
|
||
| await migrateSettings(mockContext, mockOutputChannel) | ||
| // Should still mark migration as complete | ||
| expect(mockContext.globalState.update).toHaveBeenCalledWith("defaultCommandsMigrationCompleted", true) | ||
|
|
||
| // Verify appropriate log messages | ||
| expect(mockOutputChannel.appendLine).toHaveBeenCalledWith( | ||
| expect.stringContaining("marking migration as complete"), | ||
| ) | ||
| }) | ||
|
|
||
| it("should handle non-array allowedCommands gracefully", async () => { | ||
| // Test with non-array value | ||
| mockGlobalState.set("allowedCommands", "not an array") | ||
|
|
||
| const { fileExistsAtPath } = await import("../fs") | ||
| vi.mocked(fileExistsAtPath).mockResolvedValue(false) | ||
|
|
||
| await migrateSettings(mockContext, mockOutputChannel) | ||
|
|
||
| // Should still mark migration as complete | ||
| expect(mockContext.globalState.update).toHaveBeenCalledWith("defaultCommandsMigrationCompleted", true) | ||
|
|
||
| // Verify appropriate log messages | ||
| expect(mockOutputChannel.appendLine).toHaveBeenCalledWith( | ||
| expect.stringContaining("marking migration as complete"), | ||
| ) | ||
| }) | ||
|
|
||
| it("should handle errors gracefully", async () => { | ||
| // Set up state | ||
| mockGlobalState.set("allowedCommands", ["npm install"]) | ||
|
|
||
| // Make update throw an error | ||
| mockContext.globalState.update = vi.fn().mockRejectedValue(new Error("Update failed")) | ||
|
|
||
| // Mock file system | ||
| const { fileExistsAtPath } = await import("../fs") | ||
| vi.mocked(fileExistsAtPath).mockResolvedValue(false) | ||
|
|
||
| // Run migration - should not throw | ||
| await expect(migrateSettings(mockContext, mockOutputChannel)).resolves.toBeUndefined() | ||
|
|
||
| // Verify error was logged | ||
| expect(mockOutputChannel.appendLine).toHaveBeenCalledWith( | ||
| expect.stringContaining("[Default Commands Migration] Error"), | ||
| ) | ||
| }) | ||
|
|
||
| it("should only remove exact matches, not commands with arguments", async () => { | ||
| // Set up initial state with exact matches and commands with arguments | ||
| const initialCommands = [ | ||
| "npm install", // exact match - should be removed | ||
| "npm install --save-dev typescript", // has arguments - should NOT be removed | ||
| "npm test", // exact match - should be removed | ||
| "npm test --coverage", // has arguments - should NOT be removed | ||
| "tsc", // exact match - should be removed | ||
| "tsc --noEmit", // has arguments - should NOT be removed | ||
| "git log --oneline", | ||
| ] | ||
| mockGlobalState.set("allowedCommands", initialCommands) | ||
|
|
||
| // Mock file system | ||
| const { fileExistsAtPath } = await import("../fs") | ||
| vi.mocked(fileExistsAtPath).mockResolvedValue(false) | ||
|
|
||
| // Run migration | ||
| await migrateSettings(mockContext, mockOutputChannel) | ||
|
|
||
| // Check that only exact matches were removed | ||
| const updatedCommands = mockGlobalState.get("allowedCommands") | ||
| expect(updatedCommands).toEqual([ | ||
| "npm install --save-dev typescript", | ||
| "npm test --coverage", | ||
| "tsc --noEmit", | ||
| "git log --oneline", | ||
| ]) | ||
|
|
||
| // No notification should be shown | ||
| expect(vscode.window.showInformationMessage).not.toHaveBeenCalled() | ||
| }) | ||
| }) | ||
| }) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -8,14 +8,16 @@ import * as yaml from "yaml" | |
| const deprecatedCustomModesJSONFilename = "custom_modes.json" | ||
|
|
||
| /** | ||
| * Migrates old settings files to new file names | ||
| * Migrates old settings files to new file names and removes commands from old defaults | ||
| * | ||
| * TODO: Remove this migration code in September 2025 (6 months after implementation) | ||
| */ | ||
| export async function migrateSettings( | ||
| context: vscode.ExtensionContext, | ||
| outputChannel: vscode.OutputChannel, | ||
| ): Promise<void> { | ||
| // First, migrate commands from old defaults (security fix) | ||
| await migrateDefaultCommands(context, outputChannel) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is the order intentional here? The function now calls |
||
| // Legacy file names that need to be migrated to the new names in GlobalFileNames | ||
| const fileMigrations = [ | ||
| // custom_modes.json to custom_modes.yaml is handled separately below | ||
|
|
@@ -113,3 +115,57 @@ async function migrateCustomModesToYaml(settingsDir: string, outputChannel: vsco | |
| outputChannel.appendLine(`Error reading custom_modes.json: ${fileError}. Skipping migration.`) | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Removes commands from old defaults that could execute arbitrary code | ||
| * This addresses the security vulnerability where npm install/test can run malicious postinstall scripts | ||
| */ | ||
| async function migrateDefaultCommands( | ||
| context: vscode.ExtensionContext, | ||
| outputChannel: vscode.OutputChannel, | ||
| ): Promise<void> { | ||
| try { | ||
| // Check if this migration has already been run | ||
| const migrationKey = "defaultCommandsMigrationCompleted" | ||
| if (context.globalState.get(migrationKey)) { | ||
| outputChannel.appendLine("[Default Commands Migration] Migration already completed, skipping") | ||
| return | ||
| } | ||
|
|
||
| const allowedCommands = context.globalState.get<string[]>("allowedCommands") | ||
|
|
||
| if (!allowedCommands || !Array.isArray(allowedCommands)) { | ||
| // Mark migration as complete even if no commands to migrate | ||
| await context.globalState.update(migrationKey, true) | ||
| outputChannel.appendLine("No allowed commands found in global state, marking migration as complete") | ||
| return | ||
| } | ||
|
|
||
| // Only migrate the specific commands that were removed from the defaults | ||
| const oldDefaultCommands = ["npm install", "npm test", "tsc"] | ||
|
|
||
| // Filter out old default commands (case-insensitive exact match only) | ||
| const originalLength = allowedCommands.length | ||
| const filteredCommands = allowedCommands.filter((cmd) => { | ||
| const cmdLower = cmd.toLowerCase().trim() | ||
| return !oldDefaultCommands.some((oldDefault) => cmdLower === oldDefault.toLowerCase()) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Great implementation detail! The migration correctly only removes exact matches (e.g., 'npm install') while preserving commands with arguments (e.g., 'npm install express'). This is a thoughtful approach that maintains user flexibility while addressing the security concern. |
||
| }) | ||
|
|
||
| if (filteredCommands.length < originalLength) { | ||
| const removedCount = originalLength - filteredCommands.length | ||
| await context.globalState.update("allowedCommands", filteredCommands) | ||
|
|
||
| outputChannel.appendLine( | ||
| `[Default Commands Migration] Removed ${removedCount} command(s) from old defaults to prevent arbitrary code execution vulnerability`, | ||
| ) | ||
| } else { | ||
| outputChannel.appendLine("[Default Commands Migration] No old default commands found in allowed list") | ||
| } | ||
|
|
||
| // Mark migration as complete | ||
| await context.globalState.update(migrationKey, true) | ||
| outputChannel.appendLine("[Default Commands Migration] Migration marked as complete") | ||
| } catch (error) { | ||
| outputChannel.appendLine(`[Default Commands Migration] Error migrating default commands: ${error}`) | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Excellent test coverage! This test suite thoroughly covers edge cases including case-insensitive matching, commands with arguments, error handling, and the one-time migration flag. The test for ensuring exact matches only (lines 263-294) is particularly important for validating the security fix doesn't over-reach.