Skip to content

Commit

Permalink
SCANNPM-2 Use which/where.exe to detect SonarScanner CLI presence
Browse files Browse the repository at this point in the history
  • Loading branch information
7PH committed May 3, 2024
1 parent e9c9665 commit f0aa233
Show file tree
Hide file tree
Showing 8 changed files with 219 additions and 91 deletions.
2 changes: 2 additions & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,3 +63,5 @@ export const SCANNER_CLI_VERSION = '5.0.1.3006';
export const SCANNER_CLI_MIRROR =
'https://binaries.sonarsource.com/Distribution/sonar-scanner-cli/';
export const SCANNER_CLI_INSTALL_PATH = 'native-sonar-scanner';

export const WINDOWS_WHERE_EXE_PATH = 'C:\\Windows\\System32\\where.exe';
51 changes: 51 additions & 0 deletions src/process.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/*
* sonar-scanner-npm
* Copyright (C) 2022-2024 SonarSource SA
* 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 { exec } from 'child_process';
import util from 'util';
import { WINDOWS_WHERE_EXE_PATH } from './constants';
import { log, LogLevel } from './logging';
import { isWindows } from './platform';

const execAsync = util.promisify(exec);

/**
* Verify that a given executable is accessible from the PATH.
* We use where.exe on Windows to check for the existence of the command to avoid
* search path vulnerabilities. Otherwise, Windows would search the current directory
* for the executable.
*/
export async function locateExecutableFromPath(executable: string): Promise<string | null> {
try {
log(LogLevel.INFO, `Trying to find ${executable}`);
const child = await execAsync(
`${isWindows() ? WINDOWS_WHERE_EXE_PATH : 'which'} ${executable}`,
);
const stdout = child.stdout?.trim();
if (stdout.length) {
return stdout;
}
log(LogLevel.INFO, 'Local install of SonarScanner CLI found.');
return null;
} catch (error) {
log(LogLevel.INFO, `Local install of SonarScanner CLI (${executable}) not found`);
return null;
}
}
24 changes: 15 additions & 9 deletions src/scan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,17 +21,18 @@ import { version } from '../package.json';
import { SCANNER_CLI_DEFAULT_BIN_NAME } from './constants';
import { fetchJRE, serverSupportsJREProvisioning } from './java';
import { LogLevel, log, setLogLevel } from './logging';
import { locateExecutableFromPath } from './process';
import { getProperties } from './properties';
import { initializeAxios } from './request';
import { downloadScannerCli, runScannerCli, tryLocalSonarScannerExecutable } from './scanner-cli';
import { downloadScannerCli, runScannerCli } from './scanner-cli';
import { fetchScannerEngine, runScannerEngine } from './scanner-engine';
import { ScanOptions, ScannerProperty, CliArgs } from './types';
import { CliArgs, ScanOptions, ScannerProperty } from './types';

export async function scan(scanOptions: ScanOptions, cliArgs?: CliArgs) {
try {
await runScan(scanOptions, cliArgs);
} catch (error: any) {
log(LogLevel.ERROR, `An error occurred: ${error?.message ?? error}`);
} catch (error) {
log(LogLevel.ERROR, `An error occurred: ${error}`);
}
}

Expand Down Expand Up @@ -67,13 +68,14 @@ async function runScan(scanOptions: ScanOptions, cliArgs?: CliArgs) {
log(LogLevel.INFO, `JRE Provisioning ${supportsJREProvisioning ? 'is' : 'is NOT'} supported`);

if (!supportsJREProvisioning) {
log(LogLevel.INFO, 'Will download and use sonar-scanner-cli');
log(LogLevel.INFO, 'Falling back on using sonar-scanner-cli');
if (scanOptions.localScannerCli) {
log(LogLevel.INFO, 'Local scanner is requested, will not download sonar-scanner-cli');
if (!(await tryLocalSonarScannerExecutable(SCANNER_CLI_DEFAULT_BIN_NAME))) {
throw new Error('Local scanner is requested but not found');
const scannerPath = await locateExecutableFromPath(SCANNER_CLI_DEFAULT_BIN_NAME);
if (!scannerPath) {
throw new Error('SonarScanner CLI not found in PATH');
}
await runScannerCli(scanOptions, properties, SCANNER_CLI_DEFAULT_BIN_NAME);
await runScannerCli(scanOptions, properties, scannerPath);
} else {
const binPath = await downloadScannerCli(properties);
await runScannerCli(scanOptions, properties, binPath);
Expand All @@ -86,7 +88,11 @@ async function runScan(scanOptions: ScanOptions, cliArgs?: CliArgs) {
if (properties[ScannerProperty.SonarScannerJavaExePath]) {
javaPath = properties[ScannerProperty.SonarScannerJavaExePath];
} else if (properties[ScannerProperty.SonarScannerSkipJreProvisioning] === 'true') {
javaPath = 'java';
const absoluteJavaPath = await locateExecutableFromPath('java');
if (!absoluteJavaPath) {
throw new Error('Java not found in PATH');
}
javaPath = absoluteJavaPath;
} else {
javaPath = await fetchJRE(properties);
}
Expand Down
36 changes: 6 additions & 30 deletions src/scanner-cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,10 @@ import path from 'path';
import { SCANNER_CLI_INSTALL_PATH, SCANNER_CLI_MIRROR, SCANNER_CLI_VERSION } from './constants';
import { extractArchive } from './file';
import { LogLevel, log } from './logging';
import { isLinux, isMac, isWindows } from './platform';
import { proxyUrlToJavaOptions } from './proxy';
import { download } from './request';
import { ScanOptions, ScannerProperties, ScannerProperty } from './types';
import { isMac, isWindows, isLinux } from './platform';
import { AxiosRequestConfig } from 'axios';

export function normalizePlatformName(): 'windows' | 'linux' | 'macosx' {
Expand All @@ -42,31 +42,6 @@ export function normalizePlatformName(): 'windows' | 'linux' | 'macosx' {
throw Error(`Your platform '${process.platform}' is currently not supported.`);
}

/**
* Verifies if the provided (or default) command is executable
*/
export async function tryLocalSonarScannerExecutable(command: string): Promise<boolean> {
return new Promise<boolean>(resolve => {
log(LogLevel.INFO, `Trying to find a local install of the SonarScanner: ${command}`);

if (!fsExtra.existsSync(command)) {
resolve(false);
return;
}
const scannerProcess = spawn(command, ['-v'], { shell: isWindows() });

scannerProcess.on('exit', code => {
if (code === 0) {
log(LogLevel.INFO, 'Local install of SonarScanner CLI found.');
resolve(true);
} else {
log(LogLevel.INFO, `Local install of SonarScanner CLI (${command}) not found`);
resolve(false);
}
});
});
}

/**
* Where to download the SonarScanner CLI
*/
Expand All @@ -86,24 +61,22 @@ export async function downloadScannerCli(properties: ScannerProperties): Promise
throw new Error(`Version "${version}" does not have a correct format."`);
}

const scannerCliUrl = getScannerCliUrl(properties, version);

// Build paths
const binExt = normalizePlatformName() === 'windows' ? '.bat' : '';
const dirName = `sonar-scanner-${version}-${normalizePlatformName()}`;
const installDir = path.join(properties[ScannerProperty.SonarUserHome], SCANNER_CLI_INSTALL_PATH);
const archivePath = path.join(installDir, `${dirName}.zip`);
const binPath = path.join(installDir, dirName, 'bin', `sonar-scanner${binExt}`);

// Try and execute an already downloaded scanner, which should be at the same location
if (await tryLocalSonarScannerExecutable(binPath)) {
if (await fsExtra.exists(binPath)) {
return binPath;
}

// Create parent directory if needed
await fsExtra.ensureDir(installDir);

// Add basic auth credentials when used in the UR
const scannerCliUrl = getScannerCliUrl(properties, version);
let overrides: AxiosRequestConfig | undefined;
if (scannerCliUrl.username && scannerCliUrl.password) {
overrides = {
Expand All @@ -126,6 +99,9 @@ export async function downloadScannerCli(properties: ScannerProperties): Promise
return binPath;
}

/**
* @param binPath Absolute path to the scanner CLI executable
*/
export async function runScannerCli(
scanOptions: ScanOptions,
properties: ScannerProperties,
Expand Down
25 changes: 23 additions & 2 deletions test/unit/mocks/ChildProcessMock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import { spawn, ChildProcess } from 'child_process';
import { ChildProcess, exec, spawn } from 'child_process';

export class ChildProcessMock {
private exitCode: number = 0;
Expand All @@ -27,8 +27,11 @@ export class ChildProcessMock {

private mock: Partial<ChildProcess> | null = null;

private commandHistory: string[] = [];

constructor() {
jest.mocked(spawn).mockImplementation((this.handleSpawn as any).bind(this));
jest.mocked(exec).mockImplementation((this.handleExec as any).bind(this));
}

setExitCode(exitCode: number) {
Expand All @@ -44,7 +47,12 @@ export class ChildProcessMock {
this.mock = mock;
}

handleSpawn() {
getCommandHistory() {
return this.commandHistory;
}

handleSpawn(command: string) {
this.commandHistory.push(command);
return {
on: jest.fn().mockImplementation((event, callback) => {
if (event === 'exit') {
Expand All @@ -58,11 +66,24 @@ export class ChildProcessMock {
};
}

handleExec(
command: string,
callback: (error?: Error, { stdout, stderr }?: { stdout: string; stderr: string }) => void,
) {
this.commandHistory.push(command);
const error = this.exitCode === 0 ? undefined : new Error('Command failed by mock');
callback(error, {
stdout: this.stdout,
stderr: this.stderr,
});
}

reset() {
this.exitCode = 0;
this.stdout = '';
this.stderr = '';
this.mock = null;
this.commandHistory = [];
jest.clearAllMocks();
}
}
73 changes: 73 additions & 0 deletions test/unit/process.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/*
* sonar-scanner-npm
* Copyright (C) 2022-2024 SonarSource SA
* 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 sinon from 'sinon';
import { SCANNER_CLI_DEFAULT_BIN_NAME, WINDOWS_WHERE_EXE_PATH } from '../../src/constants';
import { locateExecutableFromPath } from '../../src/process';
import { ChildProcessMock } from './mocks/ChildProcessMock';

jest.mock('fs-extra');
jest.mock('child_process');
jest.mock('../../src/request');
jest.mock('../../src/file');
jest.mock('../../src/logging');

const childProcessHandler = new ChildProcessMock();

beforeEach(() => {
childProcessHandler.reset();
});

describe('process', () => {
describe('locateExecutableFromPath', () => {
it('should use windows where.exe when on windows', async () => {
// mock windows with stub
const stub = sinon.stub(process, 'platform').value('win32');

childProcessHandler.setOutput('/bin/path/to/stuff\n', '');

expect(await locateExecutableFromPath(SCANNER_CLI_DEFAULT_BIN_NAME)).toBe(
'/bin/path/to/stuff',
);
expect(childProcessHandler.getCommandHistory()).toContain(
`${WINDOWS_WHERE_EXE_PATH} ${SCANNER_CLI_DEFAULT_BIN_NAME}`,
);

stub.restore();
});

it('should detect locally installed command', async () => {
childProcessHandler.setOutput('some output\n', '');

expect(await locateExecutableFromPath(SCANNER_CLI_DEFAULT_BIN_NAME)).toBe('some output');
});

it('should not detect locally installed command (when exit code is 1)', async () => {
childProcessHandler.setExitCode(1);

expect(await locateExecutableFromPath(SCANNER_CLI_DEFAULT_BIN_NAME)).toBe(null);
});

it('should not detect locally installed command (when empty stdout)', async () => {
childProcessHandler.setOutput('', '');

expect(await locateExecutableFromPath(SCANNER_CLI_DEFAULT_BIN_NAME)).toBe(null);
});
});
});
Loading

0 comments on commit f0aa233

Please sign in to comment.