diff --git a/packages/cli/.gitdepsecrc b/packages/cli/.gitdepsecrc new file mode 100644 index 0000000..3879bb4 --- /dev/null +++ b/packages/cli/.gitdepsecrc @@ -0,0 +1,5 @@ +{ + "github_token": "", + "include_transitive": true, + "output_format": "table" +} \ No newline at end of file diff --git a/packages/cli/README.md b/packages/cli/README.md index 410979f..561a8a9 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -110,41 +110,41 @@ export GDS_OUTPUT_FORMAT=table ## Commands -| Command | Description | -|---------|-------------| -| `gds analyse` | Analyze dependencies for vulnerabilities | -| `gds fix` | Generate fix recommendations | -| `gds init` | Create configuration file | -| `gds --version` | Show version | -| `gds --help` | Show help | +| Command | Description | +| --------------- | ---------------------------------------- | +| `gds analyse` | Analyze dependencies for vulnerabilities | +| `gds fix` | Generate fix recommendations | +| `gds init` | Create configuration file | +| `gds --version` | Show version | +| `gds --help` | Show help | ## CLI Options Reference ### `gds analyse` -| Option | Description | -|--------|-------------| -| `-f, --file ` | Manifest file(s) to analyze | -| `-r, --repo ` | GitHub repository in `owner/repo` format | -| `-b, --branch ` | Branch to analyze | -| `-t, --token ` | GitHub personal access token | -| `--no-transitive` | Disable transitive dependency scanning | -| `--format ` | Output format: `table`, `json`, `markdown` | -| `-o, --output ` | Save output to file | -| `-q, --quiet` | Minimal output | -| `-v, --verbose` | Verbose output | +| Option | Description | +| ----------------------- | ------------------------------------------ | +| `-f, --file ` | Manifest file(s) to analyze | +| `-r, --repo ` | GitHub repository in `owner/repo` format | +| `-b, --branch ` | Branch to analyze | +| `-t, --token ` | GitHub personal access token | +| `--no-transitive` | Disable transitive dependency scanning | +| `--format ` | Output format: `table`, `json`, `markdown` | +| `-o, --output ` | Save output to file | +| `-q, --quiet` | Minimal output | +| `-v, --verbose` | Verbose output | ### `gds fix` -| Option | Description | -|--------|-------------| -| `-f, --file ` | Manifest file(s) to generate fixes for | -| `-r, --repo ` | GitHub repository in `owner/repo` format | -| `-b, --branch ` | Branch to analyze | -| `-t, --token ` | GitHub personal access token | -| `--no-transitive` | Disable transitive dependency scanning | -| `--format ` | Output format: `table`, `json`, `markdown` | -| `-o, --output ` | Save output to file | +| Option | Description | +| ----------------------- | ------------------------------------------ | +| `-f, --file ` | Manifest file(s) to generate fixes for | +| `-r, --repo ` | GitHub repository in `owner/repo` format | +| `-b, --branch ` | Branch to analyze | +| `-t, --token ` | GitHub personal access token | +| `--no-transitive` | Disable transitive dependency scanning | +| `--format ` | Output format: `table`, `json`, `markdown` | +| `-o, --output ` | Save output to file | ## Supported Ecosystems @@ -159,13 +159,14 @@ export GDS_OUTPUT_FORMAT=table For `gds analyse`: -| Code | Description | -|------|-------------| -| 0 | Success, no vulnerabilities found | -| 1 | Vulnerabilities found | -| 2 | Error during analysis | +| Code | Description | +| ---- | --------------------------------- | +| 0 | Success, no vulnerabilities found | +| 1 | Vulnerabilities found | +| 2 | Error during analysis | For `gds fix`: + - `0`: Fix plan generated - `2`: Error during fix plan generation diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index b2b3598..3374bc8 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -1,6 +1,6 @@ #!/usr/bin/env node -import { Command } from "commander"; +import { Command, Option } from "commander"; import chalk from "chalk"; import { analyseCommand } from "./commands/analyse.js"; import { fixCommand } from "./commands/fix.js"; @@ -8,49 +8,114 @@ import { initCommand } from "./commands/init.js"; const program = new Command(); +// Custom help formatting +const formatOption = new Option("--format ", "Output format") + .choices(["table", "json", "markdown"]) + .default("table"); + program - .name("gds") - .description( - chalk.bold("GitDepSec") + - " - Analyze dependency vulnerabilities in your projects" - ) - .version("1.0.0"); + .name("gds") + .description( + chalk.bold("GitDepSec") + + " - Analyze dependency vulnerabilities in your projects\n\n" + + chalk.dim("Supported ecosystems: npm, pypi, maven, go, cargo, nuget, composer") + ) + .version("1.0.0") + .addHelpText("after", ` +${chalk.bold("Examples:")} + ${chalk.dim("# Scan current directory (analyse, analyze, audit all work)")} + $ gds audit + + ${chalk.dim("# Scan specific files")} + $ gds analyse -f package.json requirements.txt + + ${chalk.dim("# Scan a GitHub repository")} + $ gds analyze -r owner/repo -b main + + ${chalk.dim("# Export results as JSON")} + $ gds audit --format json -o report.json + + ${chalk.dim("# Generate fix plan")} + $ gds fix -f package.json + +${chalk.bold("Documentation:")} + ${chalk.cyan("https://github.com/viralcodex/gitdepsec#readme")} +`); // Analyse command program - .command("analyse") - .alias("analyze") - .description("Analyze dependencies for vulnerabilities") - .option("-f, --file ", "Manifest file(s) to analyze") - .option("-r, --repo ", "GitHub repository (owner/repo)") - .option("-b, --branch ", "Branch to analyze (default: main)") - .option("-t, --token ", "GitHub personal access token") - .option("--no-transitive", "Disable transitive dependency scanning") - .option("--format ", "Output format: table, json, markdown", "table") - .option("-o, --output ", "Save output to file") - .option("-q, --quiet", "Minimal output") - .option("-v, --verbose", "Verbose output") - .action(analyseCommand); + .command("analyse") + .aliases(["analyze", "audit"]) + .description("Analyze dependencies for vulnerabilities") + .option("-f, --file ", "Manifest file(s) to analyze (e.g., package.json, requirements.txt)") + .option("-r, --repo ", "GitHub repository in owner/repo format") + .option("-b, --branch ", "Branch to analyze", "main") + .option("-t, --token ", "GitHub personal access token (or set GITHUB_TOKEN env)") + .option("--transitive", "Enable transitive dependency scanning", true) + .option("--no-transitive", "Disable transitive dependency scanning") + .addOption(formatOption) + .option("-o, --output ", "Save output to file (e.g., report.json)") + .option("-q, --quiet", "Minimal output - only show summary") + .option("-v, --verbose", "Verbose output - show detailed progress") + .addHelpText("after", ` +${chalk.bold("Aliases:")} analyse, analyze, audit + +${chalk.bold("Supported Manifest Files:")} + ${chalk.cyan("npm")} package.json, package-lock.json + ${chalk.cyan("pypi")} requirements.txt, Pipfile, pyproject.toml + ${chalk.cyan("maven")} pom.xml + ${chalk.cyan("go")} go.mod + ${chalk.cyan("cargo")} Cargo.toml + ${chalk.cyan("nuget")} packages.config, *.csproj + ${chalk.cyan("composer")} composer.json + +${chalk.bold("Examples:")} + $ gds audit ${chalk.dim("# Scan current directory")} + $ gds analyse -f package.json ${chalk.dim("# Scan specific file")} + $ gds analyze -r facebook/react ${chalk.dim("# Scan GitHub repo")} + $ gds audit --format json -o out.json ${chalk.dim("# Export as JSON")} + $ gds analyse --format markdown ${chalk.dim("# Output as markdown")} + $ gds analyse --no-transitive ${chalk.dim("# Skip transitive deps")} +`) + .action(analyseCommand); // Fix command program - .command("fix") - .description("Generate fix recommendations for vulnerabilities") - .option("-f, --file ", "Manifest file(s) to fix") - .option("-r, --repo ", "GitHub repository (owner/repo)") - .option("-b, --branch ", "Branch to analyze (default: main)") - .option("-t, --token ", "GitHub personal access token") - .option("--no-transitive", "Disable transitive dependency scanning") - .option("--format ", "Output format: table, json, markdown", "table") - .option("-o, --output ", "Save output to file") - .action(fixCommand); + .command("fix") + .description("Generate fix recommendations for vulnerabilities") + .option("-f, --file ", "Manifest file(s) to fix (e.g., package.json)") + .option("-r, --repo ", "GitHub repository in owner/repo format") + .option("-b, --branch ", "Branch to analyze", "main") + .option("-t, --token ", "GitHub personal access token (or set GITHUB_TOKEN env)") + .option("--transitive", "Enable transitive dependency scanning", true) + .option("--no-transitive", "Disable transitive dependency scanning") + .addOption(formatOption) + .option("-o, --output ", "Save output to file") + .addHelpText("after", ` +${chalk.bold("Examples:")} + $ gds fix ${chalk.dim("# Generate fix plan for current dir")} + $ gds fix -f package.json ${chalk.dim("# Fix specific file")} + $ gds fix --format markdown -o fixes.md ${chalk.dim("# Export as markdown")} +`) + .action(fixCommand); // Init command program - .command("init") - .description("Create a .gitdepsecrc configuration file") - .option("--force", "Overwrite existing config") - .action(initCommand); + .command("init") + .description("Create a .gitdepsecrc configuration file") + .option("--force", "Overwrite existing config file") + .addHelpText("after", ` +${chalk.bold("Examples:")} + $ gds init ${chalk.dim("# Create config interactively")} + $ gds init --force ${chalk.dim("# Overwrite existing config")} + +${chalk.bold("Config File Options:")} + ${chalk.cyan("github_token")} GitHub personal access token + ${chalk.cyan("default_branch")} Default branch to analyze + ${chalk.cyan("format")} Default output format (table|json|markdown) + ${chalk.cyan("transitive")} Enable transitive scanning (true|false) +`) + .action(initCommand); // Parse arguments program.parse(); diff --git a/packages/cli/src/commands/analyse.ts b/packages/cli/src/commands/analyse.ts index f04247b..3a57cf8 100644 --- a/packages/cli/src/commands/analyse.ts +++ b/packages/cli/src/commands/analyse.ts @@ -5,123 +5,123 @@ import { Analyser } from "../core/analyser.js"; import { CLIProgress } from "../core/progress.js"; import { loadConfig } from "../core/config.js"; import { - formatAnalysisTable, - formatAnalysisJson, - formatAnalysisMarkdown, + formatAnalysisTable, + formatAnalysisJson, + formatAnalysisMarkdown, } from "../utils/formatters.js"; interface AnalyseCommandOptions { - file?: string[]; - repo?: string; - branch?: string; - token?: string; - transitive?: boolean; - format?: "table" | "json" | "markdown"; - output?: string; - quiet?: boolean; - verbose?: boolean; + file?: string[]; + repo?: string; + branch?: string; + token?: string; + transitive?: boolean; + format?: "table" | "json" | "markdown"; + output?: string; + quiet?: boolean; + verbose?: boolean; } export async function analyseCommand(options: AnalyseCommandOptions): Promise { - const config = loadConfig(); - const includeTransitive = options.transitive ?? config.include_transitive ?? true; - const progress = new CLIProgress({ - verbose: options.verbose, - quiet: options.quiet, - }); - - try { - // Determine token - const token = options.token || config.github_token; - - // Create analyser - const analyser = new Analyser({ - token, - onProgress: (step, pct) => progress.update(step, pct), + const config = loadConfig(); + const includeTransitive = options.transitive ?? config.include_transitive ?? true; + const progress = new CLIProgress({ + verbose: options.verbose, + quiet: options.quiet, }); - progress.start("Analyzing dependencies..."); + try { + // Determine token + const token = options.token || config.github_token; - let result; + // Create analyser + const analyser = new Analyser({ + token, + onProgress: (step, pct) => progress.update(step, pct), + }); - if (options.repo) { - // Analyze GitHub repo - const [owner, repo] = options.repo.split("/"); - if (!owner || !repo) { - progress.fail("Invalid repository format. Use: owner/repo"); - process.exit(2); - } - - result = await analyser.analyseFromRepo( - owner, - repo, - options.branch, - includeTransitive - ); - } else if (options.file && options.file.length > 0) { - // Analyze specified files - const files = options.file.map((f) => path.resolve(f)); - - // Check files exist - for (const file of files) { - if (!fs.existsSync(file)) { - progress.fail(`File not found: ${file}`); - process.exit(2); + progress.start("Analyzing dependencies..."); + + let result; + + if (options.repo) { + // Analyze GitHub repo + const [owner, repo] = options.repo.split("/"); + if (!owner || !repo) { + progress.fail("Invalid repository format. Use: owner/repo"); + process.exit(2); + } + + result = await analyser.analyseFromRepo( + owner, + repo, + options.branch, + includeTransitive + ); + } else if (options.file && options.file.length > 0) { + // Analyze specified files + const files = options.file.map((f) => path.resolve(f)); + + // Check files exist + for (const file of files) { + if (!fs.existsSync(file)) { + progress.fail(`File not found: ${file}`); + process.exit(2); + } + } + + result = await analyser.analyseFromFiles(files, includeTransitive); + } else { + // Default: analyze current directory + const cwd = process.cwd(); + const manifestNames = ["package.json", "requirements.txt", "pom.xml", "Gemfile", "composer.json", "pubspec.yaml"]; + const foundFiles = manifestNames + .map((name) => path.join(cwd, name)) + .filter((p) => fs.existsSync(p)); + + if (foundFiles.length === 0) { + progress.fail("No manifest files found in current directory"); + console.log(chalk.dim("\nSupported files: " + manifestNames.join(", "))); + process.exit(2); + } + + result = await analyser.analyseFromFiles(foundFiles, includeTransitive); } - } - - result = await analyser.analyseFromFiles(files, includeTransitive); - } else { - // Default: analyze current directory - const cwd = process.cwd(); - const manifestNames = ["package.json", "requirements.txt", "pom.xml", "Gemfile", "composer.json", "pubspec.yaml"]; - const foundFiles = manifestNames - .map((name) => path.join(cwd, name)) - .filter((p) => fs.existsSync(p)); - - if (foundFiles.length === 0) { - progress.fail("No manifest files found in current directory"); - console.log(chalk.dim("\nSupported files: " + manifestNames.join(", "))); - process.exit(2); - } - result = await analyser.analyseFromFiles(foundFiles, includeTransitive); - } + progress.stop(); - progress.stop(); - - // Format output - let output: string; - const format = options.format || config.output_format || "table"; - - switch (format) { - case "json": - output = formatAnalysisJson(result); - break; - case "markdown": - output = formatAnalysisMarkdown(result); - break; - default: - output = formatAnalysisTable(result); - } + // Format output + let output: string; + const format = options.format || config.output_format || "table"; - // Write to file or stdout - if (options.output) { - fs.writeFileSync(options.output, output); - progress.succeed(`Report saved to ${options.output}`); - } else { - console.log(output); - } + switch (format) { + case "json": + output = formatAnalysisJson(result); + break; + case "markdown": + output = formatAnalysisMarkdown(result); + break; + default: + output = formatAnalysisTable(result); + } - // Exit code based on vulnerabilities found - if (result.totalVulnerabilities > 0) { - process.exit(1); - } - } catch (error) { - progress.fail(error instanceof Error ? error.message : "Analysis failed"); - if (options.verbose && error instanceof Error) { - console.error(chalk.dim(error.stack)); + // Write to file or stdout + if (options.output) { + fs.writeFileSync(options.output, output); + progress.succeed(`Report saved to ${options.output}`); + } else { + console.log(output); + } + + // Exit code based on vulnerabilities found + if (result.totalVulnerabilities > 0) { + process.exit(1); + } + } catch (error) { + progress.fail(error instanceof Error ? error.message : "Analysis failed"); + if (options.verbose && error instanceof Error) { + console.error(chalk.dim(error.stack)); + } + process.exit(2); } - process.exit(2); - } } diff --git a/packages/cli/src/commands/fix.ts b/packages/cli/src/commands/fix.ts index 6a1fd06..950f6ff 100644 --- a/packages/cli/src/commands/fix.ts +++ b/packages/cli/src/commands/fix.ts @@ -5,94 +5,94 @@ import { generateFixPlan } from "../core/fix-planner.js"; import { CLIProgress } from "../core/progress.js"; import { loadConfig } from "../core/config.js"; import { - formatFixPlanTable, - formatFixPlanJson, - formatFixPlanMarkdown, + formatFixPlanTable, + formatFixPlanJson, + formatFixPlanMarkdown, } from "../utils/formatters.js"; interface FixCommandOptions { - file?: string[]; - repo?: string; - branch?: string; - token?: string; - transitive?: boolean; - format?: "table" | "json" | "markdown"; - output?: string; + file?: string[]; + repo?: string; + branch?: string; + token?: string; + transitive?: boolean; + format?: "table" | "json" | "markdown"; + output?: string; } export async function fixCommand(options: FixCommandOptions): Promise { - const config = loadConfig(); - const includeTransitive = options.transitive ?? config.include_transitive ?? true; - const progress = new CLIProgress(); + const config = loadConfig(); + const includeTransitive = options.transitive ?? config.include_transitive ?? true; + const progress = new CLIProgress(); - try { - const token = options.token || config.github_token; + try { + const token = options.token || config.github_token; - progress.start("Generating fix plan..."); + progress.start("Generating fix plan..."); - // Determine files to analyze - let files: string[] | undefined; - - if (options.file && options.file.length > 0) { - files = options.file.map((f) => path.resolve(f)); - for (const file of files) { - if (!fs.existsSync(file)) { - progress.fail(`File not found: ${file}`); - process.exit(2); + // Determine files to analyze + let files: string[] | undefined; + + if (options.file && options.file.length > 0) { + files = options.file.map((f) => path.resolve(f)); + for (const file of files) { + if (!fs.existsSync(file)) { + progress.fail(`File not found: ${file}`); + process.exit(2); + } + } + } else if (!options.repo) { + // Default: find files in current directory + const cwd = process.cwd(); + const manifestNames = ["package.json", "requirements.txt", "pom.xml", "Gemfile"]; + files = manifestNames + .map((name) => path.join(cwd, name)) + .filter((p) => fs.existsSync(p)); + + if (files.length === 0) { + progress.fail("No manifest files found"); + process.exit(2); + } } - } - } else if (!options.repo) { - // Default: find files in current directory - const cwd = process.cwd(); - const manifestNames = ["package.json", "requirements.txt", "pom.xml", "Gemfile"]; - files = manifestNames - .map((name) => path.join(cwd, name)) - .filter((p) => fs.existsSync(p)); - if (files.length === 0) { - progress.fail("No manifest files found"); - process.exit(2); - } - } + const plan = await generateFixPlan({ + files, + repo: options.repo, + branch: options.branch, + token, + includeTransitive, + onProgress: (step, pct) => progress.update(step, pct), + }); - const plan = await generateFixPlan({ - files, - repo: options.repo, - branch: options.branch, - token, - includeTransitive, - onProgress: (step, pct) => progress.update(step, pct), - }); + progress.stop(); - progress.stop(); + // Format output + let output: string; + const format = options.format || config.output_format || "table"; - // Format output - let output: string; - const format = options.format || config.output_format || "table"; + switch (format) { + case "json": + output = formatFixPlanJson(plan); + break; + case "markdown": + output = formatFixPlanMarkdown(plan); + break; + default: + output = formatFixPlanTable(plan); + } - switch (format) { - case "json": - output = formatFixPlanJson(plan); - break; - case "markdown": - output = formatFixPlanMarkdown(plan); - break; - default: - output = formatFixPlanTable(plan); - } + // Write to file or stdout + if (options.output) { + fs.writeFileSync(options.output, output); + progress.succeed(`Fix plan saved to ${options.output}`); + } else { + console.log(output); + } - // Write to file or stdout - if (options.output) { - fs.writeFileSync(options.output, output); - progress.succeed(`Fix plan saved to ${options.output}`); - } else { - console.log(output); + // Exit with 0 for fix command (it's informational) + process.exit(0); + } catch (error) { + progress.fail(error instanceof Error ? error.message : "Fix plan generation failed"); + process.exit(2); } - - // Exit with 0 for fix command (it's informational) - process.exit(0); - } catch (error) { - progress.fail(error instanceof Error ? error.message : "Fix plan generation failed"); - process.exit(2); - } } diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 2536087..da6ac2a 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -4,41 +4,44 @@ import chalk from "chalk"; import { getConfigPath } from "../core/config.js"; interface InitCommandOptions { - force?: boolean; + force?: boolean; } const DEFAULT_CONFIG = { - github_token: "", - include_transitive: true, - output_format: "table", + github_token: "", + include_transitive: true, + output_format: "table", }; export async function initCommand(options: InitCommandOptions): Promise { - const cwd = process.cwd(); - const configPath = path.join(cwd, ".gitdepsecrc"); - - // Check if config already exists - const existingConfig = getConfigPath(cwd); - if (existingConfig && !options.force) { - console.log(chalk.yellow(`Config already exists: ${existingConfig}`)); - console.log(chalk.dim("Use --force to overwrite")); - return; - } + const cwd = process.cwd(); + const configPath = path.join(cwd, ".gitdepsecrc"); - // Write config file - const configContent = JSON.stringify(DEFAULT_CONFIG, null, 2); - fs.writeFileSync(configPath, configContent); + // Check if config already exists + const existingConfig = getConfigPath(cwd); + if (existingConfig && !options.force) { + console.log(chalk.yellow(`Config already exists: ${existingConfig}`)); + console.log(chalk.dim("Use --force to overwrite")); + return; + } - console.log(chalk.green("✓") + ` Created ${chalk.bold(".gitdepsecrc")}`); - console.log(""); - console.log(chalk.dim("Configuration options:")); - console.log(chalk.dim("─".repeat(40))); - console.log(` ${chalk.cyan("github_token")}: GitHub PAT for private repos`); - console.log(` ${chalk.cyan("include_transitive")}: Scan transitive deps`); - console.log(` ${chalk.cyan("output_format")}: table | json | markdown`); - console.log(""); - console.log(chalk.dim("Environment variables (take precedence):")); - console.log(` ${chalk.cyan("GITHUB_TOKEN")}`); - console.log(` ${chalk.cyan("GDS_INCLUDE_TRANSITIVE")}`); - console.log(` ${chalk.cyan("GDS_OUTPUT_FORMAT")}`); + // Write config file + const configContent = JSON.stringify(DEFAULT_CONFIG, null, 2); + fs.writeFileSync(configPath, configContent); + + console.log(`${chalk.green("[ok]")} Created ${chalk.bold(".gitdepsecrc")}`); + console.log(""); + console.log(chalk.dim("Configuration options:")); + console.log(chalk.dim("─".repeat(40))); + const showConfigLine = (key: string, description: string) => { + console.log(` ${chalk.cyan(`${key}:`.padEnd(21, " "))}${description}`); + }; + showConfigLine("github_token", "GitHub PAT for private repos"); + showConfigLine("include_transitive", "Scan transitive deps"); + showConfigLine("output_format", "table | json | markdown"); + console.log(""); + console.log(chalk.dim("Environment variables (take precedence):")); + console.log(` ${chalk.cyan("GITHUB_TOKEN")}`); + console.log(` ${chalk.cyan("GDS_INCLUDE_TRANSITIVE")}`); + console.log(` ${chalk.cyan("GDS_OUTPUT_FORMAT")}`); } diff --git a/packages/cli/src/core/analyser.ts b/packages/cli/src/core/analyser.ts index 91838d7..eaca697 100644 --- a/packages/cli/src/core/analyser.ts +++ b/packages/cli/src/core/analyser.ts @@ -4,31 +4,31 @@ import { parseStringPromise } from "xml2js"; import cvssCalculator from "ae-cvss-calculator"; import { - DEPS_DEV_BASE_URL, - OSV_DEV_VULN_BATCH_URL, - OSV_DEV_VULN_DET_URL, - DEFAULT_BATCH_SIZE, - DEFAULT_CONCURRENCY, - DEFAULT_TRANSITIVE_BATCH_SIZE, - DEFAULT_TRANSITIVE_CONCURRENCY, - DEFAULT_VULN_BATCH_SIZE, - DEFAULT_VULN_CONCURRENCY, - PROGRESS_STEPS, - manifestFiles, + DEPS_DEV_BASE_URL, + OSV_DEV_VULN_BATCH_URL, + OSV_DEV_VULN_DET_URL, + DEFAULT_BATCH_SIZE, + DEFAULT_CONCURRENCY, + DEFAULT_TRANSITIVE_BATCH_SIZE, + DEFAULT_TRANSITIVE_CONCURRENCY, + DEFAULT_VULN_BATCH_SIZE, + DEFAULT_VULN_CONCURRENCY, + PROGRESS_STEPS, + manifestFiles, } from "./constants.js"; import { - Dependency, - DependencyGroups, - Ecosystem, - ManifestFiles, - MavenDependency, - OSVBatchResponse, - OSVQuery, - TransitiveDependency, - TransitiveDependencyResult, - Vulnerability, - DepsDevDependency, + Dependency, + DependencyGroups, + Ecosystem, + ManifestFiles, + MavenDependency, + OSVBatchResponse, + OSVQuery, + TransitiveDependency, + TransitiveDependencyResult, + Vulnerability, + DepsDevDependency, } from "./types.js"; import { GitHubService } from "./github.js"; @@ -36,804 +36,804 @@ import { ProgressService } from "./progress.js"; import { loadConfig, type Config } from "./config.js"; const { Cvss3P0, Cvss4P0 } = cvssCalculator as { - Cvss3P0: new (vector: string) => { calculateExactOverallScore: () => number }; - Cvss4P0: new (vector: string) => { calculateOverallScore: () => number }; + Cvss3P0: new (vector: string) => { calculateExactOverallScore: () => number }; + Cvss4P0: new (vector: string) => { calculateOverallScore: () => number }; }; const MANIFEST_NAME_TO_ECOSYSTEM = new Map( - Object.entries(manifestFiles).map(([ecosystem, fileName]) => [fileName, ecosystem]), + Object.entries(manifestFiles).map(([ecosystem, fileName]) => [fileName, ecosystem]), ); export interface AnalyseOptions { - files?: string[]; - repo?: string; - branch?: string; - token?: string; - includeTransitive?: boolean; - onProgress?: (step: string, progress: number) => void; + files?: string[]; + repo?: string; + branch?: string; + token?: string; + includeTransitive?: boolean; + onProgress?: (step: string, progress: number) => void; } export interface AnalysisResult { - dependencies: DependencyGroups; - errors?: string[]; - totalDependencies: number; - totalVulnerabilities: number; - criticalCount: number; - highCount: number; - mediumCount: number; - lowCount: number; + dependencies: DependencyGroups; + errors?: string[]; + totalDependencies: number; + totalVulnerabilities: number; + criticalCount: number; + highCount: number; + mediumCount: number; + lowCount: number; } export class Analyser { - private globalDependencyMap = new Map(); - private dependencyFileMapping = new Map(); - private stepErrors = new Map(); - private progressService: ProgressService; - private githubService: GitHubService; - private config: Config; - private npmLatestVersionCache = new Map>(); - - private performanceConfig = { - concurrency: DEFAULT_CONCURRENCY, - batchSize: DEFAULT_BATCH_SIZE, - vulnConcurrency: DEFAULT_VULN_CONCURRENCY, - vulnBatchSize: DEFAULT_VULN_BATCH_SIZE, - transitiveConcurrency: DEFAULT_TRANSITIVE_CONCURRENCY, - transitiveBatchSize: DEFAULT_TRANSITIVE_BATCH_SIZE, - }; - - constructor(options: { token?: string; onProgress?: (step: string, progress: number) => void } = {}) { - this.config = loadConfig(); - this.progressService = new ProgressService(); - this.githubService = new GitHubService(options.token || this.config.github_token); - - if (options.onProgress) { - this.progressService.onProgress(options.onProgress); - } - } + private globalDependencyMap = new Map(); + private dependencyFileMapping = new Map(); + private stepErrors = new Map(); + private progressService: ProgressService; + private githubService: GitHubService; + private config: Config; + private npmLatestVersionCache = new Map>(); + + private performanceConfig = { + concurrency: DEFAULT_CONCURRENCY, + batchSize: DEFAULT_BATCH_SIZE, + vulnConcurrency: DEFAULT_VULN_CONCURRENCY, + vulnBatchSize: DEFAULT_VULN_BATCH_SIZE, + transitiveConcurrency: DEFAULT_TRANSITIVE_CONCURRENCY, + transitiveBatchSize: DEFAULT_TRANSITIVE_BATCH_SIZE, + }; - private resetState(): void { - this.globalDependencyMap.clear(); - this.dependencyFileMapping.clear(); - this.stepErrors.clear(); - } + constructor(options: { token?: string; onProgress?: (step: string, progress: number) => void } = {}) { + this.config = loadConfig(); + this.progressService = new ProgressService(); + this.githubService = new GitHubService(options.token || this.config.github_token); - private addStepError(step: string, error: unknown): void { - if (!this.stepErrors.has(step)) { - this.stepErrors.set(step, []); - } - const errorMessage = error instanceof Error ? error.message : String(error); - this.stepErrors.get(step)?.push(errorMessage); - } - - private consolidateErrors(): string[] { - const errors: string[] = []; - this.stepErrors.forEach((errs, step) => { - if (errs.length > 0) { - if (errs.length === 1) { - errors.push(`${step}: ${errs[0]}`); - } else { - errors.push(`${step}: ${errs.length} issues encountered`); + if (options.onProgress) { + this.progressService.onProgress(options.onProgress); } - } - }); - return errors; - } - - private addDependencyToGlobalMap(dependency: Dependency, filePath: string): void { - const depKey = `${dependency.name}@${dependency.version}@${dependency.ecosystem}`; - - if (!this.globalDependencyMap.has(depKey)) { - this.globalDependencyMap.set(depKey, dependency); } - - if (!this.dependencyFileMapping.has(depKey)) { - this.dependencyFileMapping.set(depKey, []); + + private resetState(): void { + this.globalDependencyMap.clear(); + this.dependencyFileMapping.clear(); + this.stepErrors.clear(); } - const files = this.dependencyFileMapping.get(depKey); - if (files && !files.includes(filePath)) { - files.push(filePath); + + private addStepError(step: string, error: unknown): void { + if (!this.stepErrors.has(step)) { + this.stepErrors.set(step, []); + } + const errorMessage = error instanceof Error ? error.message : String(error); + this.stepErrors.get(step)?.push(errorMessage); + } + + private consolidateErrors(): string[] { + const errors: string[] = []; + this.stepErrors.forEach((errs, step) => { + if (errs.length > 0) { + if (errs.length === 1) { + errors.push(`${step}: ${errs[0]}`); + } else { + errors.push(`${step}: ${errs.length} issues encountered`); + } + } + }); + return errors; } - } - - private mapDependenciesToFiles(processedDeps: Map): DependencyGroups { - const result: DependencyGroups = {}; - processedDeps.forEach((dep, depKey) => { - const files = this.dependencyFileMapping.get(depKey) ?? []; - files.forEach((filePath) => { - if (!result[filePath]) { - result[filePath] = []; + + private addDependencyToGlobalMap(dependency: Dependency, filePath: string): void { + const depKey = `${dependency.name}@${dependency.version}@${dependency.ecosystem}`; + + if (!this.globalDependencyMap.has(depKey)) { + this.globalDependencyMap.set(depKey, dependency); } - result[filePath].push({ ...dep }); - }); - }); - return result; - } - - private normalizeVersion(version: string): string { - if (!version) return "unknown"; - return version.replace(/^[\^~>=<]+/, "").trim(); - } - - private mapEcosystem(system: string): Ecosystem { - const mapping: Record = { - npm: Ecosystem.NPM, - pypi: Ecosystem.PYPI, - maven: Ecosystem.MAVEN, - go: Ecosystem.GO, - cargo: Ecosystem.CARGO, - rubygems: Ecosystem.RUBYGEMS, - pub: Ecosystem.PUB, - }; - return mapping[system.toLowerCase()] || Ecosystem.NULL; - } - - private getCVSSSeverity(severityArray: { type: string; score: string }[]): { cvss_v3?: string; cvss_v4?: string } { - const result: { cvss_v3?: string; cvss_v4?: string } = {}; - - for (const sev of severityArray) { - try { - if (sev.type === "CVSS_V3") { - const cvss = new Cvss3P0(sev.score); - result.cvss_v3 = cvss.calculateExactOverallScore().toString(); - } else if (sev.type === "CVSS_V4") { - const cvss = new Cvss4P0(sev.score); - result.cvss_v4 = cvss.calculateOverallScore().toString(); + + if (!this.dependencyFileMapping.has(depKey)) { + this.dependencyFileMapping.set(depKey, []); } - } catch { - // Invalid CVSS, skip - } - } - return result; - } - - private async retryApiCall( - apiCall: () => Promise, - maxRetries: number = 3, - baseDelay: number = 500, - ): Promise { - for (let attempt = 1; attempt <= maxRetries; attempt++) { - try { - return await apiCall(); - } catch (error: unknown) { - const isNonRetryable = this.isNonRetryableError(error); - if (isNonRetryable || attempt === maxRetries) { - throw error; + const files = this.dependencyFileMapping.get(depKey); + if (files && !files.includes(filePath)) { + files.push(filePath); } - const delay = baseDelay * Math.pow(2, attempt - 1) + Math.random() * 200; - await new Promise((resolve) => setTimeout(resolve, delay)); - } - } - throw new Error("Max retries exceeded"); - } - - private isNonRetryableError(error: unknown): boolean { - if (error && typeof error === 'object' && 'response' in error) { - const response = (error as { response?: { status?: number } }).response; - if (response?.status) { - const status = response.status; - return status >= 400 && status < 500 && status !== 429; - } - } - return false; - } - - private async processBatchesInParallel( - items: T[], - batchSize: number, - concurrency: number, - processor: (item: T) => Promise, - progressStep?: string, - ): Promise { - const results: R[] = []; - const batches: T[][] = []; - - for (let i = 0; i < items.length; i += batchSize) { - batches.push(items.slice(i, i + batchSize)); - } - if (batches.length === 0) { - return results; } - for (let i = 0; i < batches.length; i += concurrency) { - const concurrentBatches = batches.slice(i, i + concurrency); - const batchPromises = concurrentBatches.map(async (batch) => { - return Promise.all(batch.map((item) => processor(item))); - }); - const batchResults = await Promise.all(batchPromises); - results.push(...batchResults.flat()); - - if (progressStep) { - const processed = Math.min(i + concurrency, batches.length); - const progressPercentage = (processed / batches.length) * 100; - this.progressService.progressUpdater(progressStep, progressPercentage); - } + private mapDependenciesToFiles(processedDeps: Map): DependencyGroups { + const result: DependencyGroups = {}; + processedDeps.forEach((dep, depKey) => { + const files = this.dependencyFileMapping.get(depKey) ?? []; + files.forEach((filePath) => { + if (!result[filePath]) { + result[filePath] = []; + } + result[filePath].push({ ...dep }); + }); + }); + return result; } - return results; - } - - private async fetchLatestVersionFromNpm(packageName: string): Promise { - const cachedRequest = this.npmLatestVersionCache.get(packageName); - if (cachedRequest) { - return cachedRequest; + private normalizeVersion(version: string): string { + if (!version) return "unknown"; + return version.replace(/^[\^~>=<]+/, "").trim(); } - const request = this.retryApiCall( - () => axios.get(`https://registry.npmjs.org/${packageName}/latest`, { timeout: 5000 }), - 2, - 300, - ) - .then((response) => response.data.version || "unknown") - .catch(() => "unknown"); - - this.npmLatestVersionCache.set(packageName, request); - return request; - } - - // File parsers - private async processNpmFiles(files: Array<{ path: string; content: string }>): Promise { - for (const file of files) { - try { - const packageJson = JSON.parse(file.content); - - const processDeps = async (deps: Record) => { - const entries = Object.entries(deps); - const resolved = await Promise.all(entries.map(async ([name, version]) => { - const finalVersion = version === "*" || version === "latest" || !version - ? await this.fetchLatestVersionFromNpm(name) - : version; - return { name, finalVersion }; - })); - - for (const { name, finalVersion } of resolved) { - this.addDependencyToGlobalMap({ - name, - version: this.normalizeVersion(finalVersion), - ecosystem: Ecosystem.NPM, - }, file.path); - } + private mapEcosystem(system: string): Ecosystem { + const mapping: Record = { + npm: Ecosystem.NPM, + pypi: Ecosystem.PYPI, + maven: Ecosystem.MAVEN, + go: Ecosystem.GO, + cargo: Ecosystem.CARGO, + rubygems: Ecosystem.RUBYGEMS, + pub: Ecosystem.PUB, }; - - if (packageJson.dependencies) await processDeps(packageJson.dependencies); - if (packageJson.devDependencies) await processDeps(packageJson.devDependencies); - } catch (error) { - this.addStepError("File Parsing", error); - } + return mapping[system.toLowerCase()] || Ecosystem.NULL; } - } - - private processPythonFiles(files: Array<{ path: string; content: string }>): void { - for (const file of files) { - const lines = file.content.split("\n").filter((line) => { - const trimmed = line.trim(); - return trimmed && !trimmed.startsWith("#") && !trimmed.startsWith("-"); - }); - - for (const line of lines) { - const match = line.match(/^([a-zA-Z0-9_-]+)(?:[=<>!~]+(.+))?$/); - if (match) { - this.addDependencyToGlobalMap({ - name: match[1], - version: this.normalizeVersion(match[2] || "unknown"), - ecosystem: Ecosystem.PYPI, - }, file.path); + + private getCVSSSeverity(severityArray: { type: string; score: string }[]): { cvss_v3?: string; cvss_v4?: string } { + const result: { cvss_v3?: string; cvss_v4?: string } = {}; + + for (const sev of severityArray) { + try { + if (sev.type === "CVSS_V3") { + const cvss = new Cvss3P0(sev.score); + result.cvss_v3 = cvss.calculateExactOverallScore().toString(); + } else if (sev.type === "CVSS_V4") { + const cvss = new Cvss4P0(sev.score); + result.cvss_v4 = cvss.calculateOverallScore().toString(); + } + } catch { + // Invalid CVSS, skip + } } - } + return result; } - } - - private async processMavenFiles(files: Array<{ path: string; content: string }>): Promise { - for (const file of files) { - try { - const result = await parseStringPromise(file.content); - const propertiesArray = result?.project?.properties?.[0]; - const propertiesMap: Record = {}; - - if (propertiesArray) { - for (const key in propertiesArray) { - if (Object.prototype.hasOwnProperty.call(propertiesArray, key)) { - propertiesMap[key] = propertiesArray[key]?.[0] ?? ""; + + private async retryApiCall( + apiCall: () => Promise, + maxRetries: number = 3, + baseDelay: number = 500, + ): Promise { + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + return await apiCall(); + } catch (error: unknown) { + const isNonRetryable = this.isNonRetryableError(error); + if (isNonRetryable || attempt === maxRetries) { + throw error; + } + const delay = baseDelay * Math.pow(2, attempt - 1) + Math.random() * 200; + await new Promise((resolve) => setTimeout(resolve, delay)); } - } } + throw new Error("Max retries exceeded"); + } - const dependencies = result?.project?.dependencies?.[0]?.dependency ?? []; - for (const dep of dependencies as MavenDependency[]) { - let version = dep.version?.[0] ?? "unknown"; - if (version.startsWith("${") && version.endsWith("}")) { - const propName = version.slice(2, -1); - version = propertiesMap[propName] ?? "unknown"; - } - this.addDependencyToGlobalMap({ - name: dep.artifactId?.[0] ?? "", - version: this.normalizeVersion(version), - ecosystem: Ecosystem.MAVEN, - }, file.path); + private isNonRetryableError(error: unknown): boolean { + if (error && typeof error === 'object' && 'response' in error) { + const response = (error as { response?: { status?: number } }).response; + if (response?.status) { + const status = response.status; + return status >= 400 && status < 500 && status !== 429; + } } - } catch (error) { - this.addStepError("File Parsing", error); - } + return false; } - } - - private processRubyFiles(files: Array<{ path: string; content: string }>): void { - for (const file of files) { - const lines = file.content.split("\n").filter((line) => line.trim().startsWith("gem ")); - for (const line of lines) { - const match = line.match(/gem ['"]([^'"]+)['"](, *['"]([^'"]+)['"])?/); - if (match) { - this.addDependencyToGlobalMap({ - name: match[1], - version: this.normalizeVersion(match[3] || "unknown"), - ecosystem: Ecosystem.RUBYGEMS, - }, file.path); + + private async processBatchesInParallel( + items: T[], + batchSize: number, + concurrency: number, + processor: (item: T) => Promise, + progressStep?: string, + ): Promise { + const results: R[] = []; + const batches: T[][] = []; + + for (let i = 0; i < items.length; i += batchSize) { + batches.push(items.slice(i, i + batchSize)); + } + if (batches.length === 0) { + return results; } - } + + for (let i = 0; i < batches.length; i += concurrency) { + const concurrentBatches = batches.slice(i, i + concurrency); + const batchPromises = concurrentBatches.map(async (batch) => { + return Promise.all(batch.map((item) => processor(item))); + }); + const batchResults = await Promise.all(batchPromises); + results.push(...batchResults.flat()); + + if (progressStep) { + const processed = Math.min(i + concurrency, batches.length); + const progressPercentage = (processed / batches.length) * 100; + this.progressService.progressUpdater(progressStep, progressPercentage); + } + } + + return results; } - } - - private processComposerFiles(files: Array<{ path: string; content: string }>): void { - for (const file of files) { - try { - const composer = JSON.parse(file.content); - const allDeps = { ...composer.require, ...composer["require-dev"] }; - for (const [name, version] of Object.entries(allDeps)) { - if (name !== "php" && !name.startsWith("ext-")) { - this.addDependencyToGlobalMap({ - name, - version: this.normalizeVersion(version as string), - ecosystem: Ecosystem.COMPOSER, - }, file.path); - } + + private async fetchLatestVersionFromNpm(packageName: string): Promise { + const cachedRequest = this.npmLatestVersionCache.get(packageName); + if (cachedRequest) { + return cachedRequest; } - } catch (error) { - this.addStepError("File Parsing", error); - } + + const request = this.retryApiCall( + () => axios.get(`https://registry.npmjs.org/${packageName}/latest`, { timeout: 5000 }), + 2, + 300, + ) + .then((response) => response.data.version || "unknown") + .catch(() => "unknown"); + + this.npmLatestVersionCache.set(packageName, request); + return request; } - } - - private processPubFiles(files: Array<{ path: string; content: string }>): void { - for (const file of files) { - try { - const pubspec = yaml.load(file.content) as { dependencies?: Record; dev_dependencies?: Record }; - const allDeps = { ...pubspec.dependencies, ...pubspec.dev_dependencies }; - for (const [name, version] of Object.entries(allDeps)) { - const versionStr = typeof version === 'string' ? version : 'unknown'; - this.addDependencyToGlobalMap({ - name, - version: this.normalizeVersion(versionStr), - ecosystem: Ecosystem.PUB, - }, file.path); + + // File parsers + private async processNpmFiles(files: Array<{ path: string; content: string }>): Promise { + for (const file of files) { + try { + const packageJson = JSON.parse(file.content); + + const processDeps = async (deps: Record) => { + const entries = Object.entries(deps); + const resolved = await Promise.all(entries.map(async ([name, version]) => { + const finalVersion = version === "*" || version === "latest" || !version + ? await this.fetchLatestVersionFromNpm(name) + : version; + return { name, finalVersion }; + })); + + for (const { name, finalVersion } of resolved) { + this.addDependencyToGlobalMap({ + name, + version: this.normalizeVersion(finalVersion), + ecosystem: Ecosystem.NPM, + }, file.path); + } + }; + + if (packageJson.dependencies) await processDeps(packageJson.dependencies); + if (packageJson.devDependencies) await processDeps(packageJson.devDependencies); + } catch (error) { + this.addStepError("File Parsing", error); + } } - } catch (error) { - this.addStepError("File Parsing", error); - } } - } - - // Main analysis methods - async analyseFromRepo(owner: string, repo: string, branch?: string, includeTransitive = true): Promise { - this.resetState(); - - this.progressService.progressUpdater(PROGRESS_STEPS[0], 0); - - const actualBranch = branch || await this.githubService.getDefaultBranch(owner, repo); - const tree = await this.githubService.getFileTree(owner, repo, actualBranch); - - // Find manifest files - const manifestFilesList = tree - .filter((file) => file.type === "blob") - .map((file) => { - const fileName = file.path.split("/").pop() ?? ""; - const ecosystem = MANIFEST_NAME_TO_ECOSYSTEM.get(fileName); - if (!ecosystem) return null; - return { path: file.path, ecosystem }; - }) - .filter((file): file is { path: string; ecosystem: string } => file !== null); - - if (manifestFilesList.length === 0) { - throw new Error("No manifest files found in the repository"); + + private processPythonFiles(files: Array<{ path: string; content: string }>): void { + for (const file of files) { + const lines = file.content.split("\n").filter((line) => { + const trimmed = line.trim(); + return trimmed && !trimmed.startsWith("#") && !trimmed.startsWith("-"); + }); + + for (const line of lines) { + const match = line.match(/^([a-zA-Z0-9_-]+)(?:[=<>!~]+(.+))?$/); + if (match) { + this.addDependencyToGlobalMap({ + name: match[1], + version: this.normalizeVersion(match[2] || "unknown"), + ecosystem: Ecosystem.PYPI, + }, file.path); + } + } + } } - // Group by ecosystem - const grouped: ManifestFiles = {}; - const filesWithContent = await this.processBatchesInParallel( - manifestFilesList, - this.performanceConfig.batchSize, - this.performanceConfig.concurrency, - async (file) => ({ - ecosystem: file.ecosystem, - path: file.path, - content: await this.githubService.getFileContent(owner, repo, file.path, actualBranch), - }), - PROGRESS_STEPS[0], - ); - - for (const file of filesWithContent) { - if (!grouped[file.ecosystem]) grouped[file.ecosystem] = []; - grouped[file.ecosystem].push({ path: file.path, content: file.content }); + private async processMavenFiles(files: Array<{ path: string; content: string }>): Promise { + for (const file of files) { + try { + const result = await parseStringPromise(file.content); + const propertiesArray = result?.project?.properties?.[0]; + const propertiesMap: Record = {}; + + if (propertiesArray) { + for (const key in propertiesArray) { + if (Object.prototype.hasOwnProperty.call(propertiesArray, key)) { + propertiesMap[key] = propertiesArray[key]?.[0] ?? ""; + } + } + } + + const dependencies = result?.project?.dependencies?.[0]?.dependency ?? []; + for (const dep of dependencies as MavenDependency[]) { + let version = dep.version?.[0] ?? "unknown"; + if (version.startsWith("${") && version.endsWith("}")) { + const propName = version.slice(2, -1); + version = propertiesMap[propName] ?? "unknown"; + } + this.addDependencyToGlobalMap({ + name: dep.artifactId?.[0] ?? "", + version: this.normalizeVersion(version), + ecosystem: Ecosystem.MAVEN, + }, file.path); + } + } catch (error) { + this.addStepError("File Parsing", error); + } + } } - this.progressService.progressUpdater(PROGRESS_STEPS[0], 100); - - return this.analyseManifests(grouped, includeTransitive); - } - - async analyseFromFiles(filePaths: string[], includeTransitive = true): Promise { - this.resetState(); - - this.progressService.progressUpdater(PROGRESS_STEPS[0], 0); - - const grouped: ManifestFiles = {}; - const fs = await import("fs"); - const path = await import("path"); - - for (const filePath of filePaths) { - const fileName = path.basename(filePath); - const ecosystem = MANIFEST_NAME_TO_ECOSYSTEM.get(fileName); - - if (ecosystem) { - if (!grouped[ecosystem]) grouped[ecosystem] = []; - const content = fs.readFileSync(filePath, "utf-8"); - grouped[ecosystem].push({ path: filePath, content }); - } + private processRubyFiles(files: Array<{ path: string; content: string }>): void { + for (const file of files) { + const lines = file.content.split("\n").filter((line) => line.trim().startsWith("gem ")); + for (const line of lines) { + const match = line.match(/gem ['"]([^'"]+)['"](, *['"]([^'"]+)['"])?/); + if (match) { + this.addDependencyToGlobalMap({ + name: match[1], + version: this.normalizeVersion(match[3] || "unknown"), + ecosystem: Ecosystem.RUBYGEMS, + }, file.path); + } + } + } } - if (Object.keys(grouped).length === 0) { - throw new Error("No supported manifest files found"); + private processComposerFiles(files: Array<{ path: string; content: string }>): void { + for (const file of files) { + try { + const composer = JSON.parse(file.content); + const allDeps = { ...composer.require, ...composer["require-dev"] }; + for (const [name, version] of Object.entries(allDeps)) { + if (name !== "php" && !name.startsWith("ext-")) { + this.addDependencyToGlobalMap({ + name, + version: this.normalizeVersion(version as string), + ecosystem: Ecosystem.COMPOSER, + }, file.path); + } + } + } catch (error) { + this.addStepError("File Parsing", error); + } + } } - this.progressService.progressUpdater(PROGRESS_STEPS[0], 100); - - return this.analyseManifests(grouped, includeTransitive); - } - - private async analyseManifests(manifests: ManifestFiles, includeTransitive: boolean): Promise { - this.progressService.progressUpdater(PROGRESS_STEPS[1], 0); - - // Parse all manifest files - await Promise.all([ - this.processNpmFiles(manifests["npm"] ?? []), - this.processMavenFiles(manifests["Maven"] ?? []), - Promise.resolve(this.processPythonFiles(manifests["PiPY"] ?? [])), - Promise.resolve(this.processRubyFiles(manifests["RubyGems"] ?? [])), - Promise.resolve(this.processComposerFiles(manifests["php"] ?? [])), - Promise.resolve(this.processPubFiles(manifests["Pub"] ?? [])), - ]); - - this.progressService.progressUpdater(PROGRESS_STEPS[1], 100); - - // Map to files - let dependencies = this.mapDependenciesToFiles(this.globalDependencyMap); - const totalScannedDependencies = this.countDependencies(dependencies); - - // Get transitive dependencies - if (includeTransitive) { - dependencies = await this.getTransitiveDependencies(dependencies); + private processPubFiles(files: Array<{ path: string; content: string }>): void { + for (const file of files) { + try { + const pubspec = yaml.load(file.content) as { dependencies?: Record; dev_dependencies?: Record }; + const allDeps = { ...pubspec.dependencies, ...pubspec.dev_dependencies }; + for (const [name, version] of Object.entries(allDeps)) { + const versionStr = typeof version === 'string' ? version : 'unknown'; + this.addDependencyToGlobalMap({ + name, + version: this.normalizeVersion(versionStr), + ecosystem: Ecosystem.PUB, + }, file.path); + } + } catch (error) { + this.addStepError("File Parsing", error); + } + } } - // Get vulnerabilities - dependencies = await this.getVulnerabilities(dependencies); + // Main analysis methods + async analyseFromRepo(owner: string, repo: string, branch?: string, includeTransitive = true): Promise { + this.resetState(); - this.progressService.progressUpdater(PROGRESS_STEPS[5], 100); + this.progressService.progressUpdater(PROGRESS_STEPS[0], 0); - // Calculate stats - const stats = this.calculateStats(dependencies); + const actualBranch = branch || await this.githubService.getDefaultBranch(owner, repo); + const tree = await this.githubService.getFileTree(owner, repo, actualBranch); - return { - dependencies, - errors: this.consolidateErrors(), - ...stats, - totalDependencies: totalScannedDependencies, - }; - } - - private async getTransitiveDependencies(dependencies: DependencyGroups): Promise { - const allDeps = Object.values(dependencies).flat(); - const validDeps = allDeps.filter((dep) => dep.version !== "unknown"); - - this.progressService.progressUpdater(PROGRESS_STEPS[2], 0); - - const results = await this.processBatchesInParallel( - validDeps, - this.performanceConfig.transitiveBatchSize, - this.performanceConfig.transitiveConcurrency, - async (dep): Promise => { - try { - const url = `${DEPS_DEV_BASE_URL}/${dep.ecosystem}/packages/${encodeURIComponent(dep.name)}/versions/${encodeURIComponent(dep.version)}:dependencies`; - const response = await this.retryApiCall(() => axios.get(url), 4, 800); - const data = response.data; - - const transitive: TransitiveDependency = { nodes: [], edges: [] }; - data.nodes?.forEach((node) => { - transitive.nodes?.push({ - name: node.versionKey.name, - version: node.versionKey.version, - ecosystem: this.mapEcosystem(node.versionKey.system), - vulnerabilities: [], - dependencyType: node.relation, - }); - }); - data.edges?.forEach((edge) => { - transitive.edges?.push({ - source: edge.fromNode, - target: edge.toNode, - requirement: edge.requirement, - }); - }); + // Find manifest files + const manifestFilesList = tree + .filter((file) => file.type === "blob") + .map((file) => { + const fileName = file.path.split("/").pop() ?? ""; + const ecosystem = MANIFEST_NAME_TO_ECOSYSTEM.get(fileName); + if (!ecosystem) return null; + return { path: file.path, ecosystem }; + }) + .filter((file): file is { path: string; ecosystem: string } => file !== null); - return { dependency: dep, transitiveDependencies: transitive, success: true }; - } catch { - return { dependency: dep, transitiveDependencies: { nodes: [], edges: [] }, success: false }; + if (manifestFilesList.length === 0) { + throw new Error("No manifest files found in the repository"); } - }, - PROGRESS_STEPS[2], - ); - - // Attach transitive deps to main deps - for (const result of results) { - if (result.success) { - result.dependency.transitiveDependencies = result.transitiveDependencies; - } - } - this.progressService.progressUpdater(PROGRESS_STEPS[2], 100); - return dependencies; - } - - private async getVulnerabilities(dependencies: DependencyGroups): Promise { - this.progressService.progressUpdater(PROGRESS_STEPS[3], 0); - - const allDeps: Dependency[] = []; - const depMap = new Map(); - - // Collect all deps including transitives - Object.values(dependencies).flat().forEach((dep) => { - const key = `${dep.ecosystem}:${dep.name}:${dep.version}`; - dep.vulnerabilities = []; - depMap.set(key, dep); - allDeps.push(dep); - - dep.transitiveDependencies?.nodes?.forEach((node) => { - const nodeKey = `${node.ecosystem}:${node.name}:${node.version}`; - if (!depMap.has(nodeKey)) { - node.vulnerabilities = []; - depMap.set(nodeKey, node); - allDeps.push(node); + // Group by ecosystem + const grouped: ManifestFiles = {}; + const filesWithContent = await this.processBatchesInParallel( + manifestFilesList, + this.performanceConfig.batchSize, + this.performanceConfig.concurrency, + async (file) => ({ + ecosystem: file.ecosystem, + path: file.path, + content: await this.githubService.getFileContent(owner, repo, file.path, actualBranch), + }), + PROGRESS_STEPS[0], + ); + + for (const file of filesWithContent) { + if (!grouped[file.ecosystem]) grouped[file.ecosystem] = []; + grouped[file.ecosystem].push({ path: file.path, content: file.content }); } - }); - }); - const vulnIds = new Set(); - const vulnToDeps = new Map>(); - const batches: Dependency[][] = []; - for (let i = 0; i < allDeps.length; i += this.performanceConfig.vulnBatchSize) { - batches.push(allDeps.slice(i, i + this.performanceConfig.vulnBatchSize)); + this.progressService.progressUpdater(PROGRESS_STEPS[0], 100); + + return this.analyseManifests(grouped, includeTransitive); } - // Query OSV for vulnerabilities - for (let i = 0; i < batches.length; i += this.performanceConfig.vulnConcurrency) { - const concurrentBatches = batches.slice(i, i + this.performanceConfig.vulnConcurrency); - - await Promise.all(concurrentBatches.map(async (batch) => { - const queries: OSVQuery[] = batch.map((dep) => ({ - package: { name: dep.name, ecosystem: dep.ecosystem }, - version: dep.version, - })); - - try { - const response = await this.retryApiCall( - () => axios.post(OSV_DEV_VULN_BATCH_URL, { queries }), - 3, - 1000, - ); - - response.data?.results?.forEach((result, idx) => { - const dep = batch[idx]; - if (result.vulns) { - result.vulns.forEach((vuln) => { - if (!dep.vulnerabilities?.some((v) => v.id === vuln.id)) { - dep.vulnerabilities?.push({ id: vuln.id }); - } - vulnIds.add(vuln.id); - if (!vulnToDeps.has(vuln.id)) { - vulnToDeps.set(vuln.id, new Set()); - } - vulnToDeps.get(vuln.id)?.add(dep); - }); + async analyseFromFiles(filePaths: string[], includeTransitive = true): Promise { + this.resetState(); + + this.progressService.progressUpdater(PROGRESS_STEPS[0], 0); + + const grouped: ManifestFiles = {}; + const fs = await import("fs"); + const path = await import("path"); + + for (const filePath of filePaths) { + const fileName = path.basename(filePath); + const ecosystem = MANIFEST_NAME_TO_ECOSYSTEM.get(fileName); + + if (ecosystem) { + if (!grouped[ecosystem]) grouped[ecosystem] = []; + const content = fs.readFileSync(filePath, "utf-8"); + grouped[ecosystem].push({ path: filePath, content }); } - }); - } catch (error) { - this.addStepError("Vulnerability Scanning", error); } - })); - const progress = Math.min(i + this.performanceConfig.vulnConcurrency, batches.length); - this.progressService.progressUpdater(PROGRESS_STEPS[3], (progress / batches.length) * 100); + if (Object.keys(grouped).length === 0) { + throw new Error("No supported manifest files found"); + } + + this.progressService.progressUpdater(PROGRESS_STEPS[0], 100); + + return this.analyseManifests(grouped, includeTransitive); } - this.progressService.progressUpdater(PROGRESS_STEPS[3], 100); - this.progressService.progressUpdater(PROGRESS_STEPS[4], 0); - - // Fetch vulnerability details - const vulnDetails = await this.processBatchesInParallel( - Array.from(vulnIds), - this.performanceConfig.vulnBatchSize, - this.performanceConfig.vulnConcurrency, - async (vulnId): Promise => { - try { - const response = await this.retryApiCall( - () => axios.get(`${OSV_DEV_VULN_DET_URL}${vulnId}`), - 4, - 800, - ); - return response.data; - } catch { - return null; + private async analyseManifests(manifests: ManifestFiles, includeTransitive: boolean): Promise { + this.progressService.progressUpdater(PROGRESS_STEPS[1], 0); + + // Parse all manifest files + await Promise.all([ + this.processNpmFiles(manifests["npm"] ?? []), + this.processMavenFiles(manifests["Maven"] ?? []), + Promise.resolve(this.processPythonFiles(manifests["PiPY"] ?? [])), + Promise.resolve(this.processRubyFiles(manifests["RubyGems"] ?? [])), + Promise.resolve(this.processComposerFiles(manifests["php"] ?? [])), + Promise.resolve(this.processPubFiles(manifests["Pub"] ?? [])), + ]); + + this.progressService.progressUpdater(PROGRESS_STEPS[1], 100); + + // Map to files + let dependencies = this.mapDependenciesToFiles(this.globalDependencyMap); + const totalScannedDependencies = this.countDependencies(dependencies); + + // Get transitive dependencies + if (includeTransitive) { + dependencies = await this.getTransitiveDependencies(dependencies); } - }, - PROGRESS_STEPS[4], - ); - - // Update dependencies with full vulnerability details - for (const vuln of vulnDetails) { - if (!vuln) continue; - - const matchingDeps = vulnToDeps.get(vuln.id); - if (!matchingDeps) continue; - - for (const dep of matchingDeps) { - const existingIdx = dep.vulnerabilities?.findIndex((v) => v.id === vuln.id) ?? -1; - const fixAvailable = vuln?.affected?.[0]?.ranges?.[0]?.events?.find((e) => e.fixed)?.fixed ?? ""; - - const fullVuln: Vulnerability = { - id: vuln.id, - summary: vuln.summary, - details: vuln.details, - severityScore: this.getCVSSSeverity(vuln.severity ?? []), - references: vuln.references ?? [], - affected: vuln.affected ?? [], - aliases: vuln.aliases ?? [], - fixAvailable, + + // Get vulnerabilities + dependencies = await this.getVulnerabilities(dependencies); + + this.progressService.progressUpdater(PROGRESS_STEPS[5], 100); + + // Calculate stats + const stats = this.calculateStats(dependencies); + + return { + dependencies, + errors: this.consolidateErrors(), + ...stats, + totalDependencies: totalScannedDependencies, }; + } + + private async getTransitiveDependencies(dependencies: DependencyGroups): Promise { + const allDeps = Object.values(dependencies).flat(); + const validDeps = allDeps.filter((dep) => dep.version !== "unknown"); + + this.progressService.progressUpdater(PROGRESS_STEPS[2], 0); + + const results = await this.processBatchesInParallel( + validDeps, + this.performanceConfig.transitiveBatchSize, + this.performanceConfig.transitiveConcurrency, + async (dep): Promise => { + try { + const url = `${DEPS_DEV_BASE_URL}/${dep.ecosystem}/packages/${encodeURIComponent(dep.name)}/versions/${encodeURIComponent(dep.version)}:dependencies`; + const response = await this.retryApiCall(() => axios.get(url), 4, 800); + const data = response.data; + + const transitive: TransitiveDependency = { nodes: [], edges: [] }; + data.nodes?.forEach((node) => { + transitive.nodes?.push({ + name: node.versionKey.name, + version: node.versionKey.version, + ecosystem: this.mapEcosystem(node.versionKey.system), + vulnerabilities: [], + dependencyType: node.relation, + }); + }); + data.edges?.forEach((edge) => { + transitive.edges?.push({ + source: edge.fromNode, + target: edge.toNode, + requirement: edge.requirement, + }); + }); + + return { dependency: dep, transitiveDependencies: transitive, success: true }; + } catch { + return { dependency: dep, transitiveDependencies: { nodes: [], edges: [] }, success: false }; + } + }, + PROGRESS_STEPS[2], + ); - if (existingIdx !== -1 && dep.vulnerabilities) { - dep.vulnerabilities[existingIdx] = fullVuln; + // Attach transitive deps to main deps + for (const result of results) { + if (result.success) { + result.dependency.transitiveDependencies = result.transitiveDependencies; + } } - } + + this.progressService.progressUpdater(PROGRESS_STEPS[2], 100); + return dependencies; } - this.progressService.progressUpdater(PROGRESS_STEPS[4], 100); + private async getVulnerabilities(dependencies: DependencyGroups): Promise { + this.progressService.progressUpdater(PROGRESS_STEPS[3], 0); + + const allDeps: Dependency[] = []; + const depMap = new Map(); + + // Collect all deps including transitives + Object.values(dependencies).flat().forEach((dep) => { + const key = `${dep.ecosystem}:${dep.name}:${dep.version}`; + dep.vulnerabilities = []; + depMap.set(key, dep); + allDeps.push(dep); + + dep.transitiveDependencies?.nodes?.forEach((node) => { + const nodeKey = `${node.ecosystem}:${node.name}:${node.version}`; + if (!depMap.has(nodeKey)) { + node.vulnerabilities = []; + depMap.set(nodeKey, node); + allDeps.push(node); + } + }); + }); + + const vulnIds = new Set(); + const vulnToDeps = new Map>(); + const batches: Dependency[][] = []; + for (let i = 0; i < allDeps.length; i += this.performanceConfig.vulnBatchSize) { + batches.push(allDeps.slice(i, i + this.performanceConfig.vulnBatchSize)); + } + + // Query OSV for vulnerabilities + for (let i = 0; i < batches.length; i += this.performanceConfig.vulnConcurrency) { + const concurrentBatches = batches.slice(i, i + this.performanceConfig.vulnConcurrency); + + await Promise.all(concurrentBatches.map(async (batch) => { + const queries: OSVQuery[] = batch.map((dep) => ({ + package: { name: dep.name, ecosystem: dep.ecosystem }, + version: dep.version, + })); + + try { + const response = await this.retryApiCall( + () => axios.post(OSV_DEV_VULN_BATCH_URL, { queries }), + 3, + 1000, + ); + + response.data?.results?.forEach((result, idx) => { + const dep = batch[idx]; + if (result.vulns) { + result.vulns.forEach((vuln) => { + if (!dep.vulnerabilities?.some((v) => v.id === vuln.id)) { + dep.vulnerabilities?.push({ id: vuln.id }); + } + vulnIds.add(vuln.id); + if (!vulnToDeps.has(vuln.id)) { + vulnToDeps.set(vuln.id, new Set()); + } + vulnToDeps.get(vuln.id)?.add(dep); + }); + } + }); + } catch (error) { + this.addStepError("Vulnerability Scanning", error); + } + })); - // Filter to only vulnerable dependencies - return this.filterVulnerable(dependencies); - } + const progress = Math.min(i + this.performanceConfig.vulnConcurrency, batches.length); + this.progressService.progressUpdater(PROGRESS_STEPS[3], (progress / batches.length) * 100); + } - private filterVulnerable(dependencies: DependencyGroups): DependencyGroups { - // Filter transitive nodes to only keep vulnerable ones - Object.values(dependencies).flat().forEach((dep) => { - if (dep.transitiveDependencies?.nodes) { - const vulnerableNodes = dep.transitiveDependencies.nodes.filter( - (node) => (node.vulnerabilities && node.vulnerabilities.length > 0) || node.dependencyType === "SELF" + this.progressService.progressUpdater(PROGRESS_STEPS[3], 100); + this.progressService.progressUpdater(PROGRESS_STEPS[4], 0); + + // Fetch vulnerability details + const vulnDetails = await this.processBatchesInParallel( + Array.from(vulnIds), + this.performanceConfig.vulnBatchSize, + this.performanceConfig.vulnConcurrency, + async (vulnId): Promise => { + try { + const response = await this.retryApiCall( + () => axios.get(`${OSV_DEV_VULN_DET_URL}${vulnId}`), + 4, + 800, + ); + return response.data; + } catch { + return null; + } + }, + PROGRESS_STEPS[4], ); - - const oldToNew: Record = {}; - dep.transitiveDependencies.nodes.forEach((node, oldIdx) => { - const newIdx = vulnerableNodes.findIndex( - (n) => n.name === node.name && n.version === node.version && n.ecosystem === node.ecosystem - ); - if (newIdx !== -1) oldToNew[oldIdx] = newIdx; + + // Update dependencies with full vulnerability details + for (const vuln of vulnDetails) { + if (!vuln) continue; + + const matchingDeps = vulnToDeps.get(vuln.id); + if (!matchingDeps) continue; + + for (const dep of matchingDeps) { + const existingIdx = dep.vulnerabilities?.findIndex((v) => v.id === vuln.id) ?? -1; + const fixAvailable = vuln?.affected?.[0]?.ranges?.[0]?.events?.find((e) => e.fixed)?.fixed ?? ""; + + const fullVuln: Vulnerability = { + id: vuln.id, + summary: vuln.summary, + details: vuln.details, + severityScore: this.getCVSSSeverity(vuln.severity ?? []), + references: vuln.references ?? [], + affected: vuln.affected ?? [], + aliases: vuln.aliases ?? [], + fixAvailable, + }; + + if (existingIdx !== -1 && dep.vulnerabilities) { + dep.vulnerabilities[existingIdx] = fullVuln; + } + } + } + + this.progressService.progressUpdater(PROGRESS_STEPS[4], 100); + + // Filter to only vulnerable dependencies + return this.filterVulnerable(dependencies); + } + + private filterVulnerable(dependencies: DependencyGroups): DependencyGroups { + // Filter transitive nodes to only keep vulnerable ones + Object.values(dependencies).flat().forEach((dep) => { + if (dep.transitiveDependencies?.nodes) { + const vulnerableNodes = dep.transitiveDependencies.nodes.filter( + (node) => (node.vulnerabilities && node.vulnerabilities.length > 0) || node.dependencyType === "SELF" + ); + + const oldToNew: Record = {}; + dep.transitiveDependencies.nodes.forEach((node, oldIdx) => { + const newIdx = vulnerableNodes.findIndex( + (n) => n.name === node.name && n.version === node.version && n.ecosystem === node.ecosystem + ); + if (newIdx !== -1) oldToNew[oldIdx] = newIdx; + }); + + const edgeSet = new Set(); + const vulnerableEdges = (dep.transitiveDependencies.edges ?? []) + .map((edge) => { + const newSource = oldToNew[edge.source]; + const newTarget = oldToNew[edge.target]; + if (newSource === undefined || newTarget === undefined) return null; + const key = `${newSource}-${newTarget}`; + if (edgeSet.has(key)) return null; + edgeSet.add(key); + return { source: newSource, target: newTarget, requirement: edge.requirement }; + }) + .filter((e): e is NonNullable => e !== null); + + dep.transitiveDependencies.nodes = vulnerableNodes; + dep.transitiveDependencies.edges = vulnerableEdges; + } }); - const edgeSet = new Set(); - const vulnerableEdges = (dep.transitiveDependencies.edges ?? []) - .map((edge) => { - const newSource = oldToNew[edge.source]; - const newTarget = oldToNew[edge.target]; - if (newSource === undefined || newTarget === undefined) return null; - const key = `${newSource}-${newTarget}`; - if (edgeSet.has(key)) return null; - edgeSet.add(key); - return { source: newSource, target: newTarget, requirement: edge.requirement }; - }) - .filter((e): e is NonNullable => e !== null); - - dep.transitiveDependencies.nodes = vulnerableNodes; - dep.transitiveDependencies.edges = vulnerableEdges; - } - }); + // Filter main deps + const filtered: DependencyGroups = {}; + Object.entries(dependencies).forEach(([group, deps]) => { + const relevantDeps = deps.filter((dep) => { + const hasVulns = dep.vulnerabilities && dep.vulnerabilities.length > 0; + const transitiveHasVulns = dep.transitiveDependencies?.nodes?.some( + (node) => node.vulnerabilities && node.vulnerabilities.length > 0 + ); + return hasVulns || transitiveHasVulns; + }); + if (relevantDeps.length > 0) { + filtered[group] = relevantDeps; + } + }); - // Filter main deps - const filtered: DependencyGroups = {}; - Object.entries(dependencies).forEach(([group, deps]) => { - const relevantDeps = deps.filter((dep) => { - const hasVulns = dep.vulnerabilities && dep.vulnerabilities.length > 0; - const transitiveHasVulns = dep.transitiveDependencies?.nodes?.some( - (node) => node.vulnerabilities && node.vulnerabilities.length > 0 - ); - return hasVulns || transitiveHasVulns; - }); - if (relevantDeps.length > 0) { - filtered[group] = relevantDeps; - } - }); + return filtered; + } - return filtered; - } - - private calculateStats(dependencies: DependencyGroups) { - let totalDependencies = 0; - let totalVulnerabilities = 0; - let criticalCount = 0; - let highCount = 0; - let mediumCount = 0; - let lowCount = 0; - - const countedVulns = new Set(); - - const processVulns = (vulns?: Vulnerability[]) => { - vulns?.forEach((v) => { - if (countedVulns.has(v.id)) return; - countedVulns.add(v.id); - totalVulnerabilities++; - - const score = parseFloat(v.severityScore?.cvss_v3 || v.severityScore?.cvss_v4 || "0"); - if (score >= 9.0) criticalCount++; - else if (score >= 7.0) highCount++; - else if (score >= 4.0) mediumCount++; - else lowCount++; - }); - }; + private calculateStats(dependencies: DependencyGroups) { + let totalDependencies = 0; + let totalVulnerabilities = 0; + let criticalCount = 0; + let highCount = 0; + let mediumCount = 0; + let lowCount = 0; + + const countedVulns = new Set(); + + const processVulns = (vulns?: Vulnerability[]) => { + vulns?.forEach((v) => { + if (countedVulns.has(v.id)) return; + countedVulns.add(v.id); + totalVulnerabilities++; + + const score = parseFloat(v.severityScore?.cvss_v3 || v.severityScore?.cvss_v4 || "0"); + if (score >= 9.0) criticalCount++; + else if (score >= 7.0) highCount++; + else if (score >= 4.0) mediumCount++; + else lowCount++; + }); + }; - Object.values(dependencies).flat().forEach((dep) => { - totalDependencies++; - processVulns(dep.vulnerabilities); - dep.transitiveDependencies?.nodes?.forEach((node) => { - totalDependencies++; - processVulns(node.vulnerabilities); - }); - }); + Object.values(dependencies).flat().forEach((dep) => { + totalDependencies++; + processVulns(dep.vulnerabilities); + dep.transitiveDependencies?.nodes?.forEach((node) => { + totalDependencies++; + processVulns(node.vulnerabilities); + }); + }); - return { totalDependencies, totalVulnerabilities, criticalCount, highCount, mediumCount, lowCount }; - } + return { totalDependencies, totalVulnerabilities, criticalCount, highCount, mediumCount, lowCount }; + } - private countDependencies(dependencies: DependencyGroups): number { - let total = 0; - Object.values(dependencies).flat().forEach((dep) => { - total++; - total += dep.transitiveDependencies?.nodes?.length ?? 0; - }); - return total; - } + private countDependencies(dependencies: DependencyGroups): number { + let total = 0; + Object.values(dependencies).flat().forEach((dep) => { + total++; + total += dep.transitiveDependencies?.nodes?.length ?? 0; + }); + return total; + } } // Convenience function for programmatic usage export async function analyse(options: AnalyseOptions): Promise { - const analyser = new Analyser({ - token: options.token, - onProgress: options.onProgress, - }); - - if (options.repo) { - const [owner, repo] = options.repo.split("/"); - return analyser.analyseFromRepo(owner, repo, options.branch, options.includeTransitive); - } else if (options.files && options.files.length > 0) { - return analyser.analyseFromFiles(options.files, options.includeTransitive); - } else { - // Default: look for manifest files in current directory - const fs = await import("fs"); - const path = await import("path"); - const cwd = process.cwd(); - - const defaultFiles = Object.values(manifestFiles) - .map((name) => path.join(cwd, name)) - .filter((p) => fs.existsSync(p)); - - if (defaultFiles.length === 0) { - throw new Error("No manifest files found in current directory"); - } + const analyser = new Analyser({ + token: options.token, + onProgress: options.onProgress, + }); + + if (options.repo) { + const [owner, repo] = options.repo.split("/"); + return analyser.analyseFromRepo(owner, repo, options.branch, options.includeTransitive); + } else if (options.files && options.files.length > 0) { + return analyser.analyseFromFiles(options.files, options.includeTransitive); + } else { + // Default: look for manifest files in current directory + const fs = await import("fs"); + const path = await import("path"); + const cwd = process.cwd(); + + const defaultFiles = Object.values(manifestFiles) + .map((name) => path.join(cwd, name)) + .filter((p) => fs.existsSync(p)); + + if (defaultFiles.length === 0) { + throw new Error("No manifest files found in current directory"); + } - return analyser.analyseFromFiles(defaultFiles, options.includeTransitive); - } + return analyser.analyseFromFiles(defaultFiles, options.includeTransitive); + } } diff --git a/packages/cli/src/core/config.ts b/packages/cli/src/core/config.ts index 4f68d2d..6391962 100644 --- a/packages/cli/src/core/config.ts +++ b/packages/cli/src/core/config.ts @@ -3,9 +3,9 @@ import path from "path"; import { z } from "zod"; const configSchema = z.object({ - github_token: z.string().optional(), - include_transitive: z.boolean().optional(), - output_format: z.enum(["table", "json", "markdown"]).optional(), + github_token: z.string().optional(), + include_transitive: z.boolean().optional(), + output_format: z.enum(["table", "json", "markdown"]).optional(), }); export type Config = z.infer; @@ -13,52 +13,52 @@ export type Config = z.infer; const CONFIG_FILES = [".gitdepsecrc", ".gitdepsec.json", ".gitdepsecrc.json"]; function parseBooleanEnv(value: string | undefined): boolean | undefined { - if (value === undefined) return undefined; - if (value === "true") return true; - if (value === "false") return false; - return undefined; + if (value === undefined) return undefined; + if (value === "true") return true; + if (value === "false") return false; + return undefined; } export function loadConfig(cwd: string = process.cwd()): Config { - // Start with environment variables - const envConfig: Config = { - github_token: process.env.GITHUB_TOKEN || process.env.GH_TOKEN, - include_transitive: parseBooleanEnv(process.env.GDS_INCLUDE_TRANSITIVE), - output_format: process.env.GDS_OUTPUT_FORMAT as Config["output_format"], - }; + // Start with environment variables + const envConfig: Config = { + github_token: process.env.GITHUB_TOKEN || process.env.GH_TOKEN, + include_transitive: parseBooleanEnv(process.env.GDS_INCLUDE_TRANSITIVE), + output_format: process.env.GDS_OUTPUT_FORMAT as Config["output_format"], + }; - // Try to find and load a config file - for (const configFile of CONFIG_FILES) { - const configPath = path.join(cwd, configFile); - if (fs.existsSync(configPath)) { - try { - const content = fs.readFileSync(configPath, "utf-8"); - const parsed = JSON.parse(content); - const validated = configSchema.safeParse(parsed); - if (validated.success) { - // Merge file config with env config (env takes precedence) - return { - ...validated.data, - ...Object.fromEntries( - Object.entries(envConfig).filter(([, v]) => v !== undefined) - ), - }; + // Try to find and load a config file + for (const configFile of CONFIG_FILES) { + const configPath = path.join(cwd, configFile); + if (fs.existsSync(configPath)) { + try { + const content = fs.readFileSync(configPath, "utf-8"); + const parsed = JSON.parse(content); + const validated = configSchema.safeParse(parsed); + if (validated.success) { + // Merge file config with env config (env takes precedence) + return { + ...validated.data, + ...Object.fromEntries( + Object.entries(envConfig).filter(([, v]) => v !== undefined) + ), + }; + } + } catch { + // Invalid config file, continue to next + } } - } catch { - // Invalid config file, continue to next - } } - } - return envConfig; + return envConfig; } export function getConfigPath(cwd: string = process.cwd()): string | null { - for (const configFile of CONFIG_FILES) { - const configPath = path.join(cwd, configFile); - if (fs.existsSync(configPath)) { - return configPath; + for (const configFile of CONFIG_FILES) { + const configPath = path.join(cwd, configFile); + if (fs.existsSync(configPath)) { + return configPath; + } } - } - return null; + return null; } diff --git a/packages/cli/src/core/constants.ts b/packages/cli/src/core/constants.ts index 8bc1686..fe85876 100644 --- a/packages/cli/src/core/constants.ts +++ b/packages/cli/src/core/constants.ts @@ -12,27 +12,27 @@ export const OSV_DEV_VULN_DET_URL = "https://api.osv.dev/v1/vulns/"; export const DEPS_DEV_BASE_URL = "https://api.deps.dev/v3/systems"; export const PROGRESS_STEPS = [ - "PARSING_MANIFESTS", - "PARSING_DEPENDENCIES", - "FETCHING_TRANSITIVE_DEPENDENCIES", - "FETCHING_VULNERABILTIES_ID", - "FETCHING_VULNERABILTIES_DETAILS", - "FINALISING_RESULTS", + "PARSING_MANIFESTS", + "PARSING_DEPENDENCIES", + "FETCHING_TRANSITIVE_DEPENDENCIES", + "FETCHING_VULNERABILTIES_ID", + "FETCHING_VULNERABILTIES_DETAILS", + "FINALISING_RESULTS", ] as const; export const manifestFiles: Record = { - npm: "package.json", - PiPY: "requirements.txt", - Maven: "pom.xml", - RubyGems: "Gemfile", - php: "composer.json", - Pub: "pubspec.yaml", + npm: "package.json", + PiPY: "requirements.txt", + Maven: "pom.xml", + RubyGems: "Gemfile", + php: "composer.json", + Pub: "pubspec.yaml", }; export const SEVERITY_COLORS = { - critical: "#ff0000", - high: "#ff6600", - medium: "#ffcc00", - low: "#00cc00", - unknown: "#808080", + critical: "#ff0000", + high: "#ff6600", + medium: "#ffcc00", + low: "#00cc00", + unknown: "#808080", } as const; diff --git a/packages/cli/src/core/fix-planner.ts b/packages/cli/src/core/fix-planner.ts index 148819b..760955d 100644 --- a/packages/cli/src/core/fix-planner.ts +++ b/packages/cli/src/core/fix-planner.ts @@ -5,200 +5,200 @@ export interface FixPlanOptions extends AnalyseOptions { } export interface FixAction { - dependency: string; - ecosystem: string; - currentVersion: string; - recommendedVersion?: string; - action: "upgrade" | "patch" | "replace" | "remove" | "investigate"; - command?: string; - vulnerabilities: string[]; - severity: "critical" | "high" | "medium" | "low"; - reasoning: string; + dependency: string; + ecosystem: string; + currentVersion: string; + recommendedVersion?: string; + action: "upgrade" | "patch" | "replace" | "remove" | "investigate"; + command?: string; + vulnerabilities: string[]; + severity: "critical" | "high" | "medium" | "low"; + reasoning: string; } export interface FixPlan { - summary: { - totalVulnerabilities: number; - fixableCount: number; - criticalCount: number; - highCount: number; - estimatedTime: string; - }; - quickWins: FixAction[]; - actions: FixAction[]; - phases: { - name: string; - urgency: string; + summary: { + totalVulnerabilities: number; + fixableCount: number; + criticalCount: number; + highCount: number; + estimatedTime: string; + }; + quickWins: FixAction[]; actions: FixAction[]; - }[]; + phases: { + name: string; + urgency: string; + actions: FixAction[]; + }[]; } function getSeverity(vuln: Vulnerability): "critical" | "high" | "medium" | "low" { - const score = parseFloat(vuln.severityScore?.cvss_v3 || vuln.severityScore?.cvss_v4 || "0"); - if (score >= 9.0) return "critical"; - if (score >= 7.0) return "high"; - if (score >= 4.0) return "medium"; - return "low"; + const score = parseFloat(vuln.severityScore?.cvss_v3 || vuln.severityScore?.cvss_v4 || "0"); + if (score >= 9.0) return "critical"; + if (score >= 7.0) return "high"; + if (score >= 4.0) return "medium"; + return "low"; } function getUpdateCommand(dep: Dependency, targetVersion: string): string { - switch (dep.ecosystem) { - case "npm": - return `npm install ${dep.name}@${targetVersion}`; - case "PyPI": - return `pip install ${dep.name}==${targetVersion}`; - case "Maven": - return `Update ${dep.name} to ${targetVersion} in pom.xml`; - case "Cargo": - return `cargo update -p ${dep.name}`; - case "Go": - return `go get ${dep.name}@${targetVersion}`; - case "Rubygems": - return `gem update ${dep.name} -v ${targetVersion}`; - default: - return `Update ${dep.name} to ${targetVersion}`; - } + switch (dep.ecosystem) { + case "npm": + return `npm install ${dep.name}@${targetVersion}`; + case "PyPI": + return `pip install ${dep.name}==${targetVersion}`; + case "Maven": + return `Update ${dep.name} to ${targetVersion} in pom.xml`; + case "Cargo": + return `cargo update -p ${dep.name}`; + case "Go": + return `go get ${dep.name}@${targetVersion}`; + case "Rubygems": + return `gem update ${dep.name} -v ${targetVersion}`; + default: + return `Update ${dep.name} to ${targetVersion}`; + } } function generateActions(dependencies: DependencyGroups): FixAction[] { - const actions: FixAction[] = []; - const processed = new Set(); - - Object.values(dependencies).flat().forEach((dep) => { - const key = `${dep.name}@${dep.version}`; - if (processed.has(key)) return; - processed.add(key); - - if (dep.vulnerabilities && dep.vulnerabilities.length > 0) { - const highestSeverity = dep.vulnerabilities.reduce((highest, v) => { - const sev = getSeverity(v); - const order = { critical: 4, high: 3, medium: 2, low: 1 }; - return order[sev] > order[highest] ? sev : highest; - }, "low" as "critical" | "high" | "medium" | "low"); - - // Check if there's a fix available - const fixVersions = dep.vulnerabilities - .map((v) => v.fixAvailable) - .filter((v): v is string => !!v); - - const recommendedVersion = fixVersions.length > 0 ? fixVersions[0] : undefined; - - actions.push({ - dependency: dep.name, - ecosystem: dep.ecosystem, - currentVersion: dep.version, - recommendedVersion, - action: recommendedVersion ? "upgrade" : "investigate", - command: recommendedVersion ? getUpdateCommand(dep, recommendedVersion) : undefined, - vulnerabilities: dep.vulnerabilities.map((v) => v.id), - severity: highestSeverity, - reasoning: recommendedVersion - ? `Upgrade to ${recommendedVersion} to fix ${dep.vulnerabilities.length} vulnerabilities` - : `Investigate ${dep.vulnerabilities.length} vulnerabilities - no automatic fix available`, - }); - } - - // Also check transitive dependencies - dep.transitiveDependencies?.nodes?.forEach((node) => { - const nodeKey = `${node.name}@${node.version}`; - if (processed.has(nodeKey)) return; - processed.add(nodeKey); - - if (node.vulnerabilities && node.vulnerabilities.length > 0) { - const highestSeverity = node.vulnerabilities.reduce((highest, v) => { - const sev = getSeverity(v); - const order = { critical: 4, high: 3, medium: 2, low: 1 }; - return order[sev] > order[highest] ? sev : highest; - }, "low" as "critical" | "high" | "medium" | "low"); - - const fixVersions = node.vulnerabilities - .map((v) => v.fixAvailable) - .filter((v): v is string => !!v); - - const recommendedVersion = fixVersions.length > 0 ? fixVersions[0] : undefined; - - actions.push({ - dependency: node.name, - ecosystem: node.ecosystem, - currentVersion: node.version, - recommendedVersion, - action: recommendedVersion ? "upgrade" : "investigate", - command: recommendedVersion ? getUpdateCommand(node, recommendedVersion) : undefined, - vulnerabilities: node.vulnerabilities.map((v) => v.id), - severity: highestSeverity, - reasoning: recommendedVersion - ? `Transitive: Upgrade parent dependency to get ${node.name}@${recommendedVersion}` - : `Transitive: Investigate - ${node.name} is a transitive dependency`, + const actions: FixAction[] = []; + const processed = new Set(); + + Object.values(dependencies).flat().forEach((dep) => { + const key = `${dep.name}@${dep.version}`; + if (processed.has(key)) return; + processed.add(key); + + if (dep.vulnerabilities && dep.vulnerabilities.length > 0) { + const highestSeverity = dep.vulnerabilities.reduce((highest, v) => { + const sev = getSeverity(v); + const order = { critical: 4, high: 3, medium: 2, low: 1 }; + return order[sev] > order[highest] ? sev : highest; + }, "low" as "critical" | "high" | "medium" | "low"); + + // Check if there's a fix available + const fixVersions = dep.vulnerabilities + .map((v) => v.fixAvailable) + .filter((v): v is string => !!v); + + const recommendedVersion = fixVersions.length > 0 ? fixVersions[0] : undefined; + + actions.push({ + dependency: dep.name, + ecosystem: dep.ecosystem, + currentVersion: dep.version, + recommendedVersion, + action: recommendedVersion ? "upgrade" : "investigate", + command: recommendedVersion ? getUpdateCommand(dep, recommendedVersion) : undefined, + vulnerabilities: dep.vulnerabilities.map((v) => v.id), + severity: highestSeverity, + reasoning: recommendedVersion + ? `Upgrade to ${recommendedVersion} to fix ${dep.vulnerabilities.length} vulnerabilities` + : `Investigate ${dep.vulnerabilities.length} vulnerabilities - no automatic fix available`, + }); + } + + // Also check transitive dependencies + dep.transitiveDependencies?.nodes?.forEach((node) => { + const nodeKey = `${node.name}@${node.version}`; + if (processed.has(nodeKey)) return; + processed.add(nodeKey); + + if (node.vulnerabilities && node.vulnerabilities.length > 0) { + const highestSeverity = node.vulnerabilities.reduce((highest, v) => { + const sev = getSeverity(v); + const order = { critical: 4, high: 3, medium: 2, low: 1 }; + return order[sev] > order[highest] ? sev : highest; + }, "low" as "critical" | "high" | "medium" | "low"); + + const fixVersions = node.vulnerabilities + .map((v) => v.fixAvailable) + .filter((v): v is string => !!v); + + const recommendedVersion = fixVersions.length > 0 ? fixVersions[0] : undefined; + + actions.push({ + dependency: node.name, + ecosystem: node.ecosystem, + currentVersion: node.version, + recommendedVersion, + action: recommendedVersion ? "upgrade" : "investigate", + command: recommendedVersion ? getUpdateCommand(node, recommendedVersion) : undefined, + vulnerabilities: node.vulnerabilities.map((v) => v.id), + severity: highestSeverity, + reasoning: recommendedVersion + ? `Transitive: Upgrade parent dependency to get ${node.name}@${recommendedVersion}` + : `Transitive: Investigate - ${node.name} is a transitive dependency`, + }); + } }); - } }); - }); - // Sort by severity (critical first) - const severityOrder = { critical: 0, high: 1, medium: 2, low: 3 }; - return actions.sort((a, b) => severityOrder[a.severity] - severityOrder[b.severity]); + // Sort by severity (critical first) + const severityOrder = { critical: 0, high: 1, medium: 2, low: 3 }; + return actions.sort((a, b) => severityOrder[a.severity] - severityOrder[b.severity]); } export function buildFixPlan(analysisResult: AnalysisResult): FixPlan { - // Generate fix actions - const actions = generateActions(analysisResult.dependencies); - - // Calculate summary - const fixableCount = actions.filter((a) => a.action === "upgrade").length; - const criticalCount = actions.filter((a) => a.severity === "critical").length; - const highCount = actions.filter((a) => a.severity === "high").length; - - // Estimate time (rough estimate) - const estimatedMinutes = fixableCount * 5 + (actions.length - fixableCount) * 15; - const estimatedTime = estimatedMinutes < 60 - ? `${estimatedMinutes} minutes` - : `${Math.round(estimatedMinutes / 60)} hours`; - - // Quick wins: fixable critical/high severity - const quickWins = actions.filter( - (a) => a.action === "upgrade" && (a.severity === "critical" || a.severity === "high") - ).slice(0, 5); - - // Organize into phases - const phases = [ - { - name: "Immediate", - urgency: "Within 24 hours", - actions: actions.filter((a) => a.severity === "critical"), - }, - { - name: "Urgent", - urgency: "Within 1 week", - actions: actions.filter((a) => a.severity === "high"), - }, - { - name: "Standard", - urgency: "Within 1 month", - actions: actions.filter((a) => a.severity === "medium"), - }, - { - name: "Low Priority", - urgency: "When convenient", - actions: actions.filter((a) => a.severity === "low"), - }, - ].filter((p) => p.actions.length > 0); - - return { - summary: { - totalVulnerabilities: analysisResult.totalVulnerabilities, - fixableCount, - criticalCount, - highCount, - estimatedTime, - }, - quickWins, - actions, - phases, - }; + // Generate fix actions + const actions = generateActions(analysisResult.dependencies); + + // Calculate summary + const fixableCount = actions.filter((a) => a.action === "upgrade").length; + const criticalCount = actions.filter((a) => a.severity === "critical").length; + const highCount = actions.filter((a) => a.severity === "high").length; + + // Estimate time (rough estimate) + const estimatedMinutes = fixableCount * 5 + (actions.length - fixableCount) * 15; + const estimatedTime = estimatedMinutes < 60 + ? `${estimatedMinutes} minutes` + : `${Math.round(estimatedMinutes / 60)} hours`; + + // Quick wins: fixable critical/high severity + const quickWins = actions.filter( + (a) => a.action === "upgrade" && (a.severity === "critical" || a.severity === "high") + ).slice(0, 5); + + // Organize into phases + const phases = [ + { + name: "Immediate", + urgency: "Within 24 hours", + actions: actions.filter((a) => a.severity === "critical"), + }, + { + name: "Urgent", + urgency: "Within 1 week", + actions: actions.filter((a) => a.severity === "high"), + }, + { + name: "Standard", + urgency: "Within 1 month", + actions: actions.filter((a) => a.severity === "medium"), + }, + { + name: "Low Priority", + urgency: "When convenient", + actions: actions.filter((a) => a.severity === "low"), + }, + ].filter((p) => p.actions.length > 0); + + return { + summary: { + totalVulnerabilities: analysisResult.totalVulnerabilities, + fixableCount, + criticalCount, + highCount, + estimatedTime, + }, + quickWins, + actions, + phases, + }; } export async function generateFixPlan(options: FixPlanOptions): Promise { - // First, run analysis - const analysisResult = await analyse(options); - return buildFixPlan(analysisResult); + // First, run analysis + const analysisResult = await analyse(options); + return buildFixPlan(analysisResult); } diff --git a/packages/cli/src/core/github.ts b/packages/cli/src/core/github.ts index 41c693e..b45589d 100644 --- a/packages/cli/src/core/github.ts +++ b/packages/cli/src/core/github.ts @@ -2,55 +2,55 @@ import axios, { type AxiosInstance } from "axios"; import { GITHUB_API_BASE_URL } from "./constants.js"; export class GitHubService { - private client: AxiosInstance; + private client: AxiosInstance; - constructor(token?: string) { - this.client = axios.create({ - baseURL: GITHUB_API_BASE_URL, - headers: token - ? { - Authorization: `Bearer ${token}`, - Accept: "application/vnd.github.v3+json", - } - : { - Accept: "application/vnd.github.v3+json", - }, - }); - } + constructor(token?: string) { + this.client = axios.create({ + baseURL: GITHUB_API_BASE_URL, + headers: token + ? { + Authorization: `Bearer ${token}`, + Accept: "application/vnd.github.v3+json", + } + : { + Accept: "application/vnd.github.v3+json", + }, + }); + } - async getDefaultBranch(owner: string, repo: string): Promise { - const response = await this.client.get(`/repos/${owner}/${repo}`); - return response.data.default_branch; - } + async getDefaultBranch(owner: string, repo: string): Promise { + const response = await this.client.get(`/repos/${owner}/${repo}`); + return response.data.default_branch; + } - async getFileTree( - owner: string, - repo: string, - branch: string - ): Promise> { - const response = await this.client.get( - `/repos/${owner}/${repo}/git/trees/${branch}?recursive=1` - ); - return response.data.tree; - } + async getFileTree( + owner: string, + repo: string, + branch: string + ): Promise> { + const response = await this.client.get( + `/repos/${owner}/${repo}/git/trees/${branch}?recursive=1` + ); + return response.data.tree; + } - async getFileContent( - owner: string, - repo: string, - path: string, - branch: string - ): Promise { - const response = await this.client.get( - `/repos/${owner}/${repo}/contents/${path}?ref=${branch}` - ); - const content = response.data.content; - if (!content) { - throw new Error(`File ${path} has no content`); + async getFileContent( + owner: string, + repo: string, + path: string, + branch: string + ): Promise { + const response = await this.client.get( + `/repos/${owner}/${repo}/contents/${path}?ref=${branch}` + ); + const content = response.data.content; + if (!content) { + throw new Error(`File ${path} has no content`); + } + return Buffer.from(content, "base64").toString("utf8"); } - return Buffer.from(content, "base64").toString("utf8"); - } - async get(url: string) { - return this.client.get(url); - } + async get(url: string) { + return this.client.get(url); + } } diff --git a/packages/cli/src/core/progress.ts b/packages/cli/src/core/progress.ts index 003f5f1..28b8ede 100644 --- a/packages/cli/src/core/progress.ts +++ b/packages/cli/src/core/progress.ts @@ -4,106 +4,103 @@ import chalk from "chalk"; export type ProgressCallback = (step: string, progress: number) => void; export class CLIProgress { - private spinner: Ora | null = null; - private verbose: boolean; - private quiet: boolean; - private currentStep: string = ""; - - constructor(options: { verbose?: boolean; quiet?: boolean } = {}) { - this.verbose = options.verbose ?? false; - this.quiet = options.quiet ?? false; - } - - start(text: string): void { - if (this.quiet) return; - this.spinner = ora({ text, color: "cyan" }).start(); - } - - update(step: string, progress: number): void { - if (this.quiet) return; - - const stepName = this.formatStepName(step); - const progressText = `${stepName} ${chalk.dim(`(${Math.round(progress)}%)`)}`; - - if (this.spinner) { - this.spinner.text = progressText; + private spinner: Ora | null = null; + private verbose: boolean; + private quiet: boolean; + private currentStep: string = ""; + + constructor(options: { verbose?: boolean; quiet?: boolean } = {}) { + this.verbose = options.verbose ?? false; + this.quiet = options.quiet ?? false; } - - if (this.verbose && step !== this.currentStep) { - this.currentStep = step; - console.log(chalk.dim(` → ${stepName}`)); + + start(text: string): void { + if (this.quiet) return; + this.spinner = ora({ text, color: "cyan" }).start(); } - } - - succeed(text: string): void { - if (this.quiet) return; - if (this.spinner) { - this.spinner.succeed(text); - this.spinner = null; - } else { - console.log(chalk.green("✓"), text); + + update(step: string, progress: number): void { + if (this.quiet) return; + + const stepName = this.formatStepName(step); + const progressText = `${stepName} ${chalk.dim(`(${Math.round(progress)}%)`)}`; + + if (this.spinner) { + this.spinner.text = progressText; + } + + if (this.verbose && step !== this.currentStep) { + this.currentStep = step; + console.log(chalk.dim(` - ${stepName}`)); + } } - } - - fail(text: string): void { - if (this.spinner) { - this.spinner.fail(text); - this.spinner = null; - } else { - console.error(chalk.red("✗"), text); + + succeed(text: string): void { + if (this.quiet) return; + if (this.spinner) { + this.spinner.stop(); + this.spinner = null; + } + console.log(chalk.green("done"), chalk.dim(">"), text); } - } - - warn(text: string): void { - if (this.quiet) return; - if (this.spinner) { - this.spinner.warn(text); - this.spinner = null; - } else { - console.warn(chalk.yellow("⚠"), text); + + fail(text: string): void { + if (this.spinner) { + this.spinner.stop(); + this.spinner = null; + } + console.error(chalk.red("fail"), chalk.dim(">"), text); } - } - info(text: string): void { - if (this.quiet) return; - console.log(chalk.blue("ℹ"), text); - } + warn(text: string): void { + if (this.quiet) return; + if (this.spinner) { + this.spinner.stop(); + this.spinner = null; + } + console.warn(chalk.yellow("warn"), chalk.dim(">"), text); + } + + info(text: string): void { + if (this.quiet) return; + console.log(chalk.cyan("info"), chalk.dim(">"), text); + } - stop(): void { - if (this.spinner) { - this.spinner.stop(); - this.spinner = null; + stop(): void { + if (this.spinner) { + this.spinner.stop(); + this.spinner = null; + } + } + + private formatStepName(step: string): string { + const stepMap: Record = { + PARSING_MANIFESTS: "Parsing manifest files", + PARSING_DEPENDENCIES: "Extracting dependencies", + FETCHING_TRANSITIVE_DEPENDENCIES: "Fetching transitive dependencies", + FETCHING_VULNERABILTIES_ID: "Scanning for vulnerabilities", + FETCHING_VULNERABILTIES_DETAILS: "Fetching vulnerability details", + FINALISING_RESULTS: "Finalizing results", + }; + return stepMap[step] || step; } - } - - private formatStepName(step: string): string { - const stepMap: Record = { - PARSING_MANIFESTS: "Parsing manifest files", - PARSING_DEPENDENCIES: "Extracting dependencies", - FETCHING_TRANSITIVE_DEPENDENCIES: "Fetching transitive dependencies", - FETCHING_VULNERABILTIES_ID: "Scanning for vulnerabilities", - FETCHING_VULNERABILTIES_DETAILS: "Fetching vulnerability details", - FINALISING_RESULTS: "Finalizing results", - }; - return stepMap[step] || step; - } } // Simple progress service for the analyser (compatible with backend interface) export class ProgressService { - private callback: ProgressCallback | null = null; + private callback: ProgressCallback | null = null; - onProgress(callback: ProgressCallback): void { - this.callback = callback; - } + onProgress(callback: ProgressCallback): void { + this.callback = callback; + } - progressUpdater(step: string, progress: number): void { - if (this.callback) { - this.callback(step, progress); + progressUpdater(step: string, progress: number): void { + if (this.callback) { + this.callback(step, progress); + } } - } - reset(): void { - this.callback = null; - } + reset(): void { + this.callback = null; + } } diff --git a/packages/cli/src/core/types.ts b/packages/cli/src/core/types.ts index 556d73d..6093131 100644 --- a/packages/cli/src/core/types.ts +++ b/packages/cli/src/core/types.ts @@ -1,123 +1,123 @@ export interface Dependency { - name: string; - version: string; - vulnerabilities?: Vulnerability[]; - dependencyType?: "DIRECT" | "INDIRECT" | "SELF"; - transitiveDependencies?: TransitiveDependency; - ecosystem: Ecosystem; + name: string; + version: string; + vulnerabilities?: Vulnerability[]; + dependencyType?: "DIRECT" | "INDIRECT" | "SELF"; + transitiveDependencies?: TransitiveDependency; + ecosystem: Ecosystem; } export interface TransitiveDependency { - nodes?: Dependency[]; - edges?: { - source: number; - target: number; - requirement: string; - }[]; + nodes?: Dependency[]; + edges?: { + source: number; + target: number; + requirement: string; + }[]; } export interface Vulnerability { - id: string; - summary?: string; - details?: string; - severity?: { type: string; score: string }[]; - severityScore?: { cvss_v3?: string; cvss_v4?: string }; - references?: Reference[]; - exploitAvailable?: boolean; - fixAvailable?: string; - affected?: OSVAffected[]; - aliases?: string[]; + id: string; + summary?: string; + details?: string; + severity?: { type: string; score: string }[]; + severityScore?: { cvss_v3?: string; cvss_v4?: string }; + references?: Reference[]; + exploitAvailable?: boolean; + fixAvailable?: string; + affected?: OSVAffected[]; + aliases?: string[]; } export interface Reference { - type: string; - url: string; + type: string; + url: string; } export interface OSVAffected { - package: { - ecosystem: string; - name: string; - version?: string; - }; - ranges?: { - type: string; - events: { introduced?: string; fixed?: string }[]; - }[]; - versions?: string[]; + package: { + ecosystem: string; + name: string; + version?: string; + }; + ranges?: { + type: string; + events: { introduced?: string; fixed?: string }[]; + }[]; + versions?: string[]; } export enum Ecosystem { - NPM = "npm", - PYPI = "PyPI", - MAVEN = "Maven", - GRADLE = "Gradle", - GO = "Go", - CARGO = "Cargo", - RUBYGEMS = "Rubygems", - COMPOSER = "Composer", - PUB = "Pub", - NULL = "null", + NPM = "npm", + PYPI = "PyPI", + MAVEN = "Maven", + GRADLE = "Gradle", + GO = "Go", + CARGO = "Cargo", + RUBYGEMS = "Rubygems", + COMPOSER = "Composer", + PUB = "Pub", + NULL = "null", } export type DependencyGroups = Record; export interface OSVQuery { - package: { name: string; ecosystem: Ecosystem }; - version: string; + package: { name: string; ecosystem: Ecosystem }; + version: string; } export interface OSVResult { - vulns?: Vulnerability[]; - next_page_token?: string; + vulns?: Vulnerability[]; + next_page_token?: string; } export interface OSVBatchResponse { - results: OSVResult[]; + results: OSVResult[]; } export interface ManifestFile { - path: string; - content: string; - ecosystem: Ecosystem; + path: string; + content: string; + ecosystem: Ecosystem; } export type ManifestFiles = { - [ecosystem: string]: { path: string; content: string }[]; + [ecosystem: string]: { path: string; content: string }[]; }; export interface MavenDependency { - groupId?: string[]; - artifactId?: string[]; - version?: string[]; - [key: string]: unknown; + groupId?: string[]; + artifactId?: string[]; + version?: string[]; + [key: string]: unknown; } export interface DepsDevDependency { - nodes: DepsDevNode[]; - edges: DepsDevEdge[]; - error: string; + nodes: DepsDevNode[]; + edges: DepsDevEdge[]; + error: string; } export interface DepsDevNode { - versionKey: { - system: string; - name: string; - version: string; - }; - bundled: false; - relation: "DIRECT" | "INDIRECT" | "SELF"; - errors: []; + versionKey: { + system: string; + name: string; + version: string; + }; + bundled: false; + relation: "DIRECT" | "INDIRECT" | "SELF"; + errors: []; } export interface DepsDevEdge { - fromNode: number; - toNode: number; - requirement: string; + fromNode: number; + toNode: number; + requirement: string; } export interface TransitiveDependencyResult { - dependency: Dependency; - transitiveDependencies: TransitiveDependency; - success: boolean; + dependency: Dependency; + transitiveDependencies: TransitiveDependency; + success: boolean; } diff --git a/packages/cli/src/utils/formatters.ts b/packages/cli/src/utils/formatters.ts index 8257335..f2e4320 100644 --- a/packages/cli/src/utils/formatters.ts +++ b/packages/cli/src/utils/formatters.ts @@ -1,15 +1,84 @@ import chalk from "chalk"; import type { AnalysisResult } from "../core/analyser.js"; -import type { FixPlan, FixAction } from "../core/fix-planner.js"; -import type { Dependency, DependencyGroups, Vulnerability } from "../core/types.js"; +import type { FixPlan } from "../core/fix-planner.js"; -const SEVERITY_COLORS = { - critical: chalk.bgRed.white.bold, - high: chalk.red.bold, - medium: chalk.yellow, - low: chalk.green, - unknown: chalk.gray, -}; +// Design tokens +const WIDTH = 72; + +// Vulnerability URL helpers +function getVulnerabilityUrl(id: string): string { + if (id.startsWith("GHSA-")) { + return `https://github.com/advisories/${id}`; + } + if (/^(cve|CVE)-[0-9]{4}-[0-9]{4,}$/.test(id)) { + return `https://nvd.nist.gov/vuln/detail/${id}`; + } + return `https://nvd.nist.gov/vuln/detail/${id}`; +} + +// Terminal hyperlink (OSC 8 escape sequence) +function terminalLink(text: string, url: string): string { + return `\x1b]8;;${url}\x07${text}\x1b]8;;\x07`; +} + +// Create a clickable vulnerability ID +function vulnLink(id: string): string { + const url = getVulnerabilityUrl(id); + return terminalLink(id, url); +} +const INDENT = " "; +const DOUBLE_INDENT = " "; + +// Refined severity styling - minimal, clean badges +const SEVERITY_STYLE = { + critical: (text: string) => chalk.bgRed.white.bold(` ${text} `), + high: (text: string) => chalk.red.bold(text), + medium: (text: string) => chalk.yellow(text), + low: (text: string) => chalk.dim(text), + unknown: (text: string) => chalk.gray(text), +} as const; + +// Clean horizontal rules +function rule(style: "heavy" | "light" | "double" = "light"): string { + const chars = { heavy: "━", light: "─", double: "═" }; + return chalk.dim(chars[style].repeat(WIDTH)); +} + +// Box drawing for sections +function boxTop(): string { + return chalk.dim("┌" + "─".repeat(WIDTH - 2) + "┐"); +} + +function boxBottom(): string { + return chalk.dim("└" + "─".repeat(WIDTH - 2) + "┘"); +} + +function boxRow(content: string): string { + const stripped = content.replace(/\x1b\[[0-9;]*m/g, ""); + const padding = Math.max(0, WIDTH - 4 - stripped.length); + return chalk.dim("│") + " " + content + " ".repeat(padding) + " " + chalk.dim("│"); +} + +// Stat row with aligned values +function statRow(label: string, value: string | number, color?: (s: string) => string): string { + const labelText = chalk.dim(label); + const valueText = color ? color(String(value)) : String(value); + return `${INDENT}${labelText.padEnd(28)}${valueText}`; +} + +// Severity badge - clean, consistent width +function severityBadge(severity: string): string { + const labels: Record = { + critical: "CRIT", + high: "HIGH", + medium: "MED ", + low: "LOW ", + unknown: " -- ", + }; + const label = labels[severity] || labels.unknown; + const styleFn = SEVERITY_STYLE[severity as keyof typeof SEVERITY_STYLE] || SEVERITY_STYLE.unknown; + return styleFn(label); +} function getSeverityFromScore(score?: { cvss_v3?: string; cvss_v4?: string }): string { const cvss = parseFloat(score?.cvss_v3 || score?.cvss_v4 || "0"); @@ -20,93 +89,106 @@ function getSeverityFromScore(score?: { cvss_v3?: string; cvss_v4?: string }): s return "unknown"; } -function colorSeverity(severity: string): string { - const fn = SEVERITY_COLORS[severity as keyof typeof SEVERITY_COLORS] || chalk.gray; - return fn(` ${severity.toUpperCase()} `); +function truncate(str: string, length: number): string { + if (str.length <= length) return str; + return `${str.slice(0, length - 3)}...`; +} + +function sectionHeader(title: string): string { + return `\n${chalk.bold.white(title)}\n${rule("light")}`; } export function formatAnalysisTable(result: AnalysisResult): string { const lines: string[] = []; - + // Header lines.push(""); - lines.push(chalk.bold.cyan("═══════════════════════════════════════════════════════════════")); - lines.push(chalk.bold.cyan(" VULNERABILITY REPORT ")); - lines.push(chalk.bold.cyan("═══════════════════════════════════════════════════════════════")); + lines.push(boxTop()); + lines.push(boxRow(chalk.bold.white("GITDEPSEC") + chalk.dim(" Vulnerability Report"))); + lines.push(boxBottom()); lines.push(""); - // Summary - lines.push(chalk.bold("Summary:")); - lines.push(` Total Dependencies: ${chalk.cyan(result.totalDependencies)}`); - lines.push(` Vulnerabilities: ${chalk.red.bold(result.totalVulnerabilities)}`); - lines.push(` ${SEVERITY_COLORS.critical(" CRITICAL ")} ${result.criticalCount}`); - lines.push(` ${SEVERITY_COLORS.high(" HIGH ")} ${result.highCount}`); - lines.push(` ${SEVERITY_COLORS.medium(" MEDIUM ")} ${result.mediumCount}`); - lines.push(` ${SEVERITY_COLORS.low(" LOW ")} ${result.lowCount}`); + // Summary stats in a clean grid + lines.push(statRow("Scanned", result.totalDependencies, chalk.white)); + lines.push(statRow("Vulnerabilities", result.totalVulnerabilities, result.totalVulnerabilities > 0 ? chalk.red.bold : chalk.green)); lines.push(""); + lines.push(statRow("Critical", result.criticalCount, result.criticalCount > 0 ? chalk.bgRed.white.bold : chalk.dim)); + lines.push(statRow("High", result.highCount, result.highCount > 0 ? chalk.red.bold : chalk.dim)); + lines.push(statRow("Medium", result.mediumCount, result.mediumCount > 0 ? chalk.yellow : chalk.dim)); + lines.push(statRow("Low", result.lowCount, result.lowCount > 0 ? chalk.dim : chalk.dim)); if (result.totalVulnerabilities === 0) { - lines.push(chalk.green.bold("✓ No vulnerabilities found!")); + lines.push(""); + lines.push(rule("double")); + lines.push(chalk.green.bold(`${INDENT}No vulnerabilities detected`)); + lines.push(rule("double")); + + if (result.errors && result.errors.length > 0) { + lines.push(sectionHeader("Warnings")); + result.errors.forEach((err) => lines.push(`${INDENT}${chalk.yellow(">")} ${err}`)); + } lines.push(""); return lines.join("\n"); } - // Vulnerabilities by file - lines.push(chalk.bold("Vulnerabilities by File:")); - lines.push(chalk.dim("─".repeat(65))); + // Findings section + lines.push(sectionHeader("Findings")); Object.entries(result.dependencies).forEach(([filePath, deps]) => { - lines.push(""); - lines.push(chalk.bold.underline(filePath)); - + // File path as subtle subheader + const shortPath = filePath.split("/").slice(-3).join("/"); + lines.push(`\n${INDENT}${chalk.dim.underline(shortPath)}`); + deps.forEach((dep) => { if (dep.vulnerabilities && dep.vulnerabilities.length > 0) { - lines.push(""); - lines.push(` ${chalk.bold(dep.name)}@${chalk.dim(dep.version)} ${chalk.dim(`(${dep.ecosystem})`)}`); + // Package name with version + lines.push(`\n${INDENT}${chalk.white.bold(dep.name)} ${chalk.dim("@" + dep.version)} ${chalk.dim.italic(dep.ecosystem)}`); dep.vulnerabilities.forEach((vuln) => { const severity = getSeverityFromScore(vuln.severityScore); - const score = vuln.severityScore?.cvss_v3 || vuln.severityScore?.cvss_v4 || "N/A"; - lines.push(` ${colorSeverity(severity)} ${chalk.cyan(vuln.id)} (CVSS: ${score})`); + const score = vuln.severityScore?.cvss_v3 || vuln.severityScore?.cvss_v4 || "-"; + + // Vulnerability line with aligned columns and clickable link + const linkedId = chalk.cyan(vulnLink(vuln.id.padEnd(22))); + lines.push(`${DOUBLE_INDENT}${severityBadge(severity)} ${linkedId} ${chalk.dim("CVSS")} ${chalk.white(score)}`); + if (vuln.summary) { - lines.push(` ${chalk.dim(truncate(vuln.summary, 80))}`); + lines.push(`${DOUBLE_INDENT}${chalk.dim(truncate(vuln.summary, 62))}`); } if (vuln.fixAvailable) { - lines.push(` ${chalk.green("Fix:")} Upgrade to ${chalk.green.bold(vuln.fixAvailable)}`); + lines.push(`${DOUBLE_INDENT}${chalk.dim("Fix:")} ${chalk.green(vuln.fixAvailable)}`); } }); } // Transitive vulnerabilities - const vulnTransitives = dep.transitiveDependencies?.nodes?.filter( - (n) => n.vulnerabilities && n.vulnerabilities.length > 0 - ) ?? []; - - if (vulnTransitives.length > 0) { - lines.push(` ${chalk.dim("Transitive dependencies:")}`); - vulnTransitives.forEach((node) => { - lines.push(` ${chalk.yellow("↳")} ${node.name}@${node.version}`); + const vulnerableTransitives = + dep.transitiveDependencies?.nodes?.filter( + (n) => n.vulnerabilities && n.vulnerabilities.length > 0, + ) ?? []; + + if (vulnerableTransitives.length > 0) { + lines.push(`${DOUBLE_INDENT}${chalk.dim.italic("via transitive:")}`); + vulnerableTransitives.forEach((node) => { + lines.push(`${DOUBLE_INDENT} ${chalk.dim(node.name + "@" + node.version)}`); node.vulnerabilities?.forEach((vuln) => { const severity = getSeverityFromScore(vuln.severityScore); - lines.push(` ${colorSeverity(severity)} ${chalk.cyan(vuln.id)}`); + lines.push(`${DOUBLE_INDENT} ${severityBadge(severity)} ${chalk.dim(vulnLink(vuln.id))}`); }); }); } }); }); - lines.push(""); - lines.push(chalk.dim("─".repeat(65))); - lines.push(""); - + // Errors/warnings if (result.errors && result.errors.length > 0) { - lines.push(chalk.yellow.bold("Warnings:")); - result.errors.forEach((err) => { - lines.push(` ${chalk.yellow("⚠")} ${err}`); - }); - lines.push(""); + lines.push(sectionHeader("Warnings")); + result.errors.forEach((err) => lines.push(`${INDENT}${chalk.yellow(">")} ${err}`)); } + lines.push(""); + lines.push(rule("light")); + lines.push(""); return lines.join("\n"); } @@ -116,12 +198,11 @@ export function formatAnalysisJson(result: AnalysisResult): string { export function formatAnalysisMarkdown(result: AnalysisResult): string { const lines: string[] = []; - + lines.push("# Vulnerability Report\n"); - lines.push("## Summary\n"); - lines.push(`| Metric | Count |`); - lines.push(`|--------|-------|`); + lines.push("| Metric | Count |"); + lines.push("|--------|-------|"); lines.push(`| Total Dependencies | ${result.totalDependencies} |`); lines.push(`| Total Vulnerabilities | ${result.totalVulnerabilities} |`); lines.push(`| Critical | ${result.criticalCount} |`); @@ -131,7 +212,7 @@ export function formatAnalysisMarkdown(result: AnalysisResult): string { lines.push(""); if (result.totalVulnerabilities === 0) { - lines.push("**✓ No vulnerabilities found!**\n"); + lines.push("No vulnerabilities found.\n"); return lines.join("\n"); } @@ -139,18 +220,19 @@ export function formatAnalysisMarkdown(result: AnalysisResult): string { Object.entries(result.dependencies).forEach(([filePath, deps]) => { lines.push(`### ${filePath}\n`); - + deps.forEach((dep) => { if (dep.vulnerabilities && dep.vulnerabilities.length > 0) { lines.push(`#### ${dep.name}@${dep.version} (${dep.ecosystem})\n`); lines.push("| ID | Severity | CVSS | Summary |"); lines.push("|---|---|---|---|"); - + dep.vulnerabilities.forEach((vuln) => { const severity = getSeverityFromScore(vuln.severityScore); const score = vuln.severityScore?.cvss_v3 || vuln.severityScore?.cvss_v4 || "N/A"; const summary = vuln.summary?.replace(/\|/g, "\\|") || "No summary"; - lines.push(`| ${vuln.id} | ${severity.toUpperCase()} | ${score} | ${truncate(summary, 50)} |`); + const vulnUrl = getVulnerabilityUrl(vuln.id); + lines.push(`| [${vuln.id}](${vulnUrl}) | ${severity.toUpperCase()} | ${score} | ${truncate(summary, 70)} |`); }); lines.push(""); } @@ -163,57 +245,50 @@ export function formatAnalysisMarkdown(result: AnalysisResult): string { export function formatFixPlanTable(plan: FixPlan): string { const lines: string[] = []; + // Header lines.push(""); - lines.push(chalk.bold.cyan("═══════════════════════════════════════════════════════════════")); - lines.push(chalk.bold.cyan(" FIX PLAN ")); - lines.push(chalk.bold.cyan("═══════════════════════════════════════════════════════════════")); + lines.push(boxTop()); + lines.push(boxRow(chalk.bold.white("GITDEPSEC") + chalk.dim(" Fix Plan"))); + lines.push(boxBottom()); lines.push(""); - // Summary - lines.push(chalk.bold("Summary:")); - lines.push(` Total Vulnerabilities: ${chalk.red.bold(plan.summary.totalVulnerabilities)}`); - lines.push(` Fixable: ${chalk.green.bold(plan.summary.fixableCount)}`); - lines.push(` Critical: ${SEVERITY_COLORS.critical(" " + plan.summary.criticalCount + " ")}`); - lines.push(` High: ${SEVERITY_COLORS.high(" " + plan.summary.highCount + " ")}`); - lines.push(` Estimated Time: ${chalk.cyan(plan.summary.estimatedTime)}`); - lines.push(""); + // Summary stats + lines.push(statRow("Vulnerabilities", plan.summary.totalVulnerabilities, chalk.red.bold)); + lines.push(statRow("Fixable", plan.summary.fixableCount, chalk.green.bold)); + lines.push(statRow("Critical", plan.summary.criticalCount, plan.summary.criticalCount > 0 ? chalk.bgRed.white.bold : chalk.dim)); + lines.push(statRow("High", plan.summary.highCount, plan.summary.highCount > 0 ? chalk.red.bold : chalk.dim)); + lines.push(statRow("Estimated time", plan.summary.estimatedTime, chalk.cyan)); - // Quick Wins + // Quick wins section if (plan.quickWins.length > 0) { - lines.push(chalk.bold.green("⚡ Quick Wins:")); - lines.push(chalk.dim(" These fixes address critical/high vulnerabilities with simple upgrades")); - lines.push(""); - + lines.push(sectionHeader("Quick Wins")); plan.quickWins.forEach((action, i) => { - lines.push(` ${chalk.bold(`${i + 1}.`)} ${chalk.bold(action.dependency)} ${chalk.dim(action.currentVersion)} → ${chalk.green.bold(action.recommendedVersion)}`); + lines.push(`${INDENT}${chalk.dim(`${i + 1}.`)} ${chalk.white.bold(action.dependency)}`); + lines.push(`${DOUBLE_INDENT}${chalk.dim(action.currentVersion)} ${chalk.dim("->")} ${chalk.green.bold(action.recommendedVersion)}`); if (action.command) { - lines.push(` ${chalk.cyan("$")} ${chalk.dim(action.command)}`); + lines.push(`${DOUBLE_INDENT}${chalk.dim("$")} ${chalk.cyan(action.command)}`); } }); - lines.push(""); } // Phases plan.phases.forEach((phase) => { - const phaseColor = phase.name === "Immediate" ? chalk.red.bold : - phase.name === "Urgent" ? chalk.yellow.bold : - phase.name === "Standard" ? chalk.blue.bold : chalk.gray.bold; - - lines.push(phaseColor(`📋 Phase: ${phase.name}`)); - lines.push(chalk.dim(` Urgency: ${phase.urgency}`)); + lines.push(sectionHeader(phase.name)); + lines.push(`${INDENT}${chalk.dim.italic("Window: " + phase.urgency)}`); lines.push(""); phase.actions.forEach((action) => { - const sevColor = SEVERITY_COLORS[action.severity]; - lines.push(` ${sevColor(" " + action.severity.toUpperCase() + " ")} ${chalk.bold(action.dependency)}@${action.currentVersion}`); - lines.push(` ${chalk.dim(action.reasoning)}`); + lines.push(`${INDENT}${severityBadge(action.severity)} ${chalk.white.bold(action.dependency)}${chalk.dim("@" + action.currentVersion)}`); + lines.push(`${DOUBLE_INDENT}${chalk.dim(action.reasoning)}`); if (action.command) { - lines.push(` ${chalk.cyan("$")} ${action.command}`); + lines.push(`${DOUBLE_INDENT}${chalk.dim("$")} ${chalk.cyan(action.command)}`); } lines.push(""); }); }); + lines.push(rule("light")); + lines.push(""); return lines.join("\n"); } @@ -225,7 +300,6 @@ export function formatFixPlanMarkdown(plan: FixPlan): string { const lines: string[] = []; lines.push("# Fix Plan\n"); - lines.push("## Summary\n"); lines.push(`- **Total Vulnerabilities:** ${plan.summary.totalVulnerabilities}`); lines.push(`- **Fixable:** ${plan.summary.fixableCount}`); @@ -235,9 +309,9 @@ export function formatFixPlanMarkdown(plan: FixPlan): string { lines.push(""); if (plan.quickWins.length > 0) { - lines.push("## ⚡ Quick Wins\n"); + lines.push("## Quick Wins\n"); plan.quickWins.forEach((action, i) => { - lines.push(`${i + 1}. **${action.dependency}** ${action.currentVersion} → ${action.recommendedVersion}`); + lines.push(`${i + 1}. **${action.dependency}** ${action.currentVersion} -> ${action.recommendedVersion}`); if (action.command) { lines.push(` \`\`\`bash\n ${action.command}\n \`\`\``); } @@ -247,7 +321,6 @@ export function formatFixPlanMarkdown(plan: FixPlan): string { plan.phases.forEach((phase) => { lines.push(`## ${phase.name} (${phase.urgency})\n`); - phase.actions.forEach((action) => { lines.push(`### ${action.dependency}@${action.currentVersion}\n`); lines.push(`- **Severity:** ${action.severity.toUpperCase()}`); @@ -262,8 +335,3 @@ export function formatFixPlanMarkdown(plan: FixPlan): string { return lines.join("\n"); } - -function truncate(str: string, length: number): string { - if (str.length <= length) return str; - return str.slice(0, length - 3) + "..."; -} diff --git a/packages/cli/test-api.ts b/packages/cli/test-api.ts index e47c5f4..8ec137d 100644 --- a/packages/cli/test-api.ts +++ b/packages/cli/test-api.ts @@ -8,32 +8,32 @@ import path from "path"; const testFile = path.resolve(import.meta.dir, "../../package.json"); async function main() { - console.log("Testing GitDepSec programmatic API...\n"); - console.log(`Testing with: ${testFile}\n`); + console.log("Testing GitDepSec programmatic API...\n"); + console.log(`Testing with: ${testFile}\n`); - // Test 1: Quick scan - console.log("1. Quick Scan:"); - const scanResult = await quickScan(testFile); - console.log(` Has vulnerabilities: ${scanResult.hasVulnerabilities}`); - console.log(` Risk level: ${scanResult.riskLevel}`); - console.log(` Summary: ${scanResult.summary}`); - console.log(); + // Test 1: Quick scan + console.log("1. Quick Scan:"); + const scanResult = await quickScan(testFile); + console.log(` Has vulnerabilities: ${scanResult.hasVulnerabilities}`); + console.log(` Risk level: ${scanResult.riskLevel}`); + console.log(` Summary: ${scanResult.summary}`); + console.log(); - // Test 2: Get tools - console.log("2. Available Tools:"); - const tools = getTools(); - tools.forEach((t) => console.log(` - ${t.name}: ${t.description.slice(0, 60)}...`)); - console.log(); + // Test 2: Get tools + console.log("2. Available Tools:"); + const tools = getTools(); + tools.forEach((t) => console.log(` - ${t.name}: ${t.description.slice(0, 60)}...`)); + console.log(); - // Test 3: Execute tool - console.log("3. Execute Tool:"); - const toolResult = await executeTool("scan_vulnerabilities", { - filePath: testFile, - }); - console.log(` Result: ${JSON.stringify(toolResult).slice(0, 100)}...`); - console.log(); + // Test 3: Execute tool + console.log("3. Execute Tool:"); + const toolResult = await executeTool("scan_vulnerabilities", { + filePath: testFile, + }); + console.log(` Result: ${JSON.stringify(toolResult).slice(0, 100)}...`); + console.log(); - console.log("✅ All tests passed!"); + console.log("✅ All tests passed!"); } main().catch(console.error);