1 | import chalk from "chalk";
|
2 | import * as os from "os";
|
3 |
|
4 | import { createFromJSON, DepGraph } from "@snyk/dep-graph";
|
5 | import {
|
6 | BaseImageRemediationAdvice,
|
7 | ContainerTarget,
|
8 | Issue,
|
9 | Options,
|
10 | ScanResult,
|
11 | TestResult,
|
12 | } from "./types";
|
13 |
|
14 | const BREAK_LINE = os.EOL;
|
15 | const SECTION_PADDING_TO_FORMAT_METADATA = 19;
|
16 |
|
17 | export async function display(
|
18 | scanResults: ScanResult[],
|
19 | testResults: TestResult[],
|
20 | errors: string[],
|
21 | options?: Options,
|
22 | ): Promise<string> {
|
23 | const result: string[] = [];
|
24 | let index = 0;
|
25 | for (const testResult of testResults) {
|
26 | const formattedIssue: string[] = [];
|
27 | for (const issue of testResult.issues) {
|
28 | formattedIssue.push(formatIssue(testResult, issue));
|
29 | }
|
30 | result.push(formattedIssue.join(BREAK_LINE));
|
31 | result.push(includeSectionSeparator());
|
32 |
|
33 | const scanResult: ScanResult = scanResults[index];
|
34 | const metadata = formatMetadataSection(scanResult, testResult);
|
35 | result.push(metadata);
|
36 | result.push(includeSectionSeparator());
|
37 |
|
38 | const summary = formatSummary(testResult);
|
39 | result.push(summary);
|
40 | result.push(includeSectionSeparator());
|
41 |
|
42 | const remediations = formatRemediations(testResult);
|
43 | if (remediations) {
|
44 | result.push(remediations);
|
45 | result.push(includeSectionSeparator());
|
46 | }
|
47 |
|
48 | const suggestions = formatSuggestions(options);
|
49 | if (suggestions) {
|
50 | result.push(suggestions);
|
51 | result.push(includeSectionSeparator());
|
52 | }
|
53 |
|
54 | const userCTA = formatUserCTA(options);
|
55 | if (userCTA) {
|
56 | result.push(userCTA);
|
57 | }
|
58 |
|
59 | index += 1;
|
60 | }
|
61 |
|
62 | return result.join(BREAK_LINE);
|
63 | }
|
64 |
|
65 | function includeSectionSeparator(): string {
|
66 | return BREAK_LINE;
|
67 | }
|
68 |
|
69 | function formatIssue(testResult: TestResult, issue: Issue): string {
|
70 | const result: string[] = [];
|
71 | const issueData = testResult.issuesData[issue.issueId];
|
72 | const severity = capitalize(issueData.severity);
|
73 | const pkg = issue.pkgName;
|
74 | const color = getColor(issueData.severity);
|
75 |
|
76 | const header = color(`✗ ${severity} severity vulnerability found in ${pkg}`);
|
77 | const description = ` Description: ${issueData.title}`;
|
78 | const info = ` Info: https://snyk.io/vuln/${issue.issueId}`;
|
79 | const introduced = ` Introduced through: ${formatIntroduced(
|
80 | issueData.from,
|
81 | )}`;
|
82 | const from = formatFrom(issueData.from);
|
83 | const fixedIn = formatFixedIn(issue);
|
84 |
|
85 | result.push(header);
|
86 | result.push(description);
|
87 | result.push(info);
|
88 | result.push(introduced);
|
89 | result.push(from);
|
90 | if (fixedIn) {
|
91 | result.push(fixedIn);
|
92 | }
|
93 | result.push("");
|
94 |
|
95 | return result.join(BREAK_LINE);
|
96 | }
|
97 |
|
98 | function capitalize(word: string): string {
|
99 | return word[0].toUpperCase() + word.slice(1);
|
100 | }
|
101 |
|
102 | function formatIntroduced(fromList: string[][]) {
|
103 | const result: string[] = [];
|
104 |
|
105 | for (const from of fromList) {
|
106 | result.push(from[0]);
|
107 | }
|
108 |
|
109 | return result.join(", ");
|
110 | }
|
111 | function formatFrom(fromList: string[][]): string {
|
112 | const result: string[] = [];
|
113 | let counter = 0;
|
114 | const max = 3;
|
115 | for (const localFrom of fromList) {
|
116 | if (counter >= max) {
|
117 | break;
|
118 | }
|
119 | counter += 1;
|
120 |
|
121 | result.push(` From: ${localFrom.join(" > ")}`);
|
122 | }
|
123 | if (fromList.length > max) {
|
124 | result.push(` and ${(fromList.length = max)} more...`);
|
125 | }
|
126 |
|
127 | return result.join(BREAK_LINE);
|
128 | }
|
129 |
|
130 | function formatFixedIn(issue: Issue): string | undefined {
|
131 | if (!issue.fixInfo || !issue.fixInfo.nearestFixedInVersion) {
|
132 | return undefined;
|
133 | }
|
134 |
|
135 | return chalk.bold.green(` Fixed in: ${issue.fixInfo.nearestFixedInVersion}`);
|
136 | }
|
137 |
|
138 | function formatMetadataSection(
|
139 | scanResult: ScanResult,
|
140 | testResult: TestResult,
|
141 | ): string {
|
142 | const result: string[] = [];
|
143 | result.push(formatMetadataLine("Organization:", testResult.org));
|
144 |
|
145 | const packageManager = scanResult.identity.type;
|
146 | result.push(formatMetadataLine("Package manager:", packageManager));
|
147 |
|
148 | const target: ContainerTarget = scanResult.target as ContainerTarget;
|
149 | const projectName = target.image;
|
150 | const image = target.image.replace("docker-image|", "");
|
151 | result.push(formatMetadataLine("Project name:", projectName));
|
152 | result.push(formatMetadataLine("Docker image:", image));
|
153 | if (testResult.docker && testResult.docker.baseImage) {
|
154 | result.push(formatMetadataLine("Base image:", testResult.docker.baseImage));
|
155 | }
|
156 | if (testResult.licensesPolicy) {
|
157 | result.push(formatMetadataLine("Licenses:", chalk.green("enabled")));
|
158 | }
|
159 | const platform = scanResult.identity.args?.platform;
|
160 | if (platform) {
|
161 | result.push(formatMetadataLine("Platform:", platform));
|
162 | }
|
163 |
|
164 | return result.join(BREAK_LINE);
|
165 | }
|
166 |
|
167 | function formatMetadataLine(header: string, info: string = ""): string {
|
168 | return `${chalk.green(
|
169 | padding(header, SECTION_PADDING_TO_FORMAT_METADATA),
|
170 | )} ${info}`;
|
171 | }
|
172 |
|
173 | function formatSummary(testResult: TestResult): string {
|
174 | const depGraph: DepGraph = createFromJSON(testResult.depGraphData);
|
175 | const pkgCount = depGraph?.getDepPkgs()?.length || 0;
|
176 | const pathOrDepsText = `${pkgCount} dependencies`;
|
177 | const testedInfoText = `Tested ${pathOrDepsText} for known issues`;
|
178 | const vulnPathsText = formatVulnSummaryText(testResult.issues);
|
179 | let summaryText = `${testedInfoText}, ${vulnPathsText}`;
|
180 | if (testResult.issues.length === 0) {
|
181 | summaryText = chalk.green(`✓ ${summaryText}`);
|
182 | }
|
183 |
|
184 | return summaryText;
|
185 | }
|
186 |
|
187 | function formatVulnSummaryText(issues: Issue[]): string {
|
188 | if (issues.length > 0) {
|
189 | return chalk.bold.red(`found ${issues.length} issues.`);
|
190 | }
|
191 |
|
192 | return "no vulnerable paths found.";
|
193 | }
|
194 |
|
195 | function getColor(severity: string): (text: string) => string {
|
196 | let color: (text: string) => string;
|
197 | switch (severity) {
|
198 | case "low":
|
199 | color = chalk.bold.blue;
|
200 | break;
|
201 | case "medium":
|
202 | color = chalk.bold.yellow;
|
203 | break;
|
204 | case "high":
|
205 | color = chalk.bold.red;
|
206 | break;
|
207 | default:
|
208 | color = chalk.whiteBright;
|
209 | break;
|
210 | }
|
211 |
|
212 | return color;
|
213 | }
|
214 |
|
215 | export function formatRemediations(res: TestResult) {
|
216 | if (!res.docker || !res.docker.baseImageRemediation) {
|
217 | return "";
|
218 | }
|
219 | const { advice, message } = res.docker.baseImageRemediation;
|
220 | const out = [] as any[];
|
221 |
|
222 | if (advice) {
|
223 | for (const item of advice) {
|
224 | out.push(formatString(item)(item.message));
|
225 | }
|
226 | } else if (message) {
|
227 | out.push(message);
|
228 | } else {
|
229 | return "";
|
230 | }
|
231 | return `${out.join(BREAK_LINE)}`;
|
232 | }
|
233 |
|
234 | function formatString({ color, bold }: BaseImageRemediationAdvice) {
|
235 | let formatter = chalk;
|
236 | if (color && formatter[color]) {
|
237 | formatter = formatter[color];
|
238 | }
|
239 | if (bold) {
|
240 | formatter = formatter.bold;
|
241 | }
|
242 | return formatter;
|
243 | }
|
244 |
|
245 | function formatSuggestions(options): string {
|
246 | if (options.isDockerUser) {
|
247 | return "";
|
248 | }
|
249 |
|
250 | const dockerSuggestion: string[] = [];
|
251 | if (options.config && options.config.disableSuggestions !== "true") {
|
252 | const optOutSuggestions =
|
253 | "To remove this message in the future, please run `snyk config set disableSuggestions=true`";
|
254 | if (!options.file) {
|
255 | dockerSuggestion.push(
|
256 | chalk.bold.white(
|
257 | "Pro tip: use `--file` option to get base image remediation advice.",
|
258 | ),
|
259 | );
|
260 | dockerSuggestion.push(
|
261 | chalk.bold.white(
|
262 | `Example: $ snyk container test ${options.path} --file=path/to/Dockerfile`,
|
263 | ),
|
264 | );
|
265 | dockerSuggestion.push(BREAK_LINE);
|
266 | dockerSuggestion.push(optOutSuggestions);
|
267 | } else if (!options["exclude-base-image-vulns"]) {
|
268 | dockerSuggestion.push(
|
269 | chalk.bold.white(
|
270 | "Pro tip: use `--exclude-base-image-vulns` to exclude from display Docker base image vulnerabilities.",
|
271 | ),
|
272 | );
|
273 | dockerSuggestion.push(BREAK_LINE);
|
274 | dockerSuggestion.push(optOutSuggestions);
|
275 | }
|
276 | }
|
277 | return dockerSuggestion.join(BREAK_LINE);
|
278 | }
|
279 |
|
280 | function formatUserCTA(options): string {
|
281 | if (options.isDockerUser) {
|
282 | return "For more free scans that keep your images secure, sign up to Snyk at https://dockr.ly/3ePqVcp";
|
283 | }
|
284 | return "";
|
285 | }
|
286 |
|
287 | function padding(s: string, padding: number) {
|
288 | const padLength = padding - s.length;
|
289 | if (padLength <= 0) {
|
290 | return s;
|
291 | }
|
292 |
|
293 | return s + " ".repeat(padLength);
|
294 | }
|