From dd5635096f28593082716c4cb7134e73ac31142d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ramon=20R=C3=BCttimann?= Date: Tue, 15 Aug 2023 11:21:25 +0200 Subject: [PATCH] chore: implement --print-graph for container (#4646) This commit adds support for the `--print-graph` argument that will print the dependency graph after a container scan. We need this feature in order to build the `snyk container sbom` command later on. The flag works the exact same way as the normal `snyk test --print-graph` does. See [LUM-283](https://snyksec.atlassian.net/browse/LUM-283). --- src/lib/ecosystems/test.ts | 16 ++-- src/lib/snyk-test/assemble-payloads.ts | 50 +++++++++- src/lib/snyk-test/common.ts | 15 +++ src/lib/snyk-test/run-test.ts | 9 +- .../snyk-container/container.spec.ts | 93 +++++++++++++++++++ 5 files changed, 167 insertions(+), 16 deletions(-) diff --git a/src/lib/ecosystems/test.ts b/src/lib/ecosystems/test.ts index dba9e3f3ed..c7f8b8e0c1 100644 --- a/src/lib/ecosystems/test.ts +++ b/src/lib/ecosystems/test.ts @@ -7,12 +7,14 @@ import { spinner } from '../../lib/spinner'; import { Ecosystem, ScanResult, TestResult } from './types'; import { getPlugin } from './plugins'; import { TestDependenciesResponse } from '../snyk-test/legacy'; -import { assembleQueryString } from '../snyk-test/common'; +import { + assembleQueryString, + depGraphToOutputString, +} from '../snyk-test/common'; import { getAuthHeader } from '../api-token'; import { resolveAndTestFacts } from './resolve-test-facts'; import { isUnmanagedEcosystem } from './common'; import { convertDepGraph, getUnmanagedDepGraph } from './unmanaged/utils'; -import { jsonStringifyLargeObject } from '../json'; type ScanResultsByPath = { [dir: string]: ScanResult[] }; @@ -92,13 +94,9 @@ export async function formatUnmanagedResults( const [result] = await getUnmanagedDepGraph(results); const depGraph = convertDepGraph(result); - const template = `DepGraph data: -${jsonStringifyLargeObject(depGraph)} -DepGraph target: -${target} -DepGraph end`; - - return TestCommandResult.createJsonTestCommandResult(template); + return TestCommandResult.createJsonTestCommandResult( + depGraphToOutputString(depGraph, target), + ); } async function testDependencies( diff --git a/src/lib/snyk-test/assemble-payloads.ts b/src/lib/snyk-test/assemble-payloads.ts index c42588ebc4..233b06be12 100644 --- a/src/lib/snyk-test/assemble-payloads.ts +++ b/src/lib/snyk-test/assemble-payloads.ts @@ -2,14 +2,15 @@ import * as path from 'path'; import config from '../config'; import { isCI } from '../is-ci'; import { getPlugin } from '../ecosystems'; -import { Ecosystem } from '../ecosystems/types'; +import { Ecosystem, ContainerTarget, ScanResult } from '../ecosystems/types'; import { Options, PolicyOptions, TestOptions } from '../types'; import { Payload } from './types'; -import { assembleQueryString } from './common'; +import { assembleQueryString, depGraphToOutputString } from './common'; import { spinner } from '../spinner'; import { findAndLoadPolicyForScanResult } from '../ecosystems/policy'; import { getAuthHeader } from '../../lib/api-token'; import { DockerImageNotFoundError } from '../errors'; +import { DepGraph } from '@snyk/dep-graph'; export async function assembleEcosystemPayloads( ecosystem: Ecosystem, @@ -52,6 +53,21 @@ export async function assembleEcosystemPayloads( scanResult.name = options['project-name'] || config.PROJECT_NAME || scanResult.name; + if (options['print-graph'] && !options['print-deps']) { + // not every scanResult has a 'depGraph' fact, for example the JAR + // fingerprints. I don't think we have another option than to skip + // those. + const dg = scanResult.facts.find((dg) => dg.type === 'depGraph'); + if (dg) { + console.log( + depGraphToOutputString( + dg.data.toJSON(), + constructProjectName(scanResult), + ), + ); + } + } + payloads.push({ method: 'POST', url: `${config.API}${options.testDepGraphDockerEndpoint || @@ -82,3 +98,33 @@ export async function assembleEcosystemPayloads( spinner.clear(spinnerLbl)(); } } + +// constructProjectName attempts to construct the project name the same way that +// registry does. This is a bit difficult because in Registry, the code is +// distributed over multiple functions and files that need to be kept in sync... +function constructProjectName(sr: ScanResult): string { + let suffix = ''; + if (sr.identity.targetFile) { + suffix = ':' + sr.identity.targetFile; + } + + if (sr.name) { + return sr.name + suffix; + } + + const targetImage = (sr.target as ContainerTarget | undefined)?.image; + if (targetImage) { + return targetImage + suffix; + } + + const dgFact = sr.facts.find((d) => d.type === 'depGraph'); + // not every scanResult has a depGraph, for example the JAR fingerprints. + if (dgFact) { + const name = (dgFact.data as DepGraph | undefined)?.rootPkg.name; + if (name) { + return name + suffix; + } + } + + return 'no-name' + suffix; +} diff --git a/src/lib/snyk-test/common.ts b/src/lib/snyk-test/common.ts index ac0230bffe..bd158b4812 100644 --- a/src/lib/snyk-test/common.ts +++ b/src/lib/snyk-test/common.ts @@ -1,5 +1,7 @@ import config from '../config'; import { color } from '../theme'; +import { DepGraphData } from '@snyk/dep-graph'; +import { jsonStringifyLargeObject } from '../json'; export function assembleQueryString(options) { const org = options.org || config.org || null; @@ -68,3 +70,16 @@ export type FailOn = 'all' | 'upgradable' | 'patchable'; export const RETRY_ATTEMPTS = 3; export const RETRY_DELAY = 500; + +// depGraphData formats the given depGrahData with the targetName as expected by +// the `depgraph` CLI workflow. +export function depGraphToOutputString( + dg: DepGraphData, + targetName: string, +): string { + return `DepGraph data: +${jsonStringifyLargeObject(dg)} +DepGraph target: +${targetName} +DepGraph end`; +} diff --git a/src/lib/snyk-test/run-test.ts b/src/lib/snyk-test/run-test.ts index 9748f9cb69..db2fd0d3cd 100644 --- a/src/lib/snyk-test/run-test.ts +++ b/src/lib/snyk-test/run-test.ts @@ -8,7 +8,6 @@ import { icon } from '../theme'; import { parsePackageString as moduleToObject } from 'snyk-module'; import * as depGraphLib from '@snyk/dep-graph'; import * as theme from '../../lib/theme'; -import { jsonStringifyLargeObject } from '../../lib/json'; import * as pMap from 'p-map'; import { @@ -747,7 +746,7 @@ async function assembleLocalPayloads( // print dep graph if `--print-graph` is set if (options['print-graph'] && !options['print-deps']) { - await spinner.clear(spinnerLbl)(); + spinner.clear(spinnerLbl)(); let root: depGraphLib.DepGraph; if (scannedProject.depGraph) { root = pkg as depGraphLib.DepGraph; @@ -759,9 +758,9 @@ async function assembleLocalPayloads( ); } - console.log('DepGraph data:'); - console.log(jsonStringifyLargeObject(root.toJSON())); - console.log('DepGraph target:\n' + targetFile + '\nDepGraph end'); + console.log( + common.depGraphToOutputString(root.toJSON(), targetFile || ''), + ); } const body: PayloadBody = { diff --git a/test/jest/acceptance/snyk-container/container.spec.ts b/test/jest/acceptance/snyk-container/container.spec.ts index 4ea1f30e5f..24f0adbfdc 100644 --- a/test/jest/acceptance/snyk-container/container.spec.ts +++ b/test/jest/acceptance/snyk-container/container.spec.ts @@ -1,5 +1,6 @@ import * as os from 'os'; import { startSnykCLI, TestCLI } from '../../util/startSnykCLI'; +import { runSnykCLI } from '../../util/runSnykCLI'; jest.setTimeout(1000 * 60); @@ -37,4 +38,96 @@ describe('snyk container', () => { timeout: 60 * 1000, }); }); + it('prints dep graph with --print-graph flag', async () => { + const { code, stdout } = await runSnykCLI( + 'container test --print-graph gcr.io/distroless/static@sha256:7198a357ff3a8ef750b041324873960cf2153c11cc50abb9d8d5f8bb089f6b4e', + ); + + expect(code).toBe(0); + expect(stdout).toContain('DepGraph data:'); + expect(stdout).toContain( + `DepGraph target: +docker-image|gcr.io/distroless/static +DepGraph end`, + ); + const jsonDGStr = stdout + .split('DepGraph data:')[1] + .split('DepGraph target:')[0]; + const jsonDG = JSON.parse(jsonDGStr); + expect(jsonDG).toMatchObject({ + schemaVersion: '1.3.0', + pkgManager: { + name: 'deb', + repositories: [ + { + alias: 'debian:11', + }, + ], + }, + pkgs: [ + { + id: 'docker-image|gcr.io/distroless/static@', + info: { + name: 'docker-image|gcr.io/distroless/static', + }, + }, + { + id: 'base-files@11.1+deb11u7', + info: { + name: 'base-files', + version: '11.1+deb11u7', + }, + }, + { + id: 'netbase@6.3', + info: { + name: 'netbase', + version: '6.3', + }, + }, + { + id: 'tzdata@2021a-1+deb11u10', + info: { + name: 'tzdata', + version: '2021a-1+deb11u10', + }, + }, + ], + graph: { + rootNodeId: 'root-node', + nodes: [ + { + nodeId: 'root-node', + pkgId: 'docker-image|gcr.io/distroless/static@', + deps: [ + { + nodeId: 'base-files@11.1+deb11u7', + }, + { + nodeId: 'netbase@6.3', + }, + { + nodeId: 'tzdata@2021a-1+deb11u10', + }, + ], + }, + { + nodeId: 'base-files@11.1+deb11u7', + pkgId: 'base-files@11.1+deb11u7', + deps: [], + }, + { + nodeId: 'netbase@6.3', + pkgId: 'netbase@6.3', + deps: [], + }, + { + nodeId: 'tzdata@2021a-1+deb11u10', + pkgId: 'tzdata@2021a-1+deb11u10', + deps: [], + }, + ], + }, + }); + }); });