From 71753c0b4e43a6d00199a2dcab900589b87af2a1 Mon Sep 17 00:00:00 2001 From: Mehdi Date: Sun, 8 Feb 2026 18:09:30 +0000 Subject: [PATCH 1/4] feat: restore QA automation and streamline docs on TS branch --- .github/workflows/dependency-review.yml | 2 +- .github/workflows/release.yml | 2 +- .github/workflows/sbom.yml | 11 +- Makefile | 25 +- README.md | 127 ++------ docs/DEVELOPMENT.md | 172 +++------- docs/UPDATES_AND_SIGNING.md | 72 +++++ make.ps1 | 83 +++++ package.json | 11 +- scripts/index.js | 36 +++ scripts/lib/security.js | 413 ++++++++++++++++++++++++ scripts/lib/utils.js | 15 +- 12 files changed, 730 insertions(+), 239 deletions(-) create mode 100644 docs/UPDATES_AND_SIGNING.md create mode 100644 make.ps1 create mode 100755 scripts/lib/security.js diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index d3c5679..266713a 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -24,6 +24,6 @@ jobs: persist-credentials: false - name: 'Dependency Review' - uses: actions/dependency-review-action@56339e523c0409420f6c2c9a2f4292bbb3c07dd3 + uses: actions/dependency-review-action@3c4e3dcb1aa7874d2c16be7d79418e9b7efd6261 with: comment-summary-in-pr: always diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8751378..074db0b 100755 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -199,7 +199,7 @@ jobs: path: artifacts/macos - name: Create Release - uses: softprops/action-gh-release@6da8fa9354ddfdc4aeace5fc48d7f679b5214090 + uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b with: name: Release ${{ steps.get_version.outputs.VERSION }} body: ${{ steps.changelog_reader.outputs.changes || 'No changelog provided' }} diff --git a/.github/workflows/sbom.yml b/.github/workflows/sbom.yml index c6b6b10..e204a9b 100644 --- a/.github/workflows/sbom.yml +++ b/.github/workflows/sbom.yml @@ -27,17 +27,10 @@ jobs: package-manager-cache: false - name: Install dependencies - run: | - if [ -f package-lock.json ]; then - npm ci --ignore-scripts - else - npm install --ignore-scripts --no-audit --no-fund - fi + run: npm ci --ignore-scripts - name: Generate CycloneDX SBOM - run: | - mkdir -p dist/security/sbom - npx --yes @cyclonedx/cyclonedx-npm --output-format json --output-file dist/security/sbom/sbom.cyclonedx.json + run: npm run sbom - name: Upload SBOM artifact uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f diff --git a/Makefile b/Makefile index 4dae620..c6cd6b2 100755 --- a/Makefile +++ b/Makefile @@ -5,7 +5,9 @@ # Make these targets phony (they don't create files with these names) .PHONY: all setup dev clean clean-all build build-win build-linux \ build-mac build-mac-arm build-mac-universal \ - test css css-watch lint format validate setup-hooks sonar icons sample-logo release + test css css-watch lint format validate qa setup-hooks sonar \ + security gitleaks sbom renovate renovate-local mend-scan \ + icons sample-logo release # Set executable permissions for scripts setup-scripts: @@ -63,12 +65,33 @@ format: setup-scripts validate: setup-scripts @node scripts/index.js validate +qa: setup-scripts + @node scripts/index.js qa + setup-hooks: setup-scripts @node scripts/index.js hooks sonar: setup-scripts @node scripts/index.js sonar +security: setup-scripts + @node scripts/index.js security + +gitleaks: setup-scripts + @node scripts/index.js gitleaks + +sbom: setup-scripts + @node scripts/index.js sbom + +renovate: setup-scripts + @node scripts/index.js renovate + +renovate-local: setup-scripts + @node scripts/index.js renovate-local + +mend-scan: setup-scripts + @node scripts/index.js mend-scan + release: setup-scripts @node scripts/index.js release $(VERSION) diff --git a/README.md b/README.md index 1028125..43fa5e3 100755 --- a/README.md +++ b/README.md @@ -2,130 +2,51 @@ ## Features -A desktop application for preparing and optimizing code repositories for AI processing and analysis. It helps you select specific files, count tokens, and process source code for AI systems. +A desktop app to prepare code repositories for AI workflows. - Visual directory explorer for selecting code files -- Advanced file filtering with customizable patterns -- Accurate token counting for various AI models -- Code content processing with statistics +- File filtering with custom patterns and `.gitignore` support +- Token counting support for selected files +- Processed output ready to copy/export for AI tools - Cross-platform support (Windows, macOS, Linux) -## Installation +## Download Release -### Download +Download the latest packaged build from GitHub Releases: +https://github.com/codingworkflow/ai-code-fusion/releases -Download the latest version for your platform from the [Releases page](https://github.com/codingworkflow/ai-code-fusion/releases). +- Windows: download and run the `.exe` installer +- macOS: download the `.dmg`, drag app to Applications +- Linux: download the `.AppImage`, then run: -### Windows - -1. Download the `.exe` installer -2. Run the installer and follow the instructions -3. Launch from the Start Menu or desktop shortcut - -### macOS - -1. Download the `.dmg` file -2. Open the DMG and drag the application to your Applications folder -3. Launch from Applications - -### Linux - -1. Download the `.AppImage` file -2. Make it executable: `chmod +x AI.Code.Prep-*.AppImage` -3. Run it: `./AI.Code.Prep-*.AppImage` - -## Usage Guide - -### 1. Start and Filters - -The application now features both Dark and Light modes for improved user experience. -![Start Panel Dark Mode](assets/ai_code_fusion_1.jpg) - -![Start Panel Light Mode](assets/ai_code_fusion_2.jpg) - -Extended file filtering options: - -- Exclude specific file types and patterns (using glob patterns) to remove build folders, venv, node_modules, .git from tree view and file selection -- Automatically exclude files based on .gitignore files in your repository -- Reduce selection to only the file extensions you specify -- Display token count in real-time during selection (can be disabled for very large repositories) -- Include file tree in output (recommended for better context in AI models) - -### 2. File Selection - -Select specific files and directories to analyze and process. - -![Analysis Panel](assets/ai_code_fusion_3.jpg) - -- Browse and select your root project directory -- Use the tree view to select specific files or folders -- See file counts and token sizes in real-time (when token display is enabled) - -### 3. Final Processing - -Generate the processed output ready for use with AI systems. - -![Processing Panel](assets/ai_code_fusion_4.jpg) - -- View the final processed content ready for AI systems -- Copy content directly to clipboard for immediate use -- Export to file for later reference -- Review files by token count to help identify large files you may want to exclude +```bash +chmod +x *.AppImage +./*.AppImage +``` -## Building from Source +## Build from Source -### Prerequisites +Requirements: -- Node.js (v14 or later) +- Node.js (v20 or later) - npm - Git -### Setup - ```bash -# Clone the repository git clone https://github.com/codingworkflow/ai-code-fusion cd ai-code-fusion -# Install dependencies -make setup -# or -npm install +npm ci +npm run build:webpack +npm run build ``` -### Development - -```bash -# Start the development server -make dev -# or -npm run dev -``` - -#### Troubleshooting Development - -If you encounter issues with the development server: - -1. For Windows users: The `make.bat` file has special handling for the `dev` command that properly sets environment variables -2. If you're still having issues: - - Ensure all dependencies are installed: `npm install` - - Try rebuilding: `npm run rebuild` - - Close any running instances of the app - - Restart your terminal/command prompt - - As a last resort, try direct electron launch: `npm run dev:direct` - -### Building +Optional platform-specific builds: ```bash -# Build for current platform -make build - -# Build for specific platforms -make build-win # Windows -make build-linux # Linux -make build-mac # macOS (Intel) -make build-mac-arm # macOS (Apple Silicon) -make build-mac-universal # macOS (Universal) +npm run build:win +npm run build:linux +npm run build:mac ``` ## License diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index b895a61..4b7868e 100755 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -1,166 +1,98 @@ # Development Guide -This document provides detailed information for developers working on the AI Code Fusion project. +## Prerequisites -## Development Environment Setup - -### Prerequisites - -- Node.js (v14 or later) +- Node.js (v20 or later) - npm - Git -### Platform-Specific Build Instructions +## Command Entry Points -#### Windows +### Linux/macOS -Use the included `make.bat` file for all build commands: +Use the repository `Makefile`: -```cmd +```bash make ``` -#### Linux/macOS +### Windows -Use the included `Makefile`: +Use `make.bat` from Command Prompt: -```bash +```cmd make ``` -### Common Make Commands +If the repository is opened via a WSL UNC path (`\\wsl.localhost\...`), use PowerShell: + +```powershell +.\make.ps1 +``` + +## Common Commands ```bash -# Install dependencies and set up the project +# Setup / development make setup - -# Start development server make dev -# Build for current platform +# Build make build - -# Build for Windows make build-win - -# Build for Linux make build-linux - -# Build for Mac make build-mac -# Run tests +# Quality make test - -# Run tests in watch mode -make test-watch - -# Run linter make lint - -# Format code make format - -# Run all code quality checks make validate +make qa + +# Security / dependency automation +make security +make gitleaks +make sbom +make renovate +make renovate-local +make mend-scan ``` -### Manual Setup - -If you prefer not to use the make commands: - -```bash -# Install dependencies -npm install - -# Build CSS -npm run build:css - -# Start development server -npm run dev -``` - -## Troubleshooting - -If you encounter issues with the development server: - -1. Clean the build outputs and reinstall dependencies: +## Security and Dependency Automation - ```bash - make clean - make fix-deps - ``` +- `make security` runs `gitleaks` + `sbom`. +- `make gitleaks` writes `dist/security/gitleaks/gitleaks-report.json`. +- `make sbom` writes `dist/security/sbom/sbom.cyclonedx.json`. +- `make renovate` runs Renovate against the remote repository. +- `make renovate-local` runs a local dry-run and writes `dist/security/renovate/renovate-local-report.json`. +- `make mend-scan` runs Mend Unified Agent if installed. -2. Make sure the CSS is built before starting the dev server: +Renovate token sources (in order): - ```bash - npm run build:css - ``` +- `RENOVATE_TOKEN` +- `GITHUB_TOKEN` +- `GH_TOKEN` +- `GITHUB_COM_TOKEN` +- `RENOVATE_TOKEN_FILE` +- `gh auth token` (GitHub CLI fallback) -3. Start the development server: - - ```bash - npm run dev - ``` - -If you encounter any issues with tiktoken or minimatch, you may need to install them separately: +## Manual Setup (Without Make) ```bash -npm install tiktoken minimatch +npm ci +npm run build:ts +npm run build:css +npm run build:webpack +npm run dev ``` -## Testing - -Tests are located in the `src/__tests__` directory. To add new tests: - -1. Create a file with the `.test.js` or `.test.jsx` extension in the `src/__tests__` directory -2. Use Jest and React Testing Library for component tests -3. Run tests with `make test` or `npm run test` +## Release ```bash -# Run a specific test file -make test-file FILE=src/__tests__/token-counter.test.js +node scripts/index.js release ``` -## Release Process - -For project maintainers, follow these steps to create a new release: - -1. Ensure all changes are committed to the main branch -2. Run the release preparation script using either method: - - ```bash - # Using the scripts/index.js entry point (recommended) - node scripts/index.js release - - # OR using the direct script - node scripts/prepare-release.js - ``` - - Where `` can be: - - - A specific version number (e.g., `1.0.0`) - - `patch` - increment the patch version - - `minor` - increment the minor version - - `major` - increment the major version - -3. Enter the changelog entries when prompted -4. Confirm git tag creation when prompted -5. Push the changes and tag to GitHub: - - ```bash - git push && git push origin v - ``` - -6. The GitHub Actions workflow will automatically: - - Build the application for Windows, macOS, and Linux - - Create a GitHub Release - - Upload the builds as release assets -7. Go to the GitHub releases page to review the draft release and publish it - -## Project Structure +Where `` is a semantic version (`1.2.3`) or one of `patch`, `minor`, `major`. -- `/src/main` - Electron main process code -- `/src/renderer` - React application for the renderer process -- `/src/utils` - Shared utilities -- `/src/assets` - Static assets +For update metadata and signing rollout planning, see `docs/UPDATES_AND_SIGNING.md`. diff --git a/docs/UPDATES_AND_SIGNING.md b/docs/UPDATES_AND_SIGNING.md new file mode 100644 index 0000000..ab584ef --- /dev/null +++ b/docs/UPDATES_AND_SIGNING.md @@ -0,0 +1,72 @@ +# Updates and Signing + +This document defines the lightweight auto-update flow and the signing rollout plan for Windows, macOS, and Linux. + +Current status: releases are intentionally unsigned until certificates and notarization credentials are ready. + +## Current update flow + +The app is planned to use `electron-updater` through IPC-safe handlers in the main process: + +- `updates:getStatus` returns updater state +- `updates:check` triggers a manual check +- `updates:download` downloads an available update +- `updates:quitAndInstall` restarts and installs a downloaded update + +Production behavior target: + +- Updater initializes only for packaged production builds +- Automatic background check runs shortly after startup and then every 6 hours + +## Release artifact requirements + +For `electron-updater` to work, release assets must include metadata files in addition to installers: + +- Windows: installer + `latest*.yml` + `.blockmap` +- macOS: `.zip`/`.dmg` + `latest*.yml` + `.blockmap` +- Linux (AppImage): `.AppImage` + `latest*.yml` (+ `.zsync` when generated) + +The `release.yml` workflow is configured to upload these files. + +## Signing plan + +### 1) Windows signing (Authenticode) + +Use an OV/EV code-signing certificate and configure GitHub secrets: + +- `WINDOWS_CSC_LINK` +- `WINDOWS_CSC_KEY_PASSWORD` + +Electron Builder signs automatically when these are present. + +### 2) macOS signing + notarization + +Use Apple Developer ID Application certificate and notarization credentials: + +- `MACOS_CSC_LINK` +- `MACOS_CSC_KEY_PASSWORD` +- `APPLE_ID` +- `APPLE_ID_PASSWORD` +- `APPLE_TEAM_ID` + +Recommended next step: add explicit notarization validation in CI logs and fail the build if notarization fails. + +### 3) Linux signing (optional) + +Linux app-signing is distribution-specific and less standardized than Windows/macOS: + +- AppImage: optional GPG signing + checksum publication +- Debian/RPM: sign repository metadata and package artifacts + +Pragmatic baseline: + +- Publish SHA256 checksums for Linux artifacts in each release +- Add optional GPG detached signatures for `.AppImage` + +## Rollout checklist + +1. Merge changes and create a release tag (`vX.Y.Z`). +2. Confirm release assets include installers plus update metadata (`latest*.yml`, `.blockmap`, `.zsync`). +3. Configure signing secrets in repository settings. +4. Produce first signed release on Windows and macOS. +5. Validate update path end-to-end from previous version to latest version on each OS. diff --git a/make.ps1 b/make.ps1 new file mode 100644 index 0000000..a01f5d1 --- /dev/null +++ b/make.ps1 @@ -0,0 +1,83 @@ +#!/usr/bin/env pwsh +param( + [Parameter(ValueFromRemainingArguments = $true)] + [string[]]$CommandArgs +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Remove-ProviderPrefix { + param([string]$PathValue) + + if ($null -eq $PathValue) { + return '' + } + + return ($PathValue -replace '^Microsoft\.PowerShell\.Core\\FileSystem::', '') +} + +function Quote-BashArg { + param([string]$Value) + + if ($null -eq $Value -or $Value.Length -eq 0) { + return "''" + } + + return "'" + ($Value -replace "'", "'\"'\"'") + "'" +} + +$scriptRoot = Split-Path -Parent $MyInvocation.MyCommand.Path +$scriptRoot = Remove-ProviderPrefix $scriptRoot +$indexScript = Join-Path $scriptRoot 'scripts/index.js' + +if (-not (Test-Path -LiteralPath $indexScript)) { + Write-Error "Cannot find script entry point: $indexScript" +} + +$uncPattern = '^\\\\(?:wsl\.localhost|wsl\$)\\([^\\]+)\\(.+)$' +$wslMatch = [regex]::Match($scriptRoot, $uncPattern) + +if ($wslMatch.Success) { + if (-not (Get-Command wsl.exe -ErrorAction SilentlyContinue)) { + Write-Error 'wsl.exe was not found. Install/enable WSL or run from a local Windows path.' + } + + $distro = $wslMatch.Groups[1].Value + $distroPath = $wslMatch.Groups[2].Value + $linuxRoot = '/' + ($distroPath -replace '\\', '/') + + $bashCommandParts = @( + 'cd', + (Quote-BashArg $linuxRoot), + '&&', + 'node', + 'scripts/index.js' + ) + + foreach ($arg in $CommandArgs) { + $bashCommandParts += (Quote-BashArg $arg) + } + + $bashCommand = $bashCommandParts -join ' ' + & wsl.exe -d $distro -- bash -lc $bashCommand + $exitCode = $LASTEXITCODE +} else { + if (-not (Get-Command node -ErrorAction SilentlyContinue)) { + Write-Error 'Node.js is required but was not found in PATH.' + } + + Push-Location -LiteralPath $scriptRoot + try { + & node $indexScript @CommandArgs + $exitCode = $LASTEXITCODE + } finally { + Pop-Location + } +} + +if ($null -eq $exitCode) { + $exitCode = 0 +} + +exit $exitCode diff --git a/package.json b/package.json index 3c87c17..6d82767 100755 --- a/package.json +++ b/package.json @@ -17,8 +17,8 @@ "predev": "npm run build:ts && node scripts/clean-dev-assets.js", "dev": "node scripts/index.js dev", "clear-assets": "rimraf src/renderer/bundle.js src/renderer/bundle.js.map src/renderer/bundle.js.LICENSE.txt src/renderer/output.css", - "lint": "eslint src tests --ext .js,.jsx,.ts,.tsx --cache", - "lint:tests": "eslint tests --ext .js,.jsx,.ts,.tsx --cache", + "lint": "cross-env ESLINT_USE_FLAT_CONFIG=false eslint src tests --ext .js,.jsx,.ts,.tsx --cache", + "lint:tests": "cross-env ESLINT_USE_FLAT_CONFIG=false eslint tests --ext .js,.jsx,.ts,.tsx --cache", "format": "prettier --write \"**/*.{js,jsx,ts,tsx,json,md,html,css}\"", "test": "jest --config jest.config.js --passWithNoTests", "test:watch": "jest --watch --config jest.config.js --passWithNoTests", @@ -30,6 +30,13 @@ "build:css": "npx tailwindcss -i ./src/renderer/styles.css -o ./src/renderer/output.css", "prepare": "husky install", "sonar": "node scripts/sonar-scan.js", + "qa": "node scripts/index.js qa", + "security": "node scripts/index.js security", + "gitleaks": "node scripts/index.js gitleaks", + "sbom": "node scripts/index.js sbom", + "renovate": "node scripts/index.js renovate", + "renovate:local": "node scripts/index.js renovate-local", + "mend:scan": "node scripts/index.js mend-scan", "release": "node scripts/index.js release", "clean": "node scripts/index.js clean", "setup": "node scripts/index.js setup", diff --git a/scripts/index.js b/scripts/index.js index 84d9d73..c641bc6 100755 --- a/scripts/index.js +++ b/scripts/index.js @@ -21,6 +21,7 @@ const utils = require('./lib/utils'); const build = require('./lib/build'); const dev = require('./lib/dev'); const release = require('./lib/release'); +const security = require('./lib/security'); // Get the command from first argument const [command, ...args] = process.argv.slice(2); @@ -125,6 +126,41 @@ async function executeCommand() { console.log('All validations passed!'); break; + case 'qa': + console.log('Running QA checks (lint + test + security)...'); + await utils.runNpmScript('lint'); + await utils.runNpmScript('test'); + await security.runSecurity(); + console.log('QA checks completed successfully'); + break; + + // Security automation commands + case 'security': + await security.runSecurity(); + break; + + case 'gitleaks': + await security.runGitleaks(); + break; + + case 'sbom': + await security.runSbom(); + break; + + case 'renovate': + await security.runRenovate(args); + break; + + case 'renovate-local': + case 'renovate:local': + await security.runRenovateLocal(args); + break; + + case 'mend-scan': + case 'mend:scan': + await security.runMendScan(); + break; + // Asset management commands case 'icons': await build.generateIcons(); diff --git a/scripts/lib/security.js b/scripts/lib/security.js new file mode 100755 index 0000000..076d6f8 --- /dev/null +++ b/scripts/lib/security.js @@ -0,0 +1,413 @@ +/** + * Security and dependency automation commands. + */ + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { execSync, spawnSync } = require('child_process'); +const utils = require('./utils'); + +const SECURITY_DIR = path.join(utils.ROOT_DIR, 'dist', 'security'); +const GITLEAKS_DIR = path.join(SECURITY_DIR, 'gitleaks'); +const SBOM_DIR = path.join(SECURITY_DIR, 'sbom'); +const RENOVATE_DIR = path.join(SECURITY_DIR, 'renovate'); + +function ensureSecurityDirs() { + utils.ensureDir(SECURITY_DIR); + utils.ensureDir(GITLEAKS_DIR); + utils.ensureDir(SBOM_DIR); + utils.ensureDir(RENOVATE_DIR); +} + +function hasCommand(command) { + const checkCommand = process.platform === 'win32' ? `where ${command}` : `command -v ${command}`; + + try { + execSync(checkCommand, { stdio: 'ignore' }); + return true; + } catch (_error) { + return false; + } +} + +function getCommandCandidates(command) { + if (process.platform === 'win32') { + return [`${command}.exe`, `${command}.cmd`, `${command}.bat`, command]; + } + + return [command]; +} + +function resolveCommand(command, localCandidates = []) { + for (const candidate of getCommandCandidates(command)) { + if (hasCommand(candidate)) { + return candidate; + } + } + + for (const candidate of localCandidates) { + const absolutePath = path.isAbsolute(candidate) ? candidate : path.join(utils.ROOT_DIR, candidate); + if (fs.existsSync(absolutePath)) { + return absolutePath; + } + } + + return null; +} + +function runCommand(command, args = [], options = {}) { + const sanitizedArgs = args.map((arg) => { + if (/^--token=/.test(arg) || /^--\w*token=/.test(arg)) { + const [key] = arg.split('='); + return `${key}=***`; + } + return arg; + }); + const commandLine = [command, ...sanitizedArgs].join(' '); + console.log(`Running: ${commandLine}`); + + const result = spawnSync(command, args, { + cwd: utils.ROOT_DIR, + stdio: 'inherit', + env: { ...process.env, ...(options.env || {}) }, + shell: false, + }); + + if (result.error) { + throw new Error(`Failed to start command '${command}': ${result.error.message}`); + } + + if (result.status !== 0) { + throw new Error(`Command failed (${result.status}): ${commandLine}`); + } + + return true; +} + +function getNpxCommand() { + return process.platform === 'win32' ? 'npx.cmd' : 'npx'; +} + +function readPackageMetadata() { + const packageJsonPath = path.join(utils.ROOT_DIR, 'package.json'); + try { + return JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); + } catch (_error) { + return {}; + } +} + +async function runGitleaks() { + ensureSecurityDirs(); + + 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)'); + } + + const reportPath = path.join(GITLEAKS_DIR, 'gitleaks-report.json'); + + runCommand(gitleaksPath, [ + 'detect', + '--source', + '.', + '--report-format', + 'json', + '--report-path', + reportPath, + ]); + + console.log(`Gitleaks report written to: ${reportPath}`); + return reportPath; +} + +async function runSbom() { + ensureSecurityDirs(); + + const reportPath = path.join(SBOM_DIR, 'sbom.cyclonedx.json'); + const syftPath = resolveCommand('syft', [path.join('bin', 'syft'), path.join('bin', 'syft.exe')]); + + if (syftPath) { + try { + runCommand(syftPath, ['dir:.', '-o', `cyclonedx-json=${reportPath}`]); + console.log(`SBOM generated with syft: ${reportPath}`); + return reportPath; + } catch (error) { + console.warn(`syft failed, falling back to CycloneDX npm generator: ${error.message}`); + } + } + + const npxCommand = getNpxCommand(); + if (!hasCommand(npxCommand)) { + throw new Error('npx is required for SBOM fallback but was not found'); + } + + runCommand(npxCommand, [ + '--yes', + '@cyclonedx/cyclonedx-npm', + '--ignore-npm-errors', + '--package-lock-only', + '--output-format', + 'JSON', + '--output-file', + reportPath, + ]); + + console.log(`SBOM generated with cyclonedx-npm: ${reportPath}`); + return reportPath; +} + +function resolveTokenFromFile() { + const tokenFile = process.env.RENOVATE_TOKEN_FILE; + if (!tokenFile) { + return ''; + } + + const tokenFilePath = path.isAbsolute(tokenFile) ? tokenFile : path.join(utils.ROOT_DIR, tokenFile); + if (!fs.existsSync(tokenFilePath)) { + return ''; + } + + try { + return fs.readFileSync(tokenFilePath, 'utf8').trim(); + } catch (_error) { + return ''; + } +} + +function resolveTokenFromGhCli() { + const ghPath = resolveCommand('gh'); + if (!ghPath) { + return ''; + } + + const result = spawnSync(ghPath, ['auth', 'token'], { + cwd: utils.ROOT_DIR, + stdio: ['ignore', 'pipe', 'ignore'], + encoding: 'utf8', + shell: false, + }); + + if (result.status !== 0 || !result.stdout) { + return ''; + } + + return result.stdout.trim(); +} + +function resolveRenovateToken() { + if (process.env.RENOVATE_TOKEN) { + return { token: process.env.RENOVATE_TOKEN, source: 'RENOVATE_TOKEN' }; + } + + if (process.env.GITHUB_TOKEN) { + return { token: process.env.GITHUB_TOKEN, source: 'GITHUB_TOKEN' }; + } + + if (process.env.GH_TOKEN) { + return { token: process.env.GH_TOKEN, source: 'GH_TOKEN' }; + } + + if (process.env.GITHUB_COM_TOKEN) { + return { token: process.env.GITHUB_COM_TOKEN, source: 'GITHUB_COM_TOKEN' }; + } + + const fileToken = resolveTokenFromFile(); + if (fileToken) { + return { token: fileToken, source: 'RENOVATE_TOKEN_FILE' }; + } + + const ghToken = resolveTokenFromGhCli(); + if (ghToken) { + return { token: ghToken, source: 'gh auth token' }; + } + + return { token: '', source: '' }; +} + +function detectRepoSlugFromGit() { + try { + const remote = execSync('git remote get-url origin', { + cwd: utils.ROOT_DIR, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }).trim(); + + const match = remote.match(/(?:git@|https?:\/\/)[^/:]+[:/]([^/]+\/[^/.]+?)(?:\.git)?$/); + return match ? match[1] : ''; + } catch (_error) { + return ''; + } +} + +function addTokenToRenovateEnv(token) { + if (!token) { + return {}; + } + + return { + RENOVATE_TOKEN: token, + GITHUB_TOKEN: token, + GITHUB_COM_TOKEN: token, + RENOVATE_GITHUB_COM_TOKEN: token, + }; +} + +function splitEnvArgs(value) { + if (!value) { + return []; + } + + return value.split(' ').filter(Boolean); +} + +async function runRenovate(extraArgs = []) { + const npxCommand = getNpxCommand(); + + if (!hasCommand(npxCommand)) { + throw new Error('npx not found; install Node.js and npm'); + } + + const { token, source } = resolveRenovateToken(); + if (!token) { + throw new Error( + 'RENOVATE_TOKEN (or GITHUB_TOKEN/GH_TOKEN/RENOVATE_TOKEN_FILE, or authenticated gh CLI) is required for renovate target' + ); + } + console.log(`Using token source: ${source}`); + + const explicitRepo = extraArgs.find((arg) => !arg.startsWith('-')); + const repoSlug = explicitRepo || process.env.RENOVATE_REPOSITORY || detectRepoSlugFromGit(); + if (!repoSlug) { + throw new Error( + 'RENOVATE_REPOSITORY is required when git remote origin cannot be resolved (expected owner/repo)' + ); + } + + const platform = process.env.RENOVATE_PLATFORM || 'github'; + const args = ['--yes', 'renovate', `--platform=${platform}`]; + + if (process.env.RENOVATE_ENDPOINT) { + args.push(`--endpoint=${process.env.RENOVATE_ENDPOINT}`); + } + + if (process.env.RENOVATE_EXTRA_ARGS) { + args.push(...splitEnvArgs(process.env.RENOVATE_EXTRA_ARGS)); + } + + if (extraArgs.length > 0) { + args.push(...extraArgs); + } + + if (!explicitRepo) { + args.push(repoSlug); + } + + runCommand(npxCommand, args, { env: addTokenToRenovateEnv(token) }); +} + +async function runRenovateLocal(extraArgs = []) { + ensureSecurityDirs(); + + const reportPath = path.join(RENOVATE_DIR, 'renovate-local-report.json'); + const npxCommand = getNpxCommand(); + + if (!hasCommand(npxCommand)) { + throw new Error('npx not found; install Node.js and npm'); + } + + const { token, source } = resolveRenovateToken(); + const env = addTokenToRenovateEnv(token); + const args = [ + '--yes', + 'renovate', + '--platform=local', + '--dry-run=lookup', + '--onboarding=false', + '--require-config=optional', + '--detect-host-rules-from-env=true', + '--github-token-warn=false', + '--report-type=file', + `--report-path=${reportPath}`, + ]; + + if (token) { + console.log(`Using token source: ${source}`); + } else { + console.warn( + 'No GitHub token found (RENOVATE_TOKEN/GITHUB_TOKEN/GH_TOKEN/RENOVATE_TOKEN_FILE/gh auth token); GitHub-hosted dependencies may be skipped' + ); + } + + if (process.env.RENOVATE_EXTRA_ARGS) { + args.push(...splitEnvArgs(process.env.RENOVATE_EXTRA_ARGS)); + } + + if (extraArgs.length > 0) { + args.push(...extraArgs); + } + + runCommand(npxCommand, args, { env }); + + console.log(`Renovate local report written to: ${reportPath}`); + return reportPath; +} + +async function runMendScan() { + const homeDir = os.homedir(); + const mendPath = resolveCommand('mend-scan', [ + path.join(homeDir, '.mend-unified-agent', 'bin', 'mend-scan'), + path.join(homeDir, '.mend-unified-agent', 'bin', 'mend-scan.exe'), + ]); + + if (!mendPath) { + throw new Error( + 'mend-scan not found in PATH or ~/.mend-unified-agent/bin (install Mend Unified Agent first)' + ); + } + + const pkg = readPackageMetadata(); + const project = process.env.MEND_PROJECT || process.env.BINARY_NAME || pkg.name || 'ai-code-fusion'; + const version = process.env.MEND_PROJECT_VERSION || process.env.VERSION || pkg.version || '0.0.0'; + + runCommand(mendPath, ['scan', '--project', project, '--version', version]); + console.log('Mend scan completed successfully'); +} + +async function runSecurity() { + console.log('Running security checks: gitleaks + sbom'); + + const failures = []; + + try { + await runGitleaks(); + } catch (error) { + failures.push(`gitleaks failed: ${error.message}`); + } + + try { + await runSbom(); + } catch (error) { + failures.push(`sbom failed: ${error.message}`); + } + + if (failures.length > 0) { + throw new Error(failures.join('\n')); + } + + console.log('Security checks completed successfully'); +} + +module.exports = { + runGitleaks, + runSbom, + runRenovate, + runRenovateLocal, + runMendScan, + runSecurity, +}; diff --git a/scripts/lib/utils.js b/scripts/lib/utils.js index 50b9e74..d937a92 100755 --- a/scripts/lib/utils.js +++ b/scripts/lib/utils.js @@ -61,8 +61,12 @@ async function setupProject() { console.log('Setting up the project...'); try { - // Install dependencies - runNpm('install'); + // Install dependencies using lockfile when available + if (fileExists(path.join(ROOT_DIR, 'package-lock.json'))) { + runNpm('ci'); + } else { + runNpm('install'); + } // Build CSS runNpmScript('build:css'); @@ -224,6 +228,13 @@ function printHelp() { console.log(' lint - Run linter'); console.log(' format - Format code'); console.log(' validate - Run all code quality checks'); + console.log(' qa - Run lint + tests + security checks'); + console.log(' security - Run security checks (gitleaks + sbom)'); + console.log(' gitleaks - Run gitleaks secret scan'); + console.log(' sbom - Generate CycloneDX SBOM'); + console.log(' renovate - Run Renovate against repository'); + console.log(' renovate-local - Run Renovate local dry-run report'); + console.log(' mend-scan - Run Mend Unified Agent scan'); console.log(' sonar - Run SonarQube analysis'); console.log(''); console.log('Release:'); From 32cda9642408b9095ee02814410618434bc2a4a4 Mon Sep 17 00:00:00 2001 From: Mehdi Date: Sun, 8 Feb 2026 18:15:00 +0000 Subject: [PATCH 2/4] fix: harden security command execution paths --- scripts/lib/security.js | 39 ++++++++++++++++++++++++++++++++------- 1 file changed, 32 insertions(+), 7 deletions(-) diff --git a/scripts/lib/security.js b/scripts/lib/security.js index 076d6f8..ac93d63 100755 --- a/scripts/lib/security.js +++ b/scripts/lib/security.js @@ -12,6 +12,17 @@ const SECURITY_DIR = path.join(utils.ROOT_DIR, 'dist', 'security'); 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._/\-]+$/; + +function assertSafeCommand(command) { + if (typeof command !== 'string' || command.length === 0) { + throw new Error('Command must be a non-empty string'); + } + + if (!SAFE_COMMAND_PATTERN.test(command) || command.includes('..')) { + throw new Error(`Unsafe command rejected: ${command}`); + } +} function ensureSecurityDirs() { utils.ensureDir(SECURITY_DIR); @@ -21,14 +32,16 @@ function ensureSecurityDirs() { } function hasCommand(command) { - const checkCommand = process.platform === 'win32' ? `where ${command}` : `command -v ${command}`; + assertSafeCommand(command); + const checker = process.platform === 'win32' ? 'where' : 'which'; - try { - execSync(checkCommand, { stdio: 'ignore' }); - return true; - } catch (_error) { - return false; - } + const result = spawnSync(checker, [command], { + stdio: 'ignore', + cwd: utils.ROOT_DIR, + shell: false, + }); + + return result.status === 0; } function getCommandCandidates(command) { @@ -57,7 +70,19 @@ function resolveCommand(command, localCandidates = []) { } function runCommand(command, args = [], options = {}) { + assertSafeCommand(command); + if (!Array.isArray(args)) { + throw new Error('Command arguments must be an array'); + } + const sanitizedArgs = args.map((arg) => { + if (typeof arg !== 'string') { + throw new Error('Command arguments must be strings'); + } + if (arg.includes('\0')) { + throw new Error('Command arguments may not include null bytes'); + } + if (/^--token=/.test(arg) || /^--\w*token=/.test(arg)) { const [key] = arg.split('='); return `${key}=***`; From 7ccab8ae08e29beaefec77467171623cbbf59d52 Mon Sep 17 00:00:00 2001 From: Mehdi Date: Sun, 8 Feb 2026 18:18:33 +0000 Subject: [PATCH 3/4] fix: restrict allowed executables for security runner --- scripts/lib/security.js | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/scripts/lib/security.js b/scripts/lib/security.js index ac93d63..db2d132 100755 --- a/scripts/lib/security.js +++ b/scripts/lib/security.js @@ -13,6 +13,18 @@ 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 ALLOWED_EXECUTABLES = new Set([ + 'gh', + 'gh.exe', + 'gitleaks', + 'gitleaks.exe', + 'mend-scan', + 'mend-scan.exe', + 'npx', + 'npx.cmd', + 'syft', + 'syft.exe', +]); function assertSafeCommand(command) { if (typeof command !== 'string' || command.length === 0) { @@ -24,6 +36,15 @@ function assertSafeCommand(command) { } } +function assertAllowedExecutable(command) { + assertSafeCommand(command); + const baseName = path.basename(command).toLowerCase(); + + if (!ALLOWED_EXECUTABLES.has(baseName)) { + throw new Error(`Executable not allowed: ${baseName}`); + } +} + function ensureSecurityDirs() { utils.ensureDir(SECURITY_DIR); utils.ensureDir(GITLEAKS_DIR); @@ -70,7 +91,7 @@ function resolveCommand(command, localCandidates = []) { } function runCommand(command, args = [], options = {}) { - assertSafeCommand(command); + assertAllowedExecutable(command); if (!Array.isArray(args)) { throw new Error('Command arguments must be an array'); } From f98c2d392514a75511be66d9ee34bde481d1cb44 Mon Sep 17 00:00:00 2001 From: Mehdi Date: Sun, 8 Feb 2026 18:22:39 +0000 Subject: [PATCH 4/4] fix: inline security command execution and normalize sbom format --- scripts/lib/security.js | 119 +++++++++++++++++++++++++++++----------- 1 file changed, 88 insertions(+), 31 deletions(-) diff --git a/scripts/lib/security.js b/scripts/lib/security.js index db2d132..6082649 100755 --- a/scripts/lib/security.js +++ b/scripts/lib/security.js @@ -90,13 +90,12 @@ function resolveCommand(command, localCandidates = []) { return null; } -function runCommand(command, args = [], options = {}) { - assertAllowedExecutable(command); +function sanitizeArgs(args = []) { if (!Array.isArray(args)) { throw new Error('Command arguments must be an array'); } - const sanitizedArgs = args.map((arg) => { + return args.map((arg) => { if (typeof arg !== 'string') { throw new Error('Command arguments must be strings'); } @@ -110,25 +109,25 @@ function runCommand(command, args = [], options = {}) { } return arg; }); - const commandLine = [command, ...sanitizedArgs].join(' '); - console.log(`Running: ${commandLine}`); +} - const result = spawnSync(command, args, { - cwd: utils.ROOT_DIR, - stdio: 'inherit', - env: { ...process.env, ...(options.env || {}) }, - shell: false, - }); +function withExecutablePath(baseEnv, executablePath) { + if (!executablePath || executablePath === path.basename(executablePath)) { + return baseEnv; + } + + const binDir = path.dirname(executablePath); + return { ...baseEnv, PATH: `${binDir}${path.delimiter}${baseEnv.PATH || ''}` }; +} +function assertProcessResult(result, commandLabel, commandLine) { if (result.error) { - throw new Error(`Failed to start command '${command}': ${result.error.message}`); + throw new Error(`Failed to start command '${commandLabel}': ${result.error.message}`); } if (result.status !== 0) { throw new Error(`Command failed (${result.status}): ${commandLine}`); } - - return true; } function getNpxCommand() { @@ -155,18 +154,22 @@ async function runGitleaks() { if (!gitleaksPath) { throw new Error('gitleaks not found in PATH or ./bin (install gitleaks first)'); } + assertAllowedExecutable(gitleaksPath); const reportPath = path.join(GITLEAKS_DIR, 'gitleaks-report.json'); + const args = ['detect', '--source', '.', '--report-format', 'json', '--report-path', reportPath]; + const commandName = process.platform === 'win32' ? 'gitleaks.exe' : 'gitleaks'; + const env = withExecutablePath({ ...process.env }, gitleaksPath); + const commandLine = [commandName, ...sanitizeArgs(args)].join(' '); - runCommand(gitleaksPath, [ - 'detect', - '--source', - '.', - '--report-format', - 'json', - '--report-path', - reportPath, - ]); + console.log(`Running: ${commandLine}`); + const result = spawnSync(commandName, args, { + cwd: utils.ROOT_DIR, + stdio: 'inherit', + env, + shell: false, + }); + assertProcessResult(result, commandName, commandLine); console.log(`Gitleaks report written to: ${reportPath}`); return reportPath; @@ -179,8 +182,22 @@ async function runSbom() { const syftPath = resolveCommand('syft', [path.join('bin', 'syft'), path.join('bin', 'syft.exe')]); if (syftPath) { + assertAllowedExecutable(syftPath); try { - runCommand(syftPath, ['dir:.', '-o', `cyclonedx-json=${reportPath}`]); + const args = ['dir:.', '-o', `cyclonedx-json=${reportPath}`]; + const commandName = process.platform === 'win32' ? 'syft.exe' : 'syft'; + const env = withExecutablePath({ ...process.env }, syftPath); + 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); + console.log(`SBOM generated with syft: ${reportPath}`); return reportPath; } catch (error) { @@ -192,17 +209,27 @@ async function runSbom() { if (!hasCommand(npxCommand)) { throw new Error('npx is required for SBOM fallback but was not found'); } - - runCommand(npxCommand, [ + assertAllowedExecutable(npxCommand); + const args = [ '--yes', '@cyclonedx/cyclonedx-npm', '--ignore-npm-errors', '--package-lock-only', '--output-format', - 'JSON', + 'json', '--output-file', reportPath, - ]); + ]; + const commandLine = [npxCommand, ...sanitizeArgs(args)].join(' '); + + console.log(`Running: ${commandLine}`); + const result = spawnSync(npxCommand, args, { + cwd: utils.ROOT_DIR, + stdio: 'inherit', + env: { ...process.env }, + shell: false, + }); + assertProcessResult(result, npxCommand, commandLine); console.log(`SBOM generated with cyclonedx-npm: ${reportPath}`); return reportPath; @@ -318,6 +345,7 @@ async function runRenovate(extraArgs = []) { if (!hasCommand(npxCommand)) { throw new Error('npx not found; install Node.js and npm'); } + assertAllowedExecutable(npxCommand); const { token, source } = resolveRenovateToken(); if (!token) { @@ -353,8 +381,16 @@ async function runRenovate(extraArgs = []) { if (!explicitRepo) { args.push(repoSlug); } + const commandLine = [npxCommand, ...sanitizeArgs(args)].join(' '); - runCommand(npxCommand, args, { env: addTokenToRenovateEnv(token) }); + console.log(`Running: ${commandLine}`); + const result = spawnSync(npxCommand, args, { + cwd: utils.ROOT_DIR, + stdio: 'inherit', + env: { ...process.env, ...addTokenToRenovateEnv(token) }, + shell: false, + }); + assertProcessResult(result, npxCommand, commandLine); } async function runRenovateLocal(extraArgs = []) { @@ -366,6 +402,7 @@ async function runRenovateLocal(extraArgs = []) { if (!hasCommand(npxCommand)) { throw new Error('npx not found; install Node.js and npm'); } + assertAllowedExecutable(npxCommand); const { token, source } = resolveRenovateToken(); const env = addTokenToRenovateEnv(token); @@ -397,8 +434,16 @@ async function runRenovateLocal(extraArgs = []) { if (extraArgs.length > 0) { args.push(...extraArgs); } + const commandLine = [npxCommand, ...sanitizeArgs(args)].join(' '); - runCommand(npxCommand, args, { env }); + console.log(`Running: ${commandLine}`); + const result = spawnSync(npxCommand, args, { + cwd: utils.ROOT_DIR, + stdio: 'inherit', + env: { ...process.env, ...env }, + shell: false, + }); + assertProcessResult(result, npxCommand, commandLine); console.log(`Renovate local report written to: ${reportPath}`); return reportPath; @@ -416,12 +461,24 @@ async function runMendScan() { 'mend-scan not found in PATH or ~/.mend-unified-agent/bin (install Mend Unified Agent first)' ); } + assertAllowedExecutable(mendPath); const pkg = readPackageMetadata(); const project = process.env.MEND_PROJECT || process.env.BINARY_NAME || pkg.name || 'ai-code-fusion'; const version = process.env.MEND_PROJECT_VERSION || process.env.VERSION || pkg.version || '0.0.0'; + const commandName = process.platform === 'win32' ? 'mend-scan.exe' : 'mend-scan'; + const args = ['scan', '--project', project, '--version', version]; + const env = withExecutablePath({ ...process.env }, mendPath); + const commandLine = [commandName, ...sanitizeArgs(args)].join(' '); - runCommand(mendPath, ['scan', '--project', project, '--version', version]); + console.log(`Running: ${commandLine}`); + const result = spawnSync(commandName, args, { + cwd: utils.ROOT_DIR, + stdio: 'inherit', + env, + shell: false, + }); + assertProcessResult(result, commandName, commandLine); console.log('Mend scan completed successfully'); }