Skip to content
Closed
18 changes: 18 additions & 0 deletions src/cli/command-tree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -229,6 +234,19 @@ analyze
analyzeSqaa(options, auth, cmd),
);

const dependencyRisksFormatOption = new Option('--format <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>', 'Project key')
.addOption(dependencyRisksFormatOption)
.authenticatedAction((auth, options: AnalyzeDependencyRisksOptions) =>
analyzeDependencyRisks(options, auth),
);

COMMAND_TREE.command('verify')
.description('Analyze a file for issues')
.requiredOption('--file <file>', 'File path to analyze')
Expand Down
81 changes: 81 additions & 0 deletions src/cli/commands/_common/install/sca-scanner.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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<string>;
}

export class DefaultScaScannerInstaller implements ScaScannerInstallerLike {
install(): Promise<string> {
return installScaScannerBinary();
}
}

// TODO(SCA wiring): remove this temporary hardcoded path and switch back to

Check warning on line 66 in src/cli/commands/_common/install/sca-scanner.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Complete the task associated to this "TODO" comment.

See more on https://sonarcloud.io/project/issues?id=SonarSource_sonarqube-cli&issues=AZ3ZwPT_ex1L5p0OjfsJ&open=AZ3ZwPT_ex1L5p0OjfsJ&pullRequest=231
// `DefaultScaScannerInstaller` once the binary is published.
const TEMP_SCA_SCANNER_PATH =
'C:\\Users\\georgii.borovinskikh\\Desktop\\tmp\\SCA\\sca-scanner-windows-x86-64.exe';

Check warning on line 69 in src/cli/commands/_common/install/sca-scanner.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

`String.raw` should be used to avoid escaping `\`.

See more on https://sonarcloud.io/project/issues?id=SonarSource_sonarqube-cli&issues=AZ3ZwPT_ex1L5p0OjfsK&open=AZ3ZwPT_ex1L5p0OjfsK&pullRequest=231

export class TempScaScannerInstaller implements ScaScannerInstallerLike {
install(): Promise<string> {
return Promise.resolve(TEMP_SCA_SCANNER_PATH);
}
}

export class MockScaScannerInstaller implements ScaScannerInstallerLike {
install(): Promise<string> {
return Promise.resolve('any path');
}
}
137 changes: 137 additions & 0 deletions src/cli/commands/analyze/dependency-risk-helpers/dependency-risk.ts
Original file line number Diff line number Diff line change
@@ -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<DependencyRiskSeverity, number> = {
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';
}
Original file line number Diff line number Diff line change
@@ -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<DependencyRiskType, string> = {
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<T>(header: string, rows: T[], pick: (r: T) => string): number {
return Math.max(header.length, ...rows.map((r) => pick(r).length));
}
Loading
Loading