@aikidosec/ci-api-client 1.0.17 → 1.0.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,7 +2,7 @@ import chalk from 'chalk';
2
2
  import { Argument, InvalidArgumentError, Option } from 'commander';
3
3
  import { pollScanStatus, startScan, } from '../aikidoApi.js';
4
4
  import { getApiKey } from '../configuration.js';
5
- import { outputError, outputHttpError, outputLog, startSpinner, } from '../output.js';
5
+ import { outputError, outputHttpError, outputJson, outputLog, startSpinner, } from '../output.js';
6
6
  async function cli(repoId, commitId, options, command) {
7
7
  const apiKey = getApiKey();
8
8
  if (!apiKey) {
@@ -11,6 +11,10 @@ async function cli(repoId, commitId, options, command) {
11
11
  const { apiOptions, cliOptions } = parseCliOptions(options);
12
12
  let loader;
13
13
  let pollCount = 1;
14
+ const jsonOutput = cliOptions.json;
15
+ if (jsonOutput) {
16
+ process.env.QUIET = true;
17
+ }
14
18
  const onStart = () => {
15
19
  loader = startSpinner('Starting Aikido Security release scan');
16
20
  };
@@ -27,6 +31,16 @@ async function cli(repoId, commitId, options, command) {
27
31
  loader = startSpinner('Waiting for scan to complete');
28
32
  };
29
33
  const onScanComplete = (pollResult) => {
34
+ if (jsonOutput) {
35
+ outputJson({
36
+ gate_passed: pollResult.gate_passed === true,
37
+ issue_breakdown: pollResult.issue_breakdown,
38
+ });
39
+ if (pollResult.gate_passed !== true) {
40
+ process.exitCode = 10;
41
+ }
42
+ return;
43
+ }
30
44
  if (pollResult.gate_passed === true) {
31
45
  loader?.succeed('Scan completed, no open issues found');
32
46
  if (pollResult.diff_url) {
@@ -123,8 +137,8 @@ export const scan = async ({ repoId, commitId, options, pollInterval = 10, onSta
123
137
  pollStatus();
124
138
  };
125
139
  const parseCliOptions = (userCliOptions) => {
126
- const apiOptions = { version: '1.0.16' };
127
- const cliOptions = { pollInterval: 10 };
140
+ const apiOptions = { version: '1.0.18' };
141
+ const cliOptions = { pollInterval: 10, json: false };
128
142
  if (userCliOptions.pullRequestTitle) {
129
143
  apiOptions.pull_request_metadata = {
130
144
  ...(apiOptions.pull_request_metadata ?? {}),
@@ -168,6 +182,9 @@ const parseCliOptions = (userCliOptions) => {
168
182
  else if (userCliOptions.pollInterval) {
169
183
  cliOptions.pollInterval = userCliOptions.pollInterval;
170
184
  }
185
+ if (userCliOptions.json) {
186
+ cliOptions.json = true;
187
+ }
171
188
  return { apiOptions, cliOptions };
172
189
  };
173
190
  const validateCommitId = (value) => {
@@ -193,6 +210,7 @@ export const cliSetup = (program) => program
193
210
  .argParser(parseFloat))
194
211
  .addOption(new Option('--base-branch <branchname>', 'Base branch for the release gated scan.'))
195
212
  .option('--sla-mode', 'Let Aikido only fail only on open issues that have gone out of SLA.')
213
+ .option('--json', 'Output the scan result as JSON (gate_passed and issue_breakdown)')
196
214
  .description('Run a release scan of an Aikido repo.')
197
215
  .action(cli);
198
216
  export default { cli, cliSetup, scan };
@@ -0,0 +1,15 @@
1
+ import { Command } from 'commander';
2
+ type TUploadCoverageUserCliOptions = {
3
+ repoName: string;
4
+ commitSha: string;
5
+ branchName: string;
6
+ filePaths: string[];
7
+ repositoryId: string | number;
8
+ };
9
+ declare function cli(options: TUploadCoverageUserCliOptions): Promise<void>;
10
+ export declare const cliSetup: (program: Command) => Command;
11
+ declare const _default: {
12
+ cli: typeof cli;
13
+ cliSetup: (program: Command) => Command;
14
+ };
15
+ export default _default;
@@ -0,0 +1,63 @@
1
+ import { uploadCodeCoverage } from '../aikidoApi.js';
2
+ import { collectUploadPayload } from '../codeCoverage/collectUploadPayload.js';
3
+ import { getApiKey } from '../configuration.js';
4
+ import { outputError, outputHttpError, outputLog, startSpinner, } from '../output.js';
5
+ async function cli(options) {
6
+ const apiKey = getApiKey();
7
+ if (!apiKey) {
8
+ outputError('Please set an api key using: aikido-api-client apikey <key>');
9
+ return;
10
+ return;
11
+ }
12
+ const filePaths = normalizeFilePaths(options.filePaths);
13
+ if (filePaths.length === 0) {
14
+ outputError('No code coverage file(s) provided. Specify at least one path with --file-paths.');
15
+ }
16
+ let loader = startSpinner('Collecting coverage files and repository metadata');
17
+ let payload;
18
+ try {
19
+ payload = await collectUploadPayload(filePaths);
20
+ }
21
+ catch (error) {
22
+ loader?.fail();
23
+ outputError(error instanceof Error ? error.message : String(error));
24
+ return;
25
+ }
26
+ loader?.succeed(`Found ${payload.files.length} coverage file(s) (source paths=${payload.repository_source_paths.length}, eof=${Object.keys(payload.eof).length})`);
27
+ for (const file of payload.files) {
28
+ outputLog(` ${file.filename} (${file.format})`);
29
+ }
30
+ loader = startSpinner('Uploading code coverage to Aikido');
31
+ try {
32
+ await uploadCodeCoverage({
33
+ repository_id: options.repositoryId,
34
+ repo_name: options.repoName,
35
+ commit_sha: options.commitSha,
36
+ branch_name: options.branchName,
37
+ repository_source_paths: payload.repository_source_paths,
38
+ eof: payload.eof,
39
+ files: payload.files,
40
+ });
41
+ loader?.succeed('Code coverage upload completed');
42
+ process.exit(0);
43
+ }
44
+ catch (error) {
45
+ loader?.fail();
46
+ outputHttpError(error);
47
+ process.exit(1);
48
+ }
49
+ }
50
+ const normalizeFilePaths = (filePaths) => filePaths
51
+ .flatMap((entry) => entry.split(/\n|\s+|,/))
52
+ .map((filePath) => filePath.trim())
53
+ .filter(Boolean);
54
+ export const cliSetup = (program) => program
55
+ .command('upload-coverage')
56
+ .requiredOption('-r, --repo-name <reponame>', 'Repository name as owner/repo (e.g. org/my-repo)')
57
+ .requiredOption('-c, --commit-sha <commitsha>', 'The commit SHA for this CI run')
58
+ .requiredOption('-b, --branch-name <branchname>', 'The branch name for this CI run')
59
+ .requiredOption('-f, --file-paths <paths...>', 'Path(s) to LCOV or Cobertura coverage report(s). Format is detected from each filename.')
60
+ .requiredOption('-ri, --repository-id <repositoryid>', 'The scm repository id')
61
+ .description('Upload LCOV or Cobertura coverage reports to Aikido (with repository_source_paths and EOF metadata).')
62
+ .action(cli);
63
+ export default { cli, cliSetup };
package/lib/index.js CHANGED
@@ -6,16 +6,18 @@ import apiKey from './commands/apiKey.js';
6
6
  import scan from './commands/scan.js';
7
7
  import scanRelease from './commands/scanRelease.js';
8
8
  import upload from './commands/upload.js';
9
+ import uploadCoverage from './commands/uploadCoverage.js';
9
10
  dotenv.config(process.env.NODE_ENV ? { path: `.env.${process.env.NODE_ENV}` } : {});
10
11
  export const program = new Command();
11
12
  program
12
13
  .name('Aikido API Client')
13
14
  .description('CLI api client to easily integrate the Aikido public CI API into custom deploy scripts')
14
- .version('1.0.16');
15
+ .version('1.0.18');
15
16
  apiKey.cliSetup(program);
16
17
  scan.cliSetup(program);
17
18
  scanRelease.cliSetup(program);
18
19
  upload.cliSetup(program);
20
+ uploadCoverage.cliSetup(program);
19
21
  program
20
22
  .option('-q, --quiet', 'Disable console output when executing commands')
21
23
  .on('option:quiet', function () {
package/lib/output.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { Ora } from 'ora';
2
2
  import { AxiosError } from 'axios';
3
3
  export declare const outputLog: (message: string) => void;
4
+ export declare const outputJson: (data: unknown) => void;
4
5
  export declare const outputDebug: (variable: any) => void;
5
6
  export declare const outputError: (message: string, exitCode?: number) => void;
6
7
  export declare const outputHttpError: (axiosError: AxiosError) => void;
package/lib/output.js CHANGED
@@ -6,6 +6,9 @@ export const outputLog = (message) => {
6
6
  console.log(message);
7
7
  }
8
8
  };
9
+ export const outputJson = (data) => {
10
+ console.log(JSON.stringify(data, null, 2));
11
+ };
9
12
  export const outputDebug = (variable) => {
10
13
  if (process.env.CLI_DEBUG == true || process.env.CLI_DEBUG == 'true') {
11
14
  console.log(chalk.blue(typeof variable == 'object' ? JSON.stringify(variable) : variable));
@@ -1,19 +1,20 @@
1
1
  {
2
2
  "name": "@aikidosec/ci-api-client",
3
- "version": "1.0.17",
3
+ "version": "1.0.19",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@aikidosec/ci-api-client",
9
- "version": "1.0.17",
9
+ "version": "1.0.19",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
- "axios": "1.16.1",
12
+ "axios": "1.18.0",
13
13
  "chalk": "5.3.0",
14
14
  "commander": "11.0.0",
15
15
  "configstore": "6.0.0",
16
16
  "dotenv": "16.3.1",
17
+ "ignore": "7.0.8",
17
18
  "ora": "6.3.1"
18
19
  },
19
20
  "bin": {
@@ -116,8 +117,7 @@
116
117
  "node_modules/@types/node": {
117
118
  "version": "20.3.3",
118
119
  "dev": true,
119
- "license": "MIT",
120
- "peer": true
120
+ "license": "MIT"
121
121
  },
122
122
  "node_modules/abbrev": {
123
123
  "version": "1.1.1",
@@ -206,9 +206,9 @@
206
206
  "license": "MIT"
207
207
  },
208
208
  "node_modules/axios": {
209
- "version": "1.16.1",
210
- "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.1.tgz",
211
- "integrity": "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==",
209
+ "version": "1.18.0",
210
+ "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.0.tgz",
211
+ "integrity": "sha512-E32NzpYKp++W7XRe52rHiXV2ehxmh3wbdgO7MHeFM+vqxLBYHzt0ElkiImtOBxtOmyp0yoC8C6uESVV84Y2/hw==",
212
212
  "license": "MIT",
213
213
  "dependencies": {
214
214
  "follow-redirects": "^1.16.0",
@@ -781,6 +781,15 @@
781
781
  ],
782
782
  "license": "BSD-3-Clause"
783
783
  },
784
+ "node_modules/ignore": {
785
+ "version": "7.0.8",
786
+ "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.8.tgz",
787
+ "integrity": "sha512-YYNsSlXBjMk92SKnkwvB5LOVSa6OznlFUGcsvrFgNJbJCd0M1XKeFVRc8ZByeCqz32FivYNHJVooLmdqrmvp/Q==",
788
+ "license": "MIT",
789
+ "engines": {
790
+ "node": ">= 4"
791
+ }
792
+ },
784
793
  "node_modules/ignore-by-default": {
785
794
  "version": "1.0.1",
786
795
  "dev": true,
@@ -1271,7 +1280,6 @@
1271
1280
  "version": "5.1.6",
1272
1281
  "dev": true,
1273
1282
  "license": "Apache-2.0",
1274
- "peer": true,
1275
1283
  "bin": {
1276
1284
  "tsc": "bin/tsc",
1277
1285
  "tsserver": "bin/tsserver"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aikidosec/ci-api-client",
3
- "version": "1.0.17",
3
+ "version": "1.0.19",
4
4
  "description": "CLI api client to easily integrate the Aikido public CI API into custom deploy scripts",
5
5
  "license": "MIT",
6
6
  "author": "Bert Devriese <bert@builtinbruges.com>",
@@ -25,11 +25,12 @@
25
25
  }
26
26
  },
27
27
  "dependencies": {
28
- "axios": "1.16.1",
28
+ "axios": "1.18.0",
29
29
  "chalk": "5.3.0",
30
30
  "commander": "11.0.0",
31
31
  "configstore": "6.0.0",
32
32
  "dotenv": "16.3.1",
33
+ "ignore": "7.0.8",
33
34
  "ora": "6.3.1"
34
35
  },
35
36
  "devDependencies": {
package/src/aikidoApi.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import axios from 'axios';
2
+ import { gzipSync } from 'node:zlib';
2
3
  import { getApiKey } from './configuration.js';
3
4
  import { outputDebug } from './output.js';
4
5
 
@@ -82,8 +83,26 @@ export type TPollIsScanningResult = {
82
83
  sbom_scan_completed?: boolean;
83
84
  };
84
85
 
86
+ export type TIssueSeverityBreakdown = {
87
+ critical: number;
88
+ high: number;
89
+ medium: number;
90
+ low: number;
91
+ };
92
+
93
+ export type TIssueBreakdown = {
94
+ sast: TIssueSeverityBreakdown;
95
+ sca: TIssueSeverityBreakdown;
96
+ iac: TIssueSeverityBreakdown;
97
+ secrets: TIssueSeverityBreakdown;
98
+ malware: TIssueSeverityBreakdown;
99
+ license: TIssueSeverityBreakdown;
100
+ code_quality: TIssueSeverityBreakdown;
101
+ };
102
+
85
103
  type TPollScanCompletedOptions = {
86
104
  gate_passed: boolean;
105
+ issue_breakdown?: TIssueBreakdown;
87
106
  };
88
107
 
89
108
  type TPollScanFeatureBranchCompletedOptions = {
@@ -168,3 +187,61 @@ export async function uploadCustomScanResult(
168
187
 
169
188
  return resultData;
170
189
  }
190
+ // #endregion
191
+
192
+ // #region Upload code coverage
193
+ export type TUploadCodeCoverageFile = {
194
+ filename: string;
195
+ format: 'lcov' | 'cobertura';
196
+ content: string;
197
+ };
198
+
199
+ export type TUploadCodeCoverageOptions = {
200
+ repository_id: string | number;
201
+ repo_name: string;
202
+ commit_sha: string;
203
+ branch_name: string;
204
+ repository_source_paths: string[];
205
+ eof: Record<string, number>;
206
+ files: TUploadCodeCoverageFile[];
207
+ };
208
+
209
+
210
+ export async function uploadCodeCoverage(data: TUploadCodeCoverageOptions){
211
+ const requestUrl = `${getApiUrl()}/api/integrations/continuous_integration/scan/code_coverage`;
212
+ const requestConfig = {
213
+ method: 'POST',
214
+ data: {
215
+ repository_id: data.repository_id,
216
+ repo_name: data.repo_name,
217
+ commit_sha: data.commit_sha,
218
+ branch_name: data.branch_name,
219
+ repository_source_paths: data.repository_source_paths,
220
+ eof: data.eof,
221
+ files: data.files.map((file) => ({
222
+ filename: file.filename,
223
+ format: file.format,
224
+ content: gzipSync(file.content).toString('base64'),
225
+ })),
226
+ },
227
+ headers: getApiHeaders(),
228
+ };
229
+
230
+ outputDebug(`API request: ${requestUrl}`);
231
+ outputDebug({
232
+ ...requestConfig,
233
+ data: {
234
+ ...requestConfig.data,
235
+ files: requestConfig.data.files.map((file) => ({
236
+ filename: file.filename,
237
+ format: file.format,
238
+ content: `[base64 gzip, ${file.content.length} chars]`,
239
+ })),
240
+ },
241
+ });
242
+
243
+ const resultData = (await axios(requestUrl, requestConfig)).data;
244
+ outputDebug(resultData);
245
+
246
+ return resultData;
247
+ }
@@ -0,0 +1,93 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { validateFilePath } from './paths.js';
4
+ import { loadProjectFiles } from './projectFiles.js';
5
+ import {
6
+ TCoverageFormat,
7
+ detectFormatFromFilename,
8
+ extractCoveredSourcePaths,
9
+ } from './reportPaths.js';
10
+ import { loadSourceLineFixes } from './sourceLineFixes.js';
11
+
12
+ export type TCoverageFilePayload = {
13
+ filename: string;
14
+ format: TCoverageFormat;
15
+ content: string;
16
+ };
17
+
18
+ export type TCollectUploadPayloadResult = {
19
+ repository_source_paths: string[];
20
+ eof: Record<string, number>;
21
+ files: TCoverageFilePayload[];
22
+ };
23
+
24
+ /**
25
+ * Read coverage files and collect repository_source_paths + EOF metadata
26
+ * for backend processing. Mirrors AikidoSec/code-coverage-github-action.
27
+ */
28
+ export async function collectUploadPayload(
29
+ filePaths: string[]
30
+ ): Promise<TCollectUploadPayloadResult> {
31
+ if (filePaths.length === 0) {
32
+ throw new Error(
33
+ 'No code coverage file(s) provided. Specify at least one path.'
34
+ );
35
+ }
36
+
37
+ const project = await loadProjectFiles();
38
+ if (!project || project.files.length === 0) {
39
+ throw new Error(
40
+ 'No source files found in this repository. ' +
41
+ 'Run this command from a checked-out repository before uploading coverage.'
42
+ );
43
+ }
44
+
45
+ const repositoryRoot = project.root;
46
+ const files: TCoverageFilePayload[] = [];
47
+ const coveredPaths = new Set<string>();
48
+
49
+ for (const inputPath of filePaths) {
50
+ validateFilePath(inputPath);
51
+
52
+ const absolutePath = path.resolve(inputPath);
53
+ const stats = await fs.lstat(absolutePath);
54
+ const realPath = await fs.realpath(absolutePath);
55
+ const relativePath = path.relative(repositoryRoot, realPath);
56
+ if (
57
+ stats.isSymbolicLink() ||
58
+ !stats.isFile() ||
59
+ relativePath.startsWith('..') ||
60
+ path.isAbsolute(relativePath)
61
+ ) {
62
+ throw new Error(
63
+ `Coverage file must be a regular file inside the repository: ${inputPath}`
64
+ );
65
+ }
66
+ const content = await fs.readFile(realPath, 'utf8');
67
+ const format = detectFormatFromFilename(inputPath);
68
+
69
+ for (const sourcePath of extractCoveredSourcePaths(content, format, repositoryRoot)) {
70
+ coveredPaths.add(sourcePath);
71
+ }
72
+
73
+ files.push({
74
+ filename: path.posix.basename(inputPath.replace(/\\/g, '/')),
75
+ format,
76
+ content,
77
+ });
78
+ }
79
+
80
+ const eof: Record<string, number> = {};
81
+ for (const sourcePath of coveredPaths) {
82
+ const fixes = await loadSourceLineFixes(repositoryRoot, sourcePath);
83
+ if (fixes?.eof != null) {
84
+ eof[sourcePath] = fixes.eof;
85
+ }
86
+ }
87
+
88
+ return {
89
+ repository_source_paths: project.files,
90
+ eof,
91
+ files,
92
+ };
93
+ }
@@ -0,0 +1,62 @@
1
+ import path from 'node:path';
2
+
3
+ /**
4
+ * Validate that a file path is safe to read.
5
+ * Rejects absolute paths and paths containing '..' segments to prevent
6
+ * directory traversal and arbitrary file access.
7
+ */
8
+ export function validateFilePath(filePath: string): void {
9
+ if (filePath.includes('..') || isAbsoluteSourcePath(filePath)) {
10
+ throw new Error(
11
+ 'Invalid file path: absolute paths and ".." segments are not allowed'
12
+ );
13
+ }
14
+ }
15
+
16
+ export function isAbsoluteSourcePath(sourcePath: string): boolean {
17
+ const trimmedPath = sourcePath.trim();
18
+ const pathInput = normalizePathSeparators(trimmedPath);
19
+
20
+ const windowsPath =
21
+ /^[a-zA-Z]:[\\/]/.test(trimmedPath) ||
22
+ trimmedPath.startsWith('\\\\') ||
23
+ trimmedPath.startsWith('//');
24
+ const pathApi = windowsPath ? path.win32 : path.posix;
25
+
26
+ return pathApi.isAbsolute(pathInput);
27
+ }
28
+
29
+ /** Trim, use forward slashes, drop a leading `./`. */
30
+ export function normalizePathSeparators(sourcePath: string): string {
31
+ return sourcePath.trim().replace(/\\/g, '/').replace(/^\.\//, '');
32
+ }
33
+
34
+ export function normalizeSourcePath(
35
+ sourcePath: string,
36
+ repositoryRoot: string
37
+ ): string {
38
+ const trimmedPath = sourcePath.trim();
39
+ const pathInput = normalizePathSeparators(trimmedPath);
40
+
41
+ const windowsPath =
42
+ /^[a-zA-Z]:[\\/]/.test(trimmedPath) ||
43
+ trimmedPath.startsWith('\\\\') ||
44
+ trimmedPath.startsWith('//');
45
+ const pathApi = windowsPath ? path.win32 : path.posix;
46
+ const absolutePath = pathApi.isAbsolute(pathInput);
47
+ const normalizedPath = absolutePath
48
+ ? pathApi.relative(repositoryRoot, pathInput)
49
+ : pathInput;
50
+
51
+ if (
52
+ absolutePath &&
53
+ (!normalizedPath ||
54
+ normalizedPath === '..' ||
55
+ normalizedPath.startsWith(`..${pathApi.sep}`) ||
56
+ pathApi.isAbsolute(normalizedPath))
57
+ ) {
58
+ throw new Error(`Invalid source path outside the repository: ${sourcePath}`);
59
+ }
60
+
61
+ return normalizePathSeparators(normalizedPath);
62
+ }