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..12f4110 100644 --- a/package.json +++ b/package.json @@ -31,9 +31,11 @@ "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", + "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/capture-ui-screenshot.js b/scripts/capture-ui-screenshot.js index d9fd675..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', @@ -104,6 +118,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 +229,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 +279,30 @@ const MOCK_DIRECTORY_TREE = [ ]), ]; -const MOCK_TOTAL_FILE_COUNT = countMockFiles(MOCK_DIRECTORY_TREE); +function cloneAndFilterMockTree(items, excludeSensitiveFiles) { + const filtered = []; + + for (const item of items) { + if (item.type === 'file') { + if (excludeSensitiveFiles && isSensitiveFilePath(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_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; @@ -279,25 +319,41 @@ 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', }; 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, - 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 tree = excludeSensitiveFiles ? mockFilteredDirectoryTree : mockDirectoryTree; + return cloneTree(tree); + }, analyzeRepository: async () => ({ totalFiles: 0, totalTokens: 0, @@ -337,6 +393,7 @@ async function setupMockElectronApi(page) { mockRootPath: MOCK_ROOT_PATH, mockConfig: MOCK_CONFIG, mockDirectoryTree: MOCK_DIRECTORY_TREE, + mockFilteredDirectoryTree: MOCK_FILTERED_DIRECTORY_TREE, fixedMtime: FIXED_MTIME, } ); @@ -384,13 +441,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/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..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}`); @@ -175,6 +193,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,9 +552,14 @@ async function runSecurity() { module.exports = { runGitleaks, + runGitleaksStaged, runSbom, runRenovate, runRenovateLocal, runMendScan, runSecurity, + __testUtils: { + assertSafeCommand, + assertAllowedExecutable, + }, }; 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..5977f0f 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 { scanContentForSecretsWithPolicy } from './secret-scanner'; import type { ConfigObject } from '../types/ipc'; import type { TokenCounter } from './token-counter'; import type { GitignorePatterns } from './gitignore-parser'; @@ -130,6 +131,13 @@ class FileAnalyzer { // Process text files only const content = fs.readFileSync(filePath, { encoding: 'utf-8', flag: 'r' }); + + const secretScanResult = scanContentForSecretsWithPolicy(content, this.config); + 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..e0f1a1b 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 { shouldExcludeSensitiveFilePath } 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 (shouldExcludeSensitiveFilePath(itemPath, config)) { + 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..de43269 --- /dev/null +++ b/src/utils/secret-scanner.ts @@ -0,0 +1,157 @@ +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[]; + error?: string; +} + +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; + +const cleanSecretScanResult = (): SecretScanResult => ({ + isSuspicious: false, + matches: [], +}); + +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) => { + const normalizedSegment = segment.toLowerCase(); + return ( + normalizedPath === normalizedSegment || + normalizedPath.endsWith(`/${normalizedSegment}`) || + normalizedPath.includes(`/${normalizedSegment}/`) + ); + }); +}; + +export const shouldExcludeSensitiveFilePath = (filePath: string, config?: ConfigObject): boolean => + shouldExcludeSuspiciousFiles(config) && isSensitiveFilePath(filePath); + +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 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: 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 f912889..0f81159 100644 --- a/tests/catalog.md +++ b/tests/catalog.md @@ -19,10 +19,12 @@ 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 | | `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 @@ -33,9 +35,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 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..370e78c 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/); @@ -92,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(); diff --git a/tests/unit/file-analyzer.test.ts b/tests/unit/file-analyzer.test.ts index 3847ea7..c04ab59 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,56 @@ 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(); + }); + + 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/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/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..dab9481 --- /dev/null +++ b/tests/unit/utils/secret-scanner.test.ts @@ -0,0 +1,177 @@ +const fs = require('fs'); +const { + isSensitiveFilePath, + shouldExcludeSensitiveFilePath, + scanContentForSecrets, + scanContentForSecretsWithPolicy, + 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); +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(() => { + 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('.aws/credentials')).toBe(true); + expect(isSensitiveFilePath('.npmrc')).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('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 = ` + 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 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; + console.log("hello"); + `; + + const result = scanContentForSecrets(content); + expect(result).toEqual({ + isSuspicious: false, + matches: [], + }); + }); + }); + + 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}";`); + + const result = scanFileForSecrets('/repo/src/config.ts'); + expect(result.isSuspicious).toBe(true); + expect(result.matches.length).toBeGreaterThan(0); + }); + + 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.isSuspicious).toBe(true); + expect(result.error).toBe('read error'); + expect(result.matches.some((match) => match.id === 'scan-read-error')).toBe(true); + }); + }); +});