diff --git a/src/cli/commands/test/cloud-config-output.ts b/src/cli/commands/test/cloud-config-output.ts new file mode 100644 index 00000000000..6b607806ba2 --- /dev/null +++ b/src/cli/commands/test/cloud-config-output.ts @@ -0,0 +1,60 @@ +import chalk from 'chalk'; +import * as Debug from 'debug'; +import { Options, TestOptions } from '../../../lib/types'; +import { CloudConfigTestResult } from '../../../lib/snyk-test/cloud-config-test-result'; +import { getSeverityValue } from './formatters'; +import { formatIssue } from './formatters/remediation-based-format-issues'; +import { AnnotatedCloudConfigIssue } from '../../../lib/snyk-test/cloud-config-test-result'; + +const debug = Debug('cloud-config-output'); + +export function getCloudConfigDisplayedOutput( + res: CloudConfigTestResult, + testOptions: Options & TestOptions, + testedInfoText: string, + meta: string, + prefix: string, +): string { + const issuesTextArray = [ + chalk.bold.white('\nInfrastructure as code issues:'), + ]; + + const NoNote = false; + const NotNew = false; + + const issues: AnnotatedCloudConfigIssue[] = res.result.cloudConfigResults; + debug(`iac display output - ${issues.length} issues`); + + issues + .sort((a, b) => getSeverityValue(b.severity) - getSeverityValue(a.severity)) + .forEach((issue) => { + const path: string[][] = [issue.cloudConfigPath]; + issuesTextArray.push( + formatIssue( + issue.id, + issue.title, + issue.severity, + NotNew, + issue.subType, + path, + testOptions, + NoNote, + ), + ); + }); + + const issuesInfoOutput: string[] = []; + debug(`Cloud Config display output - ${issuesTextArray.length} issues text`); + if (issuesTextArray.length > 0) { + issuesInfoOutput.push(issuesTextArray.join('\n')); + } + + let body = issuesInfoOutput.join('\n\n') + '\n\n' + meta; + + const vulnCountText = `found ${issues.length} issues`; + const summary = testedInfoText + ', ' + chalk.red.bold(vulnCountText); + + body = body + '\n\n' + summary; + + return prefix + body; +} diff --git a/src/cli/commands/test/formatters/format-test-meta.ts b/src/cli/commands/test/formatters/format-test-meta.ts index ec26b3aa975..37ea1f037b4 100644 --- a/src/cli/commands/test/formatters/format-test-meta.ts +++ b/src/cli/commands/test/formatters/format-test-meta.ts @@ -2,9 +2,10 @@ import chalk from 'chalk'; import { rightPadWithSpaces } from '../../../../lib/right-pad'; import { TestOptions, Options } from '../../../../lib/types'; import { TestResult } from '../../../../lib/snyk-test/legacy'; +import { CloudConfigTestResult } from '../../../../lib/snyk-test/cloud-config-test-result'; export function formatTestMeta( - res: TestResult, + res: TestResult | CloudConfigTestResult, options: Options & TestOptions, ): string { const padToLength = 19; // chars to align @@ -41,31 +42,37 @@ export function formatTestMeta( options.path, ); } - if (res.docker && res.docker.baseImage) { - meta.push( - chalk.bold(rightPadWithSpaces('Base image: ', padToLength)) + - res.docker.baseImage, - ); - } + if (res.payloadType !== 'k8sconfig') { + const legacyRes: TestResult = res as TestResult; + if (legacyRes.docker && legacyRes.docker.baseImage) { + meta.push( + chalk.bold(rightPadWithSpaces('Base image: ', padToLength)) + + legacyRes.docker.baseImage, + ); + } - if (res.filesystemPolicy) { - meta.push( - chalk.bold(rightPadWithSpaces('Local Snyk policy: ', padToLength)) + - chalk.green('found'), - ); - if (res.ignoreSettings && res.ignoreSettings.disregardFilesystemIgnores) { + if (legacyRes.filesystemPolicy) { meta.push( - chalk.bold( - rightPadWithSpaces('Local Snyk policy ignored: ', padToLength), - ) + chalk.red('yes'), + chalk.bold(rightPadWithSpaces('Local Snyk policy: ', padToLength)) + + chalk.green('found'), + ); + if ( + legacyRes.ignoreSettings && + legacyRes.ignoreSettings.disregardFilesystemIgnores + ) { + meta.push( + chalk.bold( + rightPadWithSpaces('Local Snyk policy ignored: ', padToLength), + ) + chalk.red('yes'), + ); + } + } + if (legacyRes.licensesPolicy) { + meta.push( + chalk.bold(rightPadWithSpaces('Licenses: ', padToLength)) + + chalk.green('enabled'), ); } - } - if (res.licensesPolicy) { - meta.push( - chalk.bold(rightPadWithSpaces('Licenses: ', padToLength)) + - chalk.green('enabled'), - ); } return meta.join('\n'); diff --git a/src/cli/commands/test/index.ts b/src/cli/commands/test/index.ts index dc6909bea78..beed128a18f 100644 --- a/src/cli/commands/test/index.ts +++ b/src/cli/commands/test/index.ts @@ -24,6 +24,7 @@ import { TestResult, VulnMetaData, } from '../../../lib/snyk-test/legacy'; +import { CloudConfigTestResult } from '../../../lib/snyk-test/cloud-config-test-result'; import { SupportedPackageManagers, WIZARD_SUPPORTED_PACKAGE_MANAGERS, @@ -43,6 +44,7 @@ import { summariseVulnerableResults, } from './formatters'; import * as utils from './utils'; +import { getCloudConfigDisplayedOutput } from './cloud-config-output'; const debug = Debug('snyk-test'); const SEPARATOR = '\n-------------------------------------------------------\n'; @@ -156,7 +158,11 @@ async function test(...args: MethodArgs): Promise { } const vulnerableResults = results.filter( - (res) => res.vulnerabilities && res.vulnerabilities.length, + (res) => + (res.vulnerabilities && res.vulnerabilities.length) || + (res.result && + res.result.cloudConfigResults && + res.result.cloudConfigResults.length), ); const errorResults = results.filter((res) => res instanceof Error); const notSuccess = errorResults.length > 0; @@ -165,7 +171,9 @@ async function test(...args: MethodArgs): Promise { // resultOptions is now an array of 1 or more options used for // the tests results is now an array of 1 or more test results // values depend on `options.json` value - string or object - const errorMappedResults = createErrorMappedResultsForJsonOutput(results); + const errorMappedResults = !options.iac + ? createErrorMappedResultsForJsonOutput(results) + : createErrorMappedResultsForJsonOutputForCloudConfig(results); // backwards compat - strip array IFF only one result const dataToSend = errorMappedResults.length === 1 @@ -295,6 +303,25 @@ function createErrorMappedResultsForJsonOutput(results) { return errorMappedResults; } +function createErrorMappedResultsForJsonOutputForCloudConfig(results) { + const errorMappedResults = results.map((result) => { + // add json for when thrown exception + if (result instanceof Error) { + return { + ok: false, + error: result.message, + path: (result as any).path, + }; + } + const res = { ...result, ...result.result }; + delete res.result; + delete res.meta; + return res; + }); + + return errorMappedResults; +} + function shouldFail(vulnerableResults: any[], failOn: FailOn) { // find reasons not to fail if (failOn === 'all') { @@ -378,7 +405,10 @@ function displayResult( if (res instanceof Error) { return prefix + res.message; } - const issuesText = res.licensesPolicy ? 'issues' : 'vulnerabilities'; + const issuesText = + res.licensesPolicy || projectType === 'k8sconfig' + ? 'issues' + : 'vulnerabilities'; let pathOrDepsText = ''; if (res.hasOwnProperty('dependencyCount')) { @@ -431,10 +461,20 @@ function displayResult( ); } + if (res.packageManager === 'k8sconfig') { + return getCloudConfigDisplayedOutput( + (res as any) as CloudConfigTestResult, + options, + testedInfoText, + meta, + prefix, + ); + } + // NOT OK => We found some vulns, let's format the vulns info return getDisplayedOutput( - res, + res as TestResult, options, testedInfoText, localPackageTest, diff --git a/src/lib/snyk-test/cloud-config-test-result.ts b/src/lib/snyk-test/cloud-config-test-result.ts new file mode 100644 index 00000000000..a92fd43f206 --- /dev/null +++ b/src/lib/snyk-test/cloud-config-test-result.ts @@ -0,0 +1,23 @@ +import { BasicResultData, SEVERITY } from './legacy'; + +export interface AnnotatedCloudConfigIssue { + id: string; + title: string; + description: string; + severity: SEVERITY; + isIgnored: boolean; + cloudConfigPath: string[]; + type: string; + subType: string; +} + +export interface CloudConfigTestResult extends BasicResultData { + targetFile: string; + projectName: string; + displayTargetFile: string; // used for display only + foundProjectCount: number; + result: { + cloudConfigResults: AnnotatedCloudConfigIssue[]; + projectType: string; + }; +} diff --git a/src/lib/snyk-test/index.js b/src/lib/snyk-test/index.js index eed58c3aff2..679b76b1fca 100644 --- a/src/lib/snyk-test/index.js +++ b/src/lib/snyk-test/index.js @@ -4,7 +4,11 @@ const detect = require('../detect'); const runTest = require('./run-test'); const chalk = require('chalk'); const pm = require('../package-managers'); -const { UnsupportedPackageManagerError } = require('../errors'); +const cloudConfigProjects = require('../cloud-config/cloud-config-projects'); +const { + UnsupportedPackageManagerError, + NoSupportedCloudConfigFileError, +} = require('../errors'); async function test(root, options, callback) { if (typeof options === 'function') { @@ -28,7 +32,11 @@ async function test(root, options, callback) { function executeTest(root, options) { try { if (!options.allProjects) { - options.packageManager = detect.detectPackageManager(root, options); + if (options.iac) { + options.packageManager = detect.isCloudConfigProject(root, options); + } else { + options.packageManager = detect.detectPackageManager(root, options); + } } return run(root, options).then((results) => { for (const res of results) { @@ -49,6 +57,18 @@ function executeTest(root, options) { } function run(root, options) { + if (options.iac) { + const projectType = options.packageManager; + if ( + !cloudConfigProjects.TEST_SUPPORTED_CLOUD_CONFIG_PROJECTS.includes( + projectType, + ) + ) { + throw new NoSupportedCloudConfigFileError(projectType); + } + return runTest(projectType, root, options); + } + const packageManager = options.packageManager; if ( !( diff --git a/src/lib/snyk-test/legacy.ts b/src/lib/snyk-test/legacy.ts index bea26b8f59f..3972e8c6e94 100644 --- a/src/lib/snyk-test/legacy.ts +++ b/src/lib/snyk-test/legacy.ts @@ -122,24 +122,27 @@ export interface IgnoreSettings { disregardFilesystemIgnores: boolean; } -export interface LegacyVulnApiResult { - vulnerabilities: AnnotatedIssue[]; +export interface BasicResultData { ok: boolean; - dependencyCount: number; + payloadType?: string; org: string; - policy: string; isPrivate: boolean; + summary: string; + packageManager?: SupportedProjectTypes; + severityThreshold?: string; +} + +export interface LegacyVulnApiResult extends BasicResultData { + vulnerabilities: AnnotatedIssue[]; + dependencyCount: number; + policy: string; licensesPolicy: object | null; - packageManager: string; ignoreSettings: IgnoreSettings | null; - summary: string; docker?: { baseImage?: any; binariesVulns?: unknown; baseImageRemediation?: BaseImageRemediation; }; - severityThreshold?: string; - filesystemPolicy?: boolean; uniqueCount?: any; remediation?: RemediationChanges; diff --git a/src/lib/snyk-test/payload-schema.ts b/src/lib/snyk-test/payload-schema.ts new file mode 100644 index 00000000000..960623ac147 --- /dev/null +++ b/src/lib/snyk-test/payload-schema.ts @@ -0,0 +1,23 @@ +//TODO(orka): future - change this file +import { SupportedCloudConfigs } from '../cloud-config/cloud-config-projects'; + +interface Scan { + type: string; + targetFile: string; + data: any; +} + +export interface CloudConfigFile { + fileContent: string; + fileType: 'yaml' | 'yml' | 'json'; +} + +export interface CloudConfigScan extends Scan { + type: SupportedCloudConfigs; + targetFile: string; + data: CloudConfigFile; + targetFileRelativePath: string; + originalProjectName: string; + policy: string; + projectNameOverride?: string; +} diff --git a/src/lib/snyk-test/run-cloud-config-test.ts b/src/lib/snyk-test/run-cloud-config-test.ts new file mode 100644 index 00000000000..61d5a45358f --- /dev/null +++ b/src/lib/snyk-test/run-cloud-config-test.ts @@ -0,0 +1,86 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as pathUtil from 'path'; +import { TestResult } from './legacy'; +import { CloudConfigTestResult } from './cloud-config-test-result'; +import * as snyk from '..'; +import { isCI } from '../is-ci'; +import * as common from './common'; +import * as config from '../config'; +import { Options, TestOptions } from '../types'; +import { Payload } from './types'; +import { CloudConfigScan } from './payload-schema'; +import { SEVERITY } from './legacy'; +import * as pathLib from 'path'; + +export async function parseCloudConfigRes( + res: CloudConfigTestResult, + targetFile: string | undefined, + projectName: any, + severityThreshold?: SEVERITY, + //TODO(orka): future - return a proper type +): Promise { + const meta = (res as any).meta || {}; + + severityThreshold = + severityThreshold === SEVERITY.LOW ? undefined : severityThreshold; + + return { + ...res, + vulnerabilities: [], + dependencyCount: 0, + licensesPolicy: null, + ignoreSettings: null, + targetFile, + projectName, + org: meta.org, + policy: meta.policy, + isPrivate: !meta.isPublic, + severityThreshold, + }; +} + +export async function assembleCloudConfigLocalPayloads( + root: string, + options: Options & TestOptions, +): Promise { + const payloads: Payload[] = []; + if (!options.file) { + return payloads; + } + // Forcing options.path to be a string as pathUtil requires is to be stringified + const targetFile = pathLib.resolve(root, options.file); + const targetFileRelativePath = targetFile + ? pathUtil.join(pathUtil.resolve(`${options.path}`), targetFile) + : ''; + + const fileContent = fs.readFileSync(targetFile, 'utf8'); + const body: CloudConfigScan = { + data: { + fileContent, + fileType: 'yaml', + }, + targetFile: options.file, + type: 'k8sconfig', + //TODO(orka): future - support policy + policy: '', + targetFileRelativePath: `${targetFileRelativePath}`, // Forcing string + originalProjectName: path.basename(path.dirname(targetFile)), + projectNameOverride: options.projectName, + }; + + const payload: Payload = { + method: 'POST', + url: config.API + (options.vulnEndpoint || '/test-iac'), + json: true, + headers: { + 'x-is-ci': isCI(), + authorization: 'token ' + (snyk as any).api, + }, + qs: common.assembleQueryString(options), + body, + }; + + payloads.push(payload); + return payloads; +} diff --git a/src/lib/snyk-test/run-test.ts b/src/lib/snyk-test/run-test.ts index e61ca4ca140..00a56beb83b 100644 --- a/src/lib/snyk-test/run-test.ts +++ b/src/lib/snyk-test/run-test.ts @@ -5,15 +5,17 @@ import * as debugModule from 'debug'; import * as pathUtil from 'path'; import { parsePackageString as moduleToObject } from 'snyk-module'; import * as depGraphLib from '@snyk/dep-graph'; +import { CloudConfigScan } from './payload-schema'; import { TestResult, DockerIssue, AnnotatedIssue, - LegacyVulnApiResult, TestDepGraphResponse, convertTestDepGraphResultToLegacy, + LegacyVulnApiResult, } from './legacy'; +import { CloudConfigTestResult } from './cloud-config-test-result'; import { AuthFailedError, InternalServerError, @@ -49,6 +51,10 @@ import { getSubProjectCount } from '../plugins/get-sub-project-count'; import { serializeCallGraphWithMetrics } from '../reachable-vulns'; import { validateOptions } from '../options-validator'; import { findAndLoadPolicy } from '../policy'; +import { + assembleCloudConfigLocalPayloads, + parseCloudConfigRes, +} from './run-cloud-config-test'; import { Payload, PayloadBody, DepTreeFromResolveDeps } from './types'; const debug = debugModule('snyk'); @@ -62,18 +68,32 @@ async function sendAndParseResults( options: Options & TestOptions, ): Promise { const results: TestResult[] = []; + for (const payload of payloads) { + await spinner(spinnerLbl); + if (options.iac) { + const cloudConfigScan: CloudConfigScan = payload.body as CloudConfigScan; + analytics.add('iac type', !!cloudConfigScan.type); + const res = (await sendTestPayload(payload)) as CloudConfigTestResult; - for (const payload of payloads) { - const payloadPolicy = payload.body && payload.body.policy; - const depGraph = payload.body && payload.body.depGraph; - const depGraphPayload: PayloadBody = payload.body as PayloadBody; - const payloadPolicy = depGraphPayload && depGraphPayload.policy; - const depGraph = depGraphPayload && depGraphPayload.depGraph; + const projectName = + cloudConfigScan.projectNameOverride || + cloudConfigScan.originalProjectName; + const result = await parseCloudConfigRes( + res, + cloudConfigScan.targetFile, + projectName, + options.severityThreshold, + ); + results.push(result); + } else { + const payloadBody: PayloadBody = payload.body as PayloadBody; + const payloadPolicy = payloadBody && payloadBody.policy; + const depGraph = payloadBody && payloadBody.depGraph; const pkgManager = depGraph && depGraph.pkgManager && (depGraph.pkgManager.name as SupportedProjectTypes); - const targetFile = depGraphPayload && depGraphPayload.targetFile; + const targetFile = payloadBody && payloadBody.targetFile; const projectName = _.get(payload, 'body.projectNameOverride') || _.get(payload, 'body.originalProjectName'); @@ -81,15 +101,14 @@ async function sendAndParseResults( const displayTargetFile = _.get(payload, 'body.displayTargetFile'); let dockerfilePackages; if ( - depGraphPayload && - depGraphPayload.docker && - depGraphPayload.docker.dockerfilePackages + payloadBody && + payloadBody.docker && + payloadBody.docker.dockerfilePackages ) { - dockerfilePackages = depGraphPayload.docker.dockerfilePackages; + dockerfilePackages = payloadBody.docker.dockerfilePackages; } - await spinner(spinnerLbl); analytics.add('depGraph', !!depGraph); - analytics.add('isDocker', !!(depGraphPayload && depGraphPayload.docker)); + analytics.add('isDocker', !!(payloadBody && payloadBody.docker)); // Type assertion might be a lie, but we are correcting that below const res = (await sendTestPayload(payload)) as LegacyVulnApiResult; @@ -112,7 +131,8 @@ async function sendAndParseResults( displayTargetFile, }); } - return results; + } + return results; } async function runTest( @@ -242,7 +262,7 @@ async function parseRes( function sendTestPayload( payload: Payload, -): Promise { +): Promise { const filesystemPolicy = payload.body && !!payload.body.policy; return new Promise((resolve, reject) => { request(payload, (error, res, body) => { @@ -309,12 +329,18 @@ async function assembleLocalPayloads( options: Options & TestOptions & PolicyOptions, ): Promise { // For --all-projects packageManager is yet undefined here. Use 'all' - const analysisType = - (options.docker ? 'docker' : options.packageManager) || 'all'; + let analysisTypeText = 'all dependencies for '; + if (options.docker) { + analysisTypeText = 'docker dependencies for '; + } else if (options.iac) { + analysisTypeText = 'Infrastruction as code configurations for '; + } else if (options.packageManager) { + analysisTypeText = options.packageManager + ' dependencies for '; + } + const spinnerLbl = 'Analyzing ' + - analysisType + - ' dependencies for ' + + analysisTypeText + (path.relative('.', path.join(root, options.file || '')) || path.relative('..', '.') + ' project dir'); @@ -322,6 +348,9 @@ async function assembleLocalPayloads( const payloads: Payload[] = []; await spinner(spinnerLbl); + if (options.iac) { + return assembleCloudConfigLocalPayloads(root, options); + } const deps = await getDepsFromPlugin(root, options); analytics.add('pluginName', deps.plugin.name); const javaVersion = _.get( diff --git a/src/lib/snyk-test/types.ts b/src/lib/snyk-test/types.ts index 0d2eb036b3e..6501b658f87 100644 --- a/src/lib/snyk-test/types.ts +++ b/src/lib/snyk-test/types.ts @@ -1,8 +1,9 @@ import * as depGraphLib from '@snyk/dep-graph'; import { GitTarget, ContainerTarget } from '../project-metadata/types'; import { DepTree } from '../types'; +import { CloudConfigScan } from './payload-schema'; -interface PayloadBody { +export interface PayloadBody { depGraph?: depGraphLib.DepGraph; // missing for legacy endpoint (options.vulnEndpoint) callGraph?: any; policy?: string; @@ -30,18 +31,7 @@ export interface Payload { 'x-is-ci': boolean; authorization: string; }; - body?: PayloadBody | CloudConfigPayloadBody; + body?: PayloadBody | CloudConfigScan; qs?: object | null; modules?: DepTreeFromResolveDeps; } - -export interface CloudConfigPayloadBody { - policy: string; - targetFile?: string; - targetFileRelativePath?: string; - originalProjectName?: string; // used only for display - foundProjectCount?: number; // used only for display - displayTargetFile?: string; - target?: GitTarget | ContainerTarget | null; - fileContent: string; -}