From 9498bea3ec8cff1d0e5f41e8469169bb53ee9a0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Morcillo=20Mu=C3=B1oz?= Date: Mon, 17 Mar 2025 14:10:37 +0100 Subject: [PATCH] Refactor how supply chain generation --- src/__snapshots__/workflow.spec.ts.snap | 19 +----- src/template.ts | 86 +++++++++++++++++++++++++ src/workflow.spec.ts | 38 ++--------- src/workflow.ts | 83 ++++-------------------- 4 files changed, 103 insertions(+), 123 deletions(-) create mode 100644 src/template.ts diff --git a/src/__snapshots__/workflow.spec.ts.snap b/src/__snapshots__/workflow.spec.ts.snap index 4f17d57..281c250 100644 --- a/src/__snapshots__/workflow.spec.ts.snap +++ b/src/__snapshots__/workflow.spec.ts.snap @@ -377,24 +377,7 @@ jobs: " `; -exports[`Workflow > supports supply chain attack, with writing the lock file 1`] = ` -"# Workflow automatically generated by gat -# DO NOT CHANGE THIS FILE MANUALLY - -name: Supply chain attack -on: - push: null -jobs: - job1: - runs-on: ubuntu-22.04 - timeout-minutes: 15 - steps: - - name: Do something - uses: tj-actions/changed-files@a284dc1814e3fd07f2e34267fc8f81227ed29fb8 -" -`; - -exports[`Workflow > supports supply chain attack, without writing the lock file 1`] = ` +exports[`Workflow > supports supply chain attack 1`] = ` "# Workflow automatically generated by gat # DO NOT CHANGE THIS FILE MANUALLY diff --git a/src/template.ts b/src/template.ts new file mode 100644 index 0000000..80a2c60 --- /dev/null +++ b/src/template.ts @@ -0,0 +1,86 @@ +import fs from "fs"; +import { Octokit } from "@octokit/rest"; + +import { Workflow } from "./workflow"; + +interface CompileOptions { + templates: Record; + lockFilePath: string; + writeLockFile: boolean; +} + +const createLockFile = async ( + templates: Record, + lockFilePath: string, +) => { + const actions = Object.values(templates).reduce((acc, template) => { + template.jobs.forEach((job) => { + if ("steps" in job.options) { + job.options.steps.forEach((step) => { + if ("uses" in step) { + acc.push(step.uses); + } + }); + } + }); + + return acc; + }, []); + + const uniqueActions = [...new Set(actions)]; + + const octokit = process.env.GITHUB_TOKEN + ? new Octokit({ + auth: process.env.GITHUB_TOKEN, + }) + : new Octokit(); + + const resolvedActions: Record = {}; + + await Promise.all( + uniqueActions.map(async (action) => { + const match = action.match(/(?.*)@(?.*)/); + + if (!match) return; + + const { repository, version } = match.groups as { + repository: string; + version: string; + }; + + const [owner, repo] = repository.split("/"); + const response = await octokit.rest.repos.listTags({ + owner, + repo, + }); + + const tag = response.data.find((tag) => tag.name === version); + + if (!tag) { + throw new Error(`Unable to retrieve ${action} from Github tags`); + } + + resolvedActions[action] = `${repository}@${tag.commit.sha}`; + }), + ); + + fs.writeFileSync(lockFilePath, JSON.stringify(resolvedActions, null, 2)); +}; + +export const compileTemplates = async (options: CompileOptions) => { + const { templates, lockFilePath, writeLockFile } = options; + + if (writeLockFile) { + await createLockFile(templates, lockFilePath); + } + + const resolvedActions = fs.existsSync(lockFilePath) + ? JSON.parse(fs.readFileSync(lockFilePath, "utf8")) + : {}; + + return Promise.all( + Object.entries(templates).map(([filePath, template]) => + template.compile({ filePath, resolvedActions }), + ), + ); +}; diff --git a/src/workflow.spec.ts b/src/workflow.spec.ts index 7d188d3..e43df5d 100644 --- a/src/workflow.spec.ts +++ b/src/workflow.spec.ts @@ -1,7 +1,6 @@ -import { describe, it, expect } from "vitest"; +import { describe, expect, it } from "vitest"; import { RunStep, UseStep } from "./step"; import { Workflow } from "./workflow"; -import fs from "fs"; describe("Workflow", () => { it("generates a simple workflow", async () => { @@ -310,7 +309,7 @@ exit 0`, expect(await workflow.compile()).toMatchSnapshot(); }); - it("supports supply chain attack, without writing the lock file", async () => { + it("supports supply chain attack", async () => { const workflow = new Workflow("Supply chain attack"); workflow.on("push").addJob("job1", { @@ -319,39 +318,12 @@ exit 0`, ], }); - fs.writeFileSync( - "/tmp/lockfile1.json", - JSON.stringify( - { + expect( + await workflow.compile({ + resolvedActions: { "tj-actions/changed-files@v45.0.7": "tj-actions/changed-files@youhavebeenhacked", }, - null, - 2, - ), - ); - - expect( - await workflow.compile({ - lockFilePath: "/tmp/lockfile1.json", - writeLockFile: false, - }), - ).toMatchSnapshot(); - }); - - it("supports supply chain attack, with writing the lock file", async () => { - const workflow = new Workflow("Supply chain attack"); - - workflow.on("push").addJob("job1", { - steps: [ - { name: "Do something", uses: "tj-actions/changed-files@v45.0.7" }, - ], - }); - - expect( - await workflow.compile({ - lockFilePath: "/tmp/lockfile2.json", - writeLockFile: true, }), ).toMatchSnapshot(); }); diff --git a/src/workflow.ts b/src/workflow.ts index 6b568b6..4f71f9b 100644 --- a/src/workflow.ts +++ b/src/workflow.ts @@ -1,10 +1,10 @@ +import fs from "fs"; import { dump } from "js-yaml"; import kebabCase from "lodash/kebabCase"; -import fs from "fs"; import path from "path"; import { promisify } from "util"; -import { Octokit } from "@octokit/rest"; +import type { Event, EventName, EventOptions } from "./event"; import { ConcurrencyGroup, Job, @@ -12,7 +12,6 @@ import { StringWithNoSpaces, UsesJobOptions, } from "./job"; -import type { Event, EventName, EventOptions } from "./event"; import { type Step, isUseStep } from "./step"; const writeFilePromise = promisify(fs.writeFile); @@ -26,10 +25,9 @@ interface EnvVar { value: string; } -interface CompileOptions { +interface WorkflowCompileOptions { filePath?: string; - lockFilePath?: string; - writeLockFile?: boolean; + resolvedActions?: Record; } export type RunnerDefinition = @@ -37,77 +35,15 @@ export type RunnerDefinition = | { group: string; labels?: string[] } | ["self-hosted", string]; -let firstCompileCall = true; - const supplyChainAttack = async ( step: Step, - compileOptions: CompileOptions, + resolvedActions: Record, ) => { - const { lockFilePath, writeLockFile = false } = compileOptions; - if (!isUseStep(step)) return; - // The user is not interested in frozen sha versions - if (!lockFilePath) return step.uses; - const uses = step.uses; - const match = uses.match(/(?.*)@(?.*)/); - - // The uses is not a valid Github action with a tag - if (!match) return uses; - - const { repository, version } = match.groups as { - repository: string; - version: string; - }; - - if (firstCompileCall && writeLockFile && fs.existsSync(lockFilePath)) { - firstCompileCall = false; - - fs.rmSync(lockFilePath); - } - - const chainAttackCache = fs.existsSync(lockFilePath) - ? JSON.parse(fs.readFileSync(lockFilePath, "utf8")) - : {}; - - if (chainAttackCache[uses]) return chainAttackCache[uses]; - - if (!writeLockFile) { - throw new Error( - `Unable to retrieve ${uses} from lock file and writeLockFile is false`, - ); - } - - const [owner, repo] = repository.split("/"); - - const octokit = process.env.GITHUB_TOKEN - ? new Octokit({ - auth: process.env.GITHUB_TOKEN, - }) - : new Octokit(); - - const response = await octokit.rest.repos.listTags({ - owner, - repo, - }); - - const tag = response.data.find((tag) => tag.name === version); - - if (!tag) { - throw new Error(`Unable to retrieve ${uses} from Github tags`); - } - - const result = `${repository}@${tag.commit.sha}`; - - chainAttackCache[uses] = result; - - if (writeLockFile) { - fs.writeFileSync(lockFilePath, JSON.stringify(chainAttackCache, null, 2)); - } - - return result; + return resolvedActions[uses] ?? uses; }; export class Workflow< @@ -162,7 +98,7 @@ export class Workflow< return "ubuntu-22.04"; } - async compile(compileOptions: CompileOptions = {}) { + async compile(compileOptions: WorkflowCompileOptions = {}) { const result = { name: this.name, on: Object.fromEntries( @@ -284,7 +220,10 @@ export class Workflow< "working-directory": workingDirectory, "timeout-minutes": timeout, ...options, - uses: await supplyChainAttack(step, compileOptions), + uses: await supplyChainAttack( + step, + compileOptions.resolvedActions ?? {}, + ), }; }), ),