@cyclonedx/cdxgen
Version:
Creates CycloneDX Software Bill of Materials (SBOM) from source or container image
1,503 lines (1,360 loc) • 63.5 kB
JavaScript
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { assert, describe, it } from "poku";
import { githubActionsParser, parseWorkflowFile } from "./githubActions.js";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(__dirname, "../../..");
const workflowsDir = path.join(repoRoot, "test", "data", "workflows");
/**
* Helper: Find a component by purl substring
*/
function findComponentByPurlSubstring(components, substring) {
return components.find((c) => c.purl?.includes(substring));
}
/**
* Helper: Extract property value from a component/workflow/task
*/
function getProp(obj, propName) {
if (!obj?.properties) return undefined;
const prop = obj.properties.find((p) => p.name === propName);
return prop?.value;
}
/**
* Helper: Check if a property exists with expected value
*/
function hasProp(obj, propName, expectedValue) {
const val = getProp(obj, propName);
return expectedValue !== undefined
? val === expectedValue
: val !== undefined;
}
/**
* Helper: Parse workflow and return flattened results for assertions
*/
function parseWorkflow(filename, options = {}) {
const wfFile = path.join(workflowsDir, filename);
return githubActionsParser.parse([wfFile], { specVersion: 1.6, ...options });
}
describe("githubActionsParser", () => {
it("has correct metadata", () => {
assert.strictEqual(githubActionsParser.id, "github-actions");
assert.ok(Array.isArray(githubActionsParser.patterns));
assert.ok(githubActionsParser.patterns.length > 0);
assert.strictEqual(typeof githubActionsParser.parse, "function");
});
it("returns empty arrays for no files", () => {
const result = githubActionsParser.parse([], {});
assert.deepStrictEqual(result.workflows, []);
assert.deepStrictEqual(result.components, []);
assert.deepStrictEqual(result.services, []);
assert.deepStrictEqual(result.properties, []);
assert.deepStrictEqual(result.dependencies, []);
});
it("parses a real GitHub Actions workflow file", () => {
const wfFile = path.join(repoRoot, ".github", "workflows", "nodejs.yml");
const result = githubActionsParser.parse([wfFile], { specVersion: 1.6 });
assert.ok(Array.isArray(result.workflows));
assert.ok(result.workflows.length > 0, "expected at least one workflow");
const wf = result.workflows[0];
assert.ok(wf["bom-ref"], "workflow must have bom-ref");
assert.ok(wf.uid, "workflow must have uid");
assert.ok(wf.name, "workflow must have a name");
assert.ok(Array.isArray(wf.tasks), "workflow must have tasks array");
assert.ok(wf.tasks.length > 0, "workflow must have at least one task");
const firstTask = wf.tasks[0];
assert.ok(firstTask["bom-ref"], "task must have bom-ref");
assert.ok(firstTask.name, "task must have a name");
// Components include referenced actions
assert.ok(Array.isArray(result.components));
assert.ok(result.components.length > 0, "expected action components");
const actionComp = result.components.find((c) =>
c.purl?.startsWith("pkg:github/"),
);
assert.ok(actionComp, "expected at least one pkg:github component");
});
it("parses the test fixture with vulnerable actions", () => {
const wfFile = path.join(
repoRoot,
"test",
"data",
"github-actions-tj.yaml",
);
const result = githubActionsParser.parse([wfFile], { specVersion: 1.5 });
assert.ok(result.workflows.length > 0);
assert.ok(result.components.length > 0);
const purls = result.components.map((c) => c.purl).filter(Boolean);
assert.ok(
purls.some((p) => p.includes("pixel/steamcmd")),
"expected pixel/steamcmd purl",
);
assert.ok(
purls.some((p) => p.includes("tj/branch")),
"expected tj/branch purl",
);
});
it("produces workflow→task dependency links", () => {
const wfFile = path.join(repoRoot, ".github", "workflows", "nodejs.yml");
const result = githubActionsParser.parse([wfFile], {});
assert.ok(Array.isArray(result.dependencies));
assert.ok(result.dependencies.length > 0);
const workflowDep = result.dependencies.find(
(d) => d.ref === result.workflows[0]["bom-ref"],
);
assert.ok(
workflowDep,
"expected a dependency entry for the workflow bom-ref",
);
assert.ok(Array.isArray(workflowDep.dependsOn));
assert.ok(workflowDep.dependsOn.length > 0);
});
it("gracefully handles missing file", () => {
const result = githubActionsParser.parse(
["/this/file/does/not/exist.yml"],
{},
);
assert.deepStrictEqual(result.workflows, []);
assert.deepStrictEqual(result.components, []);
});
it("gracefully handles malformed YAML", () => {
const jf = path.join(repoRoot, "test", "data", "Jenkinsfile");
const result = githubActionsParser.parse([jf], {});
assert.deepStrictEqual(result.workflows, []);
});
it("gracefully handles non-string run fields", () => {
const tmpDir = mkdtempSync(path.join(os.tmpdir(), "cdxgen-gha-"));
const workflowFile = path.join(tmpDir, "non-string-run.yml");
writeFileSync(
workflowFile,
[
"name: Non-string run",
"on: push",
"jobs:",
" build:",
" runs-on: ubuntu-latest",
" steps:",
" - name: Numeric run",
" run: 42",
" - name: Object run",
" run:",
" nested: true",
].join("\n"),
);
try {
const result = parseWorkflowFile(workflowFile, { specVersion: 1.7 });
assert.strictEqual(result.workflows.length, 1);
assert.ok(result.workflows[0].tasks?.length > 0);
const runStepComp = result.components.find(
(component) =>
getProp(component, "cdx:github:step:type") === "run" &&
getProp(component, "cdx:github:step:command") === "42",
);
assert.ok(runStepComp, "expected numeric run step to be normalized");
} finally {
rmSync(tmpDir, { force: true, recursive: true });
}
});
it("normalizes object-form runs-on values", () => {
const tmpDir = mkdtempSync(path.join(os.tmpdir(), "cdxgen-gha-"));
const workflowFile = path.join(tmpDir, "object-runs-on.yml");
writeFileSync(
workflowFile,
[
"name: Object runs-on",
"on: push",
"jobs:",
" grouped:",
" runs-on:",
" group: ubuntu-runners",
" labels: ubuntu-20.04-16core",
" steps:",
" - uses: actions/checkout@v4",
" selfHosted:",
" runs-on:",
" group: larger-runners",
" labels: [self-hosted, linux, x64]",
" steps:",
' - run: echo "ok"',
].join("\n"),
);
try {
const result = parseWorkflowFile(workflowFile, { specVersion: 1.7 });
const actionComp = result.components.find(
(component) =>
getProp(component, "cdx:github:action:uses") ===
"actions/checkout@v4",
);
assert.strictEqual(
getProp(actionComp, "cdx:github:job:runner"),
"ubuntu-runners,ubuntu-20.04-16core",
);
const selfHostedTask = result.workflows[0].tasks.find(
(task) => task.name === "selfHosted",
);
assert.strictEqual(
getProp(selfHostedTask, "cdx:github:job:runner"),
"larger-runners,self-hosted,linux,x64",
);
assert.strictEqual(
getProp(selfHostedTask, "cdx:github:job:isSelfHosted"),
"true",
);
} finally {
rmSync(tmpDir, { force: true, recursive: true });
}
});
it("derives unnamed workflow names from the file stem without leaking Windows-style path segments", () => {
const tmpDir = mkdtempSync(path.join(os.tmpdir(), "cdxgen-gha-"));
const workflowFile = path.join(tmpDir, "nested\\workflow-file.yml");
mkdirSync(path.dirname(workflowFile), { recursive: true });
writeFileSync(
workflowFile,
[
"on: push",
"jobs:",
" build:",
" runs-on: ubuntu-latest",
' steps:\n - run: echo "ok"',
].join("\n"),
);
try {
const result = parseWorkflowFile(workflowFile, { specVersion: 1.7 });
assert.strictEqual(result.workflows.length, 1);
assert.strictEqual(result.workflows[0].name, "workflow-file");
} finally {
rmSync(tmpDir, { force: true, recursive: true });
}
});
it("disambiguates identical steps (uniqueItems compliance)", () => {
const wfFile = path.join(
repoRoot,
"test",
"data",
"github-actions-qwiet.yaml",
);
const result = githubActionsParser.parse([wfFile], {});
assert.ok(result.workflows.length > 0);
const wf = result.workflows[0];
const uploadTask = wf.tasks?.find((t) => t.name === "uploadArtifacts");
assert.ok(uploadTask, "expected uploadArtifacts task");
const steps = uploadTask.steps ?? [];
const stepKeys = steps.map((s) => JSON.stringify(s));
const uniqueKeys = new Set(stepKeys);
assert.strictEqual(
uniqueKeys.size,
stepKeys.length,
"steps array contains duplicate items",
);
const uploadSteps = steps.filter((s) =>
s.name.startsWith("actions/upload-artifact@v1.0.0"),
);
assert.strictEqual(
uploadSteps.length,
2,
"both upload-artifact steps must be kept",
);
assert.ok(
uploadSteps.some((s) => s.name === "actions/upload-artifact@v1.0.0"),
"first upload-artifact step must keep original name",
);
assert.ok(
uploadSteps.some((s) => s.name === "actions/upload-artifact@v1.0.0 (2)"),
"second upload-artifact step must be renamed with counter",
);
const preZeroTask = wf.tasks?.find((t) => t.name === "preZero");
assert.ok(preZeroTask, "expected preZero task");
const preZeroSteps = preZeroTask.steps ?? [];
const preZeroKeys = preZeroSteps.map((s) => JSON.stringify(s));
assert.strictEqual(
new Set(preZeroKeys).size,
preZeroKeys.length,
"preZero steps must also have no duplicates",
);
});
it("annotates Cargo setup, cache, and cargo run steps", () => {
const tmpDir = mkdtempSync(path.join(os.tmpdir(), "cdxgen-gha-cargo-"));
const workflowFile = path.join(tmpDir, "cargo.yml");
writeFileSync(
workflowFile,
[
"name: Cargo CI",
"on: push",
"jobs:",
" rust:",
" runs-on: ubuntu-latest",
" steps:",
" - uses: dtolnay/rust-toolchain@stable",
" - uses: actions/cache@v4",
" with:",
" path: |",
" ~/.cargo/registry",
" ~/.cargo/git",
" key: cargo-$" +
"{{ runner.os }}-$" +
"{{ hashFiles('**/Cargo.lock') }}",
" - run: cargo build --workspace && cargo test --workspace",
].join("\n"),
);
try {
const result = parseWorkflowFile(workflowFile, { specVersion: 1.7 });
const cargoToolchainComp = result.components.find(
(component) =>
getProp(component, "cdx:github:action:uses") ===
"dtolnay/rust-toolchain@stable",
);
const cargoCacheComp = result.components.find(
(component) =>
getProp(component, "cdx:github:action:uses") === "actions/cache@v4",
);
const cargoRunComp = result.components.find(
(component) =>
getProp(component, "cdx:github:step:usesCargo") === "true",
);
assert.ok(
cargoToolchainComp,
"expected Cargo toolchain action component",
);
assert.strictEqual(
getProp(cargoToolchainComp, "cdx:github:action:ecosystem"),
"cargo",
);
assert.strictEqual(
getProp(cargoToolchainComp, "cdx:github:action:role"),
"toolchain",
);
assert.ok(cargoCacheComp, "expected Cargo cache action component");
assert.strictEqual(
getProp(cargoCacheComp, "cdx:github:action:ecosystem"),
"cargo",
);
assert.strictEqual(
getProp(cargoCacheComp, "cdx:github:action:role"),
"cache",
);
assert.ok(cargoRunComp, "expected Cargo run step component");
assert.strictEqual(
getProp(cargoRunComp, "cdx:github:step:cargoSubcommands"),
"build,test",
);
assert.strictEqual(
getProp(cargoRunComp, "cdx:github:step:cargoWorkspaceScope"),
"true",
);
} finally {
rmSync(tmpDir, { force: true, recursive: true });
}
});
describe("checkout persist-credentials property emission", () => {
it("emits persistCredentials=true when not specified (default)", () => {
const result = parseWorkflow("checkout-default.yml");
assert.ok(result.components.length > 0, "expected action components");
const checkoutComp = findComponentByPurlSubstring(
result.components,
"actions/checkout",
);
assert.ok(checkoutComp, "expected actions/checkout component");
assert.strictEqual(
getProp(checkoutComp, "cdx:github:checkout:persistCredentials"),
"true",
"persistCredentials should default to 'true' when not specified",
);
});
it("emits persistCredentials=false when explicitly disabled", () => {
const result = parseWorkflow("checkout-no-persist.yml");
const checkoutComp = findComponentByPurlSubstring(
result.components,
"actions/checkout",
);
assert.ok(checkoutComp, "expected actions/checkout component");
assert.strictEqual(
getProp(checkoutComp, "cdx:github:checkout:persistCredentials"),
"false",
"persistCredentials should be 'false' when explicitly set",
);
});
it("emits persistCredentials for checkout in privileged workflow", () => {
const result = parseWorkflow("checkout-privileged.yml");
const checkoutComp = findComponentByPurlSubstring(
result.components,
"actions/checkout",
);
assert.ok(checkoutComp, "expected actions/checkout component");
assert.strictEqual(
getProp(checkoutComp, "cdx:github:checkout:persistCredentials"),
"true",
);
assert.strictEqual(
getProp(checkoutComp, "cdx:github:workflow:hasWritePermissions"),
"true",
"workflow should have write permissions flag",
);
});
it("does not emit checkout properties for non-checkout actions", () => {
const result = parseWorkflow("simple-build.yml");
const nonCheckoutComp = result.components.find((c) =>
c.purl?.includes("actions/setup-node"),
);
assert.ok(nonCheckoutComp, "expected setup-node component");
assert.strictEqual(
getProp(nonCheckoutComp, "cdx:github:checkout:persistCredentials"),
undefined,
"non-checkout actions should not have persistCredentials property",
);
});
});
describe("cache action property emission", () => {
it("emits cache key and path properties", () => {
const result = parseWorkflow("cache-basic.yml");
const cacheComp = findComponentByPurlSubstring(
result.components,
"actions/cache",
);
assert.ok(cacheComp, "expected actions/cache component");
// biome-ignore-start lint/suspicious/noTemplateCurlyInString: Test
assert.strictEqual(
getProp(cacheComp, "cdx:github:cache:key"),
"npm-${{ hashFiles('**/package-lock.json') }}",
"cache key should be extracted",
);
// biome-ignore-end lint/suspicious/noTemplateCurlyInString: Test
assert.strictEqual(
getProp(cacheComp, "cdx:github:cache:path"),
"~/.npm",
"cache path should be extracted",
);
});
it("emits restore-keys as comma-separated list", () => {
const result = parseWorkflow("cache-restore-keys.yml");
const cacheComp = findComponentByPurlSubstring(
result.components,
"actions/cache",
);
assert.ok(cacheComp);
const restoreKeys = getProp(cacheComp, "cdx:github:cache:restoreKeys");
assert.ok(restoreKeys, "restore-keys should be emitted");
assert.ok(
restoreKeys.includes("npm-") && restoreKeys.includes("node-modules-"),
"restore-keys should contain both fallback patterns",
);
});
it("emits workflow triggers for cache context analysis", () => {
const result = parseWorkflow("cache-pull-request.yml");
const workflow = result.workflows[0];
const triggers = getProp(workflow, "cdx:github:workflow:triggers");
assert.ok(triggers, "workflow triggers should be emitted");
assert.ok(
triggers.split(",").includes("pull_request"),
"pull_request trigger should be detected",
);
const cacheComp = findComponentByPurlSubstring(
result.components,
"actions/cache",
);
assert.strictEqual(
getProp(cacheComp, "cdx:github:workflow:triggers"),
"pull_request",
"triggers should be duplicated to component level",
);
});
it("emits pull_request_target trigger metadata for cache poisoning analysis", () => {
const result = parseWorkflow("cache-pull-request-target.yml");
const workflow = result.workflows[0];
assert.strictEqual(
getProp(workflow, "cdx:github:workflow:hasPullRequestTargetTrigger"),
"true",
);
const cacheComp = findComponentByPurlSubstring(
result.components,
"actions/cache",
);
assert.ok(cacheComp, "expected actions/cache component");
assert.strictEqual(
getProp(cacheComp, "cdx:github:workflow:hasPullRequestTargetTrigger"),
"true",
);
assert.strictEqual(
getProp(cacheComp, "cdx:github:workflow:hasWritePermissions"),
"true",
);
});
it("handles cache action without optional fields gracefully", () => {
const result = parseWorkflow("cache-minimal.yml");
const cacheComp = findComponentByPurlSubstring(
result.components,
"actions/cache",
);
assert.ok(cacheComp);
assert.ok(
getProp(cacheComp, "cdx:github:cache:key"),
"cache key should always be present",
);
assert.ok(
getProp(cacheComp, "cdx:github:cache:path") === undefined ||
typeof getProp(cacheComp, "cdx:github:cache:path") === "string",
"cache path should be string or undefined",
);
});
});
describe("setup action cache disable property emission", () => {
it("emits cache disable properties for setup-node, setup-python, and setup-rust", () => {
const tmpDir = mkdtempSync(path.join(os.tmpdir(), "cdxgen-gha-cache-"));
const workflowFile = path.join(tmpDir, "cache-disable.yml");
writeFileSync(
workflowFile,
[
"name: Cache disable",
"on: push",
"jobs:",
" build:",
" runs-on: ubuntu-latest",
" steps:",
" - uses: actions/setup-node@v4",
" with:",
" node-version: 20",
" package-manager-cache: false",
" - uses: actions/setup-python@v5",
" with:",
" python-version: '3.12'",
" cache: false",
" - uses: moonrepo/setup-rust@v1",
" with:",
" cache: false",
].join("\n"),
);
try {
const result = parseWorkflowFile(workflowFile, { specVersion: 1.7 });
const setupNodeComp = result.components.find(
(component) =>
getProp(component, "cdx:github:action:uses") ===
"actions/setup-node@v4",
);
const setupPythonComp = result.components.find(
(component) =>
getProp(component, "cdx:github:action:uses") ===
"actions/setup-python@v5",
);
const setupRustComp = result.components.find(
(component) =>
getProp(component, "cdx:github:action:uses") ===
"moonrepo/setup-rust@v1",
);
assert.ok(setupNodeComp, "expected setup-node component");
assert.ok(setupPythonComp, "expected setup-python component");
assert.ok(setupRustComp, "expected setup-rust component");
assert.strictEqual(
getProp(setupNodeComp, "cdx:github:action:disablesBuildCache"),
"true",
);
assert.strictEqual(
getProp(setupNodeComp, "cdx:github:action:buildCacheEcosystem"),
"npm",
);
assert.strictEqual(
getProp(setupNodeComp, "cdx:github:action:buildCacheDisableInput"),
"package-manager-cache",
);
assert.strictEqual(
getProp(setupPythonComp, "cdx:github:action:disablesBuildCache"),
"true",
);
assert.strictEqual(
getProp(setupPythonComp, "cdx:github:action:buildCacheEcosystem"),
"pypi",
);
assert.strictEqual(
getProp(setupPythonComp, "cdx:github:action:buildCacheDisableInput"),
"cache",
);
assert.strictEqual(
getProp(setupRustComp, "cdx:github:action:disablesBuildCache"),
"true",
);
assert.strictEqual(
getProp(setupRustComp, "cdx:github:action:buildCacheEcosystem"),
"cargo",
);
assert.strictEqual(
getProp(setupRustComp, "cdx:github:action:buildCacheDisableInput"),
"cache",
);
} finally {
rmSync(tmpDir, { force: true, recursive: true });
}
});
it("does not emit cache disable properties when cache is not explicitly disabled", () => {
const result = parseWorkflow("simple-build.yml");
const setupNodeComp = result.components.find(
(component) =>
getProp(component, "cdx:github:action:uses") ===
"actions/setup-node@v4",
);
assert.ok(setupNodeComp, "expected setup-node component");
assert.strictEqual(
getProp(setupNodeComp, "cdx:github:action:disablesBuildCache"),
undefined,
);
});
});
describe("script injection interpolation detection", () => {
it("detects github.event.pull_request interpolation", () => {
const result = parseWorkflow("injection-pull-request-title.yml");
const runStepComp = result.components.find((c) =>
c.properties?.some(
(p) => p.name === "cdx:github:step:hasUntrustedInterpolation",
),
);
assert.ok(
runStepComp,
"should detect untrusted interpolation in run step",
);
assert.strictEqual(
getProp(runStepComp, "cdx:github:step:hasUntrustedInterpolation"),
"true",
);
const vars = getProp(runStepComp, "cdx:github:step:interpolatedVars");
assert.ok(vars, "interpolated variables should be listed");
assert.ok(
vars.includes("github.event.pull_request.title"),
"should detect pull_request.title interpolation",
);
});
it("detects github.head_ref interpolation", () => {
const result = parseWorkflow("injection-head-ref.yml");
const runStepComp = result.components.find((c) =>
hasProp(c, "cdx:github:step:hasUntrustedInterpolation", "true"),
);
assert.ok(runStepComp);
const vars = getProp(runStepComp, "cdx:github:step:interpolatedVars");
assert.ok(
vars.includes("github.head_ref"),
"should detect github.head_ref interpolation",
);
});
it("detects github.event.comment.body interpolation in issue_comment workflows", () => {
const result = parseWorkflow("injection-issue-comment-body.yml");
const workflow = result.workflows[0];
assert.strictEqual(
getProp(workflow, "cdx:github:workflow:hasIssueCommentTrigger"),
"true",
);
const runStepComp = result.components.find((c) =>
hasProp(c, "cdx:github:step:hasUntrustedInterpolation", "true"),
);
assert.ok(runStepComp, "expected issue_comment injection component");
assert.match(
getProp(runStepComp, "cdx:github:step:interpolatedVars"),
/github\.event\.comment\.body/,
);
});
it("detects inputs.* interpolation in workflow_dispatch", () => {
const result = parseWorkflow("injection-workflow-inputs.yml");
const runStepComp = result.components.find((c) =>
hasProp(c, "cdx:github:step:hasUntrustedInterpolation", "true"),
);
assert.ok(runStepComp);
const vars = getProp(runStepComp, "cdx:github:step:interpolatedVars");
assert.ok(
vars.split(",").some((v) => v.trim().startsWith("inputs.")),
"should detect inputs.* interpolation",
);
});
it("does not flag safe interpolations", () => {
const result = parseWorkflow("safe-interpolation.yml");
const runStepComp = result.components.find(
(c) => c.purl?.includes("run") || c.name?.includes("echo"),
);
if (runStepComp) {
assert.strictEqual(
getProp(runStepComp, "cdx:github:step:hasUntrustedInterpolation"),
undefined,
"safe env-var indirection should not trigger injection detection",
);
}
});
it("does not flag structured SHA interpolation as untrusted input", () => {
const result = parseWorkflow("safe-sha-interpolation.yml");
const runStepComp = result.components.find((c) =>
c.properties?.some((p) => p.name === "cdx:github:step:type"),
);
assert.ok(runStepComp, "expected a run step component");
assert.strictEqual(
getProp(runStepComp, "cdx:github:step:hasUntrustedInterpolation"),
undefined,
);
});
it("handles multiple interpolations in single run block", () => {
const result = parseWorkflow("injection-multiple-vars.yml");
const runStepComp = result.components.find((c) =>
hasProp(c, "cdx:github:step:hasUntrustedInterpolation", "true"),
);
assert.ok(runStepComp);
const vars = getProp(runStepComp, "cdx:github:step:interpolatedVars");
const varList = vars.split(",");
assert.ok(
varList.length >= 2,
"should detect multiple untrusted variables",
);
assert.ok(
varList.some((v) => v.includes("pull_request.title")),
"should include pull_request.title",
);
assert.ok(
varList.some((v) => v.includes("pull_request.body")),
"should include pull_request.body",
);
});
});
describe("high-risk trigger detection", () => {
it("flags pull_request_target trigger", () => {
const result = parseWorkflow("trigger-pull-request-target.yml");
const workflow = result.workflows[0];
assert.strictEqual(
getProp(workflow, "cdx:github:workflow:hasHighRiskTrigger"),
"true",
"pull_request_target should be flagged as high-risk",
);
const triggers = getProp(workflow, "cdx:github:workflow:triggers");
assert.ok(
triggers.split(",").includes("pull_request_target"),
"trigger list should include pull_request_target",
);
});
it("flags issue_comment trigger", () => {
const result = parseWorkflow("trigger-issue-comment.yml");
const workflow = result.workflows[0];
assert.strictEqual(
getProp(workflow, "cdx:github:workflow:hasHighRiskTrigger"),
"true",
"issue_comment should be flagged as high-risk",
);
});
it("flags workflow_run trigger", () => {
const result = parseWorkflow("trigger-workflow-run.yml");
const workflow = result.workflows[0];
assert.strictEqual(
getProp(workflow, "cdx:github:workflow:hasHighRiskTrigger"),
"true",
"workflow_run should be flagged as high-risk",
);
});
it("does not flag safe triggers", () => {
const result = parseWorkflow("trigger-safe-push.yml");
const workflow = result.workflows[0];
assert.strictEqual(
getProp(workflow, "cdx:github:workflow:hasHighRiskTrigger"),
undefined,
"push trigger should not be flagged as high-risk",
);
});
it("combines high-risk trigger with write permissions in components", () => {
const result = parseWorkflow("trigger-privileged.yml");
const workflow = result.workflows[0];
assert.strictEqual(
getProp(workflow, "cdx:github:workflow:hasHighRiskTrigger"),
"true",
);
assert.strictEqual(
getProp(workflow, "cdx:github:workflow:hasWritePermissions"),
"true",
);
const actionComp = result.components.find((c) =>
c.purl?.includes("actions/checkout"),
);
if (actionComp) {
assert.strictEqual(
getProp(actionComp, "cdx:github:workflow:hasHighRiskTrigger"),
"true",
"high-risk trigger should be duplicated to component",
);
assert.strictEqual(
getProp(actionComp, "cdx:github:workflow:hasWritePermissions"),
"true",
"write permissions should be duplicated to component",
);
}
});
});
describe("explicit permissions metadata and sensitive-operation heuristics", () => {
it("emits false explicit-permissions metadata and sensitive-operation flags for implicit high-risk workflows", () => {
const result = parseWorkflow(
"heuristic-implicit-permissions-sensitive.yml",
);
const workflow = result.workflows[0];
assert.strictEqual(
getProp(workflow, "cdx:github:workflow:hasHighRiskTrigger"),
"true",
);
assert.strictEqual(
getProp(workflow, "cdx:github:workflow:hasExplicitPermissionsBlock"),
"false",
);
assert.strictEqual(
getProp(workflow, "cdx:github:workflow:hasAnyExplicitPermissionsBlock"),
"false",
);
const runStepComp = result.components.find(
(component) => component.name === "Trigger downstream release",
);
assert.ok(runStepComp, "expected sensitive run-step component");
assert.strictEqual(
getProp(
runStepComp,
"cdx:github:workflow:hasAnyExplicitPermissionsBlock",
),
"false",
);
assert.strictEqual(
getProp(runStepComp, "cdx:github:step:hasSensitiveOperations"),
"true",
);
assert.match(
getProp(runStepComp, "cdx:github:step:sensitiveOperations"),
/dispatches-workflow/,
);
assert.match(
getProp(runStepComp, "cdx:github:step:sensitiveOperations"),
/references-sensitive-context/,
);
});
it("emits true explicit-permissions metadata when a permissions block is present", () => {
const result = parseWorkflow(
"heuristic-explicit-permissions-sensitive.yml",
);
const workflow = result.workflows[0];
assert.strictEqual(
getProp(workflow, "cdx:github:workflow:hasExplicitPermissionsBlock"),
"true",
);
assert.strictEqual(
getProp(workflow, "cdx:github:workflow:hasAnyExplicitPermissionsBlock"),
"true",
);
const runStepComp = result.components.find(
(component) => component.name === "Trigger downstream release",
);
assert.ok(runStepComp, "expected sensitive run-step component");
assert.strictEqual(
getProp(
runStepComp,
"cdx:github:workflow:hasAnyExplicitPermissionsBlock",
),
"true",
);
assert.strictEqual(
getProp(runStepComp, "cdx:github:step:hasSensitiveOperations"),
"true",
);
});
});
describe("job-scoped privilege and trust metadata", () => {
it("propagates job-scoped id-token write to components and workflows", () => {
const result = parseWorkflow("job-id-token-write.yml");
const workflow = result.workflows[0];
assert.strictEqual(
getProp(workflow, "cdx:github:workflow:hasIdTokenWrite"),
"true",
);
const actionComp = findComponentByPurlSubstring(
result.components,
"vendor/deploy-action",
);
assert.ok(actionComp, "expected third-party deploy action");
assert.strictEqual(
getProp(actionComp, "cdx:github:workflow:hasIdTokenWrite"),
"true",
);
assert.strictEqual(
getProp(actionComp, "cdx:github:job:hasIdTokenWrite"),
"true",
);
assert.strictEqual(
getProp(actionComp, "cdx:actions:isOfficial"),
"false",
);
assert.strictEqual(
getProp(actionComp, "cdx:actions:isVerified"),
"false",
);
});
it("emits workflow dispatch input metadata", () => {
const result = parseWorkflow("injection-workflow-inputs.yml");
const workflow = result.workflows[0];
assert.strictEqual(
getProp(workflow, "cdx:github:workflow:hasWorkflowDispatchInputs"),
"true",
);
assert.strictEqual(
getProp(workflow, "cdx:github:workflow:hasWorkflowDispatchTrigger"),
"true",
);
assert.ok(
getProp(workflow, "cdx:github:workflow:workflowDispatchInputs")?.split(
",",
).length >= 1,
);
});
});
describe("reusable workflow parsing", () => {
it("models external reusable workflows with secrets inheritance", () => {
const result = parseWorkflow("reusable-workflow-secrets-inherit.yml");
const reusableComp = result.components.find((c) =>
hasProp(c, "cdx:github:reusableWorkflow:secretsInherit", "true"),
);
assert.ok(reusableComp, "expected reusable workflow component");
assert.strictEqual(
getProp(reusableComp, "cdx:github:reusableWorkflow:isExternal"),
"true",
);
assert.strictEqual(
getProp(reusableComp, "cdx:github:reusableWorkflow:isShaPinned"),
"false",
);
assert.strictEqual(
getProp(reusableComp, "cdx:github:reusableWorkflow:versionPinningType"),
"branch",
);
});
it("models external reusable workflows pinned to mutable refs", () => {
const result = parseWorkflow("reusable-workflow-external-unpinned.yml");
const reusableComp = result.components.find((c) =>
hasProp(c, "cdx:github:reusableWorkflow:isExternal", "true"),
);
assert.ok(reusableComp, "expected external reusable workflow component");
assert.strictEqual(
getProp(reusableComp, "cdx:github:reusableWorkflow:isShaPinned"),
"false",
);
assert.strictEqual(
getProp(reusableComp, "cdx:github:reusableWorkflow:withKeys"),
"run-tests",
);
});
it("emits workflow_call producer metadata for reusable workflow definitions", () => {
const result = parseWorkflow("workflow-call-producer-risky.yml");
const workflow = result.workflows[0];
assert.strictEqual(
getProp(workflow, "cdx:github:workflow:hasWorkflowCallTrigger"),
"true",
);
assert.strictEqual(
getProp(workflow, "cdx:github:workflow:isWorkflowCallProducer"),
"true",
);
assert.strictEqual(
getProp(workflow, "cdx:github:workflow:workflowCallInputs"),
"release_tag",
);
assert.strictEqual(
getProp(workflow, "cdx:github:workflow:workflowCallSecrets"),
"release_token",
);
assert.strictEqual(
getProp(workflow, "cdx:github:workflow:workflowCallOutputs"),
"image_tag",
);
assert.strictEqual(
getProp(workflow, "cdx:github:workflow:hasWritePermissions"),
"true",
);
const actionComp = findComponentByPurlSubstring(
result.components,
"vendor/publish-action",
);
assert.ok(actionComp, "expected publish action component");
assert.strictEqual(
getProp(actionComp, "cdx:github:workflow:hasWorkflowCallTrigger"),
"true",
);
assert.strictEqual(
getProp(actionComp, "cdx:github:workflow:workflowCallSecrets"),
"release_token",
);
});
});
describe("combined security risk scenarios", () => {
it("detects cache poisoning risk: cache + pull_request + write perms", () => {
const result = parseWorkflow("risk-cache-poisoning.yml");
const cacheComp = findComponentByPurlSubstring(
result.components,
"actions/cache",
);
assert.ok(cacheComp, "expected cache component");
assert.ok(
getProp(cacheComp, "cdx:github:cache:key"),
"cache key should be present",
);
assert.strictEqual(
getProp(cacheComp, "cdx:github:workflow:triggers"),
"pull_request",
"pull_request trigger should be duplicated",
);
assert.strictEqual(
getProp(cacheComp, "cdx:github:workflow:hasWritePermissions"),
"true",
"write permissions should be duplicated",
);
assert.strictEqual(
getProp(cacheComp, "cdx:github:workflow:hasPullRequestTrigger"),
"true",
);
assert.strictEqual(
getProp(cacheComp, "cdx:github:cache:keyUsesHashFiles"),
undefined,
);
});
it("detects credential exposure: checkout persist + privileged workflow", () => {
const result = parseWorkflow("risk-credential-exposure.yml");
const checkoutComp = findComponentByPurlSubstring(
result.components,
"actions/checkout",
);
assert.ok(checkoutComp);
assert.strictEqual(
getProp(checkoutComp, "cdx:github:checkout:persistCredentials"),
"true",
);
assert.strictEqual(
getProp(checkoutComp, "cdx:github:workflow:hasWritePermissions"),
"true",
);
});
it("detects checkout of pull_request head context inside pull_request_target", () => {
const result = parseWorkflow("checkout-untrusted-pr-head.yml");
const checkoutComp = findComponentByPurlSubstring(
result.components,
"actions/checkout",
);
assert.ok(checkoutComp, "expected actions/checkout component");
assert.strictEqual(
getProp(
checkoutComp,
"cdx:github:workflow:hasPullRequestTargetTrigger",
),
"true",
);
assert.strictEqual(
getProp(checkoutComp, "cdx:github:checkout:checksOutUntrustedRef"),
"true",
);
assert.match(
getProp(checkoutComp, "cdx:github:checkout:untrustedRefContexts"),
/github\.event\.pull_request\.head\.sha/,
);
assert.strictEqual(
getProp(checkoutComp, "cdx:github:checkout:referencesForkContext"),
"true",
);
assert.match(
getProp(checkoutComp, "cdx:github:checkout:forkContextRefs"),
/github\.event\.pull_request\.head\.repo\.full_name/,
);
});
it("detects script injection in privileged context", () => {
const result = parseWorkflow("risk-injection-privileged.yml");
const injectionComp = result.components.find((c) =>
hasProp(c, "cdx:github:step:hasUntrustedInterpolation", "true"),
);
assert.ok(injectionComp, "should detect injection attempt");
assert.strictEqual(
getProp(injectionComp, "cdx:github:workflow:hasWritePermissions"),
"true",
"injection in privileged workflow should have permission flag",
);
});
it("detects unpinned action in high-risk trigger workflow", () => {
const result = parseWorkflow("risk-unpinned-high-risk.yml");
const actionComp = result.components.find((c) =>
c.purl?.includes("third-party/action"),
);
assert.ok(actionComp);
assert.strictEqual(
getProp(actionComp, "cdx:github:action:isShaPinned"),
"false",
"action should be detected as unpinned",
);
assert.strictEqual(
getProp(actionComp, "cdx:github:action:versionPinningType"),
"tag",
"pinning type should be 'tag'",
);
assert.strictEqual(
getProp(actionComp, "cdx:github:workflow:hasHighRiskTrigger"),
"true",
);
});
it("detects self-hosted runners in high-risk workflows", () => {
const result = parseWorkflow("self-hosted-high-risk.yml");
const actionComp = findComponentByPurlSubstring(
result.components,
"actions/checkout",
);
assert.ok(actionComp, "expected actions/checkout component");
assert.strictEqual(
getProp(actionComp, "cdx:github:job:isSelfHosted"),
"true",
);
assert.strictEqual(
getProp(actionComp, "cdx:github:workflow:hasHighRiskTrigger"),
"true",
);
});
it("detects runner-state mutation in privileged run steps", () => {
const result = parseWorkflow("runner-state-mutation.yml");
const runStepComp = result.components.find((c) =>
hasProp(c, "cdx:github:step:mutatesRunnerState", "true"),
);
assert.ok(runStepComp, "expected runner-state mutation component");
assert.strictEqual(
getProp(runStepComp, "cdx:github:step:runnerStateTargets"),
"GITHUB_ENV",
);
assert.strictEqual(
getProp(runStepComp, "cdx:github:workflow:hasWritePermissions"),
"true",
);
});
it("detects outbound commands that reference sensitive context", () => {
const result = parseWorkflow("outbound-sensitive-context.yml");
const runStepComp = result.components.find((c) =>
hasProp(c, "cdx:github:step:hasOutboundNetworkCommand", "true"),
);
assert.ok(runStepComp, "expected outbound network component");
assert.strictEqual(
getProp(runStepComp, "cdx:github:step:referencesSensitiveContext"),
"true",
);
assert.match(
getProp(runStepComp, "cdx:github:step:sensitiveContextRefs"),
/env:UPLOAD_AUTH/,
);
assert.strictEqual(
getProp(runStepComp, "cdx:github:step:likelyExfiltration"),
"true",
);
assert.match(
getProp(runStepComp, "cdx:github:step:exfiltrationIndicators"),
/auth-header/,
);
assert.match(
getProp(runStepComp, "cdx:github:step:exfiltrationIndicators"),
/state-changing-method/,
);
});
it("does not mark low-signal outbound steps as likely exfiltration", () => {
const result = parseWorkflow("outbound-sensitive-context-low-signal.yml");
const runStepComp = result.components.find((c) =>
hasProp(c, "cdx:github:step:hasOutboundNetworkCommand", "true"),
);
assert.ok(runStepComp, "expected outbound network component");
assert.strictEqual(
getProp(runStepComp, "cdx:github:step:referencesSensitiveContext"),
"true",
);
assert.strictEqual(
getProp(runStepComp, "cdx:github:step:likelyExfiltration"),
undefined,
);
});
it("detects fork-aware workflow dispatch chains in run steps", () => {
const result = parseWorkflow("dispatch-chain-fork-sensitive.yml");
const dispatchStepComp = result.components.find((c) =>
hasProp(c, "cdx:github:step:dispatchesWorkflow", "true"),
);
assert.ok(
dispatchStepComp,
"expected dispatching workflow step component",
);
assert.strictEqual(
getProp(dispatchStepComp, "cdx:github:step:dispatchKinds"),
"workflow_dispatch",
);
assert.match(
getProp(dispatchStepComp, "cdx:github:step:dispatchMechanisms"),
/gh-workflow-run/,
);
assert.match(
getProp(dispatchStepComp, "cdx:github:step:dispatchTargets"),
/workflow:release.yml/,
);
assert.strictEqual(
getProp(dispatchStepComp, "cdx:github:step:referencesForkContext"),
"true",
);
assert.match(
getProp(dispatchStepComp, "cdx:github:step:sensitiveContextRefs"),
/env:GH_TOKEN/,
);
});
it("detects workflow dispatches from actions/github-script", () => {
const tmpDir = mkdtempSync(path.join(os.tmpdir(), "cdxgen-gha-"));
const workflowFile = path.join(tmpDir, "github-script-dispatch.yml");
writeFileSync(
workflowFile,
[
"name: Script dispatch",
"on: workflow_run",
"permissions:",
" actions: write",
"jobs:",
" relay:",
" runs-on: ubuntu-latest",
" steps:",
" - uses: actions/github-script@v7",
" with:",
" github-token: $" + "{{ secrets.GITHUB_TOKEN }}",
" script: |",
" await github.rest.actions.createWorkflowDispatch({",
" owner: 'octo-org',",
" repo: 'release-repo',",
" workflow_id: 'release.yml',",
" ref: 'main',",
" });",
].join("\n"),
);
try {
const result = parseWorkflowFile(workflowFile, { specVersion: 1.7 });
const githubScriptComp = findComponentByPurlSubstring(
result.components,
"actions/github-script",
);
assert.ok(githubScriptComp, "expected actions/github-script component");
assert.strictEqual(
getProp(githubScriptComp, "cdx:github:step:dispatchesWorkflow"),
"true",
);
assert.match(
getProp(githubScriptComp, "cdx:github:step:dispatchTargets"),
/repo:octo-org\/release-repo/,
);
assert.match(
getProp(githubScriptComp, "cdx:github:step:sensitiveContextRefs"),
/input:github-token/,
);
} finally {
rmSync(tmpDir, { force: true, recursive: true });
}
});
it("correlates dispatch senders with local receiver workflow definitions", () => {
const tmpDir = mkdtempSync(path.join(os.tmpdir(), "cdxgen-gha-"));
const senderWorkflow = path.join(tmpDir, "sender.yml");
const dispatchReceiverWorkflow = path.join(tmpDir, "release.yml");
const repoDispatchReceiverWorkflow = path.join(
tmpDir,
"repo-dispatch.yml",
);
writeFileSync(
senderWorkflow,
[
"name: Sender workflow",
"on: push",
"jobs:",
" relay:",
" runs-on: ubuntu-latest",
" steps:",
" - name: Trigger release receiver",
" env:",
" GH_TOKEN: $" + "{{ github.token }}",
" run: gh workflow run release.yml --ref main",
" - name: Trigger promote event",
" uses: peter-evans/repository-dispatch@v3",
" with:",
" event-type: promote",
].join("\n"),
);
writeFileSync(
dispatchReceiverWorkflow,
[
"name: Release workflow",
"on:",
" workflow_dispatch:",
" inputs:",
" version:",
" required: true",
"jobs:",
" release:",
" runs-on: ubuntu-latest",
" steps:",
" - run: echo release",
].join("\n"),
);
writeFileSync(
repoDispatchReceiverWorkflow,
[
"name: Promote workflow",
"on:",
" repository_dispatch:",
" types: [promote]",
"jobs:",
" promote:",
" runs-on: ubuntu-latest",
" steps:",
" - run: echo promote",
].join("\n"),
);
try {
const result = githubActionsParser.parse(
[
senderWorkflow,
dispatchReceiverWorkflow,
repoDispatchReceiverWorkflow,
],
{ specVersion: 1.7 },
);
const runDispatchStep = result.components.find(
(component) => component.name === "Trigger release receiver",
);
assert.ok(
runDispatchStep,
"expected local workflow_dispatch sender step",
);
assert.strictEqual(
getProp(runDispatchStep, "cdx:github:step:hasLocalDispatchReceiver"),
"true",
);
assert.match(
getProp(
runDispatchStep,
"cdx:github:step:dispatchReceiverWorkflowFiles",
),
/release\.yml/,
);
assert.match(
getProp(
runDispatchStep,
"cdx:github:step:dispatchReceiverWorkflowNames",
),
/Release workflow/,
);
const actionDispatchStep = result.components.find(
(component) =>
getProp(component, "cdx:github:action:uses") ===
"peter-evans/repository-dispatch@v3",
);
assert.ok(
actionDispatchStep,
"expected local repository_dispatch sender step",
);
assert.strictEqual(
getProp(
actionDispatchStep,
"cdx:github:step:hasLocalDispatchReceiver",
),
"true",
);
assert.match(
getProp(
actionDispatchStep,
"cdx:github:step:dispatchReceiverMatchBasis",
),
/repository_dispatch:promote/,
);
const releaseWorkflow = result.workflows.find(
(workflow) => workflow.name === "Release workflow",
);
assert.ok(
releaseWorkflow,
"expected workflow_dispatch receiver workflow",
);
assert.strictEqual(
getProp(