Skip to content

AzureDevops | Add Seamless Proxy Ability (AST-67328) #28

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 13 commits into from
Nov 12, 2024
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
"postbuild": "copyfiles -u 1 src/tests/data/* dist/;",
"lint": "eslint . --ext .ts",
"lint-and-fix": "eslint . --ext .ts --fix",
"test": "copyfiles -u 1 src/tests/data/* dist/; tsc && jest"
"test": "copyfiles -u 1 src/tests/data/* dist/; tsc && jest --runInBand"
},
"repository": "https://github.com/CheckmarxDev/ast-cli-javascript-wrapper-runtime-cli.git",
"author": "Jay Nanduri",
Expand Down
29 changes: 29 additions & 0 deletions src/main/client/AstClient.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import {logger} from '../wrapper/loggerConfig';
import * as fs from 'fs';
import {finished} from 'stream/promises';
import {Client} from "./Client";

export class AstClient {
private client: Client;

constructor(client: Client) {
this.client = client;
}

public async downloadFile(url: string, outputPath: string): Promise<void> {
logger.info(`Starting download from URL: ${url}`);
const writer = fs.createWriteStream(outputPath);
try {
const response = await this.client.request(url, 'GET', null);
response.data.pipe(writer);
await finished(writer);
logger.info(`Download completed successfully. File saved to: ${outputPath}`);
} catch (error) {
logger.error(`Error downloading file from ${url}: ${error.message || error}`);
throw error;
} finally {
writer.close();
logger.info('Write stream closed.');
}
}
}
6 changes: 6 additions & 0 deletions src/main/client/Client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import {AxiosResponse} from "axios";

export interface Client {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Make sure the naming convention in TS is without "I" for interfaces. I believe it needs to be IClient ( that says that it's an interface)

getProxyConfig(): any;
request(url: string, method: string, data: any): Promise<AxiosResponse<any, any>>;
}
51 changes: 51 additions & 0 deletions src/main/client/HttpClient.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import axios, {AxiosRequestConfig, AxiosResponse} from 'axios';
import {logger} from '../wrapper/loggerConfig';
import {Client} from "./Client";

export class HttpClient implements Client {
private readonly axiosConfig: AxiosRequestConfig;

constructor() {
this.axiosConfig = {
responseType: 'stream',
proxy: this.getProxyConfig(),
};
}

public getProxyConfig() {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe better to save this value to avoid parsing the URL every time it’s called

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it's being call once every httpClient object creation

const proxyUrl = process.env.HTTP_PROXY;
if (proxyUrl) {
logger.info(`Detected proxy configuration in HTTP_PROXY`);
const parsedProxy = new URL(proxyUrl);

return {
host: parsedProxy.hostname,
port: parseInt(parsedProxy.port, 10),
protocol: parsedProxy.protocol.replace(':', ''), // remove the colon
auth: parsedProxy.username && parsedProxy.password
? {username: parsedProxy.username, password: parsedProxy.password}
: undefined,
};
}
logger.info('No proxy configuration detected.');
return undefined;
}

public async request(url: string, method: string, data: any): Promise<AxiosResponse<any, any>> {
logger.info(`Sending ${method} request to URL: ${url}`);
if (this.axiosConfig.proxy) {
logger.info(
`Using proxy - Host: ${this.axiosConfig.proxy.host}, Port: ${this.axiosConfig.proxy.port},` +
`Protocol: ${this.axiosConfig.proxy.protocol}, Auth: ${this.axiosConfig.proxy.auth ? 'Yes' : 'No'}`
);
}
try {
const response = await axios({...this.axiosConfig, url, method, data});
logger.info(`Request completed successfully.`);
return response;
} catch (error) {
logger.error(`Error sending ${method} request to ${url}: ${error.message || error}`);
throw error;
}
}
}
149 changes: 110 additions & 39 deletions src/main/osinstaller/CxInstaller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,63 +2,94 @@ import * as fsPromises from 'fs/promises';
import * as fs from 'fs';
import * as path from 'path';
import * as tar from 'tar';
import axios from 'axios';
import * as unzipper from 'unzipper';
import {logger} from "../wrapper/loggerConfig";
import {finished} from 'stream/promises';
import {AstClient} from "../client/AstClient";

const linuxOS = 'linux';
const macOS = 'darwin';
const winOS = 'win32';
type SupportedPlatforms = 'win32' | 'darwin' | 'linux';

interface PlatformData {
platform: string;
extension: string;
}

export class CxInstaller {
private readonly platform: string;
private readonly platform: SupportedPlatforms;
private cliVersion: string;
private readonly resourceDirPath: string;
private readonly cliDefaultVersion = '2.2.5'; // This will be used if the version file is not found. Should be updated with the latest version.

constructor(platform: string) {
this.platform = platform;
this.resourceDirPath = path.join(__dirname, `../wrapper/resources`);
private readonly installedCLIVersionFileName = 'cli-version';
private readonly cliDefaultVersion = '2.2.5'; // Update this with the latest version.
private readonly client: AstClient;

private static readonly PLATFORMS: Record<SupportedPlatforms, PlatformData> = {
win32: { platform: 'windows', extension: 'zip' },
darwin: { platform: macOS, extension: 'tar.gz' },
linux: { platform: linuxOS, extension: 'tar.gz' }
};

constructor(platform: string, client: AstClient) {
this.platform = platform as SupportedPlatforms;
this.resourceDirPath = path.join(__dirname, '../wrapper/resources');
this.client = client;
}

private async getDownloadURL(): Promise<string> {
async getDownloadURL(): Promise<string> {
const cliVersion = await this.readASTCLIVersion();
const platformData = CxInstaller.PLATFORMS[this.platform];

const platforms: Record<SupportedPlatforms, { platform: string; extension: string }> = {
win32: {platform: 'windows', extension: 'zip'},
darwin: {platform: 'darwin', extension: 'tar.gz'},
linux: {platform: 'linux', extension: 'tar.gz'}
};

const platformKey = this.platform as SupportedPlatforms;

const platformData = platforms[platformKey];
if (!platformData) {
throw new Error('Unsupported platform or architecture');
}

return `https://download.checkmarx.com/CxOne/CLI/${cliVersion}/ast-cli_${cliVersion}_${platformData.platform}_x64.${platformData.extension}`;
const architecture = this.getArchitecture();

return `https://download.checkmarx.com/CxOne/CLI/${cliVersion}/ast-cli_${cliVersion}_${platformData.platform}_${architecture}.${platformData.extension}`;
}

private getArchitecture(): string {
// For non-linux platforms we default to x64.
if (this.platform !== linuxOS) {
return 'x64';
}

const archMap: Record<string, string> = {
'arm64': 'arm64',
'arm': 'armv6'
};

// Default to 'x64' if the current architecture is not found in the map.
return archMap[process.arch] || 'x64';
}

public getExecutablePath(): string {
const executableName = this.platform === 'win32' ? 'cx.exe' : 'cx';
const executableName = this.platform === winOS ? 'cx.exe' : 'cx';
return path.join(this.resourceDirPath, executableName);
}

public async downloadIfNotInstalledCLI(): Promise<void> {
try {
await fs.promises.mkdir(this.resourceDirPath, {recursive: true});
const cliVersion = await this.readASTCLIVersion();

if (this.checkExecutableExists()) {
logger.info('Executable already installed.');
return;
const installedVersion = await this.readInstalledVersionFile(this.resourceDirPath);
if (installedVersion === cliVersion) {
logger.info('Executable already installed.');
return;
}
}

await this.cleanDirectoryContents(this.resourceDirPath);
const url = await this.getDownloadURL();
const zipPath = path.join(this.resourceDirPath, this.getCompressFolderName());

await this.downloadFile(url, zipPath);
logger.info('Downloaded CLI to:', zipPath);
await this.client.downloadFile(url, zipPath);

await this.extractArchive(zipPath, this.resourceDirPath);
await this.saveVersionFile(this.resourceDirPath, cliVersion);

fs.unlink(zipPath, (err) => {
if (err) {
Expand All @@ -75,6 +106,33 @@ export class CxInstaller {
}
}

private async cleanDirectoryContents(directoryPath: string): Promise<void> {
try {
const files = await fsPromises.readdir(directoryPath);

await Promise.all(files.map(async (file) => {
const filePath = path.join(directoryPath, file);
const fileStat = await fsPromises.stat(filePath);

if (fileStat.isDirectory()) {
await fsPromises.rm(filePath, {recursive: true, force: true});
logger.info(`Directory ${filePath} deleted.`);
} else {
await fsPromises.unlink(filePath);
logger.info(`File ${filePath} deleted.`);
}
}));

logger.info(`All contents in ${directoryPath} have been cleaned.`);
} catch (error) {
if (error.code === 'ENOENT') {
logger.info(`Directory at ${directoryPath} does not exist.`);
} else {
logger.error(`Failed to clean directory contents: ${error.message}`);
}
}
}

private async extractArchive(zipPath: string, extractPath: string): Promise<void> {
if (zipPath.endsWith('.zip')) {
await unzipper.Open.file(zipPath)
Expand All @@ -86,24 +144,33 @@ export class CxInstaller {
}
}

private async downloadFile(url: string, outputPath: string) {
logger.info('Downloading file from:', url);
const writer = fs.createWriteStream(outputPath);

private async saveVersionFile(resourcePath: string, version: string): Promise<void> {
const versionFilePath = path.join(resourcePath, this.installedCLIVersionFileName);
try {
const response = await axios({url, responseType: 'stream'});
response.data.pipe(writer);
await fsPromises.writeFile(versionFilePath, `${version}`, 'utf8');
logger.info(`Version file created at ${versionFilePath} with version ${version}`);
} catch (error) {
logger.error(`Failed to create version file: ${error.message}`);
}
}

await finished(writer); // Use stream promises to await the writer
logger.info('Download finished');
private async readInstalledVersionFile(resourcePath: string): Promise<string | null> {
const versionFilePath = path.join(resourcePath, this.installedCLIVersionFileName);
try {
const content = await fsPromises.readFile(versionFilePath, 'utf8');
logger.info(`Version file content: ${content}`);
return content;
} catch (error) {
logger.error('Error downloading file:', error.message || error);
} finally {
writer.close();
if (error.code === 'ENOENT') {
logger.warn(`Version file not found at ${versionFilePath}.`);
} else {
logger.error(`Failed to read version file: ${error.message}`);
}
return null;
}
}
private checkExecutableExists(): boolean {

public checkExecutableExists(): boolean {
return fs.existsSync(this.getExecutablePath());
}

Expand All @@ -122,10 +189,14 @@ export class CxInstaller {
}

private getVersionFilePath(): string {
return path.join(__dirname,'../../../checkmarx-ast-cli.version');
return path.join(__dirname, '../../../checkmarx-ast-cli.version');
}

private getCompressFolderName(): string {
return `ast-cli.${this.platform === 'win32' ? 'zip' : 'tar.gz'}`;
return `ast-cli.${this.platform === winOS ? 'zip' : 'tar.gz'}`;
}

public getPlatform(): SupportedPlatforms {
return this.platform;
}
}
4 changes: 3 additions & 1 deletion src/main/wrapper/CxWrapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import * as os from "os";
import CxBFL from "../bfl/CxBFL";
import {CxInstaller} from "../osinstaller/CxInstaller";
import {Semaphore} from "async-mutex";
import {HttpClient} from "../client/HttpClient";
import {AstClient} from "../client/AstClient";


type ParamTypeMap = Map<CxParamType, string>;
Expand All @@ -18,7 +20,7 @@ export class CxWrapper {
config: CxConfig;
cxInstaller: CxInstaller;
private constructor(cxScanConfig: CxConfig, logFilePath?: string) {
this.cxInstaller = new CxInstaller(process.platform);
this.cxInstaller = new CxInstaller(process.platform, new AstClient(new HttpClient()));
this.config = new CxConfig();
getLoggerWithFilePath(logFilePath)
if (cxScanConfig.apiKey) {
Expand Down
Loading