diff --git a/cliv2/cmd/cliv2/main.go b/cliv2/cmd/cliv2/main.go index 30cd664901..5a1290c0cf 100644 --- a/cliv2/cmd/cliv2/main.go +++ b/cliv2/cmd/cliv2/main.go @@ -61,6 +61,7 @@ import ( cli_errors "github.com/snyk/cli/cliv2/internal/errors" "github.com/snyk/cli/cliv2/pkg/basic_workflows" + snykdeltaworkflow "github.com/snyk/cli-extension-snyk-delta/pkg/delta" ) var internalOS string @@ -381,6 +382,9 @@ func createCommandsForWorkflows(rootCommand *cobra.Command, engine workflow.Engi legacy.SetupTestMonitorCommand(parentCommand) } else if currentCommandString == "auth" { parentCommand.RunE = runAuthCommand + } else if currentCommandString == "delta" { + // to preserve all flags for test-delta to be passed to the legacy cli + parentCommand.FParseErrWhitelist.UnknownFlags = true } } } @@ -552,6 +556,7 @@ func MainWithErrorCode() int { globalEngine.AddExtensionInitializer(workflows.InitConnectivityCheckWorkflow) globalEngine.AddExtensionInitializer(localworkflows.InitCodeWorkflow) globalEngine.AddExtensionInitializer(ignoreworkflow.InitIgnoreWorkflows) + globalEngine.AddExtensionInitializer(snykdeltaworkflow.InitDelta) // init engine err = globalEngine.Init() diff --git a/cliv2/go.mod b/cliv2/go.mod index 212e79ac0d..f3578a4144 100644 --- a/cliv2/go.mod +++ b/cliv2/go.mod @@ -16,9 +16,10 @@ require ( github.com/snyk/cli-extension-iac-rules v0.0.0-20250829110455-1260348bc188 github.com/snyk/cli-extension-os-flows v0.0.0-20251125150934-8bac76166638 github.com/snyk/cli-extension-sbom v0.0.0-20251113132837-5f6cc6d0cb26 + github.com/snyk/cli-extension-snyk-delta v0.0.0-20251128154224-3a5a768a357d github.com/snyk/container-cli v0.0.0-20250321132345-1e2e01681dd7 github.com/snyk/error-catalog-golang-public v0.0.0-20251024131459-25bdd340f134 - github.com/snyk/go-application-framework v0.0.0-20251118111357-8c9e565ff018 + github.com/snyk/go-application-framework v0.0.0-20251125151940-b1299180db56 github.com/snyk/go-httpauth v0.0.0-20240307114523-1f5ea3f55c65 github.com/snyk/snyk-iac-capture v0.6.5 github.com/snyk/snyk-ls v0.0.0-20251126093614-d999dd468f2e diff --git a/cliv2/go.sum b/cliv2/go.sum index 48c43cbea9..91aec4b4f0 100644 --- a/cliv2/go.sum +++ b/cliv2/go.sum @@ -1306,14 +1306,16 @@ github.com/snyk/cli-extension-os-flows v0.0.0-20251125150934-8bac76166638 h1:JSg github.com/snyk/cli-extension-os-flows v0.0.0-20251125150934-8bac76166638/go.mod h1:7cBuPp3HdioLGDBd7TvBPPUxTrehTi0dYPRh3K7fKm4= github.com/snyk/cli-extension-sbom v0.0.0-20251113132837-5f6cc6d0cb26 h1:KEiRBMdOJHefM4GKL3C3FfvH4J2G/vBFnwkonylV5+o= github.com/snyk/cli-extension-sbom v0.0.0-20251113132837-5f6cc6d0cb26/go.mod h1:zyKDBaETfZyI7BfIjPnezH3QX2seQrR/d7NM5W6LV9s= +github.com/snyk/cli-extension-snyk-delta v0.0.0-20251128154224-3a5a768a357d h1:zN4vFR30zlri7/9DeqZzVIRPtvVl/5XkrKbw+RebzxI= +github.com/snyk/cli-extension-snyk-delta v0.0.0-20251128154224-3a5a768a357d/go.mod h1:pm4RE0OLdmMwFa6BDHVSaPFf8FICiSUEu12d4jxQlwI= github.com/snyk/code-client-go v1.24.4 h1:19rmeqZFvjQMKaAmSZ0CdYZb1d0ENsDad2Cp32jeWOA= github.com/snyk/code-client-go v1.24.4/go.mod h1:uMlmMToe4uuNhNLs+yxjM3WFbytna+ytDWhpbnNwTSk= github.com/snyk/container-cli v0.0.0-20250321132345-1e2e01681dd7 h1:/2+2piwQtB9fEJCkXEOjboZjY+77lQfnvqBZ/60xNHk= github.com/snyk/container-cli v0.0.0-20250321132345-1e2e01681dd7/go.mod h1:38w+dcAQp9eG3P5t2eNS9eG0reut10AeJjLv5lJ5lpM= github.com/snyk/error-catalog-golang-public v0.0.0-20251024131459-25bdd340f134 h1:IKwMDrwicB07NDS+VrI6I8qowqdDpKI0nBEvMnbSu+w= github.com/snyk/error-catalog-golang-public v0.0.0-20251024131459-25bdd340f134/go.mod h1:Ytttq7Pw4vOCu9NtRQaOeDU2dhBYUyNBe6kX4+nIIQ4= -github.com/snyk/go-application-framework v0.0.0-20251118111357-8c9e565ff018 h1:1NErKWe//TRxFzw/qG2kfyS+LQXyPncNRXArVxD52AQ= -github.com/snyk/go-application-framework v0.0.0-20251118111357-8c9e565ff018/go.mod h1:HXON5jD2A4GarLrQyUSLBGR7jJy7LfzzHmjdkLe3VCk= +github.com/snyk/go-application-framework v0.0.0-20251125151940-b1299180db56 h1:rkLe1KJVZ9/lPVCkHXm1AKKYZVNI2AAulHjDxoYFUqA= +github.com/snyk/go-application-framework v0.0.0-20251125151940-b1299180db56/go.mod h1:HXON5jD2A4GarLrQyUSLBGR7jJy7LfzzHmjdkLe3VCk= github.com/snyk/go-httpauth v0.0.0-20240307114523-1f5ea3f55c65 h1:CEQuYv0Go6MEyRCD3YjLYM2u3Oxkx8GpCpFBd4rUTUk= github.com/snyk/go-httpauth v0.0.0-20240307114523-1f5ea3f55c65/go.mod h1:88KbbvGYlmLgee4OcQ19yr0bNpXpOr2kciOthaSzCAg= github.com/snyk/policy-engine v1.1.0 h1:vFbFZbs3B0Y3XuGSur5om2meo4JEcCaKfNzshZFGOUs= diff --git a/cliv2/pkg/basic_workflows/legacycli.go b/cliv2/pkg/basic_workflows/legacycli.go index 16c2c48657..bbc2768ee5 100644 --- a/cliv2/pkg/basic_workflows/legacycli.go +++ b/cliv2/pkg/basic_workflows/legacycli.go @@ -64,7 +64,7 @@ func finalizeArguments(args []string, unknownArgs []string) []string { func legacycliWorkflow( invocation workflow.InvocationContext, - _ []workflow.Data, + input []workflow.Data, ) (output []workflow.Data, err error) { output = []workflow.Data{} var outBuffer bytes.Buffer @@ -120,6 +120,9 @@ func legacycliWorkflow( if !useStdIo { in := bytes.NewReader([]byte{}) + if len(input) > 0 { + in = bytes.NewReader([]byte(input[0].GetPayload().([]byte))) + } outWriter = bufio.NewWriter(&outBuffer) cli.SetIoStreams(in, outWriter, stderr) } else { diff --git a/src/cli/commands/format.ts b/src/cli/commands/format.ts new file mode 100644 index 0000000000..eeff9a247e --- /dev/null +++ b/src/cli/commands/format.ts @@ -0,0 +1,9 @@ +import { MethodArgs } from '../args'; +import { formatTestOutput } from './test/format'; +import { TestCommandResult } from './types'; + +export default async function format( + ...args: MethodArgs +): Promise { + return await formatTestOutput(...args); +} diff --git a/src/cli/commands/index.js b/src/cli/commands/index.js index 664bed43fc..c3fdcc7bfd 100644 --- a/src/cli/commands/index.js +++ b/src/cli/commands/index.js @@ -25,6 +25,7 @@ const commands = { woof: async (...args) => callModule(import('./woof'), args), log4shell: async (...args) => callModule(import('./log4shell'), args), apps: async (...args) => callModule(import('./apps'), args), + format: async (...args) => callModule(import('./format'), args), }; commands.aliases = abbrev(Object.keys(commands)); diff --git a/src/cli/commands/test/format.ts b/src/cli/commands/test/format.ts new file mode 100644 index 0000000000..b6364f459a --- /dev/null +++ b/src/cli/commands/test/format.ts @@ -0,0 +1,421 @@ +import * as Debug from 'debug'; +import { EOL } from 'os'; +import * as cloneDeep from 'lodash.clonedeep'; +const omit = require('lodash.omit'); +import * as assign from 'lodash.assign'; +import chalk from 'chalk'; +import { + MissingArgError, + UnsupportedOptionCombinationError, +} from '../../../lib/errors'; +import * as theme from '../../../lib/theme'; + +import * as snyk from '../../../lib'; +import { Options, TestOptions } from '../../../lib/types'; +import { MethodArgs } from '../../args'; +import { TestCommandResult } from '../../commands/types'; +import { LegacyVulnApiResult, TestResult } from '../../../lib/snyk-test/legacy'; + +import { + summariseErrorResults, + summariseVulnerableResults, +} from '../../../lib/formatters'; +import * as utils from './utils'; +import { getEcosystemForTest, testEcosystem } from '../../../lib/ecosystems'; +import { hasFixes, hasPatches, hasUpgrades } from '../../../lib/vuln-helpers'; +import { FailOn } from '../../../lib/snyk-test/common'; +import { + createErrorMappedResultsForJsonOutput, + extractDataToSendFromResults, +} from '../../../lib/formatters/test/format-test-results'; + +import { validateCredentials } from './validate-credentials'; +import { validateTestOptions } from './validate-test-options'; +import { setDefaultTestOptions } from './set-default-test-options'; +import { processCommandArgs } from '../process-command-args'; +import { formatTestError } from './format-test-error'; +import { displayResult } from '../../../lib/formatters/test/display-result'; +import * as analytics from '../../../lib/analytics'; + +import { + getPackageJsonPathsContainingSnykDependency, + getProtectUpgradeWarningForPaths, +} from '../../../lib/protect-update-notification'; +import { + containsSpotlightVulnIds, + notificationForSpotlightVulns, +} from '../../../lib/spotlight-vuln-notification'; +import iacTestCommand from './iac'; +import * as iacTestCommandV2 from './iac/v2'; +import { + hasFeatureFlag, + hasFeatureFlagOrDefault, +} from '../../../lib/feature-flags'; +import { + SCAN_USR_LIB_JARS_FEATURE_FLAG, + CONTAINER_CLI_APP_VULNS_ENABLED_FEATURE_FLAG, + INCLUDE_SYSTEM_JARS_OPTION, + EXCLUDE_APP_VULNS_OPTION, + APP_VULNS_OPTION, +} from '../constants'; +import { checkOSSPaths } from '../../../lib/check-paths'; + +const debug = Debug('snyk-test'); +const SEPARATOR = '\n-------------------------------------------------------\n'; + +const appVulnsReleaseWarningMsg = `${theme.icon.WARNING} Important: Beginning January 24th, 2023, application dependencies in container +images will be scanned by default when using the snyk container test/monitor +commands. If you are using Snyk in a CI pipeline, action may be required. Read +https://snyk.io/blog/securing-container-applications-using-the-snyk-cli/ for +more info.`; + +// TODO: avoid using `as any` whenever it's possible + +async function readStdin(): Promise { + return new Promise((resolve, reject) => { + let data = ''; + process.stdin.setEncoding('utf8'); + + process.stdin.on('data', (chunk) => { + data += chunk; + }); + + process.stdin.on('end', () => { + resolve(data); + }); + + process.stdin.on('error', (err) => { + reject(err); + }); + }); +} + +export async function formatTestOutput( + ...args: MethodArgs +): Promise { + const { options: originalOptions, paths } = processCommandArgs(...args); + + // Read JSON from stdin + const stdinData = await readStdin(); + + if (!stdinData.trim()) { + throw new Error('No input provided via stdin'); + } + + // Parse JSON + let parsedData: any; + try { + parsedData = JSON.parse(stdinData); + } catch (parseError) { + throw new Error(`Invalid JSON: ${parseError.message}`); + } + + // TODO: should hook into the display flow that starts with + // const mappedResults = createErrorMappedResultsForJsonOutput(results); + + // Validate structure and normalize to array + + const options = setDefaultTestOptions(originalOptions); + + if (originalOptions.iac) { + throw new UnsupportedOptionCombinationError(['test-delta', 'iac']); + } + + const packageJsonPathsWithSnykDepForProtect: string[] = + getPackageJsonPathsContainingSnykDependency(options.file, paths); + + analytics.add('snyk-delta', 'VALUE'); + + if (options.docker) { + // order is important here, we want: + // 1) exclude-app-vulns set -> no app vulns + // 2) app-vulns set -> app-vulns + // 3) neither set -> containerAppVulnsEnabled + if (options[EXCLUDE_APP_VULNS_OPTION]) { + options[EXCLUDE_APP_VULNS_OPTION] = true; + } else if (options[APP_VULNS_OPTION]) { + options[EXCLUDE_APP_VULNS_OPTION] = false; + } else { + options[EXCLUDE_APP_VULNS_OPTION] = !(await hasFeatureFlagOrDefault( + CONTAINER_CLI_APP_VULNS_ENABLED_FEATURE_FLAG, + options, + true, + )); + + // we can't print the warning message with JSON output as that would make + // the JSON output invalid. + // We also only want to print the message if the user did not overwrite + // the default with one of the flags. + if ( + options[EXCLUDE_APP_VULNS_OPTION] && + !options['json'] && + !options['sarif'] + ) { + console.log(theme.color.status.warn(appVulnsReleaseWarningMsg)); + } + } + + // Check scanUsrLibJars feature flag and add --include-system-jars parameter + const scanUsrLibJarsEnabled = await hasFeatureFlagOrDefault( + SCAN_USR_LIB_JARS_FEATURE_FLAG, + options, + false, + ); + if (scanUsrLibJarsEnabled) { + options[INCLUDE_SYSTEM_JARS_OPTION] = true; + } + } + + // CODE or CPP + // const ecosystem = getEcosystemForTest(options); + // if (ecosystem) { + // try { + // const commandResult = await testEcosystem(ecosystem, paths, options); + // return commandResult; + // } catch (error) { + // if (error instanceof Error) { + // throw error; + // } else { + // throw new Error(error); + // } + // } + // } + + const resultOptions: Array = []; + const results = [] as any[]; + + // Promise waterfall to test all other paths sequentially + for (const path of paths) { + // Create a copy of the options so a specific test can + // modify them i.e. add `options.file` etc. We'll need + // these options later. + const testOpts = cloneDeep(options); + testOpts.path = path; + testOpts.projectName = testOpts['project-name']; + + let res: (TestResult | TestResult[]) | Error; + res = Array.isArray(parsedData) ? parsedData : [parsedData]; + // try { + // res = await snyk.test(path, testOpts); + // } catch (error) { + // // not throwing here but instead returning error response + // // for legacy flow reasons. + // res = formatTestError(error); + // } + + // Not all test results are arrays in order to be backwards compatible + // with scripts that use a callback with test. Coerce results/errors to be arrays + // and add the result options to each to be displayed + const resArray: any[] = Array.isArray(res) ? res : [res]; + + for (let i = 0; i < resArray.length; i++) { + const pathWithOptionalProjectName = utils.getPathWithOptionalProjectName( + path, + resArray[i], + ); + results.push(assign(resArray[i], { path: pathWithOptionalProjectName })); + // currently testOpts are identical for each test result returned even if it's for multiple projects. + // we want to return the project names, so will need to be crafty in a way that makes sense. + if (!testOpts.projectNames) { + resultOptions.push(testOpts); + } else { + resultOptions.push( + assign(cloneDeep(testOpts), { + projectName: testOpts.projectNames[i], + }), + ); + } + } + } + + const vulnerableResults = results.filter( + (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; + const foundVulnerabilities = vulnerableResults.length > 0; + + // 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 mappedResults = createErrorMappedResultsForJsonOutput(results); + + const { + stdout: dataToSend, + stringifiedData, + stringifiedJsonData, + stringifiedSarifData, + } = extractDataToSendFromResults(results, mappedResults, options); + + const jsonPayload = stringifiedJsonData.length === 0 ? dataToSend : null; + + if (options.json || options.sarif) { + // if all results are ok (.ok == true) + if (mappedResults.every((res) => res.ok)) { + return TestCommandResult.createJsonTestCommandResult( + stringifiedData, + stringifiedJsonData, + stringifiedSarifData, + jsonPayload, + ); + } + + const err = new Error(stringifiedData) as any; + + if (foundVulnerabilities) { + if (options.failOn) { + const fail = shouldFail(vulnerableResults, options.failOn); + if (!fail) { + // return here to prevent failure + return TestCommandResult.createJsonTestCommandResult( + stringifiedData, + stringifiedJsonData, + stringifiedSarifData, + jsonPayload, + ); + } + } + err.code = 'VULNS'; + const dataToSendNoVulns = omit(dataToSend, 'vulnerabilities'); + err.jsonNoVulns = dataToSendNoVulns; + } + + if (notSuccess) { + // Take the code of the first problem to go through error + // translation. + // Note: this is done based on the logic done below + // for non-json/sarif outputs, where we take the code of + // the first error. + (err as any).code = (errorResults[0] as any).code; + } + err.json = stringifiedData; + err.jsonStringifiedResults = stringifiedJsonData; + err.sarifStringifiedResults = stringifiedSarifData; + // set jsonPayload if we failed to stringify it + if (jsonPayload) { + err.jsonPayload = jsonPayload; + } + throw err; + } + + let response = results + .map((result, i) => { + return displayResult( + results[i] as LegacyVulnApiResult, + resultOptions[i], + result.foundProjectCount, + ); + }) + .join(`\n${SEPARATOR}`); + + if (notSuccess) { + debug(`Failed to test ${errorResults.length} projects, errors:`); + errorResults.forEach((err) => { + const errString = err.stack ? err.stack.toString() : err.toString(); + debug('error: %s', errString); + }); + } + + let summaryMessage = ''; + const errorResultsLength = errorResults.length; + + if (results.length > 1) { + const projects = results.length === 1 ? 'project' : 'projects'; + summaryMessage = + `\n\n\nTested ${results.length} ${projects}` + + summariseVulnerableResults(vulnerableResults, options) + + summariseErrorResults(errorResultsLength) + + '\n'; + } + + if (notSuccess) { + response += chalk.bold.red(summaryMessage); + const error = new Error(response) as any; + // take the code of the first problem to go through error + // translation + // HACK as there can be different errors, and we pass only the + // first one + (error as any).code = (errorResults[0] as any).code; + (error as any).userMessage = (errorResults[0] as any).userMessage; + (error as any).strCode = (errorResults[0] as any).strCode; + (error as any).innerError = (errorResults[0] as any).innerError; + throw error; + } + + if (foundVulnerabilities) { + if (options.failOn) { + const fail = shouldFail(vulnerableResults, options.failOn); + if (!fail) { + // return here to prevent throwing failure + response += chalk.bold.green(summaryMessage); + response += EOL + EOL; + response += getProtectUpgradeWarningForPaths( + packageJsonPathsWithSnykDepForProtect, + ); + + return TestCommandResult.createHumanReadableTestCommandResult( + response, + stringifiedJsonData, + stringifiedSarifData, + jsonPayload, + ); + } + } + + response += chalk.bold.red(summaryMessage); + + response += EOL + EOL; + const foundSpotlightVulnIds = containsSpotlightVulnIds(results); + const spotlightVulnsMsg = notificationForSpotlightVulns( + foundSpotlightVulnIds, + ); + response += spotlightVulnsMsg; + + const error = new Error(response) as any; + // take the code of the first problem to go through error + // translation + // HACK as there can be different errors, and we pass only the + // first one + error.code = vulnerableResults[0].code || 'VULNS'; + error.userMessage = vulnerableResults[0].userMessage; + error.jsonStringifiedResults = stringifiedJsonData; + error.sarifStringifiedResults = stringifiedSarifData; + // conditionally set jsonPayload for now, to determine whether to stream data to destination + if (stringifiedJsonData.length === 0) { + error.jsonPayload = dataToSend; + } + throw error; + } + + response += chalk.bold.green(summaryMessage); + response += EOL + EOL; + response += getProtectUpgradeWarningForPaths( + packageJsonPathsWithSnykDepForProtect, + ); + + return TestCommandResult.createHumanReadableTestCommandResult( + response, + stringifiedJsonData, + stringifiedSarifData, + jsonPayload, + ); +} + +function shouldFail(vulnerableResults: any[], failOn: FailOn) { + // find reasons not to fail + if (failOn === 'all') { + return hasFixes(vulnerableResults); + } + if (failOn === 'upgradable') { + return hasUpgrades(vulnerableResults); + } + if (failOn === 'patchable') { + return hasPatches(vulnerableResults); + } + // should fail by default when there are vulnerable results + return vulnerableResults.length > 0; +} diff --git a/src/lib/types.ts b/src/lib/types.ts index a00961f412..4615dd5b74 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -299,6 +299,7 @@ export enum SupportedCliCommands { drift = 'drift', describe = 'describe', 'update-exclude-policy' = 'update-exclude-policy', + format = 'format', } export interface IacFileInDirectory {