Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Refactor how supply chain generation #9

Merged
merged 1 commit into from
Mar 17, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 1 addition & 18 deletions src/__snapshots__/workflow.spec.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
86 changes: 86 additions & 0 deletions src/template.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import fs from "fs";
import { Octokit } from "@octokit/rest";

import { Workflow } from "./workflow";

interface CompileOptions {
templates: Record<string, Workflow>;
lockFilePath: string;
writeLockFile: boolean;
}

const createLockFile = async (
templates: Record<string, Workflow>,
lockFilePath: string,
) => {
const actions = Object.values(templates).reduce<string[]>((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<string, string> = {};

await Promise.all(
uniqueActions.map(async (action) => {
const match = action.match(/(?<repository>.*)@(?<version>.*)/);

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 }),
),
);
};
38 changes: 5 additions & 33 deletions src/workflow.spec.ts
Original file line number Diff line number Diff line change
@@ -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 () => {
Expand Down Expand Up @@ -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", {
Expand All @@ -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();
});
Expand Down
83 changes: 11 additions & 72 deletions src/workflow.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,17 @@
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,
StepsJobOptions,
StringWithNoSpaces,
UsesJobOptions,
} from "./job";
import type { Event, EventName, EventOptions } from "./event";
import { type Step, isUseStep } from "./step";

const writeFilePromise = promisify(fs.writeFile);
Expand All @@ -26,88 +25,25 @@ interface EnvVar {
value: string;
}

interface CompileOptions {
interface WorkflowCompileOptions {
filePath?: string;
lockFilePath?: string;
writeLockFile?: boolean;
resolvedActions?: Record<string, string>;
}

export type RunnerDefinition =
| string
| { group: string; labels?: string[] }
| ["self-hosted", string];

let firstCompileCall = true;

const supplyChainAttack = async (
step: Step,
compileOptions: CompileOptions,
resolvedActions: Record<string, string>,
) => {
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(/(?<repository>.*)@(?<version>.*)/);

// 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<
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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 ?? {},
),
};
}),
),
Expand Down