diff --git a/checkmarx-ast-cli.checksums b/checkmarx-ast-cli.checksums new file mode 100644 index 00000000..86f2acd6 --- /dev/null +++ b/checkmarx-ast-cli.checksums @@ -0,0 +1,7 @@ +{ + "windows_x64": "441ee8df46cc630ae000f8ba73925113aeed8c4d16cf274944aff3e7197e3470", + "darwin_x64": "b72f7e4ca14e5e56600b07d22c848a4b85e7c37d2e595424340cc699ea10006b", + "linux_x64": "eb3eb55add37f150188f5a8b36b2a659f902ad9569dcb7ee652531fe525022e2", + "linux_arm64": "7df61689b3c2bbd4c27face5bdc0da97f63e4533229d6b53dd777f90d3904931", + "linux_armv6": "99659f2e0804b197550efc6a9ddb6029babc980d32bdfeeb508199247ac95878" +} diff --git a/package.json b/package.json index a21ba726..e2e57e10 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ "files": [ "dist/main/**/*", "README.md", - "checkmarx-ast-cli.version" + "checkmarx-ast-cli.version", + "checkmarx-ast-cli.checksums" ], "dependencies": { "async-mutex": "^0.5.0", diff --git a/src/main/osinstaller/CxInstaller.ts b/src/main/osinstaller/CxInstaller.ts index d01fa3c7..9be6bdb5 100644 --- a/src/main/osinstaller/CxInstaller.ts +++ b/src/main/osinstaller/CxInstaller.ts @@ -3,6 +3,7 @@ import * as fs from 'fs'; import * as path from 'path'; import * as tar from 'tar'; import * as unzipper from 'unzipper'; +import * as crypto from 'crypto'; import {logger} from "../wrapper/loggerConfig"; import {AstClient} from "../client/AstClient"; import {CxError} from "../errors/CxError"; @@ -20,9 +21,9 @@ interface PlatformData { export class CxInstaller { private readonly platform: SupportedPlatforms; private cliVersion: string; + private cliChecksum: string | null; private readonly resourceDirPath: string; private readonly installedCLIVersionFileName = 'cli-version'; - private readonly cliDefaultVersion = '2.3.48'; // Update this with the latest version. private readonly client: AstClient; private static readonly PLATFORMS: Record = { @@ -31,14 +32,69 @@ export class CxInstaller { linux: { platform: linuxOS, extension: 'tar.gz' } }; + // Default version and its paired SHA-256 checksums, keyed by "platform_architecture". + // Update both together when bumping the default CLI version. + private readonly cliDefaultVersion = '2.3.48'; + private static readonly cliDefaultChecksums: Record = { + 'windows_x64': '441ee8df46cc630ae000f8ba73925113aeed8c4d16cf274944aff3e7197e3470', + 'darwin_x64': 'b72f7e4ca14e5e56600b07d22c848a4b85e7c37d2e595424340cc699ea10006b', + 'linux_x64': 'eb3eb55add37f150188f5a8b36b2a659f902ad9569dcb7ee652531fe525022e2', + 'linux_arm64': '7df61689b3c2bbd4c27face5bdc0da97f63e4533229d6b53dd777f90d3904931', + 'linux_armv6': '99659f2e0804b197550efc6a9ddb6029babc980d32bdfeeb508199247ac95878' + }; + constructor(platform: string, client: AstClient) { this.platform = platform as SupportedPlatforms; this.resourceDirPath = path.join(__dirname, '../wrapper/resources'); this.client = client; } - async getDownloadURL(): Promise { - const cliVersion = await this.readASTCLIVersion(); + // Returns the CLI version and its platform-specific SHA-256 checksum. + // Tries the version file and checksums file first; falls back to the + // hardcoded defaults if the version file is absent or empty. + // Result is cached after the first read. + async readASTCLIVersion(): Promise<{ version: string; checksum: string | null }> { + if (this.cliVersion) { + return { version: this.cliVersion, checksum: this.cliChecksum }; + } + + const platformData = CxInstaller.PLATFORMS[this.platform]; + const architecture = this.getArchitecture(); + const key = `${platformData.platform}_${architecture}`; + + let version: string | null = null; + try { + const content = await fsPromises.readFile(this.getVersionFilePath(), 'utf-8'); + const trimmed = content.trim(); + if (trimmed) version = trimmed; + } catch { + // version file absent — fall through to defaults + } + + let checksum: string | null; + if (version === null) { + version = this.cliDefaultVersion; + checksum = CxInstaller.cliDefaultChecksums[key] ?? null; + } else { + try { + const content = await fsPromises.readFile(this.getChecksumsFilePath(), 'utf-8'); + checksum = (JSON.parse(content) as Record)[key] ?? null; + if (checksum === null) { + logger.warn(`No checksum found for ${key} in checksums file. Download will not be verified.`); + } + } catch { + logger.warn(`Checksums file not found. Download of version ${version} will not be verified.`); + checksum = null; + } + } + + this.cliVersion = version; + this.cliChecksum = checksum; + return { version, checksum }; + } + + async getDownloadURL(): Promise<{ url: string; checksum: string | null }> { + const { version, checksum } = await this.readASTCLIVersion(); const platformData = CxInstaller.PLATFORMS[this.platform]; if (!platformData) { @@ -49,10 +105,16 @@ export class CxInstaller { const envVar = process.env.CX_CLI_LOCATION; if (envVar !== undefined) { - return `${envVar}/ast-cli_${cliVersion}_${platformData.platform}_${architecture}.${platformData.extension}`; + return { + url: `${envVar}/ast-cli_${version}_${platformData.platform}_${architecture}.${platformData.extension}`, + checksum: null + }; } - - return `https://download.checkmarx.com/CxOne/CLI/${cliVersion}/ast-cli_${cliVersion}_${platformData.platform}_${architecture}.${platformData.extension}`; + + return { + url: `https://download.checkmarx.com/CxOne/CLI/${version}/ast-cli_${version}_${platformData.platform}_${architecture}.${platformData.extension}`, + checksum + }; } private getArchitecture(): string { @@ -78,7 +140,7 @@ export class CxInstaller { public async downloadIfNotInstalledCLI(): Promise { try { await fs.promises.mkdir(this.resourceDirPath, {recursive: true}); - const cliVersion = await this.readASTCLIVersion(); + const { version: cliVersion } = await this.readASTCLIVersion(); if (this.checkExecutableExists()) { const installedVersion = await this.readInstalledVersionFile(this.resourceDirPath); @@ -89,11 +151,15 @@ export class CxInstaller { } await this.cleanDirectoryContents(this.resourceDirPath); - const url = await this.getDownloadURL(); + const { url, checksum } = await this.getDownloadURL(); const zipPath = path.join(this.resourceDirPath, this.getCompressFolderName()); await this.client.downloadFile(url, zipPath); + if (checksum) { + await this.verifyChecksum(zipPath, checksum); + } + await this.extractArchive(zipPath, this.resourceDirPath); await this.saveVersionFile(this.resourceDirPath, cliVersion); @@ -183,28 +249,36 @@ export class CxInstaller { return fs.existsSync(this.getExecutablePath()); } - async readASTCLIVersion(): Promise { - if (this.cliVersion) { - return this.cliVersion; - } - try { - const versionFilePath = this.getVersionFilePath(); - const versionContent = await fsPromises.readFile(versionFilePath, 'utf-8'); - return versionContent.trim(); - } catch (error) { - logger.warn('Error reading AST CLI version: ' + error.message); - return this.cliDefaultVersion; - } - } - private getVersionFilePath(): string { return path.join(__dirname, '../../../checkmarx-ast-cli.version'); } + private getChecksumsFilePath(): string { + return path.join(__dirname, '../../../checkmarx-ast-cli.checksums'); + } + private getCompressFolderName(): string { return `ast-cli.${this.platform === winOS ? 'zip' : 'tar.gz'}`; } - + + private async verifyChecksum(zipPath: string, expected: string): Promise { + const actual = await this.computeSHA256(zipPath); + if (actual !== expected) { + throw new CxError(`Checksum mismatch for ${path.basename(zipPath)}: expected ${expected}, got ${actual}`); + } + logger.info(`Checksum verified for ${path.basename(zipPath)}.`); + } + + private computeSHA256(filePath: string): Promise { + return new Promise((resolve, reject) => { + const hash = crypto.createHash('sha256'); + fs.createReadStream(filePath) + .on('data', chunk => hash.update(chunk)) + .on('end', () => resolve(hash.digest('hex'))) + .on('error', reject); + }); + } + public getPlatform(): SupportedPlatforms { return this.platform; } diff --git a/src/tests/CxInstallerTest.test.ts b/src/tests/CxInstallerTest.test.ts index 8e68230d..07954406 100644 --- a/src/tests/CxInstallerTest.test.ts +++ b/src/tests/CxInstallerTest.test.ts @@ -1,6 +1,8 @@ import { CxInstaller } from "../main/osinstaller/CxInstaller"; import { anyString, mock, instance, when, verify } from "ts-mockito"; import { AstClient } from "../main/client/AstClient"; +import * as fs from "fs"; +import * as crypto from "crypto"; // Mock AstClient and set up an instance from it const astClientMock = mock(AstClient); @@ -13,22 +15,22 @@ const cxInstallerWindows = new CxInstaller("win32", astClientInstance); describe("CxInstaller cases", () => { it('CxInstaller getDownloadURL Linux Successful case', async () => { - const url = await cxInstallerLinux.getDownloadURL(); - const version = await cxInstallerLinux.readASTCLIVersion(); + const { url } = await cxInstallerLinux.getDownloadURL(); + const { version } = await cxInstallerLinux.readASTCLIVersion(); const architecture = getArchitecture(cxInstallerLinux.getPlatform()); expect(url).toBe(`https://download.checkmarx.com/CxOne/CLI/${version}/ast-cli_${version}_linux_${architecture}.tar.gz`); }); it('CxInstaller getDownloadURL Mac Successful case', async () => { - const url = await cxInstallerMac.getDownloadURL(); - const version = await cxInstallerLinux.readASTCLIVersion(); + const { url } = await cxInstallerMac.getDownloadURL(); + const { version } = await cxInstallerLinux.readASTCLIVersion(); const architecture = getArchitecture(cxInstallerMac.getPlatform()); expect(url).toBe(`https://download.checkmarx.com/CxOne/CLI/${version}/ast-cli_${version}_darwin_${architecture}.tar.gz`); }); it('CxInstaller getDownloadURL Windows Successful case', async () => { - const url = await cxInstallerWindows.getDownloadURL(); - const version = await cxInstallerLinux.readASTCLIVersion(); + const { url } = await cxInstallerWindows.getDownloadURL(); + const { version } = await cxInstallerLinux.readASTCLIVersion(); const architecture = getArchitecture(cxInstallerWindows.getPlatform()); expect(url).toBe(`https://download.checkmarx.com/CxOne/CLI/${version}/ast-cli_${version}_windows_${architecture}.zip`); }); @@ -62,6 +64,98 @@ describe("CxInstaller checkExecutableExists cases", () => { }); }); +describe("CxInstaller checksum verification cases", () => { + let localMock: AstClient; + let localInstance: AstClient; + let localLinux: CxInstaller; + let localMac: CxInstaller; + let exitSpy: jest.SpyInstance; + + beforeEach(() => { + localMock = mock(AstClient); + localInstance = instance(localMock); + localLinux = new CxInstaller('linux', localInstance); + localMac = new CxInstaller('darwin', localInstance); + exitSpy = jest.spyOn(process, 'exit').mockImplementation(() => undefined as never); + }); + + afterEach(() => { + exitSpy.mockRestore(); + delete process.env.CX_CLI_LOCATION; + }); + + it('CxInstaller checksum match does not call process.exit (linux)', async () => { + const content = Buffer.from('test-binary-linux'); + const hash = crypto.createHash('sha256').update(content).digest('hex'); + jest.spyOn(localLinux as any, 'readASTCLIVersion').mockResolvedValue({ version: '2.3.48', checksum: hash }); + when(localMock.downloadFile(anyString(), anyString())).thenCall((_url: string, dest: string) => { + fs.writeFileSync(dest, content); + return Promise.resolve(); + }); + await localLinux.downloadIfNotInstalledCLI(); + expect(exitSpy).not.toHaveBeenCalled(); + }); + + it('CxInstaller checksum mismatch calls process.exit(1) (linux)', async () => { + jest.spyOn(localLinux as any, 'readASTCLIVersion').mockResolvedValue({ version: '2.3.48', checksum: 'deadbeef'.repeat(8) }); + when(localMock.downloadFile(anyString(), anyString())).thenCall((_url: string, dest: string) => { + fs.writeFileSync(dest, Buffer.from('tampered')); + return Promise.resolve(); + }); + await localLinux.downloadIfNotInstalledCLI(); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it('CxInstaller checksum match does not call process.exit (darwin)', async () => { + const content = Buffer.from('test-binary-darwin'); + const hash = crypto.createHash('sha256').update(content).digest('hex'); + jest.spyOn(localMac as any, 'readASTCLIVersion').mockResolvedValue({ version: '2.3.48', checksum: hash }); + when(localMock.downloadFile(anyString(), anyString())).thenCall((_url: string, dest: string) => { + fs.writeFileSync(dest, content); + return Promise.resolve(); + }); + await localMac.downloadIfNotInstalledCLI(); + expect(exitSpy).not.toHaveBeenCalled(); + }); + + it('CxInstaller checksum match does not call process.exit for custom version', async () => { + const content = Buffer.from('test-binary-custom-version'); + const hash = crypto.createHash('sha256').update(content).digest('hex'); + jest.spyOn(localLinux as any, 'readASTCLIVersion').mockResolvedValue({ version: '9.9.99', checksum: hash }); + when(localMock.downloadFile(anyString(), anyString())).thenCall((_url: string, dest: string) => { + fs.writeFileSync(dest, content); + return Promise.resolve(); + }); + await localLinux.downloadIfNotInstalledCLI(); + expect(exitSpy).not.toHaveBeenCalled(); + }); + + it('CxInstaller checksum mismatch calls process.exit(1) for custom version', async () => { + jest.spyOn(localLinux as any, 'readASTCLIVersion').mockResolvedValue({ version: '9.9.99', checksum: 'deadbeef'.repeat(8) }); + when(localMock.downloadFile(anyString(), anyString())).thenCall((_url: string, dest: string) => { + fs.writeFileSync(dest, Buffer.from('tampered')); + return Promise.resolve(); + }); + await localLinux.downloadIfNotInstalledCLI(); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it('CxInstaller null checksum skips verification', async () => { + jest.spyOn(localLinux as any, 'readASTCLIVersion').mockResolvedValue({ version: '9.9.99', checksum: null }); + when(localMock.downloadFile(anyString(), anyString())).thenResolve(); + await localLinux.downloadIfNotInstalledCLI(); + expect(exitSpy).not.toHaveBeenCalled(); + }); + + it('CxInstaller CX_CLI_LOCATION skips checksum verification', async () => { + process.env.CX_CLI_LOCATION = 'https://internal.example.com/cli'; + jest.spyOn(localLinux as any, 'readASTCLIVersion').mockResolvedValue({ version: '2.3.48', checksum: 'irrelevant' }); + when(localMock.downloadFile(anyString(), anyString())).thenResolve(); + await localLinux.downloadIfNotInstalledCLI(); + expect(exitSpy).not.toHaveBeenCalled(); + }); +}); + function getArchitecture(platform: string): string { if (platform !== 'linux') { return 'x64'; @@ -73,4 +167,4 @@ function getArchitecture(platform: string): string { }; return archMap[process.arch] || 'x64'; -} \ No newline at end of file +}