Skip to content
Merged
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@mondaycom/apps-cli",
"version": "4.9.3",
"version": "4.10.0",
"description": "A cli tool to manage apps (and monday-code projects) in monday.com",
"author": "monday.com Apps Team",
"type": "module",
Expand Down
9 changes: 7 additions & 2 deletions src/commands/code/push.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const MESSAGES = {
appId: APP_ID_TO_ENTER,
force: 'Force push to live version',
'client-side': 'Push files to CDN',
'security-scan': 'Run a security scan to find dependency vulnerabilities during code deployment',
};

export default class Push extends AuthenticatedCommand {
Expand Down Expand Up @@ -48,6 +49,10 @@ export default class Push extends AuthenticatedCommand {
char: 'c',
description: MESSAGES['client-side'],
}),
'security-scan': Flags.boolean({
char: 's',
description: MESSAGES['security-scan'],
}),
}),
);

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

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

const tasks = getTasksForServerSide(appVersionId, directoryPath, selectedRegion);
const tasks = getTasksForServerSide(appVersionId, directoryPath, selectedRegion, securityScan);

await tasks.run();
} catch (error: any) {
Expand Down
121 changes: 121 additions & 0 deletions src/commands/code/report.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import fs from 'node:fs';
import path from 'node:path';

import { Flags } from '@oclif/core';
import chalk from 'chalk';
import { StatusCodes } from 'http-status-codes';

import { AuthenticatedCommand } from 'commands-base/authenticated-command';
import { APP_VERSION_ID_TO_ENTER, VAR_UNKNOWN } from 'consts/messages';
import { DynamicChoicesService } from 'services/dynamic-choices-service';
import { getDeploymentSecurityScan } from 'services/push-service';
import { HttpError } from 'types/errors';
import { SecurityScanResponse, SecurityScanResultType } from 'types/services/push-service';
import logger from 'utils/logger';
import { addRegionToFlags, chooseRegionIfNeeded, getRegionFromString } from 'utils/region';

const DEBUG_TAG = 'code_report';

const printSecurityScanSummary = (securityScanResults: SecurityScanResultType) => {
const { summary, timestamp, version } = securityScanResults;

logger.log(`\nSecurity Scan Report (v${version})`);
logger.log(`Scan timestamp: ${timestamp}\n`);

const errors = chalk.red(`✖ ${summary.error} errors`);
const warnings = chalk.yellow(`▲ ${summary.warning} warnings`);
const notes = chalk.cyan(`ℹ ${summary.note} info`);

logger.log(`Total findings: ${summary.total}`);
logger.log(`${errors}\t${warnings}\t${notes}\n`);
};

const writeResultsToFile = (securityScanResults: SecurityScanResultType, appVersionId: number): string => {
const timestamp = new Date().toISOString().split('.')[0].replaceAll(':', '-');
const fileName = `security-scan-${appVersionId}-${timestamp}.json`;
const filePath = path.join(process.cwd(), fileName);

fs.writeFileSync(filePath, JSON.stringify(securityScanResults, null, 2), 'utf8');

return filePath;
};

export default class Report extends AuthenticatedCommand {
static description = 'Get security scan report for a monday-code deployment.';

static examples = [
'<%= config.bin %> <%= command.id %> -i APP_VERSION_ID',
'<%= config.bin %> <%= command.id %> -i APP_VERSION_ID -o',
];

static flags = Report.serializeFlags(
addRegionToFlags({
appVersionId: Flags.integer({
char: 'i',
aliases: ['v'],
description: APP_VERSION_ID_TO_ENTER,
}),
output: Flags.boolean({
char: 'o',
description: 'Save the full report to a JSON file',
default: false,
}),
}),
);

public async run(): Promise<void> {
const { flags } = await this.parse(Report);
const { region: strRegion, output } = flags;
const region = getRegionFromString(strRegion);
let appVersionId = flags.appVersionId;

try {
if (!appVersionId) {
const appAndAppVersion = await DynamicChoicesService.chooseAppAndAppVersion(true, true);
appVersionId = appAndAppVersion.appVersionId;
}

const selectedRegion = await chooseRegionIfNeeded(region, { appVersionId });
Comment thread
Shaharshaki2 marked this conversation as resolved.

this.preparePrintCommand(this, { appVersionId });

logger.debug(`Fetching security scan results for appVersionId: ${appVersionId}`, DEBUG_TAG);

const response: SecurityScanResponse = await getDeploymentSecurityScan(appVersionId, selectedRegion);

if (!response.securityScanResults) {
logger.log('\nNo security scan results available for this deployment.');
logger.log('Security scans are performed when deploying with the --security-scan (-s) flag.');
return;
}

printSecurityScanSummary(response.securityScanResults);

if (output) {
const filePath = writeResultsToFile(response.securityScanResults, appVersionId);
logger.log(`Full report saved to: ${filePath}`);
} else {
logger.log('Use the -o flag to save the full report to a JSON file.');
}
} catch (error: unknown) {
logger.debug({ res: error }, DEBUG_TAG);
if (error instanceof HttpError) {
if (error.code === StatusCodes.NOT_FOUND) {
logger.error(`No deployment found for provided app version id - "${appVersionId || VAR_UNKNOWN}"`);
} else if (error.code === 400) {
logger.error(error.message);
} else {
logger.error(`Failed to fetch security scan report: ${error.message}`);
}
} else {
logger.error(
`An unknown error happened while fetching security scan report for app version id - "${
appVersionId || VAR_UNKNOWN
}"`,
);
}

process.exit(1);
}
}
}
4 changes: 4 additions & 0 deletions src/consts/urls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,3 +142,7 @@ export const exportAppManifestUrl = (appId: AppId): string => {
export const makeAppManifestExportableUrl = (appId: AppId): string => {
return `${BASE_APPS_URL}/${appId}/manifest/exportability`;
};

export const getDeploymentSecurityScanUrl = (appVersionId: number): string => {
return `${appVersionIdBaseUrl(appVersionId)}/deployments/security-scan`;
};
85 changes: 78 additions & 7 deletions src/services/push-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,12 @@ import chalk from 'chalk';
import { StatusCodes } from 'http-status-codes';
import { ListrTaskWrapper } from 'listr2';

import { getAppVersionDeploymentStatusUrl, getDeploymentClientUpload, getDeploymentSignedUrl } from 'consts/urls';
import {
getAppVersionDeploymentStatusUrl,
getDeploymentClientUpload,
getDeploymentSecurityScanUrl,
getDeploymentSignedUrl,
} from 'consts/urls';
import { execute } from 'services/api-service';
import { getCurrentWorkingDirectory } from 'services/env-service';
import {
Expand All @@ -17,7 +22,11 @@ import {
verifyClientDirectory,
} from 'services/files-service';
import { pollPromise } from 'services/polling-service';
import { appVersionDeploymentStatusSchema, signedUrlSchema } from 'services/schemas/push-service-schemas';
import {
appVersionDeploymentStatusSchema,
securityScanResponseSchema,
signedUrlSchema,
} from 'services/schemas/push-service-schemas';
import { PushCommandTasksContext } from 'types/commands/push';
import { HttpError } from 'types/errors';
import { Region } from 'types/general/region';
Expand All @@ -26,6 +35,7 @@ import { HttpMethodTypes } from 'types/services/api-service';
import {
AppVersionDeploymentStatus,
DeploymentStatusTypesSchema,
SecurityScanResponse,
SignedUrl,
uploadClient,
} from 'types/services/push-service';
Expand All @@ -38,12 +48,19 @@ const MAX_FILE_SIZE_MB = 75;
const MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024;
const MAX_RECURSION_DEPTH = 10;

export const getSignedStorageUrl = async (appVersionId: number, region?: Region): Promise<string> => {
export const getSignedStorageUrl = async (
appVersionId: number,
region?: Region,
securityScan?: boolean,
): Promise<string> => {
const DEBUG_TAG = 'get_signed_storage_url';
try {
const baseSignUrl = getDeploymentSignedUrl(appVersionId);
const url = appsUrlBuilder(baseSignUrl);
const query = addRegionToQuery({}, region);
let query = addRegionToQuery({}, region);
if (securityScan) {
query = { ...query, securityScan: true };
}

const response = await execute<SignedUrl>(
{
Expand Down Expand Up @@ -104,6 +121,31 @@ export const getAppVersionDeploymentStatus = async (appVersionId: number, region
}
};

export const getDeploymentSecurityScan = async (
appVersionId: number,
region?: Region,
): Promise<SecurityScanResponse> => {
try {
const baseUrl = getDeploymentSecurityScanUrl(appVersionId);
const url = appsUrlBuilder(baseUrl);
const query = addRegionToQuery({}, region);

const response = await execute<SecurityScanResponse>(
{
query,
url,
headers: { Accept: 'application/json' },
method: HttpMethodTypes.GET,
},
securityScanResponseSchema,
);
return response;
} catch (error_: any | HttpError) {
const error = error_ instanceof HttpError ? error_ : new Error('Failed to fetch security scan results.');
throw error;
}
};

export const pollForDeploymentStatus = async (
appVersionId: number,
retryAfter: number,
Expand All @@ -123,6 +165,7 @@ export const pollForDeploymentStatus = async (
DeploymentStatusTypesSchema.building,
DeploymentStatusTypesSchema['building-infra'],
DeploymentStatusTypesSchema['building-app'],
DeploymentStatusTypesSchema['security-scan'],
DeploymentStatusTypesSchema['deploying-app'],
];
const response = await getAppVersionDeploymentStatus(appVersionId, region);
Expand Down Expand Up @@ -251,7 +294,7 @@ export const buildAssetToDeployTask = async (

export const prepareEnvironmentTask = async (ctx: PushCommandTasksContext) => {
try {
const signedCloudStorageUrl = await getSignedStorageUrl(ctx.appVersionId, ctx.region);
const signedCloudStorageUrl = await getSignedStorageUrl(ctx.appVersionId, ctx.region, ctx.securityScan);
const archiveContent = readFileData(ctx.archivePath!);
ctx.signedCloudStorageUrl = signedCloudStorageUrl;
ctx.archiveContent = archiveContent;
Expand Down Expand Up @@ -288,6 +331,7 @@ const STATUS_TO_PROGRESS_VALUE: Record<keyof typeof DeploymentStatusTypesSchema,
[DeploymentStatusTypesSchema.building]: PROGRESS_STEP * 10,
[DeploymentStatusTypesSchema['building-infra']]: PROGRESS_STEP * 25,
[DeploymentStatusTypesSchema['building-app']]: PROGRESS_STEP * 50,
[DeploymentStatusTypesSchema['security-scan']]: PROGRESS_STEP * 60,
Comment thread
Shaharshaki2 marked this conversation as resolved.
[DeploymentStatusTypesSchema['deploying-app']]: PROGRESS_STEP * 75,
[DeploymentStatusTypesSchema.successful]: PROGRESS_STEP * 100,
};
Expand All @@ -304,9 +348,20 @@ const setCustomTip = (tip?: string, color = 'green') => {
return tip ? `\n ${chalk.italic(chalkColor(tip))}` : '';
};

const writeSecurityScanResultsToDisk = (securityScanResults: any, appVersionId: number): string => {
const timestamp = new Date().toISOString().split('.')[0].replaceAll(':', '-');
const fileName = `security-scan-${appVersionId}-${timestamp}.json`;
const filePath = path.join(process.cwd(), fileName);

fs.writeFileSync(filePath, JSON.stringify(securityScanResults, null, 2), 'utf8');

return filePath;
};

const finalizeDeployment = (
deploymentStatus: AppVersionDeploymentStatus,
task: ListrTaskWrapper<PushCommandTasksContext, any>,
ctx: PushCommandTasksContext,
) => {
switch (deploymentStatus.status) {
case DeploymentStatusTypesSchema.failed: {
Expand All @@ -317,7 +372,23 @@ const finalizeDeployment = (

case DeploymentStatusTypesSchema.successful: {
const deploymentUrl = `Deployment successfully finished, deployment url: ${deploymentStatus.deployment!.url}`;
task.title = deploymentUrl;

if (deploymentStatus.securityScanResults) {
const scanResultsPath = writeSecurityScanResultsToDisk(deploymentStatus.securityScanResults, ctx.appVersionId);
ctx.securityScanResultsPath = scanResultsPath;

const summary = deploymentStatus.securityScanResults.summary;
const errors = chalk.red(`✖ ${summary.error} errors`);
const warnings = chalk.yellow(`▲ ${summary.warning} warnings`);
const notes = chalk.cyan(`ℹ ${summary.note} info`);
const scanSummary = `Security scan completed with ${summary.total} findings:\n ${errors}\t${warnings}\t${notes}`;
const downloadLink = `Results saved to: ${scanResultsPath}`;

task.title = `${scanSummary}\n${downloadLink}\n${deploymentUrl}`;
} else {
task.title = deploymentUrl;
}

break;
}

Expand Down Expand Up @@ -348,5 +419,5 @@ export const handleDeploymentTask = async (
},
});

finalizeDeployment(deploymentStatus, task);
finalizeDeployment(deploymentStatus, task, ctx);
};
2 changes: 1 addition & 1 deletion src/services/schemas/app-releases-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export const appReleaseSchema = z.object({
kind: z.string(),
category: z.nativeEnum(AppReleaseCategory),
state: z.string(),
region: z.nativeEnum(Region),
region: z.nativeEnum(Region).nullable(),
data: z
.object({
url: z.string().optional(),
Expand Down
Loading
Loading