Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions .husky/pre-commit
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"

set -e

# Run lint-staged (works on both Windows with Git Bash and Linux)
npx lint-staged

# Block commits that introduce secrets.
npm run gitleaks
npm run gitleaks:staged
3 changes: 3 additions & 0 deletions .husky/pre-commit.bat
Original file line number Diff line number Diff line change
@@ -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%
9 changes: 7 additions & 2 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
94 changes: 90 additions & 4 deletions scripts/capture-ui-screenshot.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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:',
Expand Down Expand Up @@ -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'),
Expand Down Expand Up @@ -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;
Expand All @@ -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);
},
Comment thread
Mehdi-Bl marked this conversation as resolved.
analyzeRepository: async () => ({
totalFiles: 0,
totalTokens: 0,
Expand Down Expand Up @@ -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,
}
);
Expand Down Expand Up @@ -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();
});
Expand Down
5 changes: 5 additions & 0 deletions scripts/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
53 changes: 51 additions & 2 deletions scripts/lib/security.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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}`);
Expand Down Expand Up @@ -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);

Comment thread
Mehdi-Bl marked this conversation as resolved.
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();

Expand Down Expand Up @@ -508,9 +552,14 @@ async function runSecurity() {

module.exports = {
runGitleaks,
runGitleaksStaged,
runSbom,
runRenovate,
runRenovateLocal,
runMendScan,
runSecurity,
__testUtils: {
assertSafeCommand,
assertAllowedExecutable,
},
};
Loading
Loading