diff --git a/src/cli/command-tree.ts b/src/cli/command-tree.ts index 46c52b1a..0f8c0888 100644 --- a/src/cli/command-tree.ts +++ b/src/cli/command-tree.ts @@ -26,6 +26,11 @@ import { MAX_PAGE_SIZE } from '../sonarqube/projects'; import { flushTelemetry, storeEvent, TELEMETRY_FLUSH_MODE_ENV } from '../telemetry'; import { parseInteger } from './commands/_common/parsing'; import { SonarCommand } from './commands/_common/sonar-command.js'; +import { + analyzeDependencyRisks, + type AnalyzeDependencyRisksOptions, + VALID_FORMATS as DEPENDENCY_RISKS_FORMATS, +} from './commands/analyze/dependency-risks'; import { analyzeSecrets, type AnalyzeSecretsOptions } from './commands/analyze/secrets'; import { analyzeSqaa, type AnalyzeSqaaOptions } from './commands/analyze/sqaa'; import { apiCommand, type ApiCommandOptions, apiExtraHelpText } from './commands/api/api'; @@ -229,6 +234,19 @@ analyze analyzeSqaa(options, auth, cmd), ); +const dependencyRisksFormatOption = new Option('--format ', 'Output format') + .choices(DEPENDENCY_RISKS_FORMATS) + .default('table'); + +analyze + .command('dependency-risks') + .description('Analyze project dependencies for security and license risks') + .requiredOption('-p, --project ', 'Project key') + .addOption(dependencyRisksFormatOption) + .authenticatedAction((auth, options: AnalyzeDependencyRisksOptions) => + analyzeDependencyRisks(options, auth), + ); + COMMAND_TREE.command('verify') .description('Analyze a file for issues') .requiredOption('--file ', 'File path to analyze') diff --git a/src/cli/commands/_common/install/sca-scanner.ts b/src/cli/commands/_common/install/sca-scanner.ts new file mode 100644 index 00000000..dfcae117 --- /dev/null +++ b/src/cli/commands/_common/install/sca-scanner.ts @@ -0,0 +1,81 @@ +/* + * SonarQube CLI + * Copyright (C) SonarSource Sàrl + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ + +// sca-scanner install: thin wrapper over the generic binary install pipeline. +// Version + signatures are placeholders until the binary is published on +// binaries.sonarsource.com — `installScaScannerBinary()` will throw until then, +// and `analyze dependency-risks` falls back to a mocked empty result. + +import { SONAR_SCA_SCANNER_DIST_PREFIX } from '../../../../lib/config-constants'; +import { SCA_SCANNER_BINARY_NAME } from '../../../../lib/install-types'; +import { + SONAR_SCA_SCANNER_SIGNATURES, + SONAR_SCA_SCANNER_VERSION, + SONARSOURCE_PUBLIC_KEY, +} from '../../../../lib/signatures'; +import { success } from '../../../../ui'; +import { type BinarySpec, installBinary, resolveBinaryPath } from './binary'; + +const SCA_SCANNER_SPEC: BinarySpec = { + name: SCA_SCANNER_BINARY_NAME, + version: SONAR_SCA_SCANNER_VERSION, + distPrefix: SONAR_SCA_SCANNER_DIST_PREFIX, + signatures: SONAR_SCA_SCANNER_SIGNATURES, + publicKey: SONARSOURCE_PUBLIC_KEY, +}; + +export async function installScaScannerBinary(): Promise { + const { binaryPath, freshlyInstalled } = await installBinary(SCA_SCANNER_SPEC); + if (freshlyInstalled) { + success(`sca-scanner installed at ${binaryPath}`); + } + return binaryPath; +} + +export function resolveScaScannerBinaryPath(): string | null { + return resolveBinaryPath(SCA_SCANNER_SPEC); +} + +export interface ScaScannerInstallerLike { + install(): Promise; +} + +export class DefaultScaScannerInstaller implements ScaScannerInstallerLike { + install(): Promise { + return installScaScannerBinary(); + } +} + +// TODO(SCA wiring): remove this temporary hardcoded path and switch back to +// `DefaultScaScannerInstaller` once the binary is published. +const TEMP_SCA_SCANNER_PATH = + 'C:\\Users\\georgii.borovinskikh\\Desktop\\tmp\\SCA\\sca-scanner-windows-x86-64.exe'; + +export class TempScaScannerInstaller implements ScaScannerInstallerLike { + install(): Promise { + return Promise.resolve(TEMP_SCA_SCANNER_PATH); + } +} + +export class MockScaScannerInstaller implements ScaScannerInstallerLike { + install(): Promise { + return Promise.resolve('any path'); + } +} diff --git a/src/cli/commands/analyze/dependency-risk-helpers/dependency-risk.ts b/src/cli/commands/analyze/dependency-risk-helpers/dependency-risk.ts new file mode 100644 index 00000000..00f084b1 --- /dev/null +++ b/src/cli/commands/analyze/dependency-risk-helpers/dependency-risk.ts @@ -0,0 +1,137 @@ +/* + * SonarQube CLI + * Copyright (C) SonarSource Sàrl + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ + +import type { + ScaPackage, + ScaPackageInfoResponse, + ScaVersionFix, + ScaVulnerability, +} from './sca-scanner.ts'; + +export type DependencyRiskType = 'VULNERABILITY' | 'PROHIBITED_LICENSE' | 'MALWARE'; +export type DependencyRiskSeverity = 'INFO' | 'LOW' | 'MEDIUM' | 'HIGH' | 'BLOCKER'; +export type SoftwareQuality = 'MAINTAINABILITY' | 'RELIABILITY' | 'SECURITY'; +export type DependencyRiskStatus = 'OPEN' | 'CONFIRM' | 'ACCEPT' | 'SAFE' | 'FIXED'; + +export interface DependencyRisk { + packageName: string; + type: DependencyRiskType; + severity: DependencyRiskSeverity; + quality: SoftwareQuality; + status: DependencyRiskStatus; + dependencyFilePaths: string[]; + dependencyChains: string[][]; + licenseExpression?: string; + vulnerabilityId?: string; + cvssScore?: number; + cweIds?: string[]; + publishedOn?: string; + fixedVersions?: ScaVersionFix[] | null; + unaffectedVersions?: string[] | null; +} + +const SEVERITIES: readonly DependencyRiskSeverity[] = ['INFO', 'LOW', 'MEDIUM', 'HIGH', 'BLOCKER']; + +const SEVERITY_RANK: Record = { + BLOCKER: 0, + HIGH: 1, + MEDIUM: 2, + LOW: 3, + INFO: 4, +}; + +export function sortDependencyRisks(risks: DependencyRisk[]): DependencyRisk[] { + return [...risks].sort((a, b) => { + const pkg = a.packageName.localeCompare(b.packageName); + if (pkg !== 0) return pkg; + const sev = SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity]; + if (sev !== 0) return sev; + return a.type.localeCompare(b.type); + }); +} + +export function toDependencyRisks(response: ScaPackageInfoResponse): DependencyRisk[] { + const risks: DependencyRisk[] = []; + for (const pkg of response.packages) { + if (pkg.malicious) { + risks.push(malwareRisk(pkg)); + } + if (pkg.license?.allowed === false) { + risks.push(prohibitedLicenseRisk(pkg)); + } + for (const vuln of pkg.vulnerabilities ?? []) { + if (vuln.withdrawn) { + continue; + } + risks.push(vulnerabilityRisk(pkg, vuln)); + } + } + return risks; +} + +function malwareRisk(pkg: ScaPackage): DependencyRisk { + return { + packageName: pkg.purl, + type: 'MALWARE', + severity: 'BLOCKER', + quality: 'SECURITY', + status: 'OPEN', + dependencyFilePaths: pkg.dependencyFilePaths, + dependencyChains: pkg.dependencyChains, + }; +} + +function prohibitedLicenseRisk(pkg: ScaPackage): DependencyRisk { + return { + packageName: pkg.purl, + type: 'PROHIBITED_LICENSE', + severity: 'HIGH', + quality: 'MAINTAINABILITY', + status: 'OPEN', + dependencyFilePaths: pkg.dependencyFilePaths, + dependencyChains: pkg.dependencyChains, + licenseExpression: pkg.license?.expression, + }; +} + +function vulnerabilityRisk(pkg: ScaPackage, vuln: ScaVulnerability): DependencyRisk { + return { + packageName: pkg.purl, + type: 'VULNERABILITY', + severity: normalizeSeverity(vuln.riskSeverity), + quality: 'SECURITY', + status: 'OPEN', + dependencyFilePaths: pkg.dependencyFilePaths, + dependencyChains: pkg.dependencyChains, + vulnerabilityId: vuln.id, + cvssScore: vuln.cvssScore, + cweIds: vuln.cweIds, + publishedOn: vuln.publishedOn, + fixedVersions: vuln.fixedVersions, + unaffectedVersions: vuln.unaffectedVersions, + }; +} + +function normalizeSeverity(raw: string): DependencyRiskSeverity { + const upper = raw.toUpperCase(); + return (SEVERITIES as readonly string[]).includes(upper) + ? (upper as DependencyRiskSeverity) + : 'INFO'; +} diff --git a/src/cli/commands/analyze/dependency-risk-helpers/format-dependency-risks-table.ts b/src/cli/commands/analyze/dependency-risk-helpers/format-dependency-risks-table.ts new file mode 100644 index 00000000..3cc990fd --- /dev/null +++ b/src/cli/commands/analyze/dependency-risk-helpers/format-dependency-risks-table.ts @@ -0,0 +1,122 @@ +/* + * SonarQube CLI + * Copyright (C) SonarSource Sàrl + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ + +import type { DependencyRisk, DependencyRiskType } from './dependency-risk.ts'; + +const STATUS_DISCLAIMER = + 'Note: SonarQube Server Status (Accepted, False Positive) is not currently displayed.'; + +const TYPE_LABELS: Record = { + VULNERABILITY: 'Vulnerability', + PROHIBITED_LICENSE: 'License', + MALWARE: 'Malware', +}; + +export function formatDependencyRisksTable( + risks: DependencyRisk[], + packagesScanned: number, +): string { + const lines: string[] = [ + STATUS_DISCLAIMER, + '', + `Scan Summary: ${packagesScanned} dependencies checked. ${risks.length} risks found`, + ]; + + if (risks.length === 0) { + return lines.join('\n'); + } + + lines.push(''); + + const rows = risks.map((risk) => ({ + severity: risk.severity, + type: TYPE_LABELS[risk.type], + pkg: risk.packageName, + manifest: risk.dependencyFilePaths.join(', '), + issue: issueCell(risk), + remediation: remediationCell(risk), + })); + + const widths = { + severity: columnWidth('SEVERITY', rows, (r) => r.severity), + type: columnWidth('TYPE', rows, (r) => r.type), + pkg: columnWidth('PACKAGE', rows, (r) => r.pkg), + manifest: columnWidth('MANIFEST', rows, (r) => r.manifest), + issue: columnWidth('ISSUE', rows, (r) => r.issue), + }; + + const header = [ + 'SEVERITY'.padEnd(widths.severity), + 'TYPE'.padEnd(widths.type), + 'PACKAGE'.padEnd(widths.pkg), + 'MANIFEST'.padEnd(widths.manifest), + 'ISSUE'.padEnd(widths.issue), + 'REMEDIATION', + ].join(' '); + + const separator = '-'.repeat(header.length); + + lines.push(header, separator); + for (const row of rows) { + lines.push( + [ + row.severity.padEnd(widths.severity), + row.type.padEnd(widths.type), + row.pkg.padEnd(widths.pkg), + row.manifest.padEnd(widths.manifest), + row.issue.padEnd(widths.issue), + row.remediation, + ] + .join(' ') + .trimEnd(), + ); + } + lines.push(separator); + + return lines.join('\n'); +} + +function issueCell(risk: DependencyRisk): string { + switch (risk.type) { + case 'VULNERABILITY': + return risk.vulnerabilityId ?? ''; + case 'PROHIBITED_LICENSE': + return risk.licenseExpression ?? ''; + case 'MALWARE': + return 'Malicious package'; + } +} + +function remediationCell(risk: DependencyRisk): string { + switch (risk.type) { + case 'VULNERABILITY': + return risk.fixedVersions && risk.fixedVersions.length > 0 + ? `Upgrade to ${risk.fixedVersions.map((f) => f.version).join(', ')}` + : ''; + case 'PROHIBITED_LICENSE': + return ''; + case 'MALWARE': + return 'Remove dependency'; + } +} + +function columnWidth(header: string, rows: T[], pick: (r: T) => string): number { + return Math.max(header.length, ...rows.map((r) => pick(r).length)); +} diff --git a/src/cli/commands/analyze/dependency-risk-helpers/sca-scanner-spawner.ts b/src/cli/commands/analyze/dependency-risk-helpers/sca-scanner-spawner.ts new file mode 100644 index 00000000..d5bbff4c --- /dev/null +++ b/src/cli/commands/analyze/dependency-risk-helpers/sca-scanner-spawner.ts @@ -0,0 +1,177 @@ +/* + * SonarQube CLI + * Copyright (C) SonarSource Sàrl + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ + +import { spawnProcessWithTimeout, type SpawnResult } from '../../../../lib/process.ts'; + +export interface ScaScannerSpawnerLike { + spawn(binaryPath: string, args: string[]): Promise; +} + +export class DefaultScaScannerSpawner implements ScaScannerSpawnerLike { + spawn(binaryPath: string, args: string[]): Promise { + return spawnProcessWithTimeout( + binaryPath, + args, + { stdout: 'pipe', stderr: 'pipe' }, + 120000, + 'Sca timed out', + ); + } +} + +export class MockScaScannerSpawner implements ScaScannerSpawnerLike { + spawn(): Promise { + return Promise.resolve({ + exitCode: 0, + stdout: JSON.stringify({ + packages: [ + { + purl: 'pkg:npm/lodash@4.17.20', + dependencyFilePaths: ['package-lock.json'], + dependencyChains: [['pkg:npm/react-scripts@5.0.1', 'pkg:npm/lodash@4.17.20']], + license: { expression: 'MIT', allowed: true }, + vulnerabilities: [ + { + id: 'CVE-2021-23337', + cvssScore: 7.2, + cweIds: ['CWE-78'], + riskSeverity: 'HIGH', + withdrawn: false, + publishedOn: '2021-02-15T11:15:00Z', + fixedVersions: [ + { version: '4.17.21', fixLevel: 'safe', descriptionCode: 'upgrade_version' }, + ], + unaffectedVersions: null, + }, + ], + malicious: false, + knownPackage: true, + knownRelease: true, + }, + { + purl: 'pkg:pypi/requests@2.31.0', + dependencyFilePaths: ['requirements.txt'], + dependencyChains: [['pkg:pypi/requests@2.31.0']], + license: { expression: 'Apache-2.0', allowed: true }, + vulnerabilities: [], + malicious: false, + knownPackage: true, + knownRelease: true, + }, + { + purl: 'pkg:npm/evil-package@0.0.1', + dependencyFilePaths: ['package-lock.json'], + dependencyChains: [['pkg:npm/evil-package@0.0.1']], + license: { expression: 'NOASSERTION', allowed: null }, + vulnerabilities: [], + malicious: true, + knownPackage: true, + knownRelease: true, + }, + { + purl: 'pkg:maven/org.gnu/gpl-lib@3.0.0', + dependencyFilePaths: ['pom.xml'], + dependencyChains: [['pkg:maven/org.gnu/gpl-lib@3.0.0']], + license: { expression: 'GPL-3.0', allowed: false }, + vulnerabilities: [], + malicious: false, + knownPackage: true, + knownRelease: true, + }, + { + purl: 'pkg:npm/express@4.17.1', + dependencyFilePaths: ['package-lock.json'], + dependencyChains: [ + ['pkg:npm/express@4.17.1'], + ['pkg:npm/some-server@1.0.0', 'pkg:npm/express@4.17.1'], + ], + license: { expression: 'MIT', allowed: true }, + vulnerabilities: [ + { + id: 'CVE-2022-24999', + cvssScore: 7.5, + cweIds: ['CWE-1321'], + riskSeverity: 'HIGH', + withdrawn: false, + publishedOn: '2022-11-26T22:15:00Z', + fixedVersions: [ + { version: '4.17.3', fixLevel: 'safe', descriptionCode: 'upgrade_version' }, + ], + unaffectedVersions: null, + }, + { + id: 'CVE-2024-29041', + cvssScore: 6.1, + cweIds: ['CWE-601'], + riskSeverity: 'MEDIUM', + withdrawn: false, + publishedOn: '2024-03-25T19:15:00Z', + fixedVersions: [ + { version: '4.19.2', fixLevel: 'safe', descriptionCode: 'upgrade_version' }, + ], + unaffectedVersions: null, + }, + { + id: 'CVE-2024-WITHDRAWN', + cvssScore: 0.0, + cweIds: [], + riskSeverity: 'LOW', + withdrawn: true, + publishedOn: '2024-01-01T00:00:00Z', + fixedVersions: null, + unaffectedVersions: null, + }, + ], + malicious: false, + knownPackage: true, + knownRelease: true, + }, + { + purl: 'pkg:gem/rails@5.2.0', + dependencyFilePaths: ['Gemfile.lock'], + dependencyChains: [['pkg:gem/rails@5.2.0']], + license: { expression: 'MIT', allowed: true }, + vulnerabilities: [ + { + id: 'CVE-2020-8163', + cvssScore: 9.8, + cweIds: ['CWE-94'], + riskSeverity: 'BLOCKER', + withdrawn: false, + publishedOn: '2020-07-02T19:15:00Z', + fixedVersions: [ + { version: '5.2.4.3', fixLevel: 'safe', descriptionCode: 'upgrade_version' }, + { version: '6.0.3.1', fixLevel: 'safe', descriptionCode: 'upgrade_version' }, + ], + unaffectedVersions: null, + }, + ], + malicious: false, + knownPackage: true, + knownRelease: true, + }, + ], + parsedFiles: ['package-lock.json', 'requirements.txt', 'pom.xml', 'Gemfile.lock'], + errors: [], + }), + stderr: '', + }); + } +} diff --git a/src/cli/commands/analyze/dependency-risk-helpers/sca-scanner.ts b/src/cli/commands/analyze/dependency-risk-helpers/sca-scanner.ts new file mode 100644 index 00000000..97da5c8c --- /dev/null +++ b/src/cli/commands/analyze/dependency-risk-helpers/sca-scanner.ts @@ -0,0 +1,199 @@ +/* + * SonarQube CLI + * Copyright (C) SonarSource Sàrl + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ + +// Scaffold for the sca-scanner invocation. The binary is not yet shipped, +// so production wiring uses `NoopScaScannerRunner`, which returns an empty +// result without attempting an install. The full spawn → parse → error path +// in `ScaScannerRunner` (modeled after `analyze secrets`) stays covered by +// unit tests so it's ready to swap in when the binary lands. + +import logger from '../../../../lib/logger.ts'; +import type { SpawnResult } from '../../../../lib/process.ts'; +import { CommandFailedError } from '../../_common/error.ts'; +import { type ScaScannerInstallerLike } from '../../_common/install/sca-scanner.ts'; +import { type ScaScannerSpawnerLike } from './sca-scanner-spawner.ts'; + +const REDACTED_TOKEN = '***'; + +export interface ScaScannerInvocation { + baseDir: string; + apiBaseUrl: string; + downloadBaseUrl: string; + sonarToken: string; + projectKey: string; + cacheDir: string; + workDir: string; + scannerProperties: Record; + excludedPaths: string[]; + includeGitIgnoredPaths: boolean; + debug: boolean; +} + +// Response shape from sca-scanner. Mirrors `AnalyzeProjectResponse` in +// sonar-sca (SCA-1852) so the same parser can consume both wrapper output and +// persisted-analysis responses (SCA-1761 wrapper-compatibility constraint). +export interface ScaPackageInfoResponse { + packages: ScaPackage[]; + parsedFiles: string[]; + errors: ScaAnalysisError[]; +} + +export interface ScaPackage { + purl: string; + dependencyFilePaths: string[]; + dependencyChains: string[][]; + license: ScaLicense | null; + vulnerabilities: ScaVulnerability[] | null; + malicious: boolean; + knownPackage: boolean; + knownRelease: boolean; +} + +export interface ScaLicense { + expression: string; + allowed: boolean | null; +} + +export interface ScaVulnerability { + id: string; + cvssScore: number; + cweIds: string[]; + riskSeverity: string; + withdrawn: boolean; + publishedOn: string; + fixedVersions: ScaVersionFix[] | null; + unaffectedVersions: string[] | null; +} + +export interface ScaVersionFix { + version: string; + fixLevel: string; + descriptionCode: string; +} + +export type ScaAnalysisErrorCode = + | 'UNKNOWN' + | 'NO_DEPENDENCIES_FOUND' + | 'DEPENDENCY_FILES_PARSE_ERROR' + | 'UNSUPPORTED_PLATFORM' + | 'INEXACT_VERSIONS' + | 'MISSING_LOCKFILE'; + +export interface ScaAnalysisError { + id: string; + code: ScaAnalysisErrorCode; + path: string | null; + message: string; +} + +export class ScaScannerRunner { + constructor( + private readonly installer: ScaScannerInstallerLike, + private readonly spawner: ScaScannerSpawnerLike, + ) {} + + async run(invocation: ScaScannerInvocation): Promise { + const args = this.buildArgs(invocation); + logger.debug(`sca-scanner args: ${JSON.stringify(this.redactedArgs(args))}`); + + const binaryPath = await this.installer.install(); + + let result: SpawnResult; + try { + result = await this.spawner.spawn(binaryPath, args); + } catch (err) { + throw new CommandFailedError(`Dependency collection error: ${(err as Error).message}`); + } + + logger.info(result.stdout); + logger.info(result.stderr); + return reportScanResult(result); + } + + buildArgs(invocation: ScaScannerInvocation): string[] { + const args: string[] = [ + 'analyze-project', + `--base-dir=${invocation.baseDir}`, + `--api-base-url=${invocation.apiBaseUrl}`, + `--download-base-url=${invocation.downloadBaseUrl}`, + `--sonar-token=${invocation.sonarToken}`, + `--project-key=${invocation.projectKey}`, + `--cache-dir=${invocation.cacheDir}`, + `--work-dir=${invocation.workDir}`, + ]; + for (const [name, value] of Object.entries(invocation.scannerProperties)) { + args.push(`--scanner-property=${name}=${value}`); + } + for (const path of invocation.excludedPaths) { + args.push(`--excluded-path=${path}`); + } + if (invocation.includeGitIgnoredPaths) { + args.push('--include-gitignored-paths'); + } + if (invocation.debug) { + args.push('--debug'); + } + return args; + } + + private redactedArgs(args: string[]): string[] { + return args.map((arg) => + arg.startsWith('--sonar-token=') ? `--sonar-token=${REDACTED_TOKEN}` : arg, + ); + } +} + +function reportScanResult(result: SpawnResult): ScaPackageInfoResponse { + const exitCode = result.exitCode ?? 1; + if (exitCode === 0) { + return handleScanSuccess(result); + } + return handleScanFailure(result, exitCode); +} + +function handleScanSuccess(result: SpawnResult): ScaPackageInfoResponse { + let parsed: unknown; + try { + parsed = JSON.parse(result.stdout); + } catch (err) { + throw new CommandFailedError( + `Dependency collection error: failed to parse output (${(err as Error).message})`, + ); + } + if (!parsed || typeof parsed !== 'object') { + throw new CommandFailedError( + `Dependency collection error: sca-scanner output is missing 'packages' array`, + ); + } + for (const field of ['packages', 'parsedFiles', 'errors'] as const) { + if (!Array.isArray((parsed as Record)[field])) { + throw new CommandFailedError( + `Dependency collection error: sca-scanner output is missing '${field}' array`, + ); + } + } + return parsed as ScaPackageInfoResponse; +} + +function handleScanFailure(result: SpawnResult, exitCode: number): never { + throw new CommandFailedError( + `Dependency collection error: sca-scanner exited with code ${exitCode}\n${result.stderr}`, + ); +} diff --git a/src/cli/commands/analyze/dependency-risks.ts b/src/cli/commands/analyze/dependency-risks.ts new file mode 100644 index 00000000..98208843 --- /dev/null +++ b/src/cli/commands/analyze/dependency-risks.ts @@ -0,0 +1,109 @@ +/* + * SonarQube CLI + * Copyright (C) SonarSource Sàrl + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ + +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { type ResolvedAuth, resolveFromEndpoint } from '../../../lib/auth-resolver'; +import { CLI_DIR } from '../../../lib/config-constants'; +import { getLogLevelConfig } from '../../../lib/logger'; +import { SonarQubeClient } from '../../../sonarqube/client'; +import { print } from '../../../ui'; +import { CommandFailedError, InvalidOptionError } from '../_common/error.js'; +import { MockScaScannerInstaller } from '../_common/install/sca-scanner.ts'; +import { + sortDependencyRisks, + toDependencyRisks, +} from './dependency-risk-helpers/dependency-risk.ts'; +import { formatDependencyRisksTable } from './dependency-risk-helpers/format-dependency-risks-table.ts'; +import { + type ScaScannerInvocation, + ScaScannerRunner, +} from './dependency-risk-helpers/sca-scanner.ts'; +import { MockScaScannerSpawner } from './dependency-risk-helpers/sca-scanner-spawner.ts'; + +export const VALID_FORMATS = ['json', 'table']; + +export interface AnalyzeDependencyRisksOptions { + project?: string; + format?: string; +} + +export async function analyzeDependencyRisks( + options: AnalyzeDependencyRisksOptions, + auth: ResolvedAuth, +): Promise { + if (!options.project) { + throw new InvalidOptionError('--project is required'); + } + + const format = (options.format ?? 'table').toLowerCase(); + if (!VALID_FORMATS.includes(format)) { + throw new InvalidOptionError( + `Invalid format: '${options.format}'. Must be one of: ${VALID_FORMATS.join(', ')}`, + ); + } + + const client = new SonarQubeClient(auth.serverUrl, auth.token); + const enabled = await client.checkScaEnabled(auth.connectionType, auth.orgKey); + if (!enabled) { + throw new CommandFailedError('Advanced Security not available'); + } + + const componentExists = await client.checkComponent(options.project); + if (!componentExists) { + throw new CommandFailedError(`No project: ${options.project}`); + } + + const properties = await client.getProjectSettings(options.project); + + const invocation: ScaScannerInvocation = { + baseDir: process.cwd(), + // TODO(SCA wiring): --api-base-url is the executable-metadata host for + // tidelift-cli, not the SonarQube /api endpoint. Replace once the backend + // exposes the correct URL. + apiBaseUrl: resolveFromEndpoint(auth.serverUrl, '/sca') + '/sca', // todo + // TODO(SCA wiring): source --download-base-url from server config. + downloadBaseUrl: + auth.connectionType === 'cloud' ? 'https://scanner.sonarcloud.io/tidelift-cli' : '', // todo + sonarToken: auth.token, + projectKey: options.project, + cacheDir: join(CLI_DIR, 'cache', 'sca-scanner'), + workDir: join(tmpdir(), `sonar-sca-${Date.now()}`), + scannerProperties: properties.scaProperties, + excludedPaths: properties.exclusions, + includeGitIgnoredPaths: properties.includeGitIgnoredPaths, + debug: getLogLevelConfig() === 'DEBUG', + }; + + const result = await new ScaScannerRunner( + // new TempScaScannerInstaller(), + // new DefaultScaScannerSpawner(), + new MockScaScannerInstaller(), + new MockScaScannerSpawner(), + ).run(invocation); + + const risks = sortDependencyRisks(toDependencyRisks(result)); + if (format === 'json') { + print(JSON.stringify({ project: options.project, risks }, null, 2)); + } else { + print(formatDependencyRisksTable(risks, result.packages.length)); + } +} diff --git a/src/lib/config-constants.ts b/src/lib/config-constants.ts index d0952251..c3bf6fcc 100644 --- a/src/lib/config-constants.ts +++ b/src/lib/config-constants.ts @@ -71,6 +71,7 @@ export const GLOBAL_HOOKS_DIR = join(CLI_DIR, 'hooks'); export const SONARSOURCE_BINARIES_URL = process.env.SONARQUBE_CLI_BINARIES_URL ?? 'https://binaries.sonarsource.com'; export const SONAR_SECRETS_DIST_PREFIX = 'CommercialDistribution/sonar-secrets'; +export const SONAR_SCA_SCANNER_DIST_PREFIX = 'CommercialDistribution/sca-scanner'; export const UPDATE_SCRIPT_BASE_URL = 'https://raw.githubusercontent.com/SonarSource/sonarqube-cli/refs/heads/master/user-scripts'; diff --git a/src/lib/install-types.ts b/src/lib/install-types.ts index a91c2191..6e9def73 100644 --- a/src/lib/install-types.ts +++ b/src/lib/install-types.ts @@ -27,6 +27,7 @@ export interface PlatformInfo { } export const SECRETS_BINARY_NAME = 'sonar-secrets'; +export const SCA_SCANNER_BINARY_NAME = 'sca-scanner'; export function buildPlatformSuffix(p: PlatformInfo): string { return `-${p.os}-${p.arch}${p.extension}`; diff --git a/src/lib/signatures.ts b/src/lib/signatures.ts index 13480434..769dc2bc 100644 --- a/src/lib/signatures.ts +++ b/src/lib/signatures.ts @@ -200,3 +200,8 @@ hMQKN37n3c0HoxQTBrEk =q/jJ -----END PGP SIGNATURE-----`, }; + +// Placeholders until sca-scanner is published. Once the binary appears, +// `bun run fetch:signatures` will populate these alongside the secrets entries. +export const SONAR_SCA_SCANNER_VERSION = '0.0.0'; +export const SONAR_SCA_SCANNER_SIGNATURES: Record = {}; diff --git a/src/sonarqube/analysis-properties.ts b/src/sonarqube/analysis-properties.ts new file mode 100644 index 00000000..169d621e --- /dev/null +++ b/src/sonarqube/analysis-properties.ts @@ -0,0 +1,79 @@ +/* + * SonarQube CLI + * Copyright (C) SonarSource Sàrl + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ + +// Parser for the subset of `/api/settings/values` results consumed by SCA +// analysis. CLI-352 will forward these to sca-scanner as +// `--scanner-property`, `--excluded-path`, and `--include-git-ignored-paths`. + +const SCA_KEY_PREFIX = 'sonar.sca.'; +const EXCLUSION_KEYS = new Set([ + 'sonar.exclusions', + 'sonar.global.exclusions', + 'sonar.test.exclusions', +]); +const SCM_EXCLUSIONS_DISABLED_KEY = 'sonar.scm.exclusions.disabled'; + +export interface SettingsValue { + key: string; + value?: string; + values?: string[]; + fieldValues?: Array>; + inherited?: boolean; +} + +export interface ProjectAnalysisProperties { + scaProperties: Record; + exclusions: string[]; + includeGitIgnoredPaths: boolean; +} + +export function parseAnalysisProperties(settings: SettingsValue[]): ProjectAnalysisProperties { + const scaProperties: Record = {}; + const exclusions: string[] = []; + let includeGitIgnoredPaths = false; + + for (const setting of settings) { + if (EXCLUSION_KEYS.has(setting.key)) { + exclusions.push(...parseExclusions(setting)); + } else if (setting.key === SCM_EXCLUSIONS_DISABLED_KEY) { + includeGitIgnoredPaths = setting.value === 'true'; + } else if (setting.key.startsWith(SCA_KEY_PREFIX)) { + const joined = setting.values?.join(',') ?? setting.value; + if (joined !== undefined) { + scaProperties[setting.key] = joined; + } + } + } + + return { scaProperties, exclusions, includeGitIgnoredPaths }; +} + +function parseExclusions(setting: SettingsValue): string[] { + if (setting.values) { + return setting.values.map((v) => v.trim()).filter(Boolean); + } + if (setting.value !== undefined) { + return setting.value + .split(',') + .map((v) => v.trim()) + .filter(Boolean); + } + return []; +} diff --git a/src/sonarqube/client.ts b/src/sonarqube/client.ts index 205c74bb..ebe990fa 100644 --- a/src/sonarqube/client.ts +++ b/src/sonarqube/client.ts @@ -23,6 +23,11 @@ import { version as VERSION } from '../../package.json'; import { isSonarQubeCloud, resolveFromEndpoint } from '../lib/auth-resolver'; import { print } from '../ui'; +import { + parseAnalysisProperties, + type ProjectAnalysisProperties, + type SettingsValue, +} from './analysis-properties'; const GET_REQUEST_TIMEOUT_MS = 30000; // 30 seconds const POST_REQUEST_TIMEOUT_MS = 60000; // 60 seconds for analysis @@ -269,6 +274,29 @@ export class SonarQubeClient { } } + /** + * Check whether Sonar Advanced Security (SCA) is enabled on the connected + * server. SonarCloud exposes this at `/sca/enabled?organization=` + * (api.sonarcloud.io); SonarQube Server at `/api/v2/sca/enabled`. Any + * failure (404, network, unauthorized) is treated as "not available" so + * callers can gate analysis with a friendly error. + */ + async checkScaEnabled(connectionType: 'cloud' | 'on-premise', orgKey?: string): Promise { + try { + const isCloud = connectionType === 'cloud'; + const endpoint = isCloud ? '/sca/enabled' : '/api/v2/sca/enabled'; + const params = isCloud && orgKey ? { organization: orgKey } : undefined; + const result = await this.get<{ enabled: boolean }>( + endpoint, + params, + resolveFromEndpoint(this.serverURL, endpoint), + ); + return result.enabled; + } catch { + return false; + } + } + /** * Convenience: resolve org UUID then check SQAA entitlement in one call. */ @@ -300,6 +328,20 @@ export class SonarQubeClient { } } + /** + * Fetch project-scoped analysis properties from `/api/settings/values` and + * project the response into the subset SCA analysis cares about + * (`sonar.sca.*`, `sonar.exclusions`, `sonar.scm.exclusions.disabled`). + * The `component` query param is what scopes the values to a specific + * project; without it the API returns global defaults. + */ + async getProjectSettings(componentKey: string): Promise { + const result = await this.get<{ settings?: SettingsValue[] }>('/api/settings/values', { + component: componentKey, + }); + return parseAnalysisProperties(result.settings ?? []); + } + /** * Check if component (project) exists */ diff --git a/tests/integration/harness/fake-sonarqube-server.ts b/tests/integration/harness/fake-sonarqube-server.ts index 47e49b48..a68c8241 100644 --- a/tests/integration/harness/fake-sonarqube-server.ts +++ b/tests/integration/harness/fake-sonarqube-server.ts @@ -21,6 +21,7 @@ // Lightweight in-process mock SonarQube HTTP server (Bun.serve) import type { SonarQubeIssue } from '../../../src/lib/types.js'; +import type { SettingsValue } from '../../../src/sonarqube/analysis-properties.js'; import type { RecordedRequest } from './types.js'; export interface IssueConfig { @@ -119,6 +120,8 @@ export class FakeSonarQubeServerBuilder { private revokeTokenStatusCode = 204; private revokeTokenResponseBody = ''; private sqaaResponse?: SqaaResponseConfig; + private scaEnabled?: boolean; + private readonly projectSettings: Map = new Map(); withProject(key: string, fn?: (p: ProjectBuilder) => void): this { const builder = new ProjectBuilder(key); @@ -176,6 +179,27 @@ export class FakeSonarQubeServerBuilder { return this; } + /** + * Configure the response of the SCA availability endpoints + * (`/sca/enabled` for cloud, `/api/v2/sca/enabled` for on-premise). + * When unset (default), both endpoints return 404 to simulate a server + * without Sonar Advanced Security installed. + */ + withScaEnabled(enabled: boolean): this { + this.scaEnabled = enabled; + return this; + } + + /** + * Configure the response of `/api/settings/values?component=`. + * Settings shape matches the real API: each entry has at least a `key`, plus + * optionally `value`, `values`, `fieldValues`, and `inherited`. + */ + withProjectSettings(componentKey: string, settings: SettingsValue[]): this { + this.projectSettings.set(componentKey, settings); + return this; + } + start(): Promise { const projects = new Map([...this.projectBuilders.entries()].map(([k, v]) => [k, v.getData()])); const { @@ -189,6 +213,8 @@ export class FakeSonarQubeServerBuilder { revokeTokenResponseBody, sqaaResponse, sqaaEntitlementOrgs, + scaEnabled, + projectSettings, } = this; const memberOrganizationsTotal = rawMemberOrganizationsTotal ?? memberOrganizations.length; const requests: RecordedRequest[] = []; @@ -387,6 +413,26 @@ export class FakeSonarQubeServerBuilder { ); } + if (path === '/api/settings/values' && req.method === 'GET') { + const component = query.component; + const settings = component ? (projectSettings.get(component) ?? []) : []; + return new Response(JSON.stringify({ settings }), { + headers: { 'Content-Type': 'application/json' }, + }); + } + + if (path === '/sca/enabled' || path === '/api/v2/sca/enabled') { + if (scaEnabled === undefined) { + return new Response(JSON.stringify({ errors: [{ msg: 'Not found' }] }), { + status: 404, + headers: { 'Content-Type': 'application/json' }, + }); + } + return new Response(JSON.stringify({ enabled: scaEnabled }), { + headers: { 'Content-Type': 'application/json' }, + }); + } + const orgConfigMatch = /^\/a3s-analysis\/org-config\/(.+)$/.exec(path); if (orgConfigMatch) { const uuid = orgConfigMatch[1]; diff --git a/tests/integration/specs/analyze/analyze-dependency-risks.test.ts b/tests/integration/specs/analyze/analyze-dependency-risks.test.ts new file mode 100644 index 00000000..f55b1548 --- /dev/null +++ b/tests/integration/specs/analyze/analyze-dependency-risks.test.ts @@ -0,0 +1,178 @@ +/* + * SonarQube CLI + * Copyright (C) SonarSource Sàrl + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ + +// Integration tests for `analyze dependency-risks` (CLI-354 skeleton + CLI-355 SCA gate +// + CLI-356 analysis properties fetch). The command is still a stub for output, but +// now pre-flights `/sca/enabled`, validates the project, and fetches analysis +// properties from `/api/settings/values`. + +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; + +import { TestHarness } from '../../harness'; + +const VALID_TOKEN = 'integration-test-token'; +const TEST_ORG = 'my-org'; + +describe('analyze dependency-risks', () => { + let harness: TestHarness; + + beforeEach(async () => { + harness = await TestHarness.create(); + }); + + afterEach(async () => { + await harness.dispose(); + }); + + it('exits with code 1 when not authenticated', async () => { + const result = await harness.run('analyze dependency-risks --project demo'); + + expect(result.exitCode).toBe(1); + expect(result.stdout + result.stderr).toContain('❌ Not authenticated. Run: sonar auth login'); + }); + + it('prints stub table output by default when authenticated (cloud)', async () => { + const server = await harness + .newFakeServer() + .withAuthToken(VALID_TOKEN) + .withScaEnabled(true) + .withProject('demo') + .withProjectSettings('demo', [ + { key: 'sonar.exclusions', values: ['**/test/**', '**/dist/**'], inherited: false }, + { key: 'sonar.sca.foo', value: 'bar', inherited: false }, + { key: 'sonar.scm.exclusions.disabled', value: 'true', inherited: false }, + ]) + .start(); + harness.withAuth(server.baseUrl(), VALID_TOKEN, TEST_ORG); + + const result = await harness.run('analyze dependency-risks --project demo'); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain( + 'Note: SonarQube Server Status (Accepted, False Positive) is not currently displayed.', + ); + expect(result.stdout).toMatch(/Scan Summary: \d+ dependencies checked\. \d+ risks found/); + expect(result.stdout).toMatch(/SEVERITY\s+TYPE\s+PACKAGE\s+MANIFEST\s+ISSUE\s+REMEDIATION/); + expect(result.stdout).toMatch(/package-lock\.json/); + expect(result.stdout).toMatch(/Vulnerability.*CVE-\d{4}-\d+/); + expect(result.stdout).toMatch(/License.*GPL-3\.0/); + expect(result.stdout).toMatch(/Malware.*Malicious package.*Remove dependency/); + + const recorded = server.getRecordedRequests(); + const scaCalls = recorded.filter((r) => r.path === '/sca/enabled'); + expect(scaCalls).toHaveLength(1); + expect(scaCalls[0].query.organization).toBe(TEST_ORG); + + const componentShowIndex = recorded.findIndex((r) => r.path === '/api/components/show'); + const settingsIndex = recorded.findIndex((r) => r.path === '/api/settings/values'); + expect(componentShowIndex).toBeGreaterThanOrEqual(0); + expect(settingsIndex).toBeGreaterThan(componentShowIndex); + expect(recorded[settingsIndex].query.component).toBe('demo'); + }); + + it('prints stub JSON output when --format json is passed (on-premise)', async () => { + const server = await harness + .newFakeServer() + .withAuthToken(VALID_TOKEN) + .withScaEnabled(true) + .withProject('demo') + .withProjectSettings('demo', []) + .start(); + harness.withAuth(server.baseUrl(), VALID_TOKEN); + + const result = await harness.run('analyze dependency-risks --project demo --format json'); + + expect(result.exitCode).toBe(0); + const parsed = JSON.parse(result.stdout); + expect(parsed.project).toBe('demo'); + expect(Array.isArray(parsed.risks)).toBe(true); + expect(parsed.risks.length).toBeGreaterThan(0); + expect(parsed.risks[0]).toHaveProperty('packageName'); + expect(parsed.risks[0]).toHaveProperty('type'); + expect(parsed.risks[0]).toHaveProperty('severity'); + expect(parsed.risks[0]).toHaveProperty('quality'); + expect(parsed.risks[0]).toHaveProperty('status'); + expect(Array.isArray(parsed.risks[0].dependencyFilePaths)).toBe(true); + expect(Array.isArray(parsed.risks[0].dependencyChains)).toBe(true); + const types = parsed.risks.map((r: { type: string }) => r.type); + expect(types).toEqual([ + 'VULNERABILITY', + 'PROHIBITED_LICENSE', + 'MALWARE', + 'VULNERABILITY', + 'VULNERABILITY', + 'VULNERABILITY', + ]); + const packageNames = parsed.risks.map((r: { packageName: string }) => r.packageName); + expect(packageNames).toEqual([...packageNames].sort()); + const vulnIds = parsed.risks + .filter((r: { type: string }) => r.type === 'VULNERABILITY') + .map((r: { vulnerabilityId: string }) => r.vulnerabilityId); + expect(vulnIds).not.toContain('CVE-2024-WITHDRAWN'); + const licenseRisk = parsed.risks.find((r: { type: string }) => r.type === 'PROHIBITED_LICENSE'); + expect(licenseRisk.licenseExpression).toBe('GPL-3.0'); + expect(server.getRecordedRequests().some((r) => r.path === '/api/v2/sca/enabled')).toBe(true); + expect( + server + .getRecordedRequests() + .some((r) => r.path === '/api/settings/values' && r.query.component === 'demo'), + ).toBe(true); + }); + + it('exits with code 1 with "No project" when project does not exist', async () => { + const server = await harness + .newFakeServer() + .withAuthToken(VALID_TOKEN) + .withScaEnabled(true) + .start(); + harness.withAuth(server.baseUrl(), VALID_TOKEN, TEST_ORG); + + const result = await harness.run('analyze dependency-risks --project demo'); + + expect(result.exitCode).toBe(1); + expect(result.stdout + result.stderr).toContain('No project: demo'); + // Settings fetch must be skipped when the project pre-check fails. + expect(server.getRecordedRequests().some((r) => r.path === '/api/settings/values')).toBe(false); + }); + + it('exits with code 1 when SCA is disabled on the server', async () => { + const server = await harness + .newFakeServer() + .withAuthToken(VALID_TOKEN) + .withScaEnabled(false) + .start(); + harness.withAuth(server.baseUrl(), VALID_TOKEN, TEST_ORG); + + const result = await harness.run('analyze dependency-risks --project demo'); + + expect(result.exitCode).toBe(1); + expect(result.stdout + result.stderr).toContain('Advanced Security not available'); + }); + + it('exits with code 1 when the SCA endpoint is absent (404)', async () => { + const server = await harness.newFakeServer().withAuthToken(VALID_TOKEN).start(); + harness.withAuth(server.baseUrl(), VALID_TOKEN); + + const result = await harness.run('analyze dependency-risks --project demo'); + + expect(result.exitCode).toBe(1); + expect(result.stdout + result.stderr).toContain('Advanced Security not available'); + }); +}); diff --git a/tests/unit/cli/commands/analyze/dependency-risk-helpers/dependency-risk.test.ts b/tests/unit/cli/commands/analyze/dependency-risk-helpers/dependency-risk.test.ts new file mode 100644 index 00000000..0c83219c --- /dev/null +++ b/tests/unit/cli/commands/analyze/dependency-risk-helpers/dependency-risk.test.ts @@ -0,0 +1,296 @@ +/* + * SonarQube CLI + * Copyright (C) SonarSource Sàrl + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ + +import { describe, expect, it } from 'bun:test'; + +import { toDependencyRisks } from '../../../../../../src/cli/commands/analyze/dependency-risk-helpers/dependency-risk.ts'; +import type { + ScaLicense, + ScaPackage, + ScaPackageInfoResponse, + ScaVulnerability, +} from '../../../../../../src/cli/commands/analyze/dependency-risk-helpers/sca-scanner.ts'; + +const DEFAULT_FILE_PATHS = ['package-lock.json']; +const DEFAULT_CHAINS = [['pkg:npm/lodash@4.17.21']]; + +function makeResponse(packages: ScaPackage[]): ScaPackageInfoResponse { + return { packages, parsedFiles: [], errors: [] }; +} + +function makePackage(overrides: Partial = {}): ScaPackage { + return { + purl: 'pkg:npm/lodash@4.17.21', + dependencyFilePaths: DEFAULT_FILE_PATHS, + dependencyChains: DEFAULT_CHAINS, + license: null, + vulnerabilities: null, + malicious: false, + knownPackage: true, + knownRelease: true, + ...overrides, + }; +} + +function makeVuln(overrides: Partial = {}): ScaVulnerability { + return { + id: 'CVE-2024-0001', + cvssScore: 7.5, + cweIds: ['CWE-79'], + riskSeverity: 'HIGH', + withdrawn: false, + publishedOn: '2024-01-01', + fixedVersions: [{ version: '1.2.3', fixLevel: 'safe', descriptionCode: 'upgrade_version' }], + unaffectedVersions: ['0.9.0'], + ...overrides, + }; +} + +function license(allowed: boolean | null, expression = 'MIT'): ScaLicense { + return { expression, allowed }; +} + +describe('toDependencyRisks', () => { + it('returns empty array when there are no packages', () => { + expect(toDependencyRisks(makeResponse([]))).toEqual([]); + }); + + it('emits one VULNERABILITY risk per vulnerability with id and cvssScore copied', () => { + const pkg = makePackage({ + purl: 'pkg:npm/foo@1.0.0', + dependencyFilePaths: ['package-lock.json'], + dependencyChains: [['pkg:npm/foo@1.0.0']], + vulnerabilities: [ + makeVuln({ + id: 'CVE-1', + cvssScore: 9.8, + riskSeverity: 'BLOCKER', + cweIds: ['CWE-78'], + publishedOn: '2024-02-15T11:15:00Z', + fixedVersions: [ + { version: '1.0.1', fixLevel: 'safe', descriptionCode: 'upgrade_version' }, + ], + unaffectedVersions: null, + }), + ], + }); + + const risks = toDependencyRisks(makeResponse([pkg])); + + expect(risks).toEqual([ + { + packageName: 'pkg:npm/foo@1.0.0', + type: 'VULNERABILITY', + severity: 'BLOCKER', + quality: 'SECURITY', + status: 'OPEN', + dependencyFilePaths: ['package-lock.json'], + dependencyChains: [['pkg:npm/foo@1.0.0']], + vulnerabilityId: 'CVE-1', + cvssScore: 9.8, + cweIds: ['CWE-78'], + publishedOn: '2024-02-15T11:15:00Z', + fixedVersions: [{ version: '1.0.1', fixLevel: 'safe', descriptionCode: 'upgrade_version' }], + unaffectedVersions: null, + }, + ]); + }); + + it('emits one risk per vulnerability when there are multiple', () => { + const pkg = makePackage({ + purl: 'pkg:npm/foo@1.0.0', + vulnerabilities: [ + makeVuln({ id: 'CVE-1', riskSeverity: 'LOW' }), + makeVuln({ id: 'CVE-2', riskSeverity: 'HIGH' }), + makeVuln({ id: 'CVE-3', riskSeverity: 'MEDIUM' }), + ], + }); + + const risks = toDependencyRisks(makeResponse([pkg])); + + expect(risks).toHaveLength(3); + expect(risks.map((r) => r.vulnerabilityId)).toEqual(['CVE-1', 'CVE-2', 'CVE-3']); + expect(risks.map((r) => r.severity)).toEqual(['LOW', 'HIGH', 'MEDIUM']); + for (const risk of risks) { + expect(risk.packageName).toBe('pkg:npm/foo@1.0.0'); + expect(risk.type).toBe('VULNERABILITY'); + } + }); + + it('does not crash when vulnerabilities is null', () => { + const pkg = makePackage({ vulnerabilities: null }); + expect(toDependencyRisks(makeResponse([pkg]))).toEqual([]); + }); + + it('filters out withdrawn vulnerabilities', () => { + const pkg = makePackage({ + vulnerabilities: [makeVuln({ id: 'CVE-WITHDRAWN', withdrawn: true })], + }); + + expect(toDependencyRisks(makeResponse([pkg]))).toEqual([]); + }); + + it('emits only the non-withdrawn vulnerabilities when mixed', () => { + const pkg = makePackage({ + vulnerabilities: [ + makeVuln({ id: 'CVE-LIVE-1', withdrawn: false }), + makeVuln({ id: 'CVE-DEAD', withdrawn: true }), + makeVuln({ id: 'CVE-LIVE-2', withdrawn: false }), + ], + }); + + const risks = toDependencyRisks(makeResponse([pkg])); + + expect(risks.map((r) => r.vulnerabilityId)).toEqual(['CVE-LIVE-1', 'CVE-LIVE-2']); + }); + + it('emits a MALWARE risk with severity BLOCKER for malicious packages', () => { + const pkg = makePackage({ + purl: 'pkg:npm/evil@0.0.1', + malicious: true, + dependencyFilePaths: ['package-lock.json'], + dependencyChains: [['pkg:npm/evil@0.0.1']], + }); + + const risks = toDependencyRisks(makeResponse([pkg])); + + expect(risks).toEqual([ + { + packageName: 'pkg:npm/evil@0.0.1', + type: 'MALWARE', + severity: 'BLOCKER', + quality: 'SECURITY', + status: 'OPEN', + dependencyFilePaths: ['package-lock.json'], + dependencyChains: [['pkg:npm/evil@0.0.1']], + }, + ]); + }); + + it('emits a PROHIBITED_LICENSE risk carrying licenseExpression', () => { + const pkg = makePackage({ + purl: 'pkg:npm/gpl-thing@2.0.0', + license: license(false, 'GPL-3.0'), + dependencyFilePaths: ['package-lock.json'], + dependencyChains: [['pkg:npm/gpl-thing@2.0.0']], + }); + + const risks = toDependencyRisks(makeResponse([pkg])); + + expect(risks).toEqual([ + { + packageName: 'pkg:npm/gpl-thing@2.0.0', + type: 'PROHIBITED_LICENSE', + severity: 'HIGH', + quality: 'MAINTAINABILITY', + status: 'OPEN', + dependencyFilePaths: ['package-lock.json'], + dependencyChains: [['pkg:npm/gpl-thing@2.0.0']], + licenseExpression: 'GPL-3.0', + }, + ]); + }); + + it('does not emit a license risk when license is null, allowed=true, or allowed=null', () => { + const packages = [ + makePackage({ license: null }), + makePackage({ license: license(true) }), + makePackage({ license: license(null) }), + ]; + + expect(toDependencyRisks(makeResponse(packages))).toEqual([]); + }); + + it('emits malware, license, and vulnerability risks in that order for a package with all three', () => { + const pkg = makePackage({ + purl: 'pkg:npm/triple-trouble@1.2.3', + malicious: true, + license: license(false, 'AGPL-3.0'), + vulnerabilities: [ + makeVuln({ id: 'CVE-A', riskSeverity: 'MEDIUM' }), + makeVuln({ id: 'CVE-B', riskSeverity: 'LOW' }), + ], + }); + + const risks = toDependencyRisks(makeResponse([pkg])); + + expect(risks.map((r) => r.type)).toEqual([ + 'MALWARE', + 'PROHIBITED_LICENSE', + 'VULNERABILITY', + 'VULNERABILITY', + ]); + expect(risks.map((r) => r.packageName)).toEqual([ + 'pkg:npm/triple-trouble@1.2.3', + 'pkg:npm/triple-trouble@1.2.3', + 'pkg:npm/triple-trouble@1.2.3', + 'pkg:npm/triple-trouble@1.2.3', + ]); + }); + + it('normalizes lowercase riskSeverity values to the enum', () => { + const pkg = makePackage({ + vulnerabilities: [makeVuln({ riskSeverity: 'high' })], + }); + + expect(toDependencyRisks(makeResponse([pkg]))[0].severity).toBe('HIGH'); + }); + + it('falls back to INFO for unknown riskSeverity values', () => { + const pkg = makePackage({ + vulnerabilities: [makeVuln({ riskSeverity: 'CATASTROPHIC' })], + }); + + expect(toDependencyRisks(makeResponse([pkg]))[0].severity).toBe('INFO'); + }); + + it('does not put vulnerability-only fields on malware or license risks', () => { + const pkg = makePackage({ + malicious: true, + license: license(false, 'GPL-3.0'), + }); + + const risks = toDependencyRisks(makeResponse([pkg])); + + for (const risk of risks) { + expect(risk.vulnerabilityId).toBeUndefined(); + expect(risk.cvssScore).toBeUndefined(); + expect(risk.cweIds).toBeUndefined(); + expect(risk.publishedOn).toBeUndefined(); + expect(risk.fixedVersions).toBeUndefined(); + expect(risk.unaffectedVersions).toBeUndefined(); + } + }); + + it('does not put licenseExpression on malware or vulnerability risks', () => { + const pkg = makePackage({ + malicious: true, + license: license(false, 'GPL-3.0'), + vulnerabilities: [makeVuln()], + }); + + const risks = toDependencyRisks(makeResponse([pkg])); + const malware = risks.find((r) => r.type === 'MALWARE'); + const vuln = risks.find((r) => r.type === 'VULNERABILITY'); + + expect(malware?.licenseExpression).toBeUndefined(); + expect(vuln?.licenseExpression).toBeUndefined(); + }); +}); diff --git a/tests/unit/cli/commands/analyze/sca-scanner.test.ts b/tests/unit/cli/commands/analyze/sca-scanner.test.ts new file mode 100644 index 00000000..cb9061f7 --- /dev/null +++ b/tests/unit/cli/commands/analyze/sca-scanner.test.ts @@ -0,0 +1,224 @@ +/* + * SonarQube CLI + * Copyright (C) SonarSource Sàrl + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ + +import { describe, expect, it } from 'bun:test'; + +import { ScaScannerInstallerLike } from '../../../../../src/cli/commands/_common/install/sca-scanner.ts'; +import { + ScaScannerInvocation, + ScaScannerRunner, +} from '../../../../../src/cli/commands/analyze/dependency-risk-helpers/sca-scanner.ts'; +import { ScaScannerSpawnerLike } from '../../../../../src/cli/commands/analyze/dependency-risk-helpers/sca-scanner-spawner.ts'; +import type { SpawnResult } from '../../../../../src/lib/process.ts'; + +const okInstaller: ScaScannerInstallerLike = { install: () => Promise.resolve('/bin/sca') }; +const noopSpawner: ScaScannerSpawnerLike = { + spawn: () => Promise.resolve({ exitCode: 0, stdout: '', stderr: '' }), +}; + +function spawnerReturning(result: SpawnResult): ScaScannerSpawnerLike { + return { spawn: () => Promise.resolve(result) }; +} + +function spawnerThrowing(err: Error): ScaScannerSpawnerLike { + return { spawn: () => Promise.reject(err) }; +} + +function makeInvocation(overrides: Partial = {}): ScaScannerInvocation { + return { + baseDir: '/repo', + apiBaseUrl: 'https://api.sonarcloud.io', + downloadBaseUrl: 'https://download.sonarcloud.io/tidelift-cli', + sonarToken: 'tok', + projectKey: 'my-project', + cacheDir: '/cache', + workDir: '/work', + scannerProperties: {}, + excludedPaths: [], + includeGitIgnoredPaths: false, + debug: false, + ...overrides, + }; +} + +describe('ScaScannerRunner.buildArgs', () => { + it('emits the fixed args in declared order', () => { + const args = new ScaScannerRunner(okInstaller, noopSpawner).buildArgs(makeInvocation()); + + expect(args).toEqual([ + 'analyze-project', + '--base-dir=/repo', + '--api-base-url=https://api.sonarcloud.io', + '--download-base-url=https://download.sonarcloud.io/tidelift-cli', + '--sonar-token=tok', + '--project-key=my-project', + '--cache-dir=/cache', + '--work-dir=/work', + ]); + }); + + it('repeats --scanner-property=name=value for each entry', () => { + const args = new ScaScannerRunner(okInstaller, noopSpawner).buildArgs( + makeInvocation({ + scannerProperties: { 'sonar.sca.foo': 'bar', 'sonar.sca.baz': '1,2' }, + }), + ); + + const pairs = args.filter((a) => a.startsWith('--scanner-property=')); + expect(pairs).toEqual([ + '--scanner-property=sonar.sca.foo=bar', + '--scanner-property=sonar.sca.baz=1,2', + ]); + }); + + it('repeats --excluded-path for each exclusion in input order', () => { + const args = new ScaScannerRunner(okInstaller, noopSpawner).buildArgs( + makeInvocation({ excludedPaths: ['**/test/**', '**/dist/**'] }), + ); + + const excluded = args.filter((a) => a.startsWith('--excluded-path=')); + expect(excluded).toEqual(['--excluded-path=**/test/**', '--excluded-path=**/dist/**']); + }); + + it('emits --include-gitignored-paths only when the flag is true', () => { + expect( + new ScaScannerRunner(okInstaller, noopSpawner).buildArgs( + makeInvocation({ includeGitIgnoredPaths: false }), + ), + ).not.toContain('--include-gitignored-paths'); + expect( + new ScaScannerRunner(okInstaller, noopSpawner).buildArgs( + makeInvocation({ includeGitIgnoredPaths: true }), + ), + ).toContain('--include-gitignored-paths'); + }); + + it('emits --debug only when the flag is true', () => { + expect( + new ScaScannerRunner(okInstaller, noopSpawner).buildArgs(makeInvocation({ debug: false })), + ).not.toContain('--debug'); + expect( + new ScaScannerRunner(okInstaller, noopSpawner).buildArgs(makeInvocation({ debug: true })), + ).toContain('--debug'); + }); +}); + +describe('ScaScannerRunner.run', () => { + it('propagates the installer error when install fails', () => { + const failingInstaller: ScaScannerInstallerLike = { + install: () => Promise.reject(new Error('not installed')), + }; + expect( + new ScaScannerRunner(failingInstaller, noopSpawner).run(makeInvocation()), + ).rejects.toThrow(/not installed/); + }); + + it('returns the parsed result on exit 0 with valid JSON', async () => { + const stdout = JSON.stringify({ + packages: [ + { + purl: 'pkg:npm/lodash@4.17.21', + dependencyFilePaths: ['package-lock.json'], + dependencyChains: [['pkg:npm/lodash@4.17.21']], + license: { expression: 'MIT', allowed: true }, + vulnerabilities: [], + malicious: false, + knownPackage: true, + knownRelease: true, + }, + ], + parsedFiles: ['package-lock.json'], + errors: [], + }); + const runner = new ScaScannerRunner( + okInstaller, + spawnerReturning({ exitCode: 0, stdout, stderr: '' }), + ); + const result = await runner.run(makeInvocation()); + expect(result.packages).toHaveLength(1); + expect(result.packages[0].purl).toBe('pkg:npm/lodash@4.17.21'); + expect(result.packages[0].license).toEqual({ expression: 'MIT', allowed: true }); + expect(result.packages[0].dependencyFilePaths).toEqual(['package-lock.json']); + expect(result.packages[0].dependencyChains).toEqual([['pkg:npm/lodash@4.17.21']]); + expect(result.parsedFiles).toEqual(['package-lock.json']); + expect(result.errors).toEqual([]); + }); + + it('throws CommandFailedError on exit 0 with non-JSON stdout', () => { + const runner = new ScaScannerRunner( + okInstaller, + spawnerReturning({ exitCode: 0, stdout: 'not json', stderr: '' }), + ); + expect(runner.run(makeInvocation())).rejects.toThrow(/failed to parse output/); + }); + + it("throws CommandFailedError when 'packages' field is missing", () => { + const runner = new ScaScannerRunner( + okInstaller, + spawnerReturning({ exitCode: 0, stdout: '{}', stderr: '' }), + ); + expect(runner.run(makeInvocation())).rejects.toThrow(/missing 'packages' array/); + }); + + it("throws CommandFailedError when 'packages' is not an array", () => { + const runner = new ScaScannerRunner( + okInstaller, + spawnerReturning({ exitCode: 0, stdout: '{"packages":"oops"}', stderr: '' }), + ); + expect(runner.run(makeInvocation())).rejects.toThrow(/missing 'packages' array/); + }); + + it("throws CommandFailedError when 'parsedFiles' is missing", () => { + const runner = new ScaScannerRunner( + okInstaller, + spawnerReturning({ exitCode: 0, stdout: '{"packages":[],"errors":[]}', stderr: '' }), + ); + expect(runner.run(makeInvocation())).rejects.toThrow(/missing 'parsedFiles' array/); + }); + + it("throws CommandFailedError when 'errors' is not an array", () => { + const runner = new ScaScannerRunner( + okInstaller, + spawnerReturning({ + exitCode: 0, + stdout: '{"packages":[],"parsedFiles":[],"errors":"oops"}', + stderr: '', + }), + ); + expect(runner.run(makeInvocation())).rejects.toThrow(/missing 'errors' array/); + }); + + it('throws CommandFailedError on non-zero exit including stderr text', () => { + const runner = new ScaScannerRunner( + okInstaller, + spawnerReturning({ exitCode: 2, stdout: '', stderr: 'boom' }), + ); + expect(runner.run(makeInvocation())).rejects.toThrow( + /sca-scanner exited with code 2[\s\S]*boom/, + ); + }); + + it('wraps a spawner rejection into CommandFailedError', () => { + const runner = new ScaScannerRunner(okInstaller, spawnerThrowing(new Error('spawn EACCES'))); + expect(runner.run(makeInvocation())).rejects.toThrow( + /Dependency collection error: spawn EACCES/, + ); + }); +}); diff --git a/tests/unit/sonarqube/analysis-properties.test.ts b/tests/unit/sonarqube/analysis-properties.test.ts new file mode 100644 index 00000000..8224a030 --- /dev/null +++ b/tests/unit/sonarqube/analysis-properties.test.ts @@ -0,0 +1,84 @@ +/* + * SonarQube CLI + * Copyright (C) SonarSource Sàrl + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ + +import { describe, expect, it } from 'bun:test'; + +import { parseAnalysisProperties } from '../../../src/sonarqube/analysis-properties.js'; + +describe('parseAnalysisProperties', () => { + it('extracts the three categories and ignores fieldValues / inherited / unrelated keys', () => { + const result = parseAnalysisProperties([ + { key: 'sonar.test.jira', value: 'abc', inherited: true }, + { key: 'sonar.exclusions', values: ['**/test/**', '**/dist/**'], inherited: false }, + { key: 'sonar.sca.foo', value: 'bar', inherited: true }, + { key: 'sonar.sca.list', values: ['a', 'b'], inherited: false }, + { key: 'sonar.scm.exclusions.disabled', value: 'true', inherited: false }, + { + key: 'sonar.demo', + fieldValues: [{ boolean: 'true', text: 'foo' }], + inherited: false, + }, + ]); + + expect(result).toEqual({ + scaProperties: { 'sonar.sca.foo': 'bar', 'sonar.sca.list': 'a,b' }, + exclusions: ['**/test/**', '**/dist/**'], + includeGitIgnoredPaths: true, + }); + }); + + it('returns defaults when settings is empty', () => { + expect(parseAnalysisProperties([])).toEqual({ + scaProperties: {}, + exclusions: [], + includeGitIgnoredPaths: false, + }); + }); + + it('merges sonar.exclusions, sonar.global.exclusions, and sonar.test.exclusions', () => { + const result = parseAnalysisProperties([ + { key: 'sonar.global.exclusions', values: ['**/vendor/**'] }, + { key: 'sonar.exclusions', values: ['**/dist/**'] }, + { key: 'sonar.test.exclusions', values: ['**/__tests__/**'] }, + ]); + + expect(result.exclusions).toEqual(['**/vendor/**', '**/dist/**', '**/__tests__/**']); + }); + + it('parses sonar.exclusions from either values[] or comma-joined value', () => { + const fromValues = parseAnalysisProperties([{ key: 'sonar.exclusions', values: ['a', 'b'] }]); + const fromValue = parseAnalysisProperties([{ key: 'sonar.exclusions', value: ' a , b ' }]); + + expect(fromValues.exclusions).toEqual(['a', 'b']); + expect(fromValue.exclusions).toEqual(['a', 'b']); + }); + + it('treats sonar.scm.exclusions.disabled as boolean toggle on string "true"', () => { + expect( + parseAnalysisProperties([{ key: 'sonar.scm.exclusions.disabled', value: 'true' }]) + .includeGitIgnoredPaths, + ).toBe(true); + expect( + parseAnalysisProperties([{ key: 'sonar.scm.exclusions.disabled', value: 'false' }]) + .includeGitIgnoredPaths, + ).toBe(false); + expect(parseAnalysisProperties([]).includeGitIgnoredPaths).toBe(false); + }); +});