-
Notifications
You must be signed in to change notification settings - Fork 30
/
SyftGithubAction.ts
612 lines (518 loc) · 17.1 KB
/
SyftGithubAction.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
import * as core from "@actions/core";
import * as github from "@actions/github";
import * as cache from "@actions/tool-cache";
import {
PullRequestEvent,
Release,
ReleaseEvent,
} from "@octokit/webhooks-types";
import * as fs from "fs";
import os from "os";
import path from "path";
import stream from "stream";
import { SyftOptions } from "../Syft";
import { VERSION } from "../SyftVersion";
import { execute } from "./Executor";
import {
DependencySnapshot,
dashWrap,
debugLog,
getClient,
} from "./GithubClient";
import { downloadSyftFromZip } from "./SyftDownloader";
import { stringify } from "./Util";
export const SYFT_BINARY_NAME = "syft";
export const SYFT_VERSION = core.getInput("syft-version") || VERSION;
const PRIOR_ARTIFACT_ENV_VAR = "ANCHORE_SBOM_ACTION_PRIOR_ARTIFACT";
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "sbom-action-"));
const githubDependencySnapshotFile = `${tempDir}/github.sbom.json`;
const exeSuffix = process.platform == "win32" ? ".exe" : "";
/**
* Tries to get a unique artifact name or otherwise as appropriate as possible
*/
export function getArtifactName(): string {
const fileName = getArtifactNameInput();
// if there is an explicit filename just return it, this could cause issues
// where earlier sboms are overwritten by later ones
if (fileName) {
return fileName;
}
const format = getSbomFormat();
let extension: string = format;
switch (format) {
case "spdx":
case "spdx-tag-value":
extension = "spdx";
break;
case "spdx-json":
extension = "spdx.json";
break;
case "cyclonedx":
case "cyclonedx-xml":
extension = "cyclonedx.xml";
break;
case "cyclonedx-json":
extension = "cyclonedx.json";
break;
case "json":
extension = "syft.json";
break;
}
const imageName = core.getInput("image");
if (imageName) {
const parts = imageName.split("/");
// remove the hostname
if (parts.length > 2) {
parts.splice(0, 1);
}
const prefix = parts.join("-").replace(/[^-a-zA-Z0-9]/g, "_");
return `${prefix}.${extension}`;
}
const {
repo: { repo },
job,
action,
} = github.context;
// when run without an id, we get various auto-generated names, like:
// __self __self_2 __anchore_sbom-action __anchore_sbom-action_2 etc.
// so just keep the number at the end if there is one, otherwise
// this will not match an id unless for some reason it starts with __
let stepName = action.replace(/__[-_a-z]+/, "");
if (stepName) {
stepName = `-${stepName}`;
}
return `${repo}-${job}${stepName}.${extension}`;
}
/**
* Returns the artifact-name input value
*/
function getArtifactNameInput() {
return core.getInput("artifact-name");
}
/**
* Gets a reference to the syft command and executes the syft action
* @param input syft input parameters
* @param format syft output format
* @param opts additional options
*/
async function executeSyft({
input,
format,
...opts
}: SyftOptions): Promise<string> {
let stdout = "";
const cmd = await getSyftCommand();
const env: { [key: string]: string } = {
...process.env,
SYFT_CHECK_FOR_APP_UPDATE: "false",
};
const registryUser = core.getInput("registry-username");
const registryPass = core.getInput("registry-password");
if (registryUser) {
env.SYFT_REGISTRY_AUTH_USERNAME = registryUser;
if (registryPass) {
env.SYFT_REGISTRY_AUTH_PASSWORD = registryPass;
} else {
core.warning(
"WARNING: registry-username specified without registry-password"
);
}
}
// https://github.com/anchore/syft#configuration
let args = ["scan"];
if (core.isDebug()) {
args = [...args, "-vv"];
}
if ("image" in input && input.image) {
if (registryUser) {
args = [...args, `registry:${input.image}`];
} else {
args = [...args, `${input.image}`];
}
} else if ("file" in input && input.file) {
args = [...args, `file:${input.file}`];
} else if ("path" in input && input.path) {
args = [...args, `dir:${input.path}`];
} else {
throw new Error("Invalid input, no image or path specified");
}
args = [...args, "-o", format];
if (opts.uploadToDependencySnapshotAPI) {
// generate github dependency format
args = [...args, "-o", `github=${githubDependencySnapshotFile}`];
}
if (opts.configFile) {
args = [...args, "-c", opts.configFile];
}
// Execute in a group so the syft output is collapsed in the GitHub log
core.info(`[command]${cmd} ${args.join(" ")}`);
// This /dev/null writable stream is required so the entire contents
// of the SBOM is not written to the GitHub action log. the listener below
// will actually capture the output
const outStream = new stream.Writable({
write(buffer, encoding, next) {
next();
},
});
const exitCode = await core.group("Executing Syft...", async () =>
execute(cmd, args, {
env,
outStream,
listeners: {
stdout(buffer) {
stdout += buffer.toString();
},
stderr(buffer) {
core.info(buffer.toString());
},
debug(message) {
core.debug(message);
},
},
})
);
if (exitCode > 0) {
debugLog("Syft stdout:", stdout);
throw new Error("An error occurred running Syft");
} else {
return stdout;
}
}
function isWindows(): boolean {
return process.platform == "win32";
}
async function downloadSyftWindowsWorkaround(version: string): Promise<string> {
const versionNoV = version.replace(/^v/, "");
const url = `https://github.com/anchore/syft/releases/download/${version}/syft_${versionNoV}_windows_amd64.zip`;
core.info(`Downloading syft from ${url}`);
const zipPath = await cache.downloadTool(url);
const toolDir = await cache.extractZip(zipPath);
return path.join(toolDir, `${SYFT_BINARY_NAME}${exeSuffix}`);
}
/**
* Downloads the appropriate Syft binary for the platform
*/
export async function downloadSyft(): Promise<string> {
const name = SYFT_BINARY_NAME;
const version = SYFT_VERSION;
if (isWindows()) {
return downloadSyftWindowsWorkaround(version);
}
const url = `https://raw.githubusercontent.com/anchore/${name}/main/install.sh`;
core.debug(`Installing ${name} ${version}`);
// Download the installer, and run
const installPath = await cache.downloadTool(url);
const syftBinaryPath = `${installPath}_${name}`;
await execute("sh", [installPath, "-d", "-b", syftBinaryPath, version]);
return path.join(syftBinaryPath, name) + exeSuffix;
}
/**
* Gets the Syft command to run via exec
*/
export async function getSyftCommand(): Promise<string> {
const name = SYFT_BINARY_NAME + exeSuffix;
const version = SYFT_VERSION;
const sourceSyft = await downloadSyftFromZip(version);
if (sourceSyft) {
core.info(`Using sourceSyft: '${sourceSyft}'`);
return sourceSyft;
}
let syftPath = cache.find(name, version);
if (!syftPath) {
// Not found; download and install it; returns a path to the binary
syftPath = await downloadSyft();
// Cache the downloaded file
syftPath = await cache.cacheFile(syftPath, name, name, version);
}
core.debug(`Got Syft path: ${syftPath} binary at: ${syftPath}/${name}`);
// Add tool to path for this and future actions to use
core.addPath(syftPath);
return `${syftPath}/${name}`;
}
/**
* Returns the SBOM format as specified by the user, defaults to SPDX
*/
export function getSbomFormat(): SyftOptions["format"] {
return (core.getInput("format") as SyftOptions["format"]) || "spdx-json";
}
/**
* Returns the SHA of the current commit, which will either be the head
* of the pull request branch or the value of github.context.sha, depending
* on the event type.
*/
export function getSha(): string {
const pull_request_events = [
"pull_request",
"pull_request_comment",
"pull_request_review",
"pull_request_review_comment",
// Note that pull_request_target is omitted here.
// That event runs in the context of the base commit of the PR,
// so the snapshot should not be associated with the head commit.
];
if (pull_request_events.includes(github.context.eventName)) {
const pr = (github.context.payload as PullRequestEvent).pull_request;
return pr.head.sha;
} else {
return github.context.sha;
}
}
/**
* Uploads a SBOM as a workflow artifact
* @param contents SBOM file contents
*/
export async function uploadSbomArtifact(contents: string): Promise<void> {
const { repo } = github.context;
const client = getClient(repo, core.getInput("github-token"));
const fileName = getArtifactName();
const filePath = `${tempDir}/${fileName}`;
fs.writeFileSync(filePath, contents);
const retentionDays = parseInt(core.getInput("upload-artifact-retention"));
core.info(dashWrap("Uploading workflow artifacts"));
core.info(filePath);
await client.uploadWorkflowArtifact({
file: filePath,
name: fileName,
retention: retentionDays,
});
}
/**
* Gets a boolean input value if supplied, otherwise returns the default
* @param name name of the input
* @param defaultValue default value to return if not set
*/
function getBooleanInput(name: string, defaultValue: boolean): boolean {
const val = core.getInput(name);
if (val === undefined || val === "") {
return defaultValue;
}
return val.toLowerCase() === "true";
}
/**
* Optionally fetches the target SBOM in order to provide some information
* on changes
*/
async function comparePullRequestTargetArtifact(): Promise<void> {
const doCompare = getBooleanInput("compare-pulls", false);
const { eventName, payload, repo } = github.context;
if (doCompare && eventName === "pull_request") {
const client = getClient(repo, core.getInput("github-token"));
const pr = (payload as PullRequestEvent).pull_request;
const branchWorkflow = await client.findLatestWorkflowRunForBranch({
branch: pr.base.ref,
});
debugLog("Got branchWorkflow:", branchWorkflow);
if (branchWorkflow) {
const baseBranchArtifacts = await client.listWorkflowRunArtifacts({
runId: branchWorkflow.id,
});
debugLog("Got baseBranchArtifacts:", baseBranchArtifacts);
for (const artifact of baseBranchArtifacts) {
if (artifact.name === getArtifactName()) {
const baseArtifact = await client.downloadWorkflowRunArtifact({
artifactId: artifact.id,
});
core.info(
`Downloaded SBOM from ref '${pr.base.ref}' to ${baseArtifact}`
);
}
}
}
}
}
function uploadToSnapshotAPI() {
return getBooleanInput("dependency-snapshot", false);
}
export async function runSyftAction(): Promise<void> {
core.info(dashWrap("Running SBOM Action"));
debugLog(`Got github context:`, github.context);
const start = Date.now();
const doUpload = getBooleanInput("upload-artifact", true);
const output = await executeSyft({
input: {
path: core.getInput("path"),
file: core.getInput("file"),
image: core.getInput("image"),
},
format: getSbomFormat(),
uploadToDependencySnapshotAPI: uploadToSnapshotAPI(),
configFile: core.getInput("config"),
});
core.info(`SBOM scan completed in: ${(Date.now() - start) / 1000}s`);
if (output) {
await comparePullRequestTargetArtifact();
// We may want to develop a supply chain during the build, this is one
// potential way to do so:
const priorArtifact = process.env[PRIOR_ARTIFACT_ENV_VAR];
if (priorArtifact) {
core.debug(`Prior artifact: ${priorArtifact}`);
}
const outputFile = core.getInput("output-file");
if (outputFile) {
fs.writeFileSync(outputFile, output);
}
if (doUpload) {
await uploadSbomArtifact(output);
core.exportVariable(PRIOR_ARTIFACT_ENV_VAR, getArtifactName());
}
} else {
throw new Error(`No Syft output`);
}
}
/**
* Attaches the SBOM assets to a release if run in release mode
*/
export async function uploadDependencySnapshot(): Promise<void> {
if (!uploadToSnapshotAPI()) {
return;
}
if (!fs.existsSync(githubDependencySnapshotFile)) {
core.warning(
`No dependency snapshot found at '${githubDependencySnapshotFile}'`
);
return;
}
const { workflow, job, runId, repo, ref } = github.context;
const sha = getSha();
const client = getClient(repo, core.getInput("github-token"));
const snapshot = JSON.parse(
fs.readFileSync(githubDependencySnapshotFile).toString("utf8")
) as DependencySnapshot;
let correlator = `${workflow}_${job}`;
// if running in a matrix build, it is not possible to determine a unique value,
// so a user must explicitly specify the artifact-name input, there isn't any
// other indicator of being run within a matrix build, so we must use that
// here in order to properly correlate dependency snapshots
const artifactInput = getArtifactNameInput();
if (artifactInput) {
correlator += `_${artifactInput}`;
}
// Need to add the job and repo details
snapshot.job = {
correlator: core.getInput("dependency-snapshot-correlator") || correlator,
id: `${runId}`,
};
snapshot.sha = sha;
snapshot.ref = ref;
core.info(
`Uploading GitHub dependency snapshot from ${githubDependencySnapshotFile}`
);
debugLog("Snapshot:", snapshot);
await client.postDependencySnapshot(snapshot);
}
/**
* Attaches the SBOM assets to a release if run in release mode
*/
export async function attachReleaseAssets(): Promise<void> {
const doRelease = getBooleanInput("upload-release-assets", true);
if (!doRelease) {
return;
}
debugLog("Got github context:", github.context);
const { eventName, ref, payload, repo } = github.context;
const client = getClient(repo, core.getInput("github-token"));
let release: Release | undefined = undefined;
// Try to detect a release
if (eventName === "release") {
// Obviously if this is run during a release
release = (payload as ReleaseEvent).release;
debugLog("Got releaseEvent:", release);
} else {
// We may have a tag-based workflow that creates releases or even drafts
const releaseRefPrefix =
core.getInput("release-ref-prefix") || "refs/tags/";
const isRefPush = eventName === "push" && ref.startsWith(releaseRefPrefix);
if (isRefPush) {
const tag = ref.substring(releaseRefPrefix.length);
release = await client.findRelease({ tag });
debugLog("Found release for ref push:", release);
}
}
if (release) {
// ^sbom.*\\.${format}$`;
const sbomArtifactInput = core.getInput("sbom-artifact-match");
const sbomArtifactPattern = sbomArtifactInput || `^${getArtifactName()}$`;
const matcher = new RegExp(sbomArtifactPattern);
const artifacts = await client.listCurrentWorkflowArtifacts();
let matched = artifacts.filter((a) => {
const matches = matcher.test(a.name);
if (matches) {
core.debug(`Found artifact: ${a.name}`);
} else {
core.debug(`Artifact: ${a.name} not matching ${sbomArtifactPattern}`);
}
return matches;
});
// We may have a release run based on a prior build from another workflow
if (eventName === "release" && !matched.length) {
core.info(
"No artifacts found in this workflow. Searching for release artifacts from prior workflow..."
);
const latestRun = await client.findLatestWorkflowRunForBranch({
branch: release.target_commitish,
});
debugLog("Got latest run for prior workflow", latestRun);
if (latestRun) {
const runArtifacts = await client.listWorkflowRunArtifacts({
runId: latestRun.id,
});
matched = runArtifacts.filter((a) => {
const matches = matcher.test(a.name);
if (matches) {
core.debug(`Found run artifact: ${a.name}`);
} else {
core.debug(
`Run artifact: ${a.name} not matching ${sbomArtifactPattern}`
);
}
return matches;
});
}
}
if (!matched.length && sbomArtifactInput) {
core.warning(`WARNING: no SBOMs found matching ${sbomArtifactInput}`);
return;
}
core.info(dashWrap(`Attaching SBOMs to release: '${release.tag_name}'`));
for (const artifact of matched) {
const file = await client.downloadWorkflowArtifact(artifact);
core.info(file);
const contents = fs.readFileSync(file);
const assetName = path.basename(file);
const assets = await client.listReleaseAssets({
release,
});
const asset = assets.find((a) => a.name === assetName);
if (asset) {
await client.deleteReleaseAsset({
release,
asset,
});
}
await client.uploadReleaseAsset({
release,
assetName,
contents: contents.toString(),
contentType: "text/plain",
});
}
}
}
/**
* Executes the provided callback and wraps any exceptions in a build failure
*/
export async function runAndFailBuildOnException<T>(
fn: () => Promise<T>
): Promise<T | void> {
try {
return await fn();
} catch (e) {
if (e instanceof Error) {
core.setFailed(e.message);
} else if (e instanceof Object) {
core.setFailed(`Action failed: ${stringify(e)}`);
} else {
core.setFailed(`An unknown error occurred: ${stringify(e)}`);
}
}
}