UNPKG

@cyclonedx/cdxgen

Version:

Creates CycloneDX Software Bill of Materials (SBOM) from source or container image

1,793 lines (1,718 loc) 71.3 kB
import { readFileSync } from "node:fs"; import path from "node:path"; import { PackageURL } from "packageurl-js"; import { v4 as uuidv4 } from "uuid"; import { parse as _load } from "yaml"; import { scanTextForHiddenUnicode } from "../unicodeScan.js"; import { disambiguateSteps } from "./common.js"; /** * Known GitHub Actions permission scopes that grant write access. * @type {string[]} */ const WRITE_SCOPES = [ "actions", "artifact-metadata", "attestations", "checks", "contents", "deployments", "discussions", "id-token", "issues", "models", "packages", "pages", "pull-requests", "security-events", "statuses", ]; /** * Workflow triggers considered high-risk because they can execute code in a * privileged context or expose secrets to untrusted input. * @type {string[]} */ const HIGH_RISK_TRIGGERS = [ "pull_request_target", "issue_comment", "workflow_run", ]; const LOW_RISK_INTERPOLATION_PATTERNS = [ /^github\.sha$/, /^github\.event\.pull_request\.(?:head|base)\.sha$/, /^github\.event\.workflow_run\.head_sha$/, /^github\.event\.pull_request\.number$/, /^github\.event\.issue\.number$/, /^github\.run_attempt$/, /^github\.run_id$/, /^github\.run_number$/, ]; const LEGACY_PUBLISH_TOKEN_ENV_NAMES = new Set([ "NPM_CONFIG_TOKEN", "TWINE_PASSWORD", ]); const SECRET_LIKE_ENV_NAME_PATTERN = /token|secret|password|credential|auth|api[_-]?key|access[_-]?key|client[_-]?secret/i; const SENSITIVE_ENV_VALUE_PATTERN = /secrets\.[A-Za-z0-9_]+|github\.token|ACTIONS_ID_TOKEN_REQUEST_(?:TOKEN|URL)/i; const SHELL_VARIABLE_REFERENCE_PATTERN = /\$[A-Za-z_][A-Za-z0-9_]*\b|\$\{[A-Za-z_][A-Za-z0-9_]*}|%[A-Za-z_][A-Za-z0-9_]*%|\$env:[A-Za-z_][A-Za-z0-9_]*\b/i; const IMPLICIT_SENSITIVE_ENV_NAMES = [ "ACTIONS_ID_TOKEN_REQUEST_TOKEN", "ACTIONS_ID_TOKEN_REQUEST_URL", "ACTIONS_RUNTIME_TOKEN", "GITHUB_TOKEN", ]; const OUTBOUND_NETWORK_TOOLS = [ ["curl", /\bcurl\b/i], ["wget", /\bwget\b/i], ["invoke-webrequest", /\b(?:invoke-webrequest|iwr)\b/i], ["invoke-restmethod", /\b(?:invoke-restmethod|irm)\b/i], ["nc", /\b(?:nc|ncat|netcat)\b/i], ["scp", /\bscp\b/i], ["rsync", /\brsync\b/i], ["ftp", /\b(?:ftp|sftp)\b/i], ]; const KNOWN_DISPATCH_ACTIONS = [ { kind: "repository_dispatch", mechanism: "repository-dispatch-action", pattern: /^peter-evans\/repository-dispatch(?:@|$)/i, repoKeys: ["repository"], targetKeys: ["event-type"], }, { kind: "workflow_dispatch", mechanism: "workflow-dispatch-action", pattern: /^(?:benc-uk\/workflow-dispatch|lasith-kg\/dispatch-workflow|convictional\/trigger-workflow-and-wait-for-workflow)(?:@|$)/i, repoKeys: ["repo", "repository"], targetKeys: ["workflow", "workflow_id", "event-type", "ref"], }, ]; const CARGO_TOOLCHAIN_ACTION_PATTERNS = [ /^dtolnay\/rust-toolchain(?:@|$)/i, /^actions-rs\/toolchain(?:@|$)/i, /^moonrepo\/setup-rust(?:@|$)/i, ]; const CARGO_CACHE_ACTION_PATTERNS = [/^swatinem\/rust-cache(?:@|$)/i]; const CARGO_TOOL_INSTALL_ACTION_PATTERNS = [/^taiki-e\/install-action(?:@|$)/i]; const DEPENDENCY_CACHE_SETUP_ACTIONS = [ { pattern: /^actions\/setup-node(?:@|$)/i, ecosystem: "npm", inputNames: ["package-manager-cache", "cache"], }, { pattern: /^actions\/setup-python(?:@|$)/i, ecosystem: "pypi", inputNames: ["cache"], }, { pattern: /^actions\/setup-go(?:@|$)/i, ecosystem: "go", inputNames: ["cache"], }, { pattern: /^actions\/setup-java(?:@|$)/i, ecosystem: "java", inputNames: ["cache"], }, { pattern: /^moonrepo\/setup-rust(?:@|$)/i, ecosystem: "cargo", inputNames: ["cache"], }, ]; const FORK_CONTEXT_PATTERNS = [ [ "github.event.pull_request.head.repo.fork", /github\.event\.pull_request\.head\.repo\.fork/i, ], [ "github.event.pull_request.head.repo.full_name", /github\.event\.pull_request\.head\.repo\.full_name/i, ], [ "github.event.pull_request.head.repo.clone_url", /github\.event\.pull_request\.head\.repo\.clone_url/i, ], [ "github.event.workflow_run.head_repository.fork", /github\.event\.workflow_run\.head_repository\.fork/i, ], [ "github.event.workflow_run.head_repository.full_name", /github\.event\.workflow_run\.head_repository\.full_name/i, ], [ "github.event.pull_request.head.ref", /github\.event\.pull_request\.head\.ref/i, ], ["github.head_ref", /github\.head_ref/i], ]; const UNTRUSTED_CHECKOUT_CONTEXT_PATTERNS = [ [ "github.event.pull_request.head.sha", /github\.event\.pull_request\.head\.sha/i, ], [ "github.event.pull_request.head.ref", /github\.event\.pull_request\.head\.ref/i, ], [ "github.event.pull_request.head.label", /github\.event\.pull_request\.head\.label/i, ], ["github.head_ref", /github\.head_ref/i], [ "github.event.workflow_run.head_sha", /github\.event\.workflow_run\.head_sha/i, ], [ "github.event.workflow_run.head_branch", /github\.event\.workflow_run\.head_branch/i, ], ]; /** * Analyse a workflow-level or job-level permissions map for any write grants. * * Accepts the raw `permissions` value from a workflow YAML which can be an * object mapping scope names to `"read"` / `"write"`, or the shorthand * strings `"write-all"` / `"read-all"`. * * @param {Object|string|undefined} permissions - The permissions map or shorthand string. * @returns {boolean} `true` when at least one scope has write access. */ function analyzePermissions(permissions) { if (!permissions) { return false; } if (typeof permissions === "string") { return permissions === "write-all"; } if (typeof permissions !== "object") { return false; } for (const scope of WRITE_SCOPES) { if (permissions[scope] === "write") { return true; } } return false; } function extractWriteScopes(permissions) { if (!permissions) { return []; } if (typeof permissions === "string") { return permissions === "write-all" ? ["all"] : []; } if (typeof permissions !== "object") { return []; } const scopes = []; for (const scope of WRITE_SCOPES) { if (permissions[scope] === "write") { scopes.push(scope); } } return scopes; } function hasIdTokenWritePermission(permissions) { if (!permissions) { return false; } if (typeof permissions === "string") { return permissions === "write-all"; } if (typeof permissions !== "object") { return false; } return permissions["id-token"] === "write"; } function getPropertyValueFromProperties(properties, propName) { return properties.find((property) => property.name === propName)?.value; } function appendSensitiveOperationProperties(properties) { const sensitiveOperations = new Set(); if ( getPropertyValueFromProperties( properties, "cdx:github:step:referencesSensitiveContext", ) === "true" ) { sensitiveOperations.add("references-sensitive-context"); } if ( getPropertyValueFromProperties( properties, "cdx:github:step:dispatchesWorkflow", ) === "true" ) { sensitiveOperations.add("dispatches-workflow"); } if ( getPropertyValueFromProperties( properties, "cdx:github:step:mutatesRunnerState", ) === "true" ) { sensitiveOperations.add("mutates-runner-state"); } if ( getPropertyValueFromProperties( properties, "cdx:github:step:usesLegacyPublishToken", ) === "true" ) { sensitiveOperations.add("legacy-publish-token"); } if ( getPropertyValueFromProperties( properties, "cdx:github:step:hasOutboundNetworkCommand", ) === "true" && getPropertyValueFromProperties( properties, "cdx:github:step:referencesSensitiveContext", ) === "true" ) { sensitiveOperations.add("outbound-network-with-sensitive-context"); } const actionUses = getPropertyValueFromProperties( properties, "cdx:github:action:uses", ); const persistCredentials = getPropertyValueFromProperties( properties, "cdx:github:checkout:persistCredentials", ); if ( actionUses?.includes("actions/checkout") && persistCredentials !== "false" ) { sensitiveOperations.add("checkout-persist-credentials"); } if (!sensitiveOperations.size) { return; } properties.push({ name: "cdx:github:step:hasSensitiveOperations", value: "true", }); properties.push({ name: "cdx:github:step:sensitiveOperations", value: Array.from(sensitiveOperations).join(","), }); } function normalizeRunnerLabels(runsOn) { if (!runsOn) { return []; } if (Array.isArray(runsOn)) { return runsOn.map((label) => String(label).trim()).filter(Boolean); } if (typeof runsOn === "string") { return runsOn .split(",") .map((label) => label.trim()) .filter(Boolean); } if (typeof runsOn === "object") { return normalizeRunnerLabels(runsOn.labels); } return []; } function normalizeRunnerValue(runsOn) { const labels = normalizeRunnerLabels(runsOn); if (runsOn && typeof runsOn === "object" && !Array.isArray(runsOn)) { const group = runsOn.group ? String(runsOn.group).trim() : ""; return [group, ...labels].filter(Boolean).join(",") || "unknown"; } return labels.join(",") || "unknown"; } function isSelfHostedRunner(runsOn) { return normalizeRunnerLabels(runsOn).some((label) => label.toLowerCase().includes("self-hosted"), ); } /** * Detect if a step uses `actions/checkout` and extract the * `persist-credentials` setting (defaults to `true` when absent). * * @param {Object} step - A single workflow step object. * @returns {Array<{name: string, value: string}>} Property entries to append. */ function analyzeCheckoutStep(step) { const props = []; if (step.uses?.includes("actions/checkout")) { const persistCreds = step.with?.["persist-credentials"] ?? true; const checkoutRef = step.with?.ref; const checkoutRepository = step.with?.repository; props.push({ name: "cdx:github:checkout:persistCredentials", value: String(persistCreds), }); if (checkoutRef) { props.push({ name: "cdx:github:checkout:ref", value: checkoutRef }); } if (checkoutRepository) { props.push({ name: "cdx:github:checkout:repository", value: checkoutRepository, }); } const untrustedCheckoutContexts = [ ...detectCheckoutUntrustedContexts(checkoutRef), ...detectCheckoutUntrustedContexts(checkoutRepository), ]; if (untrustedCheckoutContexts.length) { props.push({ name: "cdx:github:checkout:checksOutUntrustedRef", value: "true", }); props.push({ name: "cdx:github:checkout:untrustedRefContexts", value: [...new Set(untrustedCheckoutContexts)].join(","), }); } const forkContextRefs = [ ...detectForkContextReferences(checkoutRef), ...detectForkContextReferences(checkoutRepository), ]; if (forkContextRefs.length) { props.push({ name: "cdx:github:checkout:referencesForkContext", value: "true", }); props.push({ name: "cdx:github:checkout:forkContextRefs", value: [...new Set(forkContextRefs)].join(","), }); } } return props; } function detectCheckoutUntrustedContexts(textValue) { if (!textValue || typeof textValue !== "string") { return []; } const refs = []; UNTRUSTED_CHECKOUT_CONTEXT_PATTERNS.forEach(([name, pattern]) => { if (pattern.test(textValue)) { refs.push(name); } }); return refs; } /** * Detect `actions/cache` usage and extract key, path, and restore-keys * metadata from the step's `with` block. * * @param {Object} step - A single workflow step object. * @returns {Array<{name: string, value: string}>} Property entries to append. */ function analyzeCacheStep(step) { const props = []; if (step.uses?.includes("actions/cache")) { const cacheKey = step.with?.key; if (step.with?.key) { props.push({ name: "cdx:github:cache:key", value: cacheKey }); if (/hashFiles\s*\(/i.test(cacheKey)) { props.push({ name: "cdx:github:cache:keyUsesHashFiles", value: "true", }); } } if (step.with?.path) { props.push({ name: "cdx:github:cache:path", value: step.with.path }); } if (step.with?.["restore-keys"]) { let keys = step.with["restore-keys"]; if (Array.isArray(keys)) { keys = keys.join(","); } else if (typeof keys === "string" && keys.includes("\n")) { keys = keys .split("\n") .map((k) => k.trim()) .filter((k) => k) .join(","); } props.push({ name: "cdx:github:cache:restoreKeys", value: keys }); props.push({ name: "cdx:github:cache:hasRestoreKeys", value: "true" }); } } return props; } function analyzeCargoActionStep(step) { const props = []; if (!step?.uses || typeof step.uses !== "string") { return props; } const cargoRoles = new Set(); if ( CARGO_TOOLCHAIN_ACTION_PATTERNS.some((pattern) => pattern.test(step.uses)) ) { cargoRoles.add("toolchain"); } if (CARGO_CACHE_ACTION_PATTERNS.some((pattern) => pattern.test(step.uses))) { cargoRoles.add("cache"); } if ( CARGO_TOOL_INSTALL_ACTION_PATTERNS.some((pattern) => pattern.test(step.uses), ) ) { cargoRoles.add("tool-install"); } if ( step.uses.includes("actions/cache") && typeof step.with?.path === "string" && /(?:^|[\\/])\.cargo(?:[\\/]|$)|cargo[\\/](?:registry|git)/i.test( step.with.path, ) ) { cargoRoles.add("cache"); } if (!cargoRoles.size) { return props; } props.push({ name: "cdx:github:action:ecosystem", value: "cargo", }); props.push({ name: "cdx:github:action:role", value: [...cargoRoles].join(","), }); return props; } function isExplicitFalseLikeValue(value) { if (value === false) { return true; } if (typeof value !== "string") { return false; } return ["0", "false", "no", "off", "disabled"].includes( value.trim().toLowerCase(), ); } function analyzeSetupActionCacheStep(step) { const props = []; if (!step?.uses || typeof step.uses !== "string") { return props; } const setupAction = DEPENDENCY_CACHE_SETUP_ACTIONS.find((candidate) => candidate.pattern.test(step.uses), ); if (!setupAction || !step.with || typeof step.with !== "object") { return props; } const disableInputName = setupAction.inputNames.find( (inputName) => Object.hasOwn(step.with, inputName) && isExplicitFalseLikeValue(step.with[inputName]), ); if (!disableInputName) { return props; } props.push({ name: "cdx:github:action:disablesBuildCache", value: "true", }); props.push({ name: "cdx:github:action:buildCacheEcosystem", value: setupAction.ecosystem, }); props.push({ name: "cdx:github:action:buildCacheDisableInput", value: disableInputName, }); props.push({ name: "cdx:github:action:buildCacheDisableValue", value: String(step.with[disableInputName]), }); return props; } function analyzeCargoRunStep(normalizedRun) { const props = []; if (!normalizedRun || typeof normalizedRun !== "string") { return props; } const cargoSubcommands = new Set(); for (const match of normalizedRun.matchAll(/\bcargo\s+([a-z][\w-]*)/gi)) { if (match[1]) { cargoSubcommands.add(match[1].toLowerCase()); } } if (!cargoSubcommands.size) { return props; } props.push({ name: "cdx:github:step:usesCargo", value: "true", }); props.push({ name: "cdx:github:step:cargoSubcommands", value: [...cargoSubcommands].join(","), }); if (/\s--workspace\b|\s--all\b|\s--all-targets\b/i.test(normalizedRun)) { props.push({ name: "cdx:github:step:cargoWorkspaceScope", value: "true", }); } return props; } /** * Detect untrusted expression interpolation in `run:` blocks. * * Scans the raw shell string for `${{ … }}` patterns and flags any that * reference user-controlled contexts such as `github.event.pull_request.*`, * `github.event.issue.*`, `github.event.comment.*`, `github.head_ref`, or * `inputs.*`. * * @param {string|undefined} runValue - The raw `run:` block string. * @returns {{ hasInterpolation: boolean, vars: string[] }} */ function detectUntrustedInterpolation(runValue) { if (!runValue) return { hasInterpolation: false, vars: [] }; // Capture expression content inside ${{ … }}, allowing nested single braces // (e.g. the || operator in `${{ a || b }}` where } appears inside the expr). const pattern = /\$\{\{\s*([^}]+(?:}[^}])*)}}/g; const matches = [...runValue.matchAll(pattern)]; const untrustedVars = new Set(); for (const match of matches) { const expr = match[1].trim(); if (LOW_RISK_INTERPOLATION_PATTERNS.some((pattern) => pattern.test(expr))) { continue; } if ( expr.startsWith("github.event.pull_request.title") || expr.startsWith("github.event.pull_request.body") || expr.startsWith("github.event.pull_request.head.ref") || expr.startsWith("github.event.pull_request.head.label") || expr.startsWith("github.event.issue.title") || expr.startsWith("github.event.issue.body") || expr.startsWith("github.event.comment.body") || expr.startsWith("github.event.review.body") || expr.startsWith("github.event.review_comment.body") || expr.startsWith("github.head_ref") || expr.startsWith("inputs.") ) { untrustedVars.add(expr); } } return { hasInterpolation: untrustedVars.size > 0, vars: Array.from(untrustedVars), }; } function isLegacyPublishTokenEnvName(envName) { if (!envName || typeof envName !== "string") { return false; } return ( envName.endsWith("_TOKEN") || envName.startsWith("POETRY_PYPI_TOKEN") || LEGACY_PUBLISH_TOKEN_ENV_NAMES.has(envName) ); } function detectPublishEcosystem(runValue) { if (!runValue || typeof runValue !== "string") { return undefined; } if (/\b(?:npm|pnpm|yarn|bun)\s+publish\b/i.test(runValue)) { return "npm"; } if ( /\btwine\s+upload\b/i.test(runValue) || /\bpoetry\s+publish\b/i.test(runValue) || /\bflit\s+publish\b/i.test(runValue) ) { return "pypi"; } return undefined; } function normalizeRunValueEntry(entry) { if ( typeof entry === "string" || typeof entry === "number" || typeof entry === "boolean" ) { return String(entry); } return ""; } function normalizeRunValue(runValue) { if (typeof runValue === "string") { return runValue; } if (typeof runValue === "number" || typeof runValue === "boolean") { return String(runValue); } if (Array.isArray(runValue)) { const normalizedEntries = runValue .map((entry) => normalizeRunValueEntry(entry)) .filter(Boolean); return normalizedEntries.length ? normalizedEntries.join("\n") : undefined; } return undefined; } function analyzeLegacyPublishStep(step, effectiveEnv) { const props = []; const normalizedRun = normalizeRunValue(step?.run); const publishEcosystem = detectPublishEcosystem(normalizedRun); if (!publishEcosystem) { return props; } const tokenSources = []; if (normalizedRun && /\B--token(?:=|\s+\S+)/i.test(normalizedRun)) { tokenSources.push("cli-flag"); } const legacyEnvNames = Object.keys(effectiveEnv || {}).filter( isLegacyPublishTokenEnvName, ); legacyEnvNames.forEach((envName) => { tokenSources.push(`env:${envName}`); }); props.push({ name: "cdx:github:step:isPublishCommand", value: "true", }); props.push({ name: "cdx:github:step:publishEcosystem", value: publishEcosystem, }); if (!tokenSources.length) { return props; } props.push({ name: "cdx:github:step:usesLegacyPublishToken", value: "true", }); props.push({ name: "cdx:github:step:legacyPublishTokenSources", value: tokenSources.join(","), }); return props; } function detectRunnerStateMutation(runValue) { if (!runValue || typeof runValue !== "string") { return { hasMutation: false, targets: [] }; } const targets = new Set(); const patterns = [ [ "GITHUB_ENV", /(?:>>?|1>>?)\s*["']?(?:\$GITHUB_ENV|\$\{GITHUB_ENV}|%GITHUB_ENV%|\$env:GITHUB_ENV)["']?/i, ], [ "GITHUB_PATH", /(?:>>?|1>>?)\s*["']?(?:\$GITHUB_PATH|\$\{GITHUB_PATH}|%GITHUB_PATH%|\$env:GITHUB_PATH)["']?/i, ], [ "GITHUB_OUTPUT", /(?:>>?|1>>?)\s*["']?(?:\$GITHUB_OUTPUT|\$\{GITHUB_OUTPUT}|%GITHUB_OUTPUT%|\$env:GITHUB_OUTPUT)["']?/i, ], ]; patterns.forEach(([target, pattern]) => { if (pattern.test(runValue)) { targets.add(target); } }); if (/::set-output\b/i.test(runValue)) { targets.add("GITHUB_OUTPUT"); } return { hasMutation: targets.size > 0, targets: Array.from(targets), }; } function detectOutboundNetworkCommand(runValue) { if (!runValue || typeof runValue !== "string") { return { hasOutboundCommand: false, tools: [] }; } const tools = []; OUTBOUND_NETWORK_TOOLS.forEach(([name, pattern]) => { if (pattern.test(runValue)) { tools.push(name); } }); return { hasOutboundCommand: tools.length > 0, tools, }; } function collectSensitiveEnvBindings(effectiveEnv) { const sensitiveRefs = []; Object.entries(effectiveEnv || {}).forEach(([envName, envValue]) => { if (isSensitiveEnvBinding(envName, envValue)) { sensitiveRefs.push(`env:${envName}`); } }); return sensitiveRefs; } function isSensitiveEnvBinding(envName, envValue) { if (!envName || typeof envName !== "string") { return false; } if (IMPLICIT_SENSITIVE_ENV_NAMES.includes(envName)) { return true; } if (SECRET_LIKE_ENV_NAME_PATTERN.test(envName)) { return true; } if (typeof envValue !== "string") { return false; } return SENSITIVE_ENV_VALUE_PATTERN.test(envValue); } function detectSensitiveContextReferences(runValue, effectiveEnv) { if (!runValue || typeof runValue !== "string") { return []; } const sensitiveRefs = new Set(); Object.entries(effectiveEnv || {}).forEach(([envName, envValue]) => { if (!isSensitiveEnvBinding(envName, envValue)) { return; } const envPattern = new RegExp( `(?:\\$${envName}\\b|\\$\\{${envName}\\}|%${envName}%|\\$env:${envName}\\b|process\\.env\\.${envName}\\b|process\\.env\\[['"]${envName}['"]])`, "i", ); if (envPattern.test(runValue)) { sensitiveRefs.add(`env:${envName}`); } }); const contextPatterns = [ ["context:github.token", /github\.token/i], ["context:secrets", /secrets\.[A-Za-z0-9_]+/i], [ "context:github-token-input", /github-token|process\.env\.GITHUB_TOKEN|process\.env\[['"]GITHUB_TOKEN['"]]/i, ], [ "context:actions-id-token", /ACTIONS_ID_TOKEN_REQUEST_(?:TOKEN|URL)|id-token/i, ], ]; contextPatterns.forEach(([name, pattern]) => { if (pattern.test(runValue)) { sensitiveRefs.add(name); } }); return Array.from(sensitiveRefs); } function detectOutboundExfiltrationIndicators(runValue, sensitiveContextRefs) { if ( !runValue || typeof runValue !== "string" || !Array.isArray(sensitiveContextRefs) || !sensitiveContextRefs.length ) { return []; } const indicators = new Set(); if ( /(?:^|\s)(?:--header|-H)\s+[^\n]*(?:authorization|x-(?:api-key|auth-token|github-token)|private-token|token:)/i.test( runValue, ) && SHELL_VARIABLE_REFERENCE_PATTERN.test(runValue) ) { indicators.add("auth-header"); } if ( /\b(?:--data(?:-raw|-binary|-urlencode)?|--body|--form|--upload-file|-InFile|-Body|-Form)\b|(?:^|\s)-[dFT]\b/i.test( runValue, ) ) { indicators.add("request-payload"); } if ( /(?:^|\s)(?:-X|--request)\s*(?:POST|PUT|PATCH)\b|\b-Method\s+(?:Post|Put|Patch)\b/i.test( runValue, ) ) { indicators.add("state-changing-method"); } if ( /\?[^\n"'\s]*(?:token|sig|signature|auth|secret|key)=/i.test(runValue) && SHELL_VARIABLE_REFERENCE_PATTERN.test(runValue) ) { indicators.add("query-parameter"); } if (/\b(?:scp|rsync)\b/i.test(runValue)) { indicators.add("file-transfer"); } if ( /\b(?:nc|ncat|netcat)\b[^\n]*(?:<|<<)/i.test(runValue) || /\|\s*(?:nc|ncat|netcat)\b/i.test(runValue) ) { indicators.add("stream-transfer"); } if ( /\b(?:base64|openssl\s+enc)\b[^\n|]*\|\s*(?:curl|wget|nc|ncat|netcat)\b/i.test( runValue, ) ) { indicators.add("encoded-payload"); } if ( sensitiveContextRefs.some( (ref) => ref === "context:actions-id-token" || ref === "context:github.token" || ref.startsWith("context:secrets"), ) ) { indicators.add("platform-credential"); } return Array.from(indicators); } function detectForkContextReferences(textValue) { if (!textValue || typeof textValue !== "string") { return []; } const refs = []; FORK_CONTEXT_PATTERNS.forEach(([name, pattern]) => { if (pattern.test(textValue)) { refs.push(name); } }); return refs; } function addDispatchTarget(targets, prefix, value) { if (!value || typeof value !== "string") { return; } const normalizedValue = value.trim(); if (!normalizedValue) { return; } targets.add(`${prefix}:${normalizedValue}`); } function normalizeDispatchTargetPrefix(key) { if (!key) { return "unknown"; } if (["repository", "repo"].includes(key)) { return "repo"; } if (["workflow", "workflow_id"].includes(key)) { return "workflow"; } if (key === "event-type") { return "event"; } return key.replace(/_/g, "-"); } function detectWorkflowDispatchInvocations(textValue) { const kinds = new Set(); const mechanisms = new Set(); const targets = new Set(); if (!textValue || typeof textValue !== "string") { return { hasDispatch: false, kinds: [], mechanisms: [], targets: [], usesExplicitRepositoryTarget: false, }; } const ghWorkflowRunMatch = textValue.match( /\bgh\s+workflow\s+run\s+([^\s"'`]+)/i, ); if (ghWorkflowRunMatch) { kinds.add("workflow_dispatch"); mechanisms.add("gh-workflow-run"); addDispatchTarget(targets, "workflow", ghWorkflowRunMatch[1]); } const ghRepoMatch = textValue.match( /\b--repo\s+([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)/i, ); if (ghRepoMatch) { addDispatchTarget(targets, "repo", ghRepoMatch[1]); } for (const match of textValue.matchAll( /\/repos\/([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)\/actions\/workflows\/([^/\s"'`]+)\/dispatches\b/gi, )) { kinds.add("workflow_dispatch"); mechanisms.add("github-api-workflow-dispatch"); addDispatchTarget(targets, "repo", match[1]); addDispatchTarget(targets, "workflow", match[2]); } for (const match of textValue.matchAll( /\/repos\/([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)\/dispatches\b/gi, )) { kinds.add("repository_dispatch"); mechanisms.add("github-api-repository-dispatch"); addDispatchTarget(targets, "repo", match[1]); } if ( /\b(?:github|octokit)\.rest\.actions\.createWorkflowDispatch\b/i.test( textValue, ) ) { kinds.add("workflow_dispatch"); mechanisms.add("github-script-workflow-dispatch"); } if ( /\b(?:github|octokit)\.rest\.repos\.createDispatchEvent\b/i.test(textValue) ) { kinds.add("repository_dispatch"); mechanisms.add("github-script-repository-dispatch"); } if ( /\b(?:github|octokit)\.request\s*\(\s*["'`](?:POST\s+)?\/repos\/\{owner}\/\{repo}\/actions\/workflows\/\{workflow_id}\/dispatches/i.test( textValue, ) ) { kinds.add("workflow_dispatch"); mechanisms.add("octokit-request-workflow-dispatch"); } if ( /\b(?:github|octokit)\.request\s*\(\s*["'`](?:POST\s+)?\/repos\/\{owner}\/\{repo}\/dispatches/i.test( textValue, ) ) { kinds.add("repository_dispatch"); mechanisms.add("octokit-request-repository-dispatch"); } const ownerMatch = textValue.match(/\bowner\s*:\s*["'`]([^"'`]+)["'`]/i); const repoMatch = textValue.match(/\brepo\s*:\s*["'`]([^"'`]+)["'`]/i); const workflowMatch = textValue.match( /\bworkflow(?:_id)?\s*:\s*["'`]([^"'`]+)["'`]/i, ); const eventTypeMatch = textValue.match( /\bevent_type\s*:\s*["'`]([^"'`]+)["'`]/i, ); const refMatch = textValue.match(/\bref\s*:\s*["'`]([^"'`]+)["'`]/i); if (ownerMatch && repoMatch) { addDispatchTarget(targets, "repo", `${ownerMatch[1]}/${repoMatch[1]}`); } if (workflowMatch) { addDispatchTarget(targets, "workflow", workflowMatch[1]); } if (eventTypeMatch) { addDispatchTarget(targets, "event", eventTypeMatch[1]); } if (refMatch) { addDispatchTarget(targets, "ref", refMatch[1]); } const targetList = Array.from(targets); return { hasDispatch: kinds.size > 0, kinds: Array.from(kinds), mechanisms: Array.from(mechanisms), targets: targetList, usesExplicitRepositoryTarget: targetList.some((target) => target.startsWith("repo:"), ), }; } function analyzeDispatchActionStep(step) { const props = []; if (!step?.uses || typeof step.uses !== "string") { return props; } const dispatchAction = KNOWN_DISPATCH_ACTIONS.find((candidate) => candidate.pattern.test(step.uses), ); if (!dispatchAction) { return props; } const targets = new Set(); dispatchAction.repoKeys.forEach((key) => { addDispatchTarget( targets, normalizeDispatchTargetPrefix(key), step.with?.[key], ); }); dispatchAction.targetKeys.forEach((key) => { addDispatchTarget( targets, normalizeDispatchTargetPrefix(key), step.with?.[key], ); }); props.push({ name: "cdx:github:step:dispatchesWorkflow", value: "true" }); props.push({ name: "cdx:github:step:dispatchKinds", value: dispatchAction.kind, }); props.push({ name: "cdx:github:step:dispatchMechanisms", value: dispatchAction.mechanism, }); if (targets.size) { props.push({ name: "cdx:github:step:dispatchTargets", value: Array.from(targets).join(","), }); } if (Array.from(targets).some((target) => target.startsWith("repo:"))) { props.push({ name: "cdx:github:step:dispatchUsesExplicitRepositoryTarget", value: "true", }); } return props; } function appendDispatchProperties(properties, dispatchInfo) { if (!dispatchInfo?.hasDispatch) { return; } properties.push({ name: "cdx:github:step:dispatchesWorkflow", value: "true", }); properties.push({ name: "cdx:github:step:dispatchKinds", value: dispatchInfo.kinds.join(","), }); properties.push({ name: "cdx:github:step:dispatchMechanisms", value: dispatchInfo.mechanisms.join(","), }); if (dispatchInfo.targets.length) { properties.push({ name: "cdx:github:step:dispatchTargets", value: dispatchInfo.targets.join(","), }); } if (dispatchInfo.usesExplicitRepositoryTarget) { properties.push({ name: "cdx:github:step:dispatchUsesExplicitRepositoryTarget", value: "true", }); } } function appendHiddenUnicodeProperties(properties, scan, prefix) { if (!scan?.hasHiddenUnicode) { return; } properties.push({ name: `${prefix}:hasHiddenUnicode`, value: "true", }); properties.push({ name: `${prefix}:hiddenUnicodeCodePoints`, value: scan.codePoints.join(","), }); properties.push({ name: `${prefix}:hiddenUnicodeLineNumbers`, value: scan.lineNumbers.join(","), }); if (scan.inComments) { properties.push({ name: `${prefix}:hiddenUnicodeInComments`, value: "true", }); properties.push({ name: `${prefix}:hiddenUnicodeCommentCodePoints`, value: scan.commentCodePoints.join(","), }); } } /** * Classify a GitHub Actions version reference as `"sha"`, `"tag"`, or `"branch"`. * * @param {string|undefined} versionRef - The part after `@` in `uses: owner/action@ref`. * @returns {"sha"|"tag"|"branch"|"unknown"} The pinning category. */ function getVersionPinningType(versionRef) { if (!versionRef) { return "unknown"; } if (/^[a-f0-9]{40}$/.test(versionRef)) { return "sha"; } if ( versionRef === "main" || versionRef === "master" || versionRef.includes("/") ) { return "branch"; } return "tag"; } /** * Normalise the `on:` trigger value from a workflow YAML into a * comma-separated string of trigger names. * * GitHub Actions supports three forms: * - string: `on: push` * - array: `on: [push, pull_request]` * - object: `on: { push: { branches: [main] } }` * * @param {string|string[]|Object|undefined} triggers - Raw `on` value. * @returns {string} Comma-separated trigger names, or empty string. */ function normalizeTriggers(triggers) { if (!triggers) return ""; if (typeof triggers === "string") return triggers; if (Array.isArray(triggers)) return triggers.join(","); return Object.keys(triggers).join(","); } function extractWorkflowDispatchInputs(triggers) { if (!triggers || typeof triggers !== "object") { return []; } if (!triggers.workflow_dispatch?.inputs) { return []; } return Object.keys(triggers.workflow_dispatch.inputs); } function extractRepositoryDispatchTypes(triggers) { if (!triggers || typeof triggers !== "object") { return []; } const repositoryDispatch = triggers.repository_dispatch; if (!repositoryDispatch || typeof repositoryDispatch !== "object") { return []; } if (!Array.isArray(repositoryDispatch.types)) { return []; } return repositoryDispatch.types .map((eventType) => String(eventType || "").trim()) .filter(Boolean); } function normalizeTriggerNames(triggers) { const csv = normalizeTriggers(triggers); if (!csv) { return []; } return csv .split(",") .map((trigger) => trigger.trim()) .filter(Boolean); } function extractWorkflowCallMetadata(triggers) { if (!triggers || typeof triggers !== "object") { return { inputs: [], outputs: [], secrets: [] }; } const workflowCall = triggers.workflow_call; if (!workflowCall || typeof workflowCall !== "object") { return { inputs: [], outputs: [], secrets: [] }; } return { inputs: Object.keys(workflowCall.inputs || {}), outputs: Object.keys(workflowCall.outputs || {}), secrets: Object.keys(workflowCall.secrets || {}), }; } /** * Determine whether the given trigger value includes at least one high-risk * trigger (`pull_request_target`, `issue_comment`, or `workflow_run`). * * @param {string|string[]|Object|undefined} triggers - Raw `on` value. * @returns {boolean} */ function hasHighRiskTrigger(triggers) { const csv = normalizeTriggers(triggers); if (!csv) return false; return csv.split(",").some((t) => HIGH_RISK_TRIGGERS.includes(t.trim())); } /** * Build the set of common workflow-context properties that are duplicated * onto every component (action or run-step) so that policy rules written * against `components[…]` can evaluate workflow-level attributes without * traversing the formulation tree. * * @param {Object} ctx * @param {boolean} ctx.hasWritePermissions - Whether workflow OR job has write perms. * @param {boolean} ctx.hasIdTokenWrite - Whether `id-token: write` is granted. * @param {string} ctx.triggers - Comma-separated trigger names. * @param {boolean} ctx.isHighRisk - Whether any trigger is high-risk. * @param {string} concurrencyGroup - Workflow concurrency group. * @returns {Array<{name: string, value: string}>} */ function buildWorkflowContextProperties({ hasExplicitPermissionsBlock, hasAnyExplicitPermissionsBlock, hasWritePermissions, hasIdTokenWrite, triggers, triggerNames, isHighRisk, concurrencyGroup, writeScopes, dispatchInputs, repositoryDispatchTypes, workflowReceiverAliases, workflowCallMetadata, }) { const props = []; props.push({ name: "cdx:github:workflow:hasExplicitPermissionsBlock", value: String(Boolean(hasExplicitPermissionsBlock)), }); props.push({ name: "cdx:github:workflow:hasAnyExplicitPermissionsBlock", value: String(Boolean(hasAnyExplicitPermissionsBlock)), }); if (hasWritePermissions) { props.push({ name: "cdx:github:workflow:hasWritePermissions", value: "true", }); } if (hasIdTokenWrite) { props.push({ name: "cdx:github:workflow:hasIdTokenWrite", value: "true", }); } if (writeScopes?.length) { props.push({ name: "cdx:github:workflow:writeScopes", value: [...new Set(writeScopes)].join(","), }); } if (triggers) { props.push({ name: "cdx:github:workflow:triggers", value: triggers }); } const triggerSet = new Set(triggerNames || normalizeTriggerNames(triggers)); const triggerFlags = [ ["pull_request", "cdx:github:workflow:hasPullRequestTrigger"], ["pull_request_target", "cdx:github:workflow:hasPullRequestTargetTrigger"], ["issue_comment", "cdx:github:workflow:hasIssueCommentTrigger"], ["repository_dispatch", "cdx:github:workflow:hasRepositoryDispatchTrigger"], ["workflow_run", "cdx:github:workflow:hasWorkflowRunTrigger"], ["workflow_dispatch", "cdx:github:workflow:hasWorkflowDispatchTrigger"], ["workflow_call", "cdx:github:workflow:hasWorkflowCallTrigger"], ]; triggerFlags.forEach(([triggerName, propName]) => { if (triggerSet.has(triggerName)) { props.push({ name: propName, value: "true" }); } }); if (isHighRisk) { props.push({ name: "cdx:github:workflow:hasHighRiskTrigger", value: "true", }); } if (concurrencyGroup) { props.push({ name: "cdx:github:workflow:concurrencyGroup", value: concurrencyGroup, }); } if (dispatchInputs?.length) { props.push({ name: "cdx:github:workflow:hasWorkflowDispatchInputs", value: "true", }); props.push({ name: "cdx:github:workflow:workflowDispatchInputs", value: dispatchInputs.join(","), }); } if (repositoryDispatchTypes?.length) { props.push({ name: "cdx:github:workflow:repositoryDispatchTypes", value: repositoryDispatchTypes.join(","), }); } if (workflowReceiverAliases?.length) { props.push({ name: "cdx:github:workflow:workflowDispatchReceiverAliases", value: workflowReceiverAliases.join(","), }); } if (workflowCallMetadata?.inputs?.length) { props.push({ name: "cdx:github:workflow:workflowCallInputs", value: workflowCallMetadata.inputs.join(","), }); } if (workflowCallMetadata?.secrets?.length) { props.push({ name: "cdx:github:workflow:workflowCallSecrets", value: workflowCallMetadata.secrets.join(","), }); } if (workflowCallMetadata?.outputs?.length) { props.push({ name: "cdx:github:workflow:workflowCallOutputs", value: workflowCallMetadata.outputs.join(","), }); } if ( workflowCallMetadata?.inputs?.length || workflowCallMetadata?.secrets?.length || workflowCallMetadata?.outputs?.length ) { props.push({ name: "cdx:github:workflow:isWorkflowCallProducer", value: "true", }); } return props; } function buildJobContextProperties({ hasExplicitPermissionsBlock, hasWritePermissions, hasIdTokenWrite, isSelfHosted, writeScopes, condition, }) { const props = []; props.push({ name: "cdx:github:job:hasExplicitPermissionsBlock", value: String(Boolean(hasExplicitPermissionsBlock)), }); if (hasWritePermissions) { props.push({ name: "cdx:github:job:hasWritePermissions", value: "true", }); } if (hasIdTokenWrite) { props.push({ name: "cdx:github:job:hasIdTokenWrite", value: "true", }); } if (isSelfHosted) { props.push({ name: "cdx:github:job:isSelfHosted", value: "true", }); } if (writeScopes?.length) { props.push({ name: "cdx:github:job:writeScopes", value: [...new Set(writeScopes)].join(","), }); } if (condition) { props.push({ name: "cdx:github:job:if", value: condition }); } return props; } /** * @param {string} filePath workflow file path * @returns {string} workflow name derived from the file stem */ function deriveWorkflowNameFromPath(filePath) { const pathImpl = filePath.includes("\\") ? path.win32 : path.posix; return pathImpl.parse(pathImpl.basename(filePath)).name; } function deriveWorkflowReceiverAliases(filePath, workflowName) { const aliases = new Set(); if (workflowName) { aliases.add(String(workflowName).trim()); } if (filePath) { const normalizedPath = String(filePath).replace(/\\/g, "/"); const fileName = normalizedPath.split("/").pop() || normalizedPath; const fileStem = fileName.replace(/\.ya?ml$/i, ""); aliases.add(fileName); aliases.add(fileStem); aliases.add(normalizedPath); } return Array.from(aliases) .map((alias) => alias.trim()) .filter(Boolean); } function getPropertyValue(obj, propName) { return obj?.properties?.find((property) => property.name === propName)?.value; } function upsertCsvProperty(properties, name, values) { const normalizedValues = [...new Set((values || []).filter(Boolean))]; if (!normalizedValues.length) { return; } const existingProperty = properties.find( (property) => property.name === name, ); if (!existingProperty) { properties.push({ name, value: normalizedValues.join(",") }); return; } existingProperty.value = [ ...new Set([ ...String(existingProperty.value || "") .split(",") .map((value) => value.trim()) .filter(Boolean), ...normalizedValues, ]), ].join(","); } function upsertBooleanProperty(properties, name) { const existingProperty = properties.find( (property) => property.name === name, ); if (existingProperty) { existingProperty.value = "true"; return; } properties.push({ name, value: "true" }); } function parseDispatchTargets(value) { return String(value || "") .split(",") .map((target) => target.trim()) .filter(Boolean) .map((target) => { const separatorIndex = target.indexOf(":"); if (separatorIndex === -1) { return { type: "unknown", value: target }; } return { type: target.slice(0, separatorIndex), value: target.slice(separatorIndex + 1), }; }); } function normalizeDispatchTargetValue(value) { return String(value || "") .trim() .toLowerCase(); } function buildLocalDispatchReceiverIndexes(workflows) { const workflowDispatchAliasIndex = new Map(); const repositoryDispatchTypeIndex = new Map(); (workflows || []).forEach((workflow) => { if ( getPropertyValue( workflow, "cdx:github:workflow:hasWorkflowDispatchTrigger", ) === "true" ) { const aliases = String( getPropertyValue( workflow, "cdx:github:workflow:workflowDispatchReceiverAliases", ) || "", ) .split(",") .map((alias) => alias.trim()) .filter(Boolean); aliases.forEach((alias) => { const normalizedAlias = normalizeDispatchTargetValue(alias); if (!workflowDispatchAliasIndex.has(normalizedAlias)) { workflowDispatchAliasIndex.set(normalizedAlias, []); } workflowDispatchAliasIndex.get(normalizedAlias).push(workflow); }); } if ( getPropertyValue( workflow, "cdx:github:workflow:hasRepositoryDispatchTrigger", ) === "true" ) { const eventTypes = String( getPropertyValue( workflow, "cdx:github:workflow:repositoryDispatchTypes", ) || "", ) .split(",") .map((eventType) => eventType.trim()) .filter(Boolean); eventTypes.forEach((eventType) => { const normalizedEventType = normalizeDispatchTargetValue(eventType); if (!repositoryDispatchTypeIndex.has(normalizedEventType)) { repositoryDispatchTypeIndex.set(normalizedEventType, []); } repositoryDispatchTypeIndex.get(normalizedEventType).push(workflow); }); } }); return { repositoryDispatchTypeIndex, workflowDispatchAliasIndex, }; } function enrichLocalDispatchRelationships(workflows, components) { const { repositoryDispatchTypeIndex, workflowDispatchAliasIndex } = buildLocalDispatchReceiverIndexes(workflows); (components || []).forEach((component) => { if ( getPropertyValue(component, "cdx:github:step:dispatchesWorkflow") !== "true" ) { return; } const dispatchTargets = parseDispatchTargets( getPropertyValue(component, "cdx:github:step:dispatchTargets"), ); if (dispatchTargets.some((target) => target.type === "repo")) { return; } const matchedWorkflows = []; const matchBases = []; dispatchTargets.forEach((target) => { if (target.type === "workflow") { const candidates = workflowDispatchAliasIndex.get( normalizeDispatchTargetValue(target.value), ) || []; if (candidates.length === 1) { matchedWorkflows.push(candidates[0]); matchBases.push(`workflow:${target.value}`); } } if (target.type === "event") { const candidates = repositoryDispatchTypeIndex.get( normalizeDispatchTargetValue(target.value), ) || []; if (candidates.length === 1) { matchedWorkflows.push(candidates[0]); matchBases.push(`repository_dispatch:${target.value}`); } } }); const uniqueMatchedWorkflows = [...new Set(matchedWorkflows)]; if (!uniqueMatchedWorkflows.length) { return; } const receiverWorkflowFiles = uniqueMatchedWorkflows .map((workflow) => getPropertyValue(workflow, "cdx:github:workflow:file")) .filter(Boolean); const receiverWorkflowNames = uniqueMatchedWorkflows .map((workflow) => getPropertyValue(workflow, "cdx:github:workflow:name")) .filter(Boolean); upsertBooleanProperty( component.properties, "cdx:github:step:hasLocalDispatchReceiver", ); upsertCsvProperty( component.properties, "cdx:github:step:dispatchReceiverWorkflowFiles", receiverWorkflowFiles, ); upsertCsvProperty( component.properties, "cdx:github:step:dispatchReceiverWorkflowNames", receiverWorkflowNames, ); upsertCsvProperty( component.properties, "cdx:github:step:dispatchReceiverMatchBasis", matchBases, ); upsertCsvProperty( component.properties, "cdx:github:step:dispatchReceiverConfidence", ["high"], ); uniqueMatchedWorkflows.forEach((workflow) => { const senderWorkflowFile = getPropertyValue( component, "cdx:github:workflow:file", ); const senderWorkflowName = getPropertyValue( component, "cdx:github:workflow:name", ); upsertBooleanProperty( workflow.properties, "cdx:github:workflow:hasLocalDispatchSender", ); upsertCsvProperty( workflow.properties, "cdx:github:workflow:dispatchSenderWorkflowFiles", [senderWorkflowFile], ); upsertCsvProperty( workflow.properties, "cdx:github:workflow:dispatchSenderWorkflowNames", [senderWorkflowName], ); upsertCsvProperty( workflow.properties, "cdx:github:workflow:dispatchSenderMatchBasis", matchBases, ); }); }); } function buildReusableWorkflowComponent( job, jobName, filePath, workflowName, jobRunner, jobContextProperties, workflowContextProperties, options, ) { const uses = job?.uses; if (!uses || typeof uses !== "string") { return undefined; } let group; let name = uses; let purl; let versionRef; let versionPinningType = "unknown"; let isShaPinned = false; const isExternal = !uses.startsWith("./"); if (isExternal) { const tmpA = uses.split("@"); const workflowRef = tmpA[0]; versionRef = tmpA[1]; versionPinningType = getVersionPinningType(versionRef); isShaPinned = versionPinningType === "sha"; if (workflowRef.includes("/.github/workflows/")) { const [repoPath, workflowPath] = workflowRef.split("/.github/workflows/"); group = repoPath; name = workflowPath; } else { const refParts = workflowRef.split("/"); name = refParts.pop() || workflowRef; group = refParts.join("/"); } if (versionRef) { purl = new PackageURL( "github", group || undefined, name, versionRef, null, null, ).toString(); } } else { const pathImpl = uses.includes("\\") ? path.win32 : path.posix; name = pathImpl.basename(uses); } const componentRef = purl || `github-workflow:${uses}`; const properties = [ { name: "SrcFile", value: filePath }, { name: "cdx:github:workflow:name", value: workflowName }, { name: "cdx:github:workflow:file", value: filePath }, { name: "cdx:github:job:name", value: jobName }, { name: "cdx:github:job:runner", value: normalizeRunnerValue(jobRunner), }, { name: "cdx:github:reusableWorkflow:uses", value: uses }, { name: "cdx:github:reusableWorkflow:isExternal", value: String(isExternal), }, { name: "cdx:github:reusableWorkflow:versionPinningType", value: versionPinningType, }, { name: "cdx:github:reusableWorkflow:isShaPinned", value: String(isShaPinned), }, ]; if (versionRef) { p