From fbc2d091911611c8b5a39a7c4f2cb7dd40d5ce26 Mon Sep 17 00:00:00 2001 From: Mehdi Date: Sun, 8 Feb 2026 23:52:22 +0000 Subject: [PATCH 1/6] feat(security): enable default secret scanning in filters --- src/renderer/components/ConfigTab.tsx | 54 ++++++++ src/types/ipc.ts | 2 + src/utils/config.default.yaml | 2 + src/utils/file-analyzer.ts | 10 ++ src/utils/filter-utils.ts | 5 + src/utils/secret-scanner.ts | 128 ++++++++++++++++++ tests/catalog.md | 1 + tests/fixtures/configs/default.yaml | 2 + .../integration/main-process/handlers.test.ts | 27 ++++ tests/unit/components/config-tab.test.tsx | 4 + tests/unit/file-analyzer.test.ts | 11 ++ tests/unit/utils/filter-utils.test.ts | 22 +++ tests/unit/utils/secret-scanner.test.ts | 94 +++++++++++++ 13 files changed, 362 insertions(+) create mode 100644 src/utils/secret-scanner.ts create mode 100644 tests/unit/utils/secret-scanner.test.ts diff --git a/src/renderer/components/ConfigTab.tsx b/src/renderer/components/ConfigTab.tsx index 473f4a9..bee9603 100755 --- a/src/renderer/components/ConfigTab.tsx +++ b/src/renderer/components/ConfigTab.tsx @@ -14,6 +14,8 @@ type ConfigStateSetters = { setUseCustomExcludes: React.Dispatch>; setUseCustomIncludes: React.Dispatch>; setUseGitignore: React.Dispatch>; + setEnableSecretScanning: React.Dispatch>; + setExcludeSuspiciousFiles: React.Dispatch>; setIncludeTreeView: React.Dispatch>; setShowTokenCount: React.Dispatch>; }; @@ -49,6 +51,8 @@ const updateConfigStates = (config: ConfigObject, stateSetters: ConfigStateSette setUseCustomExcludes, setUseCustomIncludes, setUseGitignore, + setEnableSecretScanning, + setExcludeSuspiciousFiles, setIncludeTreeView, setShowTokenCount, } = stateSetters; @@ -70,6 +74,14 @@ const updateConfigStates = (config: ConfigObject, stateSetters: ConfigStateSette setUseGitignore(config.use_gitignore !== false); } + if (config?.enable_secret_scanning !== undefined) { + setEnableSecretScanning(config.enable_secret_scanning !== false); + } + + if (config?.exclude_suspicious_files !== undefined) { + setExcludeSuspiciousFiles(config.exclude_suspicious_files !== false); + } + if (config?.include_tree_view !== undefined) { setIncludeTreeView(config.include_tree_view === true); } @@ -84,6 +96,8 @@ const ConfigTab = ({ configContent, onConfigChange }: ConfigTabProps) => { const [useCustomExcludes, setUseCustomExcludes] = useState(true); const [useCustomIncludes, setUseCustomIncludes] = useState(true); const [useGitignore, setUseGitignore] = useState(true); + const [enableSecretScanning, setEnableSecretScanning] = useState(true); + const [excludeSuspiciousFiles, setExcludeSuspiciousFiles] = useState(true); const [includeTreeView, setIncludeTreeView] = useState(true); const [showTokenCount, setShowTokenCount] = useState(true); const [fileExtensions, setFileExtensions] = useState(''); @@ -102,6 +116,8 @@ const ConfigTab = ({ configContent, onConfigChange }: ConfigTabProps) => { setUseCustomExcludes, setUseCustomIncludes, setUseGitignore, + setEnableSecretScanning, + setExcludeSuspiciousFiles, setIncludeTreeView, setShowTokenCount, }); @@ -131,6 +147,8 @@ const ConfigTab = ({ configContent, onConfigChange }: ConfigTabProps) => { config.use_custom_excludes = useCustomExcludes; config.use_custom_includes = useCustomIncludes; config.use_gitignore = useGitignore; + config.enable_secret_scanning = enableSecretScanning; + config.exclude_suspicious_files = excludeSuspiciousFiles; config.include_tree_view = includeTreeView; config.show_token_count = showTokenCount; @@ -167,6 +185,8 @@ const ConfigTab = ({ configContent, onConfigChange }: ConfigTabProps) => { useCustomExcludes, useCustomIncludes, useGitignore, + enableSecretScanning, + excludeSuspiciousFiles, includeTreeView, showTokenCount, fileExtensions, @@ -183,6 +203,8 @@ const ConfigTab = ({ configContent, onConfigChange }: ConfigTabProps) => { useCustomExcludes, useCustomIncludes, useGitignore, + enableSecretScanning, + excludeSuspiciousFiles, includeTreeView, showTokenCount, saveConfig, @@ -345,6 +367,38 @@ const ConfigTab = ({ configContent, onConfigChange }: ConfigTabProps) => { Apply .gitignore rules + +
+ setEnableSecretScanning(e.target.checked)} + className='size-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500' + /> + +
+ +
+ setExcludeSuspiciousFiles(e.target.checked)} + className='size-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500' + /> + +
diff --git a/src/types/ipc.ts b/src/types/ipc.ts index 058d003..56b0a4a 100644 --- a/src/types/ipc.ts +++ b/src/types/ipc.ts @@ -8,6 +8,8 @@ export interface ConfigObject { use_custom_excludes?: boolean; use_custom_includes?: boolean; use_gitignore?: boolean; + enable_secret_scanning?: boolean; + exclude_suspicious_files?: boolean; include_tree_view?: boolean; show_token_count?: boolean; } diff --git a/src/utils/config.default.yaml b/src/utils/config.default.yaml index 7a869e3..e20f40d 100644 --- a/src/utils/config.default.yaml +++ b/src/utils/config.default.yaml @@ -2,6 +2,8 @@ use_custom_excludes: true use_custom_includes: false use_gitignore: true +enable_secret_scanning: true +exclude_suspicious_files: true include_tree_view: true show_token_count: true diff --git a/src/utils/file-analyzer.ts b/src/utils/file-analyzer.ts index c5605b3..b8211a4 100755 --- a/src/utils/file-analyzer.ts +++ b/src/utils/file-analyzer.ts @@ -1,6 +1,7 @@ import fs from 'fs'; import path from 'path'; import { shouldExclude } from './filter-utils'; +import { scanContentForSecrets, shouldExcludeSuspiciousFiles } from './secret-scanner'; import type { ConfigObject } from '../types/ipc'; import type { TokenCounter } from './token-counter'; import type { GitignorePatterns } from './gitignore-parser'; @@ -130,6 +131,15 @@ class FileAnalyzer { // Process text files only const content = fs.readFileSync(filePath, { encoding: 'utf-8', flag: 'r' }); + + if (shouldExcludeSuspiciousFiles(this.config)) { + const secretScanResult = scanContentForSecrets(content); + if (secretScanResult.isSuspicious) { + console.warn(`Skipping suspicious file during analysis: ${filePath}`); + return null; + } + } + return this.tokenCounter.countTokens(content); } catch (error) { console.error(`Error analyzing file ${filePath}:`, error); diff --git a/src/utils/filter-utils.ts b/src/utils/filter-utils.ts index 5b4da0b..95bb84e 100644 --- a/src/utils/filter-utils.ts +++ b/src/utils/filter-utils.ts @@ -1,6 +1,7 @@ import path from 'path'; import fnmatch from './fnmatch'; import type { ConfigObject } from '../types/ipc'; +import { isSensitiveFilePath, shouldExcludeSuspiciousFiles } from './secret-scanner'; type ExcludePatterns = string[] & { includePatterns?: string[]; includeExtensions?: string[] }; @@ -74,6 +75,10 @@ export const shouldExclude = ( const customExcludes = useCustomExcludes && Array.isArray(config?.exclude_patterns) ? config.exclude_patterns : []; + if (shouldExcludeSuspiciousFiles(config) && isSensitiveFilePath(itemPath)) { + return true; + } + if (shouldExcludeByExtension(itemPath, config)) { return true; } diff --git a/src/utils/secret-scanner.ts b/src/utils/secret-scanner.ts new file mode 100644 index 0000000..de7f5d4 --- /dev/null +++ b/src/utils/secret-scanner.ts @@ -0,0 +1,128 @@ +import fs from 'fs'; +import path from 'path'; +import type { ConfigObject } from '../types/ipc'; + +type SecretRule = { + id: string; + description: string; + pattern: RegExp; +}; + +export interface SecretMatch { + id: string; + description: string; +} + +export interface SecretScanResult { + isSuspicious: boolean; + matches: SecretMatch[]; +} + +const SECRET_RULES: SecretRule[] = [ + { + id: 'private-key-block', + description: 'Private key block detected', + pattern: /-----BEGIN (?:[A-Z ]+)?PRIVATE KEY-----/m, + }, + { + id: 'github-token', + description: 'GitHub token detected', + pattern: /\bgh[pousr]_[A-Za-z0-9]{20,}\b/, + }, + { + id: 'aws-access-key-id', + description: 'AWS access key id detected', + pattern: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/, + }, + { + id: 'aws-secret-assignment', + description: 'AWS secret key assignment detected', + pattern: + /aws(?:_|[\s-])?secret(?:_|[\s-])?access(?:_|[\s-])?key\s*[:=]\s*['"]?[A-Za-z0-9/+=]{40}['"]?/i, + }, + { + id: 'slack-token', + description: 'Slack token detected', + pattern: /\bxox[baprs]-[0-9A-Za-z-]{10,}\b/, + }, + { + id: 'stripe-secret-key', + description: 'Stripe secret key detected', + pattern: /\bsk_live_[0-9A-Za-z]{16,}\b/, + }, + { + id: 'jwt-token', + description: 'JWT-like token detected', + pattern: /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/, + }, + { + id: 'generic-credential-assignment', + description: 'Credential assignment detected', + pattern: + /(?:api[_-]?key|access[_-]?token|auth[_-]?token|secret|password|passwd|client[_-]?secret)\s*[:=]\s*['"][^'"\n]{8,}['"]/i, + }, +]; + +const SENSITIVE_FILE_NAME_PATTERNS = [ + /^\.env(?:\..+)?$/i, + /^id_(?:rsa|dsa|ecdsa|ed25519)(?:\.pub)?$/i, + /(?:^|[-_.])(?:secret|secrets|credential|credentials)(?:[-_.]|$)/i, +]; + +const SENSITIVE_FILE_EXTENSION_PATTERN = + /\.(?:pem|key|p12|pfx|jks|keystore|cer|crt|der|kdbx|asc)$/i; + +const SENSITIVE_PATH_SEGMENTS = [ + '/.aws/credentials', + '/.npmrc', + '/.pypirc', + '/.docker/config.json', +]; + +const normalizeFilePath = (filePath: string): string => filePath.replace(/\\/g, '/').toLowerCase(); + +export const shouldExcludeSuspiciousFiles = (config?: ConfigObject): boolean => + config?.enable_secret_scanning !== false && config?.exclude_suspicious_files !== false; + +export const isSensitiveFilePath = (filePath: string): boolean => { + const normalizedPath = normalizeFilePath(filePath); + const fileName = path.basename(normalizedPath); + + if (SENSITIVE_FILE_EXTENSION_PATTERN.test(fileName)) { + return true; + } + + if (SENSITIVE_FILE_NAME_PATTERNS.some((pattern) => pattern.test(fileName))) { + return true; + } + + return SENSITIVE_PATH_SEGMENTS.some((segment) => normalizedPath.includes(segment)); +}; + +export const scanContentForSecrets = (content: string): SecretScanResult => { + const matches: SecretMatch[] = []; + + for (const rule of SECRET_RULES) { + if (rule.pattern.test(content)) { + matches.push({ id: rule.id, description: rule.description }); + } + } + + return { + isSuspicious: matches.length > 0, + matches, + }; +}; + +export const scanFileForSecrets = (filePath: string): SecretScanResult => { + try { + const content = fs.readFileSync(filePath, { encoding: 'utf-8', flag: 'r' }); + return scanContentForSecrets(content); + } catch (error) { + console.error(`Error scanning file for secrets: ${filePath}`, error); + return { + isSuspicious: false, + matches: [], + }; + } +}; diff --git a/tests/catalog.md b/tests/catalog.md index f912889..79330f3 100644 --- a/tests/catalog.md +++ b/tests/catalog.md @@ -19,6 +19,7 @@ Purpose: quick map of what is covered, why it exists, and which command to run. | `tests/unit/gitignore-parser.test.ts` | `src/utils/gitignore-parser.ts` | Pattern parsing, negation behavior, caching, nested path handling | | `tests/unit/binary-detection.test.ts` | `src/utils/binary-detection.ts` | Binary signature detection, control-char thresholds, fallback-on-error behavior | | `tests/unit/utils/filter-utils.test.ts` | `src/utils/filter-utils.ts` | Path normalization, extension filtering, custom excludes, gitignore precedence | +| `tests/unit/utils/secret-scanner.test.ts` | `src/utils/secret-scanner.ts` | Sensitive path detection, secret-pattern scanning, default-on safety toggles | | `tests/unit/utils/fnmatch.test.ts` | `src/utils/fnmatch.ts` | Glob semantics: wildcards, classes, double-star, braces, path anchors | | `tests/unit/utils/content-processor.test.ts` | `src/utils/content-processor.ts` | Content assembly, binary skip logic, malformed input handling | | `tests/unit/utils/config-manager.test.ts` | `src/utils/config-manager.ts` | Default config load, parse failures, graceful fallback behavior | diff --git a/tests/fixtures/configs/default.yaml b/tests/fixtures/configs/default.yaml index a789af0..3e3a20f 100644 --- a/tests/fixtures/configs/default.yaml +++ b/tests/fixtures/configs/default.yaml @@ -2,6 +2,8 @@ use_custom_excludes: true use_custom_includes: false use_gitignore: true +enable_secret_scanning: true +exclude_suspicious_files: true include_tree_view: true show_token_count: true diff --git a/tests/integration/main-process/handlers.test.ts b/tests/integration/main-process/handlers.test.ts index 32b3881..8f50839 100644 --- a/tests/integration/main-process/handlers.test.ts +++ b/tests/integration/main-process/handlers.test.ts @@ -1,5 +1,6 @@ const fs = require('fs'); const yaml = require('yaml'); +const FAKE_GITHUB_TOKEN = ['ghp', 'AAAAAAAAAAAAAAAAAAAAAAAA'].join('_'); // Mock electron ipcMain const mockIpcHandlers = {}; @@ -325,6 +326,32 @@ describe('Main Process IPC Handlers', () => { // Should have correct number of files (only those inside root) expect(result.filesInfo.length).toBe(1); }); + + test('should skip suspicious file content when secret scanning is enabled', async () => { + const rootPath = '/mock/repo'; + const configContent = ` + use_custom_excludes: true + use_gitignore: true + include_extensions: + - .js + exclude_patterns: + - "**/node_modules/**" + `; + const selectedFiles = ['/mock/repo/src/index.js', '/mock/repo/src/secrets.js']; + + fs.readFileSync.mockImplementation((filePath) => { + if (filePath.endsWith('secrets.js')) { + return `const token = "${FAKE_GITHUB_TOKEN}";`; + } + return 'console.log("Hello world");'; + }); + + const handler = mockIpcHandlers['repo:analyze']; + const result = await handler(null, { rootPath, configContent, selectedFiles }); + + expect(result.filesInfo.find((file) => file.path === 'src/index.js')).toBeDefined(); + expect(result.filesInfo.find((file) => file.path === 'src/secrets.js')).toBeUndefined(); + }); }); describe('repo:process', () => { diff --git a/tests/unit/components/config-tab.test.tsx b/tests/unit/components/config-tab.test.tsx index f639ce5..ae1c601 100644 --- a/tests/unit/components/config-tab.test.tsx +++ b/tests/unit/components/config-tab.test.tsx @@ -17,6 +17,8 @@ jest.mock('yaml', () => ({ use_custom_excludes: true, use_gitignore: true, use_custom_includes: true, + enable_secret_scanning: true, + exclude_suspicious_files: true, }; } return {}; @@ -69,6 +71,8 @@ describe('ConfigTab', () => { expect(screen.getByLabelText('Filter by file extensions')).toBeChecked(); expect(screen.getByLabelText('Use exclude patterns')).toBeChecked(); expect(screen.getByLabelText('Apply .gitignore rules')).toBeChecked(); + expect(screen.getByLabelText('Scan content for secrets')).toBeChecked(); + expect(screen.getByLabelText('Exclude suspicious files')).toBeChecked(); // Check textareas const extensionsTextarea = screen.getByPlaceholderText(/\.py/); diff --git a/tests/unit/file-analyzer.test.ts b/tests/unit/file-analyzer.test.ts index 3847ea7..d2fbb7e 100644 --- a/tests/unit/file-analyzer.test.ts +++ b/tests/unit/file-analyzer.test.ts @@ -15,6 +15,7 @@ jest.mock('../../src/utils/file-analyzer', () => { // Now import the module with its mocked functions const fileAnalyzerModule = require('../../src/utils/file-analyzer'); const { FileAnalyzer, isBinaryFile } = fileAnalyzerModule; +const FAKE_GITHUB_TOKEN = ['ghp', 'AAAAAAAAAAAAAAAAAAAAAAAA'].join('_'); // Mock fs jest.mock('fs'); @@ -404,6 +405,16 @@ describe('FileAnalyzer', () => { expect(result).toBeNull(); }); + + test('should skip suspicious files when secret scanning is enabled', () => { + isBinaryFile.mockReturnValue(false); + fs.readFileSync.mockReturnValue(`const token = "${FAKE_GITHUB_TOKEN}";`); + + const result = fileAnalyzer.analyzeFile('src/secrets.ts'); + + expect(result).toBeNull(); + expect(mockTokenCounter.countTokens).not.toHaveBeenCalled(); + }); }); describe('shouldReadFile', () => { diff --git a/tests/unit/utils/filter-utils.test.ts b/tests/unit/utils/filter-utils.test.ts index fbb27a5..5bc7c83 100644 --- a/tests/unit/utils/filter-utils.test.ts +++ b/tests/unit/utils/filter-utils.test.ts @@ -236,6 +236,28 @@ describe('filter-utils', () => { expect(shouldExclude(itemPath, rootPath, excludePatterns, config)).toBe(true); }); + test('should exclude sensitive files by default', () => { + const itemPath = '/project/.env.production'; + const rootPath = '/project'; + const excludePatterns = []; + const config = {}; + + expect(shouldExclude(itemPath, rootPath, excludePatterns, config)).toBe(true); + }); + + test('should allow sensitive files when secret scanning is disabled', () => { + const itemPath = '/project/.env.production'; + const rootPath = '/project'; + const excludePatterns = []; + const config = { + enable_secret_scanning: false, + use_custom_excludes: false, + use_gitignore: false, + }; + + expect(shouldExclude(itemPath, rootPath, excludePatterns, config)).toBe(false); + }); + test('should handle empty patterns', () => { const itemPath = '/project/src/file.js'; const rootPath = '/project'; diff --git a/tests/unit/utils/secret-scanner.test.ts b/tests/unit/utils/secret-scanner.test.ts new file mode 100644 index 0000000..725d8a8 --- /dev/null +++ b/tests/unit/utils/secret-scanner.test.ts @@ -0,0 +1,94 @@ +const fs = require('fs'); +const { + isSensitiveFilePath, + scanContentForSecrets, + scanFileForSecrets, + shouldExcludeSuspiciousFiles, +} = require('../../../src/utils/secret-scanner'); + +jest.mock('fs'); + +const FAKE_GITHUB_TOKEN = ['ghp', 'AAAAAAAAAAAAAAAAAAAAAAAA'].join('_'); +const FAKE_AWS_SECRET_ACCESS_KEY = 'A'.repeat(40); + +describe('secret-scanner', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('shouldExcludeSuspiciousFiles', () => { + test('should be enabled by default', () => { + expect(shouldExcludeSuspiciousFiles()).toBe(true); + expect(shouldExcludeSuspiciousFiles({})).toBe(true); + }); + + test('should allow disabling secret scanning', () => { + expect(shouldExcludeSuspiciousFiles({ enable_secret_scanning: false })).toBe(false); + expect(shouldExcludeSuspiciousFiles({ exclude_suspicious_files: false })).toBe(false); + }); + }); + + describe('isSensitiveFilePath', () => { + test('should detect sensitive file names and paths', () => { + expect(isSensitiveFilePath('/repo/.env')).toBe(true); + expect(isSensitiveFilePath('/repo/.env.production')).toBe(true); + expect(isSensitiveFilePath('/repo/.aws/credentials')).toBe(true); + expect(isSensitiveFilePath('/repo/keys/private.pem')).toBe(true); + expect(isSensitiveFilePath('/repo/id_rsa')).toBe(true); + }); + + test('should not mark regular source files as sensitive', () => { + expect(isSensitiveFilePath('/repo/src/app.ts')).toBe(false); + expect(isSensitiveFilePath('/repo/src/index.tsx')).toBe(false); + expect(isSensitiveFilePath('/repo/docs/guide.md')).toBe(false); + }); + }); + + describe('scanContentForSecrets', () => { + test('should detect known secret patterns', () => { + const content = ` + const token = "${FAKE_GITHUB_TOKEN}"; + AWS_SECRET_ACCESS_KEY="${FAKE_AWS_SECRET_ACCESS_KEY}" + `; + + const result = scanContentForSecrets(content); + expect(result.isSuspicious).toBe(true); + expect(result.matches.length).toBeGreaterThan(0); + }); + + test('should return clean result for normal content', () => { + const content = ` + export const sum = (a, b) => a + b; + console.log("hello"); + `; + + const result = scanContentForSecrets(content); + expect(result).toEqual({ + isSuspicious: false, + matches: [], + }); + }); + }); + + describe('scanFileForSecrets', () => { + test('should detect secrets when file contains sensitive content', () => { + fs.readFileSync.mockReturnValue(`const key = "${FAKE_GITHUB_TOKEN}";`); + + const result = scanFileForSecrets('/repo/src/config.ts'); + expect(result.isSuspicious).toBe(true); + expect(result.matches.length).toBeGreaterThan(0); + }); + + test('should fail safely if file cannot be read', () => { + fs.readFileSync.mockImplementation(() => { + throw new Error('read error'); + }); + + const result = scanFileForSecrets('/repo/src/missing.ts'); + expect(result).toEqual({ + isSuspicious: false, + matches: [], + }); + }); + }); +}); From 49dfbb1cb61a2f8073c03283141ea097211fef04 Mon Sep 17 00:00:00 2001 From: Mehdi Date: Mon, 9 Feb 2026 00:05:34 +0000 Subject: [PATCH 2/6] chore(security): enforce staged secret scans locally and in PR gate --- .husky/pre-commit | 3 +-- .husky/pre-commit.bat | 3 +++ .pre-commit-config.yaml | 9 +++++++-- package.json | 1 + scripts/index.js | 5 +++++ scripts/lib/security.js | 27 +++++++++++++++++++++++++++ 6 files changed, 44 insertions(+), 4 deletions(-) diff --git a/.husky/pre-commit b/.husky/pre-commit index 8f798c6..914975a 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,5 +1,4 @@ #!/usr/bin/env sh -. "$(dirname -- "$0")/_/husky.sh" set -e @@ -7,4 +6,4 @@ set -e npx lint-staged # Block commits that introduce secrets. -npm run gitleaks +npm run gitleaks:staged diff --git a/.husky/pre-commit.bat b/.husky/pre-commit.bat index cd60dbc..e51942d 100755 --- a/.husky/pre-commit.bat +++ b/.husky/pre-commit.bat @@ -1,2 +1,5 @@ @echo off npx lint-staged +if errorlevel 1 exit /b %errorlevel% +npm run gitleaks:staged +if errorlevel 1 exit /b %errorlevel% diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 2bdaffa..7666e38 100755 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -14,7 +14,7 @@ repos: rev: v8.56.0 hooks: - id: eslint - files: \.(js|jsx)$ + files: \.(js|jsx|ts|tsx)$ types: [file] additional_dependencies: - eslint@8.56.0 @@ -29,4 +29,9 @@ repos: rev: v3.1.0 hooks: - id: prettier - types_or: [javascript, jsx, json, css, html] + files: \.(js|jsx|ts|tsx|json|css|html)$ + + - repo: https://github.com/gitleaks/gitleaks + rev: v8.24.3 + hooks: + - id: gitleaks diff --git a/package.json b/package.json index de965a0..6d7fd9e 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "qa:screenshot": "node scripts/capture-ui-screenshot.js", "security": "node scripts/index.js security", "gitleaks": "node scripts/index.js gitleaks", + "gitleaks:staged": "node scripts/index.js gitleaks-staged", "sbom": "node scripts/index.js sbom", "renovate": "node scripts/index.js renovate", "renovate:local": "node scripts/index.js renovate-local", diff --git a/scripts/index.js b/scripts/index.js index c641bc6..7dca026 100755 --- a/scripts/index.js +++ b/scripts/index.js @@ -143,6 +143,11 @@ async function executeCommand() { await security.runGitleaks(); break; + case 'gitleaks-staged': + case 'gitleaks:staged': + await security.runGitleaksStaged(); + break; + case 'sbom': await security.runSbom(); break; diff --git a/scripts/lib/security.js b/scripts/lib/security.js index 6082649..f674d29 100755 --- a/scripts/lib/security.js +++ b/scripts/lib/security.js @@ -175,6 +175,32 @@ async function runGitleaks() { return reportPath; } +async function runGitleaksStaged() { + const gitleaksPath = resolveCommand('gitleaks', [ + path.join('bin', 'gitleaks'), + path.join('bin', 'gitleaks.exe'), + ]); + + if (!gitleaksPath) { + throw new Error('gitleaks not found in PATH or ./bin (install gitleaks first)'); + } + assertAllowedExecutable(gitleaksPath); + + const args = ['protect', '--staged', '--redact', '--no-banner', '--verbose', '--exit-code', '1']; + const commandName = process.platform === 'win32' ? 'gitleaks.exe' : 'gitleaks'; + const env = withExecutablePath({ ...process.env }, gitleaksPath); + const commandLine = [commandName, ...sanitizeArgs(args)].join(' '); + + console.log(`Running: ${commandLine}`); + const result = spawnSync(commandName, args, { + cwd: utils.ROOT_DIR, + stdio: 'inherit', + env, + shell: false, + }); + assertProcessResult(result, commandName, commandLine); +} + async function runSbom() { ensureSecurityDirs(); @@ -508,6 +534,7 @@ async function runSecurity() { module.exports = { runGitleaks, + runGitleaksStaged, runSbom, runRenovate, runRenovateLocal, From be6b456e9e0e0eb857de58af875e40d857878e1e Mon Sep 17 00:00:00 2001 From: Mehdi Date: Mon, 9 Feb 2026 00:24:29 +0000 Subject: [PATCH 3/6] test(qa): add e2e secret-filter toggle checks to screenshot gate --- scripts/capture-ui-screenshot.js | 118 ++++++++++++++++++++++++++++++- tests/catalog.md | 6 +- 2 files changed, 118 insertions(+), 6 deletions(-) diff --git a/scripts/capture-ui-screenshot.js b/scripts/capture-ui-screenshot.js index d9fd675..5f8e938 100644 --- a/scripts/capture-ui-screenshot.js +++ b/scripts/capture-ui-screenshot.js @@ -104,6 +104,7 @@ function createStaticServer() { const MOCK_ROOT_PATH = '/mock-repository'; const MOCK_APP_FILE_PATH = `${MOCK_ROOT_PATH}/src/App.tsx`; +const MOCK_SECRET_FILE_PATH = `${MOCK_ROOT_PATH}/.env`; const MOCK_FEATURE_MODULE_COUNT = 24; const MOCK_CONFIG = [ 'include_extensions:', @@ -214,6 +215,8 @@ const MOCK_DEEP_FEATURE_FILE_PATH = toMockPath( ); const MOCK_DIRECTORY_TREE = [ + createMockFile('.env'), + createMockFile('.npmrc'), createMockDirectory('src', [ createMockFile('src/App.tsx'), createMockFile('src/index.tsx'), @@ -262,7 +265,46 @@ const MOCK_DIRECTORY_TREE = [ ]), ]; -const MOCK_TOTAL_FILE_COUNT = countMockFiles(MOCK_DIRECTORY_TREE); +function isSensitiveMockPath(itemPath) { + const normalizedPath = String(itemPath || '').replace(/\\/g, '/').toLowerCase(); + const name = normalizedPath.split('/').pop() || ''; + + if (/^\.env(?:\..+)?$/i.test(name)) { + return true; + } + + if (name === '.npmrc' || name === '.pypirc') { + return true; + } + + return /\.(pem|key|p12|pfx|jks|keystore|cer|crt|der|kdbx|asc)$/i.test(name); +} + +function cloneAndFilterMockTree(items, excludeSensitiveFiles) { + const filtered = []; + + for (const item of items) { + if (item.type === 'file') { + if (excludeSensitiveFiles && isSensitiveMockPath(item.path)) { + continue; + } + + filtered.push({ ...item }); + continue; + } + + const children = Array.isArray(item.children) + ? cloneAndFilterMockTree(item.children, excludeSensitiveFiles) + : []; + filtered.push({ ...item, children }); + } + + return filtered; +} + +const MOCK_VISIBLE_FILE_COUNT_WITH_SECRET_FILTER = countMockFiles( + cloneAndFilterMockTree(MOCK_DIRECTORY_TREE, true) +); const SCREENSHOT_NAME = sanitizeScreenshotName(process.env.UI_SCREENSHOT_NAME); const SCREENSHOT_BASE_NAME = path.parse(SCREENSHOT_NAME).name; @@ -279,12 +321,16 @@ const UI_SELECTORS = { appRoot: '#app', configTab: '[data-tab="config"]', sourceTab: '[data-tab="source"]', + secretScanningToggle: '#enable-secret-scanning', + suspiciousFilesToggle: '#exclude-suspicious-files', sourceFolderExpandButton: 'button[aria-label="Expand folder src"]', sourceFeaturesFolderExpandButton: 'button[aria-label="Expand folder features"]', sourceDeepFeatureFolderExpandButton: `button[aria-label="Expand folder ${MOCK_DEEP_FEATURE_NAME}"]`, sourceDeepUiFolderExpandButton: 'button[aria-label="Expand folder ui"]', appFileEntry: `[title="${MOCK_APP_FILE_PATH}"]`, deepFeatureFileEntry: `[title="${MOCK_DEEP_FEATURE_FILE_PATH}"]`, + secretFileEntry: `[title="${MOCK_SECRET_FILE_PATH}"]`, + refreshFileListButton: 'button[title="Refresh the file list"]', fileTreeScrollContainer: '.file-tree .overflow-auto', }; @@ -297,7 +343,44 @@ async function setupMockElectronApi(page) { window.electronAPI = { getDefaultConfig: async () => mockConfig, selectDirectory: async () => mockRootPath, - getDirectoryTree: async () => mockDirectoryTree, + getDirectoryTree: async (_dirPath, configContent) => { + const activeConfig = + typeof configContent === 'string' && configContent.trim() + ? configContent + : localStorage.getItem('configContent') || ''; + const excludeSensitiveFiles = !/(^|\n)\s*enable_secret_scanning\s*:\s*false\b/i.test( + activeConfig + ) && !/(^|\n)\s*exclude_suspicious_files\s*:\s*false\b/i.test(activeConfig); + + const cloneAndFilter = (items) => { + const filtered = []; + + for (const item of items) { + if (item.type === 'file') { + const normalizedPath = String(item.path || '').replace(/\\/g, '/').toLowerCase(); + const fileName = normalizedPath.split('/').pop() || ''; + const isSensitive = + /^\.env(?:\..+)?$/i.test(fileName) || + fileName === '.npmrc' || + fileName === '.pypirc' || + /\.(pem|key|p12|pfx|jks|keystore|cer|crt|der|kdbx|asc)$/i.test(fileName); + + if (excludeSensitiveFiles && isSensitive) { + continue; + } + filtered.push({ ...item }); + continue; + } + + const children = Array.isArray(item.children) ? cloneAndFilter(item.children) : []; + filtered.push({ ...item, children }); + } + + return filtered; + }; + + return cloneAndFilter(mockDirectoryTree); + }, analyzeRepository: async () => ({ totalFiles: 0, totalTokens: 0, @@ -384,13 +467,42 @@ async function captureAppStateScreenshots(page) { } const summaryText = fileTreeRoot.textContent || ''; return summaryText.includes(`of ${totalFiles} files selected`); - }, MOCK_TOTAL_FILE_COUNT); + }, MOCK_VISIBLE_FILE_COUNT_WITH_SECRET_FILTER); + }); + + await runStep('Verify secret files are hidden by default', async () => { + await page.waitForFunction((selector) => !document.querySelector(selector), UI_SELECTORS.secretFileEntry); }); await runStep('Capture source tab screenshot', async () => { await page.screenshot({ path: SCREENSHOTS.sourceTab, fullPage: true }); }); + await runStep('Disable secret filtering in config tab', async () => { + await page.click(UI_SELECTORS.configTab); + await page.waitForSelector(UI_SELECTORS.secretScanningToggle, { timeout: 10000 }); + await page.uncheck(UI_SELECTORS.secretScanningToggle); + await page.uncheck(UI_SELECTORS.suspiciousFilesToggle); + await page.getByRole('button', { name: /save config|saved/i }).click(); + await page.waitForFunction(() => { + const configContent = localStorage.getItem('configContent') || ''; + return ( + /(^|\n)\s*enable_secret_scanning\s*:\s*false\b/i.test(configContent) && + /(^|\n)\s*exclude_suspicious_files\s*:\s*false\b/i.test(configContent) + ); + }); + }); + + await runStep('Switch back to source tab and refresh file list', async () => { + await page.click(UI_SELECTORS.sourceTab); + await page.waitForSelector(UI_SELECTORS.refreshFileListButton, { timeout: 10000 }); + await page.click(UI_SELECTORS.refreshFileListButton); + }); + + await runStep('Verify secret file appears when filtering is disabled', async () => { + await page.waitForSelector(UI_SELECTORS.secretFileEntry, { timeout: 10000 }); + }); + await runStep('Expand source folder', async () => { await page.locator(UI_SELECTORS.sourceFolderExpandButton).first().click(); }); diff --git a/tests/catalog.md b/tests/catalog.md index 79330f3..e2be5b5 100644 --- a/tests/catalog.md +++ b/tests/catalog.md @@ -34,9 +34,9 @@ Purpose: quick map of what is covered, why it exists, and which command to run. ## Visual Regression Signal -| Command | Primary Target | Key Use Cases | -| ----------------------- | ------------------------------------------------ | ------------------------------------------------------------------------------ | -| `npm run qa:screenshot` | `scripts/capture-ui-screenshot.js` + renderer UI | Cross-OS UI sanity, resized layout checks, deep file-tree selection visibility | +| Command | Primary Target | Key Use Cases | +| ----------------------- | ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------- | +| `npm run qa:screenshot` | `scripts/capture-ui-screenshot.js` + renderer UI | Cross-OS UI sanity, resized layout checks, deep file-tree selection visibility, secret-filter toggle behavior | ## Change-to-Test Mapping From 25149c379e7dbc4eee66631f5e9a603b011f0b23 Mon Sep 17 00:00:00 2001 From: Mehdi Date: Mon, 9 Feb 2026 00:31:34 +0000 Subject: [PATCH 4/6] fix(security): address review feedback for secret filtering --- scripts/capture-ui-screenshot.js | 41 +++++-------------------- src/utils/secret-scanner.ts | 16 +++++----- tests/unit/file-analyzer.test.ts | 40 ++++++++++++++++++++++++ tests/unit/utils/secret-scanner.test.ts | 2 ++ 4 files changed, 59 insertions(+), 40 deletions(-) diff --git a/scripts/capture-ui-screenshot.js b/scripts/capture-ui-screenshot.js index 5f8e938..83a92d3 100644 --- a/scripts/capture-ui-screenshot.js +++ b/scripts/capture-ui-screenshot.js @@ -302,9 +302,8 @@ function cloneAndFilterMockTree(items, excludeSensitiveFiles) { return filtered; } -const MOCK_VISIBLE_FILE_COUNT_WITH_SECRET_FILTER = countMockFiles( - cloneAndFilterMockTree(MOCK_DIRECTORY_TREE, true) -); +const MOCK_FILTERED_DIRECTORY_TREE = cloneAndFilterMockTree(MOCK_DIRECTORY_TREE, true); +const MOCK_VISIBLE_FILE_COUNT_WITH_SECRET_FILTER = countMockFiles(MOCK_FILTERED_DIRECTORY_TREE); const SCREENSHOT_NAME = sanitizeScreenshotName(process.env.UI_SCREENSHOT_NAME); const SCREENSHOT_BASE_NAME = path.parse(SCREENSHOT_NAME).name; @@ -336,10 +335,12 @@ const UI_SELECTORS = { async function setupMockElectronApi(page) { await page.addInitScript( - ({ mockRootPath, mockConfig, mockDirectoryTree, fixedMtime }) => { + ({ mockRootPath, mockConfig, mockDirectoryTree, mockFilteredDirectoryTree, fixedMtime }) => { localStorage.setItem('rootPath', mockRootPath); localStorage.setItem('configContent', mockConfig); + const cloneTree = (treeItems) => JSON.parse(JSON.stringify(treeItems)); + window.electronAPI = { getDefaultConfig: async () => mockConfig, selectDirectory: async () => mockRootPath, @@ -351,35 +352,8 @@ async function setupMockElectronApi(page) { const excludeSensitiveFiles = !/(^|\n)\s*enable_secret_scanning\s*:\s*false\b/i.test( activeConfig ) && !/(^|\n)\s*exclude_suspicious_files\s*:\s*false\b/i.test(activeConfig); - - const cloneAndFilter = (items) => { - const filtered = []; - - for (const item of items) { - if (item.type === 'file') { - const normalizedPath = String(item.path || '').replace(/\\/g, '/').toLowerCase(); - const fileName = normalizedPath.split('/').pop() || ''; - const isSensitive = - /^\.env(?:\..+)?$/i.test(fileName) || - fileName === '.npmrc' || - fileName === '.pypirc' || - /\.(pem|key|p12|pfx|jks|keystore|cer|crt|der|kdbx|asc)$/i.test(fileName); - - if (excludeSensitiveFiles && isSensitive) { - continue; - } - filtered.push({ ...item }); - continue; - } - - const children = Array.isArray(item.children) ? cloneAndFilter(item.children) : []; - filtered.push({ ...item, children }); - } - - return filtered; - }; - - return cloneAndFilter(mockDirectoryTree); + const tree = excludeSensitiveFiles ? mockFilteredDirectoryTree : mockDirectoryTree; + return cloneTree(tree); }, analyzeRepository: async () => ({ totalFiles: 0, @@ -420,6 +394,7 @@ async function setupMockElectronApi(page) { mockRootPath: MOCK_ROOT_PATH, mockConfig: MOCK_CONFIG, mockDirectoryTree: MOCK_DIRECTORY_TREE, + mockFilteredDirectoryTree: MOCK_FILTERED_DIRECTORY_TREE, fixedMtime: FIXED_MTIME, } ); diff --git a/src/utils/secret-scanner.ts b/src/utils/secret-scanner.ts index de7f5d4..a373c7e 100644 --- a/src/utils/secret-scanner.ts +++ b/src/utils/secret-scanner.ts @@ -72,12 +72,7 @@ const SENSITIVE_FILE_NAME_PATTERNS = [ const SENSITIVE_FILE_EXTENSION_PATTERN = /\.(?:pem|key|p12|pfx|jks|keystore|cer|crt|der|kdbx|asc)$/i; -const SENSITIVE_PATH_SEGMENTS = [ - '/.aws/credentials', - '/.npmrc', - '/.pypirc', - '/.docker/config.json', -]; +const SENSITIVE_PATH_SEGMENTS = ['.aws/credentials', '.npmrc', '.pypirc', '.docker/config.json']; const normalizeFilePath = (filePath: string): string => filePath.replace(/\\/g, '/').toLowerCase(); @@ -96,7 +91,14 @@ export const isSensitiveFilePath = (filePath: string): boolean => { return true; } - return SENSITIVE_PATH_SEGMENTS.some((segment) => normalizedPath.includes(segment)); + return SENSITIVE_PATH_SEGMENTS.some((segment) => { + const normalizedSegment = segment.toLowerCase(); + return ( + normalizedPath === normalizedSegment || + normalizedPath.endsWith(`/${normalizedSegment}`) || + normalizedPath.includes(`/${normalizedSegment}/`) + ); + }); }; export const scanContentForSecrets = (content: string): SecretScanResult => { diff --git a/tests/unit/file-analyzer.test.ts b/tests/unit/file-analyzer.test.ts index d2fbb7e..c04ab59 100644 --- a/tests/unit/file-analyzer.test.ts +++ b/tests/unit/file-analyzer.test.ts @@ -415,6 +415,46 @@ describe('FileAnalyzer', () => { expect(result).toBeNull(); expect(mockTokenCounter.countTokens).not.toHaveBeenCalled(); }); + + test('should analyze suspicious files when secret scanning is disabled', () => { + isBinaryFile.mockReturnValue(false); + fs.readFileSync.mockReturnValue(`const token = "${FAKE_GITHUB_TOKEN}";`); + + const analyzer = new FileAnalyzer( + { + ...mockConfig, + enable_secret_scanning: false, + }, + mockTokenCounter + ); + + const result = analyzer.analyzeFile('src/secrets.ts'); + + expect(result).toBe(100); + expect(mockTokenCounter.countTokens).toHaveBeenCalledWith( + `const token = "${FAKE_GITHUB_TOKEN}";` + ); + }); + + test('should analyze suspicious files when suspicious file exclusion is disabled', () => { + isBinaryFile.mockReturnValue(false); + fs.readFileSync.mockReturnValue(`const token = "${FAKE_GITHUB_TOKEN}";`); + + const analyzer = new FileAnalyzer( + { + ...mockConfig, + exclude_suspicious_files: false, + }, + mockTokenCounter + ); + + const result = analyzer.analyzeFile('src/secrets.ts'); + + expect(result).toBe(100); + expect(mockTokenCounter.countTokens).toHaveBeenCalledWith( + `const token = "${FAKE_GITHUB_TOKEN}";` + ); + }); }); describe('shouldReadFile', () => { diff --git a/tests/unit/utils/secret-scanner.test.ts b/tests/unit/utils/secret-scanner.test.ts index 725d8a8..4528197 100644 --- a/tests/unit/utils/secret-scanner.test.ts +++ b/tests/unit/utils/secret-scanner.test.ts @@ -33,6 +33,8 @@ describe('secret-scanner', () => { expect(isSensitiveFilePath('/repo/.env')).toBe(true); expect(isSensitiveFilePath('/repo/.env.production')).toBe(true); expect(isSensitiveFilePath('/repo/.aws/credentials')).toBe(true); + expect(isSensitiveFilePath('.aws/credentials')).toBe(true); + expect(isSensitiveFilePath('.npmrc')).toBe(true); expect(isSensitiveFilePath('/repo/keys/private.pem')).toBe(true); expect(isSensitiveFilePath('/repo/id_rsa')).toBe(true); }); From 725a3f3625ccd97af53d3f91ca2e6a2f61f4c698 Mon Sep 17 00:00:00 2001 From: Mehdi Date: Mon, 9 Feb 2026 00:33:53 +0000 Subject: [PATCH 5/6] test(config): verify secret-toggle persistence in ConfigTab --- tests/unit/components/config-tab.test.tsx | 24 +++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tests/unit/components/config-tab.test.tsx b/tests/unit/components/config-tab.test.tsx index ae1c601..370e78c 100644 --- a/tests/unit/components/config-tab.test.tsx +++ b/tests/unit/components/config-tab.test.tsx @@ -96,6 +96,30 @@ describe('ConfigTab', () => { }); }); + test('persists secret scanning toggles in saved config', async () => { + render(); + + const scanSecretsCheckbox = screen.getByLabelText('Scan content for secrets'); + const excludeSuspiciousCheckbox = screen.getByLabelText('Exclude suspicious files'); + + act(() => { + fireEvent.click(scanSecretsCheckbox); + fireEvent.click(excludeSuspiciousCheckbox); + jest.advanceTimersByTime(100); // Advance past the debounce + }); + + await waitFor(() => { + expect(mockOnConfigChange).toHaveBeenCalled(); + }); + + const yamlLib = require('yaml'); + expect(yamlLib.stringify).toHaveBeenCalled(); + const savedConfig = yamlLib.stringify.mock.calls.at(-1)[0]; + + expect(savedConfig.enable_secret_scanning).toBe(false); + expect(savedConfig.exclude_suspicious_files).toBe(false); + }); + test('calls selectDirectory when folder button is clicked', async () => { render(); From 860f3b0da03219985338d1f542e363af316114e6 Mon Sep 17 00:00:00 2001 From: Mehdi Date: Mon, 9 Feb 2026 01:52:28 +0000 Subject: [PATCH 6/6] fix(security): centralize secret policy and close review gaps --- package.json | 1 + scripts/capture-ui-screenshot.js | 31 ++++----- scripts/lib/security.js | 26 ++++++- src/utils/file-analyzer.ts | 12 ++-- src/utils/filter-utils.ts | 4 +- src/utils/secret-scanner.ts | 31 ++++++++- tests/catalog.md | 1 + tests/unit/scripts/security.test.js | 17 +++++ tests/unit/utils/secret-scanner.test.ts | 91 +++++++++++++++++++++++-- 9 files changed, 180 insertions(+), 34 deletions(-) create mode 100644 tests/unit/scripts/security.test.js diff --git a/package.json b/package.json index 6d7fd9e..12f4110 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "prepare": "husky install", "sonar": "node scripts/sonar-scan.js", "qa": "node scripts/index.js qa", + "preqa:screenshot": "npm run build:ts", "qa:screenshot": "node scripts/capture-ui-screenshot.js", "security": "node scripts/index.js security", "gitleaks": "node scripts/index.js gitleaks", diff --git a/scripts/capture-ui-screenshot.js b/scripts/capture-ui-screenshot.js index 83a92d3..82595c8 100644 --- a/scripts/capture-ui-screenshot.js +++ b/scripts/capture-ui-screenshot.js @@ -12,6 +12,20 @@ const PORT = Number(process.env.UI_SCREENSHOT_PORT || 4173); const DEFAULT_SCREENSHOT_NAME = `ui-${process.platform}-${process.arch}.png`; const FIXED_MTIME = 1700000000000; +function loadSecretScannerHelpers() { + const compiledSecretScannerPath = path.join(ROOT_DIR, 'build', 'ts', 'utils', 'secret-scanner.js'); + + try { + return require(compiledSecretScannerPath); + } catch (_error) { + throw new Error( + 'Unable to load compiled secret scanner helpers. Run "npm run build:ts" before "npm run qa:screenshot".' + ); + } +} + +const { isSensitiveFilePath } = loadSecretScannerHelpers(); + const MIME_TYPES = { '.css': 'text/css; charset=UTF-8', '.html': 'text/html; charset=UTF-8', @@ -265,27 +279,12 @@ const MOCK_DIRECTORY_TREE = [ ]), ]; -function isSensitiveMockPath(itemPath) { - const normalizedPath = String(itemPath || '').replace(/\\/g, '/').toLowerCase(); - const name = normalizedPath.split('/').pop() || ''; - - if (/^\.env(?:\..+)?$/i.test(name)) { - return true; - } - - if (name === '.npmrc' || name === '.pypirc') { - return true; - } - - return /\.(pem|key|p12|pfx|jks|keystore|cer|crt|der|kdbx|asc)$/i.test(name); -} - function cloneAndFilterMockTree(items, excludeSensitiveFiles) { const filtered = []; for (const item of items) { if (item.type === 'file') { - if (excludeSensitiveFiles && isSensitiveMockPath(item.path)) { + if (excludeSensitiveFiles && isSensitiveFilePath(item.path)) { continue; } diff --git a/scripts/lib/security.js b/scripts/lib/security.js index f674d29..2e8bddc 100755 --- a/scripts/lib/security.js +++ b/scripts/lib/security.js @@ -13,6 +13,7 @@ const GITLEAKS_DIR = path.join(SECURITY_DIR, 'gitleaks'); const SBOM_DIR = path.join(SECURITY_DIR, 'sbom'); const RENOVATE_DIR = path.join(SECURITY_DIR, 'renovate'); const SAFE_COMMAND_PATTERN = /^[A-Za-z0-9._/\-]+$/; +const SAFE_WINDOWS_COMMAND_PATH_PATTERN = /^[A-Za-z0-9._/\\:()\- ]+$/; const ALLOWED_EXECUTABLES = new Set([ 'gh', 'gh.exe', @@ -31,14 +32,31 @@ function assertSafeCommand(command) { throw new Error('Command must be a non-empty string'); } - if (!SAFE_COMMAND_PATTERN.test(command) || command.includes('..')) { + if (command.includes('\0')) { + throw new Error(`Unsafe command rejected: ${command}`); + } + + const normalized = command.replace(/\\/g, '/'); + if (normalized.includes('..')) { + throw new Error(`Unsafe command rejected: ${command}`); + } + + const isWindowsAbsolutePath = /^[A-Za-z]:[\\/]/.test(command); + if (isWindowsAbsolutePath) { + if (!SAFE_WINDOWS_COMMAND_PATH_PATTERN.test(command)) { + throw new Error(`Unsafe command rejected: ${command}`); + } + return; + } + + if (!SAFE_COMMAND_PATTERN.test(command)) { throw new Error(`Unsafe command rejected: ${command}`); } } function assertAllowedExecutable(command) { assertSafeCommand(command); - const baseName = path.basename(command).toLowerCase(); + const baseName = path.basename(command.replace(/\\/g, '/')).toLowerCase(); if (!ALLOWED_EXECUTABLES.has(baseName)) { throw new Error(`Executable not allowed: ${baseName}`); @@ -540,4 +558,8 @@ module.exports = { runRenovateLocal, runMendScan, runSecurity, + __testUtils: { + assertSafeCommand, + assertAllowedExecutable, + }, }; diff --git a/src/utils/file-analyzer.ts b/src/utils/file-analyzer.ts index b8211a4..5977f0f 100755 --- a/src/utils/file-analyzer.ts +++ b/src/utils/file-analyzer.ts @@ -1,7 +1,7 @@ import fs from 'fs'; import path from 'path'; import { shouldExclude } from './filter-utils'; -import { scanContentForSecrets, shouldExcludeSuspiciousFiles } from './secret-scanner'; +import { scanContentForSecretsWithPolicy } from './secret-scanner'; import type { ConfigObject } from '../types/ipc'; import type { TokenCounter } from './token-counter'; import type { GitignorePatterns } from './gitignore-parser'; @@ -132,12 +132,10 @@ class FileAnalyzer { // Process text files only const content = fs.readFileSync(filePath, { encoding: 'utf-8', flag: 'r' }); - if (shouldExcludeSuspiciousFiles(this.config)) { - const secretScanResult = scanContentForSecrets(content); - if (secretScanResult.isSuspicious) { - console.warn(`Skipping suspicious file during analysis: ${filePath}`); - return null; - } + const secretScanResult = scanContentForSecretsWithPolicy(content, this.config); + if (secretScanResult.isSuspicious) { + console.warn(`Skipping suspicious file during analysis: ${filePath}`); + return null; } return this.tokenCounter.countTokens(content); diff --git a/src/utils/filter-utils.ts b/src/utils/filter-utils.ts index 95bb84e..e0f1a1b 100644 --- a/src/utils/filter-utils.ts +++ b/src/utils/filter-utils.ts @@ -1,7 +1,7 @@ import path from 'path'; import fnmatch from './fnmatch'; import type { ConfigObject } from '../types/ipc'; -import { isSensitiveFilePath, shouldExcludeSuspiciousFiles } from './secret-scanner'; +import { shouldExcludeSensitiveFilePath } from './secret-scanner'; type ExcludePatterns = string[] & { includePatterns?: string[]; includeExtensions?: string[] }; @@ -75,7 +75,7 @@ export const shouldExclude = ( const customExcludes = useCustomExcludes && Array.isArray(config?.exclude_patterns) ? config.exclude_patterns : []; - if (shouldExcludeSuspiciousFiles(config) && isSensitiveFilePath(itemPath)) { + if (shouldExcludeSensitiveFilePath(itemPath, config)) { return true; } diff --git a/src/utils/secret-scanner.ts b/src/utils/secret-scanner.ts index a373c7e..de43269 100644 --- a/src/utils/secret-scanner.ts +++ b/src/utils/secret-scanner.ts @@ -16,6 +16,7 @@ export interface SecretMatch { export interface SecretScanResult { isSuspicious: boolean; matches: SecretMatch[]; + error?: string; } const SECRET_RULES: SecretRule[] = [ @@ -79,6 +80,11 @@ const normalizeFilePath = (filePath: string): string => filePath.replace(/\\/g, export const shouldExcludeSuspiciousFiles = (config?: ConfigObject): boolean => config?.enable_secret_scanning !== false && config?.exclude_suspicious_files !== false; +const cleanSecretScanResult = (): SecretScanResult => ({ + isSuspicious: false, + matches: [], +}); + export const isSensitiveFilePath = (filePath: string): boolean => { const normalizedPath = normalizeFilePath(filePath); const fileName = path.basename(normalizedPath); @@ -101,6 +107,9 @@ export const isSensitiveFilePath = (filePath: string): boolean => { }); }; +export const shouldExcludeSensitiveFilePath = (filePath: string, config?: ConfigObject): boolean => + shouldExcludeSuspiciousFiles(config) && isSensitiveFilePath(filePath); + export const scanContentForSecrets = (content: string): SecretScanResult => { const matches: SecretMatch[] = []; @@ -116,15 +125,33 @@ export const scanContentForSecrets = (content: string): SecretScanResult => { }; }; +export const scanContentForSecretsWithPolicy = ( + content: string, + config?: ConfigObject +): SecretScanResult => { + if (!shouldExcludeSuspiciousFiles(config)) { + return cleanSecretScanResult(); + } + + return scanContentForSecrets(content); +}; + export const scanFileForSecrets = (filePath: string): SecretScanResult => { try { const content = fs.readFileSync(filePath, { encoding: 'utf-8', flag: 'r' }); return scanContentForSecrets(content); } catch (error) { console.error(`Error scanning file for secrets: ${filePath}`, error); + const errorMessage = error instanceof Error ? error.message : String(error); return { - isSuspicious: false, - matches: [], + isSuspicious: true, + matches: [ + { + id: 'scan-read-error', + description: 'Unable to read file while scanning for secrets', + }, + ], + error: errorMessage, }; } }; diff --git a/tests/catalog.md b/tests/catalog.md index e2be5b5..0f81159 100644 --- a/tests/catalog.md +++ b/tests/catalog.md @@ -24,6 +24,7 @@ Purpose: quick map of what is covered, why it exists, and which command to run. | `tests/unit/utils/content-processor.test.ts` | `src/utils/content-processor.ts` | Content assembly, binary skip logic, malformed input handling | | `tests/unit/utils/config-manager.test.ts` | `src/utils/config-manager.ts` | Default config load, parse failures, graceful fallback behavior | | `tests/unit/utils/token-counter.test.ts` | `src/utils/token-counter.ts` | Token counting basics, empty/null input handling | +| `tests/unit/scripts/security.test.js` | `scripts/lib/security.js` | Command safety validation, Windows path acceptance for approved executables | ## Integration Tests diff --git a/tests/unit/scripts/security.test.js b/tests/unit/scripts/security.test.js new file mode 100644 index 0000000..10a9ad5 --- /dev/null +++ b/tests/unit/scripts/security.test.js @@ -0,0 +1,17 @@ +const { __testUtils } = require('../../../scripts/lib/security'); + +describe('security command validation', () => { + test('allows absolute Windows executable paths for approved binaries', () => { + expect(() => __testUtils.assertAllowedExecutable('C:\\repo\\bin\\gitleaks.exe')).not.toThrow(); + }); + + test('rejects unsafe traversal in command paths', () => { + expect(() => __testUtils.assertSafeCommand('../bin/gitleaks')).toThrow('Unsafe command rejected'); + }); + + test('rejects shell metacharacters in command paths', () => { + expect(() => __testUtils.assertSafeCommand('gitleaks;rm -rf /')).toThrow( + 'Unsafe command rejected' + ); + }); +}); diff --git a/tests/unit/utils/secret-scanner.test.ts b/tests/unit/utils/secret-scanner.test.ts index 4528197..dab9481 100644 --- a/tests/unit/utils/secret-scanner.test.ts +++ b/tests/unit/utils/secret-scanner.test.ts @@ -1,7 +1,9 @@ const fs = require('fs'); const { isSensitiveFilePath, + shouldExcludeSensitiveFilePath, scanContentForSecrets, + scanContentForSecretsWithPolicy, scanFileForSecrets, shouldExcludeSuspiciousFiles, } = require('../../../src/utils/secret-scanner'); @@ -10,6 +12,9 @@ jest.mock('fs'); const FAKE_GITHUB_TOKEN = ['ghp', 'AAAAAAAAAAAAAAAAAAAAAAAA'].join('_'); const FAKE_AWS_SECRET_ACCESS_KEY = 'A'.repeat(40); +const FAKE_SLACK_TOKEN = ['xoxb', '123456789012', '123456789012', 'aaaaaaaaaaaaaaaaaaaa'].join('-'); +const FAKE_STRIPE_SECRET_KEY = ['sk_live', 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'].join('_'); +const FAKE_JWT = ['eyJ0eXAiOiJKV1Qi', 'eyJzdWIiOiJ0ZXN0LXVzZXIifQ', 'c2lnbmF0dXJlMTIzNDU2'].join('.'); describe('secret-scanner', () => { beforeEach(() => { @@ -46,6 +51,22 @@ describe('secret-scanner', () => { }); }); + describe('shouldExcludeSensitiveFilePath', () => { + test('should exclude sensitive paths by default', () => { + expect(shouldExcludeSensitiveFilePath('/repo/.env')).toBe(true); + expect(shouldExcludeSensitiveFilePath('/repo/.aws/credentials')).toBe(true); + }); + + test('should keep sensitive paths when secret scanning is disabled', () => { + expect(shouldExcludeSensitiveFilePath('/repo/.env', { enable_secret_scanning: false })).toBe( + false + ); + expect(shouldExcludeSensitiveFilePath('/repo/.env', { exclude_suspicious_files: false })).toBe( + false + ); + }); + }); + describe('scanContentForSecrets', () => { test('should detect known secret patterns', () => { const content = ` @@ -58,6 +79,47 @@ describe('secret-scanner', () => { expect(result.matches.length).toBeGreaterThan(0); }); + test('should detect AWS secret key assignments independently', () => { + const content = `AWS_SECRET_ACCESS_KEY="${FAKE_AWS_SECRET_ACCESS_KEY}"`; + + const result = scanContentForSecrets(content); + expect(result.isSuspicious).toBe(true); + expect(result.matches.some((match) => match.id === 'aws-secret-assignment')).toBe(true); + }); + + test('should detect Slack tokens', () => { + const content = `const slackToken = "${FAKE_SLACK_TOKEN}";`; + + const result = scanContentForSecrets(content); + expect(result.isSuspicious).toBe(true); + expect(result.matches.some((match) => match.id === 'slack-token')).toBe(true); + }); + + test('should detect Stripe secret keys', () => { + const content = `const stripeSecretKey = "${FAKE_STRIPE_SECRET_KEY}";`; + + const result = scanContentForSecrets(content); + expect(result.isSuspicious).toBe(true); + expect(result.matches.some((match) => match.id === 'stripe-secret-key')).toBe(true); + }); + + test('should detect JWT-like tokens', () => { + const content = `const jwt = "${FAKE_JWT}";`; + + const result = scanContentForSecrets(content); + expect(result.isSuspicious).toBe(true); + expect(result.matches.some((match) => match.id === 'jwt-token')).toBe(true); + }); + + test('should detect generic credential assignments', () => { + const secretPassword = ['test', 'password', '0001'].join('-'); + const content = `const password = "${secretPassword}";`; + + const result = scanContentForSecrets(content); + expect(result.isSuspicious).toBe(true); + expect(result.matches.some((match) => match.id === 'generic-credential-assignment')).toBe(true); + }); + test('should return clean result for normal content', () => { const content = ` export const sum = (a, b) => a + b; @@ -72,6 +134,26 @@ describe('secret-scanner', () => { }); }); + describe('scanContentForSecretsWithPolicy', () => { + test('should scan content when secret scanning policy is enabled', () => { + const result = scanContentForSecretsWithPolicy(`const key = "${FAKE_GITHUB_TOKEN}";`); + + expect(result.isSuspicious).toBe(true); + expect(result.matches.length).toBeGreaterThan(0); + }); + + test('should skip content scan when secret scanning policy is disabled', () => { + const result = scanContentForSecretsWithPolicy(`const key = "${FAKE_GITHUB_TOKEN}";`, { + enable_secret_scanning: false, + }); + + expect(result).toEqual({ + isSuspicious: false, + matches: [], + }); + }); + }); + describe('scanFileForSecrets', () => { test('should detect secrets when file contains sensitive content', () => { fs.readFileSync.mockReturnValue(`const key = "${FAKE_GITHUB_TOKEN}";`); @@ -81,16 +163,15 @@ describe('secret-scanner', () => { expect(result.matches.length).toBeGreaterThan(0); }); - test('should fail safely if file cannot be read', () => { + test('should fail closed if file cannot be read', () => { fs.readFileSync.mockImplementation(() => { throw new Error('read error'); }); const result = scanFileForSecrets('/repo/src/missing.ts'); - expect(result).toEqual({ - isSuspicious: false, - matches: [], - }); + expect(result.isSuspicious).toBe(true); + expect(result.error).toBe('read error'); + expect(result.matches.some((match) => match.id === 'scan-read-error')).toBe(true); }); }); });