Skip to content

Commit 9776d87

Browse files
committed
feat(osinstaller): add SHA-256 checksum verification for downloaded CLI binaries
- readASTCLIVersion() now returns {version, checksum} and caches both; falls back to cliDefaultVersion and cliDefaultChecksums when the version file is absent or empty, otherwise pairs the version file with checkmarx-ast-cli.checksums - getDownloadURL() returns {url, checksum}, passing null when CX_CLI_LOCATION is set - downloadIfNotInstalledCLI() verifies the downloaded archive when a checksum is available - Add checkmarx-ast-cli.checksums shipped with the package for custom version pinning - Add checksum verification test cases covering match, mismatch, null, CX_CLI_LOCATION bypass, and custom version scenarios
1 parent 8048a28 commit 9776d87

4 files changed

Lines changed: 203 additions & 31 deletions

File tree

checkmarx-ast-cli.checksums

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"windows_x64": "441ee8df46cc630ae000f8ba73925113aeed8c4d16cf274944aff3e7197e3470",
3+
"darwin_x64": "b72f7e4ca14e5e56600b07d22c848a4b85e7c37d2e595424340cc699ea10006b",
4+
"linux_x64": "eb3eb55add37f150188f5a8b36b2a659f902ad9569dcb7ee652531fe525022e2",
5+
"linux_arm64": "7df61689b3c2bbd4c27face5bdc0da97f63e4533229d6b53dd777f90d3904931",
6+
"linux_armv6": "99659f2e0804b197550efc6a9ddb6029babc980d32bdfeeb508199247ac95878"
7+
}

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@
77
"files": [
88
"dist/main/**/*",
99
"README.md",
10-
"checkmarx-ast-cli.version"
10+
"checkmarx-ast-cli.version",
11+
"checkmarx-ast-cli.checksums"
1112
],
1213
"dependencies": {
1314
"async-mutex": "^0.5.0",

src/main/osinstaller/CxInstaller.ts

Lines changed: 93 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import * as fs from 'fs';
33
import * as path from 'path';
44
import * as tar from 'tar';
55
import * as unzipper from 'unzipper';
6+
import * as crypto from 'crypto';
67
import {logger} from "../wrapper/loggerConfig";
78
import {AstClient} from "../client/AstClient";
89
import {CxError} from "../errors/CxError";
@@ -20,9 +21,9 @@ interface PlatformData {
2021
export class CxInstaller {
2122
private readonly platform: SupportedPlatforms;
2223
private cliVersion: string;
24+
private cliChecksum: string | null;
2325
private readonly resourceDirPath: string;
2426
private readonly installedCLIVersionFileName = 'cli-version';
25-
private readonly cliDefaultVersion = '2.3.48'; // Update this with the latest version.
2627
private readonly client: AstClient;
2728

2829
private static readonly PLATFORMS: Record<SupportedPlatforms, PlatformData> = {
@@ -31,14 +32,65 @@ export class CxInstaller {
3132
linux: { platform: linuxOS, extension: 'tar.gz' }
3233
};
3334

35+
// Default version and its paired SHA-256 checksums, keyed by "platform_architecture".
36+
// Update both together when bumping the default CLI version.
37+
private readonly cliDefaultVersion = '2.3.48';
38+
private static readonly cliDefaultChecksums: Record<string, string> = {
39+
'windows_x64': '441ee8df46cc630ae000f8ba73925113aeed8c4d16cf274944aff3e7197e3470',
40+
'darwin_x64': 'b72f7e4ca14e5e56600b07d22c848a4b85e7c37d2e595424340cc699ea10006b',
41+
'linux_x64': 'eb3eb55add37f150188f5a8b36b2a659f902ad9569dcb7ee652531fe525022e2',
42+
'linux_arm64': '7df61689b3c2bbd4c27face5bdc0da97f63e4533229d6b53dd777f90d3904931',
43+
'linux_armv6': '99659f2e0804b197550efc6a9ddb6029babc980d32bdfeeb508199247ac95878'
44+
};
45+
3446
constructor(platform: string, client: AstClient) {
3547
this.platform = platform as SupportedPlatforms;
3648
this.resourceDirPath = path.join(__dirname, '../wrapper/resources');
3749
this.client = client;
3850
}
3951

40-
async getDownloadURL(): Promise<string> {
41-
const cliVersion = await this.readASTCLIVersion();
52+
// Returns the CLI version and its platform-specific SHA-256 checksum.
53+
// Tries the version file and checksums file first; falls back to the
54+
// hardcoded defaults if the version file is absent or empty.
55+
// Result is cached after the first read.
56+
async readASTCLIVersion(): Promise<{ version: string; checksum: string | null }> {
57+
if (this.cliVersion) {
58+
return { version: this.cliVersion, checksum: this.cliChecksum };
59+
}
60+
61+
const platformData = CxInstaller.PLATFORMS[this.platform];
62+
const architecture = this.getArchitecture();
63+
const key = `${platformData.platform}_${architecture}`;
64+
65+
let version: string | null = null;
66+
try {
67+
const content = await fsPromises.readFile(this.getVersionFilePath(), 'utf-8');
68+
const trimmed = content.trim();
69+
if (trimmed) version = trimmed;
70+
} catch {
71+
// version file absent — fall through to defaults
72+
}
73+
74+
let checksum: string | null;
75+
if (version === null) {
76+
version = this.cliDefaultVersion;
77+
checksum = CxInstaller.cliDefaultChecksums[key] ?? null;
78+
} else {
79+
try {
80+
const content = await fsPromises.readFile(this.getChecksumsFilePath(), 'utf-8');
81+
checksum = (JSON.parse(content) as Record<string, string>)[key] ?? null;
82+
} catch {
83+
checksum = null;
84+
}
85+
}
86+
87+
this.cliVersion = version;
88+
this.cliChecksum = checksum;
89+
return { version, checksum };
90+
}
91+
92+
async getDownloadURL(): Promise<{ url: string; checksum: string | null }> {
93+
const { version, checksum } = await this.readASTCLIVersion();
4294
const platformData = CxInstaller.PLATFORMS[this.platform];
4395

4496
if (!platformData) {
@@ -49,10 +101,16 @@ export class CxInstaller {
49101

50102
const envVar = process.env.CX_CLI_LOCATION;
51103
if (envVar !== undefined) {
52-
return `${envVar}/ast-cli_${cliVersion}_${platformData.platform}_${architecture}.${platformData.extension}`;
104+
return {
105+
url: `${envVar}/ast-cli_${version}_${platformData.platform}_${architecture}.${platformData.extension}`,
106+
checksum: null
107+
};
53108
}
54-
55-
return `https://download.checkmarx.com/CxOne/CLI/${cliVersion}/ast-cli_${cliVersion}_${platformData.platform}_${architecture}.${platformData.extension}`;
109+
110+
return {
111+
url: `https://download.checkmarx.com/CxOne/CLI/${version}/ast-cli_${version}_${platformData.platform}_${architecture}.${platformData.extension}`,
112+
checksum
113+
};
56114
}
57115

58116
private getArchitecture(): string {
@@ -78,7 +136,7 @@ export class CxInstaller {
78136
public async downloadIfNotInstalledCLI(): Promise<void> {
79137
try {
80138
await fs.promises.mkdir(this.resourceDirPath, {recursive: true});
81-
const cliVersion = await this.readASTCLIVersion();
139+
const { version: cliVersion } = await this.readASTCLIVersion();
82140

83141
if (this.checkExecutableExists()) {
84142
const installedVersion = await this.readInstalledVersionFile(this.resourceDirPath);
@@ -89,11 +147,15 @@ export class CxInstaller {
89147
}
90148

91149
await this.cleanDirectoryContents(this.resourceDirPath);
92-
const url = await this.getDownloadURL();
150+
const { url, checksum } = await this.getDownloadURL();
93151
const zipPath = path.join(this.resourceDirPath, this.getCompressFolderName());
94152

95153
await this.client.downloadFile(url, zipPath);
96154

155+
if (checksum) {
156+
await this.verifyChecksum(zipPath, checksum);
157+
}
158+
97159
await this.extractArchive(zipPath, this.resourceDirPath);
98160
await this.saveVersionFile(this.resourceDirPath, cliVersion);
99161

@@ -183,28 +245,36 @@ export class CxInstaller {
183245
return fs.existsSync(this.getExecutablePath());
184246
}
185247

186-
async readASTCLIVersion(): Promise<string> {
187-
if (this.cliVersion) {
188-
return this.cliVersion;
189-
}
190-
try {
191-
const versionFilePath = this.getVersionFilePath();
192-
const versionContent = await fsPromises.readFile(versionFilePath, 'utf-8');
193-
return versionContent.trim();
194-
} catch (error) {
195-
logger.warn('Error reading AST CLI version: ' + error.message);
196-
return this.cliDefaultVersion;
197-
}
198-
}
199-
200248
private getVersionFilePath(): string {
201249
return path.join(__dirname, '../../../checkmarx-ast-cli.version');
202250
}
203251

252+
private getChecksumsFilePath(): string {
253+
return path.join(__dirname, '../../../checkmarx-ast-cli.checksums');
254+
}
255+
204256
private getCompressFolderName(): string {
205257
return `ast-cli.${this.platform === winOS ? 'zip' : 'tar.gz'}`;
206258
}
207-
259+
260+
private async verifyChecksum(zipPath: string, expected: string): Promise<void> {
261+
const actual = await this.computeSHA256(zipPath);
262+
if (actual !== expected) {
263+
throw new CxError(`Checksum mismatch for ${path.basename(zipPath)}: expected ${expected}, got ${actual}`);
264+
}
265+
logger.info(`Checksum verified for ${path.basename(zipPath)}.`);
266+
}
267+
268+
private computeSHA256(filePath: string): Promise<string> {
269+
return new Promise((resolve, reject) => {
270+
const hash = crypto.createHash('sha256');
271+
fs.createReadStream(filePath)
272+
.on('data', chunk => hash.update(chunk))
273+
.on('end', () => resolve(hash.digest('hex')))
274+
.on('error', reject);
275+
});
276+
}
277+
208278
public getPlatform(): SupportedPlatforms {
209279
return this.platform;
210280
}

src/tests/CxInstallerTest.test.ts

Lines changed: 101 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { CxInstaller } from "../main/osinstaller/CxInstaller";
22
import { anyString, mock, instance, when, verify } from "ts-mockito";
33
import { AstClient } from "../main/client/AstClient";
4+
import * as fs from "fs";
5+
import * as crypto from "crypto";
46

57
// Mock AstClient and set up an instance from it
68
const astClientMock = mock(AstClient);
@@ -13,22 +15,22 @@ const cxInstallerWindows = new CxInstaller("win32", astClientInstance);
1315

1416
describe("CxInstaller cases", () => {
1517
it('CxInstaller getDownloadURL Linux Successful case', async () => {
16-
const url = await cxInstallerLinux.getDownloadURL();
17-
const version = await cxInstallerLinux.readASTCLIVersion();
18+
const { url } = await cxInstallerLinux.getDownloadURL();
19+
const { version } = await cxInstallerLinux.readASTCLIVersion();
1820
const architecture = getArchitecture(cxInstallerLinux.getPlatform());
1921
expect(url).toBe(`https://download.checkmarx.com/CxOne/CLI/${version}/ast-cli_${version}_linux_${architecture}.tar.gz`);
2022
});
2123

2224
it('CxInstaller getDownloadURL Mac Successful case', async () => {
23-
const url = await cxInstallerMac.getDownloadURL();
24-
const version = await cxInstallerLinux.readASTCLIVersion();
25+
const { url } = await cxInstallerMac.getDownloadURL();
26+
const { version } = await cxInstallerLinux.readASTCLIVersion();
2527
const architecture = getArchitecture(cxInstallerMac.getPlatform());
2628
expect(url).toBe(`https://download.checkmarx.com/CxOne/CLI/${version}/ast-cli_${version}_darwin_${architecture}.tar.gz`);
2729
});
2830

2931
it('CxInstaller getDownloadURL Windows Successful case', async () => {
30-
const url = await cxInstallerWindows.getDownloadURL();
31-
const version = await cxInstallerLinux.readASTCLIVersion();
32+
const { url } = await cxInstallerWindows.getDownloadURL();
33+
const { version } = await cxInstallerLinux.readASTCLIVersion();
3234
const architecture = getArchitecture(cxInstallerWindows.getPlatform());
3335
expect(url).toBe(`https://download.checkmarx.com/CxOne/CLI/${version}/ast-cli_${version}_windows_${architecture}.zip`);
3436
});
@@ -62,6 +64,98 @@ describe("CxInstaller checkExecutableExists cases", () => {
6264
});
6365
});
6466

67+
describe("CxInstaller checksum verification cases", () => {
68+
let localMock: AstClient;
69+
let localInstance: AstClient;
70+
let localLinux: CxInstaller;
71+
let localMac: CxInstaller;
72+
let exitSpy: jest.SpyInstance;
73+
74+
beforeEach(() => {
75+
localMock = mock(AstClient);
76+
localInstance = instance(localMock);
77+
localLinux = new CxInstaller('linux', localInstance);
78+
localMac = new CxInstaller('darwin', localInstance);
79+
exitSpy = jest.spyOn(process, 'exit').mockImplementation(() => undefined as never);
80+
});
81+
82+
afterEach(() => {
83+
exitSpy.mockRestore();
84+
delete process.env.CX_CLI_LOCATION;
85+
});
86+
87+
it('CxInstaller checksum match does not call process.exit (linux)', async () => {
88+
const content = Buffer.from('test-binary-linux');
89+
const hash = crypto.createHash('sha256').update(content).digest('hex');
90+
jest.spyOn(localLinux as any, 'readASTCLIVersion').mockResolvedValue({ version: '2.3.48', checksum: hash });
91+
when(localMock.downloadFile(anyString(), anyString())).thenCall((_url: string, dest: string) => {
92+
fs.writeFileSync(dest, content);
93+
return Promise.resolve();
94+
});
95+
await localLinux.downloadIfNotInstalledCLI();
96+
expect(exitSpy).not.toHaveBeenCalled();
97+
});
98+
99+
it('CxInstaller checksum mismatch calls process.exit(1) (linux)', async () => {
100+
jest.spyOn(localLinux as any, 'readASTCLIVersion').mockResolvedValue({ version: '2.3.48', checksum: 'deadbeef'.repeat(8) });
101+
when(localMock.downloadFile(anyString(), anyString())).thenCall((_url: string, dest: string) => {
102+
fs.writeFileSync(dest, Buffer.from('tampered'));
103+
return Promise.resolve();
104+
});
105+
await localLinux.downloadIfNotInstalledCLI();
106+
expect(exitSpy).toHaveBeenCalledWith(1);
107+
});
108+
109+
it('CxInstaller checksum match does not call process.exit (darwin)', async () => {
110+
const content = Buffer.from('test-binary-darwin');
111+
const hash = crypto.createHash('sha256').update(content).digest('hex');
112+
jest.spyOn(localMac as any, 'readASTCLIVersion').mockResolvedValue({ version: '2.3.48', checksum: hash });
113+
when(localMock.downloadFile(anyString(), anyString())).thenCall((_url: string, dest: string) => {
114+
fs.writeFileSync(dest, content);
115+
return Promise.resolve();
116+
});
117+
await localMac.downloadIfNotInstalledCLI();
118+
expect(exitSpy).not.toHaveBeenCalled();
119+
});
120+
121+
it('CxInstaller checksum match does not call process.exit for custom version', async () => {
122+
const content = Buffer.from('test-binary-custom-version');
123+
const hash = crypto.createHash('sha256').update(content).digest('hex');
124+
jest.spyOn(localLinux as any, 'readASTCLIVersion').mockResolvedValue({ version: '9.9.99', checksum: hash });
125+
when(localMock.downloadFile(anyString(), anyString())).thenCall((_url: string, dest: string) => {
126+
fs.writeFileSync(dest, content);
127+
return Promise.resolve();
128+
});
129+
await localLinux.downloadIfNotInstalledCLI();
130+
expect(exitSpy).not.toHaveBeenCalled();
131+
});
132+
133+
it('CxInstaller checksum mismatch calls process.exit(1) for custom version', async () => {
134+
jest.spyOn(localLinux as any, 'readASTCLIVersion').mockResolvedValue({ version: '9.9.99', checksum: 'deadbeef'.repeat(8) });
135+
when(localMock.downloadFile(anyString(), anyString())).thenCall((_url: string, dest: string) => {
136+
fs.writeFileSync(dest, Buffer.from('tampered'));
137+
return Promise.resolve();
138+
});
139+
await localLinux.downloadIfNotInstalledCLI();
140+
expect(exitSpy).toHaveBeenCalledWith(1);
141+
});
142+
143+
it('CxInstaller null checksum skips verification', async () => {
144+
jest.spyOn(localLinux as any, 'readASTCLIVersion').mockResolvedValue({ version: '9.9.99', checksum: null });
145+
when(localMock.downloadFile(anyString(), anyString())).thenResolve();
146+
await localLinux.downloadIfNotInstalledCLI();
147+
expect(exitSpy).not.toHaveBeenCalled();
148+
});
149+
150+
it('CxInstaller CX_CLI_LOCATION skips checksum verification', async () => {
151+
process.env.CX_CLI_LOCATION = 'https://internal.example.com/cli';
152+
jest.spyOn(localLinux as any, 'readASTCLIVersion').mockResolvedValue({ version: '2.3.48', checksum: 'irrelevant' });
153+
when(localMock.downloadFile(anyString(), anyString())).thenResolve();
154+
await localLinux.downloadIfNotInstalledCLI();
155+
expect(exitSpy).not.toHaveBeenCalled();
156+
});
157+
});
158+
65159
function getArchitecture(platform: string): string {
66160
if (platform !== 'linux') {
67161
return 'x64';
@@ -73,4 +167,4 @@ function getArchitecture(platform: string): string {
73167
};
74168

75169
return archMap[process.arch] || 'x64';
76-
}
170+
}

0 commit comments

Comments
 (0)