Skip to content

Commit bfa9705

Browse files
authored
add new flag for security scanning (#145)
* add new flag for security scanning * remove comment * fix comments * [beta] * add new command for code:report to return the security scanning for specific deployment if exists * bump version for beta [beta] * fix comemnts * fix code:status when appRelease has null region, its happen when for client side releases * [beta] * bump version
1 parent c81cb96 commit bfa9705

File tree

10 files changed

+288
-13
lines changed

10 files changed

+288
-13
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@mondaycom/apps-cli",
3-
"version": "4.9.3",
3+
"version": "4.10.0",
44
"description": "A cli tool to manage apps (and monday-code projects) in monday.com",
55
"author": "monday.com Apps Team",
66
"type": "module",

src/commands/code/push.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ const MESSAGES = {
1515
appId: APP_ID_TO_ENTER,
1616
force: 'Force push to live version',
1717
'client-side': 'Push files to CDN',
18+
'security-scan': 'Run a security scan to find dependency vulnerabilities during code deployment',
1819
};
1920

2021
export default class Push extends AuthenticatedCommand {
@@ -48,6 +49,10 @@ export default class Push extends AuthenticatedCommand {
4849
char: 'c',
4950
description: MESSAGES['client-side'],
5051
}),
52+
'security-scan': Flags.boolean({
53+
char: 's',
54+
description: MESSAGES['security-scan'],
55+
}),
5156
}),
5257
);
5358

@@ -61,7 +66,7 @@ export default class Push extends AuthenticatedCommand {
6166

6267
public async run(): Promise<void> {
6368
const { flags } = await this.parse(Push);
64-
const { directoryPath, region: strRegion, 'client-side': clientSide } = flags;
69+
const { directoryPath, region: strRegion, 'client-side': clientSide, 'security-scan': securityScan } = flags;
6570
const region = getRegionFromString(strRegion);
6671
let appVersionId = flags.appVersionId;
6772
if (!clientSide) {
@@ -90,7 +95,7 @@ export default class Push extends AuthenticatedCommand {
9095
logger.debug(`push code to appVersionId: ${appVersionId}`, this.DEBUG_TAG);
9196
this.preparePrintCommand(this, { appVersionId, directoryPath: directoryPath });
9297

93-
const tasks = getTasksForServerSide(appVersionId, directoryPath, selectedRegion);
98+
const tasks = getTasksForServerSide(appVersionId, directoryPath, selectedRegion, securityScan);
9499

95100
await tasks.run();
96101
} catch (error: any) {

src/commands/code/report.ts

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import fs from 'node:fs';
2+
import path from 'node:path';
3+
4+
import { Flags } from '@oclif/core';
5+
import chalk from 'chalk';
6+
import { StatusCodes } from 'http-status-codes';
7+
8+
import { AuthenticatedCommand } from 'commands-base/authenticated-command';
9+
import { APP_VERSION_ID_TO_ENTER, VAR_UNKNOWN } from 'consts/messages';
10+
import { DynamicChoicesService } from 'services/dynamic-choices-service';
11+
import { getDeploymentSecurityScan } from 'services/push-service';
12+
import { HttpError } from 'types/errors';
13+
import { SecurityScanResponse, SecurityScanResultType } from 'types/services/push-service';
14+
import logger from 'utils/logger';
15+
import { addRegionToFlags, chooseRegionIfNeeded, getRegionFromString } from 'utils/region';
16+
17+
const DEBUG_TAG = 'code_report';
18+
19+
const printSecurityScanSummary = (securityScanResults: SecurityScanResultType) => {
20+
const { summary, timestamp, version } = securityScanResults;
21+
22+
logger.log(`\nSecurity Scan Report (v${version})`);
23+
logger.log(`Scan timestamp: ${timestamp}\n`);
24+
25+
const errors = chalk.red(`✖ ${summary.error} errors`);
26+
const warnings = chalk.yellow(`▲ ${summary.warning} warnings`);
27+
const notes = chalk.cyan(`ℹ ${summary.note} info`);
28+
29+
logger.log(`Total findings: ${summary.total}`);
30+
logger.log(`${errors}\t${warnings}\t${notes}\n`);
31+
};
32+
33+
const writeResultsToFile = (securityScanResults: SecurityScanResultType, appVersionId: number): string => {
34+
const timestamp = new Date().toISOString().split('.')[0].replaceAll(':', '-');
35+
const fileName = `security-scan-${appVersionId}-${timestamp}.json`;
36+
const filePath = path.join(process.cwd(), fileName);
37+
38+
fs.writeFileSync(filePath, JSON.stringify(securityScanResults, null, 2), 'utf8');
39+
40+
return filePath;
41+
};
42+
43+
export default class Report extends AuthenticatedCommand {
44+
static description = 'Get security scan report for a monday-code deployment.';
45+
46+
static examples = [
47+
'<%= config.bin %> <%= command.id %> -i APP_VERSION_ID',
48+
'<%= config.bin %> <%= command.id %> -i APP_VERSION_ID -o',
49+
];
50+
51+
static flags = Report.serializeFlags(
52+
addRegionToFlags({
53+
appVersionId: Flags.integer({
54+
char: 'i',
55+
aliases: ['v'],
56+
description: APP_VERSION_ID_TO_ENTER,
57+
}),
58+
output: Flags.boolean({
59+
char: 'o',
60+
description: 'Save the full report to a JSON file',
61+
default: false,
62+
}),
63+
}),
64+
);
65+
66+
public async run(): Promise<void> {
67+
const { flags } = await this.parse(Report);
68+
const { region: strRegion, output } = flags;
69+
const region = getRegionFromString(strRegion);
70+
let appVersionId = flags.appVersionId;
71+
72+
try {
73+
if (!appVersionId) {
74+
const appAndAppVersion = await DynamicChoicesService.chooseAppAndAppVersion(true, true);
75+
appVersionId = appAndAppVersion.appVersionId;
76+
}
77+
78+
const selectedRegion = await chooseRegionIfNeeded(region, { appVersionId });
79+
80+
this.preparePrintCommand(this, { appVersionId });
81+
82+
logger.debug(`Fetching security scan results for appVersionId: ${appVersionId}`, DEBUG_TAG);
83+
84+
const response: SecurityScanResponse = await getDeploymentSecurityScan(appVersionId, selectedRegion);
85+
86+
if (!response.securityScanResults) {
87+
logger.log('\nNo security scan results available for this deployment.');
88+
logger.log('Security scans are performed when deploying with the --security-scan (-s) flag.');
89+
return;
90+
}
91+
92+
printSecurityScanSummary(response.securityScanResults);
93+
94+
if (output) {
95+
const filePath = writeResultsToFile(response.securityScanResults, appVersionId);
96+
logger.log(`Full report saved to: ${filePath}`);
97+
} else {
98+
logger.log('Use the -o flag to save the full report to a JSON file.');
99+
}
100+
} catch (error: unknown) {
101+
logger.debug({ res: error }, DEBUG_TAG);
102+
if (error instanceof HttpError) {
103+
if (error.code === StatusCodes.NOT_FOUND) {
104+
logger.error(`No deployment found for provided app version id - "${appVersionId || VAR_UNKNOWN}"`);
105+
} else if (error.code === 400) {
106+
logger.error(error.message);
107+
} else {
108+
logger.error(`Failed to fetch security scan report: ${error.message}`);
109+
}
110+
} else {
111+
logger.error(
112+
`An unknown error happened while fetching security scan report for app version id - "${
113+
appVersionId || VAR_UNKNOWN
114+
}"`,
115+
);
116+
}
117+
118+
process.exit(1);
119+
}
120+
}
121+
}

src/consts/urls.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,3 +142,7 @@ export const exportAppManifestUrl = (appId: AppId): string => {
142142
export const makeAppManifestExportableUrl = (appId: AppId): string => {
143143
return `${BASE_APPS_URL}/${appId}/manifest/exportability`;
144144
};
145+
146+
export const getDeploymentSecurityScanUrl = (appVersionId: number): string => {
147+
return `${appVersionIdBaseUrl(appVersionId)}/deployments/security-scan`;
148+
};

src/services/push-service.ts

Lines changed: 78 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,12 @@ import chalk from 'chalk';
66
import { StatusCodes } from 'http-status-codes';
77
import { ListrTaskWrapper } from 'listr2';
88

9-
import { getAppVersionDeploymentStatusUrl, getDeploymentClientUpload, getDeploymentSignedUrl } from 'consts/urls';
9+
import {
10+
getAppVersionDeploymentStatusUrl,
11+
getDeploymentClientUpload,
12+
getDeploymentSecurityScanUrl,
13+
getDeploymentSignedUrl,
14+
} from 'consts/urls';
1015
import { execute } from 'services/api-service';
1116
import { getCurrentWorkingDirectory } from 'services/env-service';
1217
import {
@@ -17,7 +22,11 @@ import {
1722
verifyClientDirectory,
1823
} from 'services/files-service';
1924
import { pollPromise } from 'services/polling-service';
20-
import { appVersionDeploymentStatusSchema, signedUrlSchema } from 'services/schemas/push-service-schemas';
25+
import {
26+
appVersionDeploymentStatusSchema,
27+
securityScanResponseSchema,
28+
signedUrlSchema,
29+
} from 'services/schemas/push-service-schemas';
2130
import { PushCommandTasksContext } from 'types/commands/push';
2231
import { HttpError } from 'types/errors';
2332
import { Region } from 'types/general/region';
@@ -26,6 +35,7 @@ import { HttpMethodTypes } from 'types/services/api-service';
2635
import {
2736
AppVersionDeploymentStatus,
2837
DeploymentStatusTypesSchema,
38+
SecurityScanResponse,
2939
SignedUrl,
3040
uploadClient,
3141
} from 'types/services/push-service';
@@ -38,12 +48,19 @@ const MAX_FILE_SIZE_MB = 75;
3848
const MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024;
3949
const MAX_RECURSION_DEPTH = 10;
4050

41-
export const getSignedStorageUrl = async (appVersionId: number, region?: Region): Promise<string> => {
51+
export const getSignedStorageUrl = async (
52+
appVersionId: number,
53+
region?: Region,
54+
securityScan?: boolean,
55+
): Promise<string> => {
4256
const DEBUG_TAG = 'get_signed_storage_url';
4357
try {
4458
const baseSignUrl = getDeploymentSignedUrl(appVersionId);
4559
const url = appsUrlBuilder(baseSignUrl);
46-
const query = addRegionToQuery({}, region);
60+
let query = addRegionToQuery({}, region);
61+
if (securityScan) {
62+
query = { ...query, securityScan: true };
63+
}
4764

4865
const response = await execute<SignedUrl>(
4966
{
@@ -104,6 +121,31 @@ export const getAppVersionDeploymentStatus = async (appVersionId: number, region
104121
}
105122
};
106123

124+
export const getDeploymentSecurityScan = async (
125+
appVersionId: number,
126+
region?: Region,
127+
): Promise<SecurityScanResponse> => {
128+
try {
129+
const baseUrl = getDeploymentSecurityScanUrl(appVersionId);
130+
const url = appsUrlBuilder(baseUrl);
131+
const query = addRegionToQuery({}, region);
132+
133+
const response = await execute<SecurityScanResponse>(
134+
{
135+
query,
136+
url,
137+
headers: { Accept: 'application/json' },
138+
method: HttpMethodTypes.GET,
139+
},
140+
securityScanResponseSchema,
141+
);
142+
return response;
143+
} catch (error_: any | HttpError) {
144+
const error = error_ instanceof HttpError ? error_ : new Error('Failed to fetch security scan results.');
145+
throw error;
146+
}
147+
};
148+
107149
export const pollForDeploymentStatus = async (
108150
appVersionId: number,
109151
retryAfter: number,
@@ -123,6 +165,7 @@ export const pollForDeploymentStatus = async (
123165
DeploymentStatusTypesSchema.building,
124166
DeploymentStatusTypesSchema['building-infra'],
125167
DeploymentStatusTypesSchema['building-app'],
168+
DeploymentStatusTypesSchema['security-scan'],
126169
DeploymentStatusTypesSchema['deploying-app'],
127170
];
128171
const response = await getAppVersionDeploymentStatus(appVersionId, region);
@@ -251,7 +294,7 @@ export const buildAssetToDeployTask = async (
251294

252295
export const prepareEnvironmentTask = async (ctx: PushCommandTasksContext) => {
253296
try {
254-
const signedCloudStorageUrl = await getSignedStorageUrl(ctx.appVersionId, ctx.region);
297+
const signedCloudStorageUrl = await getSignedStorageUrl(ctx.appVersionId, ctx.region, ctx.securityScan);
255298
const archiveContent = readFileData(ctx.archivePath!);
256299
ctx.signedCloudStorageUrl = signedCloudStorageUrl;
257300
ctx.archiveContent = archiveContent;
@@ -288,6 +331,7 @@ const STATUS_TO_PROGRESS_VALUE: Record<keyof typeof DeploymentStatusTypesSchema,
288331
[DeploymentStatusTypesSchema.building]: PROGRESS_STEP * 10,
289332
[DeploymentStatusTypesSchema['building-infra']]: PROGRESS_STEP * 25,
290333
[DeploymentStatusTypesSchema['building-app']]: PROGRESS_STEP * 50,
334+
[DeploymentStatusTypesSchema['security-scan']]: PROGRESS_STEP * 60,
291335
[DeploymentStatusTypesSchema['deploying-app']]: PROGRESS_STEP * 75,
292336
[DeploymentStatusTypesSchema.successful]: PROGRESS_STEP * 100,
293337
};
@@ -304,9 +348,20 @@ const setCustomTip = (tip?: string, color = 'green') => {
304348
return tip ? `\n ${chalk.italic(chalkColor(tip))}` : '';
305349
};
306350

351+
const writeSecurityScanResultsToDisk = (securityScanResults: any, appVersionId: number): string => {
352+
const timestamp = new Date().toISOString().split('.')[0].replaceAll(':', '-');
353+
const fileName = `security-scan-${appVersionId}-${timestamp}.json`;
354+
const filePath = path.join(process.cwd(), fileName);
355+
356+
fs.writeFileSync(filePath, JSON.stringify(securityScanResults, null, 2), 'utf8');
357+
358+
return filePath;
359+
};
360+
307361
const finalizeDeployment = (
308362
deploymentStatus: AppVersionDeploymentStatus,
309363
task: ListrTaskWrapper<PushCommandTasksContext, any>,
364+
ctx: PushCommandTasksContext,
310365
) => {
311366
switch (deploymentStatus.status) {
312367
case DeploymentStatusTypesSchema.failed: {
@@ -317,7 +372,23 @@ const finalizeDeployment = (
317372

318373
case DeploymentStatusTypesSchema.successful: {
319374
const deploymentUrl = `Deployment successfully finished, deployment url: ${deploymentStatus.deployment!.url}`;
320-
task.title = deploymentUrl;
375+
376+
if (deploymentStatus.securityScanResults) {
377+
const scanResultsPath = writeSecurityScanResultsToDisk(deploymentStatus.securityScanResults, ctx.appVersionId);
378+
ctx.securityScanResultsPath = scanResultsPath;
379+
380+
const summary = deploymentStatus.securityScanResults.summary;
381+
const errors = chalk.red(`✖ ${summary.error} errors`);
382+
const warnings = chalk.yellow(`▲ ${summary.warning} warnings`);
383+
const notes = chalk.cyan(`ℹ ${summary.note} info`);
384+
const scanSummary = `Security scan completed with ${summary.total} findings:\n ${errors}\t${warnings}\t${notes}`;
385+
const downloadLink = `Results saved to: ${scanResultsPath}`;
386+
387+
task.title = `${scanSummary}\n${downloadLink}\n${deploymentUrl}`;
388+
} else {
389+
task.title = deploymentUrl;
390+
}
391+
321392
break;
322393
}
323394

@@ -348,5 +419,5 @@ export const handleDeploymentTask = async (
348419
},
349420
});
350421

351-
finalizeDeployment(deploymentStatus, task);
422+
finalizeDeployment(deploymentStatus, task, ctx);
352423
};

src/services/schemas/app-releases-schema.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ export const appReleaseSchema = z.object({
1212
kind: z.string(),
1313
category: z.nativeEnum(AppReleaseCategory),
1414
state: z.string(),
15-
region: z.nativeEnum(Region),
15+
region: z.nativeEnum(Region).nullable(),
1616
data: z
1717
.object({
1818
url: z.string().optional(),

0 commit comments

Comments
 (0)