diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..df6f2ea --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,72 @@ +name: CI + +on: + push: + branches: + - main + pull_request: + branches: + - main + +jobs: + compile: + timeout-minutes: 10 + runs-on: ubuntu-latest + name: Compile + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set Node.js version to 24 + uses: actions/setup-node@v4 + with: + node-version: 24 + + - name: Install dependencies + run: npm install + + - name: Compile + run: npm run compile + + test: + timeout-minutes: 10 + runs-on: macos-latest + name: Test + needs: compile + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set Node.js version to 24 + uses: actions/setup-node@v4 + with: + node-version: 24 + + - name: Install dependencies + run: npm install + + - name: Run tests + run: npm test + + prettier: + timeout-minutes: 10 + runs-on: ubuntu-latest + name: Prettier Check + needs: compile + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set Node.js version to 24 + uses: actions/setup-node@v4 + with: + node-version: 24 + + - name: Install dependencies + run: npm install + + - name: Run Prettier check + run: npm run prettier-check diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 186459d..dde8236 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,8 +1,5 @@ { - // See http://go.microsoft.com/fwlink/?LinkId=827846 - // for the documentation about the extensions.json format - "recommendations": [ - "dbaeumer.vscode-eslint", - "ms-vscode.extension-test-runner" - ] + // See http://go.microsoft.com/fwlink/?LinkId=827846 + // for the documentation about the extensions.json format + "recommendations": ["dbaeumer.vscode-eslint", "ms-vscode.extension-test-runner"] } diff --git a/.vscode/launch.json b/.vscode/launch.json index 8880465..a0ca3cb 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -3,19 +3,15 @@ // Hover to view descriptions of existing attributes. // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 { - "version": "0.2.0", - "configurations": [ - { - "name": "Run Extension", - "type": "extensionHost", - "request": "launch", - "args": [ - "--extensionDevelopmentPath=${workspaceFolder}" - ], - "outFiles": [ - "${workspaceFolder}/out/**/*.js" - ], - "preLaunchTask": "${defaultBuildTask}" - } - ] + "version": "0.2.0", + "configurations": [ + { + "name": "Run Extension", + "type": "extensionHost", + "request": "launch", + "args": ["--extensionDevelopmentPath=${workspaceFolder}"], + "outFiles": ["${workspaceFolder}/out/**/*.js"], + "preLaunchTask": "${defaultBuildTask}" + } + ] } diff --git a/.vscode/settings.json b/.vscode/settings.json index afdab66..ffeaf91 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,11 +1,11 @@ // Place your settings in this file to overwrite default and user settings. { - "files.exclude": { - "out": false // set this to true to hide the "out" folder with the compiled JS files - }, - "search.exclude": { - "out": true // set this to false to include "out" folder in search results - }, - // Turn off tsc task auto detection since we have the necessary tasks as npm scripts - "typescript.tsc.autoDetect": "off" + "files.exclude": { + "out": false // set this to true to hide the "out" folder with the compiled JS files + }, + "search.exclude": { + "out": true // set this to false to include "out" folder in search results + }, + // Turn off tsc task auto detection since we have the necessary tasks as npm scripts + "typescript.tsc.autoDetect": "off" } diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 3b17e53..078ff7e 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -1,20 +1,20 @@ // See https://go.microsoft.com/fwlink/?LinkId=733558 // for the documentation about the tasks.json format { - "version": "2.0.0", - "tasks": [ - { - "type": "npm", - "script": "watch", - "problemMatcher": "$tsc-watch", - "isBackground": true, - "presentation": { - "reveal": "never" - }, - "group": { - "kind": "build", - "isDefault": true - } - } - ] + "version": "2.0.0", + "tasks": [ + { + "type": "npm", + "script": "watch", + "problemMatcher": "$tsc-watch", + "isBackground": true, + "presentation": { + "reveal": "never" + }, + "group": { + "kind": "build", + "isDefault": true + } + } + ] } diff --git a/.vscodeignore b/.vscodeignore index 7844cf6..95aa396 100644 --- a/.vscodeignore +++ b/.vscodeignore @@ -1,10 +1,9 @@ .vscode/** .vscode-test/** src/** +tests/** !src/assets/** .gitignore -.yarnrc -vsc-extension-quickstart.md **/tsconfig.json **/eslint.config.mjs **/*.map diff --git a/CHANGELOG.md b/CHANGELOG.md index 6148b03..430d11e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,31 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.1.4] - 2026-02-10 + +### Added + +- Added `enableStrictSecretScanning` setting (and corresponding `toggleStrictSecretScanning` command) that, when enabled, makes secret scanning skip files that don't have a common secret indicator (like "API_SECRET"). +- Added `.svelte`, `.txt`, and `.toml` to the default list of scanned file extensions. + +### Changed + +- GitGerbil will now wait for up to 5 seconds to detect a git repo when activating instead of immediately failing. +- File extensions like `.test.ts` will be correctly detected as `.ts` files now and scanned if the base extension is in the list of scanned file types. +- Submitting an empty field when running `gitgerbil.setScannedFileTypes` will now reset to the default list of scanned file types instead of an empty list. +- Updated the README to mention `gitgerbil-ignore-file`. +- `.env.example` files will no longer be flagged by file path scanning. +- Fixed file name detection for files nested in subdirectories. + +### Removed + +- Replaced `enable` and `disable` commands (i.e. `gitgerbil.enableSecretScanning`) with `toggle` commands. + - `gitgerbil.toggleFilePathScanning` + - `gitgerbil.toggleSecretScanning` + - `gitgerbil.toggleStrictSecretScanning` + - `gitgerbil.toggleCommentScanning` +- Removed SQL from comment scanning. + ## [0.1.3] - 2026-02-08 ### Added diff --git a/README.md b/README.md index de23a33..687f71d 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,8 @@ Also also scans for TODO or FIXME comments in your code and gives a friendly rem > \[!TIP\] > > GitGerbil can have false positives. To ignore a line, add `// gitgerbil-ignore-line` above it (or whatever comment syntax your language uses). +> +> Or to ignore an entire file, add `// gitgerbil-ignore-file` at the top of the file. ## Extension Settings diff --git a/package.json b/package.json index 27baee4..8aa032a 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "gitgerbil", "displayName": "GitGerbil", "description": "Scan your project for potential secrets, sensitive information, and less-than-ideal files that you probably shouldn't commit.", - "version": "0.1.3", + "version": "0.1.4", "publisher": "KennethNg", "icon": "./src/assets/icon.png", "repository": { @@ -22,8 +22,11 @@ "vscode:prepublish": "npm run compile", "compile": "tsc -p ./", "watch": "tsc -watch -p ./", + "prettier-check": "prettier . --check", + "unit-test": "mocha ./out/tests/unit", + "integration-test": "node ./out/tests/runTests.js", "pretest": "npm run compile", - "test": "node ./out/test/runTests.js" + "test": "npm run unit-test && npm run integration-test" }, "devDependencies": { "@types/mocha": "^10.0.10", @@ -31,7 +34,7 @@ "@types/vscode": "^1.90.0", "@vscode/test-electron": "^2.5.2", "mocha": "^11.7.5", - "tsx": "^4.21.0", + "prettier": "^3.8.1", "typescript": "^5.8.3" }, "extensionDependencies": [ @@ -50,6 +53,7 @@ "jsx", "tsx", "vue", + "svelte", "py", "rb", "go", @@ -67,7 +71,9 @@ "json", "yaml", "yml", - "md" + "md", + "txt", + "toml" ], "description": "List of file extensions that will be scanned if any scanning options are enabled. Dotfiles are automatically included and do not need to be specified here." }, @@ -81,6 +87,11 @@ "default": true, "description": "Enable or disable secret scanning in tracked git files. If enabled, errors will be shown where potential secrets are found." }, + "gitgerbil.enableStrictSecretScanning": { + "type": "boolean", + "default": true, + "description": "Enable or disable strict secret scanning in tracked git files. If enabled, secret scanning will only run if potential secret indicators are found in the content. Does nothing if secret scanning is disabled." + }, "gitgerbil.enableCommentScanning": { "type": "boolean", "default": true, @@ -94,28 +105,20 @@ "title": "GitGerbil: Set Scanned File Types" }, { - "command": "gitgerbil.enableFilePathScanning", - "title": "GitGerbil: Enable File Path Scanning" - }, - { - "command": "gitgerbil.disableFilePathScanning", - "title": "GitGerbil: Disable File Path Scanning" - }, - { - "command": "gitgerbil.enableSecretScanning", - "title": "GitGerbil: Enable Secret Scanning" + "command": "gitgerbil.toggleFilePathScanning", + "title": "GitGerbil: Toggle File Path Scanning" }, { - "command": "gitgerbil.disableSecretScanning", - "title": "GitGerbil: Disable Secret Scanning" + "command": "gitgerbil.toggleSecretScanning", + "title": "GitGerbil: Toggle Secret Scanning" }, { - "command": "gitgerbil.enableCommentScanning", - "title": "GitGerbil: Enable Comment Scanning" + "command": "gitgerbil.toggleStrictSecretScanning", + "title": "GitGerbil: Toggle Strict Secret Scanning" }, { - "command": "gitgerbil.disableCommentScanning", - "title": "GitGerbil: Disable Comment Scanning" + "command": "gitgerbil.toggleCommentScanning", + "title": "GitGerbil: Toggle Comment Scanning" } ] } diff --git a/src/commands.ts b/src/commands.ts index 0b51fdf..77f73de 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -1,3 +1,4 @@ +import { defaultScannedFiles } from "./extension"; import * as vscode from "vscode"; export async function handleScannedFileTypes(): Promise { @@ -9,49 +10,47 @@ export async function handleScannedFileTypes(): Promise { prompt: "Enter file extensions to scan, separated by commas", value: value.join(", "), validateInput: (value) => { + if (!value) return; const extensions = value.split(",").map((ext) => ext.trim()); if (extensions.some((ext) => !/^[a-zA-Z0-9]+$/.test(ext))) return "File extensions must be alphanumeric and cannot contain dots or spaces"; } }); - if (!input) return; + if (input === undefined) return; + + const newValue = input.length === 0 ? (defaultScannedFiles as unknown as string[]) : input.split(",").map((ext) => ext.trim()); - const newValue = input.split(",").map((ext) => ext.trim()); await config.update("scannedFileTypes", newValue, vscode.ConfigurationTarget.Global); vscode.window.showInformationMessage(`Scanned file extensions updated.`); } -export async function enableFilePathScanning(): Promise { +export async function toggleFilePathScanning(): Promise { const config = vscode.workspace.getConfiguration("gitgerbil"); - await config.update("enableFilePathScanning", true, vscode.ConfigurationTarget.Global); - await vscode.window.showInformationMessage("File path scanning enabled."); -} + const newValue = !config.get("enableFilePathScanning"); -export async function enableSecretScanning(): Promise { - const config = vscode.workspace.getConfiguration("gitgerbil"); - await config.update("enableSecretScanning", true, vscode.ConfigurationTarget.Global); - await vscode.window.showInformationMessage("Secret scanning enabled."); + await config.update("enableFilePathScanning", newValue, vscode.ConfigurationTarget.Global); + await vscode.window.showInformationMessage(`File path scanning ${newValue ? "enabled" : "disabled"}.`); } -export async function enableCommentScanning(): Promise { +export async function toggleSecretScanning(): Promise { const config = vscode.workspace.getConfiguration("gitgerbil"); - await config.update("enableCommentScanning", true, vscode.ConfigurationTarget.Global); - await vscode.window.showInformationMessage("Comment scanning enabled."); -} + const newValue = !config.get("enableSecretScanning"); -export async function disableFilePathScanning(): Promise { - const config = vscode.workspace.getConfiguration("gitgerbil"); - await config.update("enableFilePathScanning", false, vscode.ConfigurationTarget.Global); - await vscode.window.showInformationMessage("File path scanning disabled."); + await config.update("enableSecretScanning", newValue, vscode.ConfigurationTarget.Global); + await vscode.window.showInformationMessage(`Secret scanning ${newValue ? "enabled" : "disabled"}.`); } -export async function disableSecretScanning(): Promise { +export async function toggleStrictSecretScanning(): Promise { const config = vscode.workspace.getConfiguration("gitgerbil"); - await config.update("enableSecretScanning", false, vscode.ConfigurationTarget.Global); - await vscode.window.showInformationMessage("Secret scanning disabled."); + const newValue = !config.get("enableStrictSecretScanning"); + + await config.update("enableStrictSecretScanning", newValue, vscode.ConfigurationTarget.Global); + await vscode.window.showInformationMessage(`Strict secret scanning ${newValue ? "enabled" : "disabled"}.`); } -export async function disableCommentScanning(): Promise { +export async function toggleCommentScanning(): Promise { const config = vscode.workspace.getConfiguration("gitgerbil"); - await config.update("enableCommentScanning", false, vscode.ConfigurationTarget.Global); - await vscode.window.showInformationMessage("Comment scanning disabled."); + const newValue = !config.get("enableCommentScanning"); + + await config.update("enableCommentScanning", newValue, vscode.ConfigurationTarget.Global); + await vscode.window.showInformationMessage(`Comment scanning ${newValue ? "enabled" : "disabled"}.`); } diff --git a/src/extension.ts b/src/extension.ts index 7258109..aaef558 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,14 +1,16 @@ import { checkComments, scanSecretKeys, validateFileName, type LineRange } from "./validate"; -import type { GitExtension, Repository } from "./types/git"; +import type { API, GitExtension, Repository } from "./types/git"; import * as commands from "./commands"; import * as vscode from "vscode"; -const defaultScannedFiles = ["ts", "js", "jsx", "tsx", "vue", "py", "rb", "go", "java", "php", "cs", "cpp", "c", "h", "rs", "html", "css", "scss", "less", "json", "yaml", "yml", "md"] as const; +// prettier-ignore +export const defaultScannedFiles = ["ts", "js", "jsx", "tsx", "vue", "svelte", "py", "rb", "go", "java", "php", "cs", "cpp", "c", "h", "rs", "html", "css", "scss", "less", "json", "yaml", "yml", "md", "txt", "toml"] as const; const ignoredFiles = new Set(["package-lock.json", "yarn.lock", "pnpm-lock.yaml", "Cargo.lock", "Gemfile.lock", "go.sum"]); const scannedFiles = new Set(); const scanningOptions = { filePathScanning: true, secretScanning: true, + strictSecretScanning: true, commentScanning: true }; let diagnostics: vscode.DiagnosticCollection; @@ -43,21 +45,20 @@ async function checkFile(repo: Repository, uri: vscode.Uri): Promise { if (ignoredFiles.has(uri.fsPath.split("/").pop() ?? "")) return diagnostics.delete(uri); if ((await repo.checkIgnore([uri.fsPath])).size) return diagnostics.delete(uri); // ? if not a dotfile and the extension isnt in scannedFiles - if (!/^\.[^./\\]+$/.test(uri.fsPath.split("/").pop() ?? "") && !scannedFiles.has(/^[^.]+\.([^.]+)$/.exec(uri.fsPath)?.[1] ?? "")) return diagnostics.delete(uri); + if (!/^\.[^./\\]+$/.test(uri.fsPath.split("/").pop() ?? "") && !scannedFiles.has(uri.fsPath.split(".").pop() ?? "")) return diagnostics.delete(uri); const buffer = await vscode.workspace.fs.readFile(uri); const content = Buffer.from(buffer).toString("utf-8"); - const contentLines = content.split("\n"); const fileDiagnostics: vscode.Diagnostic[] = []; - const fileIsIgnored = contentLines[0]?.includes("gitgerbil-ignore-file"); + const fileIsIgnored = content.split("\n")[0]?.includes("gitgerbil-ignore-file"); if (!fileIsIgnored && scanningOptions.filePathScanning) { const fileNameViolation = validateFileName(uri); if (fileNameViolation) fileDiagnostics.push(createDiagnostic(`${fileNameViolation === 1 ? "File" : "Folder"} name matches a sensitive pattern`, vscode.DiagnosticSeverity.Warning)); } if (!fileIsIgnored && scanningOptions.secretScanning) { - const secretResults = scanSecretKeys(contentLines); + const secretResults = scanSecretKeys(content, scanningOptions.strictSecretScanning); if (secretResults.length) fileDiagnostics.push(...secretResults.map(([range, message]) => createDiagnostic(message, vscode.DiagnosticSeverity.Error, range))); } if (!fileIsIgnored && scanningOptions.commentScanning) { @@ -69,7 +70,15 @@ async function checkFile(repo: Repository, uri: vscode.Uri): Promise { else diagnostics.delete(uri); } -export function activate(context: vscode.ExtensionContext) { +async function waitForGitRepo(git: API, timeout = 5000) { + const start = Date.now(); + while (git.repositories.length === 0) { + if (Date.now() - start > timeout) throw new Error("Git repository not detected"); + await new Promise((res) => setTimeout(res, 100)); + } +} + +export async function activate(context: vscode.ExtensionContext) { const gitExtension = vscode.extensions.getExtension("vscode.git")?.exports; if (!gitExtension) throw new Error("Git extension not found"); @@ -77,6 +86,7 @@ export function activate(context: vscode.ExtensionContext) { context.subscriptions.push(diagnostics); const git = gitExtension.getAPI(1); + await waitForGitRepo(git); const repo = git.repositories[0]; const scanningConfig = vscode.workspace.getConfiguration("gitgerbil"); @@ -85,6 +95,7 @@ export function activate(context: vscode.ExtensionContext) { scanningOptions.filePathScanning = scanningConfig.get("enableFilePathScanning", scanningOptions.filePathScanning); scanningOptions.secretScanning = scanningConfig.get("enableSecretScanning", scanningOptions.secretScanning); scanningOptions.commentScanning = scanningConfig.get("enableCommentScanning", scanningOptions.commentScanning); + scanningOptions.strictSecretScanning = scanningConfig.get("enableStrictSecretScanning", scanningOptions.strictSecretScanning); // * check settings context.subscriptions.push( @@ -100,6 +111,8 @@ export function activate(context: vscode.ExtensionContext) { } else if (event.affectsConfiguration("gitgerbil.enableFilePathScanning")) scanningOptions.filePathScanning = config.get("enableFilePathScanning", scanningOptions.filePathScanning); else if (event.affectsConfiguration("gitgerbil.enableSecretScanning")) scanningOptions.secretScanning = config.get("enableSecretScanning", scanningOptions.secretScanning); else if (event.affectsConfiguration("gitgerbil.enableCommentScanning")) scanningOptions.commentScanning = config.get("enableCommentScanning", scanningOptions.commentScanning); + else if (event.affectsConfiguration("gitgerbil.enableStrictSecretScanning")) + scanningOptions.strictSecretScanning = config.get("enableStrictSecretScanning", scanningOptions.strictSecretScanning); const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; if (workspaceFolder) checkAllFiles(workspaceFolder.uri); @@ -109,17 +122,12 @@ export function activate(context: vscode.ExtensionContext) { // * commands context.subscriptions.push( vscode.commands.registerCommand("gitgerbil.setScannedFileTypes", commands.handleScannedFileTypes), - vscode.commands.registerCommand("gitgerbil.enableFilePathScanning", commands.enableFilePathScanning), - vscode.commands.registerCommand("gitgerbil.enableSecretScanning", commands.enableSecretScanning), - vscode.commands.registerCommand("gitgerbil.enableCommentScanning", commands.enableCommentScanning), - vscode.commands.registerCommand("gitgerbil.disableFilePathScanning", commands.disableFilePathScanning), - vscode.commands.registerCommand("gitgerbil.disableSecretScanning", commands.disableSecretScanning), - vscode.commands.registerCommand("gitgerbil.disableCommentScanning", commands.disableCommentScanning) + vscode.commands.registerCommand("gitgerbil.toggleFilePathScanning", commands.toggleFilePathScanning), + vscode.commands.registerCommand("gitgerbil.toggleSecretScanning", commands.toggleSecretScanning), + vscode.commands.registerCommand("gitgerbil.toggleCommentScanning", commands.toggleCommentScanning), + vscode.commands.registerCommand("gitgerbil.toggleStrictSecretScanning", commands.toggleStrictSecretScanning) ); - // TODO: add unit tests - if (!repo) throw new Error("No git repository found in the workspace"); - async function checkAllFiles(path: vscode.Uri) { const directory = await vscode.workspace.fs.readDirectory(path); @@ -136,10 +144,6 @@ export function activate(context: vscode.ExtensionContext) { } } - // * check all files on actviation - const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; - if (workspaceFolder) checkAllFiles(workspaceFolder.uri); - // * add watchers const watchFn = (uri: vscode.Uri) => { if (uri.path.includes(".gitignore") && workspaceFolder) checkAllFiles(workspaceFolder.uri); @@ -149,4 +153,8 @@ export function activate(context: vscode.ExtensionContext) { watcher.onDidChange(watchFn); watcher.onDidCreate(watchFn); context.subscriptions.push(watcher); + + // * check all files on actviation + const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; + if (workspaceFolder) await checkAllFiles(workspaceFolder.uri); } diff --git a/src/tests/integration/extension.test.ts b/src/tests/integration/extension.test.ts new file mode 100644 index 0000000..9cd771d --- /dev/null +++ b/src/tests/integration/extension.test.ts @@ -0,0 +1,262 @@ +// gitgerbil-ignore-file + +import { defaultScannedFiles } from "../../extension"; +import { execSync } from "child_process"; +import { describe } from "mocha"; +import assert from "node:assert"; +import * as vscode from "vscode"; +import path from "path"; +import fs from "fs"; + +async function activateExtension() { + const extension = vscode.extensions.getExtension("KennethNg.gitgerbil"); + assert.ok(extension, "Extension not found"); + + await extension.activate(); + assert.ok(extension.isActive, "Extension failed to activate"); +} + +async function waitForDiagnostic(fileName: string, callback: (diagnostics: vscode.Diagnostic[]) => void) { + return new Promise((resolve) => { + const listener = vscode.languages.onDidChangeDiagnostics((event) => { + const uri = event.uris.find((uri) => uri.fsPath.endsWith(fileName)); + if (!uri) return; + + const diagnostics = vscode.languages.getDiagnostics(uri); + callback(diagnostics); + listener.dispose(); + resolve(); + }); + }); +} + +const redkitten6sSupabaseKey = + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImZlbG1qa2VxZWttaGt4c3JycndiIiwicm9sZSI6ImFub24iLCJpYXQiOjE3Njg1NzE4NTksImV4cCI6MjA4NDE0Nzg1OX0.cbspqjrqcdDkREd3tOlS2TcjknjIzUUeIcX_t8eNYfE"; +const redkitten6sYouTubeKey = "AIzaSyAVQKyYxMrhgHWR8f9LJms0GVpcufhMLwc"; + +describe("Extension Tests", function () { + const workspace = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; + assert.ok(workspace, "No workspace found"); + + const createFiles = (files: { name: string; content: string }[]) => { + const folder = fs.mkdtempSync(path.join(workspace, "test-")); + + for (const file of files) { + const filePath = path.join(folder, file.name); + fs.writeFileSync(filePath, file.content); + } + + return folder; + }; + + test("should activate extension", async function () { + await activateExtension(); + }); + + describe("File Path Scanning", function () { + test("should give diagnostic when file path is sensitive", async function () { + const folder = createFiles([{ name: ".env", content: "super secret env variables hehe\n" }]); + + await waitForDiagnostic(`${folder}/.env`, (diagnostics) => { + assert.strictEqual(diagnostics.length, 1, "Expected one diagnostic for unsafe file"); + }); + }); + + test("should not give diagnostics when no file path issues", async function () { + const folder = createFiles([{ name: "README.md", content: "blah blah blah\n\nnothing to see here\n" }]); + + await waitForDiagnostic(`${folder}/README.md`, (diagnostics) => { + assert.strictEqual(diagnostics.length, 0, "Expected no diagnostics for safe file"); + }); + }); + + test("should not give diagnostics when sensitive file is .gitignore'd", async function () { + const folder = createFiles([ + { name: ".gitignore", content: "*.env\n" }, + { name: ".env", content: "super secret env variables hehe\n" } + ]); + + await waitForDiagnostic(`${folder}/.env`, (diagnostics) => { + assert.strictEqual(diagnostics.length, 0, "Expected no diagnostics for .gitignore'd sensitive file"); + }); + }); + + test("should not give diagnostics if file path scanning disabled", async function () { + const folder = createFiles([{ name: ".env", content: "# gitgerbil-ignore-file\n\nsuper secret env variables hehe\n" }]); + + await waitForDiagnostic(`${folder}/.env`, (diagnostics) => { + assert.strictEqual(diagnostics.length, 0, "Expected no diagnostics when file path scanning disabled"); + }); + }); + }); + + describe("Secret Scanning", function () { + test("should give diagnostics when secret in file", async function () { + const folder = createFiles([ + { + name: "CLAUDE.md", + content: `blah blah blah\n\nnothing to see here\n\n\n\nAPI_KEY=${redkitten6sSupabaseKey}\n` + } + ]); + + await waitForDiagnostic(`${folder}/CLAUDE.md`, (diagnostics) => { + assert.strictEqual(diagnostics.length, 1, "Expected one diagnostic for one secret in file"); + }); + }); + + test("should give multiple diagnostics when multiple secrets in file", async function () { + const folder = createFiles([ + { + name: "CLAUDE.md", + content: `blah blah blah\n\nnothing to see here\n\n\n\nAPI_KEY=${redkitten6sSupabaseKey}\nAPI_KEY=${redkitten6sSupabaseKey}\nAPI_KEY=${redkitten6sYouTubeKey}\n\nAPI_KEY=${redkitten6sYouTubeKey}\n` + } + ]); + + await waitForDiagnostic(`${folder}/CLAUDE.md`, (diagnostics) => { + assert.strictEqual(diagnostics.length, 4, "Expected multiple diagnostics for multiple secrets in file"); + }); + }); + + test("should not give diagnostics when no secret in file", async function () { + const folder = createFiles([{ name: "README.md", content: "blah blah blah\n\nnothing to see here\n" }]); + + await waitForDiagnostic(`${folder}/README.md`, (diagnostics) => { + assert.strictEqual(diagnostics.length, 0, "Expected no diagnostics for safe file"); + }); + }); + + test("should not give diagnostics for ignored file type", async function () { + const folder = createFiles([ + { + name: "CLAUDE.md", + content: `blah blah blah\n\nnothing to see here\n\n\n\nAPI_KEY=${redkitten6sSupabaseKey}\n` + } + ]); + + vscode.workspace.getConfiguration("gitgerbil").update("scannedFileTypes", [], vscode.ConfigurationTarget.Global); + + await waitForDiagnostic(`${folder}/CLAUDE.md`, (diagnostics) => { + assert.strictEqual(diagnostics.length, 0, "Expected no diagnostics for ignored file"); + }); + }); + + test("should not give diagnostics for .gitignore'd file type", async function () { + const folder = createFiles([ + { + name: ".gitignore", + content: "*.md\n" + }, + { + name: "CLAUDE.md", + content: `blah blah blah\n\nnothing to see here\n\n\n\nAPI_KEY=${redkitten6sSupabaseKey}\n` + } + ]); + + await waitForDiagnostic(`${folder}/CLAUDE.md`, (diagnostics) => { + assert.strictEqual(diagnostics.length, 0, "Expected no diagnostics for .gitignore'd file"); + }); + }); + + test("should not give diagnostics if secret scanning disabled", async function () { + const folder = createFiles([ + { + name: "CLAUDE.md", + content: `blah blah blah\n\nnothing to see here\n\n\n\nAPI_KEY=${redkitten6sSupabaseKey}\n` + } + ]); + + vscode.workspace.getConfiguration("gitgerbil").update("enableSecretScanning", false, vscode.ConfigurationTarget.Global); + + await waitForDiagnostic(`${folder}/CLAUDE.md`, (diagnostics) => { + assert.strictEqual(diagnostics.length, 0, "Expected no diagnostics when secret scanning disabled"); + }); + }); + + test("should not give diagnostics if strict secret scanning enabled and no indicators in file", async function () { + const folder = createFiles([ + { + name: "CLAUDE.md", + content: `blah blah blah\n\nnothing to see here\n\n\n\n${redkitten6sSupabaseKey}\n` + } + ]); + + await waitForDiagnostic(`${folder}/CLAUDE.md`, (diagnostics) => { + assert.strictEqual(diagnostics.length, 0, "Expected no diagnostics when strict secret scanning enabled and no indicators in file"); + }); + }); + }); + + describe("Comment Scanning", function () { + test("should give diagnostics when hint comment in file", async function () { + const folder = createFiles([ + { + name: "index.ts", + content: `// TODO: commit api key\nconst apiKey = "";\n` + } + ]); + + await waitForDiagnostic(`${folder}/index.ts`, (diagnostics) => { + assert.strictEqual(diagnostics.length, 1, "Expected one diagnostic for one hint comment"); + }); + }); + + test("should give multiple diagnostics when multiple hint comments in file", async function () { + const folder = createFiles([ + { + name: "index.ts", + content: `// TODO: commit api key\nconst apiKey = "";\n\n// FIXME: commit other api key\nconst otherApiKey = "";\n` + } + ]); + + await waitForDiagnostic(`${folder}/index.ts`, (diagnostics) => { + assert.strictEqual(diagnostics.length, 2, "Expected multiple diagnostics for multiple hint comments"); + }); + }); + + test("should not give diagnostics when no hint comment in file", async function () { + const folder = createFiles([{ name: "index.ts", content: `const apiKey = "";\n` }]); + + await waitForDiagnostic(`${folder}/index.ts`, (diagnostics) => { + assert.strictEqual(diagnostics.length, 0, "Expected no diagnostics for file without hint comments"); + }); + }); + + test("should not give diagnostics for ignored file type", async function () { + const folder = createFiles([ + { + name: "index.ts", + content: `// TODO: commit api key\nconst apiKey = "";\n` + } + ]); + + vscode.workspace.getConfiguration("gitgerbil").update("scannedFileTypes", [], vscode.ConfigurationTarget.Global); + + await waitForDiagnostic(`${folder}/index.ts`, (diagnostics) => { + assert.strictEqual(diagnostics.length, 0, "Expected no diagnostics for ignored file"); + }); + }); + + test("should not give diagnostics if comment scanning disabled", async function () { + const folder = createFiles([ + { + name: "index.ts", + content: `// TODO: commit api key\nconst apiKey = "";\n` + } + ]); + + vscode.workspace.getConfiguration("gitgerbil").update("enableCommentScanning", false, vscode.ConfigurationTarget.Global); + + await waitForDiagnostic(`${folder}/index.ts`, (diagnostics) => { + assert.strictEqual(diagnostics.length, 0, "Expected no diagnostics when comment scanning disabled"); + }); + }); + }); + + this.afterEach(function () { + vscode.workspace.getConfiguration("gitgerbil").update("scannedFileTypes", defaultScannedFiles, vscode.ConfigurationTarget.Global); + vscode.workspace.getConfiguration("gitgerbil").update("enableFilePathScanning", true, vscode.ConfigurationTarget.Global); + vscode.workspace.getConfiguration("gitgerbil").update("enableSecretScanning", true, vscode.ConfigurationTarget.Global); + vscode.workspace.getConfiguration("gitgerbil").update("enableStrictSecretScanning", true, vscode.ConfigurationTarget.Global); + vscode.workspace.getConfiguration("gitgerbil").update("enableCommentScanning", true, vscode.ConfigurationTarget.Global); + }); +}); diff --git a/src/tests/integration/index.ts b/src/tests/integration/index.ts new file mode 100644 index 0000000..c27610d --- /dev/null +++ b/src/tests/integration/index.ts @@ -0,0 +1,23 @@ +import * as path from "path"; +import { glob } from "glob"; +import Mocha from "mocha"; + +export function run(): Promise { + const mocha = new Mocha({ + ui: "tdd", + color: true + }); + + const testsRoot = path.resolve(__dirname); + + return new Promise(async (resolve, reject) => { + const files = await glob("**/*.test.js", { cwd: testsRoot }); + files.forEach((file) => mocha.addFile(path.resolve(testsRoot, file))); + + try { + mocha.run((failures) => (failures > 0 ? reject(new Error(`${failures} tests failed.`)) : resolve())); + } catch (err) { + reject(err); + } + }); +} diff --git a/src/tests/runTests.ts b/src/tests/runTests.ts new file mode 100644 index 0000000..657c1bf --- /dev/null +++ b/src/tests/runTests.ts @@ -0,0 +1,33 @@ +import { runTests } from "@vscode/test-electron"; +import { execSync } from "child_process"; +import * as path from "path"; +import fs from "fs"; +import os from "os"; + +async function main() { + try { + const extensionDevelopmentPath = path.resolve(__dirname, "../../"); + const extensionTestsPath = path.resolve(__dirname, "./integration/index"); + + const repoPath = fs.mkdtempSync(path.join(os.tmpdir(), "gitgerbil-test-repo-")); + execSync("git init", { cwd: repoPath }); + execSync('git config user.email "red@kitten.six"', { cwd: repoPath }); + execSync('git config user.name "RedKitten6"', { cwd: repoPath }); + + await runTests({ + extensionDevelopmentPath, + extensionTestsPath, + launchArgs: ["--disable-workspace-trust", repoPath] + }); + + fs.glob(repoPath, (error, matches) => { + if (error) return; + for (const folder of matches) fs.rmSync(folder, { recursive: true, force: true }); + }); + } catch (error) { + console.error("Failed to run tests", error); + process.exit(1); + } +} + +main(); diff --git a/src/tests/unit/comment.test.ts b/src/tests/unit/comment.test.ts new file mode 100644 index 0000000..6438a11 --- /dev/null +++ b/src/tests/unit/comment.test.ts @@ -0,0 +1,35 @@ +import { checkComments } from "../../validate"; +import { describe, test } from "mocha"; +import assert from "node:assert"; + +describe("Comment Scanning", function () { + test("should detect a singleline comment", function () { + const content = `// TODO: this is a comment\nconst x = 6;\n`; + assert.strictEqual(checkComments(content).length, 1); + }); + + test("should detect multiple singleline comments", function () { + const content = `// TODO: this is a comment\nconst x = 6;\n\n# HACK: this is definitely not syntactically correct\ny = 7;\n`; + assert.strictEqual(checkComments(content).length, 2); + }); + + test("should detect a multiline comment", function () { + const content = `/* FIXME: this is a\n multiline comment\n */\nconst x = 6;\n`; + assert.strictEqual(checkComments(content).length, 1); + }); + + test("should detect multiple multiline comments", function () { + const content = `/* FIXME: this is a\n multiline comment\n */\nconst x = 6;\n\n\ny = 7;\n`; + assert.strictEqual(checkComments(content).length, 2); + }); + + test("should not detect comments without hints", function () { + const content = `// This is a normal comment\nconst x = 6;\n`; + assert.strictEqual(checkComments(content).length, 0); + }); + + test("should ignore lines with gitgerbil-ignore-line", function () { + const content = `// gitgerbil-ignore-line\nTODO: add a 7\nconst x = 6;\n`; + assert.strictEqual(checkComments(content).length, 0); + }); +}); diff --git a/src/tests/unit/path.test.ts b/src/tests/unit/path.test.ts new file mode 100644 index 0000000..38465d3 --- /dev/null +++ b/src/tests/unit/path.test.ts @@ -0,0 +1,48 @@ +import { validateFileName } from "../../validate"; +import { describe, test } from "mocha"; +import assert from "node:assert"; + +describe("File Name Validation", function () { + test("should flag file names that match sensitive patterns", function () { + const violations = [".env", ".env.local", ".env.development", ".env.production", ".env.test", "creds.pem"]; + + for (const fileName of violations) { + assert.strictEqual(validateFileName({ fsPath: fileName } as any), 1, `Expected "${fileName}" to be flagged as a file name violation`); + } + }); + + test("should flag folder names that match sensitive patterns", function () { + const violations = [ + "node_modules/.bin/v-lint", + "node_modules/@kennething/v-lint/src/index.js", + "dist/index.js", + "build/vlint.wasm", + "out/hello", + "bin/hello", + "tmp/test.txt", + "logs/log.txt", + ".venv/Scripts/activate", + "__pycache__/index.cpython-310.pyc" + ]; + + for (const fileName of violations) { + assert.strictEqual(validateFileName({ fsPath: fileName } as any), 2, `Expected "${fileName}" to be flagged as a folder name violation`); + } + }); + + test("should not flag safe file names", function () { + const safeFileNames = ["index.js", "app.py", "README.md", "src/configuration.ts"]; + + for (const fileName of safeFileNames) { + assert.strictEqual(validateFileName({ fsPath: fileName } as any), 0, `Expected "${fileName}" to not be flagged as a file name violation`); + } + }); + + test("should not flag files in safe folders", function () { + const safeFilePaths = ["src/index.js", "lib/app.py", "docs/README.md"]; + + for (const filePath of safeFilePaths) { + assert.strictEqual(validateFileName({ fsPath: filePath } as any), 0, `Expected "${filePath}" to not be flagged as a folder name violation`); + } + }); +}); diff --git a/src/tests/unit/secret.test.ts b/src/tests/unit/secret.test.ts new file mode 100644 index 0000000..a7761cd --- /dev/null +++ b/src/tests/unit/secret.test.ts @@ -0,0 +1,51 @@ +// gitgerbil-ignore-file + +import { scanSecretKeys } from "../../validate"; +import { describe, test } from "mocha"; +import assert from "node:assert"; + +const redkitten6sSupabaseKey = + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImZlbG1qa2VxZWttaGt4c3JycndiIiwicm9sZSI6ImFub24iLCJpYXQiOjE3Njg1NzE4NTksImV4cCI6MjA4NDE0Nzg1OX0.cbspqjrqcdDkREd3tOlS2TcjknjIzUUeIcX_t8eNYfE"; +const redkitten6sYouTubeKey = "AIzaSyAVQKyYxMrhgHWR8f9LJms0GVpcufhMLwc"; + +describe("Secret Detection", function () { + test("should detect a valid secret", function () { + const content = `API_KEY=${redkitten6sSupabaseKey}`; + assert.strictEqual(scanSecretKeys(content).length, 1); + }); + + test("should detect a valid secret with other text", function () { + const content = `const apiKey = [({"${redkitten6sSupabaseKey}"})]`; + assert.strictEqual(scanSecretKeys(content).length, 1); + }); + + test("should detect multiple secrets in the same file", function () { + const content = [ + `API_KEY=${redkitten6sSupabaseKey}`, + `API_KEY=${redkitten6sYouTubeKey}`, + "API_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.KMUFsIDTnFmyG3nMiGM6H9FNFUROf3wh7SmqJp-QV30", + `API_KEY=${redkitten6sSupabaseKey}` + ]; + assert.strictEqual(scanSecretKeys(content.join("\n")).length, content.length); + }); + + test("should detect a secret without indicators if strict mode is off", function () { + const content = `("${redkitten6sSupabaseKey}");`; + assert.strictEqual(scanSecretKeys(content, false).length, 1); + }); + + test("should not detect an invalid secret", function () { + const content = ["API_KEY=this is bob", "API_KEY=bob says hi", "API_KEY=this is bob when the train goes by", "API_KEY=splat"]; + assert.strictEqual(scanSecretKeys(content.join("\n")).length, 0); + }); + + test("should not detect a secret without indicators in strict mode", function () { + const content = `("${redkitten6sSupabaseKey}");`; + assert.strictEqual(scanSecretKeys(content).length, 0); + }); + + test("should ignore secrets on lines with gitgerbil-ignore-line", function () { + const content = `// gitgerbil-ignore-line\nAPI_KEY=${redkitten6sSupabaseKey}`; + assert.strictEqual(scanSecretKeys(content).length, 0); + }); +}); diff --git a/src/validate.ts b/src/validate.ts index fc55ca6..863da94 100644 --- a/src/validate.ts +++ b/src/validate.ts @@ -7,7 +7,7 @@ import * as vscode from "vscode"; export function validateFileName(uri: vscode.Uri): 0 | 1 | 2 { const fileName = uri.fsPath.split("/").pop() ?? ""; const filePatterns = [ - /.*\.env.*/i, + /^\.env(?!\.example).*/i, /.*\.key.*/i, /.*\.pem.*/i, /.*\.p12.*/i, @@ -26,8 +26,8 @@ export function validateFileName(uri: vscode.Uri): 0 | 1 | 2 { const fileNameMatches = filePatterns.some((pattern) => pattern.test(fileName)); if (fileNameMatches) return 1; - const folderName = uri.fsPath.split("/").slice(-2, -1)[0] ?? ""; - const folderPatterns = [/node_modules/i, /vendor/i, /dist/i, /build/i, /out/i, /bin/i, /obj/i, /target/i, /logs/i, /tmp/i, /temp/i, /\.?venv/i, /__pycache__/i] as const; + const folderName = uri.fsPath.split("/").slice(0, -1).join("/"); + const folderPatterns = [/node_modules/i, /vendor/i, /dist/i, /build/i, /out/i, /bin/i, /obj/i, /target/i, /logs/i, /tmp/i, /temp/i, /venv/i, /__pycache__/i] as const; return folderPatterns.some((pattern) => pattern.test(folderName)) ? 2 : 0; } @@ -35,13 +35,29 @@ export function validateFileName(uri: vscode.Uri): 0 | 1 | 2 { type LinePosition = [line: number, col: number]; export type LineRange = [from: LinePosition, to: LinePosition]; +const snakeCaseIndicators = ["access_key", "secret_key", "access_token", "api_key", "api_secret", "app_secret", "application_key", "app_key", "auth_token", "auth_secret"] as const; +// prettier-ignore +const indicators = [ + snakeCaseIndicators, // * snake_case + snakeCaseIndicators.map((i) => i.toUpperCase()), // * UPPER_SNAKE_CASE + snakeCaseIndicators.map((i) => i.split("_").map((part, index) => (index === 0 ? part : part.charAt(0).toUpperCase() + part.slice(1))).join("")), // * camelCase + snakeCaseIndicators.map((i) => i.split("_").map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join("")), // * PascalCase + snakeCaseIndicators.map((i) => i.replace(/_/g, "-")), // * kebab-case + snakeCaseIndicators.map((i) => i.toUpperCase().replace(/_/g, "-")), // * UPPER-KEBAB-CASE + snakeCaseIndicators.map((i) => i.replace(/_/g, "")), // * flatcase + snakeCaseIndicators.map((i) => i.toUpperCase().replace(/_/g, "")) // * UPPERFLATCASE +].flat(); + /** Scans the content for potential secret keys based on predefined patterns. * @param content The content to scan for secret keys. * @returns An array of tuples containing the range of the detected secret key and the message. */ -export function scanSecretKeys(content: string[]): [range: LineRange, message: string][] { +export function scanSecretKeys(content: string, isStrict = true): [range: LineRange, message: string][] { + const contentLines = content.split("\n"); const results: [range: LineRange, message: string][] = []; + if (isStrict && !indicators.some((indicator) => content.includes(indicator))) return []; + const patterns = [ /[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/, // * jwt /sk_live_[0-9a-zA-Z]{24}/, // * stripe @@ -53,12 +69,12 @@ export function scanSecretKeys(content: string[]): [range: LineRange, message: s /ghp_[0-9a-zA-Z]{36}/, // * github pat /github_pat_[0-9a-zA-Z]{40}/, // * github pat /sk-[0-9a-zA-Z]{48}/, // * openai - /(?=(?:[A-Za-z0-9_-]*[0-9_-]){4,})[A-Za-z0-9_-]{20,}={1,2}/, // generic base64 pattern + /(?=(?:[A-Za-z0-9_-]*[0-9_-]){4,})[A-Za-z0-9_-]{20,}={0,2}/, // generic base64 pattern /(?=(?:[0-9a-fA-F]*[0-9]){4,})[0-9a-fA-F]{16,}/ // generic hex pattern ] as const; - for (let i = 0; i < content.length; i++) { - const line = content[i]; + for (let i = 0; i < contentLines.length; i++) { + const line = contentLines[i]; patternMatch: for (const pattern of patterns) { const match = pattern.exec(line); @@ -66,7 +82,7 @@ export function scanSecretKeys(content: string[]): [range: LineRange, message: s const matchedString = match[0]; const startIndex = match.index; - if (content[i - 1]?.includes("gitgerbil-ignore-line")) break patternMatch; + if (contentLines[i - 1]?.includes("gitgerbil-ignore-line")) break patternMatch; const startLine = i; const startCol = startIndex; @@ -96,9 +112,8 @@ export function checkComments(content: string): [range: LineRange, message: stri const commentPatterns = [ /\/\/.*/g, // * singleline comments /\/\*[\s\S]*?\*\//gm, // * multiline comments - /#.*$/g, // * python comments - //gm, // * html comments - /--.*$/gm // * sql comments + /#.*/g, // * python comments + //gm // * html comments ] as const; const commentHints = ["TODO", "FIXME", "HACK", "FIX", "todo", "fixme", "hack", "fix"] as const; @@ -110,7 +125,7 @@ export function checkComments(content: string): [range: LineRange, message: stri const startIndex = match.index; const precedingLines = content.slice(0, startIndex).split("\n"); - if (precedingLines[precedingLines.length - 2].includes("gitgerbil-ignore-line")) continue; + if (precedingLines[precedingLines.length - 2]?.includes("gitgerbil-ignore-line")) continue; const startLine = precedingLines.length - 1; const startCol = precedingLines[precedingLines.length - 1].length; diff --git a/tsconfig.json b/tsconfig.json index f5b6dd4..fda282d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,10 +7,6 @@ "sourceMap": true, "rootDir": "src", "skipLibCheck": true, - "strict": true /* enable all strict type-checking options */ - /* Additional Checks */ - // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ - // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ - // "noUnusedParameters": true, /* Report errors on unused parameters. */ + "strict": true } }