Skip to content

Commit

Permalink
feat: IaC CLI Share Results
Browse files Browse the repository at this point in the history
  • Loading branch information
YairZ101 committed Feb 24, 2022
1 parent 66f09c2 commit d2fb947
Show file tree
Hide file tree
Showing 23 changed files with 602 additions and 47 deletions.
6 changes: 5 additions & 1 deletion packages/snyk-fix/src/types.ts
Expand Up @@ -13,12 +13,16 @@ export interface ContainerTarget {
image: string;
}

interface UnknownTarget {
name: string; // Should be equal to the project name
}

export interface ScanResult {
readonly identity: Identity;
readonly facts: Facts[];
readonly name?: string;
readonly policy?: string;
readonly target?: GitTarget | ContainerTarget;
readonly target?: GitTarget | ContainerTarget | UnknownTarget;
}

export interface Identity {
Expand Down
Expand Up @@ -22,6 +22,7 @@ const keys: (keyof IaCTestFlags)[] = [
'quiet',
'scan',
'legacy',
'report',

// PolicyOptions
'ignore-policy',
Expand All @@ -40,16 +41,27 @@ function getFlagName(key: string) {
}

export class FlagError extends CustomError {
constructor(key: string, featureFlag?: string) {
constructor(key: string) {
const flag = getFlagName(key);
const msg = `Unsupported flag "${flag}" provided. Run snyk iac test --help for supported flags`;
super(msg);
this.code = IaCErrorCodes.FlagError;
this.strCode = getErrorStringCode(this.code);
this.userMessage = msg;
}
}

export class FeatureFlagError extends CustomError {
constructor(key: string, featureFlag: string, hasSnykPreview?: boolean) {
const flag = getFlagName(key);
let msg;
if (featureFlag) {
if (hasSnykPreview) {
msg = `Flag "${flag}" is only supported if feature flag '${featureFlag}' is enabled. The feature flag can be enabled via Snyk Preview if you are on the Enterprise Plan`;
} else {
msg = `Unsupported flag "${flag}" provided. Run snyk iac test --help for supported flags`;
msg = `Flag "${flag}" is only supported if feature flag "${featureFlag}" is enabled. To enable it, please contact Snyk support.`;
}
super(msg);
this.code = IaCErrorCodes.FlagError;
this.code = IaCErrorCodes.FeatureFlagError;
this.strCode = getErrorStringCode(this.code);
this.userMessage = msg;
}
Expand Down Expand Up @@ -110,7 +122,7 @@ export function assertIaCOptionsFlags(argv: string[]) {
// flag strings passed to the command line (usually files)
// and `iac` is the command provided.
if (key !== '_' && key !== 'iac' && !allowed.has(key)) {
throw new FlagError(key, '');
throw new FlagError(key);
}
}

Expand All @@ -133,3 +145,7 @@ function assertTerraformPlanModes(scanModeArgValue: string) {
throw new FlagValueError('scan', scanModeArgValue);
}
}

export function isIacShareResultsOptions(options) {
return options.iac && options.report && !options.legacy;
}
33 changes: 33 additions & 0 deletions src/cli/commands/test/iac-local-execution/file-utils.ts
Expand Up @@ -7,6 +7,7 @@ import {
LOCAL_POLICY_ENGINE_DIR,
} from './local-cache';
import { CUSTOM_RULES_TARBALL } from './oci-pull';
import { isLocalFolder } from '../../../../lib/detect';

function hashData(s: string): string {
const hashedData = crypto
Expand Down Expand Up @@ -68,3 +69,35 @@ export function computeCustomRulesBundleChecksum(): string | undefined {
return;
}
}

export function computePaths(
filePath: string,
pathArg = '.',
): { targetFilePath: string; projectName: string; targetFile: string } {
const targetFilePath = path.resolve(filePath, '.');

// the absolute path is needed to compute the full project path
const cmdPath = path.resolve(pathArg);

let projectPath: string;
let targetFile: string;
if (!isLocalFolder(cmdPath)) {
// if the provided path points to a file, then the project starts at the parent folder of that file
// and the target file was provided as the path argument
projectPath = path.dirname(cmdPath);
targetFile = path.isAbsolute(pathArg)
? path.relative(process.cwd(), pathArg)
: pathArg;
} else {
// otherwise, the project starts at the provided path
// and the target file must be the relative path from the project path to the path of the scanned file
projectPath = cmdPath;
targetFile = path.relative(projectPath, targetFilePath);
}

return {
targetFilePath,
projectName: path.basename(projectPath),
targetFile,
};
}
12 changes: 12 additions & 0 deletions src/cli/commands/test/iac-local-execution/index.ts
Expand Up @@ -31,6 +31,7 @@ import { isFeatureFlagSupportedForOrg } from '../../../../lib/feature-flags';
import { initRules } from './rules';
import { NoFilesToScanError } from './file-loader';
import { parseTerraformFiles } from './file-parser';
import { formatAndShareResults } from './share-results';

// this method executes the local processing engine and then formats the results to adapt with the CLI output.
// this flow is the default GA flow for IAC scanning.
Expand Down Expand Up @@ -126,10 +127,21 @@ export async function test(
scannedFiles,
iacOrgSettings.customPolicies,
);

let projectPublicIds: Record<string, string> = {};
if (options.report) {
projectPublicIds = await formatAndShareResults(
resultsWithCustomSeverities,
options,
orgPublicId,
);
}

const formattedResults = formatScanResults(
resultsWithCustomSeverities,
options,
iacOrgSettings.meta,
projectPublicIds,
);

const { filteredIssues, ignoreCount } = filterIgnoredIssues(
Expand Down
37 changes: 3 additions & 34 deletions src/cli/commands/test/iac-local-execution/results-formatter.ts
Expand Up @@ -7,25 +7,25 @@ import {
PolicyMetadata,
TestMeta,
} from './types';
import * as path from 'path';
import { SEVERITY, SEVERITIES } from '../../../../lib/snyk-test/common';
import { IacProjectType } from '../../../../lib/iac/constants';
import { CustomError } from '../../../../lib/errors';
import { extractLineNumber, getFileTypeForParser } from './extract-line-number';
import { getErrorStringCode } from './error-utils';
import { isLocalFolder } from '../../../../lib/detect';
import {
MapsDocIdToTree,
getTrees,
parsePath,
} from '@snyk/cloud-config-parser';
import { computePaths } from './file-utils';

const severitiesArray = SEVERITIES.map((s) => s.verboseName);

export function formatScanResults(
scanResults: IacFileScanResult[],
options: IaCTestFlags,
meta: TestMeta,
projectPublicIds: Record<string, string>,
): FormattedResult[] {
try {
const groupedByFile = scanResults.reduce((memo, scanResult) => {
Expand All @@ -35,6 +35,7 @@ export function formatScanResults(
...res.result.cloudConfigResults,
);
} else {
res.meta.projectId = projectPublicIds[res.targetFile];
memo[scanResult.filePath] = res;
}
return memo;
Expand Down Expand Up @@ -131,38 +132,6 @@ function formatScanResult(
};
}

function computePaths(
filePath: string,
pathArg = '.',
): { targetFilePath: string; projectName: string; targetFile: string } {
const targetFilePath = path.resolve(filePath, '.');

// the absolute path is needed to compute the full project path
const cmdPath = path.resolve(pathArg);

let projectPath: string;
let targetFile: string;
if (!isLocalFolder(cmdPath)) {
// if the provided path points to a file, then the project starts at the parent folder of that file
// and the target file was provided as the path argument
projectPath = path.dirname(cmdPath);
targetFile = path.isAbsolute(pathArg)
? path.relative(process.cwd(), pathArg)
: pathArg;
} else {
// otherwise, the project starts at the provided path
// and the target file must be the relative path from the project path to the path of the scanned file
projectPath = cmdPath;
targetFile = path.relative(projectPath, targetFilePath);
}

return {
targetFilePath,
projectName: path.basename(projectPath),
targetFile,
};
}

export function filterPoliciesBySeverity(
violatedPolicies: PolicyMetadata[],
severityThreshold?: SEVERITY,
Expand Down
@@ -0,0 +1,47 @@
import { computePaths } from './file-utils';
import {
IacFileScanResult,
IacShareResultsFormat,
IaCTestFlags,
} from './types';

export function formatShareResults(
scanResults: IacFileScanResult[],
options: IaCTestFlags,
): IacShareResultsFormat[] {
const resultsGroupedByFilePath = groupByFilePath(scanResults);

return resultsGroupedByFilePath.map((result) => {
const { projectName, targetFile } = computePaths(
result.filePath,
options.path,
);

return {
projectName,
targetFile,
filePath: result.filePath,
fileType: result.fileType,
projectType: result.projectType,
violatedPolicies: result.violatedPolicies,
};
});
}

function groupByFilePath(scanResults: IacFileScanResult[]) {
const groupedByFilePath = scanResults.reduce((memo, scanResult) => {
scanResult.violatedPolicies.forEach((violatedPolicy) => {
violatedPolicy.docId = scanResult.docId;
});
if (memo[scanResult.filePath]) {
memo[scanResult.filePath].violatedPolicies.push(
...scanResult.violatedPolicies,
);
} else {
memo[scanResult.filePath] = scanResult;
}
return memo;
}, {} as Record<string, IacFileScanResult>);

return Object.values(groupedByFilePath);
}
22 changes: 22 additions & 0 deletions src/cli/commands/test/iac-local-execution/share-results.ts
@@ -0,0 +1,22 @@
import { isFeatureFlagSupportedForOrg } from '../../../../lib/feature-flags';
import { shareResults } from '../../../../lib/iac/cli-share-results';
import { FeatureFlagError } from './assert-iac-options-flag';
import { formatShareResults } from './share-results-formatter';

export async function formatAndShareResults(
results,
options,
orgPublicId,
): Promise<Record<string, string>> {
const isCliReportEnabled = await isFeatureFlagSupportedForOrg(
'iacCliShareResults',
orgPublicId,
);
if (!isCliReportEnabled.ok) {
throw new FeatureFlagError('report', 'iacCliShareResults');
}

const formattedResults = formatShareResults(results, options);

return await shareResults(formattedResults);
}
18 changes: 17 additions & 1 deletion src/cli/commands/test/iac-local-execution/types.ts
@@ -1,4 +1,8 @@
import { IacProjectType, IacProjectTypes } from '../../../../lib/iac/constants';
import {
IacFileTypes,
IacProjectType,
IacProjectTypes,
} from '../../../../lib/iac/constants';
import { SEVERITY } from '../../../../lib/snyk-test/common';
import {
AnnotatedIssue,
Expand Down Expand Up @@ -52,6 +56,15 @@ export interface IacFileScanResult extends IacFileParsed {
violatedPolicies: PolicyMetadata[];
}

export interface IacShareResultsFormat {
projectName: string;
targetFile: string;
filePath: string;
fileType: IacFileTypes;
projectType: IacProjectType;
violatedPolicies: PolicyMetadata[];
}

// This type is the integration point with the CLI test command, please note it is still partial in the experimental version
export type FormattedResult = {
result: {
Expand Down Expand Up @@ -149,6 +162,7 @@ export interface PolicyMetadata {
remediation?: Partial<
Record<'terraform' | 'cloudformation' | 'arm' | 'kubernetes', string>
>;
docId?: number;
}

// Collection of all options supported by `iac test` command.
Expand All @@ -163,6 +177,7 @@ export type IaCTestFlags = Pick<
| 'severityThreshold'
| 'json'
| 'sarif'
| 'report'

// PolicyOptions
| 'ignore-policy'
Expand Down Expand Up @@ -304,6 +319,7 @@ export enum IaCErrorCodes {
FlagError = 1090,
FlagValueError = 1091,
UnsupportedEntitlementFlagError = 1092,
FeatureFlagError = 1093,

// oci-pull errors
FailedToExecuteCustomRulesError = 1100,
Expand Down
18 changes: 18 additions & 0 deletions src/cli/commands/test/index.ts
Expand Up @@ -43,6 +43,8 @@ import {
containsSpotlightVulnIds,
notificationForSpotlightVulns,
} from '../../../lib/spotlight-vuln-notification';
import config from '../../../lib/config';
import { isIacShareResultsOptions } from './iac-local-execution/assert-iac-options-flag';

const debug = Debug('snyk-test');
const SEPARATOR = '\n-------------------------------------------------------\n';
Expand Down Expand Up @@ -108,6 +110,8 @@ export default async function test(
// this path is an experimental feature feature for IaC which does issue scanning locally without sending files to our Backend servers.
// once ready for GA, it is aimed to deprecate our remote-processing model, so IaC file scanning in the CLI is done locally.
const { results, failures } = await iacTest(path, testOpts);
testOpts.org = results[0]?.org;
testOpts.projectName = results[0]?.projectName;
res = results;
iacScanFailures = failures;
} else {
Expand Down Expand Up @@ -292,6 +296,13 @@ export default async function test(
);
response += spotlightVulnsMsg;

if (isIacShareResultsOptions(options)) {
response +=
chalk.bold.white(
`Your test results are available at: ${config.ROOT}/org/${resultOptions[0].org}/projects under the name ${resultOptions[0].projectName}`,
) + EOL;
}

const error = new Error(response) as any;
// take the code of the first problem to go through error
// translation
Expand All @@ -310,6 +321,13 @@ export default async function test(
packageJsonPathsWithSnykDepForProtect,
);

if (isIacShareResultsOptions(options)) {
response +=
chalk.bold.white(
`Your test results are available at: ${config.ROOT}/org/${resultOptions[0].org}/projects under the name ${resultOptions[0].projectName}`,
) + EOL;
}

return TestCommandResult.createHumanReadableTestCommandResult(
response,
stringifiedJsonData,
Expand Down

0 comments on commit d2fb947

Please sign in to comment.