From 614d49404be55cf4b9a2cc446c1336fce520329b Mon Sep 17 00:00:00 2001 From: Vladimir Date: Fri, 21 Aug 2026 11:31:32 +0800 Subject: [PATCH 01/37] Add tested public-source release audit Replace the inline worktree regex scan with a deterministic HEAD-and-index gate, fixture its source, history, workflow, encoding, LFS, redaction, and GitHub-control boundaries, and add the thin managed skill plus package documentation and MIT provenance. --- README.md | 31 +- THIRD_PARTY_NOTICES.md | 11 + bootstrap.sh | 1 + public-source-release-audit/SKILL.md | 67 + .../agents/openai.yaml | 4 + .../scripts/public-safety-audit-core.mjs | 3852 +++++++++++++++++ .../scripts/public-source-release-audit.mjs | 623 +++ .../public-source-release-audit.test.mjs | 467 ++ scripts/verify.sh | 37 +- 9 files changed, 5057 insertions(+), 36 deletions(-) create mode 100644 public-source-release-audit/SKILL.md create mode 100644 public-source-release-audit/agents/openai.yaml create mode 100644 public-source-release-audit/scripts/public-safety-audit-core.mjs create mode 100644 public-source-release-audit/scripts/public-source-release-audit.mjs create mode 100644 public-source-release-audit/tests/public-source-release-audit.test.mjs diff --git a/README.md b/README.md index e224c38..7539d9c 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # 51Code Agent Skills -This repository is the source package for 15 skills maintained by 51Code and +This repository is the source package for 16 skills maintained by 51Code and the bootstrap for our reviewed machine-global skill baseline. The repository intentionally has no catalog, registry, generated lock, sync @@ -9,14 +9,15 @@ package directly. ## Global baseline -The global baseline supports Codex and Claude Code and contains these 38 skills: +The global baseline supports Codex and Claude Code and contains these 39 skills: - 51Code-owned: `code-review`, `gemini-files-api`, `harness-engineering`, `hint-overlay-visual-verification`, `ios-xcodegen`, `lifecycle-and-side-effects-correctness`, `local-model-serving`, `mechanism-audit`, - `meeting-transcription`, `silent-pushes-setup`, `spec-creation-updating`, - `swift-testing`, `swiftui-view-refactor`, `xcode-build`, and `xcode-cloud` + `meeting-transcription`, `public-source-release-audit`, + `silent-pushes-setup`, `spec-creation-updating`, `swift-testing`, + `swiftui-view-refactor`, `xcode-build`, and `xcode-cloud` - Third-party: `swift-concurrency` and the 22 `asc-*` App Store Connect CLI skills from `rorkai/app-store-connect-cli-skills` @@ -37,7 +38,7 @@ idempotent. The script uses `skills@1.5.14`, explicit Git tags or commits, and explicit skill names. In this CLI, `#ref` selects a Git branch or tag; `@name` selects a skill and must not be used as a version pin. Sources pinned to a raw commit are checked out and verified before being passed to the manager as a -local source. Before reporting success, the script verifies all 38 entrypoints +local source. Before reporting success, the script verifies all 39 entrypoints in the shared and Claude Code manager roots and bootstraps the copied `gemini-files-api` dependencies in both roots. The Claude Code root honors `CLAUDE_CONFIG_DIR` when it is set. The script also converts the pinned @@ -46,6 +47,26 @@ manager's partial-install result into a non-zero bootstrap failure. The script installs and reconciles the baseline names only. It does not remove retired names or unrelated global skills installed outside this baseline. +## Public-source safety + +`public-source-release-audit` includes the repository's deterministic safety +gate. It audits committed files and the Git index, including filenames, +symlink targets, common encoding variants, high-confidence credentials, +private-key blocks, Git LFS pointers, and public-workflow trust boundaries. +Stage the intended publication candidate before relying on a local result: + +```bash +node public-source-release-audit/scripts/public-source-release-audit.mjs \ + --repo . +``` + +Before making a repository public or closing a suspected leak, run the same +gate with `--history` from a complete clone. Add `--github OWNER/REPO` and one +`--required-check NAME` per expected check to verify live GitHub visibility, +secret scanning, push protection, GitHub Actions check binding, branch rules, +and runner isolation. The gate's fixture suite runs as part of +`scripts/verify.sh`. + ## Updating the baseline 1. Update the 51Code-owned skill folders in this repository. diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index a5f3810..9a7f040 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -25,6 +25,17 @@ registry-local forks below also retain their reviewed upstream MIT notices. - Upstream notice: `Copyright (c) 2026 Thomas Ricouard` - Local state: modified maintained fork +## public-source-release-audit scanner + +- Source: [Codex Autopilot public safety audit](https://github.com/fiveonecode/autopilot/blob/9dd7ecb8a1aecb3d757b935a970991fd3461f5f4/agent-harness/src/lib/public-safety-audit.ts) +- Reviewed upstream commit: `9dd7ecb8a1aecb3d757b935a970991fd3461f5f4` +- Upstream notice: `Copyright (c) 2026 Codex Autopilot contributors` +- Local state: dependency-free JavaScript port with a repository-release wrapper + +The scanner is distributed under the MIT License reproduced in this +repository's [LICENSE](LICENSE). Keep local behavior changes fixture-backed and +reconcile future upstream changes deliberately. + The global bootstrap also installs `swift-concurrency` directly from its pinned upstream repository. It is not vendored here; its upstream license and notices remain authoritative. Impeccable remains an optional project-local install diff --git a/bootstrap.sh b/bootstrap.sh index 72780a0..8de31f2 100755 --- a/bootstrap.sh +++ b/bootstrap.sh @@ -39,6 +39,7 @@ owned_skills=( local-model-serving mechanism-audit meeting-transcription + public-source-release-audit silent-pushes-setup spec-creation-updating swift-testing diff --git a/public-source-release-audit/SKILL.md b/public-source-release-audit/SKILL.md new file mode 100644 index 0000000..4de7ad5 --- /dev/null +++ b/public-source-release-audit/SKILL.md @@ -0,0 +1,67 @@ +--- +name: public-source-release-audit +description: Audit a repository before public publication when committed files, Git history, workflow runner trust, credentials, private machine paths, or GitHub protection settings could leak or weaken the public source boundary. Use for public-repository launches, release readiness, repository visibility changes, or investigation of a suspected public-source leak; do not use it as a substitute for product security review. +--- + +# Public Source Release Audit + +Use the bundled gate as the deterministic oracle, then apply judgment only to +the residual public meaning that a scanner cannot decide. + +## Run The Gate + +From the repository being audited, resolve this skill's installed directory +and run: + +```bash +node /scripts/public-source-release-audit.mjs --repo . +``` + +This scans `HEAD` and the Git index, not dirty working-tree replacements. Stage +the intended publication candidate before relying on the result. + +Before a public launch or after a suspected leak, use a complete, non-shallow +clone with all public refs and Git LFS objects present, then add `--history`. +History mode fails closed for shallow or grafted repositories. + +For live GitHub controls, add the canonical repository and every required check: + +```bash +node /scripts/public-source-release-audit.mjs \ + --repo . --github OWNER/REPO --required-check CHECK_NAME +``` + +The GitHub mode requires evidence for public visibility, secret scanning, push +protection, strict default-branch status checks bound to GitHub Actions, +deletion and force-push protection, zero ruleset bypass actors, and no +self-hosted runner available to the public repository. Inability to read +required evidence is a failure, not a clean result. + +## Interpret Results + +- Any error finding blocks publication. Do not bypass it by scanning a dirty + replacement, dropping history, or weakening a rule. +- The gate never prints matched credential or path values. Preserve that + redaction in reports, issues, logs, and review comments. +- Workflow advisories identify mutable action refs, `pull_request_target`, or + dynamic runner selection that needs threat-model review. Use + `--fail-on-warning` when the repository has adopted those stricter policies. +- A historical credential requires credential revocation and a decision about + history remediation. Removing it only from the current tree is not closure. +- Fetch and verify missing Git LFS objects; do not allowlist an unaudited + pointer. + +## Finish The Human Audit + +The gate detects high-confidence credentials, private-key blocks, non-generic +machine homes, sensitive filenames and symlink targets, encoding variants, +and mechanical repository controls. It cannot decide whether ordinary prose, +screenshots, customer facts, private repository links, internal task names, or +business context are approved for publication. Review the staged diff, commit +messages, workflows, generated artifacts, and public-facing metadata for those +semantic leaks before declaring the source safe. + +Report the exact command, audited ref, whether history and live GitHub evidence +were included, error/advisory rule IDs, remediation, and remaining uncertainty. +Do not change GitHub settings, rewrite history, revoke credentials, push, or +publish without the authorization required for that separate action. diff --git a/public-source-release-audit/agents/openai.yaml b/public-source-release-audit/agents/openai.yaml new file mode 100644 index 0000000..efd5916 --- /dev/null +++ b/public-source-release-audit/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Public Source Release Audit" + short_description: "Audit repositories before public release" + default_prompt: "Use $public-source-release-audit to verify this repository's committed source, history, workflow trust, and live GitHub protections before publication." diff --git a/public-source-release-audit/scripts/public-safety-audit-core.mjs b/public-source-release-audit/scripts/public-safety-audit-core.mjs new file mode 100644 index 0000000..92b2972 --- /dev/null +++ b/public-source-release-audit/scripts/public-safety-audit-core.mjs @@ -0,0 +1,3852 @@ +/* + * Adapted from the Codex Autopilot public-safety audit at reviewed commit + * 9dd7ecb8a1aecb3d757b935a970991fd3461f5f4. Copyright (c) 2026 Codex + * Autopilot contributors. MIT license; see THIRD_PARTY_NOTICES.md. + * + * This file is the dependency-free JavaScript build of the reviewed + * TypeScript scanner. Keep behavior changes fixture-backed and reconcile them + * with the upstream source deliberately. + */ + +// -------------------------------------------------------------------------- // +// IMPORTS // +// -------------------------------------------------------------------------- // +import { execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { closeSync, existsSync, lstatSync, mkdtempSync, openSync, readFileSync, readSync, rmSync, statSync, writeSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { StringDecoder } from "node:string_decoder"; +import { TextDecoder } from "node:util"; +// -------------------------------------------------------------------------- // +// CONSTANTS // +// -------------------------------------------------------------------------- // +const GENERIC_HOME_DIRECTORY_NAMES = new Set([ + "alice", + "dashboard", + "example", + "fixture", + "fixtures", + "nobody", + "operator", + "public", + "runner", + "settings", + "shared", + "test", + "tester", + "user", + "username", +]); +const HISTORY_SHA_PATTERN = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/iu; +const GIT_TEXT_MAX_BUFFER_BYTES = 64 * 1024 * 1024; +const GITHUB_APP_JWT_PATTERN = /(?\])},;:]/u; +const POSIX_HOME_PATH_PATTERN = /\/(?:[Uu][Ss][Ee][Rr][Ss]|home)\/+([^\/\r\n]+)/gu; +const POSIX_HOME_PATH_BOUNDARY_PATTERN = /[\s"'`<>\])},;:]/u; +const PUTTY_ARGON2_DERIVATION_FIELDS = [ + "Key-Derivation", + "Argon2-Memory", + "Argon2-Passes", + "Argon2-Parallelism", + "Argon2-Salt", +]; +const PUTTY_KEY_DERIVATION_LINE_PATTERN = /^Key-Derivation: Argon2(?:d|i|id)$/u; +const PUTTY_KEY_DERIVATION_PARAMETER_LINE_PATTERN = /^(?:Argon2-(?:Memory|Passes|Parallelism): [1-9][0-9]{0,9}|Argon2-Salt: [0-9a-f]+)$/iu; +const PUTTY_PRIVATE_KEY_LINE_COUNT_PATTERN = /^Private-Lines: ([1-9][0-9]{0,5})$/u; +const PUTTY_COMMENT_LINE_PATTERN = /^Comment: [^\r\n]*$/u; +const PUTTY_PRIVATE_KEY_PAYLOAD_LINE_PATTERN = /^[A-Za-z0-9+/]+={0,2}$/u; +const PUTTY_PUBLIC_KEY_LINE_COUNT_PATTERN = /^Public-Lines: ([1-9][0-9]{0,5})$/u; +const ROOT_HOME_NAME = "ro" + "ot"; +const POSIX_HOME_NAME = "ho" + "me"; +const USERS_HOME_NAME = "Us" + "ers"; +const POSIX_HOME_PREFIX = ["", POSIX_HOME_NAME, ""].join("/"); +const VAR_ROOT_PREFIX = ["/var", ROOT_HOME_NAME].join("/"); +const ROOT_HOME_PATH_PATTERN = new RegExp(`/(?:private/var/${ROOT_HOME_NAME}|var/${ROOT_HOME_NAME}|${ROOT_HOME_NAME})`, "gu"); +const POSIX_USERS_PREFIX_PATTERN = new RegExp(`/${USERS_HOME_NAME}/`, "iu"); +const WINDOWS_USERS_PREFIX_PATTERN = new RegExp(String.raw `\\${USERS_HOME_NAME}\\`, "iu"); +const WINDOWS_DRIVE_USERS_PREFIX_PATTERN = new RegExp(`[A-Za-z]:[\\\\/]+${USERS_HOME_NAME}[\\\\/]+`, "iu"); +const SPARSE_UTF16_MIN_ALIGNED_NULL_BYTES = 8; +const SPARSE_UTF32_MIN_ALIGNED_NULL_CODE_POINTS = 8; +const STREAMING_PRIVATE_KEY_PENDING_LINE_MAX_CHARACTERS = 4096; +const TEXT_FILE_LINE_SEGMENT_MAX_CHARACTERS = 64 * 1024; +const WINDOWS_HOME_PATH_PATTERN = /(?:[A-Za-z]:)?[\\/]+Users[\\/]+([^\\/:\r\n"<>|?*]+)/giu; +const STRICT_UTF8_TEXT_DECODER = new TextDecoder("utf-8", { fatal: true }); +const WINDOWS_1252_TEXT_DECODER = new TextDecoder("windows-1252"); +const ACCESS_TOKEN_PATTERNS = [ + /(? containsAccessToken(source), + }, + { + id: "machine-home-path", + matches: (source) => containsNonGenericMachineHomePath(source), + }, + { + id: "private-key", + matches: (source) => containsPrivateKeyBlock(source), + }, +]; +// -------------------------------------------------------------------------- // +// EXPORTS // +// -------------------------------------------------------------------------- // +export function auditPublicSafety(options) { + const repoRoot = path.resolve(options.repoRoot); + const trackedTreeResult = auditTrackedTree(repoRoot); + const additionalSourceFindings = auditAdditionalSources(options.additionalSources ?? []); + const historyResult = options.includeHistory === true + ? auditHistory(repoRoot) + : { commitCount: 0, findings: [] }; + const allFindings = uniqueSortedFindings([ + ...additionalSourceFindings, + ...historyResult.findings, + ...trackedTreeResult.findings, + ]); + const findings = allFindings.slice(0, MAX_REPORTED_FINDINGS); + return { + findingCount: allFindings.length, + findings, + historyScanned: options.includeHistory === true, + omittedFindingCount: Math.max(allFindings.length - findings.length, 0), + passed: allFindings.length === 0, + scannedHistoryCommitCount: historyResult.commitCount, + scannedTrackedFileCount: trackedTreeResult.fileCount, + }; +} +// -------------------------------------------------------------------------- // +// HELPERS // +// -------------------------------------------------------------------------- // +function auditHistory(repoRoot) { + assertNoGitGrafts(repoRoot); + assertNoGitShallowBoundary(repoRoot); + const messageResult = auditCommitMessageLog(repoRoot, [ + "log", + "--all", + "--no-color", + "--no-decorate", + "--no-show-signature", + "--format=%H%x00Author: %an <%ae>%nCommitter: %cn <%ce>%n%B%x00", + ]); + const rawCommitFindings = auditRawCommitObjects(repoRoot); + const tagMessageFindings = auditAnnotatedTagMessages(repoRoot); + const historicalRefNameFindings = auditHistoricalRefNames(repoRoot); + const patchFindings = auditPatch(repoRoot, [ + "log", + "--all", + "--root", + "--no-color", + "--no-decorate", + "--no-show-signature", + "--no-abbrev", + "--no-ext-diff", + "--no-textconv", + "--no-renames", + "--format=%H", + "--patch", + "--text", + "-m", + ]); + const historicalBlobFindings = auditHistoricalBlobSnapshots(repoRoot); + return { + commitCount: messageResult.commitCount, + findings: [ + ...messageResult.findings, + ...rawCommitFindings, + ...tagMessageFindings, + ...historicalRefNameFindings, + ...patchFindings, + ...historicalBlobFindings, + ], + }; +} +function auditAdditionalSources(sources) { + return sources.flatMap((source) => findingsForSource({ + path: redactSensitivePath(source.path), + scope: "external-input", + source: "workflow-input", + text: source.text, + })); +} +function assertNoGitGrafts(repoRoot) { + const gitCommonDirectory = runGit(repoRoot, ["rev-parse", "--git-common-dir"]).trim(); + const graftsPath = path.resolve(repoRoot, gitCommonDirectory, "info", "grafts"); + if (existsSync(graftsPath)) { + throw new Error("Public safety audit cannot safely scan history while .git/info/grafts is present."); + } +} +function assertNoGitShallowBoundary(repoRoot) { + const gitCommonDirectory = runGit(repoRoot, ["rev-parse", "--git-common-dir"]).trim(); + const shallowPath = path.resolve(repoRoot, gitCommonDirectory, "shallow"); + if (existsSync(shallowPath)) { + throw new Error("Public safety audit cannot safely scan history while .git/shallow is present."); + } +} +function auditRawCommitObjects(repoRoot) { + const findings = []; + scanRawCommitObjects(repoRoot, ({ commit, rawCommitObject, rawCommitObjectPath }) => { + if (rawCommitObject === undefined) { + if (rawCommitObjectPath !== undefined) { + findings.push(...findingsForRuleIds({ + commit, + ruleIds: findRuleIdsForLargeAuditTextFile(rawCommitObjectPath), + scope: "history", + source: "commit-message", + })); + } + return; + } + const rawHeaders = extractNonMergetagCommitHeaderBytes(rawCommitObject); + if (rawHeaders.length > 0) { + findings.push(...findingsForRuleIds({ + commit, + ruleIds: findRuleIdsForAuditTextBuffer(rawHeaders), + scope: "history", + source: "commit-message", + })); + } + const rawMessageBody = extractRawCommitMessageBodyBytes(rawCommitObject); + if (shouldAuditRawCommitMessageBodyBytes(rawMessageBody)) { + findings.push(...findingsForRuleIds({ + commit, + ruleIds: findRuleIdsForAuditTextBuffer(rawMessageBody), + scope: "history", + source: "commit-message", + })); + } + extractMergetagHeaders(rawCommitObject).forEach((mergetagHeader) => { + findings.push(...findingsForRuleIds({ + commit, + ruleIds: findRuleIdsForAuditTextBuffer(mergetagHeader.headers), + scope: "history", + source: "tag-message", + })); + findings.push(...findingsForRuleIds({ + commit, + ruleIds: findRuleIdsForAuditTextBuffer(mergetagHeader.messageBody), + scope: "history", + source: "tag-message", + })); + }); + }); + return findings; +} +function scanRawCommitObjects(repoRoot, onRawCommitObject) { + const commits = []; + scanGitOutputLines(repoRoot, ["rev-list", "--all"], (commit) => { + if (HISTORY_SHA_PATTERN.test(commit)) { + commits.push(commit); + } + }); + if (commits.length === 0) { + return; + } + scanGitOutputFromInput(repoRoot, ["cat-file", "--batch"], `${commits.join("\n")}\n`, (outputPath) => { + scanRawCommitObjectBatchFile(outputPath, onRawCommitObject); + }); +} +function scanRawCommitObjectBatchFile(outputPath, onRawCommitObject) { + const reader = createBufferedFileReader(outputPath); + try { + while (true) { + const headerBuffer = readBufferedLine(reader); + if (headerBuffer === undefined) { + return; + } + const header = headerBuffer.toString("utf8"); + const headerMatch = /^([0-9a-f]{40}|[0-9a-f]{64}) ([a-z]+) ([0-9]+)$/iu.exec(header); + if (headerMatch?.[1] === undefined || headerMatch[2] === undefined || headerMatch[3] === undefined) { + throw new Error("Public safety audit could not read the local Git history."); + } + const objectSize = Number.parseInt(headerMatch[3], 10); + if (Number.isSafeInteger(objectSize) === false || objectSize < 0) { + throw new Error("Public safety audit could not read the local Git history."); + } + if (headerMatch[2] === "commit") { + if (objectSize <= GIT_BLOB_FULL_DECODE_MAX_BYTES) { + onRawCommitObject({ + commit: headerMatch[1], + rawCommitObject: readBufferedBytes(reader, objectSize), + }); + } + else { + const rawCommitObjectPath = path.join(path.dirname(outputPath), `commit-${headerMatch[1]}`); + const rawCommitObjectFd = openSync(rawCommitObjectPath, "w"); + try { + readBufferedBytesToFile(reader, objectSize, rawCommitObjectFd); + } + finally { + closeSync(rawCommitObjectFd); + } + onRawCommitObject({ + commit: headerMatch[1], + rawCommitObjectPath, + }); + } + } + else { + skipBufferedBytes(reader, objectSize); + } + const separator = readBufferedByte(reader); + if (separator !== 0x0a) { + throw new Error("Public safety audit could not read the local Git history."); + } + } + } + finally { + closeSync(reader.fileDescriptor); + } +} +function extractNonMergetagCommitHeaderBytes(source) { + const lines = splitBufferLines(source); + const headers = []; + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index]; + if (line.length === 0) { + break; + } + if (line.subarray(0, "mergetag ".length).toString("ascii") === "mergetag ") { + index += 1; + while (index < lines.length && lines[index][0] === 0x20) { + index += 1; + } + index -= 1; + continue; + } + headers.push(line); + index += 1; + while (index < lines.length && lines[index][0] === 0x20) { + headers.push(lines[index]); + index += 1; + } + index -= 1; + } + return joinBufferLines(headers); +} +function extractRawCommitMessageBodyBytes(source) { + const headerEnd = source.indexOf("\n\n"); + return headerEnd < 0 ? Buffer.alloc(0) : source.subarray(headerEnd + 2); +} +function shouldAuditRawCommitMessageBodyBytes(source) { + return source.length > 0 + && (source.includes(0) || hasByteOrderMark(source) || looksLikeUtf16Le(source) || looksLikeUtf16Be(source)); +} +function extractMergetagHeaders(source) { + const lines = splitBufferLines(source); + const mergetags = []; + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index]; + if (line.length === 0) { + break; + } + if (line.subarray(0, "mergetag ".length).toString("ascii") !== "mergetag ") { + continue; + } + const tagObjectLines = [line.subarray("mergetag ".length)]; + index += 1; + while (index < lines.length && lines[index][0] === 0x20) { + tagObjectLines.push(lines[index].subarray(1)); + index += 1; + } + index -= 1; + mergetags.push(splitRawAnnotatedTagObject(joinBufferLines(tagObjectLines))); + } + return mergetags; +} +function splitRawAnnotatedTagObject(source) { + const lines = splitBufferLines(source); + const headerLines = []; + let messageStartIndex = lines.length; + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index]; + if (line.length === 0) { + messageStartIndex = index + 1; + break; + } + headerLines.push(line); + } + return { + headers: joinBufferLines(headerLines), + messageBody: joinBufferLines(lines.slice(messageStartIndex)), + }; +} +function splitBufferLines(source) { + const lines = []; + let offset = 0; + while (offset <= source.length) { + const lineEnd = source.indexOf(0x0a, offset); + if (lineEnd < 0) { + lines.push(source.subarray(offset)); + break; + } + lines.push(source.subarray(offset, lineEnd)); + offset = lineEnd + 1; + } + return lines; +} +function joinBufferLines(lines) { + if (lines.length === 0) { + return Buffer.alloc(0); + } + const separator = Buffer.from("\n"); + return Buffer.concat(lines.flatMap((line, index) => index === 0 ? [line] : [separator, line])); +} +function auditAnnotatedTagMessages(repoRoot) { + const findings = []; + scanGitOutputLines(repoRoot, [ + "for-each-ref", + "--format=%(objecttype)%00%(objectname)%00%(refname)", + "refs", + ], (line) => { + const [objectType, objectId, refName] = line.split("\0"); + if (objectType !== "tag" || objectId === undefined || HISTORY_SHA_PATTERN.test(objectId) === false) { + return; + } + findings.push(...auditReachableAnnotatedTagMessages(repoRoot, { + objectId, + refName, + })); + }); + return findings; +} +function auditReachableAnnotatedTagMessages(repoRoot, input) { + const findings = []; + const pendingTagObjectIds = [input.objectId]; + const redactedPath = input.refName === undefined ? undefined : redactSensitivePath(input.refName); + const temporaryDirectory = mkdtempSync(path.join(os.tmpdir(), "public-safety-audit-tag-")); + const visitedTagObjectIds = new Set(); + try { + while (pendingTagObjectIds.length > 0) { + const objectId = pendingTagObjectIds.pop(); + if (objectId === undefined || visitedTagObjectIds.has(objectId)) { + continue; + } + visitedTagObjectIds.add(objectId); + const tagObjectPath = path.join(temporaryDirectory, `tag-${objectId}`); + const tagHeaderPath = path.join(temporaryDirectory, `tag-header-${objectId}`); + const tagMessagePath = path.join(temporaryDirectory, `tag-message-${objectId}`); + writeGitCatFileTagToFile(repoRoot, objectId, tagObjectPath); + const tagObject = splitAnnotatedTagObject(tagObjectPath, tagHeaderPath, tagMessagePath); + findings.push(...findingsForRuleIds({ + commit: objectId, + path: redactedPath, + ruleIds: findRuleIdsForAuditTextFile(tagHeaderPath), + scope: "history", + source: "tag-message", + })); + findings.push(...findingsForRuleIds({ + commit: objectId, + path: redactedPath, + ruleIds: findRuleIdsForAuditTextFile(tagMessagePath), + scope: "history", + source: "tag-message", + })); + const target = parseAnnotatedTagTarget(tagObject.headersText); + if (target?.type === "tag") { + pendingTagObjectIds.push(target.objectId); + } + } + } + finally { + rmSync(temporaryDirectory, { force: true, recursive: true }); + } + return findings; +} +function splitAnnotatedTagObject(tagObjectPath, tagHeaderPath, tagMessagePath) { + const reader = createBufferedFileReader(tagObjectPath); + let headerFd; + let messageFd; + const headerLines = []; + let currentHeaderLine = ""; + let currentHeaderLineOverflowed = false; + let startsLine = true; + try { + headerFd = openSync(tagHeaderPath, "w"); + while (true) { + const segment = readBufferedLineSegment(reader, TEXT_FILE_LINE_SEGMENT_MAX_CHARACTERS); + if (segment === undefined) { + if (startsLine === false && currentHeaderLineOverflowed === false) { + headerLines.push(currentHeaderLine); + } + break; + } + if (startsLine && segment.endsLine && segment.buffer.length === 0) { + break; + } + writeSync(headerFd, segment.buffer); + if (segment.endsLine) { + writeSync(headerFd, "\n"); + } + if (currentHeaderLineOverflowed === false) { + const nextHeaderLine = `${currentHeaderLine}${decodeGitPathOutput(segment.buffer)}`; + if (nextHeaderLine.length <= TEXT_FILE_LINE_SEGMENT_MAX_CHARACTERS) { + currentHeaderLine = nextHeaderLine; + } + else { + currentHeaderLineOverflowed = true; + } + } + if (segment.endsLine) { + if (currentHeaderLineOverflowed === false) { + headerLines.push(currentHeaderLine); + } + currentHeaderLine = ""; + currentHeaderLineOverflowed = false; + startsLine = true; + } + else { + startsLine = false; + } + } + closeSync(headerFd); + headerFd = undefined; + messageFd = openSync(tagMessagePath, "w"); + writeBufferedRemainderToFile(reader, messageFd); + closeSync(messageFd); + messageFd = undefined; + } + finally { + if (headerFd !== undefined) { + closeSync(headerFd); + } + if (messageFd !== undefined) { + closeSync(messageFd); + } + closeSync(reader.fileDescriptor); + } + return { + headersText: headerLines.join("\n"), + }; +} +function readAnnotatedTagHeaderText(tagObjectPath) { + const reader = createBufferedFileReader(tagObjectPath); + const headerLines = []; + let currentHeaderLine = ""; + let currentHeaderLineOverflowed = false; + let startsLine = true; + try { + while (true) { + const segment = readBufferedLineSegment(reader, TEXT_FILE_LINE_SEGMENT_MAX_CHARACTERS); + if (segment === undefined) { + if (startsLine === false && currentHeaderLineOverflowed === false) { + headerLines.push(currentHeaderLine); + } + break; + } + if (startsLine && segment.endsLine && segment.buffer.length === 0) { + break; + } + if (currentHeaderLineOverflowed === false) { + const nextHeaderLine = `${currentHeaderLine}${decodeGitPathOutput(segment.buffer)}`; + if (nextHeaderLine.length <= TEXT_FILE_LINE_SEGMENT_MAX_CHARACTERS) { + currentHeaderLine = nextHeaderLine; + } + else { + currentHeaderLineOverflowed = true; + } + } + if (segment.endsLine) { + if (currentHeaderLineOverflowed === false) { + headerLines.push(currentHeaderLine); + } + currentHeaderLine = ""; + currentHeaderLineOverflowed = false; + startsLine = true; + } + else { + startsLine = false; + } + } + } + finally { + closeSync(reader.fileDescriptor); + } + return headerLines.join("\n"); +} +function auditHistoricalRefNames(repoRoot) { + const findings = []; + scanGitOutputLines(repoRoot, [ + "for-each-ref", + "--format=%(objectname)%00%(refname)", + "refs", + ], (line) => { + const [objectId, refName] = line.split("\0"); + if (objectId === undefined + || refName === undefined + || refName.length === 0 + || HISTORY_SHA_PATTERN.test(objectId) === false) { + return; + } + findings.push(...findingsForSource({ + commit: objectId, + path: redactSensitivePath(refName), + scope: "history", + source: "patch", + text: refName, + })); + }); + return findings; +} +function auditCommitMessageLog(repoRoot, gitArgs) { + const findings = []; + const commits = new Set(); + let commit; + let commitRecord = ""; + let messageWindow = ""; + const reportedFindingKeys = new Set(); + const scanMessageSegment = (source) => { + if (commit === undefined) { + return; + } + commits.add(commit); + if (source.length > LARGE_BLOB_SCAN_OVERLAP_CHARACTERS) { + scanCommitMessageWindow(commit, source, findings, reportedFindingKeys); + } + messageWindow = boundedAuditTextWindow(messageWindow, source); + scanCommitMessageWindow(commit, messageWindow, findings, reportedFindingKeys); + }; + scanGitOutputRecordSegments(repoRoot, gitArgs, (segment) => { + if (segment.index % 2 === 0) { + commitRecord = boundedAuditTextWindow(commitRecord, segment.text); + if (segment.endsRecord) { + const candidate = commitRecord.trim(); + commit = HISTORY_SHA_PATTERN.test(candidate) ? candidate : undefined; + commitRecord = ""; + } + return; + } + scanMessageSegment(segment.text); + if (segment.endsRecord) { + commit = undefined; + messageWindow = ""; + } + }); + return { + commitCount: commits.size, + findings, + }; +} +function scanCommitMessageWindow(commit, source, findings, reportedFindingKeys) { + const prefilterSources = source.includes("\\") + ? [source, decodeJsonEscapedTextFragment(source)] + : [source]; + PUBLIC_SAFETY_AUDIT_RULES.forEach((rule) => { + if (prefilterSources.some((prefilterSource) => couldMatchRuleInText(rule.id, prefilterSource)) === false + || rule.matches(source) === false) { + return; + } + const finding = { + commit, + ruleId: rule.id, + scope: "history", + source: "commit-message", + }; + const findingKey = `${finding.commit ?? ""}\0${finding.ruleId}\0${finding.scope}\0${finding.source}`; + if (reportedFindingKeys.has(findingKey)) { + return; + } + reportedFindingKeys.add(findingKey); + findings.push(finding); + }); +} +function auditPatch(repoRoot, gitArgs) { + let commit; + let additionWindow = ""; + let inHunk = false; + const findings = []; + const reportedFindingKeys = new Set(); + const flushAdditions = () => { + additionWindow = ""; + }; + const scanPatchWindow = (source) => { + if (commit === undefined) { + return; + } + findingsForSource({ + commit, + scope: "history", + source: "patch", + text: source, + ruleIds: ["access-token", "machine-home-path"], + }).forEach((finding) => { + const findingKey = `${finding.commit ?? ""}\0${finding.ruleId}\0${finding.scope}\0${finding.source}`; + if (reportedFindingKeys.has(findingKey)) { + return; + } + reportedFindingKeys.add(findingKey); + findings.push(finding); + }); + }; + const scanAddition = (source, input) => { + if (source.length > LARGE_BLOB_SCAN_OVERLAP_CHARACTERS) { + scanPatchWindow(source); + } + const separator = input.startsNewAddition && additionWindow.length > 0 ? "\n" : ""; + additionWindow = boundedAuditTextWindow(additionWindow, `${separator}${source}`); + scanPatchWindow(additionWindow); + }; + let inAdditionContinuation = false; + scanGitOutputLineSegments(repoRoot, gitArgs, (segment) => { + if (segment.startsLine && HISTORY_SHA_PATTERN.test(segment.text)) { + flushAdditions(); + commit = segment.text; + inHunk = false; + inAdditionContinuation = false; + return; + } + if (segment.startsLine && segment.text.startsWith("diff --git ")) { + flushAdditions(); + inHunk = false; + inAdditionContinuation = false; + return; + } + if (segment.startsLine && segment.text.startsWith("@@ ")) { + flushAdditions(); + inHunk = true; + inAdditionContinuation = false; + return; + } + if (inHunk && segment.startsLine && segment.text.startsWith("+")) { + scanAddition(normalizePatchAdditionText(segment.text.slice(1)), { + startsNewAddition: true, + }); + inAdditionContinuation = segment.endsLine === false; + return; + } + if (inHunk && inAdditionContinuation && segment.startsLine === false) { + scanAddition(normalizePatchAdditionText(segment.text), { + startsNewAddition: false, + }); + inAdditionContinuation = segment.endsLine === false; + return; + } + if (segment.endsLine) { + inAdditionContinuation = false; + } + }); + flushAdditions(); + return findings; +} +function auditHistoricalBlobSnapshots(repoRoot) { + const historicalBlobEntries = getHistoricalBlobEntries(repoRoot); + const findings = []; + const blobRuleIdsByObjectId = findRuleIdsForGitBlobs(repoRoot, [...new Set(historicalBlobEntries + .filter((entry) => entry.mode !== "160000") + .map((entry) => entry.objectId))]); + historicalBlobEntries.forEach((entry) => { + const redactedPath = redactSensitivePath(entry.path); + const pathRuleIds = uniqueRuleIds([ + ...entry.pathRuleIds, + ...findingsForSource({ + commit: entry.commit, + path: redactedPath, + scope: "history", + source: "patch", + text: entry.path, + }).map((finding) => finding.ruleId), + ]); + pathRuleIds.forEach((ruleId) => { + findings.push({ + commit: entry.commit, + path: redactedPath, + ruleId, + scope: "history", + source: "patch", + }); + }); + if (entry.mode === "160000") { + return; + } + blobRuleIdsByObjectId.get(entry.objectId)?.forEach((ruleId) => { + findings.push({ + commit: entry.commit, + path: redactSensitivePath(entry.path), + ruleId, + scope: "history", + source: "patch", + }); + }); + }); + return findings; +} +function auditTrackedTree(repoRoot) { + const trackedEntries = getTrackedEntries(repoRoot); + const trackedBlobEntries = trackedEntries.filter((entry) => entry.type === "blob" && entry.mode !== "160000"); + const blobRuleIdsByObjectId = findRuleIdsForGitBlobs(repoRoot, [...new Set(trackedBlobEntries.map((entry) => entry.objectId))]); + const findings = []; + let fileCount = 0; + trackedEntries.forEach((entry) => { + const redactedPath = redactSensitivePath(entry.path); + findings.push(...findingsForSource({ + path: redactedPath, + scope: "tracked-tree", + source: "tracked-file", + text: entry.path, + })); + if (entry.type !== "blob" || entry.mode === "160000") { + return; + } + fileCount += 1; + blobRuleIdsByObjectId.get(entry.objectId)?.forEach((ruleId) => { + findings.push({ + path: redactedPath, + ruleId, + scope: "tracked-tree", + source: "tracked-file", + }); + }); + }); + return { + fileCount, + findings, + }; +} +function decodeAuditText(source) { + if (source.length >= 4 && source[0] === 0xff && source[1] === 0xfe && source[2] === 0 && source[3] === 0) { + return decodeUtf32Text(source.subarray(4), "le"); + } + if (source.length >= 4 && source[0] === 0 && source[1] === 0 && source[2] === 0xfe && source[3] === 0xff) { + return decodeUtf32Text(source.subarray(4), "be"); + } + if (source.length >= 2 && source[0] === 0xff && source[1] === 0xfe) { + return stripTextByteOrderMark(source.subarray(2).toString("utf16le")); + } + if (source.length >= 2 && source[0] === 0xfe && source[1] === 0xff) { + return stripTextByteOrderMark(swapUtf16ByteOrder(source.subarray(2)).toString("utf16le")); + } + if (looksLikeUtf16Le(source)) { + return stripTextByteOrderMark(source.toString("utf16le")); + } + if (looksLikeUtf16Be(source)) { + return stripTextByteOrderMark(swapUtf16ByteOrder(source).toString("utf16le")); + } + return decodeSingleByteSafeText(source); +} +function scanDecodedAuditTextVariants(source, scanDecodedText) { + const scanText = (decodedText) => decodedText !== undefined && scanDecodedText(decodedText); + if (scanText(decodeAuditText(source))) { + return true; + } + if (scanText(decodeOppositeUtf16ByteOrderBomText(source))) { + return true; + } + if (scanText(decodeOppositeUtf32ByteOrderBomText(source))) { + return true; + } + if (hasByteOrderMark(source) || looksLikeUtf16Le(source) || looksLikeUtf16Be(source)) { + if (scanText(decodeSingleByteSafeText(source))) { + return true; + } + } + const shouldScanUtf16Alignments = hasUtf16ByteOrderMark(source) + || looksLikeUtf16Le(source) + || looksLikeUtf16Be(source); + if (shouldScanUtf16Alignments) { + for (const byteOrder of ["le", "be"]) { + for (const alignmentOffset of [0, 1]) { + if (scanText(decodeUtf16TextAtAlignment(source, byteOrder, alignmentOffset))) { + return true; + } + } + } + } + const shouldScanUtf32Alignments = hasUtf32ByteOrderMark(source) + || looksLikeUtf32Le(source) + || looksLikeUtf32Be(source); + if (shouldScanUtf32Alignments) { + const baseByteOffset = hasUtf32ByteOrderMark(source) ? 4 : 0; + for (const byteOrder of ["le", "be"]) { + for (const alignmentOffset of [0, 1, 2, 3]) { + if (scanText(decodeUtf32TextAtByteOffset(source, byteOrder, baseByteOffset + alignmentOffset))) { + return true; + } + } + } + } + return false; +} +function decodeUtf16TextAtAlignment(source, byteOrder, alignmentOffset) { + const alignedSource = source.subarray(alignmentOffset); + if (alignedSource.length < 2) { + return undefined; + } + const evenLength = alignedSource.length - (alignedSource.length % 2); + const alignedTextSource = alignedSource.subarray(0, evenLength); + return byteOrder === "le" + ? stripTextByteOrderMark(alignedTextSource.toString("utf16le")) + : stripTextByteOrderMark(swapUtf16ByteOrder(alignedTextSource).toString("utf16le")); +} +function decodeOppositeUtf16ByteOrderBomText(source) { + if (source.length >= 4 && source[0] === 0xff && source[1] === 0xfe && source[2] === 0 && source[3] === 0) { + return undefined; + } + if (source.length >= 4 && source[0] === 0 && source[1] === 0 && source[2] === 0xfe && source[3] === 0xff) { + return undefined; + } + if (source.length >= 2 && source[0] === 0xff && source[1] === 0xfe) { + return stripTextByteOrderMark(swapUtf16ByteOrder(source.subarray(2)).toString("utf16le")); + } + if (source.length >= 2 && source[0] === 0xfe && source[1] === 0xff) { + return stripTextByteOrderMark(source.subarray(2).toString("utf16le")); + } + return undefined; +} +function decodeOppositeUtf32ByteOrderBomText(source) { + if (source.length >= 4 && source[0] === 0xff && source[1] === 0xfe && source[2] === 0 && source[3] === 0) { + return decodeUtf32Text(source.subarray(4), "be"); + } + if (source.length >= 4 && source[0] === 0 && source[1] === 0 && source[2] === 0xfe && source[3] === 0xff) { + return decodeUtf32Text(source.subarray(4), "le"); + } + return undefined; +} +function hasUtf32ByteOrderMark(source) { + return (source.length >= 4 && source[0] === 0xff && source[1] === 0xfe && source[2] === 0 && source[3] === 0) + || (source.length >= 4 && source[0] === 0 && source[1] === 0 && source[2] === 0xfe && source[3] === 0xff); +} +function hasByteOrderMark(source) { + return (source.length >= 4 && source[0] === 0xff && source[1] === 0xfe && source[2] === 0 && source[3] === 0) + || (source.length >= 4 && source[0] === 0 && source[1] === 0 && source[2] === 0xfe && source[3] === 0xff) + || (source.length >= 2 && source[0] === 0xff && source[1] === 0xfe) + || (source.length >= 2 && source[0] === 0xfe && source[1] === 0xff); +} +function hasUtf16ByteOrderMark(source) { + return (source.length >= 2 && source[0] === 0xff && source[1] === 0xfe) + || (source.length >= 2 && source[0] === 0xfe && source[1] === 0xff); +} +function getHistoricalBlobEntries(repoRoot) { + const entries = []; + let commit; + let rawMetadata; + let pathRuleIds = new Set(); + let recordText = ""; + let recordWindow = ""; + let recordWasSegmented = false; + const resetRecord = () => { + pathRuleIds = new Set(); + recordText = ""; + recordWindow = ""; + recordWasSegmented = false; + }; + const scanPathRecordSegment = (segmentText) => { + if (commit === undefined) { + return; + } + const nextWindow = boundedAuditTextWindow(recordWindow, segmentText); + const source = segmentText.length > LARGE_BLOB_SCAN_OVERLAP_CHARACTERS + ? segmentText + : nextWindow; + findingsForSource({ + commit, + scope: "history", + source: "patch", + text: source, + }).forEach((finding) => { + pathRuleIds.add(finding.ruleId); + }); + recordWindow = nextWindow; + }; + scanGitOutputRecordSegments(repoRoot, [ + "log", + "--all", + "--root", + "--no-color", + "--no-decorate", + "--no-show-signature", + "--no-abbrev", + "--no-ext-diff", + "--no-renames", + "--format=%H", + "--raw", + "-m", + "-z", + "--diff-filter=AMT", + "--reverse", + ], (segment) => { + if (rawMetadata !== undefined) { + recordWasSegmented ||= segment.endsRecord === false; + scanPathRecordSegment(segment.text); + if (recordWasSegmented === false) { + recordText += segment.text; + } + if (segment.endsRecord === false) { + return; + } + const pathRecord = recordWasSegmented ? recordWindow : recordText; + const entry = commit === undefined + ? undefined + : parseHistoricalRawBlobEntry({ + commit, + metadata: rawMetadata, + path: pathRecord, + pathRuleIds: [...pathRuleIds], + }); + if (entry !== undefined) { + entries.push(entry); + } + rawMetadata = undefined; + resetRecord(); + return; + } + recordText += segment.text; + if (segment.endsRecord === false) { + return; + } + const record = recordText; + resetRecord(); + if (HISTORY_SHA_PATTERN.test(record)) { + commit = record; + return; + } + const trimmedRecord = record.trimStart(); + if (trimmedRecord.startsWith(":")) { + rawMetadata = trimmedRecord; + } + }); + return uniqueHistoricalBlobEntries([ + ...entries, + ...getHistoricalRefBlobEntries(repoRoot), + ]); +} +function getHistoricalRefBlobEntries(repoRoot) { + const entries = []; + scanGitOutputLines(repoRoot, [ + "for-each-ref", + "--format=%(objecttype)%00%(objectname)%00%(*objecttype)%00%(*objectname)%00%(refname)", + "refs", + ], (line) => { + const [objectType, objectId, peeledObjectType, peeledObjectId, refName] = line.split("\0"); + if (objectId === undefined || refName === undefined || HISTORY_SHA_PATTERN.test(objectId) === false) { + return; + } + const rawTargetObjectType = peeledObjectType === undefined || peeledObjectType.length === 0 + ? objectType + : peeledObjectType; + const rawTargetObjectId = peeledObjectId === undefined || peeledObjectId.length === 0 + ? objectId + : peeledObjectId; + if (rawTargetObjectId === undefined + || rawTargetObjectType === undefined + || HISTORY_SHA_PATTERN.test(rawTargetObjectId) === false) { + return; + } + const target = peelGitObjectTarget(repoRoot, { + objectId: rawTargetObjectId, + type: rawTargetObjectType, + }); + if (target === undefined) { + return; + } + if (target.type === "blob") { + entries.push({ + commit: objectId, + mode: "100644", + objectId: target.objectId, + path: refName, + pathRuleIds: [], + }); + return; + } + if (target.type === "tree") { + entries.push(...parseTrackedBlobEntries(decodeGitPathRecordsOutput(runGitBuffer(repoRoot, ["ls-tree", "-r", "-z", "--full-tree", target.objectId]))) + .filter((entry) => entry.type === "blob" || entry.mode === "160000") + .map((entry) => ({ + commit: objectId, + mode: entry.mode, + objectId: entry.objectId, + path: `${refName}:${entry.path}`, + pathRuleIds: [], + }))); + } + }); + return entries; +} +function parseHistoricalRawBlobEntry(input) { + const metadataFields = input.metadata.trim().split(" "); + const mode = metadataFields[1]; + const objectId = metadataFields[3]; + const status = metadataFields[4]; + if (mode === undefined + || objectId === undefined + || status === undefined + || /^[AMT]/u.test(status) === false + || HISTORY_SHA_PATTERN.test(objectId) === false + || /^0+$/u.test(objectId) + || input.path.length === 0) { + return undefined; + } + return { + commit: input.commit, + mode, + objectId, + path: input.path, + pathRuleIds: input.pathRuleIds, + }; +} +function getTrackedEntries(repoRoot) { + return uniqueTrackedEntries([ + ...getTrackedHeadEntries(repoRoot), + ...parseTrackedBlobEntries(decodeGitPathRecordsOutput(runGitBuffer(repoRoot, ["ls-files", "-s", "-z"]))), + ]); +} +function getTrackedHeadEntries(repoRoot) { + if (hasGitHead(repoRoot) === false) { + return []; + } + return parseTrackedBlobEntries(decodeGitPathRecordsOutput(runGitBuffer(repoRoot, ["ls-tree", "-r", "-z", "--full-tree", "HEAD"]))); +} +function hasGitHead(repoRoot) { + try { + execFileSync("git", ["rev-parse", "--verify", "HEAD"], { + cwd: repoRoot, + env: auditGitEnvironment(), + stdio: "ignore", + }); + return true; + } + catch { + return false; + } +} +function containsNonGenericMachineHomePath(source) { + return containsNonGenericMachineHomePathInPlainText(source) + || containsJsonDecodedSensitiveValue(source, containsNonGenericMachineHomePathInPlainText); +} +function containsNonGenericMachineHomePathInPlainText(source) { + const tokenRanges = buildSourceTokenRanges(source); + if (containsRootMachineHomePath(source, tokenRanges)) { + return true; + } + for (const match of source.matchAll(POSIX_HOME_PATH_PATTERN)) { + const directoryName = match[1]; + if (directoryName !== undefined + && isGenericHomeDirectoryMatch(source, match) === false + && isFilesystemLikePosixHomeMatch(source, match, tokenRanges)) { + return true; + } + } + for (const match of source.matchAll(WINDOWS_HOME_PATH_PATTERN)) { + const directoryName = match[1]; + if (directoryName !== undefined + && isGenericHomeDirectoryMatch(source, match) === false + && isFilesystemLikeMachineHomeMatch(source, match, tokenRanges)) { + return true; + } + } + return false; +} +function containsJsonDecodedSensitiveValue(source, matches) { + if (source.includes("\\") === false && source.includes("\"") === false && source.includes("'") === false) { + return false; + } + if ([...source.matchAll(SOURCE_STRING_LITERAL_PATTERN)].some((match) => { + const decoded = decodeSourceStringLiteral(match[0]); + return decoded !== undefined && matches(decoded); + })) { + return true; + } + if (containsConcatenatedJsonStringLiteralValue(source, matches)) { + return true; + } + if (source.includes("\\") === false) { + return false; + } + return matches(decodeJsonEscapedTextFragment(source)); +} +function containsConcatenatedJsonStringLiteralValue(source, matches) { + let literalCount = 0; + let previousEnd; + let runtimeText = ""; + for (const match of source.matchAll(SOURCE_STRING_LITERAL_PATTERN)) { + const decoded = decodeSourceStringLiteral(match[0]); + if (decoded === undefined) { + literalCount = 0; + previousEnd = undefined; + runtimeText = ""; + continue; + } + const separator = previousEnd === undefined ? undefined : source.slice(previousEnd, match.index); + if (separator !== undefined && isSourceLiteralConcatenationSeparator(separator)) { + literalCount += 1; + runtimeText += decoded; + } + else { + literalCount = 1; + runtimeText = decoded; + } + if (literalCount > 1 && matches(runtimeText)) { + return true; + } + previousEnd = match.index + match[0].length; + } + return false; +} +function isSourceLiteralConcatenationSeparator(separator) { + const separatorWithoutComments = separator.replace(/\/\*[\s\S]*?\*\/|\/\/[^\r\n]*(?:\r\n|\r|\n)/gu, " "); + return /^\s*(?:\+\s*)?$/u.test(separatorWithoutComments); +} +function containsAccessToken(source) { + return containsAccessTokenInPlainText(source) + || containsJsonDecodedSensitiveValue(source, containsAccessTokenInPlainText); +} +function containsAccessTokenInPlainText(source) { + return ACCESS_TOKEN_PATTERNS.some((pattern) => pattern.test(source)) || containsGitHubAppJwt(source); +} +function containsGitHubAppJwt(source) { + return [...source.matchAll(GITHUB_APP_JWT_PATTERN)].some((match) => isGitHubAppJwt(match[0])); +} +function containsPrivateKeyBlock(source) { + return containsPrivateKeyBlockInPlainText(source) + || containsJsonDecodedSensitiveValue(source, containsPrivateKeyBlockInPlainText); +} +function containsPrivateKeyBlockInPlainText(source) { + const normalizedSource = stripTextByteOrderMark(source); + return containsPemPrivateKeyBlock(normalizedSource) + || containsOpenPgpPrivateKeyBlock(normalizedSource) + || containsPuttyPrivateKeyBlock(normalizedSource); +} +function containsPemPrivateKeyBlock(source) { + return collectPemPrivateKeyBlockRanges(source).length > 0; +} +function containsOpenPgpPrivateKeyBlock(source) { + return collectOpenPgpPrivateKeyBlockRanges(source).length > 0; +} +function containsPuttyPrivateKeyBlock(source) { + return collectPuttyPrivateKeyBlockRanges(source).length > 0; +} +function createStreamingPrivateKeyDetector() { + let pendingLine = ""; + let pemBlock; + let openPgpBlock; + let puttyBlock; + const processLine = (line) => { + const pemFound = processStreamingPemPrivateKeyLine(line, { + get: () => pemBlock, + set: (nextBlock) => { + pemBlock = nextBlock; + }, + }); + const openPgpFound = processStreamingOpenPgpPrivateKeyLine(line, { + get: () => openPgpBlock, + set: (nextBlock) => { + openPgpBlock = nextBlock; + }, + }); + const puttyFound = processStreamingPuttyPrivateKeyLine(line, { + get: () => puttyBlock, + set: (nextBlock) => { + puttyBlock = nextBlock; + }, + }); + return pemFound || openPgpFound || puttyFound; + }; + return { + end: () => { + const found = pendingLine.length > 0 && processLine(pendingLine); + pendingLine = ""; + return found; + }, + write: (source) => { + if (source.length === 0) { + return false; + } + const combinedSource = `${pendingLine}${source}`; + let found = false; + let start = 0; + for (const match of combinedSource.matchAll(PEM_LINE_BREAK_SCAN_PATTERN)) { + found ||= processLine(combinedSource.slice(start, match.index)); + start = match.index + match[0].length; + } + pendingLine = combinedSource.slice(start); + while (pendingLine.length > STREAMING_PRIVATE_KEY_PENDING_LINE_MAX_CHARACTERS + && (pemBlock !== undefined || openPgpBlock !== undefined || puttyBlock !== undefined)) { + found ||= processLine(pendingLine.slice(0, STREAMING_PRIVATE_KEY_PENDING_LINE_MAX_CHARACTERS)); + pendingLine = pendingLine.slice(STREAMING_PRIVATE_KEY_PENDING_LINE_MAX_CHARACTERS); + } + pendingLine = boundStreamingPrivateKeyPendingLine(pendingLine); + return found; + }, + }; +} +function boundStreamingPrivateKeyPendingLine(source) { + if (source.length <= STREAMING_PRIVATE_KEY_PENDING_LINE_MAX_CHARACTERS) { + return source; + } + return source.slice(source.length - STREAMING_PRIVATE_KEY_PENDING_LINE_MAX_CHARACTERS); +} +function processStreamingPemPrivateKeyLine(line, block) { + const beginLine = parsePemPrivateKeyBeginLine(line); + const activeBlock = block.get(); + if (activeBlock !== undefined) { + const endMarker = `-----END ${activeBlock.label}-----`; + if (isPemPrivateKeyEndLine(line, endMarker, activeBlock.sourceLiteralDelimiter)) { + block.set(undefined); + return activeBlock.invalidPayload === false + && activeBlock.payloadCharacterCount >= 32 + && activeBlock.paddingCharacterCount <= 2 + && hasValidBase64QuantumLength(activeBlock.payloadCharacterCount, activeBlock.paddingCharacterCount); + } + if (beginLine !== undefined) { + block.set(createStreamingPemPrivateKeyBlock(beginLine)); + return false; + } + updateStreamingPemPayload(activeBlock, line); + return false; + } + if (beginLine !== undefined) { + block.set(createStreamingPemPrivateKeyBlock(beginLine)); + } + return false; +} +function processStreamingOpenPgpPrivateKeyLine(line, block) { + const beginLine = parseOpenPgpPrivateKeyBeginLine(line); + const activeBlock = block.get(); + if (activeBlock !== undefined) { + if (isArmorMarkerEndLine(line, OPENPGP_PRIVATE_KEY_END_LINE, activeBlock.sourceLiteralDelimiter)) { + block.set(undefined); + return activeBlock.invalidPayload === false + && activeBlock.sawSeparator + && activeBlock.payloadCharacterCount - activeBlock.paddingCharacterCount >= 32 + && activeBlock.paddingCharacterCount <= 2 + && hasValidBase64QuantumLength(activeBlock.payloadCharacterCount, activeBlock.paddingCharacterCount) + && hasValidStreamingOpenPgpArmorChecksum(activeBlock); + } + if (beginLine !== undefined) { + block.set(createStreamingOpenPgpPrivateKeyBlock(beginLine)); + return false; + } + updateStreamingOpenPgpPayload(activeBlock, line); + return false; + } + if (beginLine !== undefined) { + block.set(createStreamingOpenPgpPrivateKeyBlock(beginLine)); + } + return false; +} +function processStreamingPuttyPrivateKeyLine(line, block) { + const trimmedLine = line.trim(); + const beginLine = parsePuttyPrivateKeyBeginLine(line); + const activeBlock = block.get(); + if (beginLine !== undefined) { + block.set(createStreamingPuttyPrivateKeyBlock(beginLine)); + return false; + } + if (activeBlock === undefined) { + return false; + } + if (trimmedLine.length === 0) { + block.set(undefined); + return false; + } + if (activeBlock.waitingForMac) { + const found = activeBlock.invalidPayload === false + && activeBlock.privateLineCount !== undefined + && activeBlock.privateLinesRead === activeBlock.privateLineCount + && activeBlock.privatePayloadCharacterCount >= 32 + && activeBlock.paddingCharacterCount <= 2 + && hasValidBase64QuantumLength(activeBlock.privatePayloadCharacterCount + activeBlock.paddingCharacterCount, activeBlock.paddingCharacterCount) + && parsePuttyPrivateKeyMacLine(line, activeBlock.sourceLiteralDelimiter, activeBlock.version) !== undefined; + block.set(undefined); + return found; + } + if (activeBlock.privateLineCount !== undefined) { + updateStreamingPuttyPayload(activeBlock, trimmedLine); + activeBlock.privateLinesRead += 1; + if (activeBlock.privateLinesRead === activeBlock.privateLineCount) { + activeBlock.waitingForMac = true; + } + return false; + } + if (activeBlock.publicLineCount !== undefined && activeBlock.publicLinesRead < activeBlock.publicLineCount) { + updateStreamingPuttyPublicPayload(activeBlock, trimmedLine); + activeBlock.publicLinesRead += 1; + return false; + } + if (PUTTY_COMMENT_LINE_PATTERN.test(trimmedLine)) { + if (activeBlock.sawEncryption === false + || activeBlock.sawComment + || activeBlock.publicLineCount !== undefined) { + activeBlock.invalidPayload = true; + } + else { + activeBlock.sawComment = true; + } + return false; + } + const privateLineMatch = PUTTY_PRIVATE_KEY_LINE_COUNT_PATTERN.exec(trimmedLine); + if (privateLineMatch?.[1] !== undefined) { + if (activeBlock.sawEncryption + && activeBlock.sawComment + && (activeBlock.encrypted === false + || activeBlock.version !== 3 + || hasCompletePuttyArgon2DerivationFields(activeBlock.keyDerivationFields)) + && activeBlock.publicLineCount !== undefined + && activeBlock.publicLinesRead === activeBlock.publicLineCount + && hasValidStreamingPuttyPublicPayload(activeBlock)) { + activeBlock.privateLineCount = Number.parseInt(privateLineMatch[1], 10); + } + else { + activeBlock.invalidPayload = true; + } + return false; + } + if (activeBlock.version === 3 + && activeBlock.publicLineCount !== undefined + && activeBlock.publicLinesRead === activeBlock.publicLineCount + && hasValidStreamingPuttyPublicPayload(activeBlock) + && isPuttyKeyDerivationLine(trimmedLine)) { + if (recordPuttyArgon2DerivationField(activeBlock.keyDerivationFields, trimmedLine) === false) { + activeBlock.invalidPayload = true; + } + return false; + } + if (activeBlock.publicLineCount !== undefined && activeBlock.publicLinesRead === activeBlock.publicLineCount) { + activeBlock.invalidPayload = true; + return false; + } + if (trimmedLine.startsWith("Encryption: ")) { + if (activeBlock.sawEncryption || activeBlock.sawComment) { + activeBlock.invalidPayload = true; + } + else { + activeBlock.sawEncryption = true; + activeBlock.encrypted = /^Encryption: none$/iu.test(trimmedLine) === false; + } + return false; + } + const publicLineMatch = PUTTY_PUBLIC_KEY_LINE_COUNT_PATTERN.exec(trimmedLine); + if (publicLineMatch?.[1] !== undefined) { + if (activeBlock.sawEncryption === false + || activeBlock.sawComment === false + || activeBlock.publicLineCount !== undefined) { + activeBlock.invalidPayload = true; + } + else { + activeBlock.publicLineCount = Number.parseInt(publicLineMatch[1], 10); + } + return false; + } + if (trimmedLine.startsWith("Public-Lines: ")) { + activeBlock.invalidPayload = true; + return false; + } + return false; +} +function createStreamingPemPrivateKeyBlock(beginLine) { + return { + invalidPayload: false, + label: beginLine.label, + paddingCharacterCount: 0, + payloadCharacterCount: 0, + sawPadding: false, + sawPayload: false, + sourceLiteralDelimiter: beginLine.sourceLiteralDelimiter, + }; +} +function createStreamingOpenPgpPrivateKeyBlock(beginLine) { + return { + base64Remainder: "", + crc24: OPENPGP_CRC24_INITIAL_VALUE, + invalidPayload: false, + paddingCharacterCount: 0, + payloadCharacterCount: 0, + sawChecksum: false, + sawPadding: false, + sawPayload: false, + sawSeparator: false, + sourceLiteralDelimiter: beginLine.sourceLiteralDelimiter, + }; +} +function createStreamingPuttyPrivateKeyBlock(beginLine) { + return { + encrypted: false, + invalidPayload: false, + keyDerivationFields: [], + paddingCharacterCount: 0, + privateLinesRead: 0, + privatePayloadCharacterCount: 0, + publicLinesRead: 0, + publicPaddingCharacterCount: 0, + publicPayloadCharacterCount: 0, + publicSawPadding: false, + sawComment: false, + sawEncryption: false, + sawPadding: false, + sourceLiteralDelimiter: beginLine.sourceLiteralDelimiter, + version: beginLine.version, + waitingForMac: false, + }; +} +function updateStreamingPemPayload(block, line) { + const trimmedLine = line.trim(); + if (trimmedLine.length === 0) { + return; + } + if (PEM_METADATA_LINE_PATTERN.test(trimmedLine)) { + if (block.sawPayload) { + block.invalidPayload = true; + } + return; + } + const payloadLine = trimmedLine.replace(/[ \t]/gu, ""); + if (PEM_PAYLOAD_LINE_PATTERN.test(payloadLine) === false) { + block.invalidPayload = true; + return; + } + block.sawPayload = true; + updateStreamingBase64Payload(block, payloadLine, true); +} +function updateStreamingOpenPgpPayload(block, line) { + const trimmedLine = line.trim(); + if (block.sawSeparator === false) { + if (trimmedLine.length === 0) { + block.sawSeparator = true; + return; + } + if (OPENPGP_ARMOR_HEADER_LINE_PATTERN.test(trimmedLine)) { + return; + } + block.invalidPayload = true; + return; + } + if (trimmedLine.length === 0) { + return; + } + if (block.sawChecksum) { + block.invalidPayload = true; + return; + } + const checksum = decodeOpenPgpArmorChecksum(trimmedLine); + if (checksum !== undefined) { + block.sawChecksum = true; + block.armorChecksum = checksum; + return; + } + if (OPENPGP_ARMOR_HEADER_LINE_PATTERN.test(trimmedLine)) { + block.invalidPayload = true; + return; + } + const payloadLine = trimmedLine.replace(/[ \t]/gu, ""); + if (OPENPGP_PAYLOAD_LINE_PATTERN.test(payloadLine) === false) { + block.invalidPayload = true; + return; + } + for (const character of payloadLine) { + if (character === "=") { + block.sawPayload = true; + block.sawPadding = true; + block.paddingCharacterCount += 1; + block.payloadCharacterCount += 1; + continue; + } + if (block.sawPadding) { + block.invalidPayload = true; + } + block.sawPayload = true; + block.payloadCharacterCount += 1; + } + updateStreamingOpenPgpPayloadChecksum(block, payloadLine); +} +function updateStreamingOpenPgpPayloadChecksum(block, payloadLine) { + block.base64Remainder += payloadLine; + const completeQuantumLength = block.base64Remainder.length - (block.base64Remainder.length % 4); + if (completeQuantumLength === 0) { + return; + } + const completePayload = block.base64Remainder.slice(0, completeQuantumLength); + block.base64Remainder = block.base64Remainder.slice(completeQuantumLength); + block.crc24 = updateOpenPgpCrc24(block.crc24, Buffer.from(completePayload, "base64")); +} +function hasValidStreamingOpenPgpArmorChecksum(block) { + if (block.sawChecksum === false) { + return true; + } + if (block.armorChecksum === undefined) { + return false; + } + const crc24 = block.base64Remainder.length === 0 + ? block.crc24 + : updateOpenPgpCrc24(block.crc24, Buffer.from(block.base64Remainder, "base64")); + return matchesOpenPgpArmorChecksum(block.armorChecksum, crc24); +} +function updateStreamingPuttyPayload(block, line) { + if (PUTTY_PRIVATE_KEY_PAYLOAD_LINE_PATTERN.test(line) === false) { + block.invalidPayload = true; + return; + } + for (const character of line) { + if (character === "=") { + block.sawPadding = true; + block.paddingCharacterCount += 1; + continue; + } + if (block.sawPadding) { + block.invalidPayload = true; + } + block.privatePayloadCharacterCount += 1; + } +} +function updateStreamingPuttyPublicPayload(block, line) { + if (PUTTY_PRIVATE_KEY_PAYLOAD_LINE_PATTERN.test(line) === false) { + block.invalidPayload = true; + return; + } + for (const character of line) { + if (character === "=") { + block.publicSawPadding = true; + block.publicPaddingCharacterCount += 1; + continue; + } + if (block.publicSawPadding) { + block.invalidPayload = true; + } + block.publicPayloadCharacterCount += 1; + } +} +function hasValidStreamingPuttyPublicPayload(block) { + return block.publicPayloadCharacterCount >= 32 + && block.publicPaddingCharacterCount <= 2 + && hasValidBase64QuantumLength(block.publicPayloadCharacterCount + block.publicPaddingCharacterCount, block.publicPaddingCharacterCount); +} +function updateStreamingBase64Payload(block, payloadLine, countPadding) { + for (const character of payloadLine) { + if (character === "=") { + block.sawPadding = true; + if (countPadding) { + block.paddingCharacterCount += 1; + } + block.payloadCharacterCount += 1; + continue; + } + if (block.sawPadding) { + block.invalidPayload = true; + } + block.payloadCharacterCount += 1; + } +} +function collectPemPrivateKeyBlockRanges(source) { + const ranges = []; + const lines = splitTextLineSpans(source); + let activeBlock; + lines.forEach((line) => { + const beginLine = parsePemPrivateKeyBeginLine(line.text); + if (activeBlock !== undefined) { + const endMarker = `-----END ${activeBlock.label}-----`; + const endMarkerIndex = line.text.indexOf(endMarker); + if (isPemPrivateKeyEndLine(line.text, endMarker, activeBlock.sourceLiteralDelimiter)) { + if (isValidPemPrivateKeyPayload(activeBlock.bodyLines)) { + ranges.push({ + end: line.start + endMarkerIndex + endMarker.length, + start: activeBlock.start, + }); + } + activeBlock = undefined; + return; + } + if (beginLine !== undefined) { + activeBlock = { + bodyLines: [], + label: beginLine.label, + start: line.start, + sourceLiteralDelimiter: beginLine.sourceLiteralDelimiter, + }; + return; + } + activeBlock.bodyLines.push(line.text); + return; + } + if (beginLine !== undefined) { + activeBlock = { + bodyLines: [], + label: beginLine.label, + start: line.start, + sourceLiteralDelimiter: beginLine.sourceLiteralDelimiter, + }; + } + }); + return ranges; +} +function collectOpenPgpPrivateKeyBlockRanges(source) { + const ranges = []; + const lines = splitTextLineSpans(source); + let activeBlock; + lines.forEach((line) => { + const beginLine = parseOpenPgpPrivateKeyBeginLine(line.text); + if (activeBlock !== undefined) { + const endMarkerIndex = line.text.indexOf(OPENPGP_PRIVATE_KEY_END_LINE); + if (isArmorMarkerEndLine(line.text, OPENPGP_PRIVATE_KEY_END_LINE, activeBlock.sourceLiteralDelimiter)) { + if (isValidOpenPgpPrivateKeyPayload(activeBlock.bodyLines)) { + ranges.push({ + end: line.start + endMarkerIndex + OPENPGP_PRIVATE_KEY_END_LINE.length, + start: activeBlock.start, + }); + } + activeBlock = undefined; + return; + } + if (beginLine !== undefined) { + activeBlock = { + bodyLines: [], + sourceLiteralDelimiter: beginLine.sourceLiteralDelimiter, + start: line.start, + }; + return; + } + activeBlock.bodyLines.push(line.text); + return; + } + if (beginLine !== undefined) { + activeBlock = { + bodyLines: [], + sourceLiteralDelimiter: beginLine.sourceLiteralDelimiter, + start: line.start, + }; + } + }); + return ranges; +} +function collectPuttyPrivateKeyBlockRanges(source) { + const ranges = []; + const lines = splitTextLineSpans(source); + for (let index = 0; index < lines.length; index += 1) { + const headerLine = lines[index]; + const beginLine = headerLine === undefined ? undefined : parsePuttyPrivateKeyBeginLine(headerLine.text); + if (headerLine === undefined || beginLine === undefined) { + continue; + } + let privateLineCount; + let privateLinesStart; + let publicLineCount; + let publicLinesRead = 0; + const publicPayloadLines = []; + let encrypted = false; + const keyDerivationFields = []; + let sawComment = false; + let sawEncryption = false; + for (let scanIndex = index + 1; scanIndex < lines.length; scanIndex += 1) { + const lineText = lines[scanIndex].text.trim(); + if (lineText.length === 0 || parsePuttyPrivateKeyBeginLine(lines[scanIndex].text) !== undefined) { + break; + } + if (publicLineCount !== undefined && publicLinesRead < publicLineCount) { + if (PUTTY_PRIVATE_KEY_PAYLOAD_LINE_PATTERN.test(lineText) === false) { + break; + } + publicPayloadLines.push(lineText); + publicLinesRead += 1; + continue; + } + if (PUTTY_COMMENT_LINE_PATTERN.test(lineText)) { + if (sawEncryption === false || sawComment || publicLineCount !== undefined) { + break; + } + sawComment = true; + continue; + } + const privateLineMatch = PUTTY_PRIVATE_KEY_LINE_COUNT_PATTERN.exec(lineText); + if (privateLineMatch?.[1] !== undefined) { + if (sawComment === false + || sawEncryption === false + || publicLineCount === undefined + || publicLinesRead !== publicLineCount + || (encrypted + && beginLine.version === 3 + && hasCompletePuttyArgon2DerivationFields(keyDerivationFields) === false)) { + break; + } + privateLineCount = Number.parseInt(privateLineMatch[1], 10); + privateLinesStart = scanIndex + 1; + break; + } + if (beginLine.version === 3 + && publicLineCount !== undefined + && publicLinesRead === publicLineCount + && isPuttyKeyDerivationLine(lineText)) { + if (recordPuttyArgon2DerivationField(keyDerivationFields, lineText) === false) { + break; + } + continue; + } + if (publicLineCount !== undefined && publicLinesRead === publicLineCount) { + break; + } + if (lineText.startsWith("Encryption: ")) { + if (sawEncryption || sawComment) { + break; + } + sawEncryption = true; + encrypted = /^Encryption: none$/iu.test(lineText) === false; + continue; + } + const publicLineMatch = PUTTY_PUBLIC_KEY_LINE_COUNT_PATTERN.exec(lineText); + if (publicLineMatch?.[1] !== undefined) { + if (sawEncryption === false || sawComment === false || publicLineCount !== undefined) { + break; + } + publicLineCount = Number.parseInt(publicLineMatch[1], 10); + continue; + } + } + if (sawEncryption === false + || sawComment === false + || publicLineCount === undefined + || publicLinesRead !== publicLineCount + || privateLineCount === undefined + || privateLinesStart === undefined + || privateLinesStart + privateLineCount >= lines.length) { + continue; + } + const privatePayloadLines = lines + .slice(privateLinesStart, privateLinesStart + privateLineCount) + .map((line) => line.text.trim()); + const privateMacLine = lines[privateLinesStart + privateLineCount]; + const privateMac = privateMacLine === undefined + ? undefined + : parsePuttyPrivateKeyMacLine(privateMacLine.text, beginLine.sourceLiteralDelimiter, beginLine.version); + if (privatePayloadLines.length === privateLineCount + && isValidPuttyPayload(privatePayloadLines) + && isValidPuttyPayload(publicPayloadLines) + && privateMacLine !== undefined + && privateMac !== undefined) { + ranges.push({ + end: privateMacLine.start + privateMac.markerIndex + privateMac.markerLength, + start: headerLine.start + beginLine.markerIndex, + }); + } + } + return ranges; +} +function isValidPemPrivateKeyPayload(bodyLines) { + const payload = []; + let sawPayload = false; + for (const line of bodyLines) { + const trimmedLine = line.trim(); + if (trimmedLine.length === 0) { + continue; + } + if (PEM_METADATA_LINE_PATTERN.test(trimmedLine)) { + if (sawPayload) { + return false; + } + continue; + } + sawPayload = true; + payload.push(trimmedLine.replace(/[ \t]/gu, "")); + } + if (payload.length === 0 || payload.some((line) => PEM_PAYLOAD_LINE_PATTERN.test(line) === false)) { + return false; + } + const combinedPayload = payload.join(""); + const paddingCharacterCount = countTerminalBase64Padding(combinedPayload); + return combinedPayload.length >= 32 + && hasValidBase64QuantumLength(combinedPayload.length, paddingCharacterCount) + && /^[A-Za-z0-9+/]+={0,2}$/u.test(combinedPayload); +} +function hasValidBase64QuantumLength(characterCount, paddingCharacterCount) { + if (paddingCharacterCount === 0) { + return characterCount % 4 !== 1; + } + if (paddingCharacterCount === 1) { + return characterCount % 4 === 0 && (characterCount - paddingCharacterCount) % 4 === 3; + } + if (paddingCharacterCount === 2) { + return characterCount % 4 === 0 && (characterCount - paddingCharacterCount) % 4 === 2; + } + return false; +} +function countTerminalBase64Padding(source) { + const paddingMatch = /=*$/u.exec(source); + return paddingMatch?.[0].length ?? 0; +} +function isValidOpenPgpPrivateKeyPayload(bodyLines) { + let armorChecksum; + let sawChecksum = false; + let sawSeparator = false; + const payload = []; + for (const line of bodyLines) { + const trimmedLine = line.trim(); + if (sawSeparator === false) { + if (trimmedLine.length === 0) { + sawSeparator = true; + continue; + } + if (OPENPGP_ARMOR_HEADER_LINE_PATTERN.test(trimmedLine)) { + continue; + } + return false; + } + if (trimmedLine.length === 0) { + continue; + } + if (sawChecksum) { + return false; + } + const checksum = decodeOpenPgpArmorChecksum(trimmedLine); + if (checksum !== undefined) { + sawChecksum = true; + armorChecksum = checksum; + continue; + } + if (OPENPGP_ARMOR_HEADER_LINE_PATTERN.test(trimmedLine)) { + return false; + } + payload.push(trimmedLine.replace(/[ \t]/gu, "")); + } + if (sawSeparator === false + || payload.length === 0 + || payload.some((line) => OPENPGP_PAYLOAD_LINE_PATTERN.test(line) === false)) { + return false; + } + const combinedPayload = payload.join(""); + const paddingCharacterCount = countTerminalBase64Padding(combinedPayload); + return combinedPayload.length - paddingCharacterCount >= 32 + && hasValidBase64QuantumLength(combinedPayload.length, paddingCharacterCount) + && /^[A-Za-z0-9+/]+={0,2}$/u.test(combinedPayload) + && (armorChecksum === undefined || matchesOpenPgpArmorChecksum(armorChecksum, calculateOpenPgpCrc24(Buffer.from(combinedPayload, "base64")))); +} +function decodeOpenPgpArmorChecksum(line) { + if (OPENPGP_ARMOR_CHECKSUM_LINE_PATTERN.test(line) === false) { + return undefined; + } + const checksum = Buffer.from(line.slice(1), "base64"); + return checksum.length === 3 ? checksum : undefined; +} +function calculateOpenPgpCrc24(source) { + return updateOpenPgpCrc24(OPENPGP_CRC24_INITIAL_VALUE, source); +} +function updateOpenPgpCrc24(crc24, source) { + let nextCrc24 = crc24; + for (const byte of source) { + nextCrc24 ^= byte << 16; + for (let bit = 0; bit < 8; bit += 1) { + nextCrc24 <<= 1; + if ((nextCrc24 & 0x1000000) !== 0) { + nextCrc24 ^= OPENPGP_CRC24_POLYNOMIAL; + } + nextCrc24 &= 0xffffff; + } + } + return nextCrc24; +} +function matchesOpenPgpArmorChecksum(checksum, crc24) { + return checksum[0] === ((crc24 >>> 16) & 0xff) + && checksum[1] === ((crc24 >>> 8) & 0xff) + && checksum[2] === (crc24 & 0xff); +} +function isValidPuttyPayload(bodyLines) { + if (bodyLines.length === 0 || bodyLines.some((line) => PUTTY_PRIVATE_KEY_PAYLOAD_LINE_PATTERN.test(line) === false)) { + return false; + } + const combinedPayload = bodyLines.join(""); + const paddingCharacterCount = countTerminalBase64Padding(combinedPayload); + return combinedPayload.length - paddingCharacterCount >= 32 + && hasValidBase64QuantumLength(combinedPayload.length, paddingCharacterCount) + && /^[A-Za-z0-9+/]+={0,2}$/u.test(combinedPayload); +} +function parsePemPrivateKeyBeginLine(lineText) { + const exactBeginMatch = PEM_PRIVATE_KEY_BEGIN_LINE_PATTERN.exec(lineText); + if (exactBeginMatch?.[1] !== undefined) { + return { + label: exactBeginMatch[1], + }; + } + const markerMatch = /-----BEGIN ((?:[A-Z0-9 ]+ )?PRIVATE KEY)-----/u.exec(lineText); + if (markerMatch?.[1] === undefined) { + return undefined; + } + const markerEnd = markerMatch.index + markerMatch[0].length; + const delimiter = parseSourceLiteralDelimiter(lineText.slice(0, markerMatch.index)); + if (delimiter === undefined || lineText.slice(markerEnd).trim().length > 0) { + return undefined; + } + return { + label: markerMatch[1], + sourceLiteralDelimiter: delimiter, + }; +} +function isPemPrivateKeyEndLine(lineText, marker, sourceLiteralDelimiter) { + return isArmorMarkerEndLine(lineText, marker, sourceLiteralDelimiter); +} +function parseOpenPgpPrivateKeyBeginLine(lineText) { + if (isExactArmorMarkerLine(lineText, OPENPGP_PRIVATE_KEY_BEGIN_LINE)) { + return {}; + } + const markerIndex = lineText.indexOf(OPENPGP_PRIVATE_KEY_BEGIN_LINE); + if (markerIndex < 0) { + return undefined; + } + const markerEnd = markerIndex + OPENPGP_PRIVATE_KEY_BEGIN_LINE.length; + const delimiter = parseSourceLiteralDelimiter(lineText.slice(0, markerIndex)); + if (delimiter === undefined || lineText.slice(markerEnd).trim().length > 0) { + return undefined; + } + return { + sourceLiteralDelimiter: delimiter, + }; +} +function parsePuttyPrivateKeyBeginLine(lineText) { + const trimmedLine = lineText.trim(); + const exactBeginMatch = /^PuTTY-User-Key-File-([123]): [^\r\n]+$/u.exec(trimmedLine); + if (exactBeginMatch?.[1] === "1" || exactBeginMatch?.[1] === "2" || exactBeginMatch?.[1] === "3") { + return { + markerIndex: lineText.indexOf(trimmedLine), + version: Number.parseInt(exactBeginMatch[1], 10), + }; + } + const markerMatch = /PuTTY-User-Key-File-([123]): [^\r\n]+$/u.exec(lineText); + if (markerMatch?.[1] === undefined) { + return undefined; + } + const delimiter = parseSourceLiteralDelimiter(lineText.slice(0, markerMatch.index)); + if (delimiter === undefined) { + return undefined; + } + return { + markerIndex: markerMatch.index, + sourceLiteralDelimiter: delimiter, + version: Number.parseInt(markerMatch[1], 10), + }; +} +function isPuttyKeyDerivationLine(lineText) { + return PUTTY_KEY_DERIVATION_LINE_PATTERN.test(lineText) + || PUTTY_KEY_DERIVATION_PARAMETER_LINE_PATTERN.test(lineText); +} +function parsePuttyArgon2DerivationField(lineText) { + const fieldName = lineText.slice(0, lineText.indexOf(":")); + return PUTTY_ARGON2_DERIVATION_FIELDS.includes(fieldName) + ? fieldName + : undefined; +} +function recordPuttyArgon2DerivationField(fields, lineText) { + const field = parsePuttyArgon2DerivationField(lineText); + const expectedField = PUTTY_ARGON2_DERIVATION_FIELDS[fields.length]; + if (field === undefined || field !== expectedField) { + return false; + } + fields.push(field); + return true; +} +function hasCompletePuttyArgon2DerivationFields(fields) { + return fields.length === PUTTY_ARGON2_DERIVATION_FIELDS.length; +} +function parsePuttyPrivateKeyMacLine(lineText, sourceLiteralDelimiter, version) { + const macLength = version === 2 ? 40 : 64; + const integrityLinePattern = version === 1 + ? /^Private-Hash: [0-9a-f]{40}$/iu + : new RegExp(`^Private-MAC: [0-9a-f]{${macLength}}$`, "iu"); + const integrityMarkerPattern = version === 1 + ? /Private-Hash: [0-9a-f]{40}/iu + : new RegExp(`Private-MAC: [0-9a-f]{${macLength}}`, "iu"); + if (sourceLiteralDelimiter === undefined) { + const trimmedLine = lineText.trim(); + return integrityLinePattern.test(trimmedLine) + ? { + markerIndex: lineText.indexOf(trimmedLine), + markerLength: trimmedLine.length, + } + : undefined; + } + const markerMatch = integrityMarkerPattern.exec(lineText); + if (markerMatch === null || /^[ \t]*$/u.test(lineText.slice(0, markerMatch.index)) === false) { + return undefined; + } + const suffix = lineText.slice(markerMatch.index + markerMatch[0].length); + if (isSourceLiteralClosingSuffix(suffix, sourceLiteralDelimiter) === false) { + return undefined; + } + return { + markerIndex: markerMatch.index, + markerLength: markerMatch[0].length, + }; +} +function isArmorMarkerEndLine(lineText, marker, sourceLiteralDelimiter) { + if (sourceLiteralDelimiter === undefined) { + return isExactArmorMarkerLine(lineText, marker); + } + const markerIndex = lineText.indexOf(marker); + if (markerIndex < 0 || /^[ \t]*$/u.test(lineText.slice(0, markerIndex)) === false) { + return false; + } + const suffix = lineText.slice(markerIndex + marker.length); + return isSourceLiteralClosingSuffix(suffix, sourceLiteralDelimiter); +} +function isSourceLiteralClosingSuffix(suffix, sourceLiteralDelimiter) { + const escapedDelimiter = escapeRegExp(sourceLiteralDelimiter); + return new RegExp(`^[ \\t]*${escapedDelimiter}[ \\t]*(?:[;,\\])}]*)[ \\t]*$`, "u").test(suffix); +} +function parseSourceLiteralDelimiter(prefix) { + const trimmedPrefix = prefix.trimEnd(); + const rawStringMatch = /(?:^|[^A-Za-z0-9_])(?:u8|u|U|L)?R"([^()\s\\]{0,16})\($/u.exec(trimmedPrefix); + if (rawStringMatch?.[1] !== undefined) { + return `)${rawStringMatch[1]}"`; + } + const rustRawStringMatch = /(?:^|[^A-Za-z0-9_])(?:b|c)?r(#{0,16})"$/u.exec(trimmedPrefix); + if (rustRawStringMatch?.[1] !== undefined) { + return `"${rustRawStringMatch[1]}`; + } + const delimiter = trimmedPrefix.at(-1); + if (delimiter !== "\"" && delimiter !== "'" && delimiter !== "`") { + return undefined; + } + let delimiterStart = trimmedPrefix.length - 1; + while (delimiterStart > 0 && trimmedPrefix[delimiterStart - 1] === delimiter) { + delimiterStart -= 1; + } + return trimmedPrefix.slice(delimiterStart); +} +function isExactArmorMarkerLine(lineText, marker) { + const markerIndex = lineText.indexOf(marker); + return markerIndex >= 0 + && /^[ \t]*$/u.test(lineText.slice(0, markerIndex)) + && /^[ \t]*$/u.test(lineText.slice(markerIndex + marker.length)); +} +function escapeRegExp(source) { + return source.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); +} +function splitTextLineSpans(source) { + const lines = []; + let start = 0; + for (const match of source.matchAll(PEM_LINE_BREAK_SCAN_PATTERN)) { + lines.push({ + end: match.index, + start, + text: source.slice(start, match.index), + }); + start = match.index + match[0].length; + } + lines.push({ + end: source.length, + start, + text: source.slice(start), + }); + return lines; +} +function redactSensitiveBlockRanges(source, ranges) { + if (ranges.length === 0) { + return source; + } + let redactedSource = ""; + let startIndex = 0; + ranges + .sort((left, right) => left.start - right.start) + .forEach((range) => { + if (range.start < startIndex) { + return; + } + redactedSource += `${source.slice(startIndex, range.start)}[REDACTED]`; + startIndex = range.end; + }); + return `${redactedSource}${source.slice(startIndex)}`; +} +function decodeBase64UrlJson(segment) { + try { + return JSON.parse(Buffer.from(segment, "base64url").toString("utf8")); + } + catch { + return undefined; + } +} +function decodeSourceStringLiteral(source) { + const delimiter = source.at(0); + if ((delimiter !== "\"" && delimiter !== "'" && delimiter !== "`") + || source.at(-1) !== delimiter + || (delimiter === "`" && source.includes("${"))) { + return undefined; + } + return decodeJsonEscapedTextFragment(source.slice(1, -1)); +} +function decodeJsonUnicodeEscapedTextFragment(source) { + if (JSON_UNICODE_ESCAPE_SEQUENCE_PATTERN.test(source) === false) { + return undefined; + } + return decodeJsonEscapedTextFragment(source); +} +function decodeJsonEscapedTextFragment(source) { + const decoded = []; + for (let index = 0; index < source.length; index += 1) { + const character = source[index]; + if (character !== "\\") { + decoded.push(character); + continue; + } + const escapeCharacter = source[index + 1]; + switch (escapeCharacter) { + case "\n": + index += 1; + break; + case "\r": + index += source[index + 2] === "\n" ? 2 : 1; + break; + case "\"": + case "'": + case "\\": + case "/": + decoded.push(escapeCharacter); + index += 1; + break; + case "b": + decoded.push("\b"); + index += 1; + break; + case "f": + decoded.push("\f"); + index += 1; + break; + case "n": + decoded.push("\n"); + index += 1; + break; + case "r": + decoded.push("\r"); + index += 1; + break; + case "t": + decoded.push("\t"); + index += 1; + break; + case "u": { + if (source[index + 2] === "{") { + const closingBraceIndex = source.indexOf("}", index + 3); + const hex = closingBraceIndex < 0 ? "" : source.slice(index + 3, closingBraceIndex); + if (/^[0-9a-fA-F]{1,6}$/u.test(hex)) { + const codePoint = Number.parseInt(hex, 16); + if (codePoint <= 0x10ffff && (codePoint < 0xd800 || codePoint > 0xdfff)) { + decoded.push(String.fromCodePoint(codePoint)); + index = closingBraceIndex; + break; + } + } + } + const hex = source.slice(index + 2, index + 6); + if (/^[0-9a-fA-F]{4}$/u.test(hex)) { + decoded.push(String.fromCharCode(Number.parseInt(hex, 16))); + index += 5; + break; + } + decoded.push(character); + break; + } + case "x": { + const hex = source.slice(index + 2, index + 4); + if (/^[0-9a-fA-F]{2}$/u.test(hex)) { + decoded.push(String.fromCharCode(Number.parseInt(hex, 16))); + index += 3; + break; + } + decoded.push(character); + break; + } + default: + if (escapeCharacter !== undefined && /^[0-7]$/u.test(escapeCharacter)) { + const octalMatch = /^[0-7]{1,3}/u.exec(source.slice(index + 1)); + if (octalMatch?.[0] !== undefined) { + decoded.push(String.fromCharCode(Number.parseInt(octalMatch[0], 8))); + index += octalMatch[0].length; + break; + } + } + decoded.push(character); + break; + } + } + return decoded.join(""); +} +function buildSourceTokenRanges(source) { + return { + filesystem: buildTextRanges(source, isFilesystemTokenDelimiter), + url: buildTextRanges(source, isUrlTokenDelimiter), + }; +} +function buildTextRanges(source, isDelimiter) { + const ranges = []; + let start = 0; + for (let index = 0; index < source.length; index += 1) { + if (isDelimiter(source[index])) { + if (start < index) { + ranges.push({ end: index, start }); + } + start = index + 1; + } + } + if (start < source.length) { + ranges.push({ end: source.length, start }); + } + return ranges; +} +function findTextRangeStart(ranges, matchIndex) { + let high = ranges.length - 1; + let low = 0; + while (low <= high) { + const middle = Math.floor((low + high) / 2); + const range = ranges[middle]; + if (matchIndex < range.start) { + high = middle - 1; + } + else if (matchIndex >= range.end) { + low = middle + 1; + } + else { + return range.start; + } + } + return 0; +} +function isFilesystemTokenDelimiter(character) { + return /[\s"'`<>\[({=]/u.test(character); +} +function isUrlTokenDelimiter(character) { + return /[\s"'`<>\[({]/u.test(character); +} +function isFilesystemLikeMachineHomeMatch(source, match, tokenRanges) { + if (hasEnclosingRemoteUrlPrefix(source, match.index, tokenRanges)) { + return false; + } + const tokenStart = findTextRangeStart(tokenRanges.filesystem, match.index); + const tokenPrefix = source.slice(tokenStart, match.index); + return isRemoteUrlPrefix(tokenPrefix) === false; +} +function isFilesystemLikePosixHomeMatch(source, match, tokenRanges) { + const directoryName = match[1]; + if (directoryName === undefined || isRouteParameterDirectory(directoryName)) { + return false; + } + if (hasEnclosingRemoteUrlPrefix(source, match.index, tokenRanges)) { + return false; + } + const tokenStart = findTextRangeStart(tokenRanges.filesystem, match.index); + const tokenPrefix = source.slice(tokenStart, match.index); + if (isRemoteUrlPrefix(tokenPrefix)) { + return false; + } + const nextCharacter = source.at(match.index + match[0].length); + return nextCharacter === "/" + || nextCharacter === "\\" + || nextCharacter === undefined + || POSIX_HOME_PATH_BOUNDARY_PATTERN.test(nextCharacter); +} +function isRouteParameterDirectory(directoryName) { + return directoryName.startsWith(":"); +} +function isFilesystemLikeRootHomeMatch(source, match, tokenRanges) { + if (hasEnclosingRemoteUrlPrefix(source, match.index, tokenRanges)) { + return false; + } + const tokenStart = findTextRangeStart(tokenRanges.filesystem, match.index); + const tokenPrefix = source.slice(tokenStart, match.index); + if (isRemoteUrlPrefix(tokenPrefix)) { + return false; + } + const nextCharacter = source.at(match.index + match[0].length); + return nextCharacter === "/" + || nextCharacter === undefined + || POSIX_HOME_PATH_BOUNDARY_PATTERN.test(nextCharacter); +} +function isGenericHomeDirectoryName(directoryName) { + return GENERIC_HOME_DIRECTORY_NAMES.has(directoryName.toLowerCase()); +} +function isGenericHomeDirectoryMatch(source, match) { + const directoryName = match[1]; + if (directoryName === undefined) { + return false; + } + const nextCharacter = source.at(match.index + match[0].length); + const boundaryIndex = nextCharacter === "/" || nextCharacter === "\\" + ? -1 + : directoryName.search(HOME_DIRECTORY_NAME_GENERIC_BOUNDARY_PATTERN); + const boundedDirectoryName = boundaryIndex < 0 + ? directoryName + : directoryName.slice(0, boundaryIndex); + return isGenericHomeDirectoryName(boundedDirectoryName); +} +function hasEnclosingRemoteUrlPrefix(source, matchIndex, tokenRanges) { + const tokenStart = findTextRangeStart(tokenRanges.url, matchIndex); + const tokenPrefix = source.slice(tokenStart, matchIndex); + const urlMatch = /(?:^|=)([A-Za-z][A-Za-z0-9+.-]*:\/\/[^\s"'`<>]*)$/u.exec(tokenPrefix); + return urlMatch !== null && /^file:\/\//iu.test(urlMatch[1] ?? "") === false; +} +function isRemoteUrlPrefix(tokenPrefix) { + return /^[A-Za-z][A-Za-z0-9+.-]*:\/\/[^\s"'`<>]*$/u.test(tokenPrefix) && /^file:\/\//iu.test(tokenPrefix) === false; +} +function isGitHubAppJwt(candidate) { + const [headerSegment, payloadSegment, signatureSegment] = candidate.split("."); + if (headerSegment === undefined || payloadSegment === undefined || signatureSegment === undefined) { + return false; + } + const header = decodeBase64UrlJson(headerSegment); + const payload = decodeBase64UrlJson(payloadSegment); + if (isJsonRecord(header) === false || isJsonRecord(payload) === false) { + return false; + } + const tokenType = header.typ; + const issuedAt = readJwtNumericDate(payload.iat); + const expiresAt = readJwtNumericDate(payload.exp); + return header.alg === "RS256" + && (tokenType === undefined || (typeof tokenType === "string" && tokenType.toUpperCase() === "JWT")) + && isGitHubAppIssuer(payload.iss) + && issuedAt !== undefined + && expiresAt !== undefined + && expiresAt > issuedAt + && expiresAt - issuedAt <= GITHUB_APP_JWT_MAX_INTERVAL_SECONDS; +} +function isGitHubAppIssuer(issuer) { + if (typeof issuer === "number") { + return Number.isSafeInteger(issuer) && issuer > 0; + } + return typeof issuer === "string" + && (/^[1-9][0-9]{0,15}$/u.test(issuer) || /^[A-Za-z0-9][A-Za-z0-9._-]{5,127}$/u.test(issuer)); +} +function isJsonRecord(value) { + return typeof value === "object" && value !== null && Array.isArray(value) === false; +} +function looksLikeUtf16Be(source) { + return hasUtf16NullBytePattern(source, "be"); +} +function looksLikeUtf16Le(source) { + return hasUtf16NullBytePattern(source, "le"); +} +function looksLikeUtf32Be(source) { + return hasUtf32NullBytePattern(source, "be"); +} +function looksLikeUtf32Le(source) { + return hasUtf32NullBytePattern(source, "le"); +} +function hasUtf16NullBytePattern(source, byteOrder) { + const sampleLength = source.length - (source.length % 2); + if (sampleLength < 4) { + return false; + } + let evenZeroByteCount = 0; + let oddZeroByteCount = 0; + let pairCount = 0; + for (let index = 0; index + 1 < sampleLength; index += 2) { + if (source[index] === 0) { + evenZeroByteCount += 1; + } + if (source[index + 1] === 0) { + oddZeroByteCount += 1; + } + pairCount += 1; + } + const expectedZeroByteCount = byteOrder === "be" ? evenZeroByteCount : oddZeroByteCount; + const unexpectedZeroByteCount = byteOrder === "be" ? oddZeroByteCount : evenZeroByteCount; + return (expectedZeroByteCount / pairCount >= 0.25 + && unexpectedZeroByteCount / pairCount <= 0.05 + && expectedZeroByteCount > unexpectedZeroByteCount * 2) + || (expectedZeroByteCount >= SPARSE_UTF16_MIN_ALIGNED_NULL_BYTES + && unexpectedZeroByteCount <= Math.max(4, Math.floor(expectedZeroByteCount * 0.05)) + && expectedZeroByteCount > unexpectedZeroByteCount * 4); +} +function hasUtf32NullBytePattern(source, byteOrder) { + return [0, 1, 2, 3].some((alignmentOffset) => (hasUtf32NullBytePatternAtAlignment(source, byteOrder, alignmentOffset))); +} +function hasUtf32NullBytePatternAtAlignment(source, byteOrder, alignmentOffset) { + const sampleLength = source.length - ((source.length - alignmentOffset) % 4); + if (sampleLength - alignmentOffset < 16) { + return false; + } + let matchingCodePointCount = 0; + let codePointCount = 0; + for (let index = alignmentOffset; index + 3 < sampleLength; index += 4) { + const valueByteIndex = byteOrder === "le" ? index : index + 3; + const firstZeroByteIndex = byteOrder === "le" ? index + 1 : index; + const secondZeroByteIndex = byteOrder === "le" ? index + 2 : index + 1; + const thirdZeroByteIndex = byteOrder === "le" ? index + 3 : index + 2; + if (source[valueByteIndex] !== 0 + && source[firstZeroByteIndex] === 0 + && source[secondZeroByteIndex] === 0 + && source[thirdZeroByteIndex] === 0) { + matchingCodePointCount += 1; + } + codePointCount += 1; + } + return matchingCodePointCount / codePointCount >= 0.25 + || matchingCodePointCount >= SPARSE_UTF32_MIN_ALIGNED_NULL_CODE_POINTS; +} +function stripTextByteOrderMark(source) { + return source.charCodeAt(0) === 0xfeff ? source.slice(1) : source; +} +function decodeGitPathOutput(source) { + try { + return STRICT_UTF8_TEXT_DECODER.decode(source); + } + catch { + return WINDOWS_1252_TEXT_DECODER.decode(source); + } +} +function decodeGitPathRecordsOutput(source) { + if (source.length === 0) { + return ""; + } + const records = []; + let recordStart = 0; + let recordEnd = source.indexOf(0, recordStart); + while (recordEnd >= 0) { + records.push(decodeGitPathOutput(source.subarray(recordStart, recordEnd))); + recordStart = recordEnd + 1; + recordEnd = source.indexOf(0, recordStart); + } + if (recordStart < source.length) { + records.push(decodeGitPathOutput(source.subarray(recordStart))); + return records.join("\0"); + } + return `${records.join("\0")}\0`; +} +function parseTrackedBlobEntries(output) { + if (output.length === 0) { + return []; + } + return output + .split("\0") + .filter(Boolean) + .flatMap((record) => { + const metadataEnd = record.indexOf("\t"); + if (metadataEnd < 0) { + return []; + } + const metadata = record.slice(0, metadataEnd); + const relativePath = record.slice(metadataEnd + 1); + const treeMatch = /^([0-9]{6}) (blob|commit) ([0-9a-f]+)$/iu.exec(metadata); + const indexMatch = /^([0-9]{6}) ([0-9a-f]+) [0-3]$/iu.exec(metadata); + const mode = treeMatch?.[1] ?? indexMatch?.[1]; + const objectId = treeMatch?.[3] ?? indexMatch?.[2]; + const type = treeMatch?.[2] === "commit" || mode === "160000" ? "commit" : "blob"; + if (mode === undefined + || objectId === undefined + || /^0+$/u.test(objectId) + || relativePath.length === 0 + || type === undefined) { + return []; + } + return [{ + mode, + objectId, + path: relativePath, + type, + }]; + }) + .sort((left, right) => left.path.localeCompare(right.path)); +} +function normalizePatchAdditionText(source) { + return source.replace(/\0+/gu, "\n"); +} +function decodeGitObjectHeaders(source) { + const headerEnd = source.indexOf("\n\n"); + if (headerEnd < 0) { + return { + bodyStart: source.length, + text: decodeGitPathOutput(source), + }; + } + return { + bodyStart: headerEnd + 2, + text: decodeGitPathOutput(source.subarray(0, headerEnd)), + }; +} +function parseAnnotatedTagTarget(source) { + let objectId; + let type; + for (const line of source.split(/\r?\n/u)) { + if (line.length === 0) { + break; + } + if (line.startsWith("object ")) { + objectId = line.slice("object ".length); + continue; + } + if (line.startsWith("type ")) { + type = line.slice("type ".length); + } + } + if (objectId === undefined || type === undefined || HISTORY_SHA_PATTERN.test(objectId) === false) { + return undefined; + } + return { + objectId, + type, + }; +} +function peelGitObjectTarget(repoRoot, input) { + let target = input; + const temporaryDirectory = mkdtempSync(path.join(os.tmpdir(), "public-safety-audit-peel-")); + const visitedTagObjectIds = new Set(); + try { + while (target.type === "tag") { + if (visitedTagObjectIds.has(target.objectId)) { + return undefined; + } + visitedTagObjectIds.add(target.objectId); + const tagObjectPath = path.join(temporaryDirectory, `tag-${target.objectId}`); + writeGitCatFileTagToFile(repoRoot, target.objectId, tagObjectPath); + const nextTarget = parseAnnotatedTagTarget(readAnnotatedTagHeaderText(tagObjectPath)); + if (nextTarget === undefined) { + return undefined; + } + target = nextTarget; + } + return target; + } + finally { + rmSync(temporaryDirectory, { force: true, recursive: true }); + } +} +function parseGitLfsPointerFile(filePath) { + const size = statSync(filePath).size; + if (size > GIT_LFS_POINTER_MAX_BYTES) { + return undefined; + } + const sourceText = decodeAuditText(readFileSync(filePath)); + return sourceText === undefined ? undefined : parseGitLfsPointer(sourceText); +} +function parseGitLfsPointer(source) { + const lines = source.trimEnd().split(/\r?\n/u); + if (lines[0] !== "version https://git-lfs.github.com/spec/v1") { + return undefined; + } + let oid; + let size; + for (const line of lines.slice(1)) { + const oidMatch = /^oid sha256:([0-9a-f]{64})$/u.exec(line); + if (oidMatch?.[1] !== undefined) { + oid = oidMatch[1]; + continue; + } + const sizeMatch = /^size ([0-9]+)$/u.exec(line); + if (sizeMatch?.[1] !== undefined) { + const parsedSize = Number.parseInt(sizeMatch[1], 10); + if (Number.isSafeInteger(parsedSize) && parsedSize >= 0) { + size = parsedSize; + } + } + } + if (oid === undefined || size === undefined) { + return undefined; + } + return { + oid, + size, + }; +} +function getLocalGitLfsObjectPath(repoRoot, oid) { + const candidatePath = path.join(getGitLfsObjectsDirectory(repoRoot), oid.slice(0, 2), oid.slice(2, 4), oid); + try { + return lstatSync(candidatePath).isFile() ? candidatePath : undefined; + } + catch { + return undefined; + } +} +function doesGitLfsObjectMatchPointer(filePath, pointer) { + let reader; + try { + const stat = statSync(filePath); + if (stat.size !== pointer.size) { + return false; + } + const hash = createHash("sha256"); + reader = createBufferedFileReader(filePath); + while (fillBufferedReader(reader)) { + hash.update(reader.buffer.subarray(reader.offset, reader.length)); + reader.offset = reader.length; + } + return hash.digest("hex") === pointer.oid; + } + catch { + return false; + } + finally { + if (reader !== undefined) { + closeSync(reader.fileDescriptor); + } + } +} +function getGitLfsObjectsDirectory(repoRoot) { + const gitCommonDirectory = path.resolve(repoRoot, runGit(repoRoot, ["rev-parse", "--git-common-dir"]).trim()); + const configuredStorage = readGitLfsStorageConfig(repoRoot); + const storageDirectory = configuredStorage === undefined + ? path.join(gitCommonDirectory, "lfs") + : resolveGitDirectoryRelativePath(gitCommonDirectory, configuredStorage); + return path.join(storageDirectory, "objects"); +} +function readGitLfsStorageConfig(repoRoot) { + try { + const value = execFileSync("git", ["config", "--get", "--path", "lfs.storage"], { + cwd: repoRoot, + encoding: "utf8", + env: auditGitEnvironment(), + stdio: ["ignore", "pipe", "ignore"], + }).trim(); + return value.length === 0 ? undefined : value; + } + catch { + return undefined; + } +} +function resolveGitDirectoryRelativePath(gitDirectory, configuredPath) { + return path.isAbsolute(configuredPath) + ? configuredPath + : path.resolve(gitDirectory, configuredPath); +} +function readJwtNumericDate(value) { + return typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : undefined; +} +function findRuleIdsForGitBlobs(repoRoot, objectIds) { + const uniqueObjectIds = [...new Set(objectIds)]; + const ruleIdsByObjectId = new Map(); + if (uniqueObjectIds.length === 0) { + return ruleIdsByObjectId; + } + const temporaryDirectory = mkdtempSync(path.join(os.tmpdir(), "public-safety-audit-blob-")); + const outputPath = path.join(temporaryDirectory, "git-cat-file-output"); + let outputFd; + try { + outputFd = openSync(outputPath, "w"); + execFileSync("git", ["cat-file", "--batch"], { + cwd: repoRoot, + env: auditGitEnvironment(), + input: `${uniqueObjectIds.join("\n")}\n`, + stdio: ["pipe", outputFd, "pipe"], + }); + closeSync(outputFd); + outputFd = undefined; + readGitCatFileBatchBlobs(repoRoot, outputPath, ruleIdsByObjectId, temporaryDirectory); + if (ruleIdsByObjectId.size !== uniqueObjectIds.length) { + throw new Error("Public safety audit could not read the local Git history."); + } + return ruleIdsByObjectId; + } + catch { + throw new Error("Public safety audit could not read the local Git history."); + } + finally { + if (outputFd !== undefined) { + closeSync(outputFd); + } + rmSync(temporaryDirectory, { force: true, recursive: true }); + } +} +function readGitCatFileBatchBlobs(repoRoot, outputPath, ruleIdsByObjectId, temporaryDirectory) { + const reader = createBufferedFileReader(outputPath); + try { + while (true) { + const headerBuffer = readBufferedLine(reader); + if (headerBuffer === undefined) { + return; + } + const header = headerBuffer.toString("utf8"); + const headerMatch = /^([0-9a-f]{40}|[0-9a-f]{64}) ([a-z]+) ([0-9]+)$/iu.exec(header); + if (headerMatch?.[1] === undefined || headerMatch[2] === undefined || headerMatch[3] === undefined) { + throw new Error("Public safety audit could not read the local Git history."); + } + const objectId = headerMatch[1]; + const objectSize = Number.parseInt(headerMatch[3], 10); + if (headerMatch[2] !== "blob" || Number.isSafeInteger(objectSize) === false || objectSize < 0) { + throw new Error("Public safety audit could not read the local Git history."); + } + ruleIdsByObjectId.set(objectId, findRuleIdsForGitCatFileBatchBlob(repoRoot, reader, objectId, objectSize, temporaryDirectory)); + const separator = readBufferedByte(reader); + if (separator !== 0x0a) { + throw new Error("Public safety audit could not read the local Git history."); + } + } + } + finally { + closeSync(reader.fileDescriptor); + } +} +function findRuleIdsForGitCatFileBatchBlob(repoRoot, reader, objectId, objectSize, temporaryDirectory) { + if (objectSize <= GIT_BLOB_FULL_DECODE_MAX_BYTES) { + return findRuleIdsForGitBlobBuffer(repoRoot, readBufferedBytes(reader, objectSize)); + } + const blobPath = path.join(temporaryDirectory, `blob-${objectId}`); + const blobFd = openSync(blobPath, "w"); + try { + readBufferedBytesToFile(reader, objectSize, blobFd); + } + finally { + closeSync(blobFd); + } + return findRuleIdsForGitBlobFile(repoRoot, blobPath); +} +function findRuleIdsForGitBlobBuffer(repoRoot, source) { + if (source.length <= GIT_LFS_POINTER_MAX_BYTES) { + const sourceText = decodeAuditText(source); + const lfsPointer = sourceText === undefined ? undefined : parseGitLfsPointer(sourceText); + if (lfsPointer !== undefined) { + const pointerRuleIds = findRuleIdsForAuditTextBuffer(source); + const lfsObjectPath = getLocalGitLfsObjectPath(repoRoot, lfsPointer.oid); + if (lfsObjectPath === undefined || doesGitLfsObjectMatchPointer(lfsObjectPath, lfsPointer) === false) { + return uniqueRuleIds([...pointerRuleIds, "git-lfs-pointer"]); + } + try { + return uniqueRuleIds([...pointerRuleIds, ...findRuleIdsForAuditTextFile(lfsObjectPath)]); + } + catch { + return uniqueRuleIds([...pointerRuleIds, "git-lfs-pointer"]); + } + } + } + return findRuleIdsForAuditTextBuffer(source); +} +function findRuleIdsForGitBlobFile(repoRoot, filePath) { + const lfsPointer = parseGitLfsPointerFile(filePath); + if (lfsPointer !== undefined) { + const pointerRuleIds = findRuleIdsForAuditTextBuffer(readFileSync(filePath)); + const lfsObjectPath = getLocalGitLfsObjectPath(repoRoot, lfsPointer.oid); + if (lfsObjectPath === undefined || doesGitLfsObjectMatchPointer(lfsObjectPath, lfsPointer) === false) { + return uniqueRuleIds([...pointerRuleIds, "git-lfs-pointer"]); + } + try { + return uniqueRuleIds([...pointerRuleIds, ...findRuleIdsForAuditTextFile(lfsObjectPath)]); + } + catch { + return uniqueRuleIds([...pointerRuleIds, "git-lfs-pointer"]); + } + } + return findRuleIdsForAuditTextFile(filePath); +} +function uniqueRuleIds(ruleIds) { + const orderedRuleIds = ["access-token", "git-lfs-pointer", "machine-home-path", "private-key"]; + return orderedRuleIds + .filter((ruleId) => ruleIds.includes(ruleId)); +} +function findRuleIdsForAuditTextFile(filePath) { + if (statSync(filePath).size <= GIT_BLOB_FULL_DECODE_MAX_BYTES) { + return findRuleIdsForAuditTextBuffer(readFileSync(filePath)); + } + return findRuleIdsForLargeAuditTextFile(filePath); +} +function findRuleIdsForAuditTextBuffer(source) { + const foundRuleIds = new Set(); + scanDecodedAuditTextVariants(source, (sourceText) => { + findRuleIdsForText(sourceText).forEach((ruleId) => { + foundRuleIds.add(ruleId); + }); + return foundRuleIds.size === PUBLIC_SAFETY_AUDIT_RULES.length; + }); + return PUBLIC_SAFETY_AUDIT_RULES + .map((rule) => rule.id) + .filter((ruleId) => foundRuleIds.has(ruleId)); +} +function findRuleIdsForText(source) { + return PUBLIC_SAFETY_AUDIT_RULES + .filter((rule) => rule.matches(source)) + .map((rule) => rule.id); +} +function couldMatchRuleInText(ruleId, source) { + switch (ruleId) { + case "access-token": + return source.includes("AKIA") + || source.includes("ASIA") + || source.includes("AIza") + || source.includes("eyJ") + || source.includes("gh") + || source.includes("github_pat_") + || source.includes("npm_") + || source.includes("pypi-") + || source.includes("sk-") + || source.includes("xox"); + case "git-lfs-pointer": + return false; + case "machine-home-path": + return POSIX_USERS_PREFIX_PATTERN.test(source) + || source.includes(POSIX_HOME_PREFIX) + || source.includes(`/${ROOT_HOME_NAME}`) + || source.includes(VAR_ROOT_PREFIX) + || WINDOWS_USERS_PREFIX_PATTERN.test(source) + || WINDOWS_DRIVE_USERS_PREFIX_PATTERN.test(source); + case "private-key": + return source.includes("PRIVATE KEY BLOCK") + || source.includes(" PRIVATE KEY-----") + || source.includes("PuTTY-User-Key-File-") + || source.includes("Private-Lines:"); + } +} +function findRuleIdsForLargeAuditTextFile(filePath) { + const foundRuleIds = new Set(); + scanLargeDecodedAuditTextFile(filePath, () => createLargeBlobTextScanner(foundRuleIds)); + return PUBLIC_SAFETY_AUDIT_RULES + .map((rule) => rule.id) + .filter((ruleId) => foundRuleIds.has(ruleId)); +} +function createLargeBlobTextScanner(foundRuleIds) { + const privateKeyDetector = createStreamingPrivateKeyDetector(); + const scanWindow = (source) => { + const prefilterSources = source.includes("\\") + ? [source, decodeJsonEscapedTextFragment(source)] + : [source]; + PUBLIC_SAFETY_AUDIT_RULES.forEach((rule) => { + if (foundRuleIds.has(rule.id) === false + && prefilterSources.some((prefilterSource) => couldMatchRuleInText(rule.id, prefilterSource)) + && rule.matches(source)) { + foundRuleIds.add(rule.id); + } + }); + return foundRuleIds.size === PUBLIC_SAFETY_AUDIT_RULES.length; + }; + return { + end: (sourceWindow) => { + if (privateKeyDetector.end()) { + foundRuleIds.add("private-key"); + } + return scanWindow(sourceWindow); + }, + write: ({ decodedChunk, sourceWindow }) => { + if (privateKeyDetector.write(decodedChunk)) { + foundRuleIds.add("private-key"); + } + return scanWindow(sourceWindow); + }, + }; +} +function containsRootMachineHomePath(source, tokenRanges) { + for (const match of source.matchAll(ROOT_HOME_PATH_PATTERN)) { + if (isFilesystemLikeRootHomeMatch(source, match, tokenRanges)) { + return true; + } + } + return false; +} +export function redactSensitivePath(sourcePath) { + const withAccessTokensRedacted = ACCESS_TOKEN_PATTERNS.reduce((redactedPath, pattern) => { + const flags = pattern.flags.includes("g") ? pattern.flags : `${pattern.flags}g`; + return redactedPath.replace(new RegExp(pattern.source, flags), "[REDACTED]"); + }, sourcePath); + const withJwtRedacted = withAccessTokensRedacted.replace(GITHUB_APP_JWT_PATTERN, "[REDACTED]"); + const withJsonDecodedSecretsRedacted = redactJsonDecodedSensitiveValues(withJwtRedacted); + const withPrivateKeysRedacted = redactPrivateKeyBlocks(withJsonDecodedSecretsRedacted); + return redactMachineHomePathSegments(withPrivateKeysRedacted); +} +function redactJsonDecodedSensitiveValues(sourcePath) { + if (sourcePath.includes("\\") === false + && sourcePath.includes("\"") === false + && sourcePath.includes("'") === false) { + return sourcePath; + } + const withSensitiveSourceLiteralsRedacted = redactSensitiveSourceLiterals(sourcePath); + const decodedFragment = decodeJsonUnicodeEscapedTextFragment(withSensitiveSourceLiteralsRedacted); + if (decodedFragment !== undefined && containsSensitiveValueInPlainText(decodedFragment)) { + return "[REDACTED]"; + } + return withSensitiveSourceLiteralsRedacted; +} +function redactSensitiveSourceLiterals(sourcePath) { + const redactionRanges = []; + let previousEnd; + let runLiterals = []; + const flushLiteralRun = () => { + if (runLiterals.length === 0) { + return; + } + if (runLiterals.length === 1) { + const literal = runLiterals[0]; + if (containsSensitiveValueInPlainText(literal.decodedText)) { + redactionRanges.push({ + end: literal.end - 1, + start: literal.start + 1, + }); + } + } + else if (containsSensitiveValueInPlainText(runLiterals.map((literal) => literal.decodedText).join(""))) { + redactionRanges.push({ + end: runLiterals[runLiterals.length - 1].end, + start: runLiterals[0].start, + }); + } + runLiterals = []; + }; + for (const match of sourcePath.matchAll(SOURCE_STRING_LITERAL_PATTERN)) { + const decodedText = decodeSourceStringLiteral(match[0]); + if (decodedText === undefined) { + flushLiteralRun(); + previousEnd = undefined; + continue; + } + const separator = previousEnd === undefined ? undefined : sourcePath.slice(previousEnd, match.index); + if (runLiterals.length > 0 && separator !== undefined && isSourceLiteralConcatenationSeparator(separator)) { + runLiterals.push({ + decodedText, + end: match.index + match[0].length, + start: match.index, + }); + } + else { + flushLiteralRun(); + runLiterals.push({ + decodedText, + end: match.index + match[0].length, + start: match.index, + }); + } + previousEnd = match.index + match[0].length; + } + flushLiteralRun(); + return redactSensitiveBlockRanges(sourcePath, redactionRanges); +} +function containsSensitiveValueInPlainText(source) { + return containsAccessTokenInPlainText(source) + || containsNonGenericMachineHomePathInPlainText(source) + || containsPrivateKeyBlockInPlainText(source); +} +function redactPrivateKeyBlocks(sourcePath) { + return redactSensitiveBlockRanges(sourcePath, [ + ...collectPemPrivateKeyBlockRanges(sourcePath), + ...collectOpenPgpPrivateKeyBlockRanges(sourcePath), + ...collectPuttyPrivateKeyBlockRanges(sourcePath), + ]); +} +function redactMachineHomePathSegments(sourcePath) { + return redactWindowsMachineHomePathSegments(redactRootMachineHomePathSegments(redactPosixMachineHomePathSegments(sourcePath))); +} +function redactPosixMachineHomePathSegments(sourcePath) { + let redactedPath = ""; + let startIndex = 0; + const tokenRanges = buildSourceTokenRanges(sourcePath); + for (const match of sourcePath.matchAll(POSIX_HOME_PATH_PATTERN)) { + const directoryName = match[1]; + if (directoryName === undefined + || isGenericHomeDirectoryMatch(sourcePath, match) + || isFilesystemLikePosixHomeMatch(sourcePath, match, tokenRanges) === false) { + continue; + } + const directoryStart = match.index + match[0].length - directoryName.length; + redactedPath += `${sourcePath.slice(startIndex, directoryStart)}[REDACTED]`; + startIndex = directoryStart + directoryName.length; + } + return `${redactedPath}${sourcePath.slice(startIndex)}`; +} +function redactWindowsMachineHomePathSegments(sourcePath) { + let redactedPath = ""; + let startIndex = 0; + const tokenRanges = buildSourceTokenRanges(sourcePath); + for (const match of sourcePath.matchAll(WINDOWS_HOME_PATH_PATTERN)) { + const directoryName = match[1]; + if (directoryName === undefined + || isGenericHomeDirectoryMatch(sourcePath, match) + || isFilesystemLikeMachineHomeMatch(sourcePath, match, tokenRanges) === false) { + continue; + } + const directoryStart = match.index + match[0].length - directoryName.length; + redactedPath += `${sourcePath.slice(startIndex, directoryStart)}[REDACTED]`; + startIndex = directoryStart + directoryName.length; + } + return `${redactedPath}${sourcePath.slice(startIndex)}`; +} +function redactRootMachineHomePathSegments(sourcePath) { + let redactedPath = ""; + let startIndex = 0; + const tokenRanges = buildSourceTokenRanges(sourcePath); + for (const match of sourcePath.matchAll(ROOT_HOME_PATH_PATTERN)) { + if (isFilesystemLikeRootHomeMatch(sourcePath, match, tokenRanges) === false) { + continue; + } + const directoryStart = match.index + 1; + redactedPath += `${sourcePath.slice(startIndex, directoryStart)}[REDACTED]`; + startIndex = match.index + match[0].length; + } + return `${redactedPath}${sourcePath.slice(startIndex)}`; +} +function swapUtf16ByteOrder(source) { + const swapped = Buffer.alloc(source.length - (source.length % 2)); + for (let index = 0; index + 1 < source.length; index += 2) { + swapped[index] = source[index + 1]; + swapped[index + 1] = source[index]; + } + return swapped; +} +function decodeUtf32Text(source, byteOrder) { + const codePoints = []; + const sourceLength = source.length - (source.length % 4); + let decoded = ""; + const flushCodePoints = () => { + if (codePoints.length > 0) { + decoded += String.fromCodePoint(...codePoints); + codePoints.length = 0; + } + }; + for (let index = 0; index + 3 < sourceLength; index += 4) { + const codePoint = byteOrder === "le" + ? source[index] + (source[index + 1] * 0x100) + (source[index + 2] * 0x10000) + (source[index + 3] * 0x1000000) + : source[index + 3] + (source[index + 2] * 0x100) + (source[index + 1] * 0x10000) + (source[index] * 0x1000000); + if (codePoint > 0x10ffff || (codePoint >= 0xd800 && codePoint <= 0xdfff)) { + codePoints.push(0xfffd); + if (codePoints.length >= 4096) { + flushCodePoints(); + } + continue; + } + codePoints.push(codePoint); + if (codePoints.length >= 4096) { + flushCodePoints(); + } + } + flushCodePoints(); + return stripTextByteOrderMark(decoded); +} +function decodeUtf32TextAtByteOffset(source, byteOrder, byteOffset) { + const offsetSource = source.subarray(byteOffset); + if (offsetSource.length < 4) { + return undefined; + } + const alignedLength = offsetSource.length - (offsetSource.length % 4); + return decodeUtf32Text(offsetSource.subarray(0, alignedLength), byteOrder); +} +function decodeSingleByteSafeText(source) { + if (source.includes(0)) { + return stripTextByteOrderMark(decodeGitPathOutput(source).replace(/\0+/gu, "\n")); + } + return stripTextByteOrderMark(decodeGitPathOutput(source)); +} +function findingsForSource(input) { + const allowedRuleIds = input.ruleIds === undefined ? undefined : new Set(input.ruleIds); + return PUBLIC_SAFETY_AUDIT_RULES + .filter((rule) => allowedRuleIds === undefined || allowedRuleIds.has(rule.id)) + .filter((rule) => rule.matches(input.text)) + .map((rule) => ({ + ...(input.commit === undefined ? {} : { commit: input.commit }), + ...(input.path === undefined ? {} : { path: input.path }), + ruleId: rule.id, + scope: input.scope, + source: input.source, + })); +} +function findingsForRuleIds(input) { + return input.ruleIds.map((ruleId) => ({ + ...(input.commit === undefined ? {} : { commit: input.commit }), + ...(input.path === undefined ? {} : { path: input.path }), + ruleId, + scope: input.scope, + source: input.source, + })); +} +function runGit(repoRoot, args) { + try { + return execFileSync("git", gitArgsWithAuditConfig(args), { + cwd: repoRoot, + encoding: "utf8", + env: auditGitEnvironment(), + maxBuffer: GIT_TEXT_MAX_BUFFER_BYTES, + stdio: ["ignore", "pipe", "pipe"], + }); + } + catch { + throw new Error("Public safety audit could not read the local Git history."); + } +} +function runGitBuffer(repoRoot, args) { + try { + return execFileSync("git", gitArgsWithAuditConfig(args), { + cwd: repoRoot, + env: auditGitEnvironment(), + maxBuffer: GIT_TEXT_MAX_BUFFER_BYTES, + stdio: ["ignore", "pipe", "pipe"], + }); + } + catch { + throw new Error("Public safety audit could not read the local Git history."); + } +} +function writeGitCatFileTagToFile(repoRoot, objectId, outputPath) { + let outputFd; + try { + outputFd = openSync(outputPath, "w"); + execFileSync("git", ["cat-file", "tag", objectId], { + cwd: repoRoot, + env: auditGitEnvironment(), + stdio: ["ignore", outputFd, "pipe"], + }); + closeSync(outputFd); + outputFd = undefined; + } + catch { + throw new Error("Public safety audit could not read the local Git history."); + } + finally { + if (outputFd !== undefined) { + closeSync(outputFd); + } + } +} +function scanGitOutputLines(repoRoot, args, onLine) { + scanGitOutputFile(repoRoot, args, (outputPath) => { + scanTextFileLines(outputPath, onLine); + }); +} +function scanGitOutputLineSegments(repoRoot, args, onSegment) { + scanGitOutputFile(repoRoot, args, (outputPath) => { + scanTextFileLineSegments(outputPath, onSegment); + }); +} +function scanGitOutputRecords(repoRoot, args, onRecord) { + scanGitOutputFile(repoRoot, args, (outputPath) => { + scanTextFileRecords(outputPath, onRecord); + }); +} +function scanGitOutputRecordSegments(repoRoot, args, onSegment) { + scanGitOutputFile(repoRoot, args, (outputPath) => { + scanTextFileRecordSegments(outputPath, onSegment); + }); +} +function scanGitOutputFile(repoRoot, args, scanOutput) { + const temporaryDirectory = mkdtempSync(path.join(os.tmpdir(), "public-safety-audit-")); + const outputPath = path.join(temporaryDirectory, "git-output.txt"); + let outputFd; + try { + outputFd = openSync(outputPath, "w"); + execFileSync("git", gitArgsWithAuditConfig(args), { + cwd: repoRoot, + env: auditGitEnvironment(), + stdio: ["ignore", outputFd, "pipe"], + }); + closeSync(outputFd); + outputFd = undefined; + scanOutput(outputPath); + } + catch { + throw new Error("Public safety audit could not read the local Git history."); + } + finally { + if (outputFd !== undefined) { + closeSync(outputFd); + } + rmSync(temporaryDirectory, { force: true, recursive: true }); + } +} +function scanGitOutputFromInput(repoRoot, args, input, scanOutput) { + const temporaryDirectory = mkdtempSync(path.join(os.tmpdir(), "public-safety-audit-")); + const outputPath = path.join(temporaryDirectory, "git-output.txt"); + let outputFd; + try { + outputFd = openSync(outputPath, "w"); + execFileSync("git", gitArgsWithAuditConfig(args), { + cwd: repoRoot, + env: auditGitEnvironment(), + input, + stdio: ["pipe", outputFd, "pipe"], + }); + closeSync(outputFd); + outputFd = undefined; + scanOutput(outputPath); + } + catch { + throw new Error("Public safety audit could not read the local Git history."); + } + finally { + if (outputFd !== undefined) { + closeSync(outputFd); + } + rmSync(temporaryDirectory, { force: true, recursive: true }); + } +} +function gitArgsWithAuditConfig(args) { + return args[0] === "log" + ? ["-c", "i18n.logOutputEncoding=UTF-8", ...args] + : args; +} +function auditGitEnvironment() { + return { + ...process.env, + GIT_NO_REPLACE_OBJECTS: "1", + }; +} +function scanLargeDecodedAuditTextFile(filePath, createScanner) { + const detection = detectLargeBlobTextEncoding(filePath); + const encodings = detection.scanSingleByteFallback + ? uniqueLargeBlobTextEncodings([...detection.encodings, "single-byte"]) + : detection.encodings; + for (const encoding of encodings) { + for (const input of largeBlobTextDecoderInputs(encoding, detection)) { + if (scanLargeDecodedAuditTextFileWithEncoding(filePath, input, createScanner())) { + return; + } + } + } +} +function scanLargeDecodedAuditTextFileWithEncoding(filePath, input, scanner) { + const buffer = Buffer.alloc(LARGE_BLOB_SCAN_CHUNK_BYTES); + const fileDescriptor = openSync(filePath, "r"); + const decoder = createLargeBlobTextDecoder(input); + let pending = ""; + try { + let bytesRead = readSync(fileDescriptor, buffer, 0, buffer.length, null); + while (bytesRead > 0) { + const chunk = Buffer.from(buffer.subarray(0, bytesRead)); + const decodedChunk = decoder.write(chunk); + pending = boundedAuditTextWindow(pending, decodedChunk); + if (scanner.write({ decodedChunk, sourceWindow: pending })) { + return true; + } + bytesRead = readSync(fileDescriptor, buffer, 0, buffer.length, null); + } + const decodedChunk = decoder.end(); + pending = boundedAuditTextWindow(pending, decodedChunk); + if (scanner.write({ decodedChunk, sourceWindow: pending })) { + return true; + } + return scanner.end(pending); + } + finally { + closeSync(fileDescriptor); + } +} +function detectLargeBlobTextEncoding(filePath) { + const buffer = Buffer.alloc(LARGE_BLOB_SCAN_CHUNK_BYTES); + const fileDescriptor = openSync(filePath, "r"); + let sawUtf16Be = false; + let sawUtf16Le = false; + let sawUtf32Be = false; + let sawUtf32Le = false; + try { + let bytesRead = readSync(fileDescriptor, buffer, 0, buffer.length, null); + let isFirstChunk = true; + while (bytesRead > 0) { + const chunk = Buffer.from(buffer.subarray(0, bytesRead)); + if (isFirstChunk) { + if (chunk.length >= 4 && chunk[0] === 0xff && chunk[1] === 0xfe && chunk[2] === 0 && chunk[3] === 0) { + return { + encodings: ["utf32le", "utf32be"], + hasBom: true, + scanSingleByteFallback: true, + }; + } + if (chunk.length >= 4 && chunk[0] === 0 && chunk[1] === 0 && chunk[2] === 0xfe && chunk[3] === 0xff) { + return { + encodings: ["utf32be", "utf32le"], + hasBom: true, + scanSingleByteFallback: true, + }; + } + if (chunk.length >= 2 && chunk[0] === 0xff && chunk[1] === 0xfe) { + return { + encodings: ["utf16le", "utf16be"], + hasBom: true, + scanSingleByteFallback: true, + }; + } + if (chunk.length >= 2 && chunk[0] === 0xfe && chunk[1] === 0xff) { + return { + encodings: ["utf16be", "utf16le"], + hasBom: true, + scanSingleByteFallback: true, + }; + } + isFirstChunk = false; + } + sawUtf32Le ||= looksLikeUtf32Le(chunk); + sawUtf32Be ||= looksLikeUtf32Be(chunk); + sawUtf16Le ||= looksLikeUtf16Le(chunk); + sawUtf16Be ||= looksLikeUtf16Be(chunk); + bytesRead = readSync(fileDescriptor, buffer, 0, buffer.length, null); + } + } + finally { + closeSync(fileDescriptor); + } + if (sawUtf32Le && sawUtf32Be) { + return { + encodings: ["utf32le", "utf32be"], + hasBom: false, + scanSingleByteFallback: true, + }; + } + if (sawUtf32Le) { + return { + encodings: ["utf32le", "utf32be"], + hasBom: false, + scanSingleByteFallback: true, + }; + } + if (sawUtf32Be) { + return { + encodings: ["utf32be", "utf32le"], + hasBom: false, + scanSingleByteFallback: true, + }; + } + if (sawUtf16Le && sawUtf16Be) { + return { + encodings: ["utf16le", "utf16be"], + hasBom: false, + scanSingleByteFallback: true, + }; + } + if (sawUtf16Le) { + return { + encodings: ["utf16le"], + hasBom: false, + scanSingleByteFallback: true, + }; + } + if (sawUtf16Be) { + return { + encodings: ["utf16be"], + hasBom: false, + scanSingleByteFallback: true, + }; + } + return { + encodings: ["single-byte"], + hasBom: false, + scanSingleByteFallback: false, + }; +} +function uniqueLargeBlobTextEncodings(encodings) { + const uniqueEncodings = []; + encodings.forEach((encoding) => { + if (uniqueEncodings.includes(encoding) === false) { + uniqueEncodings.push(encoding); + } + }); + return uniqueEncodings; +} +function largeBlobTextDecoderInputs(encoding, detection) { + if (encoding === "utf16be") { + return [ + { byteOffset: 0, encoding }, + { byteOffset: 1, encoding: detection.hasBom ? "utf16be" : "utf16le" }, + ]; + } + if (encoding === "utf16le") { + return [ + { byteOffset: 0, encoding }, + { byteOffset: 1, encoding: detection.hasBom ? "utf16le" : "utf16be" }, + ]; + } + if (encoding === "utf32be" || encoding === "utf32le") { + if (detection.hasBom) { + return [0, 1, 2, 3].map((alignmentOffset) => ({ + byteOffset: 4 + alignmentOffset, + encoding, + })); + } + return [0, 1, 2, 3].map((alignmentOffset) => ({ + byteOffset: alignmentOffset, + encoding, + })); + } + return [{ byteOffset: 0, encoding }]; +} +function createLargeBlobTextDecoder(input) { + switch (input.encoding) { + case "single-byte": + return createLargeSingleByteTextDecoder(); + case "utf16be": + return createLargeUtf16TextDecoder("be", input.byteOffset); + case "utf16le": + return createLargeUtf16TextDecoder("le", input.byteOffset); + case "utf32be": + return createLargeUtf32TextDecoder("be", input.byteOffset); + case "utf32le": + return createLargeUtf32TextDecoder("le", input.byteOffset); + } +} +function createLargeSingleByteTextDecoder() { + const decoder = new StringDecoder("utf8"); + return { + end: () => decoder.end().replace(/\0+/gu, "\n"), + write: (chunk) => decoder.write(chunk).replace(/\0+/gu, "\n"), + }; +} +function createLargeUtf16TextDecoder(byteOrder, byteOffset) { + let pending = Buffer.alloc(0); + let stripBom = true; + let remainingByteOffset = byteOffset; + const decodeChunk = (chunk) => { + const offsetChunk = remainingByteOffset === 0 + ? chunk + : chunk.subarray(Math.min(remainingByteOffset, chunk.length)); + remainingByteOffset = Math.max(remainingByteOffset - chunk.length, 0); + const source = pending.length === 0 ? offsetChunk : Buffer.concat([pending, offsetChunk]); + const alignedLength = source.length - (source.length % 2); + pending = Buffer.from(source.subarray(alignedLength)); + const aligned = source.subarray(0, alignedLength); + const decoded = byteOrder === "le" + ? aligned.toString("utf16le") + : swapUtf16ByteOrder(aligned).toString("utf16le"); + if (stripBom) { + stripBom = false; + return stripTextByteOrderMark(decoded); + } + return decoded; + }; + return { + end: () => decodeChunk(Buffer.alloc(0)), + write: decodeChunk, + }; +} +function createLargeUtf32TextDecoder(byteOrder, byteOffset) { + let pending = Buffer.alloc(0); + let remainingByteOffset = byteOffset; + const decodeChunk = (chunk) => { + const offsetChunk = remainingByteOffset === 0 + ? chunk + : chunk.subarray(Math.min(remainingByteOffset, chunk.length)); + remainingByteOffset = Math.max(remainingByteOffset - chunk.length, 0); + const source = pending.length === 0 ? offsetChunk : Buffer.concat([pending, offsetChunk]); + const alignedLength = source.length - (source.length % 4); + pending = Buffer.from(source.subarray(alignedLength)); + return decodeUtf32Text(source.subarray(0, alignedLength), byteOrder) ?? ""; + }; + return { + end: () => "", + write: decodeChunk, + }; +} +function boundedAuditTextWindow(previous, next) { + const source = `${previous}${next}`; + return source.length <= LARGE_BLOB_SCAN_OVERLAP_CHARACTERS + ? source + : source.slice(source.length - LARGE_BLOB_SCAN_OVERLAP_CHARACTERS); +} +function scanTextFileLines(filePath, onLine) { + const buffer = Buffer.alloc(64 * 1024); + const decoder = new StringDecoder("utf8"); + const fileDescriptor = openSync(filePath, "r"); + let pending = ""; + try { + let bytesRead = readSync(fileDescriptor, buffer, 0, buffer.length, null); + while (bytesRead > 0) { + pending += decoder.write(buffer.subarray(0, bytesRead)); + const lines = pending.split(/\r?\n/u); + pending = lines.pop() ?? ""; + lines.forEach((line) => { + onLine(line); + }); + bytesRead = readSync(fileDescriptor, buffer, 0, buffer.length, null); + } + pending += decoder.end(); + if (pending.length > 0) { + onLine(pending); + } + } + finally { + closeSync(fileDescriptor); + } +} +function scanTextFileLineSegments(filePath, onSegment) { + const buffer = Buffer.alloc(64 * 1024); + const decoder = new StringDecoder("utf8"); + const fileDescriptor = openSync(filePath, "r"); + let pending = ""; + let startsLine = true; + const emitSegment = (text, endsLine) => { + onSegment({ + endsLine, + startsLine, + text, + }); + startsLine = endsLine; + }; + const emitLineText = (lineText, endsLine) => { + if (lineText.length === 0 && endsLine) { + emitSegment("", true); + return; + } + let start = 0; + while (start < lineText.length) { + const end = Math.min(start + TEXT_FILE_LINE_SEGMENT_MAX_CHARACTERS, lineText.length); + emitSegment(lineText.slice(start, end), endsLine && end === lineText.length); + start = end; + } + }; + const drainCompleteLines = () => { + let start = 0; + for (const match of pending.matchAll(/\r?\n/gu)) { + emitLineText(pending.slice(start, match.index), true); + start = match.index + match[0].length; + } + pending = pending.slice(start); + }; + const drainOversizedPending = () => { + while (pending.length > TEXT_FILE_LINE_SEGMENT_MAX_CHARACTERS) { + emitSegment(pending.slice(0, TEXT_FILE_LINE_SEGMENT_MAX_CHARACTERS), false); + pending = pending.slice(TEXT_FILE_LINE_SEGMENT_MAX_CHARACTERS); + } + }; + try { + let bytesRead = readSync(fileDescriptor, buffer, 0, buffer.length, null); + while (bytesRead > 0) { + pending += decoder.write(buffer.subarray(0, bytesRead)); + drainCompleteLines(); + drainOversizedPending(); + bytesRead = readSync(fileDescriptor, buffer, 0, buffer.length, null); + } + pending += decoder.end(); + drainCompleteLines(); + if (pending.length > 0) { + emitLineText(pending, true); + } + } + finally { + closeSync(fileDescriptor); + } +} +function scanTextFileRecords(filePath, onRecord) { + const buffer = Buffer.alloc(64 * 1024); + const fileDescriptor = openSync(filePath, "r"); + let pending = Buffer.alloc(0); + try { + let bytesRead = readSync(fileDescriptor, buffer, 0, buffer.length, null); + while (bytesRead > 0) { + const chunk = pending.length === 0 + ? Buffer.from(buffer.subarray(0, bytesRead)) + : Buffer.concat([pending, buffer.subarray(0, bytesRead)]); + let recordStart = 0; + let recordEnd = chunk.indexOf(0, recordStart); + while (recordEnd >= 0) { + onRecord(decodeGitPathOutput(chunk.subarray(recordStart, recordEnd))); + recordStart = recordEnd + 1; + recordEnd = chunk.indexOf(0, recordStart); + } + pending = Buffer.from(chunk.subarray(recordStart)); + bytesRead = readSync(fileDescriptor, buffer, 0, buffer.length, null); + } + if (pending.length > 0) { + onRecord(decodeGitPathOutput(pending)); + } + } + finally { + closeSync(fileDescriptor); + } +} +function scanTextFileRecordSegments(filePath, onSegment) { + const buffer = Buffer.alloc(64 * 1024); + const decoder = new StringDecoder("utf8"); + const fileDescriptor = openSync(filePath, "r"); + let pending = ""; + let recordIndex = 0; + const emitSegment = (text, endsRecord) => { + onSegment({ + endsRecord, + index: recordIndex, + text, + }); + if (endsRecord) { + recordIndex += 1; + } + }; + const drainCompleteRecords = () => { + let recordEnd = pending.indexOf("\0"); + while (recordEnd >= 0) { + emitSegment(pending.slice(0, recordEnd), true); + pending = pending.slice(recordEnd + 1); + recordEnd = pending.indexOf("\0"); + } + }; + const drainOversizedPending = () => { + while (pending.length > TEXT_FILE_LINE_SEGMENT_MAX_CHARACTERS) { + emitSegment(pending.slice(0, TEXT_FILE_LINE_SEGMENT_MAX_CHARACTERS), false); + pending = pending.slice(TEXT_FILE_LINE_SEGMENT_MAX_CHARACTERS); + } + }; + try { + let bytesRead = readSync(fileDescriptor, buffer, 0, buffer.length, null); + while (bytesRead > 0) { + pending += decoder.write(buffer.subarray(0, bytesRead)); + drainCompleteRecords(); + drainOversizedPending(); + bytesRead = readSync(fileDescriptor, buffer, 0, buffer.length, null); + } + pending += decoder.end(); + drainCompleteRecords(); + if (pending.length > 0) { + emitSegment(pending, true); + } + } + finally { + closeSync(fileDescriptor); + } +} +function createBufferedFileReader(filePath) { + return { + buffer: Buffer.alloc(64 * 1024), + fileDescriptor: openSync(filePath, "r"), + length: 0, + offset: 0, + }; +} +function fillBufferedReader(reader) { + if (reader.offset < reader.length) { + return true; + } + reader.length = readSync(reader.fileDescriptor, reader.buffer, 0, reader.buffer.length, null); + reader.offset = 0; + return reader.length > 0; +} +function readBufferedByte(reader) { + if (fillBufferedReader(reader) === false) { + return undefined; + } + const value = reader.buffer[reader.offset]; + reader.offset += 1; + return value; +} +function readBufferedBytes(reader, byteCount) { + const output = Buffer.alloc(byteCount); + let outputOffset = 0; + while (outputOffset < byteCount) { + if (fillBufferedReader(reader) === false) { + throw new Error("Public safety audit could not read the local Git history."); + } + const chunkLength = Math.min(byteCount - outputOffset, reader.length - reader.offset); + reader.buffer.copy(output, outputOffset, reader.offset, reader.offset + chunkLength); + reader.offset += chunkLength; + outputOffset += chunkLength; + } + return output; +} +function readBufferedBytesToFile(reader, byteCount, outputFd) { + let remaining = byteCount; + while (remaining > 0) { + if (fillBufferedReader(reader) === false) { + throw new Error("Public safety audit could not read the local Git history."); + } + const chunkLength = Math.min(remaining, reader.length - reader.offset); + writeSync(outputFd, reader.buffer, reader.offset, chunkLength); + reader.offset += chunkLength; + remaining -= chunkLength; + } +} +function skipBufferedBytes(reader, byteCount) { + let remaining = byteCount; + while (remaining > 0) { + if (fillBufferedReader(reader) === false) { + throw new Error("Public safety audit could not read the local Git history."); + } + const chunkLength = Math.min(remaining, reader.length - reader.offset); + reader.offset += chunkLength; + remaining -= chunkLength; + } +} +function writeBufferedRemainderToFile(reader, outputFd) { + if (reader.offset < reader.length) { + writeSync(outputFd, reader.buffer, reader.offset, reader.length - reader.offset); + reader.offset = reader.length; + } + const buffer = Buffer.alloc(64 * 1024); + let bytesRead = readSync(reader.fileDescriptor, buffer, 0, buffer.length, null); + while (bytesRead > 0) { + writeSync(outputFd, buffer, 0, bytesRead); + bytesRead = readSync(reader.fileDescriptor, buffer, 0, buffer.length, null); + } +} +function readBufferedLine(reader) { + const chunks = []; + while (true) { + if (fillBufferedReader(reader) === false) { + return chunks.length === 0 ? undefined : Buffer.concat(chunks); + } + const lineEnd = reader.buffer.indexOf(0x0a, reader.offset); + const chunkEnd = lineEnd >= 0 && lineEnd < reader.length ? lineEnd : reader.length; + if (chunkEnd > reader.offset) { + chunks.push(Buffer.from(reader.buffer.subarray(reader.offset, chunkEnd))); + } + reader.offset = chunkEnd; + if (lineEnd >= 0 && lineEnd < reader.length) { + reader.offset += 1; + return Buffer.concat(chunks); + } + } +} +function readBufferedLineSegment(reader, maxByteCount) { + const chunks = []; + let byteCount = 0; + while (byteCount < maxByteCount) { + if (fillBufferedReader(reader) === false) { + return byteCount === 0 ? undefined : { + buffer: Buffer.concat(chunks, byteCount), + endsLine: false, + }; + } + const lineEnd = reader.buffer.indexOf(0x0a, reader.offset); + const lineEndInBuffer = lineEnd >= 0 && lineEnd < reader.length; + const chunkLimit = Math.min(lineEndInBuffer ? lineEnd : reader.length, reader.offset + maxByteCount - byteCount); + if (chunkLimit > reader.offset) { + const chunk = reader.buffer.subarray(reader.offset, chunkLimit); + chunks.push(Buffer.from(chunk)); + byteCount += chunk.length; + } + reader.offset = chunkLimit; + if (lineEndInBuffer && reader.offset === lineEnd) { + reader.offset += 1; + return { + buffer: Buffer.concat(chunks, byteCount), + endsLine: true, + }; + } + } + return { + buffer: Buffer.concat(chunks, byteCount), + endsLine: false, + }; +} +function uniqueSortedFindings(findings) { + const byKey = new Map(); + findings.forEach((finding) => { + const key = [ + finding.commit ?? "", + finding.path ?? "", + finding.ruleId, + finding.scope, + finding.source, + ].join("\0"); + byKey.set(key, finding); + }); + return [...byKey.values()].sort((left, right) => { + const leftKey = [left.commit ?? "", left.path ?? "", left.ruleId, left.scope, left.source].join("\0"); + const rightKey = [right.commit ?? "", right.path ?? "", right.ruleId, right.scope, right.source].join("\0"); + return leftKey.localeCompare(rightKey); + }); +} +function uniqueHistoricalBlobEntries(entries) { + const byKey = new Map(); + entries.forEach((entry) => { + const key = [entry.objectId, entry.path].join("\0"); + if (byKey.has(key) === false) { + byKey.set(key, entry); + } + }); + return [...byKey.values()].sort((left, right) => { + const leftKey = [left.path, left.objectId, left.commit].join("\0"); + const rightKey = [right.path, right.objectId, right.commit].join("\0"); + return leftKey.localeCompare(rightKey); + }); +} +function uniqueTrackedEntries(entries) { + const byKey = new Map(); + entries.forEach((entry) => { + byKey.set([entry.mode, entry.objectId, entry.path, entry.type].join("\0"), entry); + }); + return [...byKey.values()].sort((left, right) => { + const leftKey = [left.path, left.mode, left.objectId, left.type].join("\0"); + const rightKey = [right.path, right.mode, right.objectId, right.type].join("\0"); + return leftKey.localeCompare(rightKey); + }); +} diff --git a/public-source-release-audit/scripts/public-source-release-audit.mjs b/public-source-release-audit/scripts/public-source-release-audit.mjs new file mode 100644 index 0000000..0593373 --- /dev/null +++ b/public-source-release-audit/scripts/public-source-release-audit.mjs @@ -0,0 +1,623 @@ +#!/usr/bin/env node + +import { spawnSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import path from "node:path"; + +import { auditPublicSafety, redactSensitivePath } from "./public-safety-audit-core.mjs"; + +const SCHEMA_VERSION = 1; +const MAX_OUTPUT_FINDINGS = 100; +const GITHUB_ACTIONS_APP_ID = 15368; +const FULL_OBJECT_ID_PATTERN = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/iu; +const WORKFLOW_PATH_PATTERN = /^\.github\/workflows\/[^/]+\.ya?ml$/iu; + +main(); + +function main() { + const argumentList = process.argv.slice(2); + let options; + try { + options = parseArguments(argumentList); + } catch (error) { + reportRuntimeError(error, "usage-error", requestedOutputFormat(argumentList)); + return; + } + + if (options.help) { + process.stdout.write(helpText()); + return; + } + + try { + const repoRoot = resolveRepositoryRoot(options.repo); + const sourceResult = auditPublicSafety({ + includeHistory: options.history, + repoRoot, + }); + const workflowFindings = auditWorkflowSources(repoRoot); + const githubFindings = options.github || options.githubSnapshot + ? auditGithubControls(loadGithubEvidence({ + repoRoot, + repository: options.github, + snapshotPath: options.githubSnapshot, + }), options.requiredChecks) + : []; + const findings = uniqueSortedFindings([ + ...sourceResult.findings.map((finding) => ({ ...finding, severity: "error" })), + ...workflowFindings, + ...githubFindings, + ]); + + if (sourceResult.omittedFindingCount > 0) { + findings.push({ + message: "The source scanner omitted findings after its safe reporting limit.", + ruleId: "source-findings-omitted", + scope: "source", + severity: "error", + }); + } + + const errorCount = findings.filter((finding) => finding.severity === "error").length; + const warningCount = findings.filter((finding) => finding.severity === "warning").length; + const omittedFindingCount = Math.max(findings.length - MAX_OUTPUT_FINDINGS, 0); + const reportedFindings = findings.slice(0, MAX_OUTPUT_FINDINGS); + const passed = errorCount === 0 && (options.failOnWarning === false || warningCount === 0); + const result = { + errorCount, + findingCount: findings.length, + findings: reportedFindings, + githubChecked: Boolean(options.github || options.githubSnapshot), + historyScanned: sourceResult.historyScanned, + omittedFindingCount, + passed, + scannedHistoryCommitCount: sourceResult.scannedHistoryCommitCount, + scannedTrackedFileCount: sourceResult.scannedTrackedFileCount, + schemaVersion: SCHEMA_VERSION, + warningCount, + }; + + printResult(result, options.format); + if (passed === false) { + process.exitCode = 1; + } + } catch (error) { + reportRuntimeError(error, "audit-error", options.format); + } +} + +function requestedOutputFormat(args) { + for (let index = args.length - 2; index >= 0; index -= 1) { + if (args[index] === "--format" && args[index + 1] === "json") return "json"; + } + return "text"; +} + +function parseArguments(args) { + const options = { + failOnWarning: false, + format: "text", + github: undefined, + githubSnapshot: undefined, + help: false, + history: false, + repo: ".", + requiredChecks: [], + }; + + for (let index = 0; index < args.length; index += 1) { + const argument = args[index]; + if (argument === "--help" || argument === "-h") { + options.help = true; + } else if (argument === "--history") { + options.history = true; + } else if (argument === "--fail-on-warning") { + options.failOnWarning = true; + } else if (["--repo", "--github", "--github-snapshot", "--required-check", "--format"].includes(argument)) { + const value = args[index + 1]; + if (value === undefined || value.startsWith("--")) { + throw new Error(`${argument} requires a value.`); + } + index += 1; + if (argument === "--repo") options.repo = value; + if (argument === "--github") options.github = value; + if (argument === "--github-snapshot") options.githubSnapshot = value; + if (argument === "--required-check") options.requiredChecks.push(value); + if (argument === "--format") options.format = value; + } else { + throw new Error(`Unsupported argument: ${argument ?? ""}`); + } + } + + if (options.github && options.githubSnapshot) { + throw new Error("Use only one of --github or --github-snapshot."); + } + if (!["json", "text"].includes(options.format)) { + throw new Error("--format must be text or json."); + } + if (options.github && !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u.test(options.github)) { + throw new Error("--github must use OWNER/REPO form."); + } + if (new Set(options.requiredChecks).size !== options.requiredChecks.length) { + throw new Error("--required-check values must be unique."); + } + if (options.requiredChecks.some((value) => value.trim().length === 0)) { + throw new Error("--required-check values must not be empty."); + } + + return options; +} + +function helpText() { + return `Usage: public-source-release-audit [options]\n\nOptions:\n --repo PATH Git repository to audit (default: .)\n --history Audit all locally reachable refs and fail on shallow/grafted history\n --github OWNER/REPO Audit live GitHub repository controls with gh\n --github-snapshot PATH Audit a captured GitHub evidence fixture instead of the network\n --required-check NAME Require a strict GitHub Actions check; repeat as needed\n --fail-on-warning Treat workflow advisories as failures\n --format text|json Output format (default: text)\n --help Show this help\n`; +} + +function resolveRepositoryRoot(inputPath) { + const candidate = path.resolve(inputPath); + const result = runCommand("git", ["rev-parse", "--show-toplevel"], { cwd: candidate }); + return result.stdout.trim(); +} + +function auditWorkflowSources(repoRoot) { + const entries = trackedWorkflowEntries(repoRoot); + const findings = []; + + for (const entry of entries) { + if (entry.mode !== "100644" && entry.mode !== "100755") { + findings.push(workflowFinding({ + message: "Workflow entrypoints must be regular tracked files.", + path: entry.path, + ruleId: "workflow-entrypoint-not-regular", + severity: "error", + })); + continue; + } + + const blob = runCommand("git", ["cat-file", "blob", entry.objectId], { + cwd: repoRoot, + encoding: null, + }).stdout; + const text = blob.toString("utf8"); + if (Buffer.from(text, "utf8").equals(blob) === false) { + findings.push(workflowFinding({ + message: "Workflow YAML must be valid UTF-8 for deterministic review.", + path: entry.path, + ruleId: "workflow-non-utf8", + severity: "error", + })); + continue; + } + + findings.push(...auditWorkflowText(entry.path, text)); + } + + return findings; +} + +function trackedWorkflowEntries(repoRoot) { + const records = []; + if (hasHead(repoRoot)) { + records.push(...parseTreeRecords(runCommand("git", ["ls-tree", "-r", "-z", "--full-tree", "HEAD"], { + cwd: repoRoot, + encoding: null, + }).stdout)); + } + records.push(...parseIndexRecords(runCommand("git", ["ls-files", "--stage", "-z"], { + cwd: repoRoot, + encoding: null, + }).stdout)); + + const unique = new Map(); + for (const record of records) { + if (WORKFLOW_PATH_PATTERN.test(record.path)) { + unique.set(`${record.path}\0${record.objectId}`, record); + } + } + return [...unique.values()].sort((left, right) => left.path.localeCompare(right.path) || left.objectId.localeCompare(right.objectId)); +} + +function parseTreeRecords(buffer) { + return splitNulRecords(buffer).flatMap((record) => { + const tab = record.indexOf("\t"); + if (tab < 0) return []; + const [mode, type, objectId] = record.slice(0, tab).split(" "); + const recordPath = record.slice(tab + 1); + if (!mode || type !== "blob" || !FULL_OBJECT_ID_PATTERN.test(objectId ?? "") || !recordPath) return []; + return [{ mode, objectId, path: recordPath }]; + }); +} + +function parseIndexRecords(buffer) { + return splitNulRecords(buffer).flatMap((record) => { + const tab = record.indexOf("\t"); + if (tab < 0) return []; + const [mode, objectId, stage] = record.slice(0, tab).split(" "); + const recordPath = record.slice(tab + 1); + if (!mode || stage !== "0" || !FULL_OBJECT_ID_PATTERN.test(objectId ?? "") || !recordPath) return []; + return [{ mode, objectId, path: recordPath }]; + }); +} + +function splitNulRecords(buffer) { + return buffer.toString("utf8").split("\0").filter(Boolean); +} + +function hasHead(repoRoot) { + return spawnSync("git", ["rev-parse", "--verify", "HEAD"], { + cwd: repoRoot, + stdio: "ignore", + }).status === 0; +} + +function auditWorkflowText(workflowPath, text) { + const findings = []; + const uncommented = text.split(/\r?\n/u).map(stripYamlComment).join("\n"); + const hasPullRequestTarget = /^\s*(?:["']?pull_request_target["']?\s*:|on\s*:\s*[\[{][^\n]*\bpull_request_target\b)/imu.test(uncommented); + const hasWriteAll = /^\s*permissions\s*:\s*write-all\s*$/imu.test(uncommented); + + if (hasWriteAll) { + findings.push(workflowFinding({ + message: "Public workflows must not grant write-all permissions.", + path: workflowPath, + ruleId: "workflow-write-all", + severity: "error", + })); + } + + for (const runner of yamlKeyValues(uncommented, "runs-on")) { + if (/(?:^|[\s,[{])self-hosted(?:$|[\s,\]}])/iu.test(runner)) { + findings.push(workflowFinding({ + message: "Public workflows must not select a persistent self-hosted runner.", + path: workflowPath, + ruleId: "workflow-self-hosted-runner", + severity: "error", + })); + } else if (/\$\{\{|^\s*\*/u.test(runner)) { + findings.push(workflowFinding({ + message: "Dynamic runner selection requires proof that it cannot resolve to self-hosted.", + path: workflowPath, + ruleId: "workflow-dynamic-runner", + severity: "warning", + })); + } + } + + if (hasPullRequestTarget) { + findings.push(workflowFinding({ + message: "pull_request_target requires an explicit trusted-base and untrusted-head threat-model review.", + path: workflowPath, + ruleId: "workflow-pull-request-target", + severity: "warning", + })); + + if (/uses\s*:\s*["']?actions\/checkout@[^\n]+[\s\S]{0,1000}?ref\s*:\s*[^\n]*(?:pull_request\.head|head\.sha)/iu.test(uncommented)) { + findings.push(workflowFinding({ + message: "A privileged pull_request_target workflow must not execute an untrusted PR checkout.", + path: workflowPath, + ruleId: "workflow-privileged-untrusted-checkout", + severity: "error", + })); + } + } + + for (const actionRef of actionReferences(uncommented)) { + if (actionRef.startsWith("./") || actionRef.startsWith("docker://")) continue; + const separator = actionRef.lastIndexOf("@"); + const revision = separator < 0 ? "" : actionRef.slice(separator + 1); + if (!FULL_OBJECT_ID_PATTERN.test(revision)) { + findings.push(workflowFinding({ + message: "Remote workflow dependencies should use reviewed immutable object IDs.", + path: workflowPath, + ruleId: "workflow-mutable-action-ref", + severity: "warning", + })); + } + } + + return findings; +} + +function stripYamlComment(line) { + let singleQuoted = false; + let doubleQuoted = false; + for (let index = 0; index < line.length; index += 1) { + const character = line[index]; + if (character === "'" && !doubleQuoted) singleQuoted = !singleQuoted; + if (character === '"' && !singleQuoted && line[index - 1] !== "\\") doubleQuoted = !doubleQuoted; + if (character === "#" && !singleQuoted && !doubleQuoted && (index === 0 || /\s/u.test(line[index - 1]))) { + return line.slice(0, index); + } + } + return line; +} + +function yamlKeyValues(text, key) { + const lines = text.split("\n"); + const values = []; + for (let index = 0; index < lines.length; index += 1) { + const match = new RegExp(`^(\\s*)${escapeRegExp(key)}\\s*:\\s*(.*)$`, "iu").exec(lines[index]); + if (!match) continue; + const indentation = match[1].length; + let value = match[2]; + for (let cursor = index + 1; cursor < lines.length; cursor += 1) { + const line = lines[cursor]; + if (line.trim().length === 0) continue; + const nextIndentation = /^\s*/u.exec(line)[0].length; + if (nextIndentation <= indentation) break; + value += ` ${line.trim()}`; + } + values.push(value.trim()); + } + return values; +} + +function actionReferences(text) { + return [...text.matchAll(/^\s*(?:-\s*)?uses\s*:\s*([^\s#]+)\s*$/gimu)].map((match) => { + const reference = match[1]; + const quote = reference[0]; + return (quote === "\"" || quote === "'") && reference.at(-1) === quote + ? reference.slice(1, -1) + : reference; + }); +} + +function workflowFinding({ message, path: workflowPath, ruleId, severity }) { + return { + message, + path: redactSensitivePath(workflowPath), + ruleId, + scope: "workflow", + severity, + source: "tracked-file", + }; +} + +function loadGithubEvidence({ repoRoot, repository, snapshotPath }) { + if (snapshotPath) { + const snapshot = JSON.parse(readFileSync(path.resolve(snapshotPath), "utf8")); + return validateGithubEvidence(snapshot); + } + + const repositoryData = ghApiJson(repoRoot, `repos/${repository}`); + const rulesetSummaries = ghApiJson(repoRoot, `repos/${repository}/rulesets?includes_parents=true&per_page=100`); + const rulesets = rulesetSummaries.map((ruleset) => ghApiJson(repoRoot, `repos/${repository}/rulesets/${ruleset.id}`)); + const runners = ghApiJson(repoRoot, `repos/${repository}/actions/runners`); + const defaultBranch = repositoryData.default_branch; + const branchProtection = ghApiJson( + repoRoot, + `repos/${repository}/branches/${encodeURIComponent(defaultBranch)}/protection`, + { allowNotFound: true }, + ); + + return validateGithubEvidence({ + branchProtection, + repository: repositoryData, + rulesets, + runners, + }); +} + +function ghApiJson(repoRoot, endpoint, { allowNotFound = false } = {}) { + const result = spawnSync("gh", ["api", endpoint], { + cwd: repoRoot, + encoding: "utf8", + env: process.env, + maxBuffer: 16 * 1024 * 1024, + }); + if (result.status !== 0) { + if (allowNotFound && /HTTP 404|"status"\s*:\s*"?404"?/iu.test(result.stderr ?? "")) return null; + throw new Error(`GitHub evidence is unavailable for ${safeEndpointLabel(endpoint)}.`); + } + try { + return JSON.parse(result.stdout); + } catch { + throw new Error(`GitHub returned invalid evidence for ${safeEndpointLabel(endpoint)}.`); + } +} + +function safeEndpointLabel(endpoint) { + return endpoint.replace(/[?#].*$/u, "").replace(/\/rulesets\/\d+$/u, "/rulesets/[id]"); +} + +function validateGithubEvidence(evidence) { + if (!evidence || typeof evidence !== "object") throw new Error("GitHub evidence must be an object."); + if (!evidence.repository || typeof evidence.repository !== "object") throw new Error("GitHub evidence is missing repository metadata."); + if (!Array.isArray(evidence.rulesets)) throw new Error("GitHub evidence is missing ruleset details."); + if (!evidence.runners || typeof evidence.runners !== "object" || !Array.isArray(evidence.runners.runners)) { + throw new Error("GitHub evidence is missing runner access details."); + } + return evidence; +} + +function auditGithubControls(evidence, requiredChecks) { + const findings = []; + const repository = evidence.repository; + const security = repository.security_and_analysis ?? {}; + const defaultBranch = repository.default_branch; + + if (repository.private !== false || repository.visibility !== "public") { + findings.push(githubFinding("github-repository-not-public", "The audited GitHub repository is not confirmed public.")); + } + if (security.secret_scanning?.status !== "enabled") { + findings.push(githubFinding("github-secret-scanning-disabled", "GitHub secret scanning must be enabled.")); + } + if (security.secret_scanning_push_protection?.status !== "enabled") { + findings.push(githubFinding("github-push-protection-disabled", "GitHub push protection must be enabled.")); + } + if (typeof defaultBranch !== "string" || defaultBranch.length === 0) { + findings.push(githubFinding("github-default-branch-missing", "GitHub default-branch evidence is missing.")); + return findings; + } + + const applicableRulesets = evidence.rulesets.filter((ruleset) => ruleset.enforcement === "active" + && ruleset.target === "branch" + && rulesetAppliesToDefaultBranch(ruleset, defaultBranch)); + const rules = applicableRulesets.flatMap((ruleset) => Array.isArray(ruleset.rules) ? ruleset.rules : []); + const statusRules = rules.filter((rule) => rule.type === "required_status_checks"); + const rulesetChecks = statusRules.flatMap((rule) => rule.parameters?.required_status_checks ?? []); + const classicChecks = classicStatusChecks(evidence.branchProtection); + const requiredContexts = new Set([...rulesetChecks, ...classicChecks] + .map((check) => check.context) + .filter((context) => typeof context === "string")); + const classic = evidence.branchProtection; + const githubActionsContexts = new Set([ + ...rulesetChecks + .filter((check) => check.integration_id === GITHUB_ACTIONS_APP_ID), + ...classicChecks + .filter((check) => check.app_id === GITHUB_ACTIONS_APP_ID), + ].map((check) => check.context).filter((context) => typeof context === "string")); + + const strictStatusChecks = statusRules.some((rule) => rule.parameters?.strict_required_status_checks_policy === true) + || classic?.required_status_checks?.strict === true; + const forcePushProtected = rules.some((rule) => rule.type === "non_fast_forward") + || classic?.allow_force_pushes?.enabled === false; + const deletionProtected = rules.some((rule) => rule.type === "deletion") + || classic?.allow_deletions?.enabled === false; + const bypassActors = applicableRulesets.flatMap((ruleset) => ruleset.bypass_actors ?? []); + + if (requiredContexts.size === 0) { + findings.push(githubFinding("github-required-check-missing", "The default branch has no required status check.")); + } else if (githubActionsContexts.size === 0) { + findings.push(githubFinding("github-required-check-not-github-actions", "The default branch has no required check bound to GitHub Actions.")); + } + for (const requiredCheck of requiredChecks) { + if (!requiredContexts.has(requiredCheck)) { + findings.push(githubFinding("github-required-check-missing", `Required status check ${requiredCheck} is not enforced.`)); + } else if (!githubActionsContexts.has(requiredCheck)) { + findings.push(githubFinding("github-required-check-not-github-actions", `Required status check ${requiredCheck} is not bound to GitHub Actions.`)); + } + } + if (strictStatusChecks === false) { + findings.push(githubFinding("github-required-check-not-strict", "Required checks must enforce an up-to-date default branch.")); + } + if (forcePushProtected === false) { + findings.push(githubFinding("github-force-push-unprotected", "The default branch must reject non-fast-forward updates.")); + } + if (deletionProtected === false) { + findings.push(githubFinding("github-deletion-unprotected", "The default branch must reject deletion.")); + } + if (bypassActors.length > 0 || (classic && classic.enforce_admins?.enabled !== true)) { + findings.push(githubFinding("github-protection-bypass", "Default-branch protection must not expose bypass actors.")); + } + if (evidence.runners.total_count !== 0 || evidence.runners.runners.length !== 0) { + findings.push(githubFinding("github-self-hosted-runner-access", "A public repository must not have an available self-hosted runner.")); + } + + return findings; +} + +function classicStatusChecks(branchProtection) { + const checks = [...(branchProtection?.required_status_checks?.checks ?? [])]; + const checksWithContexts = new Set(checks + .map((check) => check.context) + .filter((context) => typeof context === "string")); + for (const context of branchProtection?.required_status_checks?.contexts ?? []) { + if (typeof context === "string" && checksWithContexts.has(context) === false) { + checks.push({ app_id: undefined, context }); + } + } + return checks; +} + +function githubFinding(ruleId, message) { + return { + message, + ruleId, + scope: "github", + severity: "error", + }; +} + +function rulesetAppliesToDefaultBranch(ruleset, defaultBranch) { + const condition = ruleset.conditions?.ref_name; + if (!condition) return true; + const reference = `refs/heads/${defaultBranch}`; + const includes = Array.isArray(condition.include) ? condition.include : []; + const excludes = Array.isArray(condition.exclude) ? condition.exclude : []; + const included = includes.length === 0 || includes.some((pattern) => refPatternMatches(pattern, reference, defaultBranch)); + const excluded = excludes.some((pattern) => refPatternMatches(pattern, reference, defaultBranch)); + return included && !excluded; +} + +function refPatternMatches(pattern, reference, defaultBranch) { + if (pattern === "~DEFAULT_BRANCH") return true; + if (pattern === defaultBranch || pattern === reference) return true; + const expression = `^${escapeRegExp(pattern).replaceAll("\\*\\*", ".*").replaceAll("\\*", "[^/]*").replaceAll("\\?", ".")}$`; + return new RegExp(expression, "u").test(reference) || new RegExp(expression, "u").test(defaultBranch); +} + +function uniqueSortedFindings(findings) { + const unique = new Map(); + for (const finding of findings) { + const key = [finding.severity, finding.scope, finding.ruleId, finding.commit ?? "", finding.path ?? "", finding.source ?? ""].join("\0"); + unique.set(key, finding); + } + const severityRank = { error: 0, warning: 1 }; + return [...unique.values()].sort((left, right) => { + return severityRank[left.severity] - severityRank[right.severity] + || left.scope.localeCompare(right.scope) + || left.ruleId.localeCompare(right.ruleId) + || (left.path ?? "").localeCompare(right.path ?? "") + || (left.commit ?? "").localeCompare(right.commit ?? ""); + }); +} + +function printResult(result, format) { + if (format === "json") { + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + return; + } + + const status = result.passed ? "passed" : "failed"; + process.stdout.write(`public-source release audit ${status}: ${result.errorCount} error(s), ${result.warningCount} warning(s), ${result.scannedTrackedFileCount} tracked file(s), ${result.scannedHistoryCommitCount} history commit(s)\n`); + for (const finding of result.findings) { + const location = finding.path + ? ` path:${displayTextValue(finding.path)}` + : finding.commit + ? ` commit:${displayTextValue(finding.commit)}` + : ""; + process.stdout.write(`- [${finding.severity}] ${finding.ruleId} (${finding.scope})${location}\n`); + } + if (result.omittedFindingCount > 0) { + process.stdout.write(`- ${result.omittedFindingCount} additional finding(s) omitted\n`); + } +} + +function reportRuntimeError(error, code, format) { + const message = sanitizeMessage(error instanceof Error ? error.message : String(error)); + if (format === "json") { + process.stdout.write(`${JSON.stringify({ + error: { code, message }, + passed: false, + schemaVersion: SCHEMA_VERSION, + }, null, 2)}\n`); + } else { + process.stderr.write(`public-source release audit error: ${message}\n`); + } + process.exitCode = 2; +} + +function sanitizeMessage(message) { + return redactSensitivePath(message); +} + +function displayTextValue(value) { + return JSON.stringify(value).slice(1, -1); +} + +function runCommand(command, args, { cwd, encoding = "utf8" }) { + const result = spawnSync(command, args, { + cwd, + encoding, + env: process.env, + maxBuffer: 128 * 1024 * 1024, + }); + if (result.status !== 0) { + throw new Error(`${command} could not provide required audit evidence.`); + } + return result; +} + +function escapeRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); +} diff --git a/public-source-release-audit/tests/public-source-release-audit.test.mjs b/public-source-release-audit/tests/public-source-release-audit.test.mjs new file mode 100644 index 0000000..e4df0a6 --- /dev/null +++ b/public-source-release-audit/tests/public-source-release-audit.test.mjs @@ -0,0 +1,467 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { mkdtempSync, mkdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, test } from "node:test"; + +const AUDIT_SCRIPT = path.resolve( + import.meta.dirname, + "..", + "scripts", + "public-source-release-audit.mjs", +); +const temporaryRoots = new Set(); + +afterEach(() => { + for (const root of temporaryRoots) { + rmSync(root, { force: true, recursive: true }); + } + temporaryRoots.clear(); +}); + +test("a safe committed repository passes", () => { + const repoRoot = makeRepository(); + write(repoRoot, "docs/example.txt", [ + "Generic examples are allowed:", + ["", "Users", "test", "project"].join("/"), + ["", "home", "runner", "work"].join("/"), + "https://example.invalid/users/me", + "", + ].join("\n")); + writeSafeWorkflow(repoRoot); + commitAll(repoRoot, "add safe examples"); + + const audit = runAudit(repoRoot); + + assert.equal(audit.status, 0); + assert.equal(audit.result.passed, true); + assert.deepEqual(audit.result.findings, []); +}); + +test("the staged candidate cannot be hidden by a clean working-tree replacement", () => { + const repoRoot = makeRepository(); + const credential = classicToken("a"); + write(repoRoot, "candidate.txt", credential); + git(repoRoot, ["add", "candidate.txt"]); + write(repoRoot, "candidate.txt", "clean replacement\n"); + + const audit = runAudit(repoRoot); + + assert.equal(audit.status, 1); + assertFinding(audit.result, "access-token", "candidate.txt"); + assert.doesNotMatch(audit.stdout, new RegExp(credential, "u")); +}); + +test("an unsafe HEAD cannot be hidden by a staged clean replacement", () => { + const repoRoot = makeRepository(); + write(repoRoot, "published.txt", classicToken("b")); + commitAll(repoRoot, "publish fixture"); + write(repoRoot, "published.txt", "clean replacement\n"); + git(repoRoot, ["add", "published.txt"]); + + const audit = runAudit(repoRoot); + + assert.equal(audit.status, 1); + assertFinding(audit.result, "access-token", "published.txt"); +}); + +test("credential and private-key formats are release-blocking", () => { + const repoRoot = makeRepository(); + write(repoRoot, "classic.txt", classicToken("c")); + write(repoRoot, "fine-grained.txt", fineGrainedToken("d")); + write(repoRoot, "app-jwt.txt", githubAppJwt()); + write(repoRoot, "private-key.txt", privateKeyBlock()); + commitAll(repoRoot, "add credential fixtures"); + + const audit = runAudit(repoRoot); + + assert.equal(audit.status, 1); + assertFinding(audit.result, "access-token", "classic.txt"); + assertFinding(audit.result, "access-token", "fine-grained.txt"); + assertFinding(audit.result, "access-token", "app-jwt.txt"); + assertFinding(audit.result, "private-key", "private-key.txt"); +}); + +test("private POSIX, Windows, and root machine paths are release-blocking", () => { + const repoRoot = makeRepository(); + write(repoRoot, "posix.txt", ["", "Users", "private-person", "project"].join("/")); + write(repoRoot, "windows.txt", ["C:", "Users", "private-person", "project"].join("\\")); + write(repoRoot, "root.txt", ["", "root", "private-project"].join("/")); + commitAll(repoRoot, "add private path fixtures"); + + const audit = runAudit(repoRoot); + + assert.equal(audit.status, 1); + assertFinding(audit.result, "machine-home-path", "posix.txt"); + assertFinding(audit.result, "machine-home-path", "windows.txt"); + assertFinding(audit.result, "machine-home-path", "root.txt"); +}); + +test("tracked filenames and symlink targets are scanned and redacted", () => { + const repoRoot = makeRepository(); + const credential = classicToken("e"); + write(repoRoot, `backup-${credential}.txt`, "clean\n"); + write(repoRoot, `.github/workflows/${credential}.yml`, [ + "name: unsafe path", + "on: push", + "jobs:", + " test:", + " runs-on: self-hosted", + "", + ].join("\n")); + const linkPath = path.join(repoRoot, "private-link"); + symlinkSync(["", "home", "private-person", "artifact"].join("/"), linkPath); + commitAll(repoRoot, "add path fixtures"); + + const audit = runAudit(repoRoot); + + assert.equal(audit.status, 1); + assertFinding(audit.result, "access-token", "backup-[REDACTED].txt"); + assertFinding(audit.result, "machine-home-path", "private-link"); + assertFinding(audit.result, "workflow-self-hosted-runner", ".github/workflows/[REDACTED].yml"); + assert.doesNotMatch(audit.stdout, new RegExp(credential, "u")); + assert.doesNotMatch(audit.stdout, /private-person/u); +}); + +test("UTF-16 and UTF-32 encoded credentials are scanned", () => { + const repoRoot = makeRepository(); + write(repoRoot, "utf16.bin", Buffer.concat([ + Buffer.from([0xff, 0xfe]), + Buffer.from(classicToken("f"), "utf16le"), + ])); + write(repoRoot, "utf32.bin", encodeUtf32WithBom(fineGrainedToken("g"))); + commitAll(repoRoot, "add encoded fixtures"); + + const audit = runAudit(repoRoot); + + assert.equal(audit.status, 1); + assertFinding(audit.result, "access-token", "utf16.bin"); + assertFinding(audit.result, "access-token", "utf32.bin"); +}); + +test("an unavailable Git LFS object fails closed", () => { + const repoRoot = makeRepository(); + write(repoRoot, "large.fixture", [ + "version https://git-lfs.github.com/spec/v1", + `oid sha256:${"a".repeat(64)}`, + "size 128", + "", + ].join("\n")); + commitAll(repoRoot, "add unavailable LFS fixture"); + + const audit = runAudit(repoRoot); + + assert.equal(audit.status, 1); + assertFinding(audit.result, "git-lfs-pointer", "large.fixture"); +}); + +test("history mode catches removed content and sensitive commit messages", () => { + const repoRoot = makeRepository(); + write(repoRoot, "removed.txt", classicToken("h")); + commitAll(repoRoot, "add removed fixture"); + rmSync(path.join(repoRoot, "removed.txt")); + commitAll(repoRoot, "remove fixture"); + git(repoRoot, ["commit", "--allow-empty", "-m", `record ${githubAppJwt()}`]); + + const currentAudit = runAudit(repoRoot); + const historyAudit = runAudit(repoRoot, ["--history"]); + + assert.equal(currentAudit.status, 0); + assert.equal(historyAudit.status, 1); + assertFinding(historyAudit.result, "access-token"); + assert.equal(historyAudit.result.historyScanned, true); + assert.ok(historyAudit.result.scannedHistoryCommitCount >= 4); +}); + +test("history mode rejects shallow evidence", () => { + const repoRoot = makeRepository(); + const head = git(repoRoot, ["rev-parse", "HEAD"]).stdout.trim(); + write(repoRoot, ".git/shallow", `${head}\n`); + + const audit = runAudit(repoRoot, ["--history"]); + + assert.equal(audit.status, 2); + assert.equal(audit.result.passed, false); + assert.equal(audit.result.error.code, "audit-error"); + assert.match(audit.result.error.message, /shallow/u); +}); + +test("workflow advisories can be promoted to failures", () => { + const repoRoot = makeRepository(); + write(repoRoot, ".github/workflows/advisory.yml", [ + "name: advisory", + "on: [pull_request_target]", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - uses: example/action@v1", + "", + ].join("\n")); + commitAll(repoRoot, "add advisory workflow"); + + const defaultAudit = runAudit(repoRoot); + const strictAudit = runAudit(repoRoot, ["--fail-on-warning"]); + + assert.equal(defaultAudit.status, 0); + assert.equal(defaultAudit.result.warningCount, 2); + assertFinding(defaultAudit.result, "workflow-pull-request-target", ".github/workflows/advisory.yml"); + assertFinding(defaultAudit.result, "workflow-mutable-action-ref", ".github/workflows/advisory.yml"); + assert.equal(strictAudit.status, 1); + assert.equal(strictAudit.result.passed, false); +}); + +test("unsafe public workflow execution is release-blocking", () => { + const repoRoot = makeRepository(); + write(repoRoot, ".github/workflows/unsafe.yml", [ + "name: unsafe", + "on:", + " pull_request_target:", + "permissions: write-all", + "jobs:", + " execute:", + " runs-on: [self-hosted, macOS]", + " steps:", + ` - uses: "actions/checkout@${"a".repeat(40)}"`, + " with:", + " ref: ${{ github.event.pull_request.head.sha }}", + "", + ].join("\n")); + commitAll(repoRoot, "add unsafe workflow"); + + const audit = runAudit(repoRoot); + + assert.equal(audit.status, 1); + assertFinding(audit.result, "workflow-write-all", ".github/workflows/unsafe.yml"); + assertFinding(audit.result, "workflow-self-hosted-runner", ".github/workflows/unsafe.yml"); + assertFinding(audit.result, "workflow-privileged-untrusted-checkout", ".github/workflows/unsafe.yml"); +}); + +test("a protected public GitHub snapshot passes", () => { + const repoRoot = makeRepository(); + writeSafeWorkflow(repoRoot); + commitAll(repoRoot, "add safe workflow"); + const snapshotPath = writeSnapshot(githubSnapshot()); + + const audit = runAudit(repoRoot, [ + "--github-snapshot", snapshotPath, + "--required-check", "verify", + ]); + + assert.equal(audit.status, 0); + assert.equal(audit.result.githubChecked, true); + assert.equal(audit.result.passed, true); + + const unboundSnapshot = githubSnapshot(); + unboundSnapshot.rulesets[0].rules[2].parameters.required_status_checks[0].integration_id = 999; + const unboundAudit = runAudit(repoRoot, [ + "--github-snapshot", writeSnapshot(unboundSnapshot), + "--required-check", "verify", + ]); + assert.equal(unboundAudit.status, 1); + assertFinding(unboundAudit.result, "github-required-check-not-github-actions"); +}); + +test("missing GitHub protections and runner isolation fail together", () => { + const repoRoot = makeRepository(); + const snapshot = githubSnapshot(); + snapshot.repository.private = true; + snapshot.repository.visibility = "private"; + snapshot.repository.security_and_analysis.secret_scanning.status = "disabled"; + snapshot.repository.security_and_analysis.secret_scanning_push_protection.status = "disabled"; + snapshot.rulesets[0].bypass_actors = [{ actor_id: 1, actor_type: "OrganizationAdmin" }]; + snapshot.rulesets[0].rules = [{ + type: "required_status_checks", + parameters: { + required_status_checks: [{ context: "other" }], + strict_required_status_checks_policy: false, + }, + }]; + snapshot.runners = { runners: [{ id: 1, name: "persistent-runner" }], total_count: 1 }; + const snapshotPath = writeSnapshot(snapshot); + + const audit = runAudit(repoRoot, [ + "--github-snapshot", snapshotPath, + "--required-check", "verify", + ]); + + assert.equal(audit.status, 1); + for (const ruleId of [ + "github-repository-not-public", + "github-secret-scanning-disabled", + "github-push-protection-disabled", + "github-required-check-missing", + "github-required-check-not-github-actions", + "github-required-check-not-strict", + "github-force-push-unprotected", + "github-deletion-unprotected", + "github-protection-bypass", + "github-self-hosted-runner-access", + ]) { + assertFinding(audit.result, ruleId); + } +}); + +test("incomplete GitHub evidence fails closed", () => { + const repoRoot = makeRepository(); + const snapshotPath = writeSnapshot({ repository: {}, rulesets: [] }); + + const audit = runAudit(repoRoot, ["--github-snapshot", snapshotPath]); + + assert.equal(audit.status, 2); + assert.equal(audit.result.passed, false); + assert.equal(audit.result.error.code, "audit-error"); +}); + +test("invalid arguments return a distinct usage failure", () => { + const repoRoot = makeRepository(); + + const audit = runAudit(repoRoot, ["--unsupported"]); + + assert.equal(audit.status, 2); + assert.equal(audit.result.passed, false); + assert.equal(audit.result.error.code, "usage-error"); +}); + +function makeRepository() { + const repoRoot = mkdtempSync(path.join(os.tmpdir(), "public-source-audit-")); + temporaryRoots.add(repoRoot); + git(repoRoot, ["init", "--quiet"]); + git(repoRoot, ["config", "user.name", "Audit Fixture"]); + git(repoRoot, ["config", "user.email", "fixture@example.invalid"]); + write(repoRoot, "README.md", "fixture repository\n"); + commitAll(repoRoot, "initial fixture"); + return repoRoot; +} + +function write(repoRoot, relativePath, content) { + const destination = path.join(repoRoot, relativePath); + mkdirSync(path.dirname(destination), { recursive: true }); + writeFileSync(destination, content); +} + +function commitAll(repoRoot, message) { + git(repoRoot, ["add", "--all"]); + git(repoRoot, ["commit", "--quiet", "-m", message]); +} + +function git(repoRoot, args) { + const result = spawnSync("git", args, { + cwd: repoRoot, + encoding: "utf8", + env: process.env, + }); + assert.equal(result.status, 0, `git ${args[0]} failed: ${result.stderr}`); + return result; +} + +function runAudit(repoRoot, args = [], { appendJsonFormat = true } = {}) { + const commandArguments = [AUDIT_SCRIPT, "--repo", repoRoot, ...args]; + if (appendJsonFormat) commandArguments.push("--format", "json"); + else if (!args.includes("--format")) commandArguments.push("--format", "json"); + const result = spawnSync(process.execPath, commandArguments, { + cwd: repoRoot, + encoding: "utf8", + env: process.env, + maxBuffer: 16 * 1024 * 1024, + }); + assert.notEqual(result.status, null, `audit did not exit: ${result.error?.message ?? "unknown error"}`); + assert.doesNotMatch(result.stderr, /credential|private-person/iu); + let parsed; + try { + parsed = JSON.parse(result.stdout); + } catch (error) { + assert.fail(`audit returned invalid JSON: ${error.message}\nstdout: ${result.stdout}\nstderr: ${result.stderr}`); + } + return { result: parsed, status: result.status, stderr: result.stderr, stdout: result.stdout }; +} + +function assertFinding(result, ruleId, findingPath) { + assert.ok(result.findings.some((finding) => finding.ruleId === ruleId + && (findingPath === undefined || finding.path === findingPath)), + `missing ${ruleId}${findingPath ? ` at ${findingPath}` : ""}: ${JSON.stringify(result.findings)}`); +} + +function classicToken(character) { + return ["ghp_", character.repeat(36)].join(""); +} + +function fineGrainedToken(character) { + return ["github_", "pat_", character.repeat(32)].join(""); +} + +function githubAppJwt() { + const header = Buffer.from(JSON.stringify({ alg: "RS256", typ: "JWT" })).toString("base64url"); + const payload = Buffer.from(JSON.stringify({ exp: 1_700_000_300, iat: 1_700_000_000, iss: "123456" })).toString("base64url"); + const signature = Buffer.from("fixture-signature-material").toString("base64url"); + return [header, payload, signature].join("."); +} + +function privateKeyBlock() { + const boundary = (verb) => [["-----", verb].join(""), "PRIVATE KEY-----"].join(" "); + return [boundary("BEGIN"), "A".repeat(64), boundary("END"), ""].join("\n"); +} + +function encodeUtf32WithBom(source) { + const body = Buffer.alloc([...source].length * 4); + [...source].forEach((character, index) => { + body.writeUInt32LE(character.codePointAt(0), index * 4); + }); + return Buffer.concat([Buffer.from([0xff, 0xfe, 0, 0]), body]); +} + +function writeSafeWorkflow(repoRoot) { + write(repoRoot, ".github/workflows/verify.yml", [ + "name: verify", + "on: push", + "permissions: read-all", + "jobs:", + " verify:", + " runs-on: ubuntu-latest", + " steps:", + ` - uses: actions/checkout@${"a".repeat(40)}`, + "", + ].join("\n")); +} + +function githubSnapshot() { + return { + branchProtection: null, + repository: { + default_branch: "main", + private: false, + security_and_analysis: { + secret_scanning: { status: "enabled" }, + secret_scanning_push_protection: { status: "enabled" }, + }, + visibility: "public", + }, + rulesets: [{ + bypass_actors: [], + conditions: { ref_name: { exclude: [], include: ["~DEFAULT_BRANCH"] } }, + enforcement: "active", + rules: [ + { type: "deletion" }, + { type: "non_fast_forward" }, + { + parameters: { + required_status_checks: [{ context: "verify", integration_id: 15368 }], + strict_required_status_checks_policy: true, + }, + type: "required_status_checks", + }, + ], + target: "branch", + }], + runners: { runners: [], total_count: 0 }, + }; +} + +function writeSnapshot(snapshot) { + const directory = mkdtempSync(path.join(os.tmpdir(), "public-source-github-")); + temporaryRoots.add(directory); + const snapshotPath = path.join(directory, "snapshot.json"); + writeFileSync(snapshotPath, `${JSON.stringify(snapshot)}\n`); + return snapshotPath; +} diff --git a/scripts/verify.sh b/scripts/verify.sh index f4c15f2..6b10898 100755 --- a/scripts/verify.sh +++ b/scripts/verify.sh @@ -60,13 +60,13 @@ if (JSON.stringify(documentedOwned) !== JSON.stringify([...owned].sort())) { fail("README.md owned skill list does not match bootstrap.sh"); } -if (owned.length !== 15 || standalone.length !== 1 || asc.length !== 22) { - fail(`Expected 15 owned, 1 standalone, and 22 ASC skills; found ${owned.length}, ${standalone.length}, and ${asc.length}`); +if (owned.length !== 16 || standalone.length !== 1 || asc.length !== 22) { + fail(`Expected 16 owned, 1 standalone, and 22 ASC skills; found ${owned.length}, ${standalone.length}, and ${asc.length}`); } const managed = [...owned, ...standalone, ...asc]; -if (managed.length !== 38 || new Set(managed).size !== managed.length) { - fail("The 38-skill global baseline contains a missing or duplicate name"); +if (managed.length !== 39 || new Set(managed).size !== managed.length) { + fail("The 39-skill global baseline contains a missing or duplicate name"); } console.log("validated direct package contract"); @@ -98,33 +98,8 @@ end puts "validated skill front matter YAML" RUBY -ruby <<'RUBY' -patterns = { - "machine-local home path" => %r{/(?:Users|home)/[A-Za-z0-9._-]+(?:/|\b)}, - "AWS access key" => /AKIA[0-9A-Z]{16}/, - "bearer credential" => /Authorization:\s*Bearer\s+[A-Za-z0-9._~-]{16,}/i, - "GitHub token" => /gh[pousr]_[A-Za-z0-9]{20,}/, - "API secret" => /sk-(?:proj-)?[A-Za-z0-9_-]{20,}/, - "private key" => /-----BEGIN (?:RSA )?PRIVATE KEY-----/ -} - -files = IO.popen(["git", "ls-files", "-co", "--exclude-standard", "-z"], &:read).split("\0") -failures = files.sort.filter_map do |file| - next unless File.file?(file) && !File.symlink?(file) - - content = File.binread(file) - next if content.include?("\0") - - text = content.force_encoding(Encoding::UTF_8) - next unless text.valid_encoding? - - labels = patterns.filter_map { |label, pattern| label if text.match?(pattern) } - "#{file}: #{labels.join(", ")}" unless labels.empty? -end - -abort "public-safety scan failed:\n#{failures.join("\n")}" unless failures.empty? -puts "public-safety scan passed" -RUBY +node public-source-release-audit/scripts/public-source-release-audit.mjs --repo . +node --test public-source-release-audit/tests/public-source-release-audit.test.mjs while IFS= read -r -d '' script; do [[ "$script" == *.sh && -f "$script" && ! -L "$script" ]] || continue From 3b4e91e2af7d600bff0956a1f71e5e0e23f6face Mon Sep 17 00:00:00 2001 From: Vladimir Date: Sat, 22 Aug 2026 00:49:47 +0800 Subject: [PATCH 02/37] Fix public-source audit review findings --- .../scripts/public-source-release-audit.mjs | 101 +++++++++-- .../public-source-release-audit.test.mjs | 167 +++++++++++++++++- 2 files changed, 247 insertions(+), 21 deletions(-) diff --git a/public-source-release-audit/scripts/public-source-release-audit.mjs b/public-source-release-audit/scripts/public-source-release-audit.mjs index 0593373..6df4599 100644 --- a/public-source-release-audit/scripts/public-source-release-audit.mjs +++ b/public-source-release-audit/scripts/public-source-release-audit.mjs @@ -10,6 +10,7 @@ const SCHEMA_VERSION = 1; const MAX_OUTPUT_FINDINGS = 100; const GITHUB_ACTIONS_APP_ID = 15368; const FULL_OBJECT_ID_PATTERN = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/iu; +const IMMUTABLE_DOCKER_ACTION_PATTERN = /^docker:\/\/[^\s@]+(?:[:][^\s@]+)?@(?:sha256:[0-9a-f]{64}|sha512:[0-9a-f]{128})$/iu; const WORKFLOW_PATH_PATTERN = /^\.github\/workflows\/[^/]+\.ya?ml$/iu; main(); @@ -35,7 +36,9 @@ function main() { includeHistory: options.history, repoRoot, }); - const workflowFindings = auditWorkflowSources(repoRoot); + const workflowFindings = auditWorkflowSources(repoRoot, { + includeHistory: options.history, + }); const githubFindings = options.github || options.githubSnapshot ? auditGithubControls(loadGithubEvidence({ repoRoot, @@ -58,14 +61,17 @@ function main() { }); } - const errorCount = findings.filter((finding) => finding.severity === "error").length; + const errorCount = findings.filter((finding) => finding.severity === "error").length + + sourceResult.omittedFindingCount; const warningCount = findings.filter((finding) => finding.severity === "warning").length; - const omittedFindingCount = Math.max(findings.length - MAX_OUTPUT_FINDINGS, 0); + const findingCount = findings.length + sourceResult.omittedFindingCount; + const omittedFindingCount = sourceResult.omittedFindingCount + + Math.max(findings.length - MAX_OUTPUT_FINDINGS, 0); const reportedFindings = findings.slice(0, MAX_OUTPUT_FINDINGS); const passed = errorCount === 0 && (options.failOnWarning === false || warningCount === 0); const result = { errorCount, - findingCount: findings.length, + findingCount, findings: reportedFindings, githubChecked: Boolean(options.github || options.githubSnapshot), historyScanned: sourceResult.historyScanned, @@ -158,8 +164,8 @@ function resolveRepositoryRoot(inputPath) { return result.stdout.trim(); } -function auditWorkflowSources(repoRoot) { - const entries = trackedWorkflowEntries(repoRoot); +function auditWorkflowSources(repoRoot, { includeHistory = false } = {}) { + const entries = trackedWorkflowEntries(repoRoot, { includeHistory }); const findings = []; for (const entry of entries) { @@ -169,6 +175,7 @@ function auditWorkflowSources(repoRoot) { path: entry.path, ruleId: "workflow-entrypoint-not-regular", severity: "error", + source: entry.source, })); continue; } @@ -184,38 +191,69 @@ function auditWorkflowSources(repoRoot) { path: entry.path, ruleId: "workflow-non-utf8", severity: "error", + source: entry.source, })); continue; } - findings.push(...auditWorkflowText(entry.path, text)); + findings.push(...auditWorkflowText(entry.path, text, entry.source)); } return findings; } -function trackedWorkflowEntries(repoRoot) { +function trackedWorkflowEntries(repoRoot, { includeHistory = false } = {}) { const records = []; if (hasHead(repoRoot)) { + if (includeHistory) { + const refs = runCommand("git", ["for-each-ref", "--format=%(refname)"], { + cwd: repoRoot, + }).stdout.split(/\r?\n/u).filter(Boolean); + const scannedTrees = new Set(); + for (const ref of refs) { + const tree = resolveTreeObjectId(repoRoot, ref); + if (!tree || scannedTrees.has(tree)) continue; + scannedTrees.add(tree); + records.push(...parseTreeRecords(runCommand( + "git", + ["ls-tree", "-r", "-z", "--full-tree", tree, "--", ".github/workflows"], + { cwd: repoRoot, encoding: null }, + ).stdout).map((record) => ({ ...record, source: "history" }))); + } + } records.push(...parseTreeRecords(runCommand("git", ["ls-tree", "-r", "-z", "--full-tree", "HEAD"], { cwd: repoRoot, encoding: null, - }).stdout)); + }).stdout).map((record) => ({ ...record, source: "tracked-file" }))); } records.push(...parseIndexRecords(runCommand("git", ["ls-files", "--stage", "-z"], { cwd: repoRoot, encoding: null, - }).stdout)); + }).stdout).map((record) => ({ ...record, source: "tracked-file" }))); const unique = new Map(); for (const record of records) { if (WORKFLOW_PATH_PATTERN.test(record.path)) { - unique.set(`${record.path}\0${record.objectId}`, record); + const key = `${record.path}\0${record.objectId}\0${record.mode}`; + if (unique.has(key) === false || record.source === "tracked-file") { + unique.set(key, record); + } } } return [...unique.values()].sort((left, right) => left.path.localeCompare(right.path) || left.objectId.localeCompare(right.objectId)); } +function resolveTreeObjectId(repoRoot, ref) { + const result = spawnSync("git", ["rev-parse", "--verify", ref + "^{tree}"], { + cwd: repoRoot, + encoding: "utf8", + env: process.env, + }); + if (result.status !== 0) return undefined; + const objectId = result.stdout.trim(); + return FULL_OBJECT_ID_PATTERN.test(objectId) ? objectId : undefined; +} + function parseTreeRecords(buffer) { return splitNulRecords(buffer).flatMap((record) => { const tab = record.indexOf("\t"); @@ -249,7 +287,7 @@ function hasHead(repoRoot) { }).status === 0; } -function auditWorkflowText(workflowPath, text) { +function auditWorkflowText(workflowPath, text, source = "tracked-file") { const findings = []; const uncommented = text.split(/\r?\n/u).map(stripYamlComment).join("\n"); const hasPullRequestTarget = /^\s*(?:["']?pull_request_target["']?\s*:|on\s*:\s*[\[{][^\n]*\bpull_request_target\b)/imu.test(uncommented); @@ -261,16 +299,18 @@ function auditWorkflowText(workflowPath, text) { path: workflowPath, ruleId: "workflow-write-all", severity: "error", + source, })); } for (const runner of yamlKeyValues(uncommented, "runs-on")) { - if (/(?:^|[\s,[{])self-hosted(?:$|[\s,\]}])/iu.test(runner)) { + if (/(?:^|[\s,[{'"])self-hosted(?:$|[\s,\]}'"])/iu.test(runner)) { findings.push(workflowFinding({ message: "Public workflows must not select a persistent self-hosted runner.", path: workflowPath, ruleId: "workflow-self-hosted-runner", severity: "error", + source, })); } else if (/\$\{\{|^\s*\*/u.test(runner)) { findings.push(workflowFinding({ @@ -278,6 +318,7 @@ function auditWorkflowText(workflowPath, text) { path: workflowPath, ruleId: "workflow-dynamic-runner", severity: "warning", + source, })); } } @@ -288,6 +329,7 @@ function auditWorkflowText(workflowPath, text) { path: workflowPath, ruleId: "workflow-pull-request-target", severity: "warning", + source, })); if (/uses\s*:\s*["']?actions\/checkout@[^\n]+[\s\S]{0,1000}?ref\s*:\s*[^\n]*(?:pull_request\.head|head\.sha)/iu.test(uncommented)) { @@ -296,20 +338,22 @@ function auditWorkflowText(workflowPath, text) { path: workflowPath, ruleId: "workflow-privileged-untrusted-checkout", severity: "error", + source, })); } } for (const actionRef of actionReferences(uncommented)) { - if (actionRef.startsWith("./") || actionRef.startsWith("docker://")) continue; + if (actionRef.startsWith("./") || IMMUTABLE_DOCKER_ACTION_PATTERN.test(actionRef)) continue; const separator = actionRef.lastIndexOf("@"); const revision = separator < 0 ? "" : actionRef.slice(separator + 1); - if (!FULL_OBJECT_ID_PATTERN.test(revision)) { + if (actionRef.startsWith("docker://") || !FULL_OBJECT_ID_PATTERN.test(revision)) { findings.push(workflowFinding({ message: "Remote workflow dependencies should use reviewed immutable object IDs.", path: workflowPath, ruleId: "workflow-mutable-action-ref", severity: "warning", + source, })); } } @@ -361,14 +405,14 @@ function actionReferences(text) { }); } -function workflowFinding({ message, path: workflowPath, ruleId, severity }) { +function workflowFinding({ message, path: workflowPath, ruleId, severity, source = "tracked-file" }) { return { message, path: redactSensitivePath(workflowPath), ruleId, scope: "workflow", severity, - source: "tracked-file", + source, }; } @@ -379,7 +423,7 @@ function loadGithubEvidence({ repoRoot, repository, snapshotPath }) { } const repositoryData = ghApiJson(repoRoot, `repos/${repository}`); - const rulesetSummaries = ghApiJson(repoRoot, `repos/${repository}/rulesets?includes_parents=true&per_page=100`); + const rulesetSummaries = ghApiPaginatedArray(repoRoot, `repos/${repository}/rulesets?includes_parents=true&per_page=100`); const rulesets = rulesetSummaries.map((ruleset) => ghApiJson(repoRoot, `repos/${repository}/rulesets/${ruleset.id}`)); const runners = ghApiJson(repoRoot, `repos/${repository}/actions/runners`); const defaultBranch = repositoryData.default_branch; @@ -397,6 +441,27 @@ function loadGithubEvidence({ repoRoot, repository, snapshotPath }) { }); } +function ghApiPaginatedArray(repoRoot, endpoint) { + const result = spawnSync("gh", ["api", "--paginate", "--slurp", endpoint], { + cwd: repoRoot, + encoding: "utf8", + env: process.env, + maxBuffer: 64 * 1024 * 1024, + }); + if (result.status !== 0) { + throw new Error(`GitHub evidence is unavailable for ${safeEndpointLabel(endpoint)}.`); + } + try { + const pages = JSON.parse(result.stdout); + if (!Array.isArray(pages) || pages.some((page) => !Array.isArray(page))) { + throw new Error("GitHub returned non-array paginated evidence."); + } + return pages.flat(); + } catch { + throw new Error(`GitHub returned invalid evidence for ${safeEndpointLabel(endpoint)}.`); + } +} + function ghApiJson(repoRoot, endpoint, { allowNotFound = false } = {}) { const result = spawnSync("gh", ["api", endpoint], { cwd: repoRoot, diff --git a/public-source-release-audit/tests/public-source-release-audit.test.mjs b/public-source-release-audit/tests/public-source-release-audit.test.mjs index e4df0a6..fb99a88 100644 --- a/public-source-release-audit/tests/public-source-release-audit.test.mjs +++ b/public-source-release-audit/tests/public-source-release-audit.test.mjs @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; -import { mkdtempSync, mkdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { chmodSync, mkdtempSync, mkdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import os from "node:os"; import path from "node:path"; import { afterEach, test } from "node:test"; @@ -174,6 +174,31 @@ test("history mode catches removed content and sensitive commit messages", () => assert.ok(historyAudit.result.scannedHistoryCommitCount >= 4); }); +test("history mode scans workflows on every reachable branch", () => { + const repoRoot = makeRepository(); + const defaultBranch = git(repoRoot, ["branch", "--show-current"]).stdout.trim(); + git(repoRoot, ["checkout", "--quiet", "-b", "public-alternate"]); + write(repoRoot, ".github/workflows/alternate.yml", [ + "name: alternate", + "on: push", + "permissions: write-all", + "jobs:", + " unsafe:", + " runs-on: self-hosted", + "", + ].join("\n")); + commitAll(repoRoot, "add alternate workflow"); + git(repoRoot, ["checkout", "--quiet", defaultBranch]); + + const currentAudit = runAudit(repoRoot); + const historyAudit = runAudit(repoRoot, ["--history"]); + + assert.equal(currentAudit.status, 0); + assert.equal(historyAudit.status, 1); + assertFinding(historyAudit.result, "workflow-write-all", ".github/workflows/alternate.yml"); + assertFinding(historyAudit.result, "workflow-self-hosted-runner", ".github/workflows/alternate.yml"); +}); + test("history mode rejects shallow evidence", () => { const repoRoot = makeRepository(); const head = git(repoRoot, ["rev-parse", "HEAD"]).stdout.trim(); @@ -212,6 +237,65 @@ test("workflow advisories can be promoted to failures", () => { assert.equal(strictAudit.result.passed, false); }); +test("quoted self-hosted runner labels are release-blocking", () => { + const repoRoot = makeRepository(); + write(repoRoot, ".github/workflows/quoted-scalar.yml", [ + "name: quoted scalar", + "on: push", + "jobs:", + " unsafe:", + " runs-on: \"self-hosted\"", + "", + ].join("\n")); + write(repoRoot, ".github/workflows/quoted-array.yml", [ + "name: quoted array", + "on: push", + "jobs:", + " unsafe:", + " runs-on: ['self-hosted', macOS]", + "", + ].join("\n")); + commitAll(repoRoot, "add quoted runner workflows"); + + const audit = runAudit(repoRoot); + + assert.equal(audit.status, 1); + assertFinding(audit.result, "workflow-self-hosted-runner", ".github/workflows/quoted-scalar.yml"); + assertFinding(audit.result, "workflow-self-hosted-runner", ".github/workflows/quoted-array.yml"); +}); + +test("mutable Docker actions warn while digest-pinned actions pass", () => { + const repoRoot = makeRepository(); + write(repoRoot, ".github/workflows/mutable-docker.yml", [ + "name: mutable docker", + "on: push", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - uses: docker://alpine:latest", + "", + ].join("\n")); + write(repoRoot, ".github/workflows/pinned-docker.yml", [ + "name: pinned docker", + "on: push", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + ` - uses: docker://alpine@sha256:${"a".repeat(64)}`, + "", + ].join("\n")); + commitAll(repoRoot, "add Docker action workflows"); + + const audit = runAudit(repoRoot); + + assert.equal(audit.status, 0); + assert.equal(audit.result.warningCount, 1); + assertFinding(audit.result, "workflow-mutable-action-ref", ".github/workflows/mutable-docker.yml"); + assert.equal(audit.result.findings.some((finding) => finding.path === ".github/workflows/pinned-docker.yml"), false); +}); + test("unsafe public workflow execution is release-blocking", () => { const repoRoot = makeRepository(); write(repoRoot, ".github/workflows/unsafe.yml", [ @@ -263,6 +347,54 @@ test("a protected public GitHub snapshot passes", () => { assertFinding(unboundAudit.result, "github-required-check-not-github-actions"); }); +test("live GitHub evidence consumes every ruleset page", () => { + const repoRoot = makeRepository(); + writeSafeWorkflow(repoRoot); + commitAll(repoRoot, "add safe workflow"); + const snapshot = githubSnapshot(); + const laterRuleset = { + bypass_actors: [{ actor_id: 1, actor_type: "OrganizationAdmin" }], + conditions: { ref_name: { exclude: [], include: ["~DEFAULT_BRANCH"] } }, + enforcement: "active", + rules: [], + target: "branch", + }; + const fakeGhDirectory = writeFakeGh({ + branchProtection: null, + repository: snapshot.repository, + rulesetPages: [[{ id: 1 }], [{ id: 2 }]], + rulesets: { 1: snapshot.rulesets[0], 2: laterRuleset }, + runners: snapshot.runners, + }); + + const audit = runAudit(repoRoot, [ + "--github", "example/public", + "--required-check", "verify", + ], { + env: { ...process.env, PATH: `${fakeGhDirectory}:${process.env.PATH}` }, + }); + + assert.equal(audit.status, 1); + assertFinding(audit.result, "github-protection-bypass"); +}); + +test("wrapper counts source findings omitted by the core scanner", () => { + const repoRoot = makeRepository(); + for (let index = 0; index < 30; index += 1) { + write(repoRoot, `credential-${index}.txt`, classicToken(String.fromCharCode(97 + (index % 26)))); + } + commitAll(repoRoot, "add many credential fixtures"); + + const audit = runAudit(repoRoot); + + assert.equal(audit.status, 1); + assert.equal(audit.result.findingCount, 31); + assert.equal(audit.result.errorCount, 31); + assert.equal(audit.result.warningCount, 0); + assert.equal(audit.result.omittedFindingCount, 5); + assertFinding(audit.result, "source-findings-omitted"); +}); + test("missing GitHub protections and runner isolation fail together", () => { const repoRoot = makeRepository(); const snapshot = githubSnapshot(); @@ -356,14 +488,14 @@ function git(repoRoot, args) { return result; } -function runAudit(repoRoot, args = [], { appendJsonFormat = true } = {}) { +function runAudit(repoRoot, args = [], { appendJsonFormat = true, env = process.env } = {}) { const commandArguments = [AUDIT_SCRIPT, "--repo", repoRoot, ...args]; if (appendJsonFormat) commandArguments.push("--format", "json"); else if (!args.includes("--format")) commandArguments.push("--format", "json"); const result = spawnSync(process.execPath, commandArguments, { cwd: repoRoot, encoding: "utf8", - env: process.env, + env, maxBuffer: 16 * 1024 * 1024, }); assert.notEqual(result.status, null, `audit did not exit: ${result.error?.message ?? "unknown error"}`); @@ -465,3 +597,32 @@ function writeSnapshot(snapshot) { writeFileSync(snapshotPath, `${JSON.stringify(snapshot)}\n`); return snapshotPath; } + +function writeFakeGh(fixture) { + const directory = mkdtempSync(path.join(os.tmpdir(), "public-source-fake-gh-")); + temporaryRoots.add(directory); + const executablePath = path.join(directory, "gh"); + const script = [ + "#!/usr/bin/env node", + `const fixture = ${JSON.stringify(fixture)};`, + "const args = process.argv.slice(2);", + "const endpoint = args.find((argument) => argument.startsWith('repos/'));", + "let response;", + "if (endpoint === 'repos/example/public') response = fixture.repository;", + "else if (endpoint === 'repos/example/public/rulesets?includes_parents=true&per_page=100') {", + " if (!args.includes('--paginate') || !args.includes('--slurp')) process.exit(3);", + " response = fixture.rulesetPages;", + "}", + "else if (endpoint?.startsWith('repos/example/public/rulesets/')) {", + " response = fixture.rulesets[endpoint.split('/').at(-1)];", + "}", + "else if (endpoint === 'repos/example/public/actions/runners') response = fixture.runners;", + "else if (endpoint === 'repos/example/public/branches/main/protection') response = fixture.branchProtection;", + "else process.exit(4);", + "process.stdout.write(JSON.stringify(response) + '\\n');", + "", + ].join("\n"); + writeFileSync(executablePath, script); + chmodSync(executablePath, 0o755); + return directory; +} From 698e5835ef3aee0b8c9ed9410143faa474e06395 Mon Sep 17 00:00:00 2001 From: Vladimir Date: Sat, 22 Aug 2026 01:05:05 +0800 Subject: [PATCH 03/37] Harden public-source audit edge cases --- .../scripts/public-safety-audit-core.mjs | 1 + .../scripts/public-source-release-audit.mjs | 140 +++++++++++++++--- .../public-source-release-audit.test.mjs | 109 ++++++++++++++ 3 files changed, 227 insertions(+), 23 deletions(-) diff --git a/public-source-release-audit/scripts/public-safety-audit-core.mjs b/public-source-release-audit/scripts/public-safety-audit-core.mjs index 92b2972..465540b 100644 --- a/public-source-release-audit/scripts/public-safety-audit-core.mjs +++ b/public-source-release-audit/scripts/public-safety-audit-core.mjs @@ -91,6 +91,7 @@ const WINDOWS_HOME_PATH_PATTERN = /(?:[A-Za-z]:)?[\\/]+Users[\\/]+([^\\/:\r\n"<> const STRICT_UTF8_TEXT_DECODER = new TextDecoder("utf-8", { fatal: true }); const WINDOWS_1252_TEXT_DECODER = new TextDecoder("windows-1252"); const ACCESS_TOKEN_PATTERNS = [ + /Authorization:\s*Bearer\s+[A-Za-z0-9._~-]{16,}/iu, /(? yamlValueContainsToken(value, "pull_request_target")); + const hasWriteAll = yamlKeyValues(uncommented, "permissions") + .some((value) => yamlScalarValue(value).toLowerCase() === "write-all"); if (hasWriteAll) { findings.push(workflowFinding({ @@ -332,7 +334,7 @@ function auditWorkflowText(workflowPath, text, source = "tracked-file") { source, })); - if (/uses\s*:\s*["']?actions\/checkout@[^\n]+[\s\S]{0,1000}?ref\s*:\s*[^\n]*(?:pull_request\.head|head\.sha)/iu.test(uncommented)) { + if (hasUntrustedPullRequestCheckout(uncommented)) { findings.push(workflowFinding({ message: "A privileged pull_request_target workflow must not execute an untrusted PR checkout.", path: workflowPath, @@ -375,33 +377,105 @@ function stripYamlComment(line) { return line; } -function yamlKeyValues(text, key) { +function parseYamlKeyLine(line) { + const match = /^(\s*)(?:"([^"\r\n]+)"|'([^'\r\n]+)'|([A-Za-z0-9_-]+))\s*:\s*(.*)$/u.exec(line); + if (!match) return undefined; + return { + indentation: match[1].length, + key: match[2] ?? match[3] ?? match[4], + value: match[5], + }; +} + +function yamlKeyValues(text, key, { indentation: requiredIndentation } = {}) { const lines = text.split("\n"); const values = []; for (let index = 0; index < lines.length; index += 1) { - const match = new RegExp(`^(\\s*)${escapeRegExp(key)}\\s*:\\s*(.*)$`, "iu").exec(lines[index]); - if (!match) continue; - const indentation = match[1].length; - let value = match[2]; + const entry = parseYamlKeyLine(lines[index]); + if (!entry || entry.key.toLowerCase() !== key.toLowerCase()) continue; + if (requiredIndentation !== undefined && entry.indentation !== requiredIndentation) continue; + let value = entry.value; for (let cursor = index + 1; cursor < lines.length; cursor += 1) { const line = lines[cursor]; if (line.trim().length === 0) continue; const nextIndentation = /^\s*/u.exec(line)[0].length; - if (nextIndentation <= indentation) break; - value += ` ${line.trim()}`; + if (nextIndentation <= entry.indentation) break; + value += " " + line.trim(); } values.push(value.trim()); } return values; } +function yamlScalarValue(value) { + const trimmed = value.trim(); + const quote = trimmed[0]; + return (quote === "\"" || quote === "'") && trimmed.at(-1) === quote + ? trimmed.slice(1, -1) + : trimmed; +} + +function yamlValueContainsToken(value, expectedToken) { + return (value.match(/[A-Za-z0-9_-]+/gu) ?? []) + .some((token) => token.toLowerCase() === expectedToken.toLowerCase()); +} + +function hasUntrustedPullRequestCheckout(text) { + const lines = text.split("\n"); + for (let index = 0; index < lines.length; index += 1) { + const sequenceItem = /^(\s*)-\s*(.*)$/u.exec(lines[index]); + if (!sequenceItem) continue; + const itemIndentation = sequenceItem[1].length; + const entries = []; + if (sequenceItem[2].trim().length > 0) { + const inlineEntry = parseYamlKeyLine(" ".repeat(itemIndentation + 2) + sequenceItem[2]); + if (inlineEntry) entries.push(inlineEntry); + } + for (let cursor = index + 1; cursor < lines.length; cursor += 1) { + const line = lines[cursor]; + if (line.trim().length === 0) continue; + const indentation = /^\s*/u.exec(line)[0].length; + if (indentation <= itemIndentation) break; + const entry = parseYamlKeyLine(line); + if (entry) entries.push(entry); + } + if (entries.length === 0) continue; + const mappingIndentation = Math.min(...entries.map((entry) => entry.indentation)); + const usesEntry = entries.find((entry) => entry.indentation === mappingIndentation + && entry.key.toLowerCase() === "uses"); + if (!usesEntry || !/^actions\/checkout@/iu.test(yamlScalarValue(usesEntry.value))) continue; + const withIndex = entries.findIndex((entry) => entry.indentation === mappingIndentation + && entry.key.toLowerCase() === "with"); + if (withIndex < 0) continue; + const withEntry = entries[withIndex]; + if (yamlValueContainsToken(withEntry.value, "ref") && isUntrustedPullRequestRef(withEntry.value)) { + return true; + } + for (let entryIndex = withIndex + 1; entryIndex < entries.length; entryIndex += 1) { + const entry = entries[entryIndex]; + if (entry.indentation <= mappingIndentation) break; + if (entry.key.toLowerCase() === "ref" && isUntrustedPullRequestRef(entry.value)) { + return true; + } + } + } + return false; +} + +function isUntrustedPullRequestRef(value) { + return /(?:pull_request\.head|head\.sha)/iu.test(value); +} + function actionReferences(text) { - return [...text.matchAll(/^\s*(?:-\s*)?uses\s*:\s*([^\s#]+)\s*$/gimu)].map((match) => { - const reference = match[1]; - const quote = reference[0]; - return (quote === "\"" || quote === "'") && reference.at(-1) === quote - ? reference.slice(1, -1) - : reference; + return text.split("\n").flatMap((line) => { + const sequenceItem = /^(\s*)-\s*(.*)$/u.exec(line); + const candidate = sequenceItem + ? " ".repeat(sequenceItem[1].length + 2) + sequenceItem[2] + : line; + const entry = parseYamlKeyLine(candidate); + return entry?.key.toLowerCase() === "uses" + ? [yamlScalarValue(entry.value)] + : []; }); } @@ -547,9 +621,17 @@ function auditGithubControls(evidence, requiredChecks) { } for (const requiredCheck of requiredChecks) { if (!requiredContexts.has(requiredCheck)) { - findings.push(githubFinding("github-required-check-missing", `Required status check ${requiredCheck} is not enforced.`)); + findings.push(githubFinding( + "github-required-check-missing", + `Required status check ${requiredCheck} is not enforced.`, + requiredCheck, + )); } else if (!githubActionsContexts.has(requiredCheck)) { - findings.push(githubFinding("github-required-check-not-github-actions", `Required status check ${requiredCheck} is not bound to GitHub Actions.`)); + findings.push(githubFinding( + "github-required-check-not-github-actions", + `Required status check ${requiredCheck} is not bound to GitHub Actions.`, + requiredCheck, + )); } } if (strictStatusChecks === false) { @@ -584,8 +666,9 @@ function classicStatusChecks(branchProtection) { return checks; } -function githubFinding(ruleId, message) { +function githubFinding(ruleId, message, check) { return { + ...(check === undefined ? {} : { check }), message, ruleId, scope: "github", @@ -605,7 +688,7 @@ function rulesetAppliesToDefaultBranch(ruleset, defaultBranch) { } function refPatternMatches(pattern, reference, defaultBranch) { - if (pattern === "~DEFAULT_BRANCH") return true; + if (pattern === "~ALL" || pattern === "~DEFAULT_BRANCH") return true; if (pattern === defaultBranch || pattern === reference) return true; const expression = `^${escapeRegExp(pattern).replaceAll("\\*\\*", ".*").replaceAll("\\*", "[^/]*").replaceAll("\\?", ".")}$`; return new RegExp(expression, "u").test(reference) || new RegExp(expression, "u").test(defaultBranch); @@ -614,7 +697,15 @@ function refPatternMatches(pattern, reference, defaultBranch) { function uniqueSortedFindings(findings) { const unique = new Map(); for (const finding of findings) { - const key = [finding.severity, finding.scope, finding.ruleId, finding.commit ?? "", finding.path ?? "", finding.source ?? ""].join("\0"); + const key = [ + finding.severity, + finding.scope, + finding.ruleId, + finding.commit ?? "", + finding.path ?? "", + finding.source ?? "", + finding.check ?? "", + ].join("\0"); unique.set(key, finding); } const severityRank = { error: 0, warning: 1 }; @@ -623,7 +714,8 @@ function uniqueSortedFindings(findings) { || left.scope.localeCompare(right.scope) || left.ruleId.localeCompare(right.ruleId) || (left.path ?? "").localeCompare(right.path ?? "") - || (left.commit ?? "").localeCompare(right.commit ?? ""); + || (left.commit ?? "").localeCompare(right.commit ?? "") + || (left.check ?? "").localeCompare(right.check ?? ""); }); } @@ -640,7 +732,9 @@ function printResult(result, format) { ? ` path:${displayTextValue(finding.path)}` : finding.commit ? ` commit:${displayTextValue(finding.commit)}` - : ""; + : finding.check + ? ` check:${displayTextValue(finding.check)}` + : ""; process.stdout.write(`- [${finding.severity}] ${finding.ruleId} (${finding.scope})${location}\n`); } if (result.omittedFindingCount > 0) { diff --git a/public-source-release-audit/tests/public-source-release-audit.test.mjs b/public-source-release-audit/tests/public-source-release-audit.test.mjs index fb99a88..f7bb7d2 100644 --- a/public-source-release-audit/tests/public-source-release-audit.test.mjs +++ b/public-source-release-audit/tests/public-source-release-audit.test.mjs @@ -71,6 +71,7 @@ test("credential and private-key formats are release-blocking", () => { write(repoRoot, "classic.txt", classicToken("c")); write(repoRoot, "fine-grained.txt", fineGrainedToken("d")); write(repoRoot, "app-jwt.txt", githubAppJwt()); + write(repoRoot, "bearer.txt", ["Authorization:", "Bearer", "b".repeat(32)].join(" ")); write(repoRoot, "private-key.txt", privateKeyBlock()); commitAll(repoRoot, "add credential fixtures"); @@ -80,6 +81,7 @@ test("credential and private-key formats are release-blocking", () => { assertFinding(audit.result, "access-token", "classic.txt"); assertFinding(audit.result, "access-token", "fine-grained.txt"); assertFinding(audit.result, "access-token", "app-jwt.txt"); + assertFinding(audit.result, "access-token", "bearer.txt"); assertFinding(audit.result, "private-key", "private-key.txt"); }); @@ -264,6 +266,79 @@ test("quoted self-hosted runner labels are release-blocking", () => { assertFinding(audit.result, "workflow-self-hosted-runner", ".github/workflows/quoted-array.yml"); }); +test("quoted permissions keys and write-all values are release-blocking", () => { + const repoRoot = makeRepository(); + write(repoRoot, ".github/workflows/quoted-permissions-value.yml", [ + "name: quoted permissions value", + "on: push", + "permissions: \"write-all\"", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + "", + ].join("\n")); + write(repoRoot, ".github/workflows/quoted-permissions-key.yml", [ + "name: quoted permissions key", + "on: push", + "\"permissions\": 'write-all'", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + "", + ].join("\n")); + commitAll(repoRoot, "add quoted permissions workflows"); + + const audit = runAudit(repoRoot); + + assert.equal(audit.status, 1); + assertFinding(audit.result, "workflow-write-all", ".github/workflows/quoted-permissions-value.yml"); + assertFinding(audit.result, "workflow-write-all", ".github/workflows/quoted-permissions-key.yml"); +}); + +test("quoted runs-on keys cannot hide self-hosted labels", () => { + const repoRoot = makeRepository(); + write(repoRoot, ".github/workflows/quoted-runner-key.yml", [ + "name: quoted runner key", + "on: push", + "jobs:", + " unsafe:", + " \"runs-on\": \"self-hosted\"", + "", + ].join("\n")); + commitAll(repoRoot, "add quoted runner key workflow"); + + const audit = runAudit(repoRoot); + + assert.equal(audit.status, 1); + assertFinding(audit.result, "workflow-self-hosted-runner", ".github/workflows/quoted-runner-key.yml"); +}); + +test("block-list privileged triggers detect reordered checkout inputs", () => { + const repoRoot = makeRepository(); + write(repoRoot, ".github/workflows/reordered-checkout.yml", [ + "name: reordered checkout", + "on:", + " - pull_request_target", + "permissions: read-all", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - name: inspect", + " with:", + [" ref: ", "$", "{{ github.event.pull_request.head.sha }}"].join(""), + " \"uses\": \"actions/checkout@" + "a".repeat(40) + "\"", + "", + ].join("\n")); + commitAll(repoRoot, "add reordered checkout workflow"); + + const audit = runAudit(repoRoot); + + assert.equal(audit.status, 1); + assertFinding(audit.result, "workflow-pull-request-target", ".github/workflows/reordered-checkout.yml"); + assertFinding(audit.result, "workflow-privileged-untrusted-checkout", ".github/workflows/reordered-checkout.yml"); +}); + test("mutable Docker actions warn while digest-pinned actions pass", () => { const repoRoot = makeRepository(); write(repoRoot, ".github/workflows/mutable-docker.yml", [ @@ -347,6 +422,40 @@ test("a protected public GitHub snapshot passes", () => { assertFinding(unboundAudit.result, "github-required-check-not-github-actions"); }); +test("each missing requested status check remains a distinct finding", () => { + const repoRoot = makeRepository(); + writeSafeWorkflow(repoRoot); + commitAll(repoRoot, "add safe workflow"); + const audit = runAudit(repoRoot, [ + "--github-snapshot", writeSnapshot(githubSnapshot()), + "--required-check", "first-check", + "--required-check", "second-check", + ]); + + const missingChecks = audit.result.findings + .filter((finding) => finding.ruleId === "github-required-check-missing"); + assert.equal(audit.status, 1); + assert.equal(audit.result.errorCount, 2); + assert.equal(audit.result.findingCount, 2); + assert.deepEqual(missingChecks.map((finding) => finding.check).sort(), ["first-check", "second-check"]); +}); + +test("the all-branches ruleset selector protects the default branch", () => { + const repoRoot = makeRepository(); + writeSafeWorkflow(repoRoot); + commitAll(repoRoot, "add safe workflow"); + const snapshot = githubSnapshot(); + snapshot.rulesets[0].conditions.ref_name.include = ["~ALL"]; + + const audit = runAudit(repoRoot, [ + "--github-snapshot", writeSnapshot(snapshot), + "--required-check", "verify", + ]); + + assert.equal(audit.status, 0); + assert.equal(audit.result.passed, true); +}); + test("live GitHub evidence consumes every ruleset page", () => { const repoRoot = makeRepository(); writeSafeWorkflow(repoRoot); From a32a2ac88d0a9989d10cf17d1bd505cc52028c13 Mon Sep 17 00:00:00 2001 From: Vladimir Date: Sat, 22 Aug 2026 01:18:16 +0800 Subject: [PATCH 04/37] Close public-source audit bypasses --- .../scripts/public-safety-audit-core.mjs | 4 +- .../scripts/public-source-release-audit.mjs | 49 +++++- .../public-source-release-audit.test.mjs | 149 ++++++++++++++++++ 3 files changed, 195 insertions(+), 7 deletions(-) diff --git a/public-source-release-audit/scripts/public-safety-audit-core.mjs b/public-source-release-audit/scripts/public-safety-audit-core.mjs index 465540b..5e4c2fb 100644 --- a/public-source-release-audit/scripts/public-safety-audit-core.mjs +++ b/public-source-release-audit/scripts/public-safety-audit-core.mjs @@ -90,6 +90,7 @@ const TEXT_FILE_LINE_SEGMENT_MAX_CHARACTERS = 64 * 1024; const WINDOWS_HOME_PATH_PATTERN = /(?:[A-Za-z]:)?[\\/]+Users[\\/]+([^\\/:\r\n"<>|?*]+)/giu; const STRICT_UTF8_TEXT_DECODER = new TextDecoder("utf-8", { fatal: true }); const WINDOWS_1252_TEXT_DECODER = new TextDecoder("windows-1252"); +const AUTHORIZATION_BEARER_PREFIX_PATTERN = /Authorization:\s*Bearer/iu; const ACCESS_TOKEN_PATTERNS = [ /Authorization:\s*Bearer\s+[A-Za-z0-9._~-]{16,}/iu, /(? 0 && !options.github && !options.githubSnapshot) { + throw new Error("--required-check requires --github or --github-snapshot."); + } if (!["json", "text"].includes(options.format)) { throw new Error("--format must be text or json."); } @@ -155,7 +158,7 @@ function parseArguments(args) { } function helpText() { - return `Usage: public-source-release-audit [options]\n\nOptions:\n --repo PATH Git repository to audit (default: .)\n --history Audit all locally reachable refs and fail on shallow/grafted history\n --github OWNER/REPO Audit live GitHub repository controls with gh\n --github-snapshot PATH Audit a captured GitHub evidence fixture instead of the network\n --required-check NAME Require a strict GitHub Actions check; repeat as needed\n --fail-on-warning Treat workflow advisories as failures\n --format text|json Output format (default: text)\n --help Show this help\n`; + return `Usage: public-source-release-audit [options]\n\nOptions:\n --repo PATH Git repository to audit (default: .)\n --history Audit all locally reachable refs and fail on shallow/grafted history\n --github OWNER/REPO Audit live GitHub repository controls with gh\n --github-snapshot PATH Audit a captured GitHub evidence fixture instead of the network\n --required-check NAME Require a strict GitHub Actions check with GitHub evidence; repeat as needed\n --fail-on-warning Treat workflow advisories as failures\n --format text|json Output format (default: text)\n --help Show this help\n`; } function resolveRepositoryRoot(inputPath) { @@ -247,7 +250,7 @@ function resolveTreeObjectId(repoRoot, ref) { const result = spawnSync("git", ["rev-parse", "--verify", ref + "^{tree}"], { cwd: repoRoot, encoding: "utf8", - env: process.env, + env: auditGitEnvironment(), }); if (result.status !== 0) return undefined; const objectId = result.stdout.trim(); @@ -283,6 +286,7 @@ function splitNulRecords(buffer) { function hasHead(repoRoot) { return spawnSync("git", ["rev-parse", "--verify", "HEAD"], { cwd: repoRoot, + env: auditGitEnvironment(), stdio: "ignore", }).status === 0; } @@ -314,9 +318,9 @@ function auditWorkflowText(workflowPath, text, source = "tracked-file") { severity: "error", source, })); - } else if (/\$\{\{|^\s*\*/u.test(runner)) { + } else if (/\$\{\{|^\s*\*/u.test(runner) || yamlValueContainsToken(runner, "group")) { findings.push(workflowFinding({ - message: "Dynamic runner selection requires proof that it cannot resolve to self-hosted.", + message: "Dynamic or runner-group selection requires proof that it cannot resolve to self-hosted.", path: workflowPath, ruleId: "workflow-dynamic-runner", severity: "warning", @@ -409,6 +413,8 @@ function yamlKeyValues(text, key, { indentation: requiredIndentation } = {}) { function yamlScalarValue(value) { const trimmed = value.trim(); + const blockScalar = /^[>|](?:[1-9]?[+-]?|[+-]?[1-9]?)?(?:\s+|$)([\s\S]*)$/u.exec(trimmed); + if (blockScalar) return blockScalar[1].trim(); const quote = trimmed[0]; return (quote === "\"" || quote === "'") && trimmed.at(-1) === quote ? trimmed.slice(1, -1) @@ -420,12 +426,34 @@ function yamlValueContainsToken(value, expectedToken) { .some((token) => token.toLowerCase() === expectedToken.toLowerCase()); } +function yamlFlowMappingValue(value, key) { + const escapedKey = escapeRegExp(key); + const keyPattern = "(?:\"" + escapedKey + "\"|'" + escapedKey + "'|" + escapedKey + ")"; + const match = new RegExp( + "(?:^|[{,])\\s*" + keyPattern + "\\s*:\\s*(?:\"([^\"]*)\"|'([^']*)'|([^\\s,}]+))", + "iu", + ).exec(value.trim()); + return match ? match[1] ?? match[2] ?? match[3] : undefined; +} + +function yamlFlowMappingHasKey(value, key) { + const escapedKey = escapeRegExp(key); + const keyPattern = "(?:\"" + escapedKey + "\"|'" + escapedKey + "'|" + escapedKey + ")"; + return new RegExp("(?:^|[{,])\\s*" + keyPattern + "\\s*:", "iu").test(value.trim()); +} + function hasUntrustedPullRequestCheckout(text) { const lines = text.split("\n"); for (let index = 0; index < lines.length; index += 1) { const sequenceItem = /^(\s*)-\s*(.*)$/u.exec(lines[index]); if (!sequenceItem) continue; const itemIndentation = sequenceItem[1].length; + const flowUses = yamlFlowMappingValue(sequenceItem[2], "uses"); + if (flowUses && /^actions\/checkout@/iu.test(flowUses) + && yamlFlowMappingHasKey(sequenceItem[2], "ref") + && isUntrustedPullRequestRef(sequenceItem[2])) { + return true; + } const entries = []; if (sequenceItem[2].trim().length > 0) { const inlineEntry = parseYamlKeyLine(" ".repeat(itemIndentation + 2) + sequenceItem[2]); @@ -463,12 +491,14 @@ function hasUntrustedPullRequestCheckout(text) { } function isUntrustedPullRequestRef(value) { - return /(?:pull_request\.head|head\.sha)/iu.test(value); + return /(?:github\.head_ref|pull_request\.head|head\.sha)/iu.test(value); } function actionReferences(text) { return text.split("\n").flatMap((line) => { const sequenceItem = /^(\s*)-\s*(.*)$/u.exec(line); + const flowReference = yamlFlowMappingValue(sequenceItem?.[2] ?? line.trim(), "uses"); + if (flowReference !== undefined) return [flowReference]; const candidate = sequenceItem ? " ".repeat(sequenceItem[1].length + 2) + sequenceItem[2] : line; @@ -768,7 +798,7 @@ function runCommand(command, args, { cwd, encoding = "utf8" }) { const result = spawnSync(command, args, { cwd, encoding, - env: process.env, + env: command === "git" ? auditGitEnvironment() : process.env, maxBuffer: 128 * 1024 * 1024, }); if (result.status !== 0) { @@ -777,6 +807,13 @@ function runCommand(command, args, { cwd, encoding = "utf8" }) { return result; } +function auditGitEnvironment() { + return { + ...process.env, + GIT_NO_REPLACE_OBJECTS: "1", + }; +} + function escapeRegExp(value) { return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); } diff --git a/public-source-release-audit/tests/public-source-release-audit.test.mjs b/public-source-release-audit/tests/public-source-release-audit.test.mjs index f7bb7d2..e3cfe55 100644 --- a/public-source-release-audit/tests/public-source-release-audit.test.mjs +++ b/public-source-release-audit/tests/public-source-release-audit.test.mjs @@ -176,6 +176,23 @@ test("history mode catches removed content and sensitive commit messages", () => assert.ok(historyAudit.result.scannedHistoryCommitCount >= 4); }); +test("history mode detects bearer credentials in commit messages", () => { + const repoRoot = makeRepository(); + git(repoRoot, [ + "commit", + "--allow-empty", + "-m", + ["Authorization:", "Bearer", "c".repeat(32)].join(" "), + ]); + + const currentAudit = runAudit(repoRoot); + const historyAudit = runAudit(repoRoot, ["--history"]); + + assert.equal(currentAudit.status, 0); + assert.equal(historyAudit.status, 1); + assertFinding(historyAudit.result, "access-token"); +}); + test("history mode scans workflows on every reachable branch", () => { const repoRoot = makeRepository(); const defaultBranch = git(repoRoot, ["branch", "--show-current"]).stdout.trim(); @@ -201,6 +218,34 @@ test("history mode scans workflows on every reachable branch", () => { assertFinding(historyAudit.result, "workflow-self-hosted-runner", ".github/workflows/alternate.yml"); }); +test("workflow history ignores local Git replacement objects", () => { + const repoRoot = makeRepository(); + const defaultBranch = git(repoRoot, ["branch", "--show-current"]).stdout.trim(); + git(repoRoot, ["checkout", "--quiet", "-b", "public-unsafe"]); + write(repoRoot, ".github/workflows/replaced.yml", [ + "name: replaced", + "on: push", + "jobs:", + " unsafe:", + " runs-on: self-hosted", + "", + ].join("\n")); + commitAll(repoRoot, "add unsafe workflow"); + const unsafeCommit = git(repoRoot, ["rev-parse", "HEAD"]).stdout.trim(); + git(repoRoot, ["checkout", "--quiet", defaultBranch]); + writeSafeWorkflow(repoRoot); + commitAll(repoRoot, "add safe workflow"); + const safeCommit = git(repoRoot, ["rev-parse", "HEAD"]).stdout.trim(); + git(repoRoot, ["replace", unsafeCommit, safeCommit]); + + const currentAudit = runAudit(repoRoot); + const historyAudit = runAudit(repoRoot, ["--history"]); + + assert.equal(currentAudit.status, 0); + assert.equal(historyAudit.status, 1); + assertFinding(historyAudit.result, "workflow-self-hosted-runner", ".github/workflows/replaced.yml"); +}); + test("history mode rejects shallow evidence", () => { const repoRoot = makeRepository(); const head = git(repoRoot, ["rev-parse", "HEAD"]).stdout.trim(); @@ -295,6 +340,26 @@ test("quoted permissions keys and write-all values are release-blocking", () => assertFinding(audit.result, "workflow-write-all", ".github/workflows/quoted-permissions-key.yml"); }); +test("block-scalar write-all permissions are release-blocking", () => { + const repoRoot = makeRepository(); + write(repoRoot, ".github/workflows/block-permissions.yml", [ + "name: block permissions", + "on: push", + "permissions: >-", + " write-all", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + "", + ].join("\n")); + commitAll(repoRoot, "add block permissions workflow"); + + const audit = runAudit(repoRoot); + + assert.equal(audit.status, 1); + assertFinding(audit.result, "workflow-write-all", ".github/workflows/block-permissions.yml"); +}); + test("quoted runs-on keys cannot hide self-hosted labels", () => { const repoRoot = makeRepository(); write(repoRoot, ".github/workflows/quoted-runner-key.yml", [ @@ -339,6 +404,28 @@ test("block-list privileged triggers detect reordered checkout inputs", () => { assertFinding(audit.result, "workflow-privileged-untrusted-checkout", ".github/workflows/reordered-checkout.yml"); }); +test("github head_ref is an untrusted privileged checkout source", () => { + const repoRoot = makeRepository(); + const expression = ["$", "{{ github.head_ref }}"].join(""); + write(repoRoot, ".github/workflows/head-ref-checkout.yml", [ + "name: head ref checkout", + "on: pull_request_target", + "permissions: read-all", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - { uses: actions/checkout@" + "a".repeat(40) + ", with: { ref: " + expression + " } }", + "", + ].join("\n")); + commitAll(repoRoot, "add head ref checkout workflow"); + + const audit = runAudit(repoRoot); + + assert.equal(audit.status, 1); + assertFinding(audit.result, "workflow-privileged-untrusted-checkout", ".github/workflows/head-ref-checkout.yml"); +}); + test("mutable Docker actions warn while digest-pinned actions pass", () => { const repoRoot = makeRepository(); write(repoRoot, ".github/workflows/mutable-docker.yml", [ @@ -371,6 +458,58 @@ test("mutable Docker actions warn while digest-pinned actions pass", () => { assert.equal(audit.result.findings.some((finding) => finding.path === ".github/workflows/pinned-docker.yml"), false); }); +test("flow-style mutable action references emit an advisory", () => { + const repoRoot = makeRepository(); + write(repoRoot, ".github/workflows/flow-action.yml", [ + "name: flow action", + "on: push", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - { name: inspect, uses: example/action@main }", + "", + ].join("\n")); + commitAll(repoRoot, "add flow action workflow"); + + const defaultAudit = runAudit(repoRoot); + const strictAudit = runAudit(repoRoot, ["--fail-on-warning"]); + + assert.equal(defaultAudit.status, 0); + assertFinding(defaultAudit.result, "workflow-mutable-action-ref", ".github/workflows/flow-action.yml"); + assert.equal(strictAudit.status, 1); +}); + +test("runner-group selectors require proof of hosted isolation", () => { + const repoRoot = makeRepository(); + write(repoRoot, ".github/workflows/flow-runner-group.yml", [ + "name: flow runner group", + "on: push", + "jobs:", + " inspect:", + " runs-on: { group: private-runners }", + "", + ].join("\n")); + write(repoRoot, ".github/workflows/block-runner-group.yml", [ + "name: block runner group", + "on: push", + "jobs:", + " inspect:", + " runs-on:", + " group: private-runners", + "", + ].join("\n")); + commitAll(repoRoot, "add runner group workflows"); + + const defaultAudit = runAudit(repoRoot); + const strictAudit = runAudit(repoRoot, ["--fail-on-warning"]); + + assert.equal(defaultAudit.status, 0); + assertFinding(defaultAudit.result, "workflow-dynamic-runner", ".github/workflows/flow-runner-group.yml"); + assertFinding(defaultAudit.result, "workflow-dynamic-runner", ".github/workflows/block-runner-group.yml"); + assert.equal(strictAudit.status, 1); +}); + test("unsafe public workflow execution is release-blocking", () => { const repoRoot = makeRepository(); write(repoRoot, ".github/workflows/unsafe.yml", [ @@ -555,6 +694,16 @@ test("incomplete GitHub evidence fails closed", () => { assert.equal(audit.result.error.code, "audit-error"); }); +test("required checks cannot be requested without GitHub evidence", () => { + const repoRoot = makeRepository(); + + const audit = runAudit(repoRoot, ["--required-check", "verify"]); + + assert.equal(audit.status, 2); + assert.equal(audit.result.passed, false); + assert.equal(audit.result.error.code, "usage-error"); +}); + test("invalid arguments return a distinct usage failure", () => { const repoRoot = makeRepository(); From 4a43c631cc6e066711079c351bfea53f1e294c20 Mon Sep 17 00:00:00 2001 From: Vladimir Date: Sat, 22 Aug 2026 01:29:32 +0800 Subject: [PATCH 05/37] Harden workflow trust matching --- .../scripts/public-source-release-audit.mjs | 29 +++-- .../public-source-release-audit.test.mjs | 108 ++++++++++++++++++ 2 files changed, 128 insertions(+), 9 deletions(-) diff --git a/public-source-release-audit/scripts/public-source-release-audit.mjs b/public-source-release-audit/scripts/public-source-release-audit.mjs index 0f6cff6..b9a07bf 100644 --- a/public-source-release-audit/scripts/public-source-release-audit.mjs +++ b/public-source-release-audit/scripts/public-source-release-audit.mjs @@ -318,7 +318,9 @@ function auditWorkflowText(workflowPath, text, source = "tracked-file") { severity: "error", source, })); - } else if (/\$\{\{|^\s*\*/u.test(runner) || yamlValueContainsToken(runner, "group")) { + } else if (/\$\{\{|^\s*\*/u.test(runner) + || yamlValueContainsToken(runner, "group") + || !isKnownGithubHostedRunnerLabel(yamlScalarValue(runner))) { findings.push(workflowFinding({ message: "Dynamic or runner-group selection requires proof that it cannot resolve to self-hosted.", path: workflowPath, @@ -412,13 +414,22 @@ function yamlKeyValues(text, key, { indentation: requiredIndentation } = {}) { } function yamlScalarValue(value) { - const trimmed = value.trim(); - const blockScalar = /^[>|](?:[1-9]?[+-]?|[+-]?[1-9]?)?(?:\s+|$)([\s\S]*)$/u.exec(trimmed); + let normalized = value.trim(); + let property = /^(?:&[^\s]+|![^\s]+)\s+/u.exec(normalized); + while (property) { + normalized = normalized.slice(property[0].length); + property = /^(?:&[^\s]+|![^\s]+)\s+/u.exec(normalized); + } + const blockScalar = /^[>|](?:[1-9]?[+-]?|[+-]?[1-9]?)?(?:\s+|$)([\s\S]*)$/u.exec(normalized); if (blockScalar) return blockScalar[1].trim(); - const quote = trimmed[0]; - return (quote === "\"" || quote === "'") && trimmed.at(-1) === quote - ? trimmed.slice(1, -1) - : trimmed; + const quote = normalized[0]; + return (quote === "\"" || quote === "'") && normalized.at(-1) === quote + ? normalized.slice(1, -1) + : normalized; +} + +function isKnownGithubHostedRunnerLabel(value) { + return /^(?:ubuntu-(?:slim|latest|\d{2}\.\d{2})(?:-arm)?|windows-(?:latest|\d{4}(?:-vs\d{4})?|\d{2}(?:-vs\d{4})?-arm)|macos-(?:latest|\d{2})(?:-(?:intel|large|xlarge))?|xcode-\d{2}(?:-xlarge)?)$/iu.test(value); } function yamlValueContainsToken(value, expectedToken) { @@ -491,7 +502,7 @@ function hasUntrustedPullRequestCheckout(text) { } function isUntrustedPullRequestRef(value) { - return /(?:github\.head_ref|pull_request\.head|head\.sha)/iu.test(value); + return /(?:github\.head_ref|pull_request\.(?:head|merge_commit_sha)|head\.sha|refs\/pull\/)/iu.test(value); } function actionReferences(text) { @@ -720,7 +731,7 @@ function rulesetAppliesToDefaultBranch(ruleset, defaultBranch) { function refPatternMatches(pattern, reference, defaultBranch) { if (pattern === "~ALL" || pattern === "~DEFAULT_BRANCH") return true; if (pattern === defaultBranch || pattern === reference) return true; - const expression = `^${escapeRegExp(pattern).replaceAll("\\*\\*", ".*").replaceAll("\\*", "[^/]*").replaceAll("\\?", ".")}$`; + const expression = `^${escapeRegExp(pattern).replaceAll("\\*\\*", ".*").replaceAll("\\*", "[^/]*").replaceAll("\\?", "[^/]")}$`; return new RegExp(expression, "u").test(reference) || new RegExp(expression, "u").test(defaultBranch); } diff --git a/public-source-release-audit/tests/public-source-release-audit.test.mjs b/public-source-release-audit/tests/public-source-release-audit.test.mjs index e3cfe55..288392a 100644 --- a/public-source-release-audit/tests/public-source-release-audit.test.mjs +++ b/public-source-release-audit/tests/public-source-release-audit.test.mjs @@ -360,6 +360,25 @@ test("block-scalar write-all permissions are release-blocking", () => { assertFinding(audit.result, "workflow-write-all", ".github/workflows/block-permissions.yml"); }); +test("anchored write-all permissions are release-blocking", () => { + const repoRoot = makeRepository(); + write(repoRoot, ".github/workflows/anchored-permissions.yml", [ + "name: anchored permissions", + "on: push", + "permissions: &all write-all", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + "", + ].join("\n")); + commitAll(repoRoot, "add anchored permissions workflow"); + + const audit = runAudit(repoRoot); + + assert.equal(audit.status, 1); + assertFinding(audit.result, "workflow-write-all", ".github/workflows/anchored-permissions.yml"); +}); + test("quoted runs-on keys cannot hide self-hosted labels", () => { const repoRoot = makeRepository(); write(repoRoot, ".github/workflows/quoted-runner-key.yml", [ @@ -426,6 +445,30 @@ test("github head_ref is an untrusted privileged checkout source", () => { assertFinding(audit.result, "workflow-privileged-untrusted-checkout", ".github/workflows/head-ref-checkout.yml"); }); +test("pull-request merge refs are untrusted privileged checkout sources", () => { + const repoRoot = makeRepository(); + const numberExpression = ["$", "{{ github.event.pull_request.number }}"].join(""); + write(repoRoot, ".github/workflows/merge-ref-checkout.yml", [ + "name: merge ref checkout", + "on: pull_request_target", + "permissions: read-all", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - uses: actions/checkout@" + "a".repeat(40), + " with:", + " ref: refs/pull/" + numberExpression + "/merge", + "", + ].join("\n")); + commitAll(repoRoot, "add merge ref checkout workflow"); + + const audit = runAudit(repoRoot); + + assert.equal(audit.status, 1); + assertFinding(audit.result, "workflow-privileged-untrusted-checkout", ".github/workflows/merge-ref-checkout.yml"); +}); + test("mutable Docker actions warn while digest-pinned actions pass", () => { const repoRoot = makeRepository(); write(repoRoot, ".github/workflows/mutable-docker.yml", [ @@ -510,6 +553,54 @@ test("runner-group selectors require proof of hosted isolation", () => { assert.equal(strictAudit.status, 1); }); +test("unknown static runner labels require proof of hosted isolation", () => { + const repoRoot = makeRepository(); + write(repoRoot, ".github/workflows/custom-runner-label.yml", [ + "name: custom runner label", + "on: push", + "jobs:", + " inspect:", + " runs-on: production-runner", + "", + ].join("\n")); + commitAll(repoRoot, "add custom runner label workflow"); + + const defaultAudit = runAudit(repoRoot); + const strictAudit = runAudit(repoRoot, ["--fail-on-warning"]); + + assert.equal(defaultAudit.status, 0); + assertFinding(defaultAudit.result, "workflow-dynamic-runner", ".github/workflows/custom-runner-label.yml"); + assert.equal(strictAudit.status, 1); +}); + +test("standard GitHub-hosted runner labels remain trusted", () => { + const repoRoot = makeRepository(); + write(repoRoot, ".github/workflows/hosted-runner-labels.yml", [ + "name: hosted runner labels", + "on: push", + "jobs:", + " slim:", + " runs-on: ubuntu-slim", + " linux-arm:", + " runs-on: ubuntu-26.04-arm", + " windows:", + " runs-on: windows-2025-vs2026", + " windows-arm:", + " runs-on: windows-11-vs2026-arm", + " mac-intel:", + " runs-on: macos-15-intel", + " xcode:", + " runs-on: xcode-27", + "", + ].join("\n")); + commitAll(repoRoot, "add hosted runner label workflow"); + + const audit = runAudit(repoRoot); + + assert.equal(audit.status, 0); + assert.equal(audit.result.warningCount, 0); +}); + test("unsafe public workflow execution is release-blocking", () => { const repoRoot = makeRepository(); write(repoRoot, ".github/workflows/unsafe.yml", [ @@ -595,6 +686,23 @@ test("the all-branches ruleset selector protects the default branch", () => { assert.equal(audit.result.passed, true); }); +test("ruleset question globs do not cross branch separators", () => { + const repoRoot = makeRepository(); + writeSafeWorkflow(repoRoot); + commitAll(repoRoot, "add safe workflow"); + const snapshot = githubSnapshot(); + snapshot.repository.default_branch = "release/main"; + snapshot.rulesets[0].conditions.ref_name.include = ["refs/heads/release?main"]; + + const audit = runAudit(repoRoot, [ + "--github-snapshot", writeSnapshot(snapshot), + "--required-check", "verify", + ]); + + assert.equal(audit.status, 1); + assertFinding(audit.result, "github-required-check-missing"); +}); + test("live GitHub evidence consumes every ruleset page", () => { const repoRoot = makeRepository(); writeSafeWorkflow(repoRoot); From 1f1595d4c8d97cfb1de4b5c352ff7dc46f366a8e Mon Sep 17 00:00:00 2001 From: Vladimir Date: Sat, 22 Aug 2026 01:40:31 +0800 Subject: [PATCH 06/37] Parse guarded flow-style workflow keys --- .../scripts/public-source-release-audit.mjs | 151 ++++++++++++++++-- .../public-source-release-audit.test.mjs | 16 ++ 2 files changed, 156 insertions(+), 11 deletions(-) diff --git a/public-source-release-audit/scripts/public-source-release-audit.mjs b/public-source-release-audit/scripts/public-source-release-audit.mjs index b9a07bf..3c987d4 100644 --- a/public-source-release-audit/scripts/public-source-release-audit.mjs +++ b/public-source-release-audit/scripts/public-source-release-audit.mjs @@ -410,7 +410,10 @@ function yamlKeyValues(text, key, { indentation: requiredIndentation } = {}) { } values.push(value.trim()); } - return values; + const flowValues = requiredIndentation === 0 + ? yamlRootFlowMappingValues(text, key) + : yamlFlowMappingValues(text, key); + return [...values, ...flowValues]; } function yamlScalarValue(value) { @@ -437,20 +440,146 @@ function yamlValueContainsToken(value, expectedToken) { .some((token) => token.toLowerCase() === expectedToken.toLowerCase()); } +function yamlFlowMappingValues(text, key) { + const values = []; + let quote; + for (let index = 0; index < text.length; index += 1) { + const character = text[index]; + if (quote) { + if (quote === "'" && character === "'" && text[index + 1] === "'") { + index += 1; + } else if (quote === "\"" && character === "\\") { + index += 1; + } else if (character === quote) { + quote = undefined; + } + continue; + } + if (character === "'" || character === "\"") { + quote = character; + continue; + } + if (character !== "{" || !isYamlFlowMappingStart(text, index)) continue; + for (const entry of yamlFlowMappingEntriesAt(text, index)) { + if (entry.key.toLowerCase() === key.toLowerCase()) { + values.push(entry.value); + } + } + } + return values; +} + function yamlFlowMappingValue(value, key) { - const escapedKey = escapeRegExp(key); - const keyPattern = "(?:\"" + escapedKey + "\"|'" + escapedKey + "'|" + escapedKey + ")"; - const match = new RegExp( - "(?:^|[{,])\\s*" + keyPattern + "\\s*:\\s*(?:\"([^\"]*)\"|'([^']*)'|([^\\s,}]+))", - "iu", - ).exec(value.trim()); - return match ? match[1] ?? match[2] ?? match[3] : undefined; + return yamlFlowMappingValues(value, key)[0]; +} + +function yamlRootFlowMappingValues(text, key) { + const openingBraceIndex = text.search(/\S/u); + if (openingBraceIndex < 0 || text[openingBraceIndex] !== "{") return []; + return yamlFlowMappingEntriesAt(text, openingBraceIndex) + .filter((entry) => entry.key.toLowerCase() === key.toLowerCase()) + .map((entry) => entry.value); } function yamlFlowMappingHasKey(value, key) { - const escapedKey = escapeRegExp(key); - const keyPattern = "(?:\"" + escapedKey + "\"|'" + escapedKey + "'|" + escapedKey + ")"; - return new RegExp("(?:^|[{,])\\s*" + keyPattern + "\\s*:", "iu").test(value.trim()); + return yamlFlowMappingValues(value, key).length > 0; +} + +function isYamlFlowMappingStart(text, index) { + for (let cursor = index - 1; cursor >= 0; cursor -= 1) { + if (/\s/u.test(text[cursor])) continue; + return [":", ",", "[", "{", "-"].includes(text[cursor]); + } + return true; +} + +function yamlFlowMappingEntriesAt(text, openingBraceIndex) { + const entries = []; + let cursor = openingBraceIndex + 1; + while (cursor < text.length) { + while (cursor < text.length && /\s/u.test(text[cursor])) cursor += 1; + if (text[cursor] === "}") break; + const keyResult = readYamlFlowKey(text, cursor); + if (!keyResult) break; + cursor = keyResult.nextIndex; + while (cursor < text.length && /\s/u.test(text[cursor])) cursor += 1; + if (text[cursor] !== ":") break; + cursor += 1; + const valueStart = cursor; + let braces = 0; + let brackets = 0; + let quote; + while (cursor < text.length) { + const character = text[cursor]; + if (quote) { + if (quote === "'" && character === "'" && text[cursor + 1] === "'") { + cursor += 2; + continue; + } + if (quote === "\"" && character === "\\") { + cursor += 2; + continue; + } + if (character === quote) quote = undefined; + cursor += 1; + continue; + } + if (character === "'" || character === "\"") { + quote = character; + } else if (character === "{") { + braces += 1; + } else if (character === "}") { + if (braces === 0 && brackets === 0) break; + braces -= 1; + } else if (character === "[") { + brackets += 1; + } else if (character === "]") { + brackets -= 1; + } else if (character === "," && braces === 0 && brackets === 0) { + break; + } + cursor += 1; + } + entries.push({ + key: keyResult.key, + value: text.slice(valueStart, cursor).trim(), + }); + if (text[cursor] === ",") { + cursor += 1; + continue; + } + break; + } + return entries; +} + +function readYamlFlowKey(text, startIndex) { + const quote = text[startIndex]; + if (quote === "'" || quote === "\"") { + let cursor = startIndex + 1; + while (cursor < text.length) { + if (quote === "'" && text[cursor] === "'" && text[cursor + 1] === "'") { + cursor += 2; + continue; + } + if (quote === "\"" && text[cursor] === "\\") { + cursor += 2; + continue; + } + if (text[cursor] === quote) { + return { + key: text.slice(startIndex + 1, cursor), + nextIndex: cursor + 1, + }; + } + cursor += 1; + } + return undefined; + } + let cursor = startIndex; + while (cursor < text.length && !/[:,{}]/u.test(text[cursor])) cursor += 1; + const key = text.slice(startIndex, cursor).trim(); + return key.length > 0 ? { key, nextIndex: cursor } : undefined; } function hasUntrustedPullRequestCheckout(text) { diff --git a/public-source-release-audit/tests/public-source-release-audit.test.mjs b/public-source-release-audit/tests/public-source-release-audit.test.mjs index 288392a..21ed17b 100644 --- a/public-source-release-audit/tests/public-source-release-audit.test.mjs +++ b/public-source-release-audit/tests/public-source-release-audit.test.mjs @@ -379,6 +379,22 @@ test("anchored write-all permissions are release-blocking", () => { assertFinding(audit.result, "workflow-write-all", ".github/workflows/anchored-permissions.yml"); }); +test("flow-style workflow mappings preserve guarded key checks", () => { + const repoRoot = makeRepository(); + write( + repoRoot, + ".github/workflows/flow-document.yml", + "{name: unsafe, on: push, \"permissions\": write-all, jobs: {build: {'runs-on': self-hosted}}}\n", + ); + commitAll(repoRoot, "add flow workflow document"); + + const audit = runAudit(repoRoot); + + assert.equal(audit.status, 1); + assertFinding(audit.result, "workflow-write-all", ".github/workflows/flow-document.yml"); + assertFinding(audit.result, "workflow-self-hosted-runner", ".github/workflows/flow-document.yml"); +}); + test("quoted runs-on keys cannot hide self-hosted labels", () => { const repoRoot = makeRepository(); write(repoRoot, ".github/workflows/quoted-runner-key.yml", [ From d4eb99aad51dc4ecbdbe3deeeca7ba5145fb3db1 Mon Sep 17 00:00:00 2001 From: Vladimir Date: Sat, 22 Aug 2026 01:54:04 +0800 Subject: [PATCH 07/37] Resolve remaining audit review edge cases --- .../scripts/public-safety-audit-core.mjs | 3 +- .../scripts/public-source-release-audit.mjs | 109 +++++++++++++++--- .../public-source-release-audit.test.mjs | 54 +++++++++ 3 files changed, 148 insertions(+), 18 deletions(-) diff --git a/public-source-release-audit/scripts/public-safety-audit-core.mjs b/public-source-release-audit/scripts/public-safety-audit-core.mjs index 5e4c2fb..019c47c 100644 --- a/public-source-release-audit/scripts/public-safety-audit-core.mjs +++ b/public-source-release-audit/scripts/public-safety-audit-core.mjs @@ -95,7 +95,8 @@ const ACCESS_TOKEN_PATTERNS = [ /Authorization:\s*Bearer\s+[A-Za-z0-9._~-]{16,}/iu, /(? yamlValueContainsToken(value, "pull_request_target")); const hasWriteAll = yamlKeyValues(uncommented, "permissions") - .some((value) => yamlScalarValue(value).toLowerCase() === "write-all"); + .some((value) => resolveYamlScalarValue(value, scalarAnchors).toLowerCase() === "write-all"); if (hasWriteAll) { findings.push(workflowFinding({ @@ -394,12 +395,22 @@ function parseYamlKeyLine(line) { } function yamlKeyValues(text, key, { indentation: requiredIndentation } = {}) { + const values = yamlBlockMappingEntries(text) + .filter((entry) => entry.key.toLowerCase() === key.toLowerCase() + && (requiredIndentation === undefined || entry.indentation === requiredIndentation)) + .map((entry) => entry.value); + const flowValues = requiredIndentation === 0 + ? yamlRootFlowMappingValues(text, key) + : yamlFlowMappingValues(text, key); + return [...values, ...flowValues]; +} + +function yamlBlockMappingEntries(text) { const lines = text.split("\n"); - const values = []; + const entries = []; for (let index = 0; index < lines.length; index += 1) { const entry = parseYamlKeyLine(lines[index]); - if (!entry || entry.key.toLowerCase() !== key.toLowerCase()) continue; - if (requiredIndentation !== undefined && entry.indentation !== requiredIndentation) continue; + if (!entry) continue; let value = entry.value; for (let cursor = index + 1; cursor < lines.length; cursor += 1) { const line = lines[cursor]; @@ -408,12 +419,9 @@ function yamlKeyValues(text, key, { indentation: requiredIndentation } = {}) { if (nextIndentation <= entry.indentation) break; value += " " + line.trim(); } - values.push(value.trim()); + entries.push({ ...entry, value: value.trim() }); } - const flowValues = requiredIndentation === 0 - ? yamlRootFlowMappingValues(text, key) - : yamlFlowMappingValues(text, key); - return [...values, ...flowValues]; + return entries; } function yamlScalarValue(value) { @@ -431,6 +439,35 @@ function yamlScalarValue(value) { : normalized; } +function yamlScalarAnchors(text) { + const anchors = new Map(); + const entries = [ + ...yamlBlockMappingEntries(text), + ...yamlFlowMappings(text).flat(), + ]; + for (const entry of entries) { + let value = entry.value.trim(); + while (/^![^\s]+\s+/u.test(value)) { + value = value.replace(/^![^\s]+\s+/u, ""); + } + const anchor = /^&([^\s]+)\s+([\s\S]*)$/u.exec(value); + if (anchor) anchors.set(anchor[1], anchor[2].trim()); + } + return anchors; +} + +function resolveYamlScalarValue(value, anchors) { + const visited = new Set(); + let currentValue = value; + while (true) { + const scalar = yamlScalarValue(currentValue); + const alias = /^\*([^\s]+)$/u.exec(scalar); + if (!alias || !anchors.has(alias[1]) || visited.has(alias[1])) return scalar; + visited.add(alias[1]); + currentValue = anchors.get(alias[1]); + } +} + function isKnownGithubHostedRunnerLabel(value) { return /^(?:ubuntu-(?:slim|latest|\d{2}\.\d{2})(?:-arm)?|windows-(?:latest|\d{4}(?:-vs\d{4})?|\d{2}(?:-vs\d{4})?-arm)|macos-(?:latest|\d{2})(?:-(?:intel|large|xlarge))?|xcode-\d{2}(?:-xlarge)?)$/iu.test(value); } @@ -441,7 +478,14 @@ function yamlValueContainsToken(value, expectedToken) { } function yamlFlowMappingValues(text, key) { - const values = []; + return yamlFlowMappings(text) + .flat() + .filter((entry) => entry.key.toLowerCase() === key.toLowerCase()) + .map((entry) => entry.value); +} + +function yamlFlowMappings(text) { + const mappings = []; let quote; for (let index = 0; index < text.length; index += 1) { const character = text[index]; @@ -460,13 +504,9 @@ function yamlFlowMappingValues(text, key) { continue; } if (character !== "{" || !isYamlFlowMappingStart(text, index)) continue; - for (const entry of yamlFlowMappingEntriesAt(text, index)) { - if (entry.key.toLowerCase() === key.toLowerCase()) { - values.push(entry.value); - } - } + mappings.push(yamlFlowMappingEntriesAt(text, index)); } - return values; + return mappings; } function yamlFlowMappingValue(value, key) { @@ -583,6 +623,18 @@ function readYamlFlowKey(text, startIndex) { } function hasUntrustedPullRequestCheckout(text) { + for (const entries of yamlFlowMappings(text)) { + const usesEntry = entries.find((entry) => entry.key.toLowerCase() === "uses"); + const withEntry = entries.find((entry) => entry.key.toLowerCase() === "with"); + if (!usesEntry || !withEntry + || !/^actions\/checkout@/iu.test(yamlScalarValue(usesEntry.value))) { + continue; + } + const refs = yamlFlowMappingValues(withEntry.value, "ref"); + if (refs.some((ref) => isUntrustedPullRequestRef(yamlScalarValue(ref)))) { + return true; + } + } const lines = text.split("\n"); for (let index = 0; index < lines.length; index += 1) { const sequenceItem = /^(\s*)-\s*(.*)$/u.exec(lines[index]); @@ -860,10 +912,33 @@ function rulesetAppliesToDefaultBranch(ruleset, defaultBranch) { function refPatternMatches(pattern, reference, defaultBranch) { if (pattern === "~ALL" || pattern === "~DEFAULT_BRANCH") return true; if (pattern === defaultBranch || pattern === reference) return true; - const expression = `^${escapeRegExp(pattern).replaceAll("\\*\\*", ".*").replaceAll("\\*", "[^/]*").replaceAll("\\?", "[^/]")}$`; + const expression = refGlobExpression(pattern); return new RegExp(expression, "u").test(reference) || new RegExp(expression, "u").test(defaultBranch); } +function refGlobExpression(pattern) { + let expression = "^"; + for (let index = 0; index < pattern.length; index += 1) { + const character = pattern[index]; + if (character === "*" && pattern[index + 1] === "*") { + if (pattern[index + 2] === "/") { + expression += "(?:.*/)?"; + index += 2; + } else { + expression += ".*"; + index += 1; + } + } else if (character === "*") { + expression += "[^/]*"; + } else if (character === "?") { + expression += "[^/]"; + } else { + expression += escapeRegExp(character); + } + } + return expression + "$"; +} + function uniqueSortedFindings(findings) { const unique = new Map(); for (const finding of findings) { diff --git a/public-source-release-audit/tests/public-source-release-audit.test.mjs b/public-source-release-audit/tests/public-source-release-audit.test.mjs index 21ed17b..3ecde4c 100644 --- a/public-source-release-audit/tests/public-source-release-audit.test.mjs +++ b/public-source-release-audit/tests/public-source-release-audit.test.mjs @@ -72,6 +72,7 @@ test("credential and private-key formats are release-blocking", () => { write(repoRoot, "fine-grained.txt", fineGrainedToken("d")); write(repoRoot, "app-jwt.txt", githubAppJwt()); write(repoRoot, "bearer.txt", ["Authorization:", "Bearer", "b".repeat(32)].join(" ")); + write(repoRoot, "refresh.txt", ["ghr_", "r".repeat(76)].join("")); write(repoRoot, "private-key.txt", privateKeyBlock()); commitAll(repoRoot, "add credential fixtures"); @@ -82,6 +83,7 @@ test("credential and private-key formats are release-blocking", () => { assertFinding(audit.result, "access-token", "fine-grained.txt"); assertFinding(audit.result, "access-token", "app-jwt.txt"); assertFinding(audit.result, "access-token", "bearer.txt"); + assertFinding(audit.result, "access-token", "refresh.txt"); assertFinding(audit.result, "private-key", "private-key.txt"); }); @@ -379,6 +381,26 @@ test("anchored write-all permissions are release-blocking", () => { assertFinding(audit.result, "workflow-write-all", ".github/workflows/anchored-permissions.yml"); }); +test("aliased write-all permissions are release-blocking", () => { + const repoRoot = makeRepository(); + write(repoRoot, ".github/workflows/aliased-permissions.yml", [ + "name: aliased permissions", + "on: push", + "x-all: &all write-all", + "permissions: *all", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + "", + ].join("\n")); + commitAll(repoRoot, "add aliased permissions workflow"); + + const audit = runAudit(repoRoot); + + assert.equal(audit.status, 1); + assertFinding(audit.result, "workflow-write-all", ".github/workflows/aliased-permissions.yml"); +}); + test("flow-style workflow mappings preserve guarded key checks", () => { const repoRoot = makeRepository(); write( @@ -395,6 +417,21 @@ test("flow-style workflow mappings preserve guarded key checks", () => { assertFinding(audit.result, "workflow-self-hosted-runner", ".github/workflows/flow-document.yml"); }); +test("flow-style step sequences preserve privileged checkout checks", () => { + const repoRoot = makeRepository(); + const expression = ["$", "{{ github.head_ref }}"].join(""); + const workflow = "{on: pull_request_target, permissions: read-all, jobs: {build: {runs-on: ubuntu-latest, steps: [" + + "{uses: actions/checkout@" + "a".repeat(40) + ", with: {ref: '" + expression + "'}}, " + + "{run: ./script.sh}]}}}\n"; + write(repoRoot, ".github/workflows/flow-steps.yml", workflow); + commitAll(repoRoot, "add flow steps workflow"); + + const audit = runAudit(repoRoot); + + assert.equal(audit.status, 1); + assertFinding(audit.result, "workflow-privileged-untrusted-checkout", ".github/workflows/flow-steps.yml"); +}); + test("quoted runs-on keys cannot hide self-hosted labels", () => { const repoRoot = makeRepository(); write(repoRoot, ".github/workflows/quoted-runner-key.yml", [ @@ -719,6 +756,23 @@ test("ruleset question globs do not cross branch separators", () => { assertFinding(audit.result, "github-required-check-missing"); }); +test("ruleset double-star directories may match zero levels", () => { + const repoRoot = makeRepository(); + writeSafeWorkflow(repoRoot); + commitAll(repoRoot, "add safe workflow"); + const snapshot = githubSnapshot(); + snapshot.repository.default_branch = "releases/1"; + snapshot.rulesets[0].conditions.ref_name.include = ["refs/heads/releases/**/*"]; + + const audit = runAudit(repoRoot, [ + "--github-snapshot", writeSnapshot(snapshot), + "--required-check", "verify", + ]); + + assert.equal(audit.status, 0); + assert.equal(audit.result.passed, true); +}); + test("live GitHub evidence consumes every ruleset page", () => { const repoRoot = makeRepository(); writeSafeWorkflow(repoRoot); From 85a85d422d7952f87df59eaa68b1e023452074ad Mon Sep 17 00:00:00 2001 From: Vladimir Date: Sat, 22 Aug 2026 02:11:45 +0800 Subject: [PATCH 08/37] Close remaining release audit review gaps --- .../scripts/public-safety-audit-core.mjs | 3 +- .../scripts/public-source-release-audit.mjs | 123 +++++++++++++-- .../public-source-release-audit.test.mjs | 145 ++++++++++++++++++ 3 files changed, 255 insertions(+), 16 deletions(-) diff --git a/public-source-release-audit/scripts/public-safety-audit-core.mjs b/public-source-release-audit/scripts/public-safety-audit-core.mjs index 019c47c..f208bca 100644 --- a/public-source-release-audit/scripts/public-safety-audit-core.mjs +++ b/public-source-release-audit/scripts/public-safety-audit-core.mjs @@ -805,7 +805,7 @@ function auditTrackedTree(repoRoot) { const trackedBlobEntries = trackedEntries.filter((entry) => entry.type === "blob" && entry.mode !== "160000"); const blobRuleIdsByObjectId = findRuleIdsForGitBlobs(repoRoot, [...new Set(trackedBlobEntries.map((entry) => entry.objectId))]); const findings = []; - let fileCount = 0; + const fileCount = new Set(trackedBlobEntries.map((entry) => entry.path)).size; trackedEntries.forEach((entry) => { const redactedPath = redactSensitivePath(entry.path); findings.push(...findingsForSource({ @@ -817,7 +817,6 @@ function auditTrackedTree(repoRoot) { if (entry.type !== "blob" || entry.mode === "160000") { return; } - fileCount += 1; blobRuleIdsByObjectId.get(entry.objectId)?.forEach((ruleId) => { findings.push({ path: redactedPath, diff --git a/public-source-release-audit/scripts/public-source-release-audit.mjs b/public-source-release-audit/scripts/public-source-release-audit.mjs index 97c2169..7441fd2 100644 --- a/public-source-release-audit/scripts/public-source-release-audit.mjs +++ b/public-source-release-audit/scripts/public-source-release-audit.mjs @@ -296,7 +296,10 @@ function auditWorkflowText(workflowPath, text, source = "tracked-file") { const uncommented = text.split(/\r?\n/u).map(stripYamlComment).join("\n"); const scalarAnchors = yamlScalarAnchors(uncommented); const hasPullRequestTarget = yamlKeyValues(uncommented, "on", { indentation: 0 }) - .some((value) => yamlValueContainsToken(value, "pull_request_target")); + .some((value) => yamlValueContainsToken( + resolveYamlScalarValue(value, scalarAnchors), + "pull_request_target", + )); const hasWriteAll = yamlKeyValues(uncommented, "permissions") .some((value) => resolveYamlScalarValue(value, scalarAnchors).toLowerCase() === "write-all"); @@ -311,7 +314,8 @@ function auditWorkflowText(workflowPath, text, source = "tracked-file") { } for (const runner of yamlKeyValues(uncommented, "runs-on")) { - if (/(?:^|[\s,[{'"])self-hosted(?:$|[\s,\]}'"])/iu.test(runner)) { + const resolvedRunner = resolveYamlScalarValue(runner, scalarAnchors); + if (/(?:^|[\s,[{'"])self-hosted(?:$|[\s,\]}'"])/iu.test(resolvedRunner)) { findings.push(workflowFinding({ message: "Public workflows must not select a persistent self-hosted runner.", path: workflowPath, @@ -319,9 +323,9 @@ function auditWorkflowText(workflowPath, text, source = "tracked-file") { severity: "error", source, })); - } else if (/\$\{\{|^\s*\*/u.test(runner) - || yamlValueContainsToken(runner, "group") - || !isKnownGithubHostedRunnerLabel(yamlScalarValue(runner))) { + } else if (/\$\{\{|^\s*\*/u.test(resolvedRunner) + || yamlValueContainsToken(resolvedRunner, "group") + || !isKnownGithubHostedRunnerLabel(yamlScalarValue(resolvedRunner))) { findings.push(workflowFinding({ message: "Dynamic or runner-group selection requires proof that it cannot resolve to self-hosted.", path: workflowPath, @@ -341,7 +345,7 @@ function auditWorkflowText(workflowPath, text, source = "tracked-file") { source, })); - if (hasUntrustedPullRequestCheckout(uncommented)) { + if (hasUntrustedPullRequestCheckout(uncommented, scalarAnchors)) { findings.push(workflowFinding({ message: "A privileged pull_request_target workflow must not execute an untrusted PR checkout.", path: workflowPath, @@ -352,7 +356,7 @@ function auditWorkflowText(workflowPath, text, source = "tracked-file") { } } - for (const actionRef of actionReferences(uncommented)) { + for (const actionRef of actionReferences(uncommented, scalarAnchors)) { if (actionRef.startsWith("./") || IMMUTABLE_DOCKER_ACTION_PATTERN.test(actionRef)) continue; const separator = actionRef.lastIndexOf("@"); const revision = separator < 0 ? "" : actionRef.slice(separator + 1); @@ -407,8 +411,10 @@ function yamlKeyValues(text, key, { indentation: requiredIndentation } = {}) { function yamlBlockMappingEntries(text) { const lines = text.split("\n"); + const blockScalarBodyLines = yamlBlockScalarBodyLineIndexes(lines); const entries = []; for (let index = 0; index < lines.length; index += 1) { + if (blockScalarBodyLines.has(index)) continue; const entry = parseYamlKeyLine(lines[index]); if (!entry) continue; let value = entry.value; @@ -424,6 +430,51 @@ function yamlBlockMappingEntries(text) { return entries; } +function yamlBlockScalarBodyLineIndexes(lines) { + const bodyLines = new Set(); + let scalarIndentation; + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index]; + const indentation = /^\s*/u.exec(line)[0].length; + if (scalarIndentation !== undefined) { + if (line.trim().length === 0 || indentation > scalarIndentation) { + bodyLines.add(index); + continue; + } + scalarIndentation = undefined; + } + const entry = parseYamlStructuralKeyLine(line); + if (entry && isYamlBlockScalarHeader(entry.value)) { + scalarIndentation = entry.indentation; + } + } + return bodyLines; +} + +function parseYamlStructuralKeyLine(line) { + const directEntry = parseYamlKeyLine(line); + if (directEntry) return directEntry; + const sequenceItem = /^(\s*)-\s*(.*)$/u.exec(line); + if (!sequenceItem) return undefined; + return parseYamlKeyLine(" ".repeat(sequenceItem[1].length + 2) + sequenceItem[2]); +} + +function isYamlBlockScalarHeader(value) { + let normalized = value.trim(); + let property = /^(?:&[^\s]+|![^\s]+)\s+/u.exec(normalized); + while (property) { + normalized = normalized.slice(property[0].length); + property = /^(?:&[^\s]+|![^\s]+)\s+/u.exec(normalized); + } + return /^[>|](?:[1-9]?[+-]?|[+-]?[1-9]?)?\s*$/u.test(normalized); +} + +function maskYamlBlockScalarBodies(text) { + const lines = text.split("\n"); + const bodyLines = yamlBlockScalarBodyLineIndexes(lines); + return lines.map((line, index) => bodyLines.has(index) ? "" : line).join("\n"); +} + function yamlScalarValue(value) { let normalized = value.trim(); let property = /^(?:&[^\s]+|![^\s]+)\s+/u.exec(normalized); @@ -443,6 +494,11 @@ function yamlScalarAnchors(text) { const anchors = new Map(); const entries = [ ...yamlBlockMappingEntries(text), + ...maskYamlBlockScalarBodies(text).split("\n").flatMap((line) => { + if (!/^(\s*)-\s*/u.test(line)) return []; + const entry = parseYamlStructuralKeyLine(line); + return entry ? [entry] : []; + }), ...yamlFlowMappings(text).flat(), ]; for (const entry of entries) { @@ -485,6 +541,7 @@ function yamlFlowMappingValues(text, key) { } function yamlFlowMappings(text) { + text = maskYamlBlockScalarBodies(text); const mappings = []; let quote; for (let index = 0; index < text.length; index += 1) { @@ -622,12 +679,13 @@ function readYamlFlowKey(text, startIndex) { return key.length > 0 ? { key, nextIndex: cursor } : undefined; } -function hasUntrustedPullRequestCheckout(text) { +function hasUntrustedPullRequestCheckout(text, scalarAnchors) { + text = maskYamlBlockScalarBodies(text); for (const entries of yamlFlowMappings(text)) { const usesEntry = entries.find((entry) => entry.key.toLowerCase() === "uses"); const withEntry = entries.find((entry) => entry.key.toLowerCase() === "with"); if (!usesEntry || !withEntry - || !/^actions\/checkout@/iu.test(yamlScalarValue(usesEntry.value))) { + || !/^actions\/checkout@/iu.test(resolveYamlScalarValue(usesEntry.value, scalarAnchors))) { continue; } const refs = yamlFlowMappingValues(withEntry.value, "ref"); @@ -641,7 +699,7 @@ function hasUntrustedPullRequestCheckout(text) { if (!sequenceItem) continue; const itemIndentation = sequenceItem[1].length; const flowUses = yamlFlowMappingValue(sequenceItem[2], "uses"); - if (flowUses && /^actions\/checkout@/iu.test(flowUses) + if (flowUses && /^actions\/checkout@/iu.test(resolveYamlScalarValue(flowUses, scalarAnchors)) && yamlFlowMappingHasKey(sequenceItem[2], "ref") && isUntrustedPullRequestRef(sequenceItem[2])) { return true; @@ -663,7 +721,8 @@ function hasUntrustedPullRequestCheckout(text) { const mappingIndentation = Math.min(...entries.map((entry) => entry.indentation)); const usesEntry = entries.find((entry) => entry.indentation === mappingIndentation && entry.key.toLowerCase() === "uses"); - if (!usesEntry || !/^actions\/checkout@/iu.test(yamlScalarValue(usesEntry.value))) continue; + if (!usesEntry + || !/^actions\/checkout@/iu.test(resolveYamlScalarValue(usesEntry.value, scalarAnchors))) continue; const withIndex = entries.findIndex((entry) => entry.indentation === mappingIndentation && entry.key.toLowerCase() === "with"); if (withIndex < 0) continue; @@ -686,17 +745,20 @@ function isUntrustedPullRequestRef(value) { return /(?:github\.head_ref|pull_request\.(?:head|merge_commit_sha)|head\.sha|refs\/pull\/)/iu.test(value); } -function actionReferences(text) { +function actionReferences(text, scalarAnchors) { + text = maskYamlBlockScalarBodies(text); return text.split("\n").flatMap((line) => { const sequenceItem = /^(\s*)-\s*(.*)$/u.exec(line); const flowReference = yamlFlowMappingValue(sequenceItem?.[2] ?? line.trim(), "uses"); - if (flowReference !== undefined) return [flowReference]; + if (flowReference !== undefined) { + return [resolveYamlScalarValue(flowReference, scalarAnchors)]; + } const candidate = sequenceItem ? " ".repeat(sequenceItem[1].length + 2) + sequenceItem[2] : line; const entry = parseYamlKeyLine(candidate); return entry?.key.toLowerCase() === "uses" - ? [yamlScalarValue(entry.value)] + ? [resolveYamlScalarValue(entry.value, scalarAnchors)] : []; }); } @@ -932,6 +994,14 @@ function refGlobExpression(pattern) { expression += "[^/]*"; } else if (character === "?") { expression += "[^/]"; + } else if (character === "[") { + const characterClass = refGlobCharacterClass(pattern, index); + if (characterClass) { + expression += characterClass.expression; + index = characterClass.closingIndex; + } else { + expression += "\\["; + } } else { expression += escapeRegExp(character); } @@ -939,6 +1009,31 @@ function refGlobExpression(pattern) { return expression + "$"; } +function refGlobCharacterClass(pattern, openingIndex) { + let closingIndex = openingIndex + 1; + if (pattern[closingIndex] === "]") closingIndex += 1; + while (closingIndex < pattern.length && pattern[closingIndex] !== "]") closingIndex += 1; + if (closingIndex >= pattern.length) return undefined; + const content = pattern.slice(openingIndex + 1, closingIndex); + if (content.length === 0) return undefined; + let expression = ""; + for (let index = 0; index < content.length; index += 1) { + const character = content[index]; + if (character === "\\" || character === "]" || character === "[" || character === "^") { + expression += `\\${character}`; + } else if (character === "-" && (index === 0 || index === content.length - 1 + || content.codePointAt(index - 1) > content.codePointAt(index + 1))) { + expression += "\\-"; + } else { + expression += character; + } + } + return { + closingIndex, + expression: `(?=[^/])[${expression}]`, + }; +} + function uniqueSortedFindings(findings) { const unique = new Map(); for (const finding of findings) { diff --git a/public-source-release-audit/tests/public-source-release-audit.test.mjs b/public-source-release-audit/tests/public-source-release-audit.test.mjs index 3ecde4c..0023a40 100644 --- a/public-source-release-audit/tests/public-source-release-audit.test.mjs +++ b/public-source-release-audit/tests/public-source-release-audit.test.mjs @@ -53,6 +53,19 @@ test("the staged candidate cannot be hidden by a clean working-tree replacement" assert.doesNotMatch(audit.stdout, new RegExp(credential, "u")); }); +test("staged blob versions do not inflate the tracked path count", () => { + const repoRoot = makeRepository(); + writeSafeWorkflow(repoRoot); + commitAll(repoRoot, "add safe workflow"); + write(repoRoot, "README.md", "updated fixture repository\n"); + git(repoRoot, ["add", "README.md"]); + + const audit = runAudit(repoRoot); + + assert.equal(audit.status, 0); + assert.equal(audit.result.scannedTrackedFileCount, 2); +}); + test("an unsafe HEAD cannot be hidden by a staged clean replacement", () => { const repoRoot = makeRepository(); write(repoRoot, "published.txt", classicToken("b")); @@ -401,6 +414,31 @@ test("aliased write-all permissions are release-blocking", () => { assertFinding(audit.result, "workflow-write-all", ".github/workflows/aliased-permissions.yml"); }); +test("aliased privileged triggers remain guarded", () => { + const repoRoot = makeRepository(); + const expression = ["$", "{{ github.head_ref }}"].join(""); + write(repoRoot, ".github/workflows/aliased-trigger.yml", [ + "name: &event pull_request_target", + "on: *event", + "permissions: read-all", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - uses: actions/checkout@" + "a".repeat(40), + " with:", + " ref: " + expression, + "", + ].join("\n")); + commitAll(repoRoot, "add aliased trigger workflow"); + + const audit = runAudit(repoRoot); + + assert.equal(audit.status, 1); + assertFinding(audit.result, "workflow-pull-request-target", ".github/workflows/aliased-trigger.yml"); + assertFinding(audit.result, "workflow-privileged-untrusted-checkout", ".github/workflows/aliased-trigger.yml"); +}); + test("flow-style workflow mappings preserve guarded key checks", () => { const repoRoot = makeRepository(); write( @@ -432,6 +470,56 @@ test("flow-style step sequences preserve privileged checkout checks", () => { assertFinding(audit.result, "workflow-privileged-untrusted-checkout", ".github/workflows/flow-steps.yml"); }); +test("aliased checkout actions remain guarded", () => { + const repoRoot = makeRepository(); + const expression = ["$", "{{ github.head_ref }}"].join(""); + write(repoRoot, ".github/workflows/aliased-checkout.yml", [ + "name: aliased checkout", + "on: pull_request_target", + "permissions: read-all", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - uses: &checkout actions/checkout@" + "a".repeat(40), + " - uses: *checkout", + " with:", + " ref: " + expression, + "", + ].join("\n")); + commitAll(repoRoot, "add aliased checkout workflow"); + + const audit = runAudit(repoRoot); + + assert.equal(audit.status, 1); + assertFinding(audit.result, "workflow-privileged-untrusted-checkout", ".github/workflows/aliased-checkout.yml"); + assert.equal(audit.result.findings.some((finding) => finding.ruleId === "workflow-mutable-action-ref"), false); +}); + +test("block scalar script bodies are not parsed as workflow mappings", () => { + const repoRoot = makeRepository(); + write(repoRoot, ".github/workflows/generated-yaml.yml", [ + "name: generated yaml", + "on: push", + "permissions: read-all", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - run: |", + " permissions: write-all", + " uses: example/action@main", + " echo '{permissions: write-all, uses: example/action@main}'", + "", + ].join("\n")); + commitAll(repoRoot, "add generated yaml workflow"); + + const audit = runAudit(repoRoot, ["--fail-on-warning"]); + + assert.equal(audit.status, 0); + assert.equal(audit.result.findingCount, 0); +}); + test("quoted runs-on keys cannot hide self-hosted labels", () => { const repoRoot = makeRepository(); write(repoRoot, ".github/workflows/quoted-runner-key.yml", [ @@ -450,6 +538,24 @@ test("quoted runs-on keys cannot hide self-hosted labels", () => { assertFinding(audit.result, "workflow-self-hosted-runner", ".github/workflows/quoted-runner-key.yml"); }); +test("aliased self-hosted runner labels remain release-blocking", () => { + const repoRoot = makeRepository(); + write(repoRoot, ".github/workflows/aliased-runner.yml", [ + "name: &runner self-hosted", + "on: push", + "jobs:", + " unsafe:", + " runs-on: *runner", + "", + ].join("\n")); + commitAll(repoRoot, "add aliased runner workflow"); + + const audit = runAudit(repoRoot); + + assert.equal(audit.status, 1); + assertFinding(audit.result, "workflow-self-hosted-runner", ".github/workflows/aliased-runner.yml"); +}); + test("block-list privileged triggers detect reordered checkout inputs", () => { const repoRoot = makeRepository(); write(repoRoot, ".github/workflows/reordered-checkout.yml", [ @@ -576,6 +682,28 @@ test("flow-style mutable action references emit an advisory", () => { assert.equal(strictAudit.status, 1); }); +test("quoted immutable flow-style action references remain trusted", () => { + const repoRoot = makeRepository(); + write(repoRoot, ".github/workflows/quoted-flow-actions.yml", [ + "name: quoted flow actions", + "on: push", + "permissions: read-all", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - { uses: './local-action' }", + " - { uses: 'example/action@" + "a".repeat(40) + "' }", + "", + ].join("\n")); + commitAll(repoRoot, "add quoted flow action workflow"); + + const audit = runAudit(repoRoot, ["--fail-on-warning"]); + + assert.equal(audit.status, 0); + assert.equal(audit.result.warningCount, 0); +}); + test("runner-group selectors require proof of hosted isolation", () => { const repoRoot = makeRepository(); write(repoRoot, ".github/workflows/flow-runner-group.yml", [ @@ -773,6 +901,23 @@ test("ruleset double-star directories may match zero levels", () => { assert.equal(audit.result.passed, true); }); +test("ruleset character classes match default branch segments", () => { + const repoRoot = makeRepository(); + writeSafeWorkflow(repoRoot); + commitAll(repoRoot, "add safe workflow"); + const snapshot = githubSnapshot(); + snapshot.repository.default_branch = "release/1.0"; + snapshot.rulesets[0].conditions.ref_name.include = ["refs/heads/release/[0-9]*"]; + + const audit = runAudit(repoRoot, [ + "--github-snapshot", writeSnapshot(snapshot), + "--required-check", "verify", + ]); + + assert.equal(audit.status, 0); + assert.equal(audit.result.passed, true); +}); + test("live GitHub evidence consumes every ruleset page", () => { const repoRoot = makeRepository(); writeSafeWorkflow(repoRoot); From e630a330965c998e152f5a33ea69627c1371099c Mon Sep 17 00:00:00 2001 From: Vladimir Date: Sat, 22 Aug 2026 02:32:38 +0800 Subject: [PATCH 09/37] Harden workflow scope and YAML semantics --- .../scripts/public-source-release-audit.mjs | 285 +++++++++++++++--- .../public-source-release-audit.test.mjs | 189 ++++++++++++ 2 files changed, 438 insertions(+), 36 deletions(-) diff --git a/public-source-release-audit/scripts/public-source-release-audit.mjs b/public-source-release-audit/scripts/public-source-release-audit.mjs index 7441fd2..a12830e 100644 --- a/public-source-release-audit/scripts/public-source-release-audit.mjs +++ b/public-source-release-audit/scripts/public-source-release-audit.mjs @@ -11,7 +11,7 @@ const MAX_OUTPUT_FINDINGS = 100; const GITHUB_ACTIONS_APP_ID = 15368; const FULL_OBJECT_ID_PATTERN = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/iu; const IMMUTABLE_DOCKER_ACTION_PATTERN = /^docker:\/\/[^\s@]+(?:[:][^\s@]+)?@(?:sha256:[0-9a-f]{64}|sha512:[0-9a-f]{128})$/iu; -const WORKFLOW_PATH_PATTERN = /^\.github\/workflows\/[^/]+\.ya?ml$/iu; +const WORKFLOW_PATH_PATTERN = /^\.github\/workflows\/[^/]+\.ya?ml$/u; main(); @@ -295,12 +295,12 @@ function auditWorkflowText(workflowPath, text, source = "tracked-file") { const findings = []; const uncommented = text.split(/\r?\n/u).map(stripYamlComment).join("\n"); const scalarAnchors = yamlScalarAnchors(uncommented); - const hasPullRequestTarget = yamlKeyValues(uncommented, "on", { indentation: 0 }) - .some((value) => yamlValueContainsToken( - resolveYamlScalarValue(value, scalarAnchors), - "pull_request_target", - )); - const hasWriteAll = yamlKeyValues(uncommented, "permissions") + const hasPullRequestTarget = workflowTriggerNames(uncommented, scalarAnchors) + .some((eventName) => eventName.toLowerCase() === "pull_request_target"); + const hasWriteAll = [ + ...workflowRootValues(uncommented, "permissions"), + ...workflowJobValues(uncommented, "permissions", scalarAnchors), + ] .some((value) => resolveYamlScalarValue(value, scalarAnchors).toLowerCase() === "write-all"); if (hasWriteAll) { @@ -313,7 +313,7 @@ function auditWorkflowText(workflowPath, text, source = "tracked-file") { })); } - for (const runner of yamlKeyValues(uncommented, "runs-on")) { + for (const runner of workflowJobValues(uncommented, "runs-on", scalarAnchors)) { const resolvedRunner = resolveYamlScalarValue(runner, scalarAnchors); if (/(?:^|[\s,[{'"])self-hosted(?:$|[\s,\]}'"])/iu.test(resolvedRunner)) { findings.push(workflowFinding({ @@ -389,24 +389,118 @@ function stripYamlComment(line) { } function parseYamlKeyLine(line) { - const match = /^(\s*)(?:"([^"\r\n]+)"|'([^'\r\n]+)'|([A-Za-z0-9_-]+))\s*:\s*(.*)$/u.exec(line); + const match = /^(\s*)(?:"((?:\\[^\r\n]|[^"\\\r\n])*)"|'((?:''|[^'\r\n])*)'|([A-Za-z0-9_-]+))\s*:\s*(.*)$/u.exec(line); if (!match) return undefined; return { indentation: match[1].length, - key: match[2] ?? match[3] ?? match[4], + key: match[2] !== undefined + ? decodeYamlDoubleQuotedScalar(match[2]) + : (match[3]?.replaceAll("''", "'") ?? match[4]), value: match[5], }; } -function yamlKeyValues(text, key, { indentation: requiredIndentation } = {}) { - const values = yamlBlockMappingEntries(text) - .filter((entry) => entry.key.toLowerCase() === key.toLowerCase() - && (requiredIndentation === undefined || entry.indentation === requiredIndentation)) - .map((entry) => entry.value); - const flowValues = requiredIndentation === 0 - ? yamlRootFlowMappingValues(text, key) - : yamlFlowMappingValues(text, key); - return [...values, ...flowValues]; +function workflowRootValues(text, key) { + return [ + ...yamlBlockMappingEntries(text) + .filter((entry) => entry.indentation === 0 && entry.key.toLowerCase() === key.toLowerCase()) + .map((entry) => entry.value), + ...yamlRootFlowMappingValues(text, key), + ]; +} + +function workflowJobValues(text, key, scalarAnchors) { + const entries = yamlBlockMappingEntries(text); + const values = []; + for (let index = 0; index < entries.length; index += 1) { + const jobsEntry = entries[index]; + if (jobsEntry.indentation !== 0 || jobsEntry.key.toLowerCase() !== "jobs") continue; + for (const job of directBlockMappingChildren(entries, index)) { + values.push(...directBlockMappingChildren(entries, job.index) + .filter(({ entry }) => entry.key.toLowerCase() === key.toLowerCase()) + .map(({ entry }) => entry.value)); + values.push(...yamlDirectFlowMappingValues( + resolveYamlScalarValue(job.entry.inlineValue, scalarAnchors), + key, + )); + } + values.push(...workflowJobValuesFromFlowJobs(jobsEntry.inlineValue, key, scalarAnchors)); + } + for (const jobsValue of yamlRootFlowMappingValues(text, "jobs")) { + values.push(...workflowJobValuesFromFlowJobs(jobsValue, key, scalarAnchors)); + } + return values; +} + +function directBlockMappingChildren(entries, parentIndex) { + const parent = entries[parentIndex]; + const descendants = []; + for (let index = parentIndex + 1; index < entries.length; index += 1) { + const entry = entries[index]; + if (entry.indentation <= parent.indentation) break; + descendants.push({ entry, index }); + } + if (descendants.length === 0) return []; + const childIndentation = Math.min(...descendants.map(({ entry }) => entry.indentation)); + return descendants.filter(({ entry }) => entry.indentation === childIndentation); +} + +function workflowJobValuesFromFlowJobs(value, key, scalarAnchors) { + const jobsValue = resolveYamlScalarValue(value, scalarAnchors); + return yamlDirectFlowMappingEntries(jobsValue).flatMap((jobEntry) => { + const jobValue = resolveYamlScalarValue(jobEntry.value, scalarAnchors); + return yamlDirectFlowMappingValues(jobValue, key); + }); +} + +function workflowTriggerNames(text, scalarAnchors) { + const lines = maskYamlBlockScalarBodies(text).split("\n"); + const names = []; + for (let index = 0; index < lines.length; index += 1) { + const entry = parseYamlKeyLine(lines[index]); + if (!entry || entry.indentation !== 0 || entry.key.toLowerCase() !== "on") continue; + if (entry.value.trim().length > 0 && !yamlValueHasOnlyProperties(entry.value)) { + names.push(...workflowTriggerNamesFromValue(entry.value, scalarAnchors)); + continue; + } + let childIndentation; + for (let cursor = index + 1; cursor < lines.length; cursor += 1) { + const line = lines[cursor]; + if (line.trim().length === 0) continue; + const indentation = /^\s*/u.exec(line)[0].length; + if (indentation <= entry.indentation) break; + if (childIndentation === undefined) childIndentation = indentation; + if (indentation !== childIndentation) continue; + const sequenceItem = /^(\s*)-\s*(.*)$/u.exec(line); + if (sequenceItem) { + names.push(...workflowTriggerNamesFromValue(sequenceItem[2], scalarAnchors)); + continue; + } + const eventEntry = parseYamlKeyLine(line); + if (eventEntry) names.push(eventEntry.key); + } + } + for (const onValue of yamlRootFlowMappingValues(text, "on")) { + names.push(...workflowTriggerNamesFromValue(onValue, scalarAnchors)); + } + return names; +} + +function yamlValueHasOnlyProperties(value) { + return /^(?:(?:&[^\s]+|![^\s]+)\s*)+$/u.test(value.trim()); +} + +function workflowTriggerNamesFromValue(value, scalarAnchors) { + const resolved = resolveYamlScalarValue(value, scalarAnchors); + const mappingEntries = yamlDirectFlowMappingEntries(resolved); + if (resolved.trimStart().startsWith("{")) { + return mappingEntries.map((entry) => entry.key); + } + const sequenceValues = yamlFlowSequenceValues(resolved); + if (sequenceValues) { + return sequenceValues.flatMap((item) => workflowTriggerNamesFromValue(item, scalarAnchors)); + } + return [resolved]; } function yamlBlockMappingEntries(text) { @@ -425,7 +519,11 @@ function yamlBlockMappingEntries(text) { if (nextIndentation <= entry.indentation) break; value += " " + line.trim(); } - entries.push({ ...entry, value: value.trim() }); + entries.push({ + ...entry, + inlineValue: entry.value.trim(), + value: value.trim(), + }); } return entries; } @@ -485,9 +583,62 @@ function yamlScalarValue(value) { const blockScalar = /^[>|](?:[1-9]?[+-]?|[+-]?[1-9]?)?(?:\s+|$)([\s\S]*)$/u.exec(normalized); if (blockScalar) return blockScalar[1].trim(); const quote = normalized[0]; - return (quote === "\"" || quote === "'") && normalized.at(-1) === quote - ? normalized.slice(1, -1) - : normalized; + if (quote === "\"" && normalized.at(-1) === quote) { + return decodeYamlDoubleQuotedScalar(normalized.slice(1, -1)); + } + if (quote === "'" && normalized.at(-1) === quote) { + return normalized.slice(1, -1).replaceAll("''", "'"); + } + return normalized; +} + +function decodeYamlDoubleQuotedScalar(value) { + const simpleEscapes = new Map([ + ["0", "\0"], + ["a", "\x07"], + ["b", "\b"], + ["t", "\t"], + ["n", "\n"], + ["v", "\v"], + ["f", "\f"], + ["r", "\r"], + ["e", "\x1b"], + [" ", " "], + ["\"", "\""], + ["/", "/"], + ["\\", "\\"], + ["N", "\u0085"], + ["_", "\u00a0"], + ["L", "\u2028"], + ["P", "\u2029"], + ]); + let decoded = ""; + for (let index = 0; index < value.length; index += 1) { + const character = value[index]; + if (character !== "\\" || index + 1 >= value.length) { + decoded += character; + continue; + } + const escape = value[index + 1]; + if (simpleEscapes.has(escape)) { + decoded += simpleEscapes.get(escape); + index += 1; + continue; + } + const width = escape === "x" ? 2 : escape === "u" ? 4 : escape === "U" ? 8 : 0; + const digits = value.slice(index + 2, index + 2 + width); + if (width > 0 && digits.length === width && /^[0-9a-f]+$/iu.test(digits)) { + const codePoint = Number.parseInt(digits, 16); + if (codePoint <= 0x10ffff && !(codePoint >= 0xd800 && codePoint <= 0xdfff)) { + decoded += String.fromCodePoint(codePoint); + index += width + 1; + continue; + } + } + decoded += `\\${escape}`; + index += 1; + } + return decoded; } function yamlScalarAnchors(text) { @@ -540,6 +691,62 @@ function yamlFlowMappingValues(text, key) { .map((entry) => entry.value); } +function yamlDirectFlowMappingEntries(value) { + const openingBraceIndex = value.search(/\S/u); + if (openingBraceIndex < 0 || value[openingBraceIndex] !== "{") return []; + return yamlFlowMappingEntriesAt(value, openingBraceIndex); +} + +function yamlDirectFlowMappingValues(value, key) { + return yamlDirectFlowMappingEntries(value) + .filter((entry) => entry.key.toLowerCase() === key.toLowerCase()) + .map((entry) => entry.value); +} + +function yamlFlowSequenceValues(value) { + const normalized = value.trim(); + if (!normalized.startsWith("[")) return undefined; + const values = []; + let braces = 0; + let brackets = 0; + let itemStart = 1; + let quote; + for (let cursor = 1; cursor < normalized.length; cursor += 1) { + const character = normalized[cursor]; + if (quote) { + if (quote === "'" && character === "'" && normalized[cursor + 1] === "'") { + cursor += 1; + } else if (quote === "\"" && character === "\\") { + cursor += 1; + } else if (character === quote) { + quote = undefined; + } + continue; + } + if (character === "'" || character === "\"") { + quote = character; + } else if (character === "{") { + braces += 1; + } else if (character === "}") { + braces -= 1; + } else if (character === "[") { + brackets += 1; + } else if (character === "]") { + if (braces === 0 && brackets === 0) { + const item = normalized.slice(itemStart, cursor).trim(); + if (item.length > 0) values.push(item); + return normalized.slice(cursor + 1).trim().length === 0 ? values : undefined; + } + brackets -= 1; + } else if (character === "," && braces === 0 && brackets === 0) { + const item = normalized.slice(itemStart, cursor).trim(); + if (item.length > 0) values.push(item); + itemStart = cursor + 1; + } + } + return undefined; +} + function yamlFlowMappings(text) { text = maskYamlBlockScalarBodies(text); const mappings = []; @@ -578,10 +785,6 @@ function yamlRootFlowMappingValues(text, key) { .map((entry) => entry.value); } -function yamlFlowMappingHasKey(value, key) { - return yamlFlowMappingValues(value, key).length > 0; -} - function isYamlFlowMappingStart(text, index) { for (let cursor = index - 1; cursor >= 0; cursor -= 1) { if (/\s/u.test(text[cursor])) continue; @@ -665,7 +868,9 @@ function readYamlFlowKey(text, startIndex) { } if (text[cursor] === quote) { return { - key: text.slice(startIndex + 1, cursor), + key: quote === "\"" + ? decodeYamlDoubleQuotedScalar(text.slice(startIndex + 1, cursor)) + : text.slice(startIndex + 1, cursor).replaceAll("''", "'"), nextIndex: cursor + 1, }; } @@ -689,7 +894,7 @@ function hasUntrustedPullRequestCheckout(text, scalarAnchors) { continue; } const refs = yamlFlowMappingValues(withEntry.value, "ref"); - if (refs.some((ref) => isUntrustedPullRequestRef(yamlScalarValue(ref)))) { + if (refs.some((ref) => isUntrustedPullRequestRef(resolveYamlScalarValue(ref, scalarAnchors)))) { return true; } } @@ -699,9 +904,11 @@ function hasUntrustedPullRequestCheckout(text, scalarAnchors) { if (!sequenceItem) continue; const itemIndentation = sequenceItem[1].length; const flowUses = yamlFlowMappingValue(sequenceItem[2], "uses"); + const flowRefs = yamlFlowMappingValues(sequenceItem[2], "ref"); if (flowUses && /^actions\/checkout@/iu.test(resolveYamlScalarValue(flowUses, scalarAnchors)) - && yamlFlowMappingHasKey(sequenceItem[2], "ref") - && isUntrustedPullRequestRef(sequenceItem[2])) { + && flowRefs.some((ref) => isUntrustedPullRequestRef( + resolveYamlScalarValue(ref, scalarAnchors), + ))) { return true; } const entries = []; @@ -727,13 +934,17 @@ function hasUntrustedPullRequestCheckout(text, scalarAnchors) { && entry.key.toLowerCase() === "with"); if (withIndex < 0) continue; const withEntry = entries[withIndex]; - if (yamlValueContainsToken(withEntry.value, "ref") && isUntrustedPullRequestRef(withEntry.value)) { + if (yamlFlowMappingValues(withEntry.value, "ref").some((ref) => isUntrustedPullRequestRef( + resolveYamlScalarValue(ref, scalarAnchors), + ))) { return true; } for (let entryIndex = withIndex + 1; entryIndex < entries.length; entryIndex += 1) { const entry = entries[entryIndex]; if (entry.indentation <= mappingIndentation) break; - if (entry.key.toLowerCase() === "ref" && isUntrustedPullRequestRef(entry.value)) { + if (entry.key.toLowerCase() === "ref" && isUntrustedPullRequestRef( + resolveYamlScalarValue(entry.value, scalarAnchors), + )) { return true; } } @@ -1014,8 +1225,10 @@ function refGlobCharacterClass(pattern, openingIndex) { if (pattern[closingIndex] === "]") closingIndex += 1; while (closingIndex < pattern.length && pattern[closingIndex] !== "]") closingIndex += 1; if (closingIndex >= pattern.length) return undefined; - const content = pattern.slice(openingIndex + 1, closingIndex); - if (content.length === 0) return undefined; + const rawContent = pattern.slice(openingIndex + 1, closingIndex); + if (rawContent.length === 0) return undefined; + const negated = rawContent.startsWith("!") && rawContent.length > 1; + const content = negated ? rawContent.slice(1) : rawContent; let expression = ""; for (let index = 0; index < content.length; index += 1) { const character = content[index]; @@ -1030,7 +1243,7 @@ function refGlobCharacterClass(pattern, openingIndex) { } return { closingIndex, - expression: `(?=[^/])[${expression}]`, + expression: `(?=[^/])[${negated ? "^" : ""}${expression}]`, }; } diff --git a/public-source-release-audit/tests/public-source-release-audit.test.mjs b/public-source-release-audit/tests/public-source-release-audit.test.mjs index 0023a40..aea9dc6 100644 --- a/public-source-release-audit/tests/public-source-release-audit.test.mjs +++ b/public-source-release-audit/tests/public-source-release-audit.test.mjs @@ -355,6 +355,44 @@ test("quoted permissions keys and write-all values are release-blocking", () => assertFinding(audit.result, "workflow-write-all", ".github/workflows/quoted-permissions-key.yml"); }); +test("only workflow and job permissions grant token access", () => { + const repoRoot = makeRepository(); + write(repoRoot, ".github/workflows/action-inputs.yml", [ + "name: action inputs", + "on: push", + "permissions: read-all", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - { uses: 'example/action@" + "a".repeat(40) + "', with: { permissions: write-all, runs-on: self-hosted } }", + "", + ].join("\n")); + write(repoRoot, ".github/workflows/job-permissions.yml", [ + "name: job permissions", + "on: push", + "permissions: read-all", + "jobs:", + " inspect:", + " permissions: write-all", + " runs-on: ubuntu-latest", + "", + ].join("\n")); + write( + repoRoot, + ".github/workflows/flow-job-permissions.yml", + "{name: flow job permissions, on: push, permissions: read-all, jobs: {inspect: {permissions: write-all, runs-on: ubuntu-latest}}}\n", + ); + commitAll(repoRoot, "add scoped permission workflows"); + + const audit = runAudit(repoRoot, ["--fail-on-warning"]); + + assert.equal(audit.status, 1); + assertFinding(audit.result, "workflow-write-all", ".github/workflows/job-permissions.yml"); + assertFinding(audit.result, "workflow-write-all", ".github/workflows/flow-job-permissions.yml"); + assert.equal(audit.result.findings.some((finding) => finding.path === ".github/workflows/action-inputs.yml"), false); +}); + test("block-scalar write-all permissions are release-blocking", () => { const repoRoot = makeRepository(); write(repoRoot, ".github/workflows/block-permissions.yml", [ @@ -496,6 +534,32 @@ test("aliased checkout actions remain guarded", () => { assert.equal(audit.result.findings.some((finding) => finding.ruleId === "workflow-mutable-action-ref"), false); }); +test("aliased checkout refs remain guarded", () => { + const repoRoot = makeRepository(); + const expression = ["$", "{{ github.head_ref }}"].join(""); + write(repoRoot, ".github/workflows/aliased-checkout-ref.yml", [ + "name: aliased checkout ref", + "on: pull_request_target", + "permissions: read-all", + "env:", + " PR_HEAD: &pr-head " + expression, + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - uses: actions/checkout@" + "a".repeat(40), + " with:", + " ref: *pr-head", + "", + ].join("\n")); + commitAll(repoRoot, "add aliased checkout ref workflow"); + + const audit = runAudit(repoRoot); + + assert.equal(audit.status, 1); + assertFinding(audit.result, "workflow-privileged-untrusted-checkout", ".github/workflows/aliased-checkout-ref.yml"); +}); + test("block scalar script bodies are not parsed as workflow mappings", () => { const repoRoot = makeRepository(); write(repoRoot, ".github/workflows/generated-yaml.yml", [ @@ -520,6 +584,107 @@ test("block scalar script bodies are not parsed as workflow mappings", () => { assert.equal(audit.result.findingCount, 0); }); +test("nested trigger filters are not treated as enabled events", () => { + const repoRoot = makeRepository(); + const expression = ["$", "{{ github.head_ref }}"].join(""); + write(repoRoot, ".github/workflows/push-filter.yml", [ + "name: push filter", + "on:", + " push:", + " branches: [pull_request_target]", + "permissions: read-all", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - uses: actions/checkout@" + "a".repeat(40), + " with:", + " ref: " + expression, + "", + ].join("\n")); + commitAll(repoRoot, "add nested trigger filter workflow"); + + const audit = runAudit(repoRoot, ["--fail-on-warning"]); + + assert.equal(audit.status, 0); + assert.equal(audit.result.findingCount, 0); +}); + +test("mapping properties do not hide privileged trigger keys", () => { + const repoRoot = makeRepository(); + const expression = ["$", "{{ github.head_ref }}"].join(""); + write(repoRoot, ".github/workflows/anchored-trigger-map.yml", [ + "name: anchored trigger map", + "on: &events", + " pull_request_target:", + "permissions: read-all", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - uses: actions/checkout@" + "a".repeat(40), + " with:", + " ref: " + expression, + "", + ].join("\n")); + commitAll(repoRoot, "add anchored trigger mapping workflow"); + + const audit = runAudit(repoRoot); + + assert.equal(audit.status, 1); + assertFinding(audit.result, "workflow-privileged-untrusted-checkout", ".github/workflows/anchored-trigger-map.yml"); +}); + +test("YAML double-quoted escapes cannot hide guarded values", () => { + const repoRoot = makeRepository(); + const expression = ["$", "{{ github\\u002ehead_ref }}"].join(""); + write(repoRoot, ".github/workflows/escaped-values.yml", [ + "name: escaped values", + "\"o\\u006e\": \"pull\\u005frequest_target\"", + "\"permiss\\u0069ons\": \"write\\u002dall\"", + "jobs:", + " inspect:", + " \"runs\\u002don\": \"self\\u002dhosted\"", + " steps:", + " - uses: actions/checkout@" + "a".repeat(40), + " with:", + " \"r\\u0065f\": \"" + expression + "\"", + "", + ].join("\n")); + commitAll(repoRoot, "add escaped guarded values workflow"); + + const audit = runAudit(repoRoot); + + assert.equal(audit.status, 1); + assertFinding(audit.result, "workflow-write-all", ".github/workflows/escaped-values.yml"); + assertFinding(audit.result, "workflow-self-hosted-runner", ".github/workflows/escaped-values.yml"); + assertFinding(audit.result, "workflow-pull-request-target", ".github/workflows/escaped-values.yml"); + assertFinding(audit.result, "workflow-privileged-untrusted-checkout", ".github/workflows/escaped-values.yml"); +}); + +test("workflow entrypoint paths are case-sensitive", () => { + const directoryRepo = makeRepository(); + write(directoryRepo, ".GITHUB/workflows/lookalike.yml", [ + "on: push", + "permissions: write-all", + "jobs: { unsafe: { runs-on: self-hosted } }", + "", + ].join("\n")); + commitAll(directoryRepo, "add lookalike workflow directory"); + + const extensionRepo = makeRepository(); + write(extensionRepo, ".github/workflows/lookalike.YML", [ + "on: push", + "permissions: write-all", + "jobs: { unsafe: { runs-on: self-hosted } }", + "", + ].join("\n")); + commitAll(extensionRepo, "add lookalike workflow extension"); + + assert.equal(runAudit(directoryRepo, ["--fail-on-warning"]).status, 0); + assert.equal(runAudit(extensionRepo, ["--fail-on-warning"]).status, 0); +}); + test("quoted runs-on keys cannot hide self-hosted labels", () => { const repoRoot = makeRepository(); write(repoRoot, ".github/workflows/quoted-runner-key.yml", [ @@ -918,6 +1083,30 @@ test("ruleset character classes match default branch segments", () => { assert.equal(audit.result.passed, true); }); +test("ruleset negated character classes exclude default branches", () => { + const repoRoot = makeRepository(); + writeSafeWorkflow(repoRoot); + commitAll(repoRoot, "add safe workflow"); + const excludedSnapshot = githubSnapshot(); + excludedSnapshot.repository.default_branch = "release/0"; + excludedSnapshot.rulesets[0].conditions.ref_name.include = ["refs/heads/release/[!0]*"]; + const includedSnapshot = structuredClone(excludedSnapshot); + includedSnapshot.repository.default_branch = "release/1"; + + const excludedAudit = runAudit(repoRoot, [ + "--github-snapshot", writeSnapshot(excludedSnapshot), + "--required-check", "verify", + ]); + const includedAudit = runAudit(repoRoot, [ + "--github-snapshot", writeSnapshot(includedSnapshot), + "--required-check", "verify", + ]); + + assert.equal(excludedAudit.status, 1); + assertFinding(excludedAudit.result, "github-required-check-missing"); + assert.equal(includedAudit.status, 0); +}); + test("live GitHub evidence consumes every ruleset page", () => { const repoRoot = makeRepository(); writeSafeWorkflow(repoRoot); From 1171471077d705118c725470e2611e0e2458c9f8 Mon Sep 17 00:00:00 2001 From: Vladimir Date: Sat, 22 Aug 2026 02:53:31 +0800 Subject: [PATCH 10/37] Cover reusable workflows and YAML edge cases --- .../scripts/public-source-release-audit.mjs | 270 ++++++++++++++++-- .../public-source-release-audit.test.mjs | 170 +++++++++++ 2 files changed, 421 insertions(+), 19 deletions(-) diff --git a/public-source-release-audit/scripts/public-source-release-audit.mjs b/public-source-release-audit/scripts/public-source-release-audit.mjs index a12830e..43fe497 100644 --- a/public-source-release-audit/scripts/public-source-release-audit.mjs +++ b/public-source-release-audit/scripts/public-source-release-audit.mjs @@ -170,6 +170,7 @@ function resolveRepositoryRoot(inputPath) { function auditWorkflowSources(repoRoot, { includeHistory = false } = {}) { const entries = trackedWorkflowEntries(repoRoot, { includeHistory }); const findings = []; + const workflowSources = []; for (const entry of entries) { if (entry.mode !== "100644" && entry.mode !== "100755") { @@ -199,12 +200,72 @@ function auditWorkflowSources(repoRoot, { includeHistory = false } = {}) { continue; } + workflowSources.push({ ...entry, text }); findings.push(...auditWorkflowText(entry.path, text, entry.source)); } + findings.push(...auditPrivilegedReusableWorkflowCalls(workflowSources)); + + return findings; +} + +function auditPrivilegedReusableWorkflowCalls(workflowSources) { + const sourceBySnapshotPath = new Map(workflowSources.map((source) => [ + `${source.snapshot}\0${source.path}`, + source, + ])); + const analysisBySource = new Map(); + const analysisFor = (source) => { + if (analysisBySource.has(source)) return analysisBySource.get(source); + const syntax = workflowSyntax(source.text); + const analysis = { + hasPrivilegedTrigger: syntax.triggerNames.some((name) => name.toLowerCase() === "pull_request_target"), + isReusable: syntax.triggerNames.some((name) => name.toLowerCase() === "workflow_call"), + localCalls: localReusableWorkflowPaths(syntax.uncommented, syntax.scalarAnchors), + untrustedCheckout: hasUntrustedPullRequestCheckout(syntax.uncommented, syntax.scalarAnchors), + }; + analysisBySource.set(source, analysis); + return analysis; + }; + const findings = []; + for (const caller of workflowSources) { + const callerAnalysis = analysisFor(caller); + if (!callerAnalysis.hasPrivilegedTrigger) continue; + const pending = callerAnalysis.localCalls.map((workflowPath) => ({ depth: 1, workflowPath })); + const visited = new Set(); + while (pending.length > 0) { + const { depth, workflowPath } = pending.pop(); + if (depth > 10 || visited.has(workflowPath)) continue; + visited.add(workflowPath); + const callee = sourceBySnapshotPath.get(`${caller.snapshot}\0${workflowPath}`); + if (!callee) continue; + const calleeAnalysis = analysisFor(callee); + if (!calleeAnalysis.isReusable) continue; + if (calleeAnalysis.untrustedCheckout) { + findings.push(workflowFinding({ + message: "A reusable workflow called from pull_request_target must not execute an untrusted PR checkout.", + path: callee.path, + ruleId: "workflow-privileged-untrusted-checkout", + severity: "error", + source: callee.source, + })); + } + pending.push(...calleeAnalysis.localCalls.map((nestedPath) => ({ + depth: depth + 1, + workflowPath: nestedPath, + }))); + } + } return findings; } +function localReusableWorkflowPaths(text, scalarAnchors) { + return workflowJobValues(text, "uses", scalarAnchors) + .map((value) => resolveYamlScalarValue(value, scalarAnchors)) + .filter((value) => /^\.\/\.github\/workflows\/[^/]+\.ya?ml$/u.test(value)) + .map((value) => value.slice(2)); +} + function trackedWorkflowEntries(repoRoot, { includeHistory = false } = {}) { const records = []; if (hasHead(repoRoot)) { @@ -221,23 +282,24 @@ function trackedWorkflowEntries(repoRoot, { includeHistory = false } = {}) { "git", ["ls-tree", "-r", "-z", "--full-tree", tree, "--", ".github/workflows"], { cwd: repoRoot, encoding: null }, - ).stdout).map((record) => ({ ...record, source: "history" }))); + ).stdout).map((record) => ({ ...record, snapshot: tree, source: "history" }))); } } + const headTree = resolveTreeObjectId(repoRoot, "HEAD"); records.push(...parseTreeRecords(runCommand("git", ["ls-tree", "-r", "-z", "--full-tree", "HEAD"], { cwd: repoRoot, encoding: null, - }).stdout).map((record) => ({ ...record, source: "tracked-file" }))); + }).stdout).map((record) => ({ ...record, snapshot: headTree ?? "HEAD", source: "tracked-file" }))); } records.push(...parseIndexRecords(runCommand("git", ["ls-files", "--stage", "-z"], { cwd: repoRoot, encoding: null, - }).stdout).map((record) => ({ ...record, source: "tracked-file" }))); + }).stdout).map((record) => ({ ...record, snapshot: "index", source: "tracked-file" }))); const unique = new Map(); for (const record of records) { if (WORKFLOW_PATH_PATTERN.test(record.path)) { - const key = `${record.path}\0${record.objectId}\0${record.mode}`; + const key = `${record.snapshot}\0${record.path}\0${record.objectId}\0${record.mode}`; if (unique.has(key) === false || record.source === "tracked-file") { unique.set(key, record); } @@ -291,11 +353,21 @@ function hasHead(repoRoot) { }).status === 0; } +function workflowSyntax(text) { + const normalized = text.replace(/\r\n?|\u0085|\u2028|\u2029/gu, "\n"); + const uncommented = stripYamlComments(normalized); + const scalarAnchors = yamlScalarAnchors(uncommented); + return { + scalarAnchors, + triggerNames: workflowTriggerNames(uncommented, scalarAnchors), + uncommented, + }; +} + function auditWorkflowText(workflowPath, text, source = "tracked-file") { const findings = []; - const uncommented = text.split(/\r?\n/u).map(stripYamlComment).join("\n"); - const scalarAnchors = yamlScalarAnchors(uncommented); - const hasPullRequestTarget = workflowTriggerNames(uncommented, scalarAnchors) + const { scalarAnchors, triggerNames, uncommented } = workflowSyntax(text); + const hasPullRequestTarget = triggerNames .some((eventName) => eventName.toLowerCase() === "pull_request_target"); const hasWriteAll = [ ...workflowRootValues(uncommented, "permissions"), @@ -388,6 +460,49 @@ function stripYamlComment(line) { return line; } +function stripYamlComments(text) { + const lines = text.split("\n"); + const blockScalarBodyLines = yamlBlockScalarBodyLineIndexes(lines.map(stripYamlComment)); + let singleQuoted = false; + let doubleQuoted = false; + return lines.map((line, lineIndex) => { + if (blockScalarBodyLines.has(lineIndex)) { + singleQuoted = false; + doubleQuoted = false; + return line; + } + let uncommented = ""; + for (let index = 0; index < line.length; index += 1) { + const character = line[index]; + if (singleQuoted) { + uncommented += character; + if (character === "'" && line[index + 1] === "'") { + uncommented += line[index + 1]; + index += 1; + } else if (character === "'") { + singleQuoted = false; + } + continue; + } + if (doubleQuoted) { + uncommented += character; + if (character === "\\" && line[index + 1] !== undefined) { + uncommented += line[index + 1]; + index += 1; + } else if (character === "\"") { + doubleQuoted = false; + } + continue; + } + if (character === "'") singleQuoted = true; + if (character === "\"") doubleQuoted = true; + if (character === "#" && (index === 0 || /\s/u.test(line[index - 1]))) break; + uncommented += character; + } + return uncommented; + }).join("\n"); +} + function parseYamlKeyLine(line) { const match = /^(\s*)(?:"((?:\\[^\r\n]|[^"\\\r\n])*)"|'((?:''|[^'\r\n])*)'|([A-Za-z0-9_-]+))\s*:\s*(.*)$/u.exec(line); if (!match) return undefined; @@ -411,6 +526,7 @@ function workflowRootValues(text, key) { function workflowJobValues(text, key, scalarAnchors) { const entries = yamlBlockMappingEntries(text); + const blockNodeAnchors = yamlBlockNodeAnchors(entries); const values = []; for (let index = 0; index < entries.length; index += 1) { const jobsEntry = entries[index]; @@ -419,6 +535,12 @@ function workflowJobValues(text, key, scalarAnchors) { values.push(...directBlockMappingChildren(entries, job.index) .filter(({ entry }) => entry.key.toLowerCase() === key.toLowerCase()) .map(({ entry }) => entry.value)); + values.push(...yamlBlockAliasMappingValues( + job.entry.inlineValue, + key, + blockNodeAnchors, + scalarAnchors, + )); values.push(...yamlDirectFlowMappingValues( resolveYamlScalarValue(job.entry.inlineValue, scalarAnchors), key, @@ -432,6 +554,42 @@ function workflowJobValues(text, key, scalarAnchors) { return values; } +function yamlBlockNodeAnchors(entries) { + const anchors = new Map(); + for (let index = 0; index < entries.length; index += 1) { + let value = entries[index].inlineValue.trim(); + let anchorName; + while (true) { + const property = /^(&([^\s]+)|![^\s]+)(?:\s+|$)/u.exec(value); + if (!property) break; + if (property[2]) anchorName = property[2]; + value = value.slice(property[0].length); + } + if (!anchorName) continue; + const children = directBlockMappingChildren(entries, index); + if (children.length > 0) anchors.set(anchorName, children); + } + return anchors; +} + +function yamlBlockAliasMappingValues(value, key, blockNodeAnchors, scalarAnchors) { + const visited = new Set(); + let current = yamlScalarValue(value); + while (true) { + const alias = /^\*([^\s]+)$/u.exec(current); + if (!alias || visited.has(alias[1])) return []; + visited.add(alias[1]); + const blockNode = blockNodeAnchors.get(alias[1]); + if (blockNode) { + return blockNode + .filter(({ entry }) => entry.key.toLowerCase() === key.toLowerCase()) + .map(({ entry }) => entry.value); + } + if (!scalarAnchors.has(alias[1])) return []; + current = yamlScalarValue(scalarAnchors.get(alias[1])); + } +} + function directBlockMappingChildren(entries, parentIndex) { const parent = entries[parentIndex]; const descendants = []; @@ -471,6 +629,17 @@ function workflowTriggerNames(text, scalarAnchors) { if (indentation <= entry.indentation) break; if (childIndentation === undefined) childIndentation = indentation; if (indentation !== childIndentation) continue; + if (/^[\[{]/u.test(line.trim())) { + let flowValue = line.trim(); + for (let continuation = cursor + 1; continuation < lines.length; continuation += 1) { + const continuationLine = lines[continuation]; + if (continuationLine.trim().length === 0) continue; + if (/^\s*/u.exec(continuationLine)[0].length <= entry.indentation) break; + flowValue += " " + continuationLine.trim(); + } + names.push(...workflowTriggerNamesFromValue(flowValue, scalarAnchors)); + break; + } const sequenceItem = /^(\s*)-\s*(.*)$/u.exec(line); if (sequenceItem) { names.push(...workflowTriggerNamesFromValue(sequenceItem[2], scalarAnchors)); @@ -517,7 +686,11 @@ function yamlBlockMappingEntries(text) { if (line.trim().length === 0) continue; const nextIndentation = /^\s*/u.exec(line)[0].length; if (nextIndentation <= entry.indentation) break; - value += " " + line.trim(); + if (yamlDoubleQuotedScalarContinues(value)) { + value = value.slice(0, -1) + line.trimStart(); + } else { + value += " " + line.trim(); + } } entries.push({ ...entry, @@ -528,6 +701,26 @@ function yamlBlockMappingEntries(text) { return entries; } +function yamlDoubleQuotedScalarContinues(value) { + if (!value.endsWith("\\")) return false; + let normalized = value.trimStart(); + let property = /^(?:&[^\s]+|![^\s]+)\s+/u.exec(normalized); + while (property) { + normalized = normalized.slice(property[0].length); + property = /^(?:&[^\s]+|![^\s]+)\s+/u.exec(normalized); + } + if (!normalized.startsWith("\"")) return false; + for (let index = 1; index < normalized.length; index += 1) { + if (normalized[index] === "\\") { + if (index === normalized.length - 1) return true; + index += 1; + } else if (normalized[index] === "\"") { + return false; + } + } + return false; +} + function yamlBlockScalarBodyLineIndexes(lines) { const bodyLines = new Set(); let scalarIndentation; @@ -620,6 +813,11 @@ function decodeYamlDoubleQuotedScalar(value) { continue; } const escape = value[index + 1]; + if (escape === "\n") { + index += 1; + while (index + 1 < value.length && /[ \t]/u.test(value[index + 1])) index += 1; + continue; + } if (simpleEscapes.has(escape)) { decoded += simpleEscapes.get(escape); index += 1; @@ -778,13 +976,37 @@ function yamlFlowMappingValue(value, key) { } function yamlRootFlowMappingValues(text, key) { - const openingBraceIndex = text.search(/\S/u); + const openingBraceIndex = yamlDocumentContentStart(text); if (openingBraceIndex < 0 || text[openingBraceIndex] !== "{") return []; return yamlFlowMappingEntriesAt(text, openingBraceIndex) .filter((entry) => entry.key.toLowerCase() === key.toLowerCase()) .map((entry) => entry.value); } +function yamlDocumentContentStart(text) { + let index = 0; + while (index < text.length) { + while (index < text.length && /\s/u.test(text[index])) index += 1; + if (text[index] === "\ufeff") { + index += 1; + continue; + } + if (text[index] === "%") { + const lineEnd = text.indexOf("\n", index); + if (lineEnd < 0) return -1; + index = lineEnd + 1; + continue; + } + if (text.startsWith("---", index) + && (text[index + 3] === undefined || /\s/u.test(text[index + 3]))) { + index += 3; + continue; + } + return index; + } + return -1; +} + function isYamlFlowMappingStart(text, index) { for (let cursor = index - 1; cursor >= 0; cursor -= 1) { if (/\s/u.test(text[cursor])) continue; @@ -893,8 +1115,7 @@ function hasUntrustedPullRequestCheckout(text, scalarAnchors) { || !/^actions\/checkout@/iu.test(resolveYamlScalarValue(usesEntry.value, scalarAnchors))) { continue; } - const refs = yamlFlowMappingValues(withEntry.value, "ref"); - if (refs.some((ref) => isUntrustedPullRequestRef(resolveYamlScalarValue(ref, scalarAnchors)))) { + if (hasUntrustedCheckoutInputs(withEntry.value, scalarAnchors)) { return true; } } @@ -904,11 +1125,8 @@ function hasUntrustedPullRequestCheckout(text, scalarAnchors) { if (!sequenceItem) continue; const itemIndentation = sequenceItem[1].length; const flowUses = yamlFlowMappingValue(sequenceItem[2], "uses"); - const flowRefs = yamlFlowMappingValues(sequenceItem[2], "ref"); if (flowUses && /^actions\/checkout@/iu.test(resolveYamlScalarValue(flowUses, scalarAnchors)) - && flowRefs.some((ref) => isUntrustedPullRequestRef( - resolveYamlScalarValue(ref, scalarAnchors), - ))) { + && hasUntrustedCheckoutInputs(sequenceItem[2], scalarAnchors)) { return true; } const entries = []; @@ -934,15 +1152,14 @@ function hasUntrustedPullRequestCheckout(text, scalarAnchors) { && entry.key.toLowerCase() === "with"); if (withIndex < 0) continue; const withEntry = entries[withIndex]; - if (yamlFlowMappingValues(withEntry.value, "ref").some((ref) => isUntrustedPullRequestRef( - resolveYamlScalarValue(ref, scalarAnchors), - ))) { + if (hasUntrustedCheckoutInputs(withEntry.value, scalarAnchors)) { return true; } for (let entryIndex = withIndex + 1; entryIndex < entries.length; entryIndex += 1) { const entry = entries[entryIndex]; if (entry.indentation <= mappingIndentation) break; - if (entry.key.toLowerCase() === "ref" && isUntrustedPullRequestRef( + if (isUntrustedCheckoutInput( + entry.key, resolveYamlScalarValue(entry.value, scalarAnchors), )) { return true; @@ -952,6 +1169,21 @@ function hasUntrustedPullRequestCheckout(text, scalarAnchors) { return false; } +function hasUntrustedCheckoutInputs(value, scalarAnchors) { + return ["ref", "repository"].some((key) => yamlFlowMappingValues(value, key) + .some((input) => isUntrustedCheckoutInput( + key, + resolveYamlScalarValue(input, scalarAnchors), + ))); +} + +function isUntrustedCheckoutInput(key, value) { + if (key.toLowerCase() === "repository") { + return /pull_request\.head\.repo(?:\.|\b)/iu.test(value); + } + return key.toLowerCase() === "ref" && isUntrustedPullRequestRef(value); +} + function isUntrustedPullRequestRef(value) { return /(?:github\.head_ref|pull_request\.(?:head|merge_commit_sha)|head\.sha|refs\/pull\/)/iu.test(value); } diff --git a/public-source-release-audit/tests/public-source-release-audit.test.mjs b/public-source-release-audit/tests/public-source-release-audit.test.mjs index aea9dc6..9cf2501 100644 --- a/public-source-release-audit/tests/public-source-release-audit.test.mjs +++ b/public-source-release-audit/tests/public-source-release-audit.test.mjs @@ -560,6 +560,51 @@ test("aliased checkout refs remain guarded", () => { assertFinding(audit.result, "workflow-privileged-untrusted-checkout", ".github/workflows/aliased-checkout-ref.yml"); }); +test("untrusted checkout repository inputs remain guarded", () => { + const repoRoot = makeRepository(); + const expression = ["$", "{{ github.event.pull_request.head.repo.full_name }}"].join(""); + write(repoRoot, ".github/workflows/fork-repository-checkout.yml", [ + "name: fork repository checkout", + "on: pull_request_target", + "permissions: read-all", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - uses: actions/checkout@" + "a".repeat(40), + " with:", + " repository: " + expression, + "", + ].join("\n")); + commitAll(repoRoot, "add fork repository checkout workflow"); + + const audit = runAudit(repoRoot); + + assert.equal(audit.status, 1); + assertFinding(audit.result, "workflow-privileged-untrusted-checkout", ".github/workflows/fork-repository-checkout.yml"); +}); + +test("block-node aliases used as jobs preserve guarded properties", () => { + const repoRoot = makeRepository(); + write(repoRoot, ".github/workflows/aliased-job.yml", [ + "name: aliased job", + "on: push", + "x-job: &unsafe-job", + " permissions: write-all", + " runs-on: self-hosted", + "jobs:", + " build: *unsafe-job", + "", + ].join("\n")); + commitAll(repoRoot, "add aliased job workflow"); + + const audit = runAudit(repoRoot); + + assert.equal(audit.status, 1); + assertFinding(audit.result, "workflow-write-all", ".github/workflows/aliased-job.yml"); + assertFinding(audit.result, "workflow-self-hosted-runner", ".github/workflows/aliased-job.yml"); +}); + test("block scalar script bodies are not parsed as workflow mappings", () => { const repoRoot = makeRepository(); write(repoRoot, ".github/workflows/generated-yaml.yml", [ @@ -635,6 +680,31 @@ test("mapping properties do not hide privileged trigger keys", () => { assertFinding(audit.result, "workflow-privileged-untrusted-checkout", ".github/workflows/anchored-trigger-map.yml"); }); +test("indented flow sequences preserve privileged trigger names", () => { + const repoRoot = makeRepository(); + const expression = ["$", "{{ github.head_ref }}"].join(""); + write(repoRoot, ".github/workflows/indented-flow-trigger.yml", [ + "name: indented flow trigger", + "on:", + " [pull_request_target]", + "permissions: read-all", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - uses: actions/checkout@" + "a".repeat(40), + " with:", + " ref: " + expression, + "", + ].join("\n")); + commitAll(repoRoot, "add indented flow trigger workflow"); + + const audit = runAudit(repoRoot); + + assert.equal(audit.status, 1); + assertFinding(audit.result, "workflow-privileged-untrusted-checkout", ".github/workflows/indented-flow-trigger.yml"); +}); + test("YAML double-quoted escapes cannot hide guarded values", () => { const repoRoot = makeRepository(); const expression = ["$", "{{ github\\u002ehead_ref }}"].join(""); @@ -662,6 +732,64 @@ test("YAML double-quoted escapes cannot hide guarded values", () => { assertFinding(audit.result, "workflow-privileged-untrusted-checkout", ".github/workflows/escaped-values.yml"); }); +test("escaped multiline YAML scalars cannot hide guarded values", () => { + const repoRoot = makeRepository(); + write(repoRoot, ".github/workflows/multiline-values.yml", [ + "name: multiline values", + "on: push", + ["permissions: \"write-", "\\"].join(""), + " all\" # folded permission", + "jobs:", + " inspect:", + [" runs-on: \"self-", "\\"].join(""), + " hosted\" # folded runner", + "", + ].join("\n")); + commitAll(repoRoot, "add multiline guarded values workflow"); + + const audit = runAudit(repoRoot); + + assert.equal(audit.status, 1); + assertFinding(audit.result, "workflow-write-all", ".github/workflows/multiline-values.yml"); + assertFinding(audit.result, "workflow-self-hosted-runner", ".github/workflows/multiline-values.yml"); +}); + +test("CR-only workflow lines preserve guarded mappings", () => { + const repoRoot = makeRepository(); + write(repoRoot, ".github/workflows/cr-only.yml", [ + "name: cr only", + "on: push", + "permissions: write-all", + "jobs:", + " inspect:", + " runs-on: self-hosted", + "", + ].join("\r")); + commitAll(repoRoot, "add CR-only workflow"); + + const audit = runAudit(repoRoot); + + assert.equal(audit.status, 1); + assertFinding(audit.result, "workflow-write-all", ".github/workflows/cr-only.yml"); + assertFinding(audit.result, "workflow-self-hosted-runner", ".github/workflows/cr-only.yml"); +}); + +test("document markers preserve root flow workflow checks", () => { + const repoRoot = makeRepository(); + write( + repoRoot, + ".github/workflows/document-flow.yml", + "%YAML 1.2\n--- {name: document flow, on: push, permissions: write-all, jobs: {build: {runs-on: self-hosted}}}\n", + ); + commitAll(repoRoot, "add marked flow workflow"); + + const audit = runAudit(repoRoot); + + assert.equal(audit.status, 1); + assertFinding(audit.result, "workflow-write-all", ".github/workflows/document-flow.yml"); + assertFinding(audit.result, "workflow-self-hosted-runner", ".github/workflows/document-flow.yml"); +}); + test("workflow entrypoint paths are case-sensitive", () => { const directoryRepo = makeRepository(); write(directoryRepo, ".GITHUB/workflows/lookalike.yml", [ @@ -685,6 +813,48 @@ test("workflow entrypoint paths are case-sensitive", () => { assert.equal(runAudit(extensionRepo, ["--fail-on-warning"]).status, 0); }); +test("privileged context propagates through local reusable workflows", () => { + const repoRoot = makeRepository(); + const expression = ["$", "{{ github.head_ref }}"].join(""); + write(repoRoot, ".github/workflows/caller.yml", [ + "name: caller", + "on: pull_request_target", + "permissions: read-all", + "jobs:", + " call:", + " uses: ./.github/workflows/middle.yml", + "", + ].join("\n")); + write(repoRoot, ".github/workflows/middle.yml", [ + "name: middle", + "on: workflow_call", + "permissions: read-all", + "jobs:", + " call:", + " uses: ./.github/workflows/reusable.yml", + "", + ].join("\n")); + write(repoRoot, ".github/workflows/reusable.yml", [ + "name: reusable", + "on: workflow_call", + "permissions: read-all", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - uses: actions/checkout@" + "a".repeat(40), + " with:", + " ref: " + expression, + "", + ].join("\n")); + commitAll(repoRoot, "add privileged reusable workflow chain"); + + const audit = runAudit(repoRoot); + + assert.equal(audit.status, 1); + assertFinding(audit.result, "workflow-privileged-untrusted-checkout", ".github/workflows/reusable.yml"); +}); + test("quoted runs-on keys cannot hide self-hosted labels", () => { const repoRoot = makeRepository(); write(repoRoot, ".github/workflows/quoted-runner-key.yml", [ From 3d78c377970c600b8529d59c903319ac2def83d3 Mon Sep 17 00:00:00 2001 From: Vladimir Date: Sat, 22 Aug 2026 03:18:35 +0800 Subject: [PATCH 11/37] Harden public audit review edge cases --- .../scripts/public-safety-audit-core.mjs | 2 +- .../scripts/public-source-release-audit.mjs | 356 ++++++++++++++---- .../public-source-release-audit.test.mjs | 123 ++++++ 3 files changed, 410 insertions(+), 71 deletions(-) diff --git a/public-source-release-audit/scripts/public-safety-audit-core.mjs b/public-source-release-audit/scripts/public-safety-audit-core.mjs index f208bca..9f14464 100644 --- a/public-source-release-audit/scripts/public-safety-audit-core.mjs +++ b/public-source-release-audit/scripts/public-safety-audit-core.mjs @@ -92,7 +92,7 @@ const STRICT_UTF8_TEXT_DECODER = new TextDecoder("utf-8", { fatal: true }); const WINDOWS_1252_TEXT_DECODER = new TextDecoder("windows-1252"); const AUTHORIZATION_BEARER_PREFIX_PATTERN = /Authorization:\s*Bearer/iu; const ACCESS_TOKEN_PATTERNS = [ - /Authorization:\s*Bearer\s+[A-Za-z0-9._~-]{16,}/iu, + /Authorization:\s*Bearer\s+[A-Za-z0-9._~+/=-]{16,}/iu, /(? name.toLowerCase() === "pull_request_target"), isReusable: syntax.triggerNames.some((name) => name.toLowerCase() === "workflow_call"), - localCalls: localReusableWorkflowPaths(syntax.uncommented, syntax.scalarAnchors), - untrustedCheckout: hasUntrustedPullRequestCheckout(syntax.uncommented, syntax.scalarAnchors), + localCalls: localReusableWorkflowCalls(syntax.uncommented, syntax.scalarAnchors), + syntax, }; analysisBySource.set(source, analysis); return analysis; @@ -231,17 +231,26 @@ function auditPrivilegedReusableWorkflowCalls(workflowSources) { for (const caller of workflowSources) { const callerAnalysis = analysisFor(caller); if (!callerAnalysis.hasPrivilegedTrigger) continue; - const pending = callerAnalysis.localCalls.map((workflowPath) => ({ depth: 1, workflowPath })); + const pending = callerAnalysis.localCalls.map((call) => ({ + depth: 1, + taintedBindings: reusableCallTaintedBindings(call, new Set()), + workflowPath: call.workflowPath, + })); const visited = new Set(); while (pending.length > 0) { - const { depth, workflowPath } = pending.pop(); - if (depth > 10 || visited.has(workflowPath)) continue; - visited.add(workflowPath); + const { depth, taintedBindings, workflowPath } = pending.pop(); + const stateKey = `${workflowPath}\0${[...taintedBindings].sort().join("\0")}`; + if (depth > 10 || visited.has(stateKey)) continue; + visited.add(stateKey); const callee = sourceBySnapshotPath.get(`${caller.snapshot}\0${workflowPath}`); if (!callee) continue; const calleeAnalysis = analysisFor(callee); if (!calleeAnalysis.isReusable) continue; - if (calleeAnalysis.untrustedCheckout) { + if (hasUntrustedPullRequestCheckout( + calleeAnalysis.syntax.uncommented, + calleeAnalysis.syntax.scalarAnchors, + taintedBindings, + )) { findings.push(workflowFinding({ message: "A reusable workflow called from pull_request_target must not execute an untrusted PR checkout.", path: callee.path, @@ -250,20 +259,104 @@ function auditPrivilegedReusableWorkflowCalls(workflowSources) { source: callee.source, })); } - pending.push(...calleeAnalysis.localCalls.map((nestedPath) => ({ + pending.push(...calleeAnalysis.localCalls.map((nestedCall) => ({ depth: depth + 1, - workflowPath: nestedPath, + taintedBindings: reusableCallTaintedBindings(nestedCall, taintedBindings), + workflowPath: nestedCall.workflowPath, }))); } } return findings; } -function localReusableWorkflowPaths(text, scalarAnchors) { - return workflowJobValues(text, "uses", scalarAnchors) - .map((value) => resolveYamlScalarValue(value, scalarAnchors)) - .filter((value) => /^\.\/\.github\/workflows\/[^/]+\.ya?ml$/u.test(value)) - .map((value) => value.slice(2)); +function localReusableWorkflowCalls(text, scalarAnchors) { + const calls = []; + for (const group of workflowJobPropertyGroups(text, scalarAnchors)) { + const bindings = [ + ...workflowJobMappingBindings(group, "with", "inputs", scalarAnchors), + ...workflowJobMappingBindings(group, "secrets", "secrets", scalarAnchors), + ]; + const inheritSecrets = group.properties + .filter(({ entry }) => entry.key.toLowerCase() === "secrets") + .some(({ entry }) => resolveYamlScalarValue( + entry.inlineValue ?? entry.value, + scalarAnchors, + ).toLowerCase() === "inherit"); + for (const { entry } of group.properties) { + if (entry.key.toLowerCase() !== "uses") continue; + const uses = resolveYamlScalarValue(entry.value, scalarAnchors); + if (!/^\.\/\.github\/workflows\/[^/]+\.ya?ml$/u.test(uses)) continue; + calls.push({ + bindings, + inheritSecrets, + workflowPath: uses.slice(2), + }); + } + } + return calls; +} + +function workflowJobMappingBindings(group, propertyName, namespace, scalarAnchors) { + const bindings = []; + for (const property of group.properties) { + if (property.entry.key.toLowerCase() !== propertyName) continue; + const inlineValue = property.entry.inlineValue ?? property.entry.value; + const mappingEntries = [ + ...yamlDirectFlowMappingEntries(resolveYamlScalarValue(inlineValue, scalarAnchors)) + .map((entry) => ({ entry })), + ...(group.entries && property.index !== undefined + ? directBlockMappingChildren(group.entries, property.index) + : []), + ...yamlBlockAliasMappingEntries( + inlineValue, + group.blockNodeAnchors, + scalarAnchors, + ), + ]; + for (const { entry } of mappingEntries) { + bindings.push({ + name: entry.key.toLowerCase(), + namespace, + value: resolveYamlScalarValue(entry.value, scalarAnchors), + }); + } + } + return bindings; +} + +function reusableCallTaintedBindings(call, callerTaintedBindings) { + const taintedBindings = new Set(); + for (const binding of call.bindings) { + if (isUntrustedReusableValue(binding.value, callerTaintedBindings)) { + taintedBindings.add(`${binding.namespace}.${binding.name}`); + } + } + if (call.inheritSecrets) { + for (const binding of callerTaintedBindings) { + if (binding.startsWith("secrets.")) taintedBindings.add(binding); + } + } + return taintedBindings; +} + +function isUntrustedReusableValue(value, taintedBindings) { + return isUntrustedPullRequestRef(value) + || /pull_request\.head\.repo(?:\.|\b)/iu.test(value) + || valueReferencesTaintedBinding(value, taintedBindings); +} + +function valueReferencesTaintedBinding(value, taintedBindings) { + for (const binding of taintedBindings) { + const separator = binding.indexOf("."); + const namespace = binding.slice(0, separator); + const name = binding.slice(separator + 1); + const reference = new RegExp( + String.raw`\b${escapeRegExp(namespace)}\s*(?:\.\s*${escapeRegExp(name)}(?![A-Za-z0-9_-])|\[\s*["']${escapeRegExp(name)}["']\s*\])`, + "iu", + ); + if (reference.test(value)) return true; + } + return false; } function trackedWorkflowEntries(repoRoot, { includeHistory = false } = {}) { @@ -386,8 +479,8 @@ function auditWorkflowText(workflowPath, text, source = "tracked-file") { } for (const runner of workflowJobValues(uncommented, "runs-on", scalarAnchors)) { - const resolvedRunner = resolveYamlScalarValue(runner, scalarAnchors); - if (/(?:^|[\s,[{'"])self-hosted(?:$|[\s,\]}'"])/iu.test(resolvedRunner)) { + const runnerLabels = workflowRunnerLabels(runner, scalarAnchors); + if (runnerLabels.some((label) => yamlScalarValue(label).toLowerCase() === "self-hosted")) { findings.push(workflowFinding({ message: "Public workflows must not select a persistent self-hosted runner.", path: workflowPath, @@ -395,9 +488,9 @@ function auditWorkflowText(workflowPath, text, source = "tracked-file") { severity: "error", source, })); - } else if (/\$\{\{|^\s*\*/u.test(resolvedRunner) - || yamlValueContainsToken(resolvedRunner, "group") - || !isKnownGithubHostedRunnerLabel(yamlScalarValue(resolvedRunner))) { + } else if (runnerLabels.some((label) => /\$\{\{|^\s*\*/u.test(label) + || yamlValueContainsToken(label, "group") + || !isKnownGithubHostedRunnerLabel(yamlScalarValue(label)))) { findings.push(workflowFinding({ message: "Dynamic or runner-group selection requires proof that it cannot resolve to self-hosted.", path: workflowPath, @@ -515,6 +608,30 @@ function parseYamlKeyLine(line) { }; } +function parseYamlMappingEntryAt(lines, index) { + const directEntry = parseYamlKeyLine(lines[index]); + if (directEntry) return { entry: directEntry, valueLineIndex: index }; + + const explicitKey = /^(\s*)\?\s+(.+?)\s*$/u.exec(lines[index]); + if (!explicitKey) return undefined; + for (let cursor = index + 1; cursor < lines.length; cursor += 1) { + if (lines[cursor].trim().length === 0) continue; + const explicitValue = /^(\s*):\s*(.*)$/u.exec(lines[cursor]); + if (!explicitValue || explicitValue[1].length !== explicitKey[1].length) return undefined; + const key = yamlScalarValue(explicitKey[2]); + if (key.length === 0) return undefined; + return { + entry: { + indentation: explicitKey[1].length, + key, + value: explicitValue[2], + }, + valueLineIndex: cursor, + }; + } + return undefined; +} + function workflowRootValues(text, key) { return [ ...yamlBlockMappingEntries(text) @@ -525,33 +642,54 @@ function workflowRootValues(text, key) { } function workflowJobValues(text, key, scalarAnchors) { + return workflowJobPropertyGroups(text, scalarAnchors).flatMap(({ properties }) => properties + .filter(({ entry }) => entry.key.toLowerCase() === key.toLowerCase()) + .map(({ entry }) => entry.value)); +} + +function workflowJobPropertyGroups(text, scalarAnchors) { const entries = yamlBlockMappingEntries(text); const blockNodeAnchors = yamlBlockNodeAnchors(entries); - const values = []; + const groups = []; for (let index = 0; index < entries.length; index += 1) { const jobsEntry = entries[index]; if (jobsEntry.indentation !== 0 || jobsEntry.key.toLowerCase() !== "jobs") continue; for (const job of directBlockMappingChildren(entries, index)) { - values.push(...directBlockMappingChildren(entries, job.index) - .filter(({ entry }) => entry.key.toLowerCase() === key.toLowerCase()) - .map(({ entry }) => entry.value)); - values.push(...yamlBlockAliasMappingValues( + const properties = directBlockMappingChildren(entries, job.index); + if (properties.length > 0) { + groups.push({ blockNodeAnchors, entries, properties, text }); + } + const aliasProperties = yamlBlockAliasMappingEntries( job.entry.inlineValue, - key, blockNodeAnchors, scalarAnchors, - )); - values.push(...yamlDirectFlowMappingValues( + ); + if (aliasProperties.length > 0) { + groups.push({ blockNodeAnchors, entries, properties: aliasProperties, text }); + } + const flowProperties = yamlDirectFlowMappingEntries( resolveYamlScalarValue(job.entry.inlineValue, scalarAnchors), - key, - )); + ).map((entry) => ({ entry })); + if (flowProperties.length > 0) { + groups.push({ blockNodeAnchors, entries: undefined, properties: flowProperties, text }); + } } - values.push(...workflowJobValuesFromFlowJobs(jobsEntry.inlineValue, key, scalarAnchors)); + groups.push(...workflowJobPropertyGroupsFromFlowJobs( + jobsEntry.value, + scalarAnchors, + blockNodeAnchors, + text, + )); } for (const jobsValue of yamlRootFlowMappingValues(text, "jobs")) { - values.push(...workflowJobValuesFromFlowJobs(jobsValue, key, scalarAnchors)); + groups.push(...workflowJobPropertyGroupsFromFlowJobs( + jobsValue, + scalarAnchors, + blockNodeAnchors, + text, + )); } - return values; + return groups; } function yamlBlockNodeAnchors(entries) { @@ -572,7 +710,7 @@ function yamlBlockNodeAnchors(entries) { return anchors; } -function yamlBlockAliasMappingValues(value, key, blockNodeAnchors, scalarAnchors) { +function yamlBlockAliasMappingEntries(value, blockNodeAnchors, scalarAnchors) { const visited = new Set(); let current = yamlScalarValue(value); while (true) { @@ -580,11 +718,7 @@ function yamlBlockAliasMappingValues(value, key, blockNodeAnchors, scalarAnchors if (!alias || visited.has(alias[1])) return []; visited.add(alias[1]); const blockNode = blockNodeAnchors.get(alias[1]); - if (blockNode) { - return blockNode - .filter(({ entry }) => entry.key.toLowerCase() === key.toLowerCase()) - .map(({ entry }) => entry.value); - } + if (blockNode) return blockNode; if (!scalarAnchors.has(alias[1])) return []; current = yamlScalarValue(scalarAnchors.get(alias[1])); } @@ -603,11 +737,14 @@ function directBlockMappingChildren(entries, parentIndex) { return descendants.filter(({ entry }) => entry.indentation === childIndentation); } -function workflowJobValuesFromFlowJobs(value, key, scalarAnchors) { +function workflowJobPropertyGroupsFromFlowJobs(value, scalarAnchors, blockNodeAnchors, text) { const jobsValue = resolveYamlScalarValue(value, scalarAnchors); return yamlDirectFlowMappingEntries(jobsValue).flatMap((jobEntry) => { const jobValue = resolveYamlScalarValue(jobEntry.value, scalarAnchors); - return yamlDirectFlowMappingValues(jobValue, key); + const properties = yamlDirectFlowMappingEntries(jobValue).map((entry) => ({ entry })); + return properties.length > 0 + ? [{ blockNodeAnchors, entries: undefined, properties, text }] + : []; }); } @@ -615,14 +752,15 @@ function workflowTriggerNames(text, scalarAnchors) { const lines = maskYamlBlockScalarBodies(text).split("\n"); const names = []; for (let index = 0; index < lines.length; index += 1) { - const entry = parseYamlKeyLine(lines[index]); + const parsedEntry = parseYamlMappingEntryAt(lines, index); + const entry = parsedEntry?.entry; if (!entry || entry.indentation !== 0 || entry.key.toLowerCase() !== "on") continue; if (entry.value.trim().length > 0 && !yamlValueHasOnlyProperties(entry.value)) { names.push(...workflowTriggerNamesFromValue(entry.value, scalarAnchors)); continue; } let childIndentation; - for (let cursor = index + 1; cursor < lines.length; cursor += 1) { + for (let cursor = parsedEntry.valueLineIndex + 1; cursor < lines.length; cursor += 1) { const line = lines[cursor]; if (line.trim().length === 0) continue; const indentation = /^\s*/u.exec(line)[0].length; @@ -645,8 +783,11 @@ function workflowTriggerNames(text, scalarAnchors) { names.push(...workflowTriggerNamesFromValue(sequenceItem[2], scalarAnchors)); continue; } - const eventEntry = parseYamlKeyLine(line); - if (eventEntry) names.push(eventEntry.key); + const parsedEvent = parseYamlMappingEntryAt(lines, cursor); + if (parsedEvent) { + names.push(parsedEvent.entry.key); + cursor = parsedEvent.valueLineIndex; + } } } for (const onValue of yamlRootFlowMappingValues(text, "on")) { @@ -678,10 +819,11 @@ function yamlBlockMappingEntries(text) { const entries = []; for (let index = 0; index < lines.length; index += 1) { if (blockScalarBodyLines.has(index)) continue; - const entry = parseYamlKeyLine(lines[index]); - if (!entry) continue; + const parsedEntry = parseYamlMappingEntryAt(lines, index); + if (!parsedEntry) continue; + const { entry, valueLineIndex } = parsedEntry; let value = entry.value; - for (let cursor = index + 1; cursor < lines.length; cursor += 1) { + for (let cursor = valueLineIndex + 1; cursor < lines.length; cursor += 1) { const line = lines[cursor]; if (line.trim().length === 0) continue; const nextIndentation = /^\s*/u.exec(line)[0].length; @@ -695,7 +837,9 @@ function yamlBlockMappingEntries(text) { entries.push({ ...entry, inlineValue: entry.value.trim(), + lineIndex: index, value: value.trim(), + valueLineIndex, }); } return entries; @@ -734,9 +878,11 @@ function yamlBlockScalarBodyLineIndexes(lines) { } scalarIndentation = undefined; } - const entry = parseYamlStructuralKeyLine(line); - if (entry && isYamlBlockScalarHeader(entry.value)) { - scalarIndentation = entry.indentation; + const parsedEntry = parseYamlMappingEntryAt(lines, index); + const structuralEntry = parsedEntry?.entry ?? parseYamlStructuralKeyLine(line); + if (structuralEntry && isYamlBlockScalarHeader(structuralEntry.value)) { + scalarIndentation = structuralEntry.indentation; + if (parsedEntry) index = parsedEntry.valueLineIndex; } } return bodyLines; @@ -877,6 +1023,14 @@ function isKnownGithubHostedRunnerLabel(value) { return /^(?:ubuntu-(?:slim|latest|\d{2}\.\d{2})(?:-arm)?|windows-(?:latest|\d{4}(?:-vs\d{4})?|\d{2}(?:-vs\d{4})?-arm)|macos-(?:latest|\d{2})(?:-(?:intel|large|xlarge))?|xcode-\d{2}(?:-xlarge)?)$/iu.test(value); } +function workflowRunnerLabels(value, scalarAnchors) { + const resolved = resolveYamlScalarValue(value, scalarAnchors); + const sequenceValues = yamlFlowSequenceValues(resolved); + return sequenceValues + ? sequenceValues.flatMap((item) => workflowRunnerLabels(item, scalarAnchors)) + : [resolved]; +} + function yamlValueContainsToken(value, expectedToken) { return (value.match(/[A-Za-z0-9_-]+/gu) ?? []) .some((token) => token.toLowerCase() === expectedToken.toLowerCase()); @@ -1106,7 +1260,7 @@ function readYamlFlowKey(text, startIndex) { return key.length > 0 ? { key, nextIndex: cursor } : undefined; } -function hasUntrustedPullRequestCheckout(text, scalarAnchors) { +function hasUntrustedPullRequestCheckout(text, scalarAnchors, taintedBindings = new Set()) { text = maskYamlBlockScalarBodies(text); for (const entries of yamlFlowMappings(text)) { const usesEntry = entries.find((entry) => entry.key.toLowerCase() === "uses"); @@ -1115,7 +1269,7 @@ function hasUntrustedPullRequestCheckout(text, scalarAnchors) { || !/^actions\/checkout@/iu.test(resolveYamlScalarValue(usesEntry.value, scalarAnchors))) { continue; } - if (hasUntrustedCheckoutInputs(withEntry.value, scalarAnchors)) { + if (hasUntrustedCheckoutInputs(withEntry.value, scalarAnchors, taintedBindings)) { return true; } } @@ -1126,7 +1280,7 @@ function hasUntrustedPullRequestCheckout(text, scalarAnchors) { const itemIndentation = sequenceItem[1].length; const flowUses = yamlFlowMappingValue(sequenceItem[2], "uses"); if (flowUses && /^actions\/checkout@/iu.test(resolveYamlScalarValue(flowUses, scalarAnchors)) - && hasUntrustedCheckoutInputs(sequenceItem[2], scalarAnchors)) { + && hasUntrustedCheckoutInputs(sequenceItem[2], scalarAnchors, taintedBindings)) { return true; } const entries = []; @@ -1152,7 +1306,7 @@ function hasUntrustedPullRequestCheckout(text, scalarAnchors) { && entry.key.toLowerCase() === "with"); if (withIndex < 0) continue; const withEntry = entries[withIndex]; - if (hasUntrustedCheckoutInputs(withEntry.value, scalarAnchors)) { + if (hasUntrustedCheckoutInputs(withEntry.value, scalarAnchors, taintedBindings)) { return true; } for (let entryIndex = withIndex + 1; entryIndex < entries.length; entryIndex += 1) { @@ -1161,6 +1315,7 @@ function hasUntrustedPullRequestCheckout(text, scalarAnchors) { if (isUntrustedCheckoutInput( entry.key, resolveYamlScalarValue(entry.value, scalarAnchors), + taintedBindings, )) { return true; } @@ -1169,19 +1324,22 @@ function hasUntrustedPullRequestCheckout(text, scalarAnchors) { return false; } -function hasUntrustedCheckoutInputs(value, scalarAnchors) { +function hasUntrustedCheckoutInputs(value, scalarAnchors, taintedBindings) { return ["ref", "repository"].some((key) => yamlFlowMappingValues(value, key) .some((input) => isUntrustedCheckoutInput( key, resolveYamlScalarValue(input, scalarAnchors), + taintedBindings, ))); } -function isUntrustedCheckoutInput(key, value) { +function isUntrustedCheckoutInput(key, value, taintedBindings = new Set()) { + if (!["ref", "repository"].includes(key.toLowerCase())) return false; + if (valueReferencesTaintedBinding(value, taintedBindings)) return true; if (key.toLowerCase() === "repository") { return /pull_request\.head\.repo(?:\.|\b)/iu.test(value); } - return key.toLowerCase() === "ref" && isUntrustedPullRequestRef(value); + return isUntrustedPullRequestRef(value); } function isUntrustedPullRequestRef(value) { @@ -1190,20 +1348,78 @@ function isUntrustedPullRequestRef(value) { function actionReferences(text, scalarAnchors) { text = maskYamlBlockScalarBodies(text); - return text.split("\n").flatMap((line) => { - const sequenceItem = /^(\s*)-\s*(.*)$/u.exec(line); - const flowReference = yamlFlowMappingValue(sequenceItem?.[2] ?? line.trim(), "uses"); - if (flowReference !== undefined) { - return [resolveYamlScalarValue(flowReference, scalarAnchors)]; + return [ + ...workflowJobValues(text, "uses", scalarAnchors), + ...workflowStepUsesValues(text, scalarAnchors), + ].map((value) => resolveYamlScalarValue(value, scalarAnchors)); +} + +function workflowStepUsesValues(text, scalarAnchors) { + const values = []; + for (const group of workflowJobPropertyGroups(text, scalarAnchors)) { + for (const property of group.properties) { + if (property.entry.key.toLowerCase() !== "steps") continue; + const inlineValue = property.entry.inlineValue ?? property.entry.value; + const resolvedValue = resolveYamlScalarValue(inlineValue, scalarAnchors); + const flowSteps = yamlFlowSequenceValues(resolvedValue); + if (flowSteps) { + for (const step of flowSteps) { + const resolvedStep = resolveYamlScalarValue(step, scalarAnchors); + values.push(...yamlDirectFlowMappingValues(resolvedStep, "uses")); + } + } + if (group.entries && property.index !== undefined) { + values.push(...yamlBlockStepUsesValues(group.text, group.entries, property.entry, scalarAnchors)); + } } - const candidate = sequenceItem - ? " ".repeat(sequenceItem[1].length + 2) + sequenceItem[2] - : line; - const entry = parseYamlKeyLine(candidate); - return entry?.key.toLowerCase() === "uses" - ? [resolveYamlScalarValue(entry.value, scalarAnchors)] - : []; - }); + } + return values; +} + +function yamlBlockStepUsesValues(text, entries, stepsEntry, scalarAnchors) { + const lines = text.split("\n"); + const startLine = stepsEntry.valueLineIndex + 1; + let endLine = lines.length; + for (let index = startLine; index < lines.length; index += 1) { + if (lines[index].trim().length === 0) continue; + if (/^\s*/u.exec(lines[index])[0].length <= stepsEntry.indentation) { + endLine = index; + break; + } + } + const sequenceItems = []; + for (let index = startLine; index < endLine; index += 1) { + const match = /^(\s*)-\s*(.*)$/u.exec(lines[index]); + if (match) sequenceItems.push({ indentation: match[1].length, lineIndex: index, value: match[2] }); + } + if (sequenceItems.length === 0) return []; + const stepIndentation = Math.min(...sequenceItems.map((item) => item.indentation)); + const steps = sequenceItems.filter((item) => item.indentation === stepIndentation); + const values = []; + for (let stepIndex = 0; stepIndex < steps.length; stepIndex += 1) { + const step = steps[stepIndex]; + const nextLine = steps[stepIndex + 1]?.lineIndex ?? endLine; + const resolvedStep = resolveYamlScalarValue(step.value, scalarAnchors); + values.push(...yamlDirectFlowMappingValues(resolvedStep, "uses")); + + const inlineEntry = parseYamlKeyLine(" ".repeat(step.indentation + 2) + step.value); + if (inlineEntry?.key.toLowerCase() === "uses") values.push(inlineEntry.value); + + const continuationEntries = entries.filter((entry) => entry.lineIndex > step.lineIndex + && entry.lineIndex < nextLine + && entry.indentation > step.indentation); + const mappingIndentation = inlineEntry?.indentation + ?? (continuationEntries.length > 0 + ? Math.min(...continuationEntries.map((entry) => entry.indentation)) + : undefined); + if (mappingIndentation !== undefined) { + values.push(...continuationEntries + .filter((entry) => entry.indentation === mappingIndentation + && entry.key.toLowerCase() === "uses") + .map((entry) => entry.value)); + } + } + return values; } function workflowFinding({ message, path: workflowPath, ruleId, severity, source = "tracked-file" }) { diff --git a/public-source-release-audit/tests/public-source-release-audit.test.mjs b/public-source-release-audit/tests/public-source-release-audit.test.mjs index 9cf2501..bb8578b 100644 --- a/public-source-release-audit/tests/public-source-release-audit.test.mjs +++ b/public-source-release-audit/tests/public-source-release-audit.test.mjs @@ -85,6 +85,7 @@ test("credential and private-key formats are release-blocking", () => { write(repoRoot, "fine-grained.txt", fineGrainedToken("d")); write(repoRoot, "app-jwt.txt", githubAppJwt()); write(repoRoot, "bearer.txt", ["Authorization:", "Bearer", "b".repeat(32)].join(" ")); + write(repoRoot, "bearer-symbols.txt", ["Authorization: Bearer AAAA+", "BBBB/CCCC=DDDD\n"].join("")); write(repoRoot, "refresh.txt", ["ghr_", "r".repeat(76)].join("")); write(repoRoot, "private-key.txt", privateKeyBlock()); commitAll(repoRoot, "add credential fixtures"); @@ -96,6 +97,7 @@ test("credential and private-key formats are release-blocking", () => { assertFinding(audit.result, "access-token", "fine-grained.txt"); assertFinding(audit.result, "access-token", "app-jwt.txt"); assertFinding(audit.result, "access-token", "bearer.txt"); + assertFinding(audit.result, "access-token", "bearer-symbols.txt"); assertFinding(audit.result, "access-token", "refresh.txt"); assertFinding(audit.result, "private-key", "private-key.txt"); }); @@ -393,6 +395,28 @@ test("only workflow and job permissions grant token access", () => { assert.equal(audit.result.findings.some((finding) => finding.path === ".github/workflows/action-inputs.yml"), false); }); +test("explicit YAML mapping keys preserve guarded workflow settings", () => { + const repoRoot = makeRepository(); + write(repoRoot, ".github/workflows/explicit-keys.yml", [ + "name: explicit keys", + "on: push", + "? permissions", + ": write-all", + "jobs:", + " build:", + " ? runs-on", + " : self-hosted", + "", + ].join("\n")); + commitAll(repoRoot, "add explicit key workflow"); + + const audit = runAudit(repoRoot); + + assert.equal(audit.status, 1); + assertFinding(audit.result, "workflow-write-all", ".github/workflows/explicit-keys.yml"); + assertFinding(audit.result, "workflow-self-hosted-runner", ".github/workflows/explicit-keys.yml"); +}); + test("block-scalar write-all permissions are release-blocking", () => { const repoRoot = makeRepository(); write(repoRoot, ".github/workflows/block-permissions.yml", [ @@ -493,6 +517,24 @@ test("flow-style workflow mappings preserve guarded key checks", () => { assertFinding(audit.result, "workflow-self-hosted-runner", ".github/workflows/flow-document.yml"); }); +test("indented flow job mappings preserve guarded key checks", () => { + const repoRoot = makeRepository(); + write(repoRoot, ".github/workflows/indented-flow-jobs.yml", [ + "name: indented flow jobs", + "on: push", + "jobs:", + " {build: {permissions: write-all, runs-on: self-hosted}}", + "", + ].join("\n")); + commitAll(repoRoot, "add indented flow jobs workflow"); + + const audit = runAudit(repoRoot); + + assert.equal(audit.status, 1); + assertFinding(audit.result, "workflow-write-all", ".github/workflows/indented-flow-jobs.yml"); + assertFinding(audit.result, "workflow-self-hosted-runner", ".github/workflows/indented-flow-jobs.yml"); +}); + test("flow-style step sequences preserve privileged checkout checks", () => { const repoRoot = makeRepository(); const expression = ["$", "{{ github.head_ref }}"].join(""); @@ -855,6 +897,47 @@ test("privileged context propagates through local reusable workflows", () => { assertFinding(audit.result, "workflow-privileged-untrusted-checkout", ".github/workflows/reusable.yml"); }); +test("untrusted inputs propagate through local reusable workflows", () => { + const repoRoot = makeRepository(); + const headExpression = ["$", "{{ github.head_ref }}"].join(""); + const inputExpression = ["$", "{{ inputs.ref }}"].join(""); + write(repoRoot, ".github/workflows/input-caller.yml", [ + "name: input caller", + "on: pull_request_target", + "permissions: read-all", + "jobs:", + " call:", + " uses: ./.github/workflows/input-callee.yml", + " with:", + " ref: " + headExpression, + "", + ].join("\n")); + write(repoRoot, ".github/workflows/input-callee.yml", [ + "name: input callee", + "on:", + " workflow_call:", + " inputs:", + " ref:", + " required: true", + " type: string", + "permissions: read-all", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - uses: actions/checkout@" + "a".repeat(40), + " with:", + " ref: " + inputExpression, + "", + ].join("\n")); + commitAll(repoRoot, "add untrusted reusable input chain"); + + const audit = runAudit(repoRoot); + + assert.equal(audit.status, 1); + assertFinding(audit.result, "workflow-privileged-untrusted-checkout", ".github/workflows/input-callee.yml"); +}); + test("quoted runs-on keys cannot hide self-hosted labels", () => { const repoRoot = makeRepository(); write(repoRoot, ".github/workflows/quoted-runner-key.yml", [ @@ -891,6 +974,24 @@ test("aliased self-hosted runner labels remain release-blocking", () => { assertFinding(audit.result, "workflow-self-hosted-runner", ".github/workflows/aliased-runner.yml"); }); +test("aliases inside runner label sequences remain release-blocking", () => { + const repoRoot = makeRepository(); + write(repoRoot, ".github/workflows/aliased-runner-sequence.yml", [ + "name: &runner self-hosted", + "on: push", + "jobs:", + " unsafe:", + " runs-on: [*runner, linux]", + "", + ].join("\n")); + commitAll(repoRoot, "add aliased runner sequence workflow"); + + const audit = runAudit(repoRoot); + + assert.equal(audit.status, 1); + assertFinding(audit.result, "workflow-self-hosted-runner", ".github/workflows/aliased-runner-sequence.yml"); +}); + test("block-list privileged triggers detect reordered checkout inputs", () => { const repoRoot = makeRepository(); write(repoRoot, ".github/workflows/reordered-checkout.yml", [ @@ -1039,6 +1140,28 @@ test("quoted immutable flow-style action references remain trusted", () => { assert.equal(audit.result.warningCount, 0); }); +test("action inputs named uses are not dependency references", () => { + const repoRoot = makeRepository(); + write(repoRoot, ".github/workflows/uses-input.yml", [ + "name: uses input", + "on: push", + "permissions: read-all", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - uses: example/action@" + "a".repeat(40), + " with: { uses: arbitrary-input-value }", + "", + ].join("\n")); + commitAll(repoRoot, "add uses action input workflow"); + + const audit = runAudit(repoRoot, ["--fail-on-warning"]); + + assert.equal(audit.status, 0); + assert.equal(audit.result.warningCount, 0); +}); + test("runner-group selectors require proof of hosted isolation", () => { const repoRoot = makeRepository(); write(repoRoot, ".github/workflows/flow-runner-group.yml", [ From b0bbcb8fd18c52e5aec48a7acfe79d019ac69de4 Mon Sep 17 00:00:00 2001 From: Vladimir Date: Sat, 22 Aug 2026 03:37:56 +0800 Subject: [PATCH 12/37] Address exact-head Codex review findings --- .../scripts/public-source-release-audit.mjs | 385 +++++++++++++----- .../public-source-release-audit.test.mjs | 191 +++++++++ 2 files changed, 466 insertions(+), 110 deletions(-) diff --git a/public-source-release-audit/scripts/public-source-release-audit.mjs b/public-source-release-audit/scripts/public-source-release-audit.mjs index ea7ba7f..035ad0f 100644 --- a/public-source-release-audit/scripts/public-source-release-audit.mjs +++ b/public-source-release-audit/scripts/public-source-release-audit.mjs @@ -273,8 +273,8 @@ function localReusableWorkflowCalls(text, scalarAnchors) { const calls = []; for (const group of workflowJobPropertyGroups(text, scalarAnchors)) { const bindings = [ - ...workflowJobMappingBindings(group, "with", "inputs", scalarAnchors), - ...workflowJobMappingBindings(group, "secrets", "secrets", scalarAnchors), + ...workflowMappingBindings(group, "with", "inputs", scalarAnchors), + ...workflowMappingBindings(group, "secrets", "secrets", scalarAnchors), ]; const inheritSecrets = group.properties .filter(({ entry }) => entry.key.toLowerCase() === "secrets") @@ -296,7 +296,7 @@ function localReusableWorkflowCalls(text, scalarAnchors) { return calls; } -function workflowJobMappingBindings(group, propertyName, namespace, scalarAnchors) { +function workflowMappingBindings(group, propertyName, namespace, scalarAnchors) { const bindings = []; for (const property of group.properties) { if (property.entry.key.toLowerCase() !== propertyName) continue; @@ -304,9 +304,9 @@ function workflowJobMappingBindings(group, propertyName, namespace, scalarAnchor const mappingEntries = [ ...yamlDirectFlowMappingEntries(resolveYamlScalarValue(inlineValue, scalarAnchors)) .map((entry) => ({ entry })), - ...(group.entries && property.index !== undefined + ...(property.children ?? (group.entries && property.index !== undefined ? directBlockMappingChildren(group.entries, property.index) - : []), + : [])), ...yamlBlockAliasMappingEntries( inlineValue, group.blockNodeAnchors, @@ -324,6 +324,32 @@ function workflowJobMappingBindings(group, propertyName, namespace, scalarAnchor return bindings; } +function workflowRootMappingBindings(text, propertyName, namespace, scalarAnchors) { + const entries = yamlBlockMappingEntries(text); + const blockNodeAnchors = yamlBlockNodeAnchors(entries); + const groups = []; + for (let index = 0; index < entries.length; index += 1) { + const entry = entries[index]; + if (entry.indentation === 0 && entry.key.toLowerCase() === propertyName) { + groups.push({ blockNodeAnchors, entries, properties: [{ entry, index }], text }); + } + } + for (const value of yamlRootFlowMappingValues(text, propertyName)) { + groups.push({ + blockNodeAnchors, + entries: undefined, + properties: [{ entry: { key: propertyName, value } }], + text, + }); + } + return groups.flatMap((group) => workflowMappingBindings( + group, + propertyName, + namespace, + scalarAnchors, + )); +} + function reusableCallTaintedBindings(call, callerTaintedBindings) { const taintedBindings = new Set(); for (const binding of call.bindings) { @@ -447,12 +473,16 @@ function hasHead(repoRoot) { } function workflowSyntax(text) { - const normalized = text.replace(/\r\n?|\u0085|\u2028|\u2029/gu, "\n"); + const normalized = text + .replace(/\r\n?|\u0085|\u2028|\u2029/gu, "\n") + .replace(/^\ufeff/u, ""); const uncommented = stripYamlComments(normalized); const scalarAnchors = yamlScalarAnchors(uncommented); + const blockNodeAnchors = yamlBlockNodeAnchors(yamlBlockMappingEntries(uncommented)); return { + blockNodeAnchors, scalarAnchors, - triggerNames: workflowTriggerNames(uncommented, scalarAnchors), + triggerNames: workflowTriggerNames(uncommented, scalarAnchors, blockNodeAnchors), uncommented, }; } @@ -478,8 +508,7 @@ function auditWorkflowText(workflowPath, text, source = "tracked-file") { })); } - for (const runner of workflowJobValues(uncommented, "runs-on", scalarAnchors)) { - const runnerLabels = workflowRunnerLabels(runner, scalarAnchors); + for (const runnerLabels of workflowJobRunnerLabelSets(uncommented, scalarAnchors)) { if (runnerLabels.some((label) => yamlScalarValue(label).toLowerCase() === "self-hosted")) { findings.push(workflowFinding({ message: "Public workflows must not select a persistent self-hosted runner.", @@ -597,7 +626,7 @@ function stripYamlComments(text) { } function parseYamlKeyLine(line) { - const match = /^(\s*)(?:"((?:\\[^\r\n]|[^"\\\r\n])*)"|'((?:''|[^'\r\n])*)'|([A-Za-z0-9_-]+))\s*:\s*(.*)$/u.exec(line); + const match = /^(\s*)(?:(?:&[^\s]+|![^\s]+)\s+)*(?:"((?:\\[^\r\n]|[^"\\\r\n])*)"|'((?:''|[^'\r\n])*)'|([A-Za-z0-9_-]+))\s*:\s*(.*)$/u.exec(line); if (!match) return undefined; return { indentation: match[1].length, @@ -748,7 +777,7 @@ function workflowJobPropertyGroupsFromFlowJobs(value, scalarAnchors, blockNodeAn }); } -function workflowTriggerNames(text, scalarAnchors) { +function workflowTriggerNames(text, scalarAnchors, blockNodeAnchors) { const lines = maskYamlBlockScalarBodies(text).split("\n"); const names = []; for (let index = 0; index < lines.length; index += 1) { @@ -756,7 +785,7 @@ function workflowTriggerNames(text, scalarAnchors) { const entry = parsedEntry?.entry; if (!entry || entry.indentation !== 0 || entry.key.toLowerCase() !== "on") continue; if (entry.value.trim().length > 0 && !yamlValueHasOnlyProperties(entry.value)) { - names.push(...workflowTriggerNamesFromValue(entry.value, scalarAnchors)); + names.push(...workflowTriggerNamesFromValue(entry.value, scalarAnchors, blockNodeAnchors)); continue; } let childIndentation; @@ -775,12 +804,12 @@ function workflowTriggerNames(text, scalarAnchors) { if (/^\s*/u.exec(continuationLine)[0].length <= entry.indentation) break; flowValue += " " + continuationLine.trim(); } - names.push(...workflowTriggerNamesFromValue(flowValue, scalarAnchors)); + names.push(...workflowTriggerNamesFromValue(flowValue, scalarAnchors, blockNodeAnchors)); break; } const sequenceItem = /^(\s*)-\s*(.*)$/u.exec(line); if (sequenceItem) { - names.push(...workflowTriggerNamesFromValue(sequenceItem[2], scalarAnchors)); + names.push(...workflowTriggerNamesFromValue(sequenceItem[2], scalarAnchors, blockNodeAnchors)); continue; } const parsedEvent = parseYamlMappingEntryAt(lines, cursor); @@ -791,7 +820,7 @@ function workflowTriggerNames(text, scalarAnchors) { } } for (const onValue of yamlRootFlowMappingValues(text, "on")) { - names.push(...workflowTriggerNamesFromValue(onValue, scalarAnchors)); + names.push(...workflowTriggerNamesFromValue(onValue, scalarAnchors, blockNodeAnchors)); } return names; } @@ -800,7 +829,15 @@ function yamlValueHasOnlyProperties(value) { return /^(?:(?:&[^\s]+|![^\s]+)\s*)+$/u.test(value.trim()); } -function workflowTriggerNamesFromValue(value, scalarAnchors) { +function workflowTriggerNamesFromValue(value, scalarAnchors, blockNodeAnchors) { + const blockAliasEntries = yamlBlockAliasMappingEntries( + value, + blockNodeAnchors, + scalarAnchors, + ); + if (blockAliasEntries.length > 0) { + return blockAliasEntries.map(({ entry }) => entry.key); + } const resolved = resolveYamlScalarValue(value, scalarAnchors); const mappingEntries = yamlDirectFlowMappingEntries(resolved); if (resolved.trimStart().startsWith("{")) { @@ -808,7 +845,11 @@ function workflowTriggerNamesFromValue(value, scalarAnchors) { } const sequenceValues = yamlFlowSequenceValues(resolved); if (sequenceValues) { - return sequenceValues.flatMap((item) => workflowTriggerNamesFromValue(item, scalarAnchors)); + return sequenceValues.flatMap((item) => workflowTriggerNamesFromValue( + item, + scalarAnchors, + blockNodeAnchors, + )); } return [resolved]; } @@ -1031,6 +1072,47 @@ function workflowRunnerLabels(value, scalarAnchors) { : [resolved]; } +function workflowJobRunnerLabelSets(text, scalarAnchors) { + const runnerLabelSets = []; + for (const group of workflowJobPropertyGroups(text, scalarAnchors)) { + for (const property of group.properties) { + if (property.entry.key.toLowerCase() !== "runs-on") continue; + const inlineValue = property.entry.inlineValue ?? property.entry.value; + const blockValues = group.entries + && property.index !== undefined + && (inlineValue.length === 0 || yamlValueHasOnlyProperties(inlineValue)) + ? yamlBlockSequenceValues(group.text, property.entry) + : undefined; + const runnerValues = blockValues ?? [property.entry.value]; + runnerLabelSets.push(runnerValues.flatMap((value) => workflowRunnerLabels( + value, + scalarAnchors, + ))); + } + } + return runnerLabelSets; +} + +function yamlBlockSequenceValues(text, parentEntry) { + const lines = text.split("\n"); + const values = []; + for (let index = parentEntry.valueLineIndex + 1; index < lines.length; index += 1) { + const line = lines[index]; + if (line.trim().length === 0) continue; + const indentation = /^\s*/u.exec(line)[0].length; + if (indentation <= parentEntry.indentation) break; + const sequenceItem = /^(\s*)-\s*(.*)$/u.exec(line); + if (sequenceItem) { + values.push({ indentation: sequenceItem[1].length, value: sequenceItem[2] }); + } + } + if (values.length === 0) return undefined; + const itemIndentation = Math.min(...values.map((item) => item.indentation)); + return values + .filter((item) => item.indentation === itemIndentation) + .map((item) => item.value); +} + function yamlValueContainsToken(value, expectedToken) { return (value.match(/[A-Za-z0-9_-]+/gu) ?? []) .some((token) => token.toLowerCase() === expectedToken.toLowerCase()); @@ -1262,75 +1344,58 @@ function readYamlFlowKey(text, startIndex) { function hasUntrustedPullRequestCheckout(text, scalarAnchors, taintedBindings = new Set()) { text = maskYamlBlockScalarBodies(text); - for (const entries of yamlFlowMappings(text)) { - const usesEntry = entries.find((entry) => entry.key.toLowerCase() === "uses"); - const withEntry = entries.find((entry) => entry.key.toLowerCase() === "with"); - if (!usesEntry || !withEntry - || !/^actions\/checkout@/iu.test(resolveYamlScalarValue(usesEntry.value, scalarAnchors))) { - continue; - } - if (hasUntrustedCheckoutInputs(withEntry.value, scalarAnchors, taintedBindings)) { - return true; - } - } - const lines = text.split("\n"); - for (let index = 0; index < lines.length; index += 1) { - const sequenceItem = /^(\s*)-\s*(.*)$/u.exec(lines[index]); - if (!sequenceItem) continue; - const itemIndentation = sequenceItem[1].length; - const flowUses = yamlFlowMappingValue(sequenceItem[2], "uses"); - if (flowUses && /^actions\/checkout@/iu.test(resolveYamlScalarValue(flowUses, scalarAnchors)) - && hasUntrustedCheckoutInputs(sequenceItem[2], scalarAnchors, taintedBindings)) { - return true; - } - const entries = []; - if (sequenceItem[2].trim().length > 0) { - const inlineEntry = parseYamlKeyLine(" ".repeat(itemIndentation + 2) + sequenceItem[2]); - if (inlineEntry) entries.push(inlineEntry); - } - for (let cursor = index + 1; cursor < lines.length; cursor += 1) { - const line = lines[cursor]; - if (line.trim().length === 0) continue; - const indentation = /^\s*/u.exec(line)[0].length; - if (indentation <= itemIndentation) break; - const entry = parseYamlKeyLine(line); - if (entry) entries.push(entry); - } - if (entries.length === 0) continue; - const mappingIndentation = Math.min(...entries.map((entry) => entry.indentation)); - const usesEntry = entries.find((entry) => entry.indentation === mappingIndentation - && entry.key.toLowerCase() === "uses"); - if (!usesEntry - || !/^actions\/checkout@/iu.test(resolveYamlScalarValue(usesEntry.value, scalarAnchors))) continue; - const withIndex = entries.findIndex((entry) => entry.indentation === mappingIndentation - && entry.key.toLowerCase() === "with"); - if (withIndex < 0) continue; - const withEntry = entries[withIndex]; - if (hasUntrustedCheckoutInputs(withEntry.value, scalarAnchors, taintedBindings)) { - return true; - } - for (let entryIndex = withIndex + 1; entryIndex < entries.length; entryIndex += 1) { - const entry = entries[entryIndex]; - if (entry.indentation <= mappingIndentation) break; - if (isUntrustedCheckoutInput( - entry.key, + const rootTaintedBindings = environmentTaintedBindings( + workflowRootMappingBindings(text, "env", "env", scalarAnchors), + taintedBindings, + ); + for (const { jobGroup, stepGroup } of workflowStepPropertyGroups(text, scalarAnchors)) { + const usesCheckout = stepGroup.properties + .filter(({ entry }) => entry.key.toLowerCase() === "uses") + .some(({ entry }) => /^actions\/checkout@/iu.test( resolveYamlScalarValue(entry.value, scalarAnchors), - taintedBindings, - )) { - return true; - } + )); + if (!usesCheckout) continue; + const jobTaintedBindings = environmentTaintedBindings( + workflowMappingBindings(jobGroup, "env", "env", scalarAnchors), + rootTaintedBindings, + ); + const stepTaintedBindings = environmentTaintedBindings( + workflowMappingBindings(stepGroup, "env", "env", scalarAnchors), + jobTaintedBindings, + ); + const checkoutInputs = workflowMappingBindings( + stepGroup, + "with", + "with", + scalarAnchors, + ); + if (checkoutInputs.some((input) => isUntrustedCheckoutInput( + input.name, + input.value, + stepTaintedBindings, + ))) { + return true; } } return false; } -function hasUntrustedCheckoutInputs(value, scalarAnchors, taintedBindings) { - return ["ref", "repository"].some((key) => yamlFlowMappingValues(value, key) - .some((input) => isUntrustedCheckoutInput( - key, - resolveYamlScalarValue(input, scalarAnchors), - taintedBindings, - ))); +function environmentTaintedBindings(bindings, inheritedTaintedBindings) { + const taintedBindings = new Set(inheritedTaintedBindings); + for (const binding of bindings) taintedBindings.delete("env." + binding.name); + let changed; + do { + changed = false; + for (const binding of bindings) { + const name = "env." + binding.name; + if (!taintedBindings.has(name) + && isUntrustedReusableValue(binding.value, taintedBindings)) { + taintedBindings.add(name); + changed = true; + } + } + } while (changed); + return taintedBindings; } function isUntrustedCheckoutInput(key, value, taintedBindings = new Set()) { @@ -1355,28 +1420,72 @@ function actionReferences(text, scalarAnchors) { } function workflowStepUsesValues(text, scalarAnchors) { - const values = []; - for (const group of workflowJobPropertyGroups(text, scalarAnchors)) { - for (const property of group.properties) { + return workflowStepPropertyGroups(text, scalarAnchors).flatMap(({ stepGroup }) => ( + stepGroup.properties + .filter(({ entry }) => entry.key.toLowerCase() === "uses") + .map(({ entry }) => entry.value) + )); +} + +function workflowStepPropertyGroups(text, scalarAnchors) { + const stepGroups = []; + for (const jobGroup of workflowJobPropertyGroups(text, scalarAnchors)) { + for (const property of jobGroup.properties) { if (property.entry.key.toLowerCase() !== "steps") continue; const inlineValue = property.entry.inlineValue ?? property.entry.value; const resolvedValue = resolveYamlScalarValue(inlineValue, scalarAnchors); const flowSteps = yamlFlowSequenceValues(resolvedValue); if (flowSteps) { for (const step of flowSteps) { - const resolvedStep = resolveYamlScalarValue(step, scalarAnchors); - values.push(...yamlDirectFlowMappingValues(resolvedStep, "uses")); + const stepGroup = workflowStepPropertyGroupFromValue( + step, + jobGroup, + scalarAnchors, + ); + if (stepGroup) stepGroups.push({ jobGroup, stepGroup }); } } - if (group.entries && property.index !== undefined) { - values.push(...yamlBlockStepUsesValues(group.text, group.entries, property.entry, scalarAnchors)); + if (jobGroup.entries && property.index !== undefined) { + stepGroups.push(...workflowBlockStepPropertyGroups( + jobGroup, + property.entry, + scalarAnchors, + ).map((stepGroup) => ({ jobGroup, stepGroup }))); } } } - return values; + return stepGroups; +} + +function workflowStepPropertyGroupFromValue(value, parentGroup, scalarAnchors) { + const aliasProperties = yamlBlockAliasMappingEntries( + value, + parentGroup.blockNodeAnchors, + scalarAnchors, + ); + if (aliasProperties.length > 0) { + return { + blockNodeAnchors: parentGroup.blockNodeAnchors, + entries: parentGroup.entries, + properties: aliasProperties, + text: parentGroup.text, + }; + } + const resolvedValue = resolveYamlScalarValue(value, scalarAnchors); + const flowProperties = yamlDirectFlowMappingEntries(resolvedValue) + .map((entry) => ({ children: [], entry })); + return flowProperties.length > 0 + ? { + blockNodeAnchors: parentGroup.blockNodeAnchors, + entries: undefined, + properties: flowProperties, + text: parentGroup.text, + } + : undefined; } -function yamlBlockStepUsesValues(text, entries, stepsEntry, scalarAnchors) { +function workflowBlockStepPropertyGroups(jobGroup, stepsEntry, scalarAnchors) { + const { entries, text } = jobGroup; const lines = text.split("\n"); const startLine = stepsEntry.valueLineIndex + 1; let endLine = lines.length; @@ -1395,31 +1504,66 @@ function yamlBlockStepUsesValues(text, entries, stepsEntry, scalarAnchors) { if (sequenceItems.length === 0) return []; const stepIndentation = Math.min(...sequenceItems.map((item) => item.indentation)); const steps = sequenceItems.filter((item) => item.indentation === stepIndentation); - const values = []; + const groups = []; for (let stepIndex = 0; stepIndex < steps.length; stepIndex += 1) { const step = steps[stepIndex]; const nextLine = steps[stepIndex + 1]?.lineIndex ?? endLine; - const resolvedStep = resolveYamlScalarValue(step.value, scalarAnchors); - values.push(...yamlDirectFlowMappingValues(resolvedStep, "uses")); + const valueGroup = workflowStepPropertyGroupFromValue(step.value, jobGroup, scalarAnchors); + if (valueGroup) { + groups.push(valueGroup); + continue; + } const inlineEntry = parseYamlKeyLine(" ".repeat(step.indentation + 2) + step.value); - if (inlineEntry?.key.toLowerCase() === "uses") values.push(inlineEntry.value); - - const continuationEntries = entries.filter((entry) => entry.lineIndex > step.lineIndex + const continuationEntries = entries.flatMap((entry, index) => entry.lineIndex > step.lineIndex && entry.lineIndex < nextLine - && entry.indentation > step.indentation); + && entry.indentation > step.indentation + ? [{ entry, index }] + : []); const mappingIndentation = inlineEntry?.indentation ?? (continuationEntries.length > 0 - ? Math.min(...continuationEntries.map((entry) => entry.indentation)) + ? Math.min(...continuationEntries.map(({ entry }) => entry.indentation)) : undefined); - if (mappingIndentation !== undefined) { - values.push(...continuationEntries - .filter((entry) => entry.indentation === mappingIndentation - && entry.key.toLowerCase() === "uses") - .map((entry) => entry.value)); + if (mappingIndentation === undefined) continue; + const properties = continuationEntries + .filter(({ entry }) => entry.indentation === mappingIndentation); + if (inlineEntry) { + const inlineValue = inlineEntry.value.trim(); + properties.unshift({ + entry: { + ...inlineEntry, + inlineValue, + lineIndex: step.lineIndex, + value: inlineValue, + valueLineIndex: step.lineIndex, + }, + }); } + const scopedProperties = properties.map((property, propertyIndex) => { + const nextPropertyLine = properties[propertyIndex + 1]?.entry.lineIndex ?? nextLine; + const descendants = continuationEntries.filter(({ entry }) => ( + entry.lineIndex > property.entry.lineIndex + && entry.lineIndex < nextPropertyLine + && entry.indentation > property.entry.indentation + )); + const childIndentation = descendants.length > 0 + ? Math.min(...descendants.map(({ entry }) => entry.indentation)) + : undefined; + return { + ...property, + children: childIndentation === undefined + ? [] + : descendants.filter(({ entry }) => entry.indentation === childIndentation), + }; + }); + groups.push({ + blockNodeAnchors: jobGroup.blockNodeAnchors, + entries, + properties: scopedProperties, + text, + }); } - return values; + return groups; } function workflowFinding({ message, path: workflowPath, ruleId, severity, source = "tracked-file" }) { @@ -1536,21 +1680,30 @@ function auditGithubControls(evidence, requiredChecks) { && rulesetAppliesToDefaultBranch(ruleset, defaultBranch)); const rules = applicableRulesets.flatMap((ruleset) => Array.isArray(ruleset.rules) ? ruleset.rules : []); const statusRules = rules.filter((rule) => rule.type === "required_status_checks"); - const rulesetChecks = statusRules.flatMap((rule) => rule.parameters?.required_status_checks ?? []); - const classicChecks = classicStatusChecks(evidence.branchProtection); + const rulesetChecks = statusRules.flatMap((rule) => ( + rule.parameters?.required_status_checks ?? [] + ).map((check) => ({ + ...check, + strict: rule.parameters?.strict_required_status_checks_policy === true, + }))); + const classic = evidence.branchProtection; + const classicChecks = classicStatusChecks(classic).map((check) => ({ + ...check, + strict: classic?.required_status_checks?.strict === true, + })); const requiredContexts = new Set([...rulesetChecks, ...classicChecks] .map((check) => check.context) .filter((context) => typeof context === "string")); - const classic = evidence.branchProtection; const githubActionsContexts = new Set([ ...rulesetChecks .filter((check) => check.integration_id === GITHUB_ACTIONS_APP_ID), ...classicChecks .filter((check) => check.app_id === GITHUB_ACTIONS_APP_ID), ].map((check) => check.context).filter((context) => typeof context === "string")); - - const strictStatusChecks = statusRules.some((rule) => rule.parameters?.strict_required_status_checks_policy === true) - || classic?.required_status_checks?.strict === true; + const strictRequiredContexts = new Set([...rulesetChecks, ...classicChecks] + .filter((check) => check.strict) + .map((check) => check.context) + .filter((context) => typeof context === "string")); const forcePushProtected = rules.some((rule) => rule.type === "non_fast_forward") || classic?.allow_force_pushes?.enabled === false; const deletionProtected = rules.some((rule) => rule.type === "deletion") @@ -1577,7 +1730,19 @@ function auditGithubControls(evidence, requiredChecks) { )); } } - if (strictStatusChecks === false) { + const strictnessContexts = requiredChecks.length > 0 + ? requiredChecks.filter((context) => requiredContexts.has(context)) + : [...requiredContexts]; + const nonStrictContexts = strictnessContexts + .filter((context) => !strictRequiredContexts.has(context)); + for (const context of nonStrictContexts) { + findings.push(githubFinding( + "github-required-check-not-strict", + `Required status check ${context} does not enforce an up-to-date default branch.`, + context, + )); + } + if (nonStrictContexts.length === 0 && strictRequiredContexts.size === 0) { findings.push(githubFinding("github-required-check-not-strict", "Required checks must enforce an up-to-date default branch.")); } if (forcePushProtected === false) { diff --git a/public-source-release-audit/tests/public-source-release-audit.test.mjs b/public-source-release-audit/tests/public-source-release-audit.test.mjs index bb8578b..26fa937 100644 --- a/public-source-release-audit/tests/public-source-release-audit.test.mjs +++ b/public-source-release-audit/tests/public-source-release-audit.test.mjs @@ -417,6 +417,26 @@ test("explicit YAML mapping keys preserve guarded workflow settings", () => { assertFinding(audit.result, "workflow-self-hosted-runner", ".github/workflows/explicit-keys.yml"); }); +test("YAML node properties preceding keys preserve guarded workflow settings", () => { + const repoRoot = makeRepository(); + write(repoRoot, ".github/workflows/key-properties.yml", [ + "name: key properties", + "on: push", + "&permission-key permissions: write-all", + "jobs:", + " build:", + " &runner-key runs-on: self-hosted", + "", + ].join("\n")); + commitAll(repoRoot, "add key property workflow"); + + const audit = runAudit(repoRoot); + + assert.equal(audit.status, 1); + assertFinding(audit.result, "workflow-write-all", ".github/workflows/key-properties.yml"); + assertFinding(audit.result, "workflow-self-hosted-runner", ".github/workflows/key-properties.yml"); +}); + test("block-scalar write-all permissions are release-blocking", () => { const repoRoot = makeRepository(); write(repoRoot, ".github/workflows/block-permissions.yml", [ @@ -501,6 +521,33 @@ test("aliased privileged triggers remain guarded", () => { assertFinding(audit.result, "workflow-privileged-untrusted-checkout", ".github/workflows/aliased-trigger.yml"); }); +test("block-node aliases used as triggers remain guarded", () => { + const repoRoot = makeRepository(); + const expression = ["$", "{{ github.head_ref }}"].join(""); + write(repoRoot, ".github/workflows/block-aliased-trigger.yml", [ + "name: block aliased trigger", + "x-events: &events", + " pull_request_target:", + "on: *events", + "permissions: read-all", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - uses: actions/checkout@" + "a".repeat(40), + " with:", + " ref: " + expression, + "", + ].join("\n")); + commitAll(repoRoot, "add block aliased trigger workflow"); + + const audit = runAudit(repoRoot); + + assert.equal(audit.status, 1); + assertFinding(audit.result, "workflow-pull-request-target", ".github/workflows/block-aliased-trigger.yml"); + assertFinding(audit.result, "workflow-privileged-untrusted-checkout", ".github/workflows/block-aliased-trigger.yml"); +}); + test("flow-style workflow mappings preserve guarded key checks", () => { const repoRoot = makeRepository(); write( @@ -647,6 +694,32 @@ test("block-node aliases used as jobs preserve guarded properties", () => { assertFinding(audit.result, "workflow-self-hosted-runner", ".github/workflows/aliased-job.yml"); }); +test("block-node aliases used as complete steps remain guarded", () => { + const repoRoot = makeRepository(); + const expression = ["$", "{{ github.head_ref }}"].join(""); + write(repoRoot, ".github/workflows/aliased-step.yml", [ + "name: aliased step", + "on: pull_request_target", + "permissions: read-all", + "x-step: &unsafe-step", + " uses: actions/checkout@" + "a".repeat(40), + " with:", + " ref: " + expression, + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - *unsafe-step", + "", + ].join("\n")); + commitAll(repoRoot, "add aliased checkout step"); + + const audit = runAudit(repoRoot); + + assert.equal(audit.status, 1); + assertFinding(audit.result, "workflow-privileged-untrusted-checkout", ".github/workflows/aliased-step.yml"); +}); + test("block scalar script bodies are not parsed as workflow mappings", () => { const repoRoot = makeRepository(); write(repoRoot, ".github/workflows/generated-yaml.yml", [ @@ -816,6 +889,24 @@ test("CR-only workflow lines preserve guarded mappings", () => { assertFinding(audit.result, "workflow-self-hosted-runner", ".github/workflows/cr-only.yml"); }); +test("a UTF-8 BOM cannot hide root block mappings", () => { + const repoRoot = makeRepository(); + write(repoRoot, ".github/workflows/bom.yml", [ + "\ufeffpermissions: write-all", + "on: push", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + "", + ].join("\n")); + commitAll(repoRoot, "add BOM workflow"); + + const audit = runAudit(repoRoot); + + assert.equal(audit.status, 1); + assertFinding(audit.result, "workflow-write-all", ".github/workflows/bom.yml"); +}); + test("document markers preserve root flow workflow checks", () => { const repoRoot = makeRepository(); write( @@ -938,6 +1029,52 @@ test("untrusted inputs propagate through local reusable workflows", () => { assertFinding(audit.result, "workflow-privileged-untrusted-checkout", ".github/workflows/input-callee.yml"); }); +test("untrusted refs propagate through workflow job and step environments", () => { + const repoRoot = makeRepository(); + const headExpression = ["$", "{{ github.head_ref }}"].join(""); + const envExpression = ["$", "{{ env.PR_REF }}"].join(""); + const scopes = ["workflow", "job", "step"]; + for (const scope of scopes) { + const workflowEnv = scope === "workflow" + ? ["env:", " PR_REF: " + headExpression] + : []; + const jobEnv = scope === "job" + ? [" env:", " PR_REF: " + headExpression] + : []; + const stepEnv = scope === "step" + ? [" env:", " PR_REF: " + headExpression] + : []; + write(repoRoot, `.github/workflows/${scope}-env.yml`, [ + "name: " + scope + " env", + "on: pull_request_target", + "permissions: read-all", + ...workflowEnv, + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + ...jobEnv, + " steps:", + " - uses: actions/checkout@" + "a".repeat(40), + ...stepEnv, + " with:", + " ref: " + envExpression, + "", + ].join("\n")); + } + commitAll(repoRoot, "add environment checkout workflows"); + + const audit = runAudit(repoRoot); + + assert.equal(audit.status, 1); + for (const scope of scopes) { + assertFinding( + audit.result, + "workflow-privileged-untrusted-checkout", + `.github/workflows/${scope}-env.yml`, + ); + } +}); + test("quoted runs-on keys cannot hide self-hosted labels", () => { const repoRoot = makeRepository(); write(repoRoot, ".github/workflows/quoted-runner-key.yml", [ @@ -992,6 +1129,26 @@ test("aliases inside runner label sequences remain release-blocking", () => { assertFinding(audit.result, "workflow-self-hosted-runner", ".github/workflows/aliased-runner-sequence.yml"); }); +test("block-sequence runner labels remain release-blocking", () => { + const repoRoot = makeRepository(); + write(repoRoot, ".github/workflows/block-runner-sequence.yml", [ + "name: block runner sequence", + "on: push", + "jobs:", + " unsafe:", + " runs-on:", + " - self-hosted", + " - linux", + "", + ].join("\n")); + commitAll(repoRoot, "add block runner sequence workflow"); + + const audit = runAudit(repoRoot); + + assert.equal(audit.status, 1); + assertFinding(audit.result, "workflow-self-hosted-runner", ".github/workflows/block-runner-sequence.yml"); +}); + test("block-list privileged triggers detect reordered checkout inputs", () => { const repoRoot = makeRepository(); write(repoRoot, ".github/workflows/reordered-checkout.yml", [ @@ -1291,6 +1448,40 @@ test("a protected public GitHub snapshot passes", () => { assertFinding(unboundAudit.result, "github-required-check-not-github-actions"); }); +test("required-check strictness is associated with the supplying rule", () => { + const repoRoot = makeRepository(); + writeSafeWorkflow(repoRoot); + commitAll(repoRoot, "add safe workflow"); + const snapshot = githubSnapshot(); + snapshot.rulesets[0].rules[2].parameters.strict_required_status_checks_policy = false; + snapshot.rulesets.push({ + bypass_actors: [], + conditions: { ref_name: { exclude: [], include: ["~DEFAULT_BRANCH"] } }, + enforcement: "active", + rules: [{ + parameters: { + required_status_checks: [{ context: "lint", integration_id: 15368 }], + strict_required_status_checks_policy: true, + }, + type: "required_status_checks", + }], + target: "branch", + }); + + const audit = runAudit(repoRoot, [ + "--github-snapshot", writeSnapshot(snapshot), + "--required-check", "verify", + ]); + + assert.equal(audit.status, 1); + assertFinding(audit.result, "github-required-check-not-strict"); + assert.equal( + audit.result.findings.some((finding) => finding.ruleId === "github-required-check-not-strict" + && finding.check === "verify"), + true, + ); +}); + test("each missing requested status check remains a distinct finding", () => { const repoRoot = makeRepository(); writeSafeWorkflow(repoRoot); From 18bcd5ea4926de998c2fc21c71d7efaca19ca585 Mon Sep 17 00:00:00 2001 From: Vladimir Date: Sat, 22 Aug 2026 03:54:58 +0800 Subject: [PATCH 13/37] Close remaining public audit gaps --- .../scripts/public-source-release-audit.mjs | 227 +++++++++++++----- .../public-source-release-audit.test.mjs | 111 ++++++++- 2 files changed, 278 insertions(+), 60 deletions(-) diff --git a/public-source-release-audit/scripts/public-source-release-audit.mjs b/public-source-release-audit/scripts/public-source-release-audit.mjs index 035ad0f..5bb9b5f 100644 --- a/public-source-release-audit/scripts/public-source-release-audit.mjs +++ b/public-source-release-audit/scripts/public-source-release-audit.mjs @@ -219,7 +219,10 @@ function auditPrivilegedReusableWorkflowCalls(workflowSources) { if (analysisBySource.has(source)) return analysisBySource.get(source); const syntax = workflowSyntax(source.text); const analysis = { - hasPrivilegedTrigger: syntax.triggerNames.some((name) => name.toLowerCase() === "pull_request_target"), + hasPrivilegedTrigger: syntax.triggerNames.some((name) => [ + "pull_request_target", + "workflow_run", + ].includes(name.toLowerCase())), isReusable: syntax.triggerNames.some((name) => name.toLowerCase() === "workflow_call"), localCalls: localReusableWorkflowCalls(syntax.uncommented, syntax.scalarAnchors), syntax, @@ -252,7 +255,7 @@ function auditPrivilegedReusableWorkflowCalls(workflowSources) { taintedBindings, )) { findings.push(workflowFinding({ - message: "A reusable workflow called from pull_request_target must not execute an untrusted PR checkout.", + message: "A reusable workflow called from a privileged trigger must not execute an untrusted checkout.", path: callee.path, ruleId: "workflow-privileged-untrusted-checkout", severity: "error", @@ -300,22 +303,10 @@ function workflowMappingBindings(group, propertyName, namespace, scalarAnchors) const bindings = []; for (const property of group.properties) { if (property.entry.key.toLowerCase() !== propertyName) continue; - const inlineValue = property.entry.inlineValue ?? property.entry.value; - const mappingEntries = [ - ...yamlDirectFlowMappingEntries(resolveYamlScalarValue(inlineValue, scalarAnchors)) - .map((entry) => ({ entry })), - ...(property.children ?? (group.entries && property.index !== undefined - ? directBlockMappingChildren(group.entries, property.index) - : [])), - ...yamlBlockAliasMappingEntries( - inlineValue, - group.blockNodeAnchors, - scalarAnchors, - ), - ]; + const mappingEntries = workflowPropertyMappingEntries(group, property, scalarAnchors); for (const { entry } of mappingEntries) { bindings.push({ - name: entry.key.toLowerCase(), + name: resolveYamlScalarValue(entry.key, scalarAnchors).toLowerCase(), namespace, value: resolveYamlScalarValue(entry.value, scalarAnchors), }); @@ -324,8 +315,54 @@ function workflowMappingBindings(group, propertyName, namespace, scalarAnchors) return bindings; } +function workflowPropertyMappingEntries(group, property, scalarAnchors) { + const inlineValue = property.entry.inlineValue ?? property.entry.value; + return [ + ...yamlDirectFlowMappingEntries(resolveYamlScalarValue(inlineValue, scalarAnchors)) + .map((entry) => ({ entry })), + ...(property.children ?? (group.entries && property.index !== undefined + ? directBlockMappingChildren(group.entries, property.index) + : [])), + ...yamlBlockAliasMappingEntries( + inlineValue, + group.blockNodeAnchors, + scalarAnchors, + ), + ]; +} + +function workflowNestedMappingBindings( + group, + propertyName, + nestedPropertyName, + namespace, + scalarAnchors, +) { + const bindings = []; + for (const property of group.properties) { + if (property.entry.key.toLowerCase() !== propertyName) continue; + for (const nestedProperty of workflowPropertyMappingEntries(group, property, scalarAnchors)) { + const nestedKey = resolveYamlScalarValue(nestedProperty.entry.key, scalarAnchors); + if (nestedKey.toLowerCase() !== nestedPropertyName) continue; + bindings.push(...workflowMappingBindings( + { + ...group, + properties: [{ + ...nestedProperty, + entry: { ...nestedProperty.entry, key: nestedKey }, + }], + }, + nestedPropertyName, + namespace, + scalarAnchors, + )); + } + } + return bindings; +} + function workflowRootMappingBindings(text, propertyName, namespace, scalarAnchors) { - const entries = yamlBlockMappingEntries(text); + const entries = yamlBlockMappingEntries(text, scalarAnchors); const blockNodeAnchors = yamlBlockNodeAnchors(entries); const groups = []; for (let index = 0; index < entries.length; index += 1) { @@ -334,7 +371,7 @@ function workflowRootMappingBindings(text, propertyName, namespace, scalarAnchor groups.push({ blockNodeAnchors, entries, properties: [{ entry, index }], text }); } } - for (const value of yamlRootFlowMappingValues(text, propertyName)) { + for (const value of yamlRootFlowMappingValues(text, propertyName, scalarAnchors)) { groups.push({ blockNodeAnchors, entries: undefined, @@ -367,7 +404,7 @@ function reusableCallTaintedBindings(call, callerTaintedBindings) { function isUntrustedReusableValue(value, taintedBindings) { return isUntrustedPullRequestRef(value) - || /pull_request\.head\.repo(?:\.|\b)/iu.test(value) + || /(?:pull_request\.head\.repo|workflow_run\.head_repository)(?:\.|\b)/iu.test(value) || valueReferencesTaintedBinding(value, taintedBindings); } @@ -478,7 +515,7 @@ function workflowSyntax(text) { .replace(/^\ufeff/u, ""); const uncommented = stripYamlComments(normalized); const scalarAnchors = yamlScalarAnchors(uncommented); - const blockNodeAnchors = yamlBlockNodeAnchors(yamlBlockMappingEntries(uncommented)); + const blockNodeAnchors = yamlBlockNodeAnchors(yamlBlockMappingEntries(uncommented, scalarAnchors)); return { blockNodeAnchors, scalarAnchors, @@ -492,8 +529,10 @@ function auditWorkflowText(workflowPath, text, source = "tracked-file") { const { scalarAnchors, triggerNames, uncommented } = workflowSyntax(text); const hasPullRequestTarget = triggerNames .some((eventName) => eventName.toLowerCase() === "pull_request_target"); + const hasWorkflowRun = triggerNames + .some((eventName) => eventName.toLowerCase() === "workflow_run"); const hasWriteAll = [ - ...workflowRootValues(uncommented, "permissions"), + ...workflowRootValues(uncommented, "permissions", scalarAnchors), ...workflowJobValues(uncommented, "permissions", scalarAnchors), ] .some((value) => resolveYamlScalarValue(value, scalarAnchors).toLowerCase() === "write-all"); @@ -539,15 +578,17 @@ function auditWorkflowText(workflowPath, text, source = "tracked-file") { source, })); - if (hasUntrustedPullRequestCheckout(uncommented, scalarAnchors)) { - findings.push(workflowFinding({ - message: "A privileged pull_request_target workflow must not execute an untrusted PR checkout.", - path: workflowPath, - ruleId: "workflow-privileged-untrusted-checkout", - severity: "error", - source, - })); - } + } + + if ((hasPullRequestTarget || hasWorkflowRun) + && hasUntrustedPullRequestCheckout(uncommented, scalarAnchors)) { + findings.push(workflowFinding({ + message: "A privileged workflow must not execute an untrusted checkout.", + path: workflowPath, + ruleId: "workflow-privileged-untrusted-checkout", + severity: "error", + source, + })); } for (const actionRef of actionReferences(uncommented, scalarAnchors)) { @@ -626,14 +667,14 @@ function stripYamlComments(text) { } function parseYamlKeyLine(line) { - const match = /^(\s*)(?:(?:&[^\s]+|![^\s]+)\s+)*(?:"((?:\\[^\r\n]|[^"\\\r\n])*)"|'((?:''|[^'\r\n])*)'|([A-Za-z0-9_-]+))\s*:\s*(.*)$/u.exec(line); + const match = /^(\s*)(?:(?:&[^\s]+|![^\s]+)\s+)*(?:"((?:\\[^\r\n]|[^"\\\r\n])*)"|'((?:''|[^'\r\n])*)'|(\*[^\s:,{}\[\]]+)|([A-Za-z0-9_-]+))\s*:\s*(.*)$/u.exec(line); if (!match) return undefined; return { indentation: match[1].length, key: match[2] !== undefined ? decodeYamlDoubleQuotedScalar(match[2]) - : (match[3]?.replaceAll("''", "'") ?? match[4]), - value: match[5], + : (match[3]?.replaceAll("''", "'") ?? match[4] ?? match[5]), + value: match[6], }; } @@ -661,12 +702,12 @@ function parseYamlMappingEntryAt(lines, index) { return undefined; } -function workflowRootValues(text, key) { +function workflowRootValues(text, key, scalarAnchors) { return [ - ...yamlBlockMappingEntries(text) + ...yamlBlockMappingEntries(text, scalarAnchors) .filter((entry) => entry.indentation === 0 && entry.key.toLowerCase() === key.toLowerCase()) .map((entry) => entry.value), - ...yamlRootFlowMappingValues(text, key), + ...yamlRootFlowMappingValues(text, key, scalarAnchors), ]; } @@ -677,7 +718,7 @@ function workflowJobValues(text, key, scalarAnchors) { } function workflowJobPropertyGroups(text, scalarAnchors) { - const entries = yamlBlockMappingEntries(text); + const entries = yamlBlockMappingEntries(text, scalarAnchors); const blockNodeAnchors = yamlBlockNodeAnchors(entries); const groups = []; for (let index = 0; index < entries.length; index += 1) { @@ -698,7 +739,9 @@ function workflowJobPropertyGroups(text, scalarAnchors) { } const flowProperties = yamlDirectFlowMappingEntries( resolveYamlScalarValue(job.entry.inlineValue, scalarAnchors), - ).map((entry) => ({ entry })); + ).map((entry) => ({ + entry: { ...entry, key: resolveYamlScalarValue(entry.key, scalarAnchors) }, + })); if (flowProperties.length > 0) { groups.push({ blockNodeAnchors, entries: undefined, properties: flowProperties, text }); } @@ -710,7 +753,7 @@ function workflowJobPropertyGroups(text, scalarAnchors) { text, )); } - for (const jobsValue of yamlRootFlowMappingValues(text, "jobs")) { + for (const jobsValue of yamlRootFlowMappingValues(text, "jobs", scalarAnchors)) { groups.push(...workflowJobPropertyGroupsFromFlowJobs( jobsValue, scalarAnchors, @@ -770,7 +813,9 @@ function workflowJobPropertyGroupsFromFlowJobs(value, scalarAnchors, blockNodeAn const jobsValue = resolveYamlScalarValue(value, scalarAnchors); return yamlDirectFlowMappingEntries(jobsValue).flatMap((jobEntry) => { const jobValue = resolveYamlScalarValue(jobEntry.value, scalarAnchors); - const properties = yamlDirectFlowMappingEntries(jobValue).map((entry) => ({ entry })); + const properties = yamlDirectFlowMappingEntries(jobValue).map((entry) => ({ + entry: { ...entry, key: resolveYamlScalarValue(entry.key, scalarAnchors) }, + })); return properties.length > 0 ? [{ blockNodeAnchors, entries: undefined, properties, text }] : []; @@ -783,6 +828,7 @@ function workflowTriggerNames(text, scalarAnchors, blockNodeAnchors) { for (let index = 0; index < lines.length; index += 1) { const parsedEntry = parseYamlMappingEntryAt(lines, index); const entry = parsedEntry?.entry; + if (entry) entry.key = resolveYamlScalarValue(entry.key, scalarAnchors); if (!entry || entry.indentation !== 0 || entry.key.toLowerCase() !== "on") continue; if (entry.value.trim().length > 0 && !yamlValueHasOnlyProperties(entry.value)) { names.push(...workflowTriggerNamesFromValue(entry.value, scalarAnchors, blockNodeAnchors)); @@ -814,12 +860,12 @@ function workflowTriggerNames(text, scalarAnchors, blockNodeAnchors) { } const parsedEvent = parseYamlMappingEntryAt(lines, cursor); if (parsedEvent) { - names.push(parsedEvent.entry.key); + names.push(resolveYamlScalarValue(parsedEvent.entry.key, scalarAnchors)); cursor = parsedEvent.valueLineIndex; } } } - for (const onValue of yamlRootFlowMappingValues(text, "on")) { + for (const onValue of yamlRootFlowMappingValues(text, "on", scalarAnchors)) { names.push(...workflowTriggerNamesFromValue(onValue, scalarAnchors, blockNodeAnchors)); } return names; @@ -836,12 +882,12 @@ function workflowTriggerNamesFromValue(value, scalarAnchors, blockNodeAnchors) { scalarAnchors, ); if (blockAliasEntries.length > 0) { - return blockAliasEntries.map(({ entry }) => entry.key); + return blockAliasEntries.map(({ entry }) => resolveYamlScalarValue(entry.key, scalarAnchors)); } const resolved = resolveYamlScalarValue(value, scalarAnchors); const mappingEntries = yamlDirectFlowMappingEntries(resolved); if (resolved.trimStart().startsWith("{")) { - return mappingEntries.map((entry) => entry.key); + return mappingEntries.map((entry) => resolveYamlScalarValue(entry.key, scalarAnchors)); } const sequenceValues = yamlFlowSequenceValues(resolved); if (sequenceValues) { @@ -854,7 +900,7 @@ function workflowTriggerNamesFromValue(value, scalarAnchors, blockNodeAnchors) { return [resolved]; } -function yamlBlockMappingEntries(text) { +function yamlBlockMappingEntries(text, scalarAnchors) { const lines = text.split("\n"); const blockScalarBodyLines = yamlBlockScalarBodyLineIndexes(lines); const entries = []; @@ -878,6 +924,9 @@ function yamlBlockMappingEntries(text) { entries.push({ ...entry, inlineValue: entry.value.trim(), + key: scalarAnchors + ? resolveYamlScalarValue(entry.key, scalarAnchors) + : entry.key, lineIndex: index, value: value.trim(), valueLineIndex, @@ -1211,11 +1260,11 @@ function yamlFlowMappingValue(value, key) { return yamlFlowMappingValues(value, key)[0]; } -function yamlRootFlowMappingValues(text, key) { +function yamlRootFlowMappingValues(text, key, scalarAnchors = new Map()) { const openingBraceIndex = yamlDocumentContentStart(text); if (openingBraceIndex < 0 || text[openingBraceIndex] !== "{") return []; return yamlFlowMappingEntriesAt(text, openingBraceIndex) - .filter((entry) => entry.key.toLowerCase() === key.toLowerCase()) + .filter((entry) => resolveYamlScalarValue(entry.key, scalarAnchors).toLowerCase() === key.toLowerCase()) .map((entry) => entry.value); } @@ -1344,7 +1393,7 @@ function readYamlFlowKey(text, startIndex) { function hasUntrustedPullRequestCheckout(text, scalarAnchors, taintedBindings = new Set()) { text = maskYamlBlockScalarBodies(text); - const rootTaintedBindings = environmentTaintedBindings( + const rootTaintedBindings = contextTaintedBindings( workflowRootMappingBindings(text, "env", "env", scalarAnchors), taintedBindings, ); @@ -1355,14 +1404,24 @@ function hasUntrustedPullRequestCheckout(text, scalarAnchors, taintedBindings = resolveYamlScalarValue(entry.value, scalarAnchors), )); if (!usesCheckout) continue; - const jobTaintedBindings = environmentTaintedBindings( + const jobTaintedBindings = contextTaintedBindings( workflowMappingBindings(jobGroup, "env", "env", scalarAnchors), rootTaintedBindings, ); - const stepTaintedBindings = environmentTaintedBindings( - workflowMappingBindings(stepGroup, "env", "env", scalarAnchors), + const matrixTaintedBindings = contextTaintedBindings( + workflowNestedMappingBindings( + jobGroup, + "strategy", + "matrix", + "matrix", + scalarAnchors, + ), jobTaintedBindings, ); + const stepTaintedBindings = contextTaintedBindings( + workflowMappingBindings(stepGroup, "env", "env", scalarAnchors), + matrixTaintedBindings, + ); const checkoutInputs = workflowMappingBindings( stepGroup, "with", @@ -1380,14 +1439,16 @@ function hasUntrustedPullRequestCheckout(text, scalarAnchors, taintedBindings = return false; } -function environmentTaintedBindings(bindings, inheritedTaintedBindings) { +function contextTaintedBindings(bindings, inheritedTaintedBindings) { const taintedBindings = new Set(inheritedTaintedBindings); - for (const binding of bindings) taintedBindings.delete("env." + binding.name); + for (const binding of bindings) { + taintedBindings.delete(binding.namespace + "." + binding.name); + } let changed; do { changed = false; for (const binding of bindings) { - const name = "env." + binding.name; + const name = binding.namespace + "." + binding.name; if (!taintedBindings.has(name) && isUntrustedReusableValue(binding.value, taintedBindings)) { taintedBindings.add(name); @@ -1402,13 +1463,13 @@ function isUntrustedCheckoutInput(key, value, taintedBindings = new Set()) { if (!["ref", "repository"].includes(key.toLowerCase())) return false; if (valueReferencesTaintedBinding(value, taintedBindings)) return true; if (key.toLowerCase() === "repository") { - return /pull_request\.head\.repo(?:\.|\b)/iu.test(value); + return /(?:pull_request\.head\.repo|workflow_run\.head_repository)(?:\.|\b)/iu.test(value); } return isUntrustedPullRequestRef(value); } function isUntrustedPullRequestRef(value) { - return /(?:github\.head_ref|pull_request\.(?:head|merge_commit_sha)|head\.sha|refs\/pull\/)/iu.test(value); + return /(?:github\.head_ref|pull_request\.(?:head|merge_commit_sha)|head\.sha|refs\/pull\/|workflow_run\.head_sha)/iu.test(value); } function actionReferences(text, scalarAnchors) { @@ -1473,7 +1534,10 @@ function workflowStepPropertyGroupFromValue(value, parentGroup, scalarAnchors) { } const resolvedValue = resolveYamlScalarValue(value, scalarAnchors); const flowProperties = yamlDirectFlowMappingEntries(resolvedValue) - .map((entry) => ({ children: [], entry })); + .map((entry) => ({ + children: [], + entry: { ...entry, key: resolveYamlScalarValue(entry.key, scalarAnchors) }, + })); return flowProperties.length > 0 ? { blockNodeAnchors: parentGroup.blockNodeAnchors, @@ -1708,7 +1772,49 @@ function auditGithubControls(evidence, requiredChecks) { || classic?.allow_force_pushes?.enabled === false; const deletionProtected = rules.some((rule) => rule.type === "deletion") || classic?.allow_deletions?.enabled === false; - const bypassActors = applicableRulesets.flatMap((ruleset) => ruleset.bypass_actors ?? []); + const missingBypassEvidence = applicableRulesets + .some((ruleset) => !Array.isArray(ruleset.bypass_actors)); + const bypassActors = applicableRulesets.flatMap((ruleset) => ( + Array.isArray(ruleset.bypass_actors) ? ruleset.bypass_actors : [] + )); + const unbypassableRules = applicableRulesets + .filter((ruleset) => Array.isArray(ruleset.bypass_actors) + && ruleset.bypass_actors.length === 0) + .flatMap((ruleset) => Array.isArray(ruleset.rules) ? ruleset.rules : []); + const unbypassableStatusChecks = unbypassableRules + .filter((rule) => rule.type === "required_status_checks") + .flatMap((rule) => (rule.parameters?.required_status_checks ?? []).map((check) => ({ + ...check, + strict: rule.parameters?.strict_required_status_checks_policy === true, + }))); + if (classic?.enforce_admins?.enabled === true) { + unbypassableStatusChecks.push(...classicChecks); + } + const unbypassableStrictContexts = new Set(unbypassableStatusChecks + .filter((check) => check.strict) + .map((check) => check.context) + .filter((context) => typeof context === "string")); + const unbypassableGithubActionsContexts = new Set(unbypassableStatusChecks + .filter((check) => check.integration_id === GITHUB_ACTIONS_APP_ID + || check.app_id === GITHUB_ACTIONS_APP_ID) + .map((check) => check.context) + .filter((context) => typeof context === "string")); + const statusChecksAreUnbypassable = requiredChecks.length > 0 + ? requiredChecks.every((context) => unbypassableStrictContexts.has(context) + && unbypassableGithubActionsContexts.has(context)) + : [...unbypassableStrictContexts] + .some((context) => unbypassableGithubActionsContexts.has(context)); + const forcePushProtectionIsUnbypassable = unbypassableRules + .some((rule) => rule.type === "non_fast_forward") + || (classic?.enforce_admins?.enabled === true + && classic?.allow_force_pushes?.enabled === false); + const deletionProtectionIsUnbypassable = unbypassableRules + .some((rule) => rule.type === "deletion") + || (classic?.enforce_admins?.enabled === true + && classic?.allow_deletions?.enabled === false); + const requiredProtectionIsUnbypassable = statusChecksAreUnbypassable + && forcePushProtectionIsUnbypassable + && deletionProtectionIsUnbypassable; if (requiredContexts.size === 0) { findings.push(githubFinding("github-required-check-missing", "The default branch has no required status check.")); @@ -1751,7 +1857,10 @@ function auditGithubControls(evidence, requiredChecks) { if (deletionProtected === false) { findings.push(githubFinding("github-deletion-unprotected", "The default branch must reject deletion.")); } - if (bypassActors.length > 0 || (classic && classic.enforce_admins?.enabled !== true)) { + const protectionHasPotentialBypass = bypassActors.length > 0 + || (classic && classic.enforce_admins?.enabled !== true); + if (missingBypassEvidence + || (protectionHasPotentialBypass && !requiredProtectionIsUnbypassable)) { findings.push(githubFinding("github-protection-bypass", "Default-branch protection must not expose bypass actors.")); } if (evidence.runners.total_count !== 0 || evidence.runners.runners.length !== 0) { diff --git a/public-source-release-audit/tests/public-source-release-audit.test.mjs b/public-source-release-audit/tests/public-source-release-audit.test.mjs index 26fa937..b9a19c2 100644 --- a/public-source-release-audit/tests/public-source-release-audit.test.mjs +++ b/public-source-release-audit/tests/public-source-release-audit.test.mjs @@ -437,6 +437,27 @@ test("YAML node properties preceding keys preserve guarded workflow settings", ( assertFinding(audit.result, "workflow-self-hosted-runner", ".github/workflows/key-properties.yml"); }); +test("scalar aliases used as keys preserve guarded workflow settings", () => { + const repoRoot = makeRepository(); + write(repoRoot, ".github/workflows/aliased-keys.yml", [ + "name: &permission-key permissions", + "x-runner: &runner-key runs-on", + "on: push", + "*permission-key: write-all", + "jobs:", + " build:", + " *runner-key: self-hosted", + "", + ].join("\n")); + commitAll(repoRoot, "add aliased key workflow"); + + const audit = runAudit(repoRoot); + + assert.equal(audit.status, 1); + assertFinding(audit.result, "workflow-write-all", ".github/workflows/aliased-keys.yml"); + assertFinding(audit.result, "workflow-self-hosted-runner", ".github/workflows/aliased-keys.yml"); +}); + test("block-scalar write-all permissions are release-blocking", () => { const repoRoot = makeRepository(); write(repoRoot, ".github/workflows/block-permissions.yml", [ @@ -1075,6 +1096,34 @@ test("untrusted refs propagate through workflow job and step environments", () = } }); +test("untrusted refs propagate through job matrix bindings", () => { + const repoRoot = makeRepository(); + const headExpression = ["$", "{{ github.head_ref }}"].join(""); + const matrixExpression = ["$", "{{ matrix.revision }}"].join(""); + write(repoRoot, ".github/workflows/matrix-ref.yml", [ + "name: matrix ref", + "on: pull_request_target", + "permissions: read-all", + "jobs:", + " inspect:", + " strategy:", + " matrix:", + " revision: [" + headExpression + "]", + " runs-on: ubuntu-latest", + " steps:", + " - uses: actions/checkout@" + "a".repeat(40), + " with:", + " ref: " + matrixExpression, + "", + ].join("\n")); + commitAll(repoRoot, "add matrix checkout workflow"); + + const audit = runAudit(repoRoot); + + assert.equal(audit.status, 1); + assertFinding(audit.result, "workflow-privileged-untrusted-checkout", ".github/workflows/matrix-ref.yml"); +}); + test("quoted runs-on keys cannot hide self-hosted labels", () => { const repoRoot = makeRepository(); write(repoRoot, ".github/workflows/quoted-runner-key.yml", [ @@ -1221,6 +1270,35 @@ test("pull-request merge refs are untrusted privileged checkout sources", () => assertFinding(audit.result, "workflow-privileged-untrusted-checkout", ".github/workflows/merge-ref-checkout.yml"); }); +test("workflow_run head checkouts are privileged and untrusted", () => { + const repoRoot = makeRepository(); + const repositoryExpression = ["$", "{{ github.event.workflow_run.head_repository.full_name }}"].join(""); + const shaExpression = ["$", "{{ github.event.workflow_run.head_sha }}"].join(""); + write(repoRoot, ".github/workflows/workflow-run-checkout.yml", [ + "name: workflow run checkout", + "on:", + " workflow_run:", + " workflows: [verify]", + " types: [completed]", + "permissions: write-all", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - uses: actions/checkout@" + "a".repeat(40), + " with:", + " repository: " + repositoryExpression, + " ref: " + shaExpression, + "", + ].join("\n")); + commitAll(repoRoot, "add workflow run checkout"); + + const audit = runAudit(repoRoot); + + assert.equal(audit.status, 1); + assertFinding(audit.result, "workflow-privileged-untrusted-checkout", ".github/workflows/workflow-run-checkout.yml"); +}); + test("mutable Docker actions warn while digest-pinned actions pass", () => { const repoRoot = makeRepository(); write(repoRoot, ".github/workflows/mutable-docker.yml", [ @@ -1482,6 +1560,38 @@ test("required-check strictness is associated with the supplying rule", () => { ); }); +test("missing ruleset bypass evidence fails closed", () => { + const repoRoot = makeRepository(); + writeSafeWorkflow(repoRoot); + commitAll(repoRoot, "add safe workflow"); + const snapshot = githubSnapshot(); + delete snapshot.rulesets[0].bypass_actors; + + const audit = runAudit(repoRoot, [ + "--github-snapshot", writeSnapshot(snapshot), + "--required-check", "verify", + ]); + + assert.equal(audit.status, 1); + assertFinding(audit.result, "github-protection-bypass"); +}); + +test("an unbypassable ruleset covers a classic administrator exemption", () => { + const repoRoot = makeRepository(); + writeSafeWorkflow(repoRoot); + commitAll(repoRoot, "add safe workflow"); + const snapshot = githubSnapshot(); + snapshot.branchProtection = { enforce_admins: { enabled: false } }; + + const audit = runAudit(repoRoot, [ + "--github-snapshot", writeSnapshot(snapshot), + "--required-check", "verify", + ]); + + assert.equal(audit.status, 0); + assert.equal(audit.result.passed, true); +}); + test("each missing requested status check remains a distinct finding", () => { const repoRoot = makeRepository(); writeSafeWorkflow(repoRoot); @@ -1597,7 +1707,6 @@ test("live GitHub evidence consumes every ruleset page", () => { commitAll(repoRoot, "add safe workflow"); const snapshot = githubSnapshot(); const laterRuleset = { - bypass_actors: [{ actor_id: 1, actor_type: "OrganizationAdmin" }], conditions: { ref_name: { exclude: [], include: ["~DEFAULT_BRANCH"] } }, enforcement: "active", rules: [], From 5e23fd4ccdce5d90155caf90201ca317c3a2e541 Mon Sep 17 00:00:00 2001 From: Vladimir Date: Sat, 22 Aug 2026 04:16:42 +0800 Subject: [PATCH 14/37] Audit indirect workflow dependencies --- .../scripts/public-safety-audit-core.mjs | 28 +- .../scripts/public-source-release-audit.mjs | 440 ++++++++++++++---- .../public-source-release-audit.test.mjs | 182 ++++++++ 3 files changed, 550 insertions(+), 100 deletions(-) diff --git a/public-source-release-audit/scripts/public-safety-audit-core.mjs b/public-source-release-audit/scripts/public-safety-audit-core.mjs index 9f14464..caa3f00 100644 --- a/public-source-release-audit/scripts/public-safety-audit-core.mjs +++ b/public-source-release-audit/scripts/public-safety-audit-core.mjs @@ -3274,10 +3274,30 @@ function gitArgsWithAuditConfig(args) { : args; } function auditGitEnvironment() { - return { - ...process.env, - GIT_NO_REPLACE_OBJECTS: "1", - }; + const environment = { ...process.env }; + for (const name of [ + "GIT_ALTERNATE_OBJECT_DIRECTORIES", + "GIT_COMMON_DIR", + "GIT_CONFIG_COUNT", + "GIT_CONFIG_GLOBAL", + "GIT_CONFIG_SYSTEM", + "GIT_DIR", + "GIT_GRAFT_FILE", + "GIT_INDEX_FILE", + "GIT_NAMESPACE", + "GIT_OBJECT_DIRECTORY", + "GIT_REPLACE_REF_BASE", + "GIT_SHALLOW_FILE", + "GIT_WORK_TREE", + ]) { + delete environment[name]; + } + for (const name of Object.keys(environment)) { + if (/^GIT_CONFIG_(?:KEY|VALUE)_\d+$/u.test(name)) + delete environment[name]; + } + environment.GIT_NO_REPLACE_OBJECTS = "1"; + return environment; } function scanLargeDecodedAuditTextFile(filePath, createScanner) { const detection = detectLargeBlobTextEncoding(filePath); diff --git a/public-source-release-audit/scripts/public-source-release-audit.mjs b/public-source-release-audit/scripts/public-source-release-audit.mjs index 5bb9b5f..26737c9 100644 --- a/public-source-release-audit/scripts/public-source-release-audit.mjs +++ b/public-source-release-audit/scripts/public-source-release-audit.mjs @@ -11,6 +11,7 @@ const MAX_OUTPUT_FINDINGS = 100; const GITHUB_ACTIONS_APP_ID = 15368; const FULL_OBJECT_ID_PATTERN = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/iu; const IMMUTABLE_DOCKER_ACTION_PATTERN = /^docker:\/\/[^\s@]+(?:[:][^\s@]+)?@(?:sha256:[0-9a-f]{64}|sha512:[0-9a-f]{128})$/iu; +const ACTION_MANIFEST_PATH_PATTERN = /(?:^|\/)action\.ya?ml$/u; const WORKFLOW_PATH_PATTERN = /^\.github\/workflows\/[^/]+\.ya?ml$/u; main(); @@ -170,12 +171,13 @@ function resolveRepositoryRoot(inputPath) { function auditWorkflowSources(repoRoot, { includeHistory = false } = {}) { const entries = trackedWorkflowEntries(repoRoot, { includeHistory }); const findings = []; + const actionSources = []; const workflowSources = []; for (const entry of entries) { if (entry.mode !== "100644" && entry.mode !== "100755") { findings.push(workflowFinding({ - message: "Workflow entrypoints must be regular tracked files.", + message: "Workflow and local-action entrypoints must be regular tracked files.", path: entry.path, ruleId: "workflow-entrypoint-not-regular", severity: "error", @@ -191,7 +193,7 @@ function auditWorkflowSources(repoRoot, { includeHistory = false } = {}) { const text = blob.toString("utf8"); if (Buffer.from(text, "utf8").equals(blob) === false) { findings.push(workflowFinding({ - message: "Workflow YAML must be valid UTF-8 for deterministic review.", + message: "Workflow and local-action YAML must be valid UTF-8 for deterministic review.", path: entry.path, ruleId: "workflow-non-utf8", severity: "error", @@ -200,11 +202,17 @@ function auditWorkflowSources(repoRoot, { includeHistory = false } = {}) { continue; } - workflowSources.push({ ...entry, text }); - findings.push(...auditWorkflowText(entry.path, text, entry.source)); + const source = { ...entry, text }; + if (WORKFLOW_PATH_PATTERN.test(entry.path)) { + workflowSources.push(source); + findings.push(...auditWorkflowText(entry.path, text, entry.source)); + } else { + actionSources.push(source); + } } findings.push(...auditPrivilegedReusableWorkflowCalls(workflowSources)); + findings.push(...auditLocalCompositeActions(workflowSources, actionSources)); return findings; } @@ -272,8 +280,116 @@ function auditPrivilegedReusableWorkflowCalls(workflowSources) { return findings; } +function auditLocalCompositeActions(workflowSources, actionSources) { + const actionBySnapshotPath = new Map(actionSources.map((source) => [ + `${source.snapshot}\0${source.path}`, + source, + ])); + const analysisBySource = new Map(); + const analysisFor = (source) => { + if (analysisBySource.has(source)) return analysisBySource.get(source); + const syntax = workflowSyntax(source.text); + const runGroups = workflowRootContainerGroups( + syntax.uncommented, + "runs", + syntax.scalarAnchors, + ).filter((group) => group.properties + .filter(({ entry }) => entry.key.toLowerCase() === "using") + .some(({ entry }) => resolveYamlScalarValue( + entry.value, + syntax.scalarAnchors, + ).toLowerCase() === "composite")); + const stepGroups = runGroups.flatMap((group) => mappingContainerStepPropertyGroups( + group, + syntax.scalarAnchors, + )); + const references = stepGroups.flatMap((stepGroup) => stepGroup.properties + .filter(({ entry }) => entry.key.toLowerCase() === "uses") + .map(({ entry }) => resolveYamlScalarValue(entry.value, syntax.scalarAnchors))); + const analysis = { + references, + untrustedCheckout: stepGroups.some((stepGroup) => stepGroupHasUntrustedCheckout( + stepGroup, + syntax.scalarAnchors, + new Set(), + )), + }; + analysisBySource.set(source, analysis); + return analysis; + }; + const findings = []; + for (const workflow of workflowSources) { + const syntax = workflowSyntax(workflow.text); + const privileged = syntax.triggerNames.some((name) => [ + "pull_request_target", + "workflow_run", + ].includes(name.toLowerCase())); + const pending = actionReferences(syntax.uncommented, syntax.scalarAnchors) + .flatMap((reference) => localActionManifestPaths(reference)) + .map((manifestPath) => ({ depth: 1, manifestPath })); + const visited = new Set(); + while (pending.length > 0) { + const { depth, manifestPath } = pending.pop(); + if (depth > 10 || visited.has(manifestPath)) continue; + visited.add(manifestPath); + const action = actionBySnapshotPath.get(`${workflow.snapshot}\0${manifestPath}`); + if (!action) continue; + const analysis = analysisFor(action); + for (const reference of analysis.references) { + if (isMutableRemoteActionReference(reference)) { + findings.push(workflowFinding({ + message: "Remote workflow dependencies should use reviewed immutable object IDs.", + path: action.path, + ruleId: "workflow-mutable-action-ref", + severity: "warning", + source: action.source, + })); + } + pending.push(...localActionManifestPaths(reference).map((nestedPath) => ({ + depth: depth + 1, + manifestPath: nestedPath, + }))); + } + if (privileged && analysis.untrustedCheckout) { + findings.push(workflowFinding({ + message: "A local composite action called from a privileged workflow must not execute an untrusted checkout.", + path: action.path, + ruleId: "workflow-privileged-untrusted-checkout", + severity: "error", + source: action.source, + })); + } + } + } + return findings; +} + +function localActionManifestPaths(reference) { + if (!reference.startsWith("./") || /\$\{\{/u.test(reference)) return []; + const actionPath = path.posix.normalize(reference.slice(2)); + if (actionPath.length === 0 || actionPath === "." || actionPath === ".." + || actionPath.startsWith("../") || path.posix.isAbsolute(actionPath)) { + return []; + } + if (ACTION_MANIFEST_PATH_PATTERN.test(actionPath)) return [actionPath]; + return [`${actionPath}/action.yml`, `${actionPath}/action.yaml`]; +} + +function isMutableRemoteActionReference(actionRef) { + if (actionRef.startsWith("./") || IMMUTABLE_DOCKER_ACTION_PATTERN.test(actionRef)) return false; + const separator = actionRef.lastIndexOf("@"); + const revision = separator < 0 ? "" : actionRef.slice(separator + 1); + return actionRef.startsWith("docker://") || !FULL_OBJECT_ID_PATTERN.test(revision); +} + function localReusableWorkflowCalls(text, scalarAnchors) { const calls = []; + const workflowEnvBindings = workflowRootMappingBindings( + text, + "env", + "env", + scalarAnchors, + ); for (const group of workflowJobPropertyGroups(text, scalarAnchors)) { const bindings = [ ...workflowMappingBindings(group, "with", "inputs", scalarAnchors), @@ -292,6 +408,9 @@ function localReusableWorkflowCalls(text, scalarAnchors) { calls.push({ bindings, inheritSecrets, + jobEnvBindings: workflowMappingBindings(group, "env", "env", scalarAnchors), + matrixBindings: workflowJobMatrixBindings(group, scalarAnchors), + workflowEnvBindings, workflowPath: uses.slice(2), }); } @@ -331,36 +450,73 @@ function workflowPropertyMappingEntries(group, property, scalarAnchors) { ]; } -function workflowNestedMappingBindings( - group, - propertyName, - nestedPropertyName, - namespace, - scalarAnchors, -) { +function workflowJobMatrixBindings(group, scalarAnchors) { const bindings = []; - for (const property of group.properties) { - if (property.entry.key.toLowerCase() !== propertyName) continue; - for (const nestedProperty of workflowPropertyMappingEntries(group, property, scalarAnchors)) { - const nestedKey = resolveYamlScalarValue(nestedProperty.entry.key, scalarAnchors); - if (nestedKey.toLowerCase() !== nestedPropertyName) continue; - bindings.push(...workflowMappingBindings( - { - ...group, - properties: [{ - ...nestedProperty, - entry: { ...nestedProperty.entry, key: nestedKey }, - }], - }, - nestedPropertyName, - namespace, + for (const strategyProperty of group.properties) { + if (strategyProperty.entry.key.toLowerCase() !== "strategy") continue; + for (const matrixProperty of workflowPropertyMappingEntries( + group, + strategyProperty, + scalarAnchors, + )) { + const matrixKey = resolveYamlScalarValue(matrixProperty.entry.key, scalarAnchors); + if (matrixKey.toLowerCase() !== "matrix") continue; + const matrixGroup = { + ...group, + properties: [{ + ...matrixProperty, + entry: { ...matrixProperty.entry, key: matrixKey }, + }], + }; + for (const matrixEntry of workflowPropertyMappingEntries( + matrixGroup, + matrixGroup.properties[0], scalarAnchors, - )); + )) { + const name = resolveYamlScalarValue(matrixEntry.entry.key, scalarAnchors).toLowerCase(); + if (name === "include") { + bindings.push(...workflowMatrixIncludeBindings( + matrixGroup, + matrixEntry, + scalarAnchors, + )); + } else if (name !== "exclude") { + bindings.push({ + name, + namespace: "matrix", + value: resolveYamlScalarValue(matrixEntry.entry.value, scalarAnchors), + }); + } + } } } return bindings; } +function workflowMatrixIncludeBindings(matrixGroup, includeProperty, scalarAnchors) { + const itemGroups = []; + const inlineValue = includeProperty.entry.inlineValue ?? includeProperty.entry.value; + const flowItems = yamlFlowSequenceValues(resolveYamlScalarValue(inlineValue, scalarAnchors)); + if (flowItems) { + for (const item of flowItems) { + const itemGroup = workflowStepPropertyGroupFromValue(item, matrixGroup, scalarAnchors); + if (itemGroup) itemGroups.push(itemGroup); + } + } + if (matrixGroup.entries && includeProperty.index !== undefined) { + itemGroups.push(...workflowBlockStepPropertyGroups( + matrixGroup, + includeProperty.entry, + scalarAnchors, + )); + } + return itemGroups.flatMap((itemGroup) => itemGroup.properties.map(({ entry }) => ({ + name: resolveYamlScalarValue(entry.key, scalarAnchors).toLowerCase(), + namespace: "matrix", + value: resolveYamlScalarValue(entry.value, scalarAnchors), + }))); +} + function workflowRootMappingBindings(text, propertyName, namespace, scalarAnchors) { const entries = yamlBlockMappingEntries(text, scalarAnchors); const blockNodeAnchors = yamlBlockNodeAnchors(entries); @@ -388,14 +544,26 @@ function workflowRootMappingBindings(text, propertyName, namespace, scalarAnchor } function reusableCallTaintedBindings(call, callerTaintedBindings) { + const workflowTaintedBindings = contextTaintedBindings( + call.workflowEnvBindings, + callerTaintedBindings, + ); + const jobTaintedBindings = contextTaintedBindings( + call.jobEnvBindings, + workflowTaintedBindings, + ); + const callSiteTaintedBindings = contextTaintedBindings( + call.matrixBindings, + jobTaintedBindings, + ); const taintedBindings = new Set(); for (const binding of call.bindings) { - if (isUntrustedReusableValue(binding.value, callerTaintedBindings)) { + if (isUntrustedReusableValue(binding.value, callSiteTaintedBindings)) { taintedBindings.add(`${binding.namespace}.${binding.name}`); } } if (call.inheritSecrets) { - for (const binding of callerTaintedBindings) { + for (const binding of callSiteTaintedBindings) { if (binding.startsWith("secrets.")) taintedBindings.add(binding); } } @@ -404,7 +572,8 @@ function reusableCallTaintedBindings(call, callerTaintedBindings) { function isUntrustedReusableValue(value, taintedBindings) { return isUntrustedPullRequestRef(value) - || /(?:pull_request\.head\.repo|workflow_run\.head_repository)(?:\.|\b)/iu.test(value) + || /(?:pull_request\.head\.repo|workflow_run\.head_repository)(?:\.|\b)/iu + .test(normalizeExpressionPropertyAccess(value)) || valueReferencesTaintedBinding(value, taintedBindings); } @@ -436,7 +605,7 @@ function trackedWorkflowEntries(repoRoot, { includeHistory = false } = {}) { scannedTrees.add(tree); records.push(...parseTreeRecords(runCommand( "git", - ["ls-tree", "-r", "-z", "--full-tree", tree, "--", ".github/workflows"], + ["ls-tree", "-r", "-z", "--full-tree", tree], { cwd: repoRoot, encoding: null }, ).stdout).map((record) => ({ ...record, snapshot: tree, source: "history" }))); } @@ -454,7 +623,7 @@ function trackedWorkflowEntries(repoRoot, { includeHistory = false } = {}) { const unique = new Map(); for (const record of records) { - if (WORKFLOW_PATH_PATTERN.test(record.path)) { + if (WORKFLOW_PATH_PATTERN.test(record.path) || ACTION_MANIFEST_PATH_PATTERN.test(record.path)) { const key = `${record.snapshot}\0${record.path}\0${record.objectId}\0${record.mode}`; if (unique.has(key) === false || record.source === "tracked-file") { unique.set(key, record); @@ -592,10 +761,7 @@ function auditWorkflowText(workflowPath, text, source = "tracked-file") { } for (const actionRef of actionReferences(uncommented, scalarAnchors)) { - if (actionRef.startsWith("./") || IMMUTABLE_DOCKER_ACTION_PATTERN.test(actionRef)) continue; - const separator = actionRef.lastIndexOf("@"); - const revision = separator < 0 ? "" : actionRef.slice(separator + 1); - if (actionRef.startsWith("docker://") || !FULL_OBJECT_ID_PATTERN.test(revision)) { + if (isMutableRemoteActionReference(actionRef)) { findings.push(workflowFinding({ message: "Remote workflow dependencies should use reviewed immutable object IDs.", path: workflowPath, @@ -711,6 +877,49 @@ function workflowRootValues(text, key, scalarAnchors) { ]; } +function workflowRootContainerGroups(text, key, scalarAnchors) { + const entries = yamlBlockMappingEntries(text, scalarAnchors); + const blockNodeAnchors = yamlBlockNodeAnchors(entries); + const groups = []; + for (let index = 0; index < entries.length; index += 1) { + const rootEntry = entries[index]; + if (rootEntry.indentation !== 0 || rootEntry.key.toLowerCase() !== key.toLowerCase()) { + continue; + } + const properties = directBlockMappingChildren(entries, index); + if (properties.length > 0) { + groups.push({ blockNodeAnchors, entries, properties, text }); + } + const aliasProperties = yamlBlockAliasMappingEntries( + rootEntry.inlineValue, + blockNodeAnchors, + scalarAnchors, + ); + if (aliasProperties.length > 0) { + groups.push({ blockNodeAnchors, entries, properties: aliasProperties, text }); + } + const flowProperties = yamlDirectFlowMappingEntries( + resolveYamlScalarValue(rootEntry.value, scalarAnchors), + ).map((entry) => ({ + entry: { ...entry, key: resolveYamlScalarValue(entry.key, scalarAnchors) }, + })); + if (flowProperties.length > 0) { + groups.push({ blockNodeAnchors, entries: undefined, properties: flowProperties, text }); + } + } + for (const rootValue of yamlRootFlowMappingValues(text, key, scalarAnchors)) { + const properties = yamlDirectFlowMappingEntries( + resolveYamlScalarValue(rootValue, scalarAnchors), + ).map((entry) => ({ + entry: { ...entry, key: resolveYamlScalarValue(entry.key, scalarAnchors) }, + })); + if (properties.length > 0) { + groups.push({ blockNodeAnchors, entries: undefined, properties, text }); + } + } + return groups; +} + function workflowJobValues(text, key, scalarAnchors) { return workflowJobPropertyGroups(text, scalarAnchors).flatMap(({ properties }) => properties .filter(({ entry }) => entry.key.toLowerCase() === key.toLowerCase()) @@ -1361,9 +1570,14 @@ function yamlFlowMappingEntriesAt(text, openingBraceIndex) { } function readYamlFlowKey(text, startIndex) { - const quote = text[startIndex]; + let keyStart = startIndex; + if (text[keyStart] === "?" && /\s/u.test(text[keyStart + 1] ?? "")) { + keyStart += 1; + while (/\s/u.test(text[keyStart] ?? "")) keyStart += 1; + } + const quote = text[keyStart]; if (quote === "'" || quote === "\"") { - let cursor = startIndex + 1; + let cursor = keyStart + 1; while (cursor < text.length) { if (quote === "'" && text[cursor] === "'" && text[cursor + 1] === "'") { cursor += 2; @@ -1376,8 +1590,8 @@ function readYamlFlowKey(text, startIndex) { if (text[cursor] === quote) { return { key: quote === "\"" - ? decodeYamlDoubleQuotedScalar(text.slice(startIndex + 1, cursor)) - : text.slice(startIndex + 1, cursor).replaceAll("''", "'"), + ? decodeYamlDoubleQuotedScalar(text.slice(keyStart + 1, cursor)) + : text.slice(keyStart + 1, cursor).replaceAll("''", "'"), nextIndex: cursor + 1, }; } @@ -1385,9 +1599,9 @@ function readYamlFlowKey(text, startIndex) { } return undefined; } - let cursor = startIndex; + let cursor = keyStart; while (cursor < text.length && !/[:,{}]/u.test(text[cursor])) cursor += 1; - const key = text.slice(startIndex, cursor).trim(); + const key = text.slice(keyStart, cursor).trim(); return key.length > 0 ? { key, nextIndex: cursor } : undefined; } @@ -1398,47 +1612,40 @@ function hasUntrustedPullRequestCheckout(text, scalarAnchors, taintedBindings = taintedBindings, ); for (const { jobGroup, stepGroup } of workflowStepPropertyGroups(text, scalarAnchors)) { - const usesCheckout = stepGroup.properties - .filter(({ entry }) => entry.key.toLowerCase() === "uses") - .some(({ entry }) => /^actions\/checkout@/iu.test( - resolveYamlScalarValue(entry.value, scalarAnchors), - )); - if (!usesCheckout) continue; const jobTaintedBindings = contextTaintedBindings( workflowMappingBindings(jobGroup, "env", "env", scalarAnchors), rootTaintedBindings, ); const matrixTaintedBindings = contextTaintedBindings( - workflowNestedMappingBindings( - jobGroup, - "strategy", - "matrix", - "matrix", - scalarAnchors, - ), + workflowJobMatrixBindings(jobGroup, scalarAnchors), jobTaintedBindings, ); - const stepTaintedBindings = contextTaintedBindings( - workflowMappingBindings(stepGroup, "env", "env", scalarAnchors), - matrixTaintedBindings, - ); - const checkoutInputs = workflowMappingBindings( - stepGroup, - "with", - "with", - scalarAnchors, - ); - if (checkoutInputs.some((input) => isUntrustedCheckoutInput( - input.name, - input.value, - stepTaintedBindings, - ))) { + if (stepGroupHasUntrustedCheckout(stepGroup, scalarAnchors, matrixTaintedBindings)) { return true; } } return false; } +function stepGroupHasUntrustedCheckout(stepGroup, scalarAnchors, inheritedTaintedBindings) { + const usesCheckout = stepGroup.properties + .filter(({ entry }) => entry.key.toLowerCase() === "uses") + .some(({ entry }) => /^actions\/checkout@/iu.test( + resolveYamlScalarValue(entry.value, scalarAnchors), + )); + if (!usesCheckout) return false; + const stepTaintedBindings = contextTaintedBindings( + workflowMappingBindings(stepGroup, "env", "env", scalarAnchors), + inheritedTaintedBindings, + ); + return workflowMappingBindings(stepGroup, "with", "with", scalarAnchors) + .some((input) => isUntrustedCheckoutInput( + input.name, + input.value, + stepTaintedBindings, + )); +} + function contextTaintedBindings(bindings, inheritedTaintedBindings) { const taintedBindings = new Set(inheritedTaintedBindings); for (const binding of bindings) { @@ -1463,13 +1670,28 @@ function isUntrustedCheckoutInput(key, value, taintedBindings = new Set()) { if (!["ref", "repository"].includes(key.toLowerCase())) return false; if (valueReferencesTaintedBinding(value, taintedBindings)) return true; if (key.toLowerCase() === "repository") { - return /(?:pull_request\.head\.repo|workflow_run\.head_repository)(?:\.|\b)/iu.test(value); + return /(?:pull_request\.head\.repo|workflow_run\.head_repository)(?:\.|\b)/iu + .test(normalizeExpressionPropertyAccess(value)); } return isUntrustedPullRequestRef(value); } function isUntrustedPullRequestRef(value) { - return /(?:github\.head_ref|pull_request\.(?:head|merge_commit_sha)|head\.sha|refs\/pull\/|workflow_run\.head_sha)/iu.test(value); + return /(?:github\.head_ref|pull_request\.(?:head|merge_commit_sha)|head\.sha|refs\/pull\/|workflow_run\.head_sha)/iu + .test(normalizeExpressionPropertyAccess(value)); +} + +function normalizeExpressionPropertyAccess(value) { + let normalized = value; + let previous; + do { + previous = normalized; + normalized = normalized.replace( + /\[\s*(["'])([A-Za-z0-9_-]+)\1\s*\]/gu, + ".$2", + ); + } while (normalized !== previous); + return normalized; } function actionReferences(text, scalarAnchors) { @@ -1491,29 +1713,36 @@ function workflowStepUsesValues(text, scalarAnchors) { function workflowStepPropertyGroups(text, scalarAnchors) { const stepGroups = []; for (const jobGroup of workflowJobPropertyGroups(text, scalarAnchors)) { - for (const property of jobGroup.properties) { - if (property.entry.key.toLowerCase() !== "steps") continue; - const inlineValue = property.entry.inlineValue ?? property.entry.value; - const resolvedValue = resolveYamlScalarValue(inlineValue, scalarAnchors); - const flowSteps = yamlFlowSequenceValues(resolvedValue); - if (flowSteps) { - for (const step of flowSteps) { - const stepGroup = workflowStepPropertyGroupFromValue( - step, - jobGroup, - scalarAnchors, - ); - if (stepGroup) stepGroups.push({ jobGroup, stepGroup }); - } - } - if (jobGroup.entries && property.index !== undefined) { - stepGroups.push(...workflowBlockStepPropertyGroups( - jobGroup, - property.entry, + stepGroups.push(...mappingContainerStepPropertyGroups(jobGroup, scalarAnchors) + .map((stepGroup) => ({ jobGroup, stepGroup }))); + } + return stepGroups; +} + +function mappingContainerStepPropertyGroups(containerGroup, scalarAnchors) { + const stepGroups = []; + for (const property of containerGroup.properties) { + if (property.entry.key.toLowerCase() !== "steps") continue; + const inlineValue = property.entry.inlineValue ?? property.entry.value; + const resolvedValue = resolveYamlScalarValue(inlineValue, scalarAnchors); + const flowSteps = yamlFlowSequenceValues(resolvedValue); + if (flowSteps) { + for (const step of flowSteps) { + const stepGroup = workflowStepPropertyGroupFromValue( + step, + containerGroup, scalarAnchors, - ).map((stepGroup) => ({ jobGroup, stepGroup }))); + ); + if (stepGroup) stepGroups.push(stepGroup); } } + if (containerGroup.entries && property.index !== undefined) { + stepGroups.push(...workflowBlockStepPropertyGroups( + containerGroup, + property.entry, + scalarAnchors, + )); + } } return stepGroups; } @@ -2053,10 +2282,29 @@ function runCommand(command, args, { cwd, encoding = "utf8" }) { } function auditGitEnvironment() { - return { - ...process.env, - GIT_NO_REPLACE_OBJECTS: "1", - }; + const environment = { ...process.env }; + for (const name of [ + "GIT_ALTERNATE_OBJECT_DIRECTORIES", + "GIT_COMMON_DIR", + "GIT_CONFIG_COUNT", + "GIT_CONFIG_GLOBAL", + "GIT_CONFIG_SYSTEM", + "GIT_DIR", + "GIT_GRAFT_FILE", + "GIT_INDEX_FILE", + "GIT_NAMESPACE", + "GIT_OBJECT_DIRECTORY", + "GIT_REPLACE_REF_BASE", + "GIT_SHALLOW_FILE", + "GIT_WORK_TREE", + ]) { + delete environment[name]; + } + for (const name of Object.keys(environment)) { + if (/^GIT_CONFIG_(?:KEY|VALUE)_\d+$/u.test(name)) delete environment[name]; + } + environment.GIT_NO_REPLACE_OBJECTS = "1"; + return environment; } function escapeRegExp(value) { diff --git a/public-source-release-audit/tests/public-source-release-audit.test.mjs b/public-source-release-audit/tests/public-source-release-audit.test.mjs index b9a19c2..11f3ed5 100644 --- a/public-source-release-audit/tests/public-source-release-audit.test.mjs +++ b/public-source-release-audit/tests/public-source-release-audit.test.mjs @@ -53,6 +53,30 @@ test("the staged candidate cannot be hidden by a clean working-tree replacement" assert.doesNotMatch(audit.stdout, new RegExp(credential, "u")); }); +test("Git environment variables cannot redirect the audited index", () => { + const repoRoot = makeRepository(); + writeSafeWorkflow(repoRoot); + commitAll(repoRoot, "add safe workflow"); + write(repoRoot, "candidate.txt", classicToken("z")); + git(repoRoot, ["add", "candidate.txt"]); + const alternateDirectory = mkdtempSync(path.join(os.tmpdir(), "public-source-index-")); + temporaryRoots.add(alternateDirectory); + const alternateIndex = path.join(alternateDirectory, "index"); + const readTree = spawnSync("git", ["read-tree", "HEAD"], { + cwd: repoRoot, + encoding: "utf8", + env: { ...process.env, GIT_INDEX_FILE: alternateIndex }, + }); + assert.equal(readTree.status, 0, readTree.stderr); + + const audit = runAudit(repoRoot, [], { + env: { ...process.env, GIT_INDEX_FILE: alternateIndex }, + }); + + assert.equal(audit.status, 1); + assertFinding(audit.result, "access-token", "candidate.txt"); +}); + test("staged blob versions do not inflate the tracked path count", () => { const repoRoot = makeRepository(); writeSafeWorkflow(repoRoot); @@ -585,6 +609,22 @@ test("flow-style workflow mappings preserve guarded key checks", () => { assertFinding(audit.result, "workflow-self-hosted-runner", ".github/workflows/flow-document.yml"); }); +test("explicit keys in flow mappings preserve guarded workflow settings", () => { + const repoRoot = makeRepository(); + write( + repoRoot, + ".github/workflows/explicit-flow-keys.yml", + "{on: push, ? permissions: write-all, jobs: {build: {? runs-on: self-hosted}}}\n", + ); + commitAll(repoRoot, "add explicit flow key workflow"); + + const audit = runAudit(repoRoot); + + assert.equal(audit.status, 1); + assertFinding(audit.result, "workflow-write-all", ".github/workflows/explicit-flow-keys.yml"); + assertFinding(audit.result, "workflow-self-hosted-runner", ".github/workflows/explicit-flow-keys.yml"); +}); + test("indented flow job mappings preserve guarded key checks", () => { const repoRoot = makeRepository(); write(repoRoot, ".github/workflows/indented-flow-jobs.yml", [ @@ -1050,6 +1090,46 @@ test("untrusted inputs propagate through local reusable workflows", () => { assertFinding(audit.result, "workflow-privileged-untrusted-checkout", ".github/workflows/input-callee.yml"); }); +test("caller matrix taint propagates into reusable workflow inputs", () => { + const repoRoot = makeRepository(); + const headExpression = ["$", "{{ github.head_ref }}"].join(""); + const matrixExpression = ["$", "{{ matrix.revision }}"].join(""); + const inputExpression = ["$", "{{ inputs.ref }}"].join(""); + write(repoRoot, ".github/workflows/matrix-caller.yml", [ + "name: matrix caller", + "on: pull_request_target", + "permissions: read-all", + "jobs:", + " call:", + " strategy:", + " matrix:", + " revision: [" + headExpression + "]", + " uses: ./.github/workflows/matrix-callee.yml", + " with:", + " ref: " + matrixExpression, + "", + ].join("\n")); + write(repoRoot, ".github/workflows/matrix-callee.yml", [ + "name: matrix callee", + "on: workflow_call", + "permissions: read-all", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - uses: actions/checkout@" + "a".repeat(40), + " with:", + " ref: " + inputExpression, + "", + ].join("\n")); + commitAll(repoRoot, "add reusable matrix checkout"); + + const audit = runAudit(repoRoot); + + assert.equal(audit.status, 1); + assertFinding(audit.result, "workflow-privileged-untrusted-checkout", ".github/workflows/matrix-callee.yml"); +}); + test("untrusted refs propagate through workflow job and step environments", () => { const repoRoot = makeRepository(); const headExpression = ["$", "{{ github.head_ref }}"].join(""); @@ -1124,6 +1204,34 @@ test("untrusted refs propagate through job matrix bindings", () => { assertFinding(audit.result, "workflow-privileged-untrusted-checkout", ".github/workflows/matrix-ref.yml"); }); +test("matrix include objects propagate untrusted checkout refs", () => { + const repoRoot = makeRepository(); + const headExpression = ["$", "{{ github.head_ref }}"].join(""); + const matrixExpression = ["$", "{{ matrix.revision }}"].join(""); + write(repoRoot, ".github/workflows/matrix-include.yml", [ + "name: matrix include", + "on: pull_request_target", + "permissions: read-all", + "jobs:", + " inspect:", + " strategy:", + " matrix:", + " include: [{revision: " + headExpression + "}]", + " runs-on: ubuntu-latest", + " steps:", + " - uses: actions/checkout@" + "a".repeat(40), + " with:", + " ref: " + matrixExpression, + "", + ].join("\n")); + commitAll(repoRoot, "add matrix include checkout"); + + const audit = runAudit(repoRoot); + + assert.equal(audit.status, 1); + assertFinding(audit.result, "workflow-privileged-untrusted-checkout", ".github/workflows/matrix-include.yml"); +}); + test("quoted runs-on keys cannot hide self-hosted labels", () => { const repoRoot = makeRepository(); write(repoRoot, ".github/workflows/quoted-runner-key.yml", [ @@ -1246,6 +1354,30 @@ test("github head_ref is an untrusted privileged checkout source", () => { assertFinding(audit.result, "workflow-privileged-untrusted-checkout", ".github/workflows/head-ref-checkout.yml"); }); +test("bracket notation preserves untrusted GitHub checkout sources", () => { + const repoRoot = makeRepository(); + const expression = ["$", "{{ github['head_ref'] }}"].join(""); + write(repoRoot, ".github/workflows/bracket-head-ref.yml", [ + "name: bracket head ref", + "on: pull_request_target", + "permissions: read-all", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - uses: actions/checkout@" + "a".repeat(40), + " with:", + " ref: " + expression, + "", + ].join("\n")); + commitAll(repoRoot, "add bracket checkout source"); + + const audit = runAudit(repoRoot); + + assert.equal(audit.status, 1); + assertFinding(audit.result, "workflow-privileged-untrusted-checkout", ".github/workflows/bracket-head-ref.yml"); +}); + test("pull-request merge refs are untrusted privileged checkout sources", () => { const repoRoot = makeRepository(); const numberExpression = ["$", "{{ github.event.pull_request.number }}"].join(""); @@ -1397,6 +1529,56 @@ test("action inputs named uses are not dependency references", () => { assert.equal(audit.result.warningCount, 0); }); +test("local composite action dependencies are audited recursively", () => { + const repoRoot = makeRepository(); + const expression = ["$", "{{ github.head_ref }}"].join(""); + write(repoRoot, ".github/workflows/local-action.yml", [ + "name: local action", + "on: pull_request_target", + "permissions: read-all", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - uses: ./.github/actions/outer", + "", + ].join("\n")); + write(repoRoot, ".github/actions/outer/action.yml", [ + "name: outer", + "runs:", + " using: composite", + " steps:", + " - uses: ./.github/actions/inner", + " - uses: example/action@main", + "", + ].join("\n")); + write(repoRoot, ".github/actions/inner/action.yml", [ + "name: inner", + "runs:", + " using: composite", + " steps:", + " - uses: actions/checkout@" + "a".repeat(40), + " with:", + " ref: " + expression, + "", + ].join("\n")); + commitAll(repoRoot, "add recursive composite actions"); + + const audit = runAudit(repoRoot, ["--fail-on-warning"]); + + assert.equal(audit.status, 1); + assertFinding( + audit.result, + "workflow-privileged-untrusted-checkout", + ".github/actions/inner/action.yml", + ); + assertFinding( + audit.result, + "workflow-mutable-action-ref", + ".github/actions/outer/action.yml", + ); +}); + test("runner-group selectors require proof of hosted isolation", () => { const repoRoot = makeRepository(); write(repoRoot, ".github/workflows/flow-runner-group.yml", [ From 6d664e0b08dede2adce099f6fcc4ce6cc72ff744 Mon Sep 17 00:00:00 2001 From: Vladimir Date: Sat, 22 Aug 2026 04:39:36 +0800 Subject: [PATCH 15/37] Close indirect workflow audit gaps --- .../scripts/public-safety-audit-core.mjs | 2 +- .../scripts/public-source-release-audit.mjs | 386 ++++++++++++++---- .../public-source-release-audit.test.mjs | 288 +++++++++++++ 3 files changed, 587 insertions(+), 89 deletions(-) diff --git a/public-source-release-audit/scripts/public-safety-audit-core.mjs b/public-source-release-audit/scripts/public-safety-audit-core.mjs index caa3f00..966dc28 100644 --- a/public-source-release-audit/scripts/public-safety-audit-core.mjs +++ b/public-source-release-audit/scripts/public-safety-audit-core.mjs @@ -54,7 +54,7 @@ const OPENPGP_ARMOR_HEADER_LINE_PATTERN = /^[^:\r\n]+: ?[^\r\n]*$/u; const OPENPGP_PAYLOAD_LINE_PATTERN = /^[A-Za-z0-9+/]+={0,2}$/u; const OPENPGP_PRIVATE_KEY_BEGIN_LINE = "-----BEGIN PGP PRIVATE KEY BLOCK-----"; const OPENPGP_PRIVATE_KEY_END_LINE = "-----END PGP PRIVATE KEY BLOCK-----"; -const PEM_LINE_BREAK_SCAN_PATTERN = /(?:\\r)?\\n|\r?\n/gu; +const PEM_LINE_BREAK_SCAN_PATTERN = /(?:\\r)?\\n|\r\n?|\n/gu; const PEM_PRIVATE_KEY_BEGIN_LINE_PATTERN = /^[ \t]*-----BEGIN ((?:[A-Z0-9 ]+ )?PRIVATE KEY)-----[ \t]*$/u; const JSON_UNICODE_ESCAPE_SEQUENCE_PATTERN = /\\u(?:[0-9a-fA-F]{4}|\{[0-9a-fA-F]{1,6}\})/u; const SOURCE_STRING_LITERAL_PATTERN = /(?:"(?:\\(?:\r\n|\r|\n|["\\/bfnrt]|u(?:[0-9a-fA-F]{4}|\{[0-9a-fA-F]{1,6}\})|x[0-9a-fA-F]{2}|[0-7]{1,3})|[^"\\\r\n])*"|'(?:\\(?:\r\n|\r|\n|['"\\/bfnrt]|u(?:[0-9a-fA-F]{4}|\{[0-9a-fA-F]{1,6}\})|x[0-9a-fA-F]{2}|[0-7]{1,3})|[^'\\\r\n])*'|`(?:\\(?:\r\n|\r|\n|[`"'\\/bfnrt]|u(?:[0-9a-fA-F]{4}|\{[0-9a-fA-F]{1,6}\})|x[0-9a-fA-F]{2}|[0-7]{1,3})|[^`\\$]|\$(?!\{))*`)/gu; diff --git a/public-source-release-audit/scripts/public-source-release-audit.mjs b/public-source-release-audit/scripts/public-source-release-audit.mjs index 26737c9..5dfedb1 100644 --- a/public-source-release-audit/scripts/public-source-release-audit.mjs +++ b/public-source-release-audit/scripts/public-source-release-audit.mjs @@ -12,6 +12,11 @@ const GITHUB_ACTIONS_APP_ID = 15368; const FULL_OBJECT_ID_PATTERN = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/iu; const IMMUTABLE_DOCKER_ACTION_PATTERN = /^docker:\/\/[^\s@]+(?:[:][^\s@]+)?@(?:sha256:[0-9a-f]{64}|sha512:[0-9a-f]{128})$/iu; const ACTION_MANIFEST_PATH_PATTERN = /(?:^|\/)action\.ya?ml$/u; +const PRIVILEGED_WORKFLOW_TRIGGERS = new Set([ + "issue_comment", + "pull_request_target", + "workflow_run", +]); const WORKFLOW_PATH_PATTERN = /^\.github\/workflows\/[^/]+\.ya?ml$/u; main(); @@ -227,10 +232,7 @@ function auditPrivilegedReusableWorkflowCalls(workflowSources) { if (analysisBySource.has(source)) return analysisBySource.get(source); const syntax = workflowSyntax(source.text); const analysis = { - hasPrivilegedTrigger: syntax.triggerNames.some((name) => [ - "pull_request_target", - "workflow_run", - ].includes(name.toLowerCase())), + hasPrivilegedTrigger: hasPrivilegedWorkflowTrigger(syntax.triggerNames), isReusable: syntax.triggerNames.some((name) => name.toLowerCase() === "workflow_call"), localCalls: localReusableWorkflowCalls(syntax.uncommented, syntax.scalarAnchors), syntax, @@ -293,26 +295,36 @@ function auditLocalCompositeActions(workflowSources, actionSources) { syntax.uncommented, "runs", syntax.scalarAnchors, - ).filter((group) => group.properties + ); + const compositeRunGroups = runGroups.filter((group) => group.properties .filter(({ entry }) => entry.key.toLowerCase() === "using") .some(({ entry }) => resolveYamlScalarValue( entry.value, syntax.scalarAnchors, ).toLowerCase() === "composite")); - const stepGroups = runGroups.flatMap((group) => mappingContainerStepPropertyGroups( + const dockerRunGroups = runGroups.filter((group) => group.properties + .filter(({ entry }) => entry.key.toLowerCase() === "using") + .some(({ entry }) => resolveYamlScalarValue( + entry.value, + syntax.scalarAnchors, + ).toLowerCase() === "docker")); + const stepGroups = compositeRunGroups.flatMap((group) => mappingContainerStepPropertyGroups( group, syntax.scalarAnchors, )); - const references = stepGroups.flatMap((stepGroup) => stepGroup.properties - .filter(({ entry }) => entry.key.toLowerCase() === "uses") - .map(({ entry }) => resolveYamlScalarValue(entry.value, syntax.scalarAnchors))); + const references = [ + ...stepGroups.flatMap((stepGroup) => stepGroup.properties + .filter(({ entry }) => entry.key.toLowerCase() === "uses") + .map(({ entry }) => resolveYamlScalarValue(entry.value, syntax.scalarAnchors))), + ...dockerRunGroups.flatMap((group) => group.properties + .filter(({ entry }) => entry.key.toLowerCase() === "image") + .map(({ entry }) => resolveYamlScalarValue(entry.value, syntax.scalarAnchors)) + .filter((image) => image.startsWith("docker://"))), + ]; const analysis = { references, - untrustedCheckout: stepGroups.some((stepGroup) => stepGroupHasUntrustedCheckout( - stepGroup, - syntax.scalarAnchors, - new Set(), - )), + stepGroups, + syntax, }; analysisBySource.set(source, analysis); return analysis; @@ -320,18 +332,25 @@ function auditLocalCompositeActions(workflowSources, actionSources) { const findings = []; for (const workflow of workflowSources) { const syntax = workflowSyntax(workflow.text); - const privileged = syntax.triggerNames.some((name) => [ - "pull_request_target", - "workflow_run", - ].includes(name.toLowerCase())); - const pending = actionReferences(syntax.uncommented, syntax.scalarAnchors) - .flatMap((reference) => localActionManifestPaths(reference)) - .map((manifestPath) => ({ depth: 1, manifestPath })); + const privileged = hasPrivilegedWorkflowTrigger(syntax.triggerNames); + const resolveManifestPath = (reference) => localActionManifestPath( + reference, + (manifestPath) => actionBySnapshotPath.has(`${workflow.snapshot}\0${manifestPath}`), + ); + const pending = workflowLocalActionCalls( + syntax.uncommented, + syntax.scalarAnchors, + new Set(), + ).flatMap((call) => { + const manifestPath = resolveManifestPath(call.reference); + return manifestPath ? [{ ...call, depth: 1, manifestPath }] : []; + }); const visited = new Set(); while (pending.length > 0) { - const { depth, manifestPath } = pending.pop(); - if (depth > 10 || visited.has(manifestPath)) continue; - visited.add(manifestPath); + const { depth, manifestPath, taintedBindings } = pending.pop(); + const stateKey = `${manifestPath}\0${[...taintedBindings].sort().join("\0")}`; + if (depth > 10 || visited.has(stateKey)) continue; + visited.add(stateKey); const action = actionBySnapshotPath.get(`${workflow.snapshot}\0${manifestPath}`); if (!action) continue; const analysis = analysisFor(action); @@ -345,12 +364,24 @@ function auditLocalCompositeActions(workflowSources, actionSources) { source: action.source, })); } - pending.push(...localActionManifestPaths(reference).map((nestedPath) => ({ + } + pending.push(...compositeLocalActionCalls( + analysis.stepGroups, + analysis.syntax.scalarAnchors, + taintedBindings, + ).flatMap((call) => { + const nestedPath = resolveManifestPath(call.reference); + return nestedPath ? [{ + ...call, depth: depth + 1, manifestPath: nestedPath, - }))); - } - if (privileged && analysis.untrustedCheckout) { + }] : []; + })); + if (privileged && analysis.stepGroups.some((stepGroup) => stepGroupHasUntrustedCheckout( + stepGroup, + analysis.syntax.scalarAnchors, + taintedBindings, + ))) { findings.push(workflowFinding({ message: "A local composite action called from a privileged workflow must not execute an untrusted checkout.", path: action.path, @@ -364,7 +395,11 @@ function auditLocalCompositeActions(workflowSources, actionSources) { return findings; } -function localActionManifestPaths(reference) { +function localActionManifestPath(reference, manifestExists) { + return localActionManifestCandidates(reference).find(manifestExists); +} + +function localActionManifestCandidates(reference) { if (!reference.startsWith("./") || /\$\{\{/u.test(reference)) return []; const actionPath = path.posix.normalize(reference.slice(2)); if (actionPath.length === 0 || actionPath === "." || actionPath === ".." @@ -390,7 +425,8 @@ function localReusableWorkflowCalls(text, scalarAnchors) { "env", scalarAnchors, ); - for (const group of workflowJobPropertyGroups(text, scalarAnchors)) { + const jobGroups = workflowJobPropertyGroups(text, scalarAnchors); + for (const group of jobGroups) { const bindings = [ ...workflowMappingBindings(group, "with", "inputs", scalarAnchors), ...workflowMappingBindings(group, "secrets", "secrets", scalarAnchors), @@ -408,8 +444,9 @@ function localReusableWorkflowCalls(text, scalarAnchors) { calls.push({ bindings, inheritSecrets, - jobEnvBindings: workflowMappingBindings(group, "env", "env", scalarAnchors), - matrixBindings: workflowJobMatrixBindings(group, scalarAnchors), + jobGroup: group, + jobGroups, + scalarAnchors, workflowEnvBindings, workflowPath: uses.slice(2), }); @@ -544,18 +581,12 @@ function workflowRootMappingBindings(text, propertyName, namespace, scalarAnchor } function reusableCallTaintedBindings(call, callerTaintedBindings) { - const workflowTaintedBindings = contextTaintedBindings( + const callSiteTaintedBindings = workflowJobTaintContexts( + call.jobGroups, call.workflowEnvBindings, + call.scalarAnchors, callerTaintedBindings, - ); - const jobTaintedBindings = contextTaintedBindings( - call.jobEnvBindings, - workflowTaintedBindings, - ); - const callSiteTaintedBindings = contextTaintedBindings( - call.matrixBindings, - jobTaintedBindings, - ); + ).get(call.jobGroup) ?? new Set(callerTaintedBindings); const taintedBindings = new Set(); for (const binding of call.bindings) { if (isUntrustedReusableValue(binding.value, callSiteTaintedBindings)) { @@ -570,6 +601,116 @@ function reusableCallTaintedBindings(call, callerTaintedBindings) { return taintedBindings; } +function workflowJobTaintContexts( + jobGroups, + workflowEnvBindings, + scalarAnchors, + inheritedTaintedBindings, +) { + const workflowTaintedBindings = contextTaintedBindings( + workflowEnvBindings, + inheritedTaintedBindings, + ); + const outputTaintedBindings = new Set(); + let changed; + do { + changed = false; + const jobInheritedBindings = mergeTaintedBindings( + workflowTaintedBindings, + outputTaintedBindings, + ); + for (const group of jobGroups) { + if (!group.jobName) continue; + const jobTaintedBindings = workflowJobTaintedBindings( + group, + scalarAnchors, + jobInheritedBindings, + ); + for (const binding of workflowMappingBindings( + group, + "outputs", + `needs.${group.jobName}.outputs`, + scalarAnchors, + )) { + const name = `${binding.namespace}.${binding.name}`; + if (!outputTaintedBindings.has(name) + && isUntrustedReusableValue(binding.value, jobTaintedBindings)) { + outputTaintedBindings.add(name); + changed = true; + } + } + } + } while (changed); + + const jobInheritedBindings = mergeTaintedBindings( + workflowTaintedBindings, + outputTaintedBindings, + ); + return new Map(jobGroups.map((group) => [ + group, + workflowJobTaintedBindings(group, scalarAnchors, jobInheritedBindings), + ])); +} + +function workflowJobTaintedBindings(group, scalarAnchors, inheritedTaintedBindings) { + const jobTaintedBindings = contextTaintedBindings( + workflowMappingBindings(group, "env", "env", scalarAnchors), + inheritedTaintedBindings, + ); + return contextTaintedBindings( + workflowJobMatrixBindings(group, scalarAnchors), + jobTaintedBindings, + ); +} + +function mergeTaintedBindings(...bindingSets) { + return new Set(bindingSets.flatMap((bindings) => [...bindings])); +} + +function workflowLocalActionCalls(text, scalarAnchors, inheritedTaintedBindings) { + const jobGroups = workflowJobPropertyGroups(text, scalarAnchors); + const contexts = workflowJobTaintContexts( + jobGroups, + workflowRootMappingBindings(text, "env", "env", scalarAnchors), + scalarAnchors, + inheritedTaintedBindings, + ); + return jobGroups.flatMap((group) => mappingContainerStepPropertyGroups( + group, + scalarAnchors, + ).flatMap((stepGroup) => localActionCallsFromStepGroup( + stepGroup, + scalarAnchors, + contexts.get(group) ?? inheritedTaintedBindings, + ))); +} + +function compositeLocalActionCalls(stepGroups, scalarAnchors, inheritedTaintedBindings) { + return stepGroups.flatMap((stepGroup) => localActionCallsFromStepGroup( + stepGroup, + scalarAnchors, + inheritedTaintedBindings, + )); +} + +function localActionCallsFromStepGroup(stepGroup, scalarAnchors, inheritedTaintedBindings) { + const stepTaintedBindings = contextTaintedBindings( + workflowMappingBindings(stepGroup, "env", "env", scalarAnchors), + inheritedTaintedBindings, + ); + const taintedBindings = new Set(); + for (const binding of workflowMappingBindings(stepGroup, "with", "inputs", scalarAnchors)) { + if (isUntrustedReusableValue(binding.value, stepTaintedBindings)) { + taintedBindings.add(`${binding.namespace}.${binding.name}`); + } + } + return stepGroup.properties + .filter(({ entry }) => entry.key.toLowerCase() === "uses") + .map(({ entry }) => resolveYamlScalarValue(entry.value, scalarAnchors)) + .filter((reference) => localActionManifestCandidates(reference).length > 0) + .map((reference) => ({ reference, taintedBindings })); +} + function isUntrustedReusableValue(value, taintedBindings) { return isUntrustedPullRequestRef(value) || /(?:pull_request\.head\.repo|workflow_run\.head_repository)(?:\.|\b)/iu @@ -698,8 +839,7 @@ function auditWorkflowText(workflowPath, text, source = "tracked-file") { const { scalarAnchors, triggerNames, uncommented } = workflowSyntax(text); const hasPullRequestTarget = triggerNames .some((eventName) => eventName.toLowerCase() === "pull_request_target"); - const hasWorkflowRun = triggerNames - .some((eventName) => eventName.toLowerCase() === "workflow_run"); + const hasPrivilegedTrigger = hasPrivilegedWorkflowTrigger(triggerNames); const hasWriteAll = [ ...workflowRootValues(uncommented, "permissions", scalarAnchors), ...workflowJobValues(uncommented, "permissions", scalarAnchors), @@ -749,7 +889,7 @@ function auditWorkflowText(workflowPath, text, source = "tracked-file") { } - if ((hasPullRequestTarget || hasWorkflowRun) + if (hasPrivilegedTrigger && hasUntrustedPullRequestCheckout(uncommented, scalarAnchors)) { findings.push(workflowFinding({ message: "A privileged workflow must not execute an untrusted checkout.", @@ -775,6 +915,10 @@ function auditWorkflowText(workflowPath, text, source = "tracked-file") { return findings; } +function hasPrivilegedWorkflowTrigger(triggerNames) { + return triggerNames.some((name) => PRIVILEGED_WORKFLOW_TRIGGERS.has(name.toLowerCase())); +} + function stripYamlComment(line) { let singleQuoted = false; let doubleQuoted = false; @@ -933,28 +1077,21 @@ function workflowJobPropertyGroups(text, scalarAnchors) { for (let index = 0; index < entries.length; index += 1) { const jobsEntry = entries[index]; if (jobsEntry.indentation !== 0 || jobsEntry.key.toLowerCase() !== "jobs") continue; - for (const job of directBlockMappingChildren(entries, index)) { - const properties = directBlockMappingChildren(entries, job.index); - if (properties.length > 0) { - groups.push({ blockNodeAnchors, entries, properties, text }); - } - const aliasProperties = yamlBlockAliasMappingEntries( - job.entry.inlineValue, + const blockJobs = [ + ...directBlockMappingChildren(entries, index), + ...yamlBlockAliasMappingEntries( + jobsEntry.inlineValue, blockNodeAnchors, scalarAnchors, - ); - if (aliasProperties.length > 0) { - groups.push({ blockNodeAnchors, entries, properties: aliasProperties, text }); - } - const flowProperties = yamlDirectFlowMappingEntries( - resolveYamlScalarValue(job.entry.inlineValue, scalarAnchors), - ).map((entry) => ({ - entry: { ...entry, key: resolveYamlScalarValue(entry.key, scalarAnchors) }, - })); - if (flowProperties.length > 0) { - groups.push({ blockNodeAnchors, entries: undefined, properties: flowProperties, text }); - } - } + ), + ]; + groups.push(...workflowJobPropertyGroupsFromBlockJobs( + blockJobs, + entries, + scalarAnchors, + blockNodeAnchors, + text, + )); groups.push(...workflowJobPropertyGroupsFromFlowJobs( jobsEntry.value, scalarAnchors, @@ -973,17 +1110,44 @@ function workflowJobPropertyGroups(text, scalarAnchors) { return groups; } +function workflowJobPropertyGroupsFromBlockJobs( + jobs, + entries, + scalarAnchors, + blockNodeAnchors, + text, +) { + const groups = []; + for (const job of jobs) { + const jobName = resolveYamlScalarValue(job.entry.key, scalarAnchors).toLowerCase(); + const properties = directBlockMappingChildren(entries, job.index); + if (properties.length > 0) { + groups.push({ blockNodeAnchors, entries, jobName, properties, text }); + } + const aliasProperties = yamlBlockAliasMappingEntries( + job.entry.inlineValue, + blockNodeAnchors, + scalarAnchors, + ); + if (aliasProperties.length > 0) { + groups.push({ blockNodeAnchors, entries, jobName, properties: aliasProperties, text }); + } + const flowProperties = yamlDirectFlowMappingEntries( + resolveYamlScalarValue(job.entry.inlineValue, scalarAnchors), + ).map((entry) => ({ + entry: { ...entry, key: resolveYamlScalarValue(entry.key, scalarAnchors) }, + })); + if (flowProperties.length > 0) { + groups.push({ blockNodeAnchors, entries: undefined, jobName, properties: flowProperties, text }); + } + } + return groups; +} + function yamlBlockNodeAnchors(entries) { const anchors = new Map(); for (let index = 0; index < entries.length; index += 1) { - let value = entries[index].inlineValue.trim(); - let anchorName; - while (true) { - const property = /^(&([^\s]+)|![^\s]+)(?:\s+|$)/u.exec(value); - if (!property) break; - if (property[2]) anchorName = property[2]; - value = value.slice(property[0].length); - } + const anchorName = yamlBlockNodeAnchorName(entries[index].inlineValue); if (!anchorName) continue; const children = directBlockMappingChildren(entries, index); if (children.length > 0) anchors.set(anchorName, children); @@ -991,6 +1155,17 @@ function yamlBlockNodeAnchors(entries) { return anchors; } +function yamlBlockNodeAnchorName(value) { + let remaining = value.trim(); + let anchorName; + while (true) { + const property = /^(&([^\s]+)|![^\s]+)(?:\s+|$)/u.exec(remaining); + if (!property) return anchorName; + if (property[2]) anchorName = property[2]; + remaining = remaining.slice(property[0].length); + } +} + function yamlBlockAliasMappingEntries(value, blockNodeAnchors, scalarAnchors) { const visited = new Set(); let current = yamlScalarValue(value); @@ -1005,6 +1180,20 @@ function yamlBlockAliasMappingEntries(value, blockNodeAnchors, scalarAnchors) { } } +function yamlBlockSequenceAliasParentEntry(value, entries, scalarAnchors) { + const visited = new Set(); + let current = yamlScalarValue(value); + while (true) { + const alias = /^\*([^\s]+)$/u.exec(current); + if (!alias || visited.has(alias[1])) return undefined; + visited.add(alias[1]); + const parent = entries.find((entry) => yamlBlockNodeAnchorName(entry.inlineValue) === alias[1]); + if (parent) return parent; + if (!scalarAnchors.has(alias[1])) return undefined; + current = yamlScalarValue(scalarAnchors.get(alias[1])); + } +} + function directBlockMappingChildren(entries, parentIndex) { const parent = entries[parentIndex]; const descendants = []; @@ -1021,12 +1210,13 @@ function directBlockMappingChildren(entries, parentIndex) { function workflowJobPropertyGroupsFromFlowJobs(value, scalarAnchors, blockNodeAnchors, text) { const jobsValue = resolveYamlScalarValue(value, scalarAnchors); return yamlDirectFlowMappingEntries(jobsValue).flatMap((jobEntry) => { + const jobName = resolveYamlScalarValue(jobEntry.key, scalarAnchors).toLowerCase(); const jobValue = resolveYamlScalarValue(jobEntry.value, scalarAnchors); const properties = yamlDirectFlowMappingEntries(jobValue).map((entry) => ({ entry: { ...entry, key: resolveYamlScalarValue(entry.key, scalarAnchors) }, })); return properties.length > 0 - ? [{ blockNodeAnchors, entries: undefined, properties, text }] + ? [{ blockNodeAnchors, entries: undefined, jobName, properties, text }] : []; }); } @@ -1607,21 +1797,22 @@ function readYamlFlowKey(text, startIndex) { function hasUntrustedPullRequestCheckout(text, scalarAnchors, taintedBindings = new Set()) { text = maskYamlBlockScalarBodies(text); - const rootTaintedBindings = contextTaintedBindings( + const jobGroups = workflowJobPropertyGroups(text, scalarAnchors); + const jobTaintContexts = workflowJobTaintContexts( + jobGroups, workflowRootMappingBindings(text, "env", "env", scalarAnchors), + scalarAnchors, taintedBindings, ); - for (const { jobGroup, stepGroup } of workflowStepPropertyGroups(text, scalarAnchors)) { - const jobTaintedBindings = contextTaintedBindings( - workflowMappingBindings(jobGroup, "env", "env", scalarAnchors), - rootTaintedBindings, - ); - const matrixTaintedBindings = contextTaintedBindings( - workflowJobMatrixBindings(jobGroup, scalarAnchors), - jobTaintedBindings, - ); - if (stepGroupHasUntrustedCheckout(stepGroup, scalarAnchors, matrixTaintedBindings)) { - return true; + for (const jobGroup of jobGroups) { + for (const stepGroup of mappingContainerStepPropertyGroups(jobGroup, scalarAnchors)) { + if (stepGroupHasUntrustedCheckout( + stepGroup, + scalarAnchors, + jobTaintContexts.get(jobGroup) ?? taintedBindings, + )) { + return true; + } } } return false; @@ -1742,6 +1933,18 @@ function mappingContainerStepPropertyGroups(containerGroup, scalarAnchors) { property.entry, scalarAnchors, )); + const aliasParent = yamlBlockSequenceAliasParentEntry( + inlineValue, + containerGroup.entries, + scalarAnchors, + ); + if (aliasParent) { + stepGroups.push(...workflowBlockStepPropertyGroups( + containerGroup, + aliasParent, + scalarAnchors, + )); + } } } return stepGroups; @@ -1896,7 +2099,14 @@ function loadGithubEvidence({ repoRoot, repository, snapshotPath }) { } function ghApiPaginatedArray(repoRoot, endpoint) { - const result = spawnSync("gh", ["api", "--paginate", "--slurp", endpoint], { + const result = spawnSync("gh", [ + "api", + "--hostname", + "github.com", + "--paginate", + "--slurp", + endpoint, + ], { cwd: repoRoot, encoding: "utf8", env: process.env, @@ -1917,7 +2127,7 @@ function ghApiPaginatedArray(repoRoot, endpoint) { } function ghApiJson(repoRoot, endpoint, { allowNotFound = false } = {}) { - const result = spawnSync("gh", ["api", endpoint], { + const result = spawnSync("gh", ["api", "--hostname", "github.com", endpoint], { cwd: repoRoot, encoding: "utf8", env: process.env, diff --git a/public-source-release-audit/tests/public-source-release-audit.test.mjs b/public-source-release-audit/tests/public-source-release-audit.test.mjs index 11f3ed5..c577c9b 100644 --- a/public-source-release-audit/tests/public-source-release-audit.test.mjs +++ b/public-source-release-audit/tests/public-source-release-audit.test.mjs @@ -126,6 +126,23 @@ test("credential and private-key formats are release-blocking", () => { assertFinding(audit.result, "private-key", "private-key.txt"); }); +test("CR-only private-key blocks are detected by buffered and streaming scans", () => { + const repoRoot = makeRepository(); + const crOnlyKey = privateKeyBlock().replaceAll("\n", "\r"); + write(repoRoot, "cr-private-key.txt", crOnlyKey); + write(repoRoot, "large-cr-private-key.txt", Buffer.concat([ + Buffer.alloc(64 * 1024 * 1024, 0x78), + Buffer.from("\r" + crOnlyKey), + ])); + commitAll(repoRoot, "add CR-only private-key fixtures"); + + const audit = runAudit(repoRoot); + + assert.equal(audit.status, 1); + assertFinding(audit.result, "private-key", "cr-private-key.txt"); + assertFinding(audit.result, "private-key", "large-cr-private-key.txt"); +}); + test("private POSIX, Windows, and root machine paths are release-blocking", () => { const repoRoot = makeRepository(); write(repoRoot, "posix.txt", ["", "Users", "private-person", "project"].join("/")); @@ -755,6 +772,27 @@ test("block-node aliases used as jobs preserve guarded properties", () => { assertFinding(audit.result, "workflow-self-hosted-runner", ".github/workflows/aliased-job.yml"); }); +test("block-node aliases used as the complete jobs mapping remain guarded", () => { + const repoRoot = makeRepository(); + write(repoRoot, ".github/workflows/aliased-jobs.yml", [ + "name: aliased jobs", + "on: push", + "x-jobs: &unsafe-jobs", + " build:", + " permissions: write-all", + " runs-on: self-hosted", + "jobs: *unsafe-jobs", + "", + ].join("\n")); + commitAll(repoRoot, "add aliased jobs workflow"); + + const audit = runAudit(repoRoot); + + assert.equal(audit.status, 1); + assertFinding(audit.result, "workflow-write-all", ".github/workflows/aliased-jobs.yml"); + assertFinding(audit.result, "workflow-self-hosted-runner", ".github/workflows/aliased-jobs.yml"); +}); + test("block-node aliases used as complete steps remain guarded", () => { const repoRoot = makeRepository(); const expression = ["$", "{{ github.head_ref }}"].join(""); @@ -781,6 +819,32 @@ test("block-node aliases used as complete steps remain guarded", () => { assertFinding(audit.result, "workflow-privileged-untrusted-checkout", ".github/workflows/aliased-step.yml"); }); +test("block-node aliases used as the complete steps sequence remain guarded", () => { + const repoRoot = makeRepository(); + const expression = ["$", "{{ github.head_ref }}"].join(""); + write(repoRoot, ".github/workflows/aliased-steps.yml", [ + "name: aliased steps", + "on: pull_request_target", + "permissions: read-all", + "x-steps: &unsafe-steps", + " - uses: actions/checkout@" + "a".repeat(40), + " with:", + " ref: " + expression, + " - run: ./execute-reviewed-tree", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps: *unsafe-steps", + "", + ].join("\n")); + commitAll(repoRoot, "add aliased steps workflow"); + + const audit = runAudit(repoRoot); + + assert.equal(audit.status, 1); + assertFinding(audit.result, "workflow-privileged-untrusted-checkout", ".github/workflows/aliased-steps.yml"); +}); + test("block scalar script bodies are not parsed as workflow mappings", () => { const repoRoot = makeRepository(); write(repoRoot, ".github/workflows/generated-yaml.yml", [ @@ -1176,6 +1240,38 @@ test("untrusted refs propagate through workflow job and step environments", () = } }); +test("untrusted refs propagate through job outputs", () => { + const repoRoot = makeRepository(); + const headExpression = ["$", "{{ github.head_ref }}"].join(""); + const outputExpression = ["$", "{{ needs.source.outputs.revision }}"].join(""); + write(repoRoot, ".github/workflows/job-output-ref.yml", [ + "name: job output ref", + "on: pull_request_target", + "permissions: read-all", + "jobs:", + " source:", + " runs-on: ubuntu-latest", + " outputs:", + " revision: " + headExpression, + " steps:", + " - run: echo source", + " inspect:", + " needs: source", + " runs-on: ubuntu-latest", + " steps:", + " - uses: actions/checkout@" + "a".repeat(40), + " with:", + " ref: " + outputExpression, + "", + ].join("\n")); + commitAll(repoRoot, "add job output checkout workflow"); + + const audit = runAudit(repoRoot); + + assert.equal(audit.status, 1); + assertFinding(audit.result, "workflow-privileged-untrusted-checkout", ".github/workflows/job-output-ref.yml"); +}); + test("untrusted refs propagate through job matrix bindings", () => { const repoRoot = makeRepository(); const headExpression = ["$", "{{ github.head_ref }}"].join(""); @@ -1431,6 +1527,31 @@ test("workflow_run head checkouts are privileged and untrusted", () => { assertFinding(audit.result, "workflow-privileged-untrusted-checkout", ".github/workflows/workflow-run-checkout.yml"); }); +test("issue_comment pull-request checkouts are privileged and untrusted", () => { + const repoRoot = makeRepository(); + const issueExpression = ["$", "{{ github.event.issue.number }}"].join(""); + write(repoRoot, ".github/workflows/issue-comment-checkout.yml", [ + "name: issue comment checkout", + "on: issue_comment", + "permissions:", + " contents: write", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - uses: actions/checkout@" + "a".repeat(40), + " with:", + " ref: refs/pull/" + issueExpression + "/head", + "", + ].join("\n")); + commitAll(repoRoot, "add issue comment checkout"); + + const audit = runAudit(repoRoot); + + assert.equal(audit.status, 1); + assertFinding(audit.result, "workflow-privileged-untrusted-checkout", ".github/workflows/issue-comment-checkout.yml"); +}); + test("mutable Docker actions warn while digest-pinned actions pass", () => { const repoRoot = makeRepository(); write(repoRoot, ".github/workflows/mutable-docker.yml", [ @@ -1579,6 +1700,144 @@ test("local composite action dependencies are audited recursively", () => { ); }); +test("caller taint propagates into local composite action inputs", () => { + const repoRoot = makeRepository(); + const headExpression = ["$", "{{ github.head_ref }}"].join(""); + const inputExpression = ["$", "{{ inputs.ref }}"].join(""); + write(repoRoot, ".github/workflows/composite-input.yml", [ + "name: composite input", + "on: pull_request_target", + "permissions: read-all", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - uses: ./.github/actions/input-checkout", + " with:", + " ref: " + headExpression, + "", + ].join("\n")); + write(repoRoot, ".github/actions/input-checkout/action.yml", [ + "name: input checkout", + "inputs:", + " ref:", + " required: true", + "runs:", + " using: composite", + " steps:", + " - uses: actions/checkout@" + "a".repeat(40), + " with:", + " ref: " + inputExpression, + "", + ].join("\n")); + commitAll(repoRoot, "add composite input checkout"); + + const audit = runAudit(repoRoot); + + assert.equal(audit.status, 1); + assertFinding( + audit.result, + "workflow-privileged-untrusted-checkout", + ".github/actions/input-checkout/action.yml", + ); +}); + +test("action.yml takes precedence while action.yaml remains a fallback", () => { + const repoRoot = makeRepository(); + write(repoRoot, ".github/workflows/action-manifest-precedence.yml", [ + "name: action manifest precedence", + "on: push", + "permissions: read-all", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - uses: ./.github/actions/preferred", + " - uses: ./.github/actions/yaml-only", + "", + ].join("\n")); + write(repoRoot, ".github/actions/preferred/action.yml", [ + "name: preferred", + "runs:", + " using: composite", + " steps:", + " - uses: example/action@" + "a".repeat(40), + "", + ].join("\n")); + write(repoRoot, ".github/actions/preferred/action.yaml", [ + "name: unused fallback", + "runs:", + " using: composite", + " steps:", + " - uses: example/action@main", + "", + ].join("\n")); + write(repoRoot, ".github/actions/yaml-only/action.yaml", [ + "name: yaml fallback", + "runs:", + " using: composite", + " steps:", + " - uses: example/action@main", + "", + ].join("\n")); + commitAll(repoRoot, "add action manifest precedence fixtures"); + + const audit = runAudit(repoRoot, ["--fail-on-warning"]); + + assert.equal(audit.status, 1); + assertFinding( + audit.result, + "workflow-mutable-action-ref", + ".github/actions/yaml-only/action.yaml", + ); + assert.equal(audit.result.findings.some((finding) => ( + finding.path === ".github/actions/preferred/action.yaml" + )), false); +}); + +test("local Docker action images require immutable digests", () => { + const repoRoot = makeRepository(); + write(repoRoot, ".github/workflows/local-docker-actions.yml", [ + "name: local Docker actions", + "on: push", + "permissions: read-all", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - uses: ./.github/actions/mutable-image", + " - uses: ./.github/actions/pinned-image", + "", + ].join("\n")); + write(repoRoot, ".github/actions/mutable-image/action.yml", [ + "name: mutable image", + "runs:", + " using: docker", + " image: docker://alpine:latest", + "", + ].join("\n")); + write(repoRoot, ".github/actions/pinned-image/action.yml", [ + "name: pinned image", + "runs:", + " using: docker", + ` image: docker://alpine@sha256:${"a".repeat(64)}`, + "", + ].join("\n")); + commitAll(repoRoot, "add local Docker action fixtures"); + + const audit = runAudit(repoRoot, ["--fail-on-warning"]); + + assert.equal(audit.status, 1); + assertFinding( + audit.result, + "workflow-mutable-action-ref", + ".github/actions/mutable-image/action.yml", + ); + assert.equal(audit.result.findings.some((finding) => ( + finding.path === ".github/actions/pinned-image/action.yml" + )), false); +}); + test("runner-group selectors require proof of hosted isolation", () => { const repoRoot = makeRepository(); write(repoRoot, ".github/workflows/flow-runner-group.yml", [ @@ -1913,6 +2172,33 @@ test("live GitHub evidence consumes every ruleset page", () => { assertFinding(audit.result, "github-protection-bypass"); }); +test("live GitHub evidence is pinned to github.com", () => { + const repoRoot = makeRepository(); + writeSafeWorkflow(repoRoot); + commitAll(repoRoot, "add safe workflow"); + const snapshot = githubSnapshot(); + const fakeGhDirectory = writeFakeGh({ + branchProtection: snapshot.branchProtection, + repository: snapshot.repository, + rulesetPages: [[{ id: 1 }]], + rulesets: { 1: snapshot.rulesets[0] }, + runners: snapshot.runners, + }); + + const audit = runAudit(repoRoot, [ + "--github", "example/public", + "--required-check", "verify", + ], { + env: { + ...process.env, + GH_HOST: "enterprise.example.invalid", + PATH: `${fakeGhDirectory}:${process.env.PATH}`, + }, + }); + + assert.equal(audit.status, 0); +}); + test("wrapper counts source findings omitted by the core scanner", () => { const repoRoot = makeRepository(); for (let index = 0; index < 30; index += 1) { @@ -2151,6 +2437,8 @@ function writeFakeGh(fixture) { "#!/usr/bin/env node", `const fixture = ${JSON.stringify(fixture)};`, "const args = process.argv.slice(2);", + "const hostnameIndex = args.indexOf('--hostname');", + "if (hostnameIndex < 0 || args[hostnameIndex + 1] !== 'github.com') process.exit(5);", "const endpoint = args.find((argument) => argument.startsWith('repos/'));", "let response;", "if (endpoint === 'repos/example/public') response = fixture.repository;", From 35f5433c4fbc170b88a4883518fe27c198fb7ab9 Mon Sep 17 00:00:00 2001 From: Vladimir Date: Sat, 22 Aug 2026 04:54:34 +0800 Subject: [PATCH 16/37] Harden derived workflow input checks --- .../scripts/public-source-release-audit.mjs | 135 ++++++++++------ .../public-source-release-audit.test.mjs | 146 ++++++++++++++++++ 2 files changed, 235 insertions(+), 46 deletions(-) diff --git a/public-source-release-audit/scripts/public-source-release-audit.mjs b/public-source-release-audit/scripts/public-source-release-audit.mjs index 5dfedb1..7b9f4b3 100644 --- a/public-source-release-audit/scripts/public-source-release-audit.mjs +++ b/public-source-release-audit/scripts/public-source-release-audit.mjs @@ -322,6 +322,10 @@ function auditLocalCompositeActions(workflowSources, actionSources) { .filter((image) => image.startsWith("docker://"))), ]; const analysis = { + inputDefaultBindings: actionInputDefaultBindings( + syntax.uncommented, + syntax.scalarAnchors, + ), references, stepGroups, syntax, @@ -347,13 +351,19 @@ function auditLocalCompositeActions(workflowSources, actionSources) { }); const visited = new Set(); while (pending.length > 0) { - const { depth, manifestPath, taintedBindings } = pending.pop(); - const stateKey = `${manifestPath}\0${[...taintedBindings].sort().join("\0")}`; - if (depth > 10 || visited.has(stateKey)) continue; - visited.add(stateKey); + const { depth, manifestPath, providedBindings, taintedBindings } = pending.pop(); + if (depth > 10) continue; const action = actionBySnapshotPath.get(`${workflow.snapshot}\0${manifestPath}`); if (!action) continue; const analysis = analysisFor(action); + const effectiveTaintedBindings = actionInputTaintedBindings( + analysis.inputDefaultBindings, + taintedBindings, + providedBindings, + ); + const stateKey = `${manifestPath}\0${[...effectiveTaintedBindings].sort().join("\0")}`; + if (visited.has(stateKey)) continue; + visited.add(stateKey); for (const reference of analysis.references) { if (isMutableRemoteActionReference(reference)) { findings.push(workflowFinding({ @@ -368,7 +378,7 @@ function auditLocalCompositeActions(workflowSources, actionSources) { pending.push(...compositeLocalActionCalls( analysis.stepGroups, analysis.syntax.scalarAnchors, - taintedBindings, + effectiveTaintedBindings, ).flatMap((call) => { const nestedPath = resolveManifestPath(call.reference); return nestedPath ? [{ @@ -380,7 +390,7 @@ function auditLocalCompositeActions(workflowSources, actionSources) { if (privileged && analysis.stepGroups.some((stepGroup) => stepGroupHasUntrustedCheckout( stepGroup, analysis.syntax.scalarAnchors, - taintedBindings, + effectiveTaintedBindings, ))) { findings.push(workflowFinding({ message: "A local composite action called from a privileged workflow must not execute an untrusted checkout.", @@ -399,6 +409,39 @@ function localActionManifestPath(reference, manifestExists) { return localActionManifestCandidates(reference).find(manifestExists); } +function actionInputDefaultBindings(text, scalarAnchors) { + return workflowRootContainerGroups(text, "inputs", scalarAnchors) + .flatMap((group) => group.properties.flatMap((inputProperty) => ( + workflowPropertyMappingEntries(group, inputProperty, scalarAnchors) + .filter(({ entry }) => entry.key.toLowerCase() === "default") + .map(({ entry }) => ({ + name: resolveYamlScalarValue(inputProperty.entry.key, scalarAnchors).toLowerCase(), + namespace: "inputs", + value: resolveYamlScalarValue(entry.value, scalarAnchors), + })) + ))); +} + +function actionInputTaintedBindings(defaultBindings, callerTaintedBindings, providedBindings) { + const taintedBindings = new Set(callerTaintedBindings); + const defaults = defaultBindings.filter((binding) => ( + !providedBindings.has(`${binding.namespace}.${binding.name}`) + )); + let changed; + do { + changed = false; + for (const binding of defaults) { + const name = `${binding.namespace}.${binding.name}`; + if (!taintedBindings.has(name) + && isUntrustedReusableValue(binding.value, taintedBindings)) { + taintedBindings.add(name); + changed = true; + } + } + } while (changed); + return taintedBindings; +} + function localActionManifestCandidates(reference) { if (!reference.startsWith("./") || /\$\{\{/u.test(reference)) return []; const actionPath = path.posix.normalize(reference.slice(2)); @@ -698,8 +741,12 @@ function localActionCallsFromStepGroup(stepGroup, scalarAnchors, inheritedTainte workflowMappingBindings(stepGroup, "env", "env", scalarAnchors), inheritedTaintedBindings, ); + const inputBindings = workflowMappingBindings(stepGroup, "with", "inputs", scalarAnchors); + const providedBindings = new Set(inputBindings.map((binding) => ( + `${binding.namespace}.${binding.name}` + ))); const taintedBindings = new Set(); - for (const binding of workflowMappingBindings(stepGroup, "with", "inputs", scalarAnchors)) { + for (const binding of inputBindings) { if (isUntrustedReusableValue(binding.value, stepTaintedBindings)) { taintedBindings.add(`${binding.namespace}.${binding.name}`); } @@ -708,7 +755,7 @@ function localActionCallsFromStepGroup(stepGroup, scalarAnchors, inheritedTainte .filter(({ entry }) => entry.key.toLowerCase() === "uses") .map(({ entry }) => resolveYamlScalarValue(entry.value, scalarAnchors)) .filter((reference) => localActionManifestCandidates(reference).length > 0) - .map((reference) => ({ reference, taintedBindings })); + .map((reference) => ({ providedBindings, reference, taintedBindings })); } function isUntrustedReusableValue(value, taintedBindings) { @@ -1868,7 +1915,7 @@ function isUntrustedCheckoutInput(key, value, taintedBindings = new Set()) { } function isUntrustedPullRequestRef(value) { - return /(?:github\.head_ref|pull_request\.(?:head|merge_commit_sha)|head\.sha|refs\/pull\/|workflow_run\.head_sha)/iu + return /(?:github\.head_ref|github\.event\.issue\.number|pull_request\.(?:head|merge_commit_sha)|head\.sha|refs\/pull\/|workflow_run\.head_sha)/iu .test(normalizeExpressionPropertyAccess(value)); } @@ -1933,18 +1980,20 @@ function mappingContainerStepPropertyGroups(containerGroup, scalarAnchors) { property.entry, scalarAnchors, )); - const aliasParent = yamlBlockSequenceAliasParentEntry( - inlineValue, - containerGroup.entries, + } + const entries = containerGroup.entries + ?? yamlBlockMappingEntries(containerGroup.text, scalarAnchors); + const aliasParent = yamlBlockSequenceAliasParentEntry( + inlineValue, + entries, + scalarAnchors, + ); + if (aliasParent) { + stepGroups.push(...workflowBlockStepPropertyGroups( + { ...containerGroup, entries }, + aliasParent, scalarAnchors, - ); - if (aliasParent) { - stepGroups.push(...workflowBlockStepPropertyGroups( - containerGroup, - aliasParent, - scalarAnchors, - )); - } + )); } } return stepGroups; @@ -2203,8 +2252,10 @@ function auditGithubControls(evidence, requiredChecks) { ...classicChecks .filter((check) => check.app_id === GITHUB_ACTIONS_APP_ID), ].map((check) => check.context).filter((context) => typeof context === "string")); - const strictRequiredContexts = new Set([...rulesetChecks, ...classicChecks] - .filter((check) => check.strict) + const strictGithubActionsContexts = new Set([...rulesetChecks, ...classicChecks] + .filter((check) => check.strict + && (check.integration_id === GITHUB_ACTIONS_APP_ID + || check.app_id === GITHUB_ACTIONS_APP_ID)) .map((check) => check.context) .filter((context) => typeof context === "string")); const forcePushProtected = rules.some((rule) => rule.type === "non_fast_forward") @@ -2229,20 +2280,15 @@ function auditGithubControls(evidence, requiredChecks) { if (classic?.enforce_admins?.enabled === true) { unbypassableStatusChecks.push(...classicChecks); } - const unbypassableStrictContexts = new Set(unbypassableStatusChecks - .filter((check) => check.strict) - .map((check) => check.context) - .filter((context) => typeof context === "string")); - const unbypassableGithubActionsContexts = new Set(unbypassableStatusChecks - .filter((check) => check.integration_id === GITHUB_ACTIONS_APP_ID - || check.app_id === GITHUB_ACTIONS_APP_ID) + const unbypassableStrictGithubActionsContexts = new Set(unbypassableStatusChecks + .filter((check) => check.strict + && (check.integration_id === GITHUB_ACTIONS_APP_ID + || check.app_id === GITHUB_ACTIONS_APP_ID)) .map((check) => check.context) .filter((context) => typeof context === "string")); const statusChecksAreUnbypassable = requiredChecks.length > 0 - ? requiredChecks.every((context) => unbypassableStrictContexts.has(context) - && unbypassableGithubActionsContexts.has(context)) - : [...unbypassableStrictContexts] - .some((context) => unbypassableGithubActionsContexts.has(context)); + ? requiredChecks.every((context) => unbypassableStrictGithubActionsContexts.has(context)) + : unbypassableStrictGithubActionsContexts.size > 0; const forcePushProtectionIsUnbypassable = unbypassableRules .some((rule) => rule.type === "non_fast_forward") || (classic?.enforce_admins?.enabled === true @@ -2273,21 +2319,18 @@ function auditGithubControls(evidence, requiredChecks) { `Required status check ${requiredCheck} is not bound to GitHub Actions.`, requiredCheck, )); + } else if (!strictGithubActionsContexts.has(requiredCheck)) { + findings.push(githubFinding( + "github-required-check-not-strict", + `Required status check ${requiredCheck} does not enforce an up-to-date default branch as a GitHub Actions check.`, + requiredCheck, + )); } } - const strictnessContexts = requiredChecks.length > 0 - ? requiredChecks.filter((context) => requiredContexts.has(context)) - : [...requiredContexts]; - const nonStrictContexts = strictnessContexts - .filter((context) => !strictRequiredContexts.has(context)); - for (const context of nonStrictContexts) { - findings.push(githubFinding( - "github-required-check-not-strict", - `Required status check ${context} does not enforce an up-to-date default branch.`, - context, - )); - } - if (nonStrictContexts.length === 0 && strictRequiredContexts.size === 0) { + if (strictGithubActionsContexts.size === 0 + && (requiredChecks.length === 0 + || !requiredChecks.some((context) => requiredContexts.has(context) + && githubActionsContexts.has(context)))) { findings.push(githubFinding("github-required-check-not-strict", "Required checks must enforce an up-to-date default branch.")); } if (forcePushProtected === false) { diff --git a/public-source-release-audit/tests/public-source-release-audit.test.mjs b/public-source-release-audit/tests/public-source-release-audit.test.mjs index c577c9b..926cf32 100644 --- a/public-source-release-audit/tests/public-source-release-audit.test.mjs +++ b/public-source-release-audit/tests/public-source-release-audit.test.mjs @@ -1552,6 +1552,38 @@ test("issue_comment pull-request checkouts are privileged and untrusted", () => assertFinding(audit.result, "workflow-privileged-untrusted-checkout", ".github/workflows/issue-comment-checkout.yml"); }); +test("constructed issue_comment pull-request refs remain untrusted", () => { + const repoRoot = makeRepository(); + const expression = [ + "$", + "{{ format('refs/{0}/{1}/head', 'pull', github.event.issue.number) }}", + ].join(""); + write(repoRoot, ".github/workflows/constructed-issue-comment-checkout.yml", [ + "name: constructed issue comment checkout", + "on: issue_comment", + "permissions:", + " contents: write", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - uses: actions/checkout@" + "a".repeat(40), + " with:", + " ref: " + expression, + "", + ].join("\n")); + commitAll(repoRoot, "add constructed issue comment checkout"); + + const audit = runAudit(repoRoot); + + assert.equal(audit.status, 1); + assertFinding( + audit.result, + "workflow-privileged-untrusted-checkout", + ".github/workflows/constructed-issue-comment-checkout.yml", + ); +}); + test("mutable Docker actions warn while digest-pinned actions pass", () => { const repoRoot = makeRepository(); write(repoRoot, ".github/workflows/mutable-docker.yml", [ @@ -1700,6 +1732,41 @@ test("local composite action dependencies are audited recursively", () => { ); }); +test("flow-style composite runs expand block sequence aliases", () => { + const repoRoot = makeRepository(); + const expression = ["$", "{{ github.head_ref }}"].join(""); + write(repoRoot, ".github/workflows/flow-composite-alias.yml", [ + "name: flow composite alias", + "on: pull_request_target", + "permissions: read-all", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - uses: ./.github/actions/flow-composite-alias", + "", + ].join("\n")); + write(repoRoot, ".github/actions/flow-composite-alias/action.yml", [ + "name: flow composite alias", + "x-steps: &unsafe-steps", + " - uses: actions/checkout@" + "a".repeat(40), + " with:", + " ref: " + expression, + "runs: { using: composite, steps: *unsafe-steps }", + "", + ].join("\n")); + commitAll(repoRoot, "add flow composite sequence alias"); + + const audit = runAudit(repoRoot); + + assert.equal(audit.status, 1); + assertFinding( + audit.result, + "workflow-privileged-untrusted-checkout", + ".github/actions/flow-composite-alias/action.yml", + ); +}); + test("caller taint propagates into local composite action inputs", () => { const repoRoot = makeRepository(); const headExpression = ["$", "{{ github.head_ref }}"].join(""); @@ -1742,6 +1809,52 @@ test("caller taint propagates into local composite action inputs", () => { ); }); +test("composite input defaults are tainted unless callers override them", () => { + const unsafeRepo = makeRepository(); + const safeRepo = makeRepository(); + const headExpression = ["$", "{{ github.head_ref }}"].join(""); + const inputExpression = ["$", "{{ inputs.ref }}"].join(""); + for (const [repoRoot, override] of [[unsafeRepo, false], [safeRepo, true]]) { + write(repoRoot, ".github/workflows/composite-default.yml", [ + "name: composite default", + "on: pull_request_target", + "permissions: read-all", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - uses: ./.github/actions/default-checkout", + ...(override ? [" with:", " ref: main"] : []), + "", + ].join("\n")); + write(repoRoot, ".github/actions/default-checkout/action.yml", [ + "name: default checkout", + "inputs:", + " ref:", + " default: " + headExpression, + "runs:", + " using: composite", + " steps:", + " - uses: actions/checkout@" + "a".repeat(40), + " with:", + " ref: " + inputExpression, + "", + ].join("\n")); + commitAll(repoRoot, "add composite default checkout"); + } + + const unsafeAudit = runAudit(unsafeRepo); + const safeAudit = runAudit(safeRepo); + + assert.equal(unsafeAudit.status, 1); + assertFinding( + unsafeAudit.result, + "workflow-privileged-untrusted-checkout", + ".github/actions/default-checkout/action.yml", + ); + assert.equal(safeAudit.status, 0); +}); + test("action.yml takes precedence while action.yaml remains a fallback", () => { const repoRoot = makeRepository(); write(repoRoot, ".github/workflows/action-manifest-precedence.yml", [ @@ -2001,6 +2114,39 @@ test("required-check strictness is associated with the supplying rule", () => { ); }); +test("required-check strictness and app identity come from the same record", () => { + const repoRoot = makeRepository(); + writeSafeWorkflow(repoRoot); + commitAll(repoRoot, "add safe workflow"); + const snapshot = githubSnapshot(); + snapshot.rulesets[0].rules[2].parameters.strict_required_status_checks_policy = false; + snapshot.rulesets.push({ + bypass_actors: [], + conditions: { ref_name: { exclude: [], include: ["~DEFAULT_BRANCH"] } }, + enforcement: "active", + rules: [{ + parameters: { + required_status_checks: [{ context: "verify", integration_id: 999 }], + strict_required_status_checks_policy: true, + }, + type: "required_status_checks", + }], + target: "branch", + }); + + const audit = runAudit(repoRoot, [ + "--github-snapshot", writeSnapshot(snapshot), + "--required-check", "verify", + ]); + + assert.equal(audit.status, 1); + assert.equal( + audit.result.findings.some((finding) => finding.ruleId === "github-required-check-not-strict" + && finding.check === "verify"), + true, + ); +}); + test("missing ruleset bypass evidence fails closed", () => { const repoRoot = makeRepository(); writeSafeWorkflow(repoRoot); From 7be2b826241b7037717560ed27dd46c8852127cc Mon Sep 17 00:00:00 2001 From: Vladimir Date: Sat, 22 Aug 2026 05:12:49 +0800 Subject: [PATCH 17/37] Propagate privileged workflow taint end to end --- .../scripts/public-source-release-audit.mjs | 195 +++++++++++++++- .../public-source-release-audit.test.mjs | 208 ++++++++++++++++++ 2 files changed, 391 insertions(+), 12 deletions(-) diff --git a/public-source-release-audit/scripts/public-source-release-audit.mjs b/public-source-release-audit/scripts/public-source-release-audit.mjs index 7b9f4b3..f43c43d 100644 --- a/public-source-release-audit/scripts/public-source-release-audit.mjs +++ b/public-source-release-audit/scripts/public-source-release-audit.mjs @@ -282,6 +282,60 @@ function auditPrivilegedReusableWorkflowCalls(workflowSources) { return findings; } +function workflowExecutionStates(workflowSources) { + const sourceBySnapshotPath = new Map(workflowSources.map((source) => [ + `${source.snapshot}\0${source.path}`, + source, + ])); + const analysisBySource = new Map(); + const analysisFor = (source) => { + if (analysisBySource.has(source)) return analysisBySource.get(source); + const syntax = workflowSyntax(source.text); + const analysis = { + isReusable: syntax.triggerNames.some((name) => name.toLowerCase() === "workflow_call"), + localCalls: localReusableWorkflowCalls(syntax.uncommented, syntax.scalarAnchors), + privileged: hasPrivilegedWorkflowTrigger(syntax.triggerNames), + syntax, + }; + analysisBySource.set(source, analysis); + return analysis; + }; + const pending = workflowSources.map((workflow) => ({ + depth: 0, + privileged: analysisFor(workflow).privileged, + taintedBindings: new Set(), + workflow, + })); + const states = []; + const visited = new Set(); + while (pending.length > 0) { + const state = pending.pop(); + const stateKey = [ + state.workflow.snapshot, + state.workflow.path, + state.privileged ? "privileged" : "unprivileged", + ...[...state.taintedBindings].sort(), + ].join("\0"); + if (state.depth > 10 || visited.has(stateKey)) continue; + visited.add(stateKey); + states.push(state); + const analysis = analysisFor(state.workflow); + for (const call of analysis.localCalls) { + const callee = sourceBySnapshotPath.get( + `${state.workflow.snapshot}\0${call.workflowPath}`, + ); + if (!callee || !analysisFor(callee).isReusable) continue; + pending.push({ + depth: state.depth + 1, + privileged: state.privileged, + taintedBindings: reusableCallTaintedBindings(call, state.taintedBindings), + workflow: callee, + }); + } + } + return states; +} + function auditLocalCompositeActions(workflowSources, actionSources) { const actionBySnapshotPath = new Map(actionSources.map((source) => [ `${source.snapshot}\0${source.path}`, @@ -334,9 +388,9 @@ function auditLocalCompositeActions(workflowSources, actionSources) { return analysis; }; const findings = []; - for (const workflow of workflowSources) { + for (const execution of workflowExecutionStates(workflowSources)) { + const { privileged, taintedBindings: workflowTaintedBindings, workflow } = execution; const syntax = workflowSyntax(workflow.text); - const privileged = hasPrivilegedWorkflowTrigger(syntax.triggerNames); const resolveManifestPath = (reference) => localActionManifestPath( reference, (manifestPath) => actionBySnapshotPath.has(`${workflow.snapshot}\0${manifestPath}`), @@ -344,7 +398,7 @@ function auditLocalCompositeActions(workflowSources, actionSources) { const pending = workflowLocalActionCalls( syntax.uncommented, syntax.scalarAnchors, - new Set(), + workflowTaintedBindings, ).flatMap((call) => { const manifestPath = resolveManifestPath(call.reference); return manifestPath ? [{ ...call, depth: 1, manifestPath }] : []; @@ -445,7 +499,8 @@ function actionInputTaintedBindings(defaultBindings, callerTaintedBindings, prov function localActionManifestCandidates(reference) { if (!reference.startsWith("./") || /\$\{\{/u.test(reference)) return []; const actionPath = path.posix.normalize(reference.slice(2)); - if (actionPath.length === 0 || actionPath === "." || actionPath === ".." + if (actionPath === ".") return ["action.yml", "action.yaml"]; + if (actionPath.length === 0 || actionPath === ".." || actionPath.startsWith("../") || path.posix.isAbsolute(actionPath)) { return []; } @@ -700,10 +755,43 @@ function workflowJobTaintedBindings(group, scalarAnchors, inheritedTaintedBindin workflowMappingBindings(group, "env", "env", scalarAnchors), inheritedTaintedBindings, ); - return contextTaintedBindings( + const matrixTaintedBindings = contextTaintedBindings( workflowJobMatrixBindings(group, scalarAnchors), jobTaintedBindings, ); + return mergeTaintedBindings( + matrixTaintedBindings, + workflowStepOutputTaintedBindings(group, scalarAnchors, matrixTaintedBindings), + ); +} + +function workflowStepOutputTaintedBindings(group, scalarAnchors, inheritedTaintedBindings) { + const taintedBindings = new Set(); + for (const stepGroup of mappingContainerStepPropertyGroups(group, scalarAnchors)) { + const stepId = stepGroup.properties + .filter(({ entry }) => entry.key.toLowerCase() === "id") + .map(({ entry }) => resolveYamlScalarValue(entry.value, scalarAnchors).toLowerCase()) + .find(Boolean); + if (!stepId) continue; + const stepTaintedBindings = contextTaintedBindings( + workflowMappingBindings(stepGroup, "env", "env", scalarAnchors), + inheritedTaintedBindings, + ); + const outputSources = [ + ...stepGroup.properties + .filter(({ entry }) => entry.key.toLowerCase() === "run") + .map(({ entry }) => resolveYamlScalarValue(entry.value, scalarAnchors)), + ...workflowMappingBindings(stepGroup, "with", "with", scalarAnchors) + .map((binding) => binding.value), + ]; + if (outputSources.some((value) => isUntrustedReusableValue( + value, + stepTaintedBindings, + ))) { + taintedBindings.add(`steps.${stepId}.outputs.*`); + } + } + return taintedBindings; } function mergeTaintedBindings(...bindingSets) { @@ -767,6 +855,16 @@ function isUntrustedReusableValue(value, taintedBindings) { function valueReferencesTaintedBinding(value, taintedBindings) { for (const binding of taintedBindings) { + if (binding.endsWith(".*")) { + const pathPattern = binding.slice(0, -2).split(".") + .map((segment) => escapeRegExp(segment)) + .join(String.raw`\s*\.\s*`); + if (new RegExp( + String.raw`\b${pathPattern}\s*\.\s*[A-Za-z0-9_-]+`, + "iu", + ).test(normalizeExpressionPropertyAccess(value))) return true; + continue; + } const separator = binding.indexOf("."); const namespace = binding.slice(0, separator); const name = binding.slice(separator + 1); @@ -1277,7 +1375,12 @@ function workflowTriggerNames(text, scalarAnchors, blockNodeAnchors) { if (entry) entry.key = resolveYamlScalarValue(entry.key, scalarAnchors); if (!entry || entry.indentation !== 0 || entry.key.toLowerCase() !== "on") continue; if (entry.value.trim().length > 0 && !yamlValueHasOnlyProperties(entry.value)) { - names.push(...workflowTriggerNamesFromValue(entry.value, scalarAnchors, blockNodeAnchors)); + names.push(...workflowTriggerNamesFromValue( + entry.value, + scalarAnchors, + blockNodeAnchors, + text, + )); continue; } let childIndentation; @@ -1296,12 +1399,22 @@ function workflowTriggerNames(text, scalarAnchors, blockNodeAnchors) { if (/^\s*/u.exec(continuationLine)[0].length <= entry.indentation) break; flowValue += " " + continuationLine.trim(); } - names.push(...workflowTriggerNamesFromValue(flowValue, scalarAnchors, blockNodeAnchors)); + names.push(...workflowTriggerNamesFromValue( + flowValue, + scalarAnchors, + blockNodeAnchors, + text, + )); break; } const sequenceItem = /^(\s*)-\s*(.*)$/u.exec(line); if (sequenceItem) { - names.push(...workflowTriggerNamesFromValue(sequenceItem[2], scalarAnchors, blockNodeAnchors)); + names.push(...workflowTriggerNamesFromValue( + sequenceItem[2], + scalarAnchors, + blockNodeAnchors, + text, + )); continue; } const parsedEvent = parseYamlMappingEntryAt(lines, cursor); @@ -1312,7 +1425,12 @@ function workflowTriggerNames(text, scalarAnchors, blockNodeAnchors) { } } for (const onValue of yamlRootFlowMappingValues(text, "on", scalarAnchors)) { - names.push(...workflowTriggerNamesFromValue(onValue, scalarAnchors, blockNodeAnchors)); + names.push(...workflowTriggerNamesFromValue( + onValue, + scalarAnchors, + blockNodeAnchors, + text, + )); } return names; } @@ -1321,7 +1439,7 @@ function yamlValueHasOnlyProperties(value) { return /^(?:(?:&[^\s]+|![^\s]+)\s*)+$/u.test(value.trim()); } -function workflowTriggerNamesFromValue(value, scalarAnchors, blockNodeAnchors) { +function workflowTriggerNamesFromValue(value, scalarAnchors, blockNodeAnchors, text) { const blockAliasEntries = yamlBlockAliasMappingEntries( value, blockNodeAnchors, @@ -1330,6 +1448,22 @@ function workflowTriggerNamesFromValue(value, scalarAnchors, blockNodeAnchors) { if (blockAliasEntries.length > 0) { return blockAliasEntries.map(({ entry }) => resolveYamlScalarValue(entry.key, scalarAnchors)); } + const blockSequenceParent = yamlBlockSequenceAliasParentEntry( + value, + yamlBlockMappingEntries(text, scalarAnchors), + scalarAnchors, + ); + const blockSequenceValues = blockSequenceParent + ? yamlBlockSequenceValues(text, blockSequenceParent) + : undefined; + if (blockSequenceValues) { + return blockSequenceValues.flatMap((item) => workflowTriggerNamesFromValue( + item, + scalarAnchors, + blockNodeAnchors, + text, + )); + } const resolved = resolveYamlScalarValue(value, scalarAnchors); const mappingEntries = yamlDirectFlowMappingEntries(resolved); if (resolved.trimStart().startsWith("{")) { @@ -1341,6 +1475,7 @@ function workflowTriggerNamesFromValue(value, scalarAnchors, blockNodeAnchors) { item, scalarAnchors, blockNodeAnchors, + text, )); } return [resolved]; @@ -1573,6 +1708,40 @@ function workflowJobRunnerLabelSets(text, scalarAnchors) { for (const property of group.properties) { if (property.entry.key.toLowerCase() !== "runs-on") continue; const inlineValue = property.entry.inlineValue ?? property.entry.value; + const mappingEntries = workflowPropertyMappingEntries( + group, + property, + scalarAnchors, + ); + if (mappingEntries.length > 0) { + const mappingRunnerValues = mappingEntries.flatMap((mappingEntry) => { + const key = resolveYamlScalarValue( + mappingEntry.entry.key, + scalarAnchors, + ).toLowerCase(); + if (key === "group") { + return [`group: ${resolveYamlScalarValue( + mappingEntry.entry.value, + scalarAnchors, + )}`]; + } + if (key !== "labels") return []; + const labelInlineValue = mappingEntry.entry.inlineValue + ?? mappingEntry.entry.value; + const blockLabels = group.entries + && mappingEntry.index !== undefined + && (labelInlineValue.length === 0 || yamlValueHasOnlyProperties(labelInlineValue)) + ? yamlBlockSequenceValues(group.text, mappingEntry.entry) + : undefined; + return (blockLabels ?? [mappingEntry.entry.value]).flatMap((value) => ( + workflowRunnerLabels(value, scalarAnchors) + )); + }); + if (mappingRunnerValues.length > 0) { + runnerLabelSets.push(mappingRunnerValues); + continue; + } + } const blockValues = group.entries && property.index !== undefined && (inlineValue.length === 0 || yamlValueHasOnlyProperties(inlineValue)) @@ -1843,7 +2012,6 @@ function readYamlFlowKey(text, startIndex) { } function hasUntrustedPullRequestCheckout(text, scalarAnchors, taintedBindings = new Set()) { - text = maskYamlBlockScalarBodies(text); const jobGroups = workflowJobPropertyGroups(text, scalarAnchors); const jobTaintContexts = workflowJobTaintContexts( jobGroups, @@ -1907,6 +2075,9 @@ function contextTaintedBindings(bindings, inheritedTaintedBindings) { function isUntrustedCheckoutInput(key, value, taintedBindings = new Set()) { if (!["ref", "repository"].includes(key.toLowerCase())) return false; if (valueReferencesTaintedBinding(value, taintedBindings)) return true; + if (/github\.event\.comment\.body(?:\.|\b)/iu.test( + normalizeExpressionPropertyAccess(value), + )) return true; if (key.toLowerCase() === "repository") { return /(?:pull_request\.head\.repo|workflow_run\.head_repository)(?:\.|\b)/iu .test(normalizeExpressionPropertyAccess(value)); @@ -1915,7 +2086,7 @@ function isUntrustedCheckoutInput(key, value, taintedBindings = new Set()) { } function isUntrustedPullRequestRef(value) { - return /(?:github\.head_ref|github\.event\.issue\.number|pull_request\.(?:head|merge_commit_sha)|head\.sha|refs\/pull\/|workflow_run\.head_sha)/iu + return /(?:github\.head_ref|github\.event\.(?:comment\.body|issue\.number)|pull_request\.(?:head|merge_commit_sha)|head\.sha|refs\/pull\/|workflow_run\.head_sha)/iu .test(normalizeExpressionPropertyAccess(value)); } diff --git a/public-source-release-audit/tests/public-source-release-audit.test.mjs b/public-source-release-audit/tests/public-source-release-audit.test.mjs index 926cf32..9ab00d1 100644 --- a/public-source-release-audit/tests/public-source-release-audit.test.mjs +++ b/public-source-release-audit/tests/public-source-release-audit.test.mjs @@ -610,6 +610,33 @@ test("block-node aliases used as triggers remain guarded", () => { assertFinding(audit.result, "workflow-privileged-untrusted-checkout", ".github/workflows/block-aliased-trigger.yml"); }); +test("block-sequence aliases used as triggers remain guarded", () => { + const repoRoot = makeRepository(); + const expression = ["$", "{{ github.head_ref }}"].join(""); + write(repoRoot, ".github/workflows/sequence-aliased-trigger.yml", [ + "name: sequence aliased trigger", + "x-events: &events", + " - pull_request_target", + "on: *events", + "permissions: read-all", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - uses: actions/checkout@" + "a".repeat(40), + " with:", + " ref: " + expression, + "", + ].join("\n")); + commitAll(repoRoot, "add sequence aliased trigger workflow"); + + const audit = runAudit(repoRoot); + + assert.equal(audit.status, 1); + assertFinding(audit.result, "workflow-pull-request-target", ".github/workflows/sequence-aliased-trigger.yml"); + assertFinding(audit.result, "workflow-privileged-untrusted-checkout", ".github/workflows/sequence-aliased-trigger.yml"); +}); + test("flow-style workflow mappings preserve guarded key checks", () => { const repoRoot = makeRepository(); write( @@ -1113,6 +1140,51 @@ test("privileged context propagates through local reusable workflows", () => { assertFinding(audit.result, "workflow-privileged-untrusted-checkout", ".github/workflows/reusable.yml"); }); +test("privileged reusable workflows propagate into local composite actions", () => { + const repoRoot = makeRepository(); + const expression = ["$", "{{ github.head_ref }}"].join(""); + write(repoRoot, ".github/workflows/action-caller.yml", [ + "name: action caller", + "on: pull_request_target", + "permissions: read-all", + "jobs:", + " call:", + " uses: ./.github/workflows/action-callee.yml", + "", + ].join("\n")); + write(repoRoot, ".github/workflows/action-callee.yml", [ + "name: action callee", + "on: workflow_call", + "permissions: read-all", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - uses: ./.github/actions/reusable-checkout", + "", + ].join("\n")); + write(repoRoot, ".github/actions/reusable-checkout/action.yml", [ + "name: reusable checkout", + "runs:", + " using: composite", + " steps:", + " - uses: actions/checkout@" + "a".repeat(40), + " with:", + " ref: " + expression, + "", + ].join("\n")); + commitAll(repoRoot, "add reusable composite checkout chain"); + + const audit = runAudit(repoRoot); + + assert.equal(audit.status, 1); + assertFinding( + audit.result, + "workflow-privileged-untrusted-checkout", + ".github/actions/reusable-checkout/action.yml", + ); +}); + test("untrusted inputs propagate through local reusable workflows", () => { const repoRoot = makeRepository(); const headExpression = ["$", "{{ github.head_ref }}"].join(""); @@ -1272,6 +1344,40 @@ test("untrusted refs propagate through job outputs", () => { assertFinding(audit.result, "workflow-privileged-untrusted-checkout", ".github/workflows/job-output-ref.yml"); }); +test("untrusted refs propagate through step and job outputs", () => { + const repoRoot = makeRepository(); + const headExpression = ["$", "{{ github.head_ref }}"].join(""); + const outputExpression = ["$", "{{ needs.source.outputs.revision }}"].join(""); + write(repoRoot, ".github/workflows/step-output-ref.yml", [ + "name: step output ref", + "on: pull_request_target", + "permissions: read-all", + "jobs:", + " source:", + " runs-on: ubuntu-latest", + " outputs:", + " revision: ${{ steps.ref.outputs.revision }}", + " steps:", + " - id: ref", + " run: |", + " echo \"revision=" + headExpression + "\" >> \"$GITHUB_OUTPUT\"", + " inspect:", + " needs: source", + " runs-on: ubuntu-latest", + " steps:", + " - uses: actions/checkout@" + "a".repeat(40), + " with:", + " ref: " + outputExpression, + "", + ].join("\n")); + commitAll(repoRoot, "add step output checkout workflow"); + + const audit = runAudit(repoRoot); + + assert.equal(audit.status, 1); + assertFinding(audit.result, "workflow-privileged-untrusted-checkout", ".github/workflows/step-output-ref.yml"); +}); + test("untrusted refs propagate through job matrix bindings", () => { const repoRoot = makeRepository(); const headExpression = ["$", "{{ github.head_ref }}"].join(""); @@ -1402,6 +1508,37 @@ test("block-sequence runner labels remain release-blocking", () => { assertFinding(audit.result, "workflow-self-hosted-runner", ".github/workflows/block-runner-sequence.yml"); }); +test("runs-on mappings preserve blocking self-hosted labels", () => { + const repoRoot = makeRepository(); + write(repoRoot, ".github/workflows/flow-runner-mapping.yml", [ + "name: flow runner mapping", + "on: push", + "jobs:", + " unsafe:", + " runs-on: { group: public-runners, labels: self-hosted }", + "", + ].join("\n")); + write(repoRoot, ".github/workflows/block-runner-mapping.yml", [ + "name: block runner mapping", + "on: push", + "jobs:", + " unsafe:", + " runs-on:", + " group: public-runners", + " labels:", + " - self-hosted", + " - linux", + "", + ].join("\n")); + commitAll(repoRoot, "add runner mapping workflows"); + + const audit = runAudit(repoRoot); + + assert.equal(audit.status, 1); + assertFinding(audit.result, "workflow-self-hosted-runner", ".github/workflows/flow-runner-mapping.yml"); + assertFinding(audit.result, "workflow-self-hosted-runner", ".github/workflows/block-runner-mapping.yml"); +}); + test("block-list privileged triggers detect reordered checkout inputs", () => { const repoRoot = makeRepository(); write(repoRoot, ".github/workflows/reordered-checkout.yml", [ @@ -1584,6 +1721,43 @@ test("constructed issue_comment pull-request refs remain untrusted", () => { ); }); +test("issue-comment bodies are untrusted checkout coordinates", () => { + const repoRoot = makeRepository(); + const repositoryExpression = [ + "$", + "{{ fromJSON(github.event.comment.body).repository }}", + ].join(""); + const refExpression = [ + "$", + "{{ fromJSON(github.event.comment.body).ref }}", + ].join(""); + write(repoRoot, ".github/workflows/comment-body-checkout.yml", [ + "name: comment body checkout", + "on: issue_comment", + "permissions:", + " contents: write", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - uses: actions/checkout@" + "a".repeat(40), + " with:", + " repository: " + repositoryExpression, + " ref: " + refExpression, + "", + ].join("\n")); + commitAll(repoRoot, "add comment body checkout"); + + const audit = runAudit(repoRoot); + + assert.equal(audit.status, 1); + assertFinding( + audit.result, + "workflow-privileged-untrusted-checkout", + ".github/workflows/comment-body-checkout.yml", + ); +}); + test("mutable Docker actions warn while digest-pinned actions pass", () => { const repoRoot = makeRepository(); write(repoRoot, ".github/workflows/mutable-docker.yml", [ @@ -1732,6 +1906,40 @@ test("local composite action dependencies are audited recursively", () => { ); }); +test("repository-root local actions are audited", () => { + const repoRoot = makeRepository(); + const expression = ["$", "{{ github.head_ref }}"].join(""); + write(repoRoot, ".github/workflows/root-action.yml", [ + "name: root action", + "on: pull_request_target", + "permissions: read-all", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - uses: ./", + "", + ].join("\n")); + write(repoRoot, "action.yml", [ + "name: root action", + "runs:", + " using: composite", + " steps:", + " - uses: actions/checkout@" + "a".repeat(40), + " with:", + " ref: " + expression, + " - uses: example/action@main", + "", + ].join("\n")); + commitAll(repoRoot, "add repository root action"); + + const audit = runAudit(repoRoot, ["--fail-on-warning"]); + + assert.equal(audit.status, 1); + assertFinding(audit.result, "workflow-privileged-untrusted-checkout", "action.yml"); + assertFinding(audit.result, "workflow-mutable-action-ref", "action.yml"); +}); + test("flow-style composite runs expand block sequence aliases", () => { const repoRoot = makeRepository(); const expression = ["$", "{{ github.head_ref }}"].join(""); From d2d4e8afcf227e1f4ec130dae140d367ce577726 Mon Sep 17 00:00:00 2001 From: Vladimir Date: Sat, 22 Aug 2026 05:28:37 +0800 Subject: [PATCH 18/37] Audit dynamic workflow runtime inputs --- .../scripts/public-source-release-audit.mjs | 105 +++++++++++-- .../public-source-release-audit.test.mjs | 143 ++++++++++++++++++ 2 files changed, 238 insertions(+), 10 deletions(-) diff --git a/public-source-release-audit/scripts/public-source-release-audit.mjs b/public-source-release-audit/scripts/public-source-release-audit.mjs index f43c43d..67dafed 100644 --- a/public-source-release-audit/scripts/public-source-release-audit.mjs +++ b/public-source-release-audit/scripts/public-source-release-audit.mjs @@ -12,8 +12,10 @@ const GITHUB_ACTIONS_APP_ID = 15368; const FULL_OBJECT_ID_PATTERN = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/iu; const IMMUTABLE_DOCKER_ACTION_PATTERN = /^docker:\/\/[^\s@]+(?:[:][^\s@]+)?@(?:sha256:[0-9a-f]{64}|sha512:[0-9a-f]{128})$/iu; const ACTION_MANIFEST_PATH_PATTERN = /(?:^|\/)action\.ya?ml$/u; +const DOCKERFILE_PATH_PATTERN = /(?:^|\/)(?:Dockerfile|[^/]+\.dockerfile)$/iu; const PRIVILEGED_WORKFLOW_TRIGGERS = new Set([ "issue_comment", + "issues", "pull_request_target", "workflow_run", ]); @@ -177,12 +179,13 @@ function auditWorkflowSources(repoRoot, { includeHistory = false } = {}) { const entries = trackedWorkflowEntries(repoRoot, { includeHistory }); const findings = []; const actionSources = []; + const dockerfileSources = []; const workflowSources = []; for (const entry of entries) { if (entry.mode !== "100644" && entry.mode !== "100755") { findings.push(workflowFinding({ - message: "Workflow and local-action entrypoints must be regular tracked files.", + message: "Workflow, local-action, and Dockerfile entrypoints must be regular tracked files.", path: entry.path, ruleId: "workflow-entrypoint-not-regular", severity: "error", @@ -198,7 +201,7 @@ function auditWorkflowSources(repoRoot, { includeHistory = false } = {}) { const text = blob.toString("utf8"); if (Buffer.from(text, "utf8").equals(blob) === false) { findings.push(workflowFinding({ - message: "Workflow and local-action YAML must be valid UTF-8 for deterministic review.", + message: "Workflow, local-action, and Dockerfile sources must be valid UTF-8 for deterministic review.", path: entry.path, ruleId: "workflow-non-utf8", severity: "error", @@ -211,13 +214,19 @@ function auditWorkflowSources(repoRoot, { includeHistory = false } = {}) { if (WORKFLOW_PATH_PATTERN.test(entry.path)) { workflowSources.push(source); findings.push(...auditWorkflowText(entry.path, text, entry.source)); - } else { + } else if (ACTION_MANIFEST_PATH_PATTERN.test(entry.path)) { actionSources.push(source); + } else { + dockerfileSources.push(source); } } findings.push(...auditPrivilegedReusableWorkflowCalls(workflowSources)); - findings.push(...auditLocalCompositeActions(workflowSources, actionSources)); + findings.push(...auditLocalCompositeActions( + workflowSources, + actionSources, + dockerfileSources, + )); return findings; } @@ -336,11 +345,15 @@ function workflowExecutionStates(workflowSources) { return states; } -function auditLocalCompositeActions(workflowSources, actionSources) { +function auditLocalCompositeActions(workflowSources, actionSources, dockerfileSources) { const actionBySnapshotPath = new Map(actionSources.map((source) => [ `${source.snapshot}\0${source.path}`, source, ])); + const dockerfileBySnapshotPath = new Map(dockerfileSources.map((source) => [ + `${source.snapshot}\0${source.path}`, + source, + ])); const analysisBySource = new Map(); const analysisFor = (source) => { if (analysisBySource.has(source)) return analysisBySource.get(source); @@ -375,7 +388,16 @@ function auditLocalCompositeActions(workflowSources, actionSources) { .map(({ entry }) => resolveYamlScalarValue(entry.value, syntax.scalarAnchors)) .filter((image) => image.startsWith("docker://"))), ]; + const dockerfilePaths = dockerRunGroups.flatMap((group) => group.properties + .filter(({ entry }) => entry.key.toLowerCase() === "image") + .map(({ entry }) => resolveYamlScalarValue(entry.value, syntax.scalarAnchors)) + .filter((image) => !image.startsWith("docker://")) + .flatMap((image) => { + const dockerfilePath = localDockerfilePath(source.path, image); + return dockerfilePath ? [dockerfilePath] : []; + })); const analysis = { + dockerfilePaths, inputDefaultBindings: actionInputDefaultBindings( syntax.uncommented, syntax.scalarAnchors, @@ -429,6 +451,21 @@ function auditLocalCompositeActions(workflowSources, actionSources) { })); } } + for (const dockerfilePath of analysis.dockerfilePaths) { + const dockerfile = dockerfileBySnapshotPath.get( + `${workflow.snapshot}\0${dockerfilePath}`, + ); + if (!dockerfile) continue; + if (dockerfileBaseImages(dockerfile.text).some(isMutableDockerBaseImage)) { + findings.push(workflowFinding({ + message: "Docker action base images should use reviewed immutable digests.", + path: dockerfile.path, + ruleId: "workflow-mutable-action-ref", + severity: "warning", + source: dockerfile.source, + })); + } + } pending.push(...compositeLocalActionCalls( analysis.stepGroups, analysis.syntax.scalarAnchors, @@ -463,6 +500,33 @@ function localActionManifestPath(reference, manifestExists) { return localActionManifestCandidates(reference).find(manifestExists); } +function localDockerfilePath(actionManifestPath, image) { + if (!image || /\$\{\{/u.test(image) || path.posix.isAbsolute(image)) return undefined; + const dockerfilePath = path.posix.normalize(path.posix.join( + path.posix.dirname(actionManifestPath), + image, + )); + if (dockerfilePath === ".." || dockerfilePath.startsWith("../")) return undefined; + return DOCKERFILE_PATH_PATTERN.test(dockerfilePath) ? dockerfilePath : undefined; +} + +function dockerfileBaseImages(text) { + const logicalLines = text + .replace(/\r\n?|\u0085|\u2028|\u2029/gu, "\n") + .replace(/\\\n[ \t]*/gu, " ") + .split("\n"); + return logicalLines.flatMap((line) => { + if (/^\s*#/u.test(line)) return []; + const from = /^\s*FROM\s+(?:(?:--[^\s=]+=[^\s]+)\s+)*(\S+)/iu.exec(line); + return from ? [from[1]] : []; + }); +} + +function isMutableDockerBaseImage(image) { + if (image.toLowerCase() === "scratch") return false; + return !/@(?:sha256:[0-9a-f]{64}|sha512:[0-9a-f]{128})$/iu.test(image); +} + function actionInputDefaultBindings(text, scalarAnchors) { return workflowRootContainerGroups(text, "inputs", scalarAnchors) .flatMap((group) => group.properties.flatMap((inputProperty) => ( @@ -603,11 +667,22 @@ function workflowJobMatrixBindings(group, scalarAnchors) { entry: { ...matrixProperty.entry, key: matrixKey }, }], }; - for (const matrixEntry of workflowPropertyMappingEntries( + const matrixEntries = workflowPropertyMappingEntries( matrixGroup, matrixGroup.properties[0], scalarAnchors, - )) { + ); + if (matrixEntries.length === 0) { + bindings.push({ + name: "*", + namespace: "matrix", + value: resolveYamlScalarValue( + matrixProperty.entry.inlineValue ?? matrixProperty.entry.value, + scalarAnchors, + ), + }); + } + for (const matrixEntry of matrixEntries) { const name = resolveYamlScalarValue(matrixEntry.entry.key, scalarAnchors).toLowerCase(); if (name === "include") { bindings.push(...workflowMatrixIncludeBindings( @@ -909,7 +984,9 @@ function trackedWorkflowEntries(repoRoot, { includeHistory = false } = {}) { const unique = new Map(); for (const record of records) { - if (WORKFLOW_PATH_PATTERN.test(record.path) || ACTION_MANIFEST_PATH_PATTERN.test(record.path)) { + if (WORKFLOW_PATH_PATTERN.test(record.path) + || ACTION_MANIFEST_PATH_PATTERN.test(record.path) + || DOCKERFILE_PATH_PATTERN.test(record.path)) { const key = `${record.snapshot}\0${record.path}\0${record.objectId}\0${record.mode}`; if (unique.has(key) === false || record.source === "tracked-file") { unique.set(key, record); @@ -1696,6 +1773,14 @@ function isKnownGithubHostedRunnerLabel(value) { function workflowRunnerLabels(value, scalarAnchors) { const resolved = resolveYamlScalarValue(value, scalarAnchors); + const literalExpression = /^\$\{\{\s*(?:'((?:''|[^'])*)'|"((?:\\.|[^"\\])*)")\s*\}\}$/u.exec( + resolved.trim(), + ); + if (literalExpression) { + return [literalExpression[1] !== undefined + ? literalExpression[1].replaceAll("''", "'") + : decodeYamlDoubleQuotedScalar(literalExpression[2])]; + } const sequenceValues = yamlFlowSequenceValues(resolved); return sequenceValues ? sequenceValues.flatMap((item) => workflowRunnerLabels(item, scalarAnchors)) @@ -2075,7 +2160,7 @@ function contextTaintedBindings(bindings, inheritedTaintedBindings) { function isUntrustedCheckoutInput(key, value, taintedBindings = new Set()) { if (!["ref", "repository"].includes(key.toLowerCase())) return false; if (valueReferencesTaintedBinding(value, taintedBindings)) return true; - if (/github\.event\.comment\.body(?:\.|\b)/iu.test( + if (/github\.event\.(?:comment|issue)\.body(?:\.|\b)/iu.test( normalizeExpressionPropertyAccess(value), )) return true; if (key.toLowerCase() === "repository") { @@ -2086,7 +2171,7 @@ function isUntrustedCheckoutInput(key, value, taintedBindings = new Set()) { } function isUntrustedPullRequestRef(value) { - return /(?:github\.head_ref|github\.event\.(?:comment\.body|issue\.number)|pull_request\.(?:head|merge_commit_sha)|head\.sha|refs\/pull\/|workflow_run\.head_sha)/iu + return /(?:github\.head_ref|github\.event\.(?:comment\.body|issue\.(?:body|number))|pull_request\.(?:head|merge_commit_sha)|head\.sha|refs\/pull\/|workflow_run\.head_sha)/iu .test(normalizeExpressionPropertyAccess(value)); } diff --git a/public-source-release-audit/tests/public-source-release-audit.test.mjs b/public-source-release-audit/tests/public-source-release-audit.test.mjs index 9ab00d1..3faae70 100644 --- a/public-source-release-audit/tests/public-source-release-audit.test.mjs +++ b/public-source-release-audit/tests/public-source-release-audit.test.mjs @@ -1434,6 +1434,42 @@ test("matrix include objects propagate untrusted checkout refs", () => { assertFinding(audit.result, "workflow-privileged-untrusted-checkout", ".github/workflows/matrix-include.yml"); }); +test("expression-defined matrices propagate untrusted checkout coordinates", () => { + const repoRoot = makeRepository(); + const matrixExpression = [ + "$", + "{{ fromJSON(github.event.comment.body) }}", + ].join(""); + const repositoryExpression = ["$", "{{ matrix.repository }}"].join(""); + const refExpression = ["$", "{{ matrix.ref }}"].join(""); + write(repoRoot, ".github/workflows/expression-matrix.yml", [ + "name: expression matrix", + "on: issue_comment", + "permissions: read-all", + "jobs:", + " inspect:", + " strategy:", + " matrix: " + matrixExpression, + " runs-on: ubuntu-latest", + " steps:", + " - uses: actions/checkout@" + "a".repeat(40), + " with:", + " repository: " + repositoryExpression, + " ref: " + refExpression, + "", + ].join("\n")); + commitAll(repoRoot, "add expression matrix checkout"); + + const audit = runAudit(repoRoot); + + assert.equal(audit.status, 1); + assertFinding( + audit.result, + "workflow-privileged-untrusted-checkout", + ".github/workflows/expression-matrix.yml", + ); +}); + test("quoted runs-on keys cannot hide self-hosted labels", () => { const repoRoot = makeRepository(); write(repoRoot, ".github/workflows/quoted-runner-key.yml", [ @@ -1452,6 +1488,28 @@ test("quoted runs-on keys cannot hide self-hosted labels", () => { assertFinding(audit.result, "workflow-self-hosted-runner", ".github/workflows/quoted-runner-key.yml"); }); +test("literal runner expressions preserve blocking self-hosted labels", () => { + const repoRoot = makeRepository(); + write(repoRoot, ".github/workflows/literal-runner-expression.yml", [ + "name: literal runner expression", + "on: push", + "jobs:", + " unsafe:", + " runs-on: ${{ 'self-hosted' }}", + "", + ].join("\n")); + commitAll(repoRoot, "add literal runner expression workflow"); + + const audit = runAudit(repoRoot); + + assert.equal(audit.status, 1); + assertFinding( + audit.result, + "workflow-self-hosted-runner", + ".github/workflows/literal-runner-expression.yml", + ); +}); + test("aliased self-hosted runner labels remain release-blocking", () => { const repoRoot = makeRepository(); write(repoRoot, ".github/workflows/aliased-runner.yml", [ @@ -1758,6 +1816,43 @@ test("issue-comment bodies are untrusted checkout coordinates", () => { ); }); +test("issue bodies are privileged untrusted checkout coordinates", () => { + const repoRoot = makeRepository(); + const repositoryExpression = [ + "$", + "{{ fromJSON(github.event.issue.body).repository }}", + ].join(""); + const refExpression = [ + "$", + "{{ fromJSON(github.event.issue.body).ref }}", + ].join(""); + write(repoRoot, ".github/workflows/issue-body-checkout.yml", [ + "name: issue body checkout", + "on: issues", + "permissions:", + " contents: write", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - uses: actions/checkout@" + "a".repeat(40), + " with:", + " repository: " + repositoryExpression, + " ref: " + refExpression, + "", + ].join("\n")); + commitAll(repoRoot, "add issue body checkout"); + + const audit = runAudit(repoRoot); + + assert.equal(audit.status, 1); + assertFinding( + audit.result, + "workflow-privileged-untrusted-checkout", + ".github/workflows/issue-body-checkout.yml", + ); +}); + test("mutable Docker actions warn while digest-pinned actions pass", () => { const repoRoot = makeRepository(); write(repoRoot, ".github/workflows/mutable-docker.yml", [ @@ -2159,6 +2254,54 @@ test("local Docker action images require immutable digests", () => { )), false); }); +test("Dockerfile-backed local actions require immutable base images", () => { + const repoRoot = makeRepository(); + write(repoRoot, ".github/workflows/dockerfile-actions.yml", [ + "name: Dockerfile actions", + "on: push", + "permissions: read-all", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - uses: ./.github/actions/mutable-base", + " - uses: ./.github/actions/pinned-base", + "", + ].join("\n")); + for (const actionName of ["mutable-base", "pinned-base"]) { + write(repoRoot, `.github/actions/${actionName}/action.yml`, [ + "name: " + actionName, + "runs:", + " using: docker", + " image: Dockerfile", + "", + ].join("\n")); + } + write( + repoRoot, + ".github/actions/mutable-base/Dockerfile", + "FROM alpine:latest\n", + ); + write( + repoRoot, + ".github/actions/pinned-base/Dockerfile", + `FROM alpine@sha256:${"a".repeat(64)}\n`, + ); + commitAll(repoRoot, "add Dockerfile action fixtures"); + + const audit = runAudit(repoRoot, ["--fail-on-warning"]); + + assert.equal(audit.status, 1); + assertFinding( + audit.result, + "workflow-mutable-action-ref", + ".github/actions/mutable-base/Dockerfile", + ); + assert.equal(audit.result.findings.some((finding) => ( + finding.path === ".github/actions/pinned-base/Dockerfile" + )), false); +}); + test("runner-group selectors require proof of hosted isolation", () => { const repoRoot = makeRepository(); write(repoRoot, ".github/workflows/flow-runner-group.yml", [ From e2e248851b0e08516e47d753b2a6656bacfe8db1 Mon Sep 17 00:00:00 2001 From: Vladimir Date: Sat, 22 Aug 2026 05:49:59 +0800 Subject: [PATCH 19/37] Audit persisted and shell checkout inputs --- .../scripts/public-source-release-audit.mjs | 230 ++++++++++++--- .../public-source-release-audit.test.mjs | 263 ++++++++++++++++++ 2 files changed, 447 insertions(+), 46 deletions(-) diff --git a/public-source-release-audit/scripts/public-source-release-audit.mjs b/public-source-release-audit/scripts/public-source-release-audit.mjs index 67dafed..1f04ef4 100644 --- a/public-source-release-audit/scripts/public-source-release-audit.mjs +++ b/public-source-release-audit/scripts/public-source-release-audit.mjs @@ -456,9 +456,9 @@ function auditLocalCompositeActions(workflowSources, actionSources, dockerfileSo `${workflow.snapshot}\0${dockerfilePath}`, ); if (!dockerfile) continue; - if (dockerfileBaseImages(dockerfile.text).some(isMutableDockerBaseImage)) { + if (dockerfileReferencedImages(dockerfile.text).some(isMutableDockerBaseImage)) { findings.push(workflowFinding({ - message: "Docker action base images should use reviewed immutable digests.", + message: "Docker action image dependencies should use reviewed immutable digests.", path: dockerfile.path, ruleId: "workflow-mutable-action-ref", severity: "warning", @@ -478,10 +478,17 @@ function auditLocalCompositeActions(workflowSources, actionSources, dockerfileSo manifestPath: nestedPath, }] : []; })); - if (privileged && analysis.stepGroups.some((stepGroup) => stepGroupHasUntrustedCheckout( - stepGroup, + const actionStepContexts = workflowStepTaintAnalysis( + analysis.stepGroups, analysis.syntax.scalarAnchors, effectiveTaintedBindings, + ).stepContexts; + if (privileged && actionStepContexts.some(({ stepGroup, taintedBindings }) => ( + stepGroupHasUntrustedCheckout( + stepGroup, + analysis.syntax.scalarAnchors, + taintedBindings, + ) ))) { findings.push(workflowFinding({ message: "A local composite action called from a privileged workflow must not execute an untrusted checkout.", @@ -510,16 +517,28 @@ function localDockerfilePath(actionManifestPath, image) { return DOCKERFILE_PATH_PATTERN.test(dockerfilePath) ? dockerfilePath : undefined; } -function dockerfileBaseImages(text) { +function dockerfileReferencedImages(text) { const logicalLines = text .replace(/\r\n?|\u0085|\u2028|\u2029/gu, "\n") .replace(/\\\n[ \t]*/gu, " ") .split("\n"); - return logicalLines.flatMap((line) => { - if (/^\s*#/u.test(line)) return []; - const from = /^\s*FROM\s+(?:(?:--[^\s=]+=[^\s]+)\s+)*(\S+)/iu.exec(line); - return from ? [from[1]] : []; + const baseImages = []; + const stageNames = new Set(); + for (const line of logicalLines) { + if (/^\s*#/u.test(line)) continue; + const from = /^\s*FROM\s+(?:(?:--[^\s=]+=[^\s]+)\s+)*(\S+)(?:\s+AS\s+(\S+))?/iu.exec(line); + if (!from) continue; + baseImages.push(from[1]); + if (from[2]) stageNames.add(from[2].toLowerCase()); + } + const copyImages = logicalLines.flatMap((line) => { + if (/^\s*#/u.test(line) || !/^\s*COPY\b/iu.test(line)) return []; + const from = /(?:^|\s)--from=(?:"([^"]+)"|'([^']+)'|([^\s]+))/iu.exec(line); + const image = from?.[1] ?? from?.[2] ?? from?.[3]; + if (!image || /^\d+$/u.test(image) || stageNames.has(image.toLowerCase())) return []; + return [image]; }); + return [...baseImages, ...copyImages]; } function isMutableDockerBaseImage(image) { @@ -779,6 +798,20 @@ function workflowJobTaintContexts( workflowEnvBindings, scalarAnchors, inheritedTaintedBindings, +) { + return new Map([...workflowJobTaintAnalyses( + jobGroups, + workflowEnvBindings, + scalarAnchors, + inheritedTaintedBindings, + )].map(([group, analysis]) => [group, analysis.taintedBindings])); +} + +function workflowJobTaintAnalyses( + jobGroups, + workflowEnvBindings, + scalarAnchors, + inheritedTaintedBindings, ) { const workflowTaintedBindings = contextTaintedBindings( workflowEnvBindings, @@ -821,11 +854,19 @@ function workflowJobTaintContexts( ); return new Map(jobGroups.map((group) => [ group, - workflowJobTaintedBindings(group, scalarAnchors, jobInheritedBindings), + workflowJobTaintAnalysis(group, scalarAnchors, jobInheritedBindings), ])); } function workflowJobTaintedBindings(group, scalarAnchors, inheritedTaintedBindings) { + return workflowJobTaintAnalysis( + group, + scalarAnchors, + inheritedTaintedBindings, + ).taintedBindings; +} + +function workflowJobTaintAnalysis(group, scalarAnchors, inheritedTaintedBindings) { const jobTaintedBindings = contextTaintedBindings( workflowMappingBindings(group, "env", "env", scalarAnchors), inheritedTaintedBindings, @@ -834,28 +875,48 @@ function workflowJobTaintedBindings(group, scalarAnchors, inheritedTaintedBindin workflowJobMatrixBindings(group, scalarAnchors), jobTaintedBindings, ); - return mergeTaintedBindings( + const stepAnalysis = workflowStepTaintAnalysis( + mappingContainerStepPropertyGroups(group, scalarAnchors), + scalarAnchors, matrixTaintedBindings, - workflowStepOutputTaintedBindings(group, scalarAnchors, matrixTaintedBindings), ); + return { + stepContexts: stepAnalysis.stepContexts, + taintedBindings: mergeTaintedBindings( + matrixTaintedBindings, + stepAnalysis.derivedTaintedBindings, + ), + }; } -function workflowStepOutputTaintedBindings(group, scalarAnchors, inheritedTaintedBindings) { - const taintedBindings = new Set(); - for (const stepGroup of mappingContainerStepPropertyGroups(group, scalarAnchors)) { +function workflowStepTaintAnalysis(stepGroups, scalarAnchors, inheritedTaintedBindings) { + const derivedTaintedBindings = new Set(); + const stepContexts = []; + for (const stepGroup of stepGroups) { + const accumulatedTaintedBindings = mergeTaintedBindings( + inheritedTaintedBindings, + derivedTaintedBindings, + ); const stepId = stepGroup.properties .filter(({ entry }) => entry.key.toLowerCase() === "id") .map(({ entry }) => resolveYamlScalarValue(entry.value, scalarAnchors).toLowerCase()) .find(Boolean); - if (!stepId) continue; const stepTaintedBindings = contextTaintedBindings( workflowMappingBindings(stepGroup, "env", "env", scalarAnchors), - inheritedTaintedBindings, + accumulatedTaintedBindings, ); + stepContexts.push({ stepGroup, taintedBindings: stepTaintedBindings }); + const runSources = stepGroup.properties + .filter(({ entry }) => entry.key.toLowerCase() === "run") + .map(({ entry }) => resolveYamlScalarValue(entry.value, scalarAnchors)); + for (const runSource of runSources) { + for (const binding of githubEnvironmentWriteBindings(runSource, stepTaintedBindings)) { + derivedTaintedBindings.add(`env.${binding}`); + } + } + if (!stepId) continue; const outputSources = [ - ...stepGroup.properties - .filter(({ entry }) => entry.key.toLowerCase() === "run") - .map(({ entry }) => resolveYamlScalarValue(entry.value, scalarAnchors)), + ...runSources, ...workflowMappingBindings(stepGroup, "with", "with", scalarAnchors) .map((binding) => binding.value), ]; @@ -863,10 +924,35 @@ function workflowStepOutputTaintedBindings(group, scalarAnchors, inheritedTainte value, stepTaintedBindings, ))) { - taintedBindings.add(`steps.${stepId}.outputs.*`); + derivedTaintedBindings.add(`steps.${stepId}.outputs.*`); } } - return taintedBindings; + return { derivedTaintedBindings, stepContexts }; +} + +function githubEnvironmentWriteBindings(runSource, taintedBindings) { + const taintedEnvironmentVariables = new Set([...taintedBindings].flatMap((binding) => ( + binding.startsWith("env.") && binding !== "env.*" + ? [binding.slice("env.".length).toLowerCase()] + : [] + ))); + if (!isUntrustedReusableValue(runSource, taintedBindings) + && !shellSourceReferencesTaintedVariable( + runSource, + taintedEnvironmentVariables, + taintedBindings.has("env.*"), + )) return []; + const writeLines = runSource + .replace(/\r\n?|\u0085|\u2028|\u2029/gu, "\n") + .replace(/\\\n[ \t]*/gu, " ") + .split("\n") + .filter((line) => /GITHUB_ENV/iu.test(line) + && /(?:>>?|\b(?:Add-Content|Out-File|Set-Content|tee)\b)/iu.test(line)); + if (writeLines.length === 0) return []; + const names = new Set(writeLines.flatMap((line) => [ + ...line.matchAll(/(?:^|[\s"'`])([A-Za-z_][A-Za-z0-9_]*)\s*(?:=|<<)/gu), + ].map((match) => match[1].toLowerCase()))); + return names.size > 0 ? [...names] : ["*"]; } function mergeTaintedBindings(...bindingSets) { @@ -875,35 +961,32 @@ function mergeTaintedBindings(...bindingSets) { function workflowLocalActionCalls(text, scalarAnchors, inheritedTaintedBindings) { const jobGroups = workflowJobPropertyGroups(text, scalarAnchors); - const contexts = workflowJobTaintContexts( + const analyses = workflowJobTaintAnalyses( jobGroups, workflowRootMappingBindings(text, "env", "env", scalarAnchors), scalarAnchors, inheritedTaintedBindings, ); - return jobGroups.flatMap((group) => mappingContainerStepPropertyGroups( - group, - scalarAnchors, - ).flatMap((stepGroup) => localActionCallsFromStepGroup( + return jobGroups.flatMap((group) => ( + analyses.get(group)?.stepContexts ?? [] + ).flatMap(({ stepGroup, taintedBindings }) => localActionCallsFromStepGroup( stepGroup, scalarAnchors, - contexts.get(group) ?? inheritedTaintedBindings, + taintedBindings, ))); } function compositeLocalActionCalls(stepGroups, scalarAnchors, inheritedTaintedBindings) { - return stepGroups.flatMap((stepGroup) => localActionCallsFromStepGroup( - stepGroup, + return workflowStepTaintAnalysis( + stepGroups, scalarAnchors, inheritedTaintedBindings, + ).stepContexts.flatMap(({ stepGroup, taintedBindings }) => ( + localActionCallsFromStepGroup(stepGroup, scalarAnchors, taintedBindings) )); } -function localActionCallsFromStepGroup(stepGroup, scalarAnchors, inheritedTaintedBindings) { - const stepTaintedBindings = contextTaintedBindings( - workflowMappingBindings(stepGroup, "env", "env", scalarAnchors), - inheritedTaintedBindings, - ); +function localActionCallsFromStepGroup(stepGroup, scalarAnchors, stepTaintedBindings) { const inputBindings = workflowMappingBindings(stepGroup, "with", "inputs", scalarAnchors); const providedBindings = new Set(inputBindings.map((binding) => ( `${binding.namespace}.${binding.name}` @@ -2098,18 +2181,20 @@ function readYamlFlowKey(text, startIndex) { function hasUntrustedPullRequestCheckout(text, scalarAnchors, taintedBindings = new Set()) { const jobGroups = workflowJobPropertyGroups(text, scalarAnchors); - const jobTaintContexts = workflowJobTaintContexts( + const jobTaintAnalyses = workflowJobTaintAnalyses( jobGroups, workflowRootMappingBindings(text, "env", "env", scalarAnchors), scalarAnchors, taintedBindings, ); for (const jobGroup of jobGroups) { - for (const stepGroup of mappingContainerStepPropertyGroups(jobGroup, scalarAnchors)) { + for (const { stepGroup, taintedBindings: stepTaintedBindings } of ( + jobTaintAnalyses.get(jobGroup)?.stepContexts ?? [] + )) { if (stepGroupHasUntrustedCheckout( stepGroup, scalarAnchors, - jobTaintContexts.get(jobGroup) ?? taintedBindings, + stepTaintedBindings, )) { return true; } @@ -2118,17 +2203,18 @@ function hasUntrustedPullRequestCheckout(text, scalarAnchors, taintedBindings = return false; } -function stepGroupHasUntrustedCheckout(stepGroup, scalarAnchors, inheritedTaintedBindings) { +function stepGroupHasUntrustedCheckout(stepGroup, scalarAnchors, stepTaintedBindings) { + const hasUntrustedShellCheckout = stepGroup.properties + .filter(({ entry }) => entry.key.toLowerCase() === "run") + .map(({ entry }) => resolveYamlScalarValue(entry.value, scalarAnchors)) + .some((runSource) => shellRunHasUntrustedCheckout(runSource, stepTaintedBindings)); + if (hasUntrustedShellCheckout) return true; const usesCheckout = stepGroup.properties .filter(({ entry }) => entry.key.toLowerCase() === "uses") .some(({ entry }) => /^actions\/checkout@/iu.test( resolveYamlScalarValue(entry.value, scalarAnchors), )); if (!usesCheckout) return false; - const stepTaintedBindings = contextTaintedBindings( - workflowMappingBindings(stepGroup, "env", "env", scalarAnchors), - inheritedTaintedBindings, - ); return workflowMappingBindings(stepGroup, "with", "with", scalarAnchors) .some((input) => isUntrustedCheckoutInput( input.name, @@ -2137,6 +2223,49 @@ function stepGroupHasUntrustedCheckout(stepGroup, scalarAnchors, inheritedTainte )); } +function shellRunHasUntrustedCheckout(runSource, taintedBindings) { + const checkoutCommand = /\b(?:gh\s+repo\s+clone|git(?:\s+--?[^\s]+(?:[=\s][^\s]+)?)*\s+(?:checkout|clone|pull|reset|switch|worktree))\b/iu; + const taintedVariables = new Set([...taintedBindings].flatMap((binding) => ( + binding.startsWith("env.") && binding !== "env.*" + ? [binding.slice("env.".length).toLowerCase()] + : [] + ))); + taintedVariables.add("github_head_ref"); + const anyEnvironmentVariableTainted = taintedBindings.has("env.*"); + const lines = runSource + .replace(/\r\n?|\u0085|\u2028|\u2029/gu, "\n") + .replace(/\\\n[ \t]*/gu, " ") + .split("\n") + .filter((line) => !/^\s*#/u.test(line)); + for (const line of lines) { + const assignment = /^\s*(?:(?:export|local|readonly)\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=|^\s*\$([A-Za-z_][A-Za-z0-9_]*)\s*=/iu.exec(line); + if (assignment && (isUntrustedReusableValue(line, taintedBindings) + || shellSourceReferencesTaintedVariable( + line, + taintedVariables, + anyEnvironmentVariableTainted, + ))) { + taintedVariables.add((assignment[1] ?? assignment[2]).toLowerCase()); + } + if (checkoutCommand.test(line) + && (isUntrustedReusableValue(line, taintedBindings) + || shellSourceReferencesTaintedVariable( + line, + taintedVariables, + anyEnvironmentVariableTainted, + ))) return true; + } + return false; +} + +function shellSourceReferencesTaintedVariable(source, taintedVariables, anyTainted) { + const references = [ + ...source.matchAll(/\$(?:env:)?(?:\{([A-Za-z_][A-Za-z0-9_]*)\}|([A-Za-z_][A-Za-z0-9_]*))/giu), + ...source.matchAll(/%([A-Za-z_][A-Za-z0-9_]*)%/gu), + ].map((match) => (match[1] ?? match[2]).toLowerCase()); + return references.some((name) => anyTainted || taintedVariables.has(name)); +} + function contextTaintedBindings(bindings, inheritedTaintedBindings) { const taintedBindings = new Set(inheritedTaintedBindings); for (const binding of bindings) { @@ -2160,7 +2289,7 @@ function contextTaintedBindings(bindings, inheritedTaintedBindings) { function isUntrustedCheckoutInput(key, value, taintedBindings = new Set()) { if (!["ref", "repository"].includes(key.toLowerCase())) return false; if (valueReferencesTaintedBinding(value, taintedBindings)) return true; - if (/github\.event\.(?:comment|issue)\.body(?:\.|\b)/iu.test( + if (/github\.event\.(?:comment\.body|issue\.(?:body|title))(?:\.|\b)/iu.test( normalizeExpressionPropertyAccess(value), )) return true; if (key.toLowerCase() === "repository") { @@ -2171,7 +2300,7 @@ function isUntrustedCheckoutInput(key, value, taintedBindings = new Set()) { } function isUntrustedPullRequestRef(value) { - return /(?:github\.head_ref|github\.event\.(?:comment\.body|issue\.(?:body|number))|pull_request\.(?:head|merge_commit_sha)|head\.sha|refs\/pull\/|workflow_run\.head_sha)/iu + return /(?:github\.head_ref|github\.event\.(?:comment\.body|issue\.(?:body|number|title))|pull_request\.(?:head|merge_commit_sha)|head\.sha|refs\/pull\/|workflow_run\.head_sha)/iu .test(normalizeExpressionPropertyAccess(value)); } @@ -2330,12 +2459,21 @@ function workflowBlockStepPropertyGroups(jobGroup, stepsEntry, scalarAnchors) { .filter(({ entry }) => entry.indentation === mappingIndentation); if (inlineEntry) { const inlineValue = inlineEntry.value.trim(); + let value = inlineValue; + if (isYamlBlockScalarHeader(inlineValue)) { + for (let lineIndex = step.lineIndex + 1; lineIndex < nextLine; lineIndex += 1) { + const line = lines[lineIndex]; + if (line.trim().length > 0 + && /^\s*/u.exec(line)[0].length <= inlineEntry.indentation) break; + value += "\n" + line.trimStart(); + } + } properties.unshift({ entry: { ...inlineEntry, inlineValue, lineIndex: step.lineIndex, - value: inlineValue, + value, valueLineIndex: step.lineIndex, }, }); diff --git a/public-source-release-audit/tests/public-source-release-audit.test.mjs b/public-source-release-audit/tests/public-source-release-audit.test.mjs index 3faae70..ed67a28 100644 --- a/public-source-release-audit/tests/public-source-release-audit.test.mjs +++ b/public-source-release-audit/tests/public-source-release-audit.test.mjs @@ -1312,6 +1312,68 @@ test("untrusted refs propagate through workflow job and step environments", () = } }); +test("untrusted refs persisted through GITHUB_ENV reach later steps", () => { + const repoRoot = makeRepository(); + const headExpression = ["$", "{{ github.head_ref }}"].join(""); + const envExpression = ["$", "{{ env.PR_REF }}"].join(""); + write(repoRoot, ".github/workflows/github-env-ref.yml", [ + "name: GITHUB_ENV ref", + "on: pull_request_target", + "permissions: read-all", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - run: |", + " echo \"PR_REF=" + headExpression + "\" >> \"$GITHUB_ENV\"", + " - uses: actions/checkout@" + "a".repeat(40), + " with:", + " ref: " + envExpression, + "", + ].join("\n")); + commitAll(repoRoot, "add persisted environment checkout workflow"); + + const audit = runAudit(repoRoot); + + assert.equal(audit.status, 1); + assertFinding( + audit.result, + "workflow-privileged-untrusted-checkout", + ".github/workflows/github-env-ref.yml", + ); +}); + +test("GITHUB_ENV taint does not flow backward to earlier steps", () => { + const repoRoot = makeRepository(); + const headExpression = ["$", "{{ github.head_ref }}"].join(""); + const envExpression = ["$", "{{ env.PR_REF }}"].join(""); + write(repoRoot, ".github/workflows/github-env-order.yml", [ + "name: GITHUB_ENV order", + "on: pull_request_target", + "permissions: read-all", + "env:", + " PR_REF: main", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - uses: actions/checkout@" + "a".repeat(40), + " with:", + " ref: " + envExpression, + " - run: |", + " echo \"PR_REF=" + headExpression + "\" >> \"$GITHUB_ENV\"", + "", + ].join("\n")); + commitAll(repoRoot, "add ordered environment workflow"); + + const audit = runAudit(repoRoot); + + assert.equal(audit.status, 0); + assert.equal(audit.result.findings.some((finding) => ( + finding.ruleId === "workflow-privileged-untrusted-checkout" + )), false); +}); + test("untrusted refs propagate through job outputs", () => { const repoRoot = makeRepository(); const headExpression = ["$", "{{ github.head_ref }}"].join(""); @@ -1853,6 +1915,104 @@ test("issue bodies are privileged untrusted checkout coordinates", () => { ); }); +test("issue titles are privileged untrusted checkout coordinates", () => { + const repoRoot = makeRepository(); + const repositoryExpression = [ + "$", + "{{ fromJSON(github.event.issue.title).repository }}", + ].join(""); + const refExpression = [ + "$", + "{{ fromJSON(github.event.issue.title).ref }}", + ].join(""); + write(repoRoot, ".github/workflows/issue-title-checkout.yml", [ + "name: issue title checkout", + "on: issues", + "permissions:", + " contents: write", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - uses: actions/checkout@" + "a".repeat(40), + " with:", + " repository: " + repositoryExpression, + " ref: " + refExpression, + "", + ].join("\n")); + commitAll(repoRoot, "add issue title checkout"); + + const audit = runAudit(repoRoot); + + assert.equal(audit.status, 1); + assertFinding( + audit.result, + "workflow-privileged-untrusted-checkout", + ".github/workflows/issue-title-checkout.yml", + ); +}); + +test("privileged workflows reject shell-based untrusted checkouts", () => { + const repoRoot = makeRepository(); + const repositoryExpression = [ + "$", + "{{ github.event.pull_request.head.repo.full_name }}", + ].join(""); + const refExpression = ["$", "{{ github.head_ref }}"].join(""); + write(repoRoot, ".github/workflows/shell-checkout.yml", [ + "name: shell checkout", + "on: pull_request_target", + "permissions: read-all", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - run: |", + " REF=\"" + refExpression + "\"", + " git clone \"https://github.com/" + repositoryExpression + "\" source", + " git -C source checkout \"$REF\"", + " ./source/verify.sh", + "", + ].join("\n")); + commitAll(repoRoot, "add shell checkout workflow"); + + const audit = runAudit(repoRoot); + + assert.equal(audit.status, 1); + assertFinding( + audit.result, + "workflow-privileged-untrusted-checkout", + ".github/workflows/shell-checkout.yml", + ); +}); + +test("untrusted shell values unrelated to a fixed checkout do not block", () => { + const repoRoot = makeRepository(); + const refExpression = ["$", "{{ github.head_ref }}"].join(""); + write(repoRoot, ".github/workflows/fixed-shell-checkout.yml", [ + "name: fixed shell checkout", + "on: pull_request_target", + "permissions: read-all", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - run: |", + " git fetch origin \"" + refExpression + "\"", + " git checkout main", + " echo \"requested ref: " + refExpression + "\"", + "", + ].join("\n")); + commitAll(repoRoot, "add fixed shell checkout workflow"); + + const audit = runAudit(repoRoot); + + assert.equal(audit.status, 0); + assert.equal(audit.result.findings.some((finding) => ( + finding.ruleId === "workflow-privileged-untrusted-checkout" + )), false); +}); + test("mutable Docker actions warn while digest-pinned actions pass", () => { const repoRoot = makeRepository(); write(repoRoot, ".github/workflows/mutable-docker.yml", [ @@ -2112,6 +2272,52 @@ test("caller taint propagates into local composite action inputs", () => { ); }); +test("GITHUB_ENV taint propagates across local composite action steps", () => { + const repoRoot = makeRepository(); + const headExpression = ["$", "{{ github.head_ref }}"].join(""); + const inputExpression = ["$", "{{ inputs.ref }}"].join(""); + const envExpression = ["$", "{{ env.PR_REF }}"].join(""); + write(repoRoot, ".github/workflows/composite-environment.yml", [ + "name: composite environment", + "on: pull_request_target", + "permissions: read-all", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - uses: ./.github/actions/environment-checkout", + " with:", + " ref: " + headExpression, + "", + ].join("\n")); + write(repoRoot, ".github/actions/environment-checkout/action.yml", [ + "name: environment checkout", + "inputs:", + " ref:", + " required: true", + "runs:", + " using: composite", + " steps:", + " - shell: bash", + " run: |", + " echo \"PR_REF=" + inputExpression + "\" >> \"$GITHUB_ENV\"", + " - uses: actions/checkout@" + "a".repeat(40), + " with:", + " ref: " + envExpression, + "", + ].join("\n")); + commitAll(repoRoot, "add composite environment checkout"); + + const audit = runAudit(repoRoot); + + assert.equal(audit.status, 1); + assertFinding( + audit.result, + "workflow-privileged-untrusted-checkout", + ".github/actions/environment-checkout/action.yml", + ); +}); + test("composite input defaults are tainted unless callers override them", () => { const unsafeRepo = makeRepository(); const safeRepo = makeRepository(); @@ -2302,6 +2508,63 @@ test("Dockerfile-backed local actions require immutable base images", () => { )), false); }); +test("Dockerfile-backed actions require immutable external COPY images", () => { + const repoRoot = makeRepository(); + write(repoRoot, ".github/workflows/dockerfile-copy-actions.yml", [ + "name: Dockerfile COPY actions", + "on: push", + "permissions: read-all", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - uses: ./.github/actions/mutable-copy", + " - uses: ./.github/actions/pinned-copy", + " - uses: ./.github/actions/staged-copy", + "", + ].join("\n")); + for (const actionName of ["mutable-copy", "pinned-copy", "staged-copy"]) { + write(repoRoot, `.github/actions/${actionName}/action.yml`, [ + "name: " + actionName, + "runs:", + " using: docker", + " image: Dockerfile", + "", + ].join("\n")); + } + write(repoRoot, ".github/actions/mutable-copy/Dockerfile", [ + "FROM scratch", + "COPY --from=alpine:latest /bin/sh /bin/sh", + "", + ].join("\n")); + write(repoRoot, ".github/actions/pinned-copy/Dockerfile", [ + "FROM scratch", + `COPY --from=alpine@sha256:${"a".repeat(64)} /bin/sh /bin/sh`, + "", + ].join("\n")); + write(repoRoot, ".github/actions/staged-copy/Dockerfile", [ + `FROM alpine@sha256:${"b".repeat(64)} AS builder`, + "FROM scratch", + "COPY --from=builder /bin/sh /bin/sh", + "", + ].join("\n")); + commitAll(repoRoot, "add Dockerfile COPY fixtures"); + + const audit = runAudit(repoRoot, ["--fail-on-warning"]); + + assert.equal(audit.status, 1); + assertFinding( + audit.result, + "workflow-mutable-action-ref", + ".github/actions/mutable-copy/Dockerfile", + ); + for (const actionName of ["pinned-copy", "staged-copy"]) { + assert.equal(audit.result.findings.some((finding) => ( + finding.path === `.github/actions/${actionName}/Dockerfile` + )), false); + } +}); + test("runner-group selectors require proof of hosted isolation", () => { const repoRoot = makeRepository(); write(repoRoot, ".github/workflows/flow-runner-group.yml", [ From db76cd22517fc8664240b2172a26acf0bdba40d5 Mon Sep 17 00:00:00 2001 From: Vladimir Date: Sat, 22 Aug 2026 06:13:45 +0800 Subject: [PATCH 20/37] Audit reusable outputs and workflow artifacts --- .../scripts/public-source-release-audit.mjs | 485 ++++++++++++++++-- .../public-source-release-audit.test.mjs | 204 ++++++++ 2 files changed, 647 insertions(+), 42 deletions(-) diff --git a/public-source-release-audit/scripts/public-source-release-audit.mjs b/public-source-release-audit/scripts/public-source-release-audit.mjs index 1f04ef4..363a9e9 100644 --- a/public-source-release-audit/scripts/public-source-release-audit.mjs +++ b/public-source-release-audit/scripts/public-source-release-audit.mjs @@ -14,6 +14,8 @@ const IMMUTABLE_DOCKER_ACTION_PATTERN = /^docker:\/\/[^\s@]+(?:[:][^\s@]+)?@(?:s const ACTION_MANIFEST_PATH_PATTERN = /(?:^|\/)action\.ya?ml$/u; const DOCKERFILE_PATH_PATTERN = /(?:^|\/)(?:Dockerfile|[^/]+\.dockerfile)$/iu; const PRIVILEGED_WORKFLOW_TRIGGERS = new Set([ + "discussion", + "discussion_comment", "issue_comment", "issues", "pull_request_target", @@ -231,7 +233,7 @@ function auditWorkflowSources(repoRoot, { includeHistory = false } = {}) { return findings; } -function auditPrivilegedReusableWorkflowCalls(workflowSources) { +function reusableWorkflowGraph(workflowSources) { const sourceBySnapshotPath = new Map(workflowSources.map((source) => [ `${source.snapshot}\0${source.path}`, source, @@ -241,21 +243,136 @@ function auditPrivilegedReusableWorkflowCalls(workflowSources) { if (analysisBySource.has(source)) return analysisBySource.get(source); const syntax = workflowSyntax(source.text); const analysis = { - hasPrivilegedTrigger: hasPrivilegedWorkflowTrigger(syntax.triggerNames), isReusable: syntax.triggerNames.some((name) => name.toLowerCase() === "workflow_call"), localCalls: localReusableWorkflowCalls(syntax.uncommented, syntax.scalarAnchors), + privileged: hasPrivilegedWorkflowTrigger(syntax.triggerNames), syntax, }; analysisBySource.set(source, analysis); return analysis; }; + const taintedOutputMemo = new Map(); + const taintedOutputsFor = ( + source, + inheritedTaintedBindings, + depth = 0, + active = new Set(), + ) => { + const stateKey = [ + source.snapshot, + source.path, + ...[...inheritedTaintedBindings].sort(), + ].join("\0"); + if (depth > 10 || active.has(stateKey)) return new Set(); + if (taintedOutputMemo.has(stateKey)) return taintedOutputMemo.get(stateKey); + const analysis = analysisFor(source); + if (!analysis.isReusable) return new Set(); + const nestedReturnedBindings = new Set(); + let changed; + let iteration = 0; + do { + changed = false; + iteration += 1; + const callerTaintedBindings = mergeTaintedBindings( + inheritedTaintedBindings, + nestedReturnedBindings, + ); + for (const call of analysis.localCalls) { + const callee = sourceBySnapshotPath.get( + `${source.snapshot}\0${call.workflowPath}`, + ); + if (!callee || !analysisFor(callee).isReusable) continue; + const calleeInputs = reusableCallTaintedBindings(call, callerTaintedBindings); + const calleeOutputs = taintedOutputsFor( + callee, + calleeInputs, + depth + 1, + new Set([...active, stateKey]), + ); + for (const outputName of calleeOutputs) { + const binding = `needs.${call.jobGroup.jobName}.outputs.${outputName}`; + if (nestedReturnedBindings.has(binding)) continue; + nestedReturnedBindings.add(binding); + changed = true; + } + } + } while (changed && iteration <= 10); + const effectiveTaintedBindings = mergeTaintedBindings( + inheritedTaintedBindings, + nestedReturnedBindings, + ); + const outputEvaluationBindings = mergeTaintedBindings( + effectiveTaintedBindings, + workflowJobOutputTaintedBindings( + analysis.syntax.uncommented, + analysis.syntax.scalarAnchors, + effectiveTaintedBindings, + ), + ); + const outputs = new Set(reusableWorkflowOutputBindings( + analysis.syntax.uncommented, + analysis.syntax.scalarAnchors, + ).filter((binding) => isUntrustedReusableValue( + binding.value, + outputEvaluationBindings, + )).map((binding) => binding.name)); + taintedOutputMemo.set(stateKey, outputs); + return outputs; + }; + const returnedBindingsFor = (source, inheritedTaintedBindings) => { + const analysis = analysisFor(source); + const returnedBindings = new Set(); + let changed; + let iteration = 0; + do { + changed = false; + iteration += 1; + const callerTaintedBindings = mergeTaintedBindings( + inheritedTaintedBindings, + returnedBindings, + ); + for (const call of analysis.localCalls) { + const callee = sourceBySnapshotPath.get(`${source.snapshot}\0${call.workflowPath}`); + if (!callee || !analysisFor(callee).isReusable) continue; + const calleeInputs = reusableCallTaintedBindings(call, callerTaintedBindings); + for (const outputName of taintedOutputsFor(callee, calleeInputs)) { + const binding = `needs.${call.jobGroup.jobName}.outputs.${outputName}`; + if (returnedBindings.has(binding)) continue; + returnedBindings.add(binding); + changed = true; + } + } + } while (changed && iteration <= 10); + return returnedBindings; + }; + return { analysisFor, returnedBindingsFor, sourceBySnapshotPath }; +} + +function auditPrivilegedReusableWorkflowCalls(workflowSources) { + const { analysisFor, returnedBindingsFor, sourceBySnapshotPath } = reusableWorkflowGraph( + workflowSources, + ); const findings = []; for (const caller of workflowSources) { const callerAnalysis = analysisFor(caller); - if (!callerAnalysis.hasPrivilegedTrigger) continue; + if (!callerAnalysis.privileged) continue; + const returnedBindings = returnedBindingsFor(caller, new Set()); + if (hasUntrustedPullRequestCheckout( + callerAnalysis.syntax.uncommented, + callerAnalysis.syntax.scalarAnchors, + returnedBindings, + )) { + findings.push(workflowFinding({ + message: "A privileged workflow must not execute an untrusted checkout returned through a reusable workflow output.", + path: caller.path, + ruleId: "workflow-privileged-untrusted-checkout", + severity: "error", + source: caller.source, + })); + } const pending = callerAnalysis.localCalls.map((call) => ({ depth: 1, - taintedBindings: reusableCallTaintedBindings(call, new Set()), + taintedBindings: reusableCallTaintedBindings(call, returnedBindings), workflowPath: call.workflowPath, })); const visited = new Set(); @@ -281,6 +398,19 @@ function auditPrivilegedReusableWorkflowCalls(workflowSources) { source: callee.source, })); } + if (hasUntrustedWorkflowArtifactExecution( + calleeAnalysis.syntax.uncommented, + calleeAnalysis.syntax.scalarAnchors, + taintedBindings, + )) { + findings.push(workflowFinding({ + message: "A reusable workflow called from a privileged trigger must not execute untrusted workflow artifacts.", + path: callee.path, + ruleId: "workflow-privileged-untrusted-artifact-execution", + severity: "error", + source: callee.source, + })); + } pending.push(...calleeAnalysis.localCalls.map((nestedCall) => ({ depth: depth + 1, taintedBindings: reusableCallTaintedBindings(nestedCall, taintedBindings), @@ -292,23 +422,9 @@ function auditPrivilegedReusableWorkflowCalls(workflowSources) { } function workflowExecutionStates(workflowSources) { - const sourceBySnapshotPath = new Map(workflowSources.map((source) => [ - `${source.snapshot}\0${source.path}`, - source, - ])); - const analysisBySource = new Map(); - const analysisFor = (source) => { - if (analysisBySource.has(source)) return analysisBySource.get(source); - const syntax = workflowSyntax(source.text); - const analysis = { - isReusable: syntax.triggerNames.some((name) => name.toLowerCase() === "workflow_call"), - localCalls: localReusableWorkflowCalls(syntax.uncommented, syntax.scalarAnchors), - privileged: hasPrivilegedWorkflowTrigger(syntax.triggerNames), - syntax, - }; - analysisBySource.set(source, analysis); - return analysis; - }; + const { analysisFor, returnedBindingsFor, sourceBySnapshotPath } = reusableWorkflowGraph( + workflowSources, + ); const pending = workflowSources.map((workflow) => ({ depth: 0, privileged: analysisFor(workflow).privileged, @@ -319,15 +435,19 @@ function workflowExecutionStates(workflowSources) { const visited = new Set(); while (pending.length > 0) { const state = pending.pop(); + const effectiveTaintedBindings = mergeTaintedBindings( + state.taintedBindings, + returnedBindingsFor(state.workflow, state.taintedBindings), + ); const stateKey = [ state.workflow.snapshot, state.workflow.path, state.privileged ? "privileged" : "unprivileged", - ...[...state.taintedBindings].sort(), + ...[...effectiveTaintedBindings].sort(), ].join("\0"); if (state.depth > 10 || visited.has(stateKey)) continue; visited.add(stateKey); - states.push(state); + states.push({ ...state, taintedBindings: effectiveTaintedBindings }); const analysis = analysisFor(state.workflow); for (const call of analysis.localCalls) { const callee = sourceBySnapshotPath.get( @@ -337,7 +457,7 @@ function workflowExecutionStates(workflowSources) { pending.push({ depth: state.depth + 1, privileged: state.privileged, - taintedBindings: reusableCallTaintedBindings(call, state.taintedBindings), + taintedBindings: reusableCallTaintedBindings(call, effectiveTaintedBindings), workflow: callee, }); } @@ -498,6 +618,18 @@ function auditLocalCompositeActions(workflowSources, actionSources, dockerfileSo source: action.source, })); } + if (privileged && stepContextsHaveUntrustedArtifactExecution( + actionStepContexts, + analysis.syntax.scalarAnchors, + )) { + findings.push(workflowFinding({ + message: "A local composite action called from a privileged workflow must not execute untrusted workflow artifacts.", + path: action.path, + ruleId: "workflow-privileged-untrusted-artifact-execution", + severity: "error", + source: action.source, + })); + } } } return findings; @@ -636,6 +768,73 @@ function localReusableWorkflowCalls(text, scalarAnchors) { return calls; } +function reusableWorkflowOutputBindings(text, scalarAnchors) { + const bindings = []; + for (const onGroup of workflowRootContainerGroups(text, "on", scalarAnchors)) { + for (const workflowCallProperty of onGroup.properties) { + if (workflowCallProperty.entry.key.toLowerCase() !== "workflow_call") continue; + const workflowCallGroup = { ...onGroup, properties: [workflowCallProperty] }; + for (const outputsProperty of workflowPropertyMappingEntries( + workflowCallGroup, + workflowCallProperty, + scalarAnchors, + )) { + if (outputsProperty.entry.key.toLowerCase() !== "outputs") continue; + const outputsGroup = { ...onGroup, properties: [outputsProperty] }; + for (const outputProperty of workflowPropertyMappingEntries( + outputsGroup, + outputsProperty, + scalarAnchors, + )) { + const outputName = resolveYamlScalarValue( + outputProperty.entry.key, + scalarAnchors, + ).toLowerCase(); + const outputGroup = { ...onGroup, properties: [outputProperty] }; + for (const valueProperty of workflowPropertyMappingEntries( + outputGroup, + outputProperty, + scalarAnchors, + )) { + if (valueProperty.entry.key.toLowerCase() !== "value") continue; + bindings.push({ + name: outputName, + value: resolveYamlScalarValue(valueProperty.entry.value, scalarAnchors), + }); + } + } + } + } + } + return bindings; +} + +function workflowJobOutputTaintedBindings(text, scalarAnchors, inheritedTaintedBindings) { + const jobGroups = workflowJobPropertyGroups(text, scalarAnchors); + const contexts = workflowJobTaintContexts( + jobGroups, + workflowRootMappingBindings(text, "env", "env", scalarAnchors), + scalarAnchors, + inheritedTaintedBindings, + ); + const taintedBindings = new Set(); + for (const group of jobGroups) { + if (!group.jobName) continue; + const jobTaintedBindings = contexts.get(group) ?? inheritedTaintedBindings; + for (const binding of workflowMappingBindings( + group, + "outputs", + `needs.${group.jobName}.outputs`, + scalarAnchors, + )) { + if (!isUntrustedReusableValue(binding.value, jobTaintedBindings)) continue; + taintedBindings.add(`${binding.namespace}.${binding.name}`); + taintedBindings.add(`jobs.${group.jobName}.outputs.${binding.name}`); + } + } + return taintedBindings; +} + function workflowMappingBindings(group, propertyName, namespace, scalarAnchors) { const bindings = []; for (const property of group.properties) { @@ -913,6 +1112,9 @@ function workflowStepTaintAnalysis(stepGroups, scalarAnchors, inheritedTaintedBi for (const binding of githubEnvironmentWriteBindings(runSource, stepTaintedBindings)) { derivedTaintedBindings.add(`env.${binding}`); } + if (shellRunTaintsFetchHead(runSource, stepTaintedBindings)) { + derivedTaintedBindings.add("git.fetch_head"); + } } if (!stepId) continue; const outputSources = [ @@ -1205,6 +1407,17 @@ function auditWorkflowText(workflowPath, text, source = "tracked-file") { })); } + if (hasPrivilegedTrigger + && hasUntrustedWorkflowArtifactExecution(uncommented, scalarAnchors)) { + findings.push(workflowFinding({ + message: "A privileged workflow must not execute artifacts produced by an untrusted triggering run.", + path: workflowPath, + ruleId: "workflow-privileged-untrusted-artifact-execution", + severity: "error", + source, + })); + } + for (const actionRef of actionReferences(uncommented, scalarAnchors)) { if (isMutableRemoteActionReference(actionRef)) { findings.push(workflowFinding({ @@ -1856,20 +2069,95 @@ function isKnownGithubHostedRunnerLabel(value) { function workflowRunnerLabels(value, scalarAnchors) { const resolved = resolveYamlScalarValue(value, scalarAnchors); - const literalExpression = /^\$\{\{\s*(?:'((?:''|[^'])*)'|"((?:\\.|[^"\\])*)")\s*\}\}$/u.exec( - resolved.trim(), - ); - if (literalExpression) { - return [literalExpression[1] !== undefined - ? literalExpression[1].replaceAll("''", "'") - : decodeYamlDoubleQuotedScalar(literalExpression[2])]; - } + const constantExpression = githubConstantExpressionValue(resolved); + if (typeof constantExpression === "string") return [constantExpression]; const sequenceValues = yamlFlowSequenceValues(resolved); return sequenceValues ? sequenceValues.flatMap((item) => workflowRunnerLabels(item, scalarAnchors)) : [resolved]; } +function githubConstantExpressionValue(value) { + const expression = /^\$\{\{\s*([\s\S]*?)\s*\}\}$/u.exec(value.trim()); + return expression ? evaluateGithubConstantExpression(expression[1]) : undefined; +} + +function evaluateGithubConstantExpression(expression) { + const normalized = expression.trim(); + const singleQuoted = /^'((?:''|[^'])*)'$/u.exec(normalized); + if (singleQuoted) return singleQuoted[1].replaceAll("''", "'"); + const doubleQuoted = /^"((?:\\.|[^"\\])*)"$/u.exec(normalized); + if (doubleQuoted) return decodeYamlDoubleQuotedScalar(doubleQuoted[1]); + const call = /^([A-Za-z_][A-Za-z0-9_]*)\s*\(([\s\S]*)\)$/u.exec(normalized); + if (!call) return undefined; + const argumentSources = splitGithubExpressionArguments(call[2]); + if (!argumentSources) return undefined; + const arguments_ = argumentSources.map(evaluateGithubConstantExpression); + if (arguments_.some((argument) => argument === undefined)) return undefined; + if (call[1].toLowerCase() === "fromjson" && arguments_.length === 1 + && typeof arguments_[0] === "string") { + try { + return JSON.parse(arguments_[0]); + } catch { + return undefined; + } + } + if (call[1].toLowerCase() === "join" && arguments_.length >= 1 + && Array.isArray(arguments_[0])) { + const separator = arguments_[1] === undefined ? "," : String(arguments_[1]); + return arguments_[0].map(String).join(separator); + } + if (call[1].toLowerCase() !== "format" || arguments_.length === 0 + || typeof arguments_[0] !== "string") return undefined; + const openBrace = "\u0000OPEN_BRACE\u0000"; + const closeBrace = "\u0000CLOSE_BRACE\u0000"; + return arguments_[0] + .replaceAll("{{", openBrace) + .replaceAll("}}", closeBrace) + .replace(/\{(\d+)\}/gu, (placeholder, index) => ( + arguments_[Number(index) + 1] === undefined + ? placeholder + : String(arguments_[Number(index) + 1]) + )) + .replaceAll(openBrace, "{") + .replaceAll(closeBrace, "}"); +} + +function splitGithubExpressionArguments(source) { + if (source.trim().length === 0) return []; + const arguments_ = []; + let argumentStart = 0; + let depth = 0; + let quote; + for (let index = 0; index < source.length; index += 1) { + const character = source[index]; + if (quote) { + if (quote === "'" && character === "'" && source[index + 1] === "'") { + index += 1; + } else if (quote === "\"" && character === "\\") { + index += 1; + } else if (character === quote) { + quote = undefined; + } + continue; + } + if (character === "'" || character === "\"") { + quote = character; + } else if (["(", "[", "{"].includes(character)) { + depth += 1; + } else if ([")", "]", "}"].includes(character)) { + depth -= 1; + if (depth < 0) return undefined; + } else if (character === "," && depth === 0) { + arguments_.push(source.slice(argumentStart, index).trim()); + argumentStart = index + 1; + } + } + if (quote || depth !== 0) return undefined; + arguments_.push(source.slice(argumentStart).trim()); + return arguments_.every((argument) => argument.length > 0) ? arguments_ : undefined; +} + function workflowJobRunnerLabelSets(text, scalarAnchors) { const runnerLabelSets = []; for (const group of workflowJobPropertyGroups(text, scalarAnchors)) { @@ -2203,6 +2491,101 @@ function hasUntrustedPullRequestCheckout(text, scalarAnchors, taintedBindings = return false; } +function hasUntrustedWorkflowArtifactExecution( + text, + scalarAnchors, + taintedBindings = new Set(), +) { + const jobGroups = workflowJobPropertyGroups(text, scalarAnchors); + const jobTaintAnalyses = workflowJobTaintAnalyses( + jobGroups, + workflowRootMappingBindings(text, "env", "env", scalarAnchors), + scalarAnchors, + taintedBindings, + ); + return jobGroups.some((jobGroup) => stepContextsHaveUntrustedArtifactExecution( + jobTaintAnalyses.get(jobGroup)?.stepContexts ?? [], + scalarAnchors, + )); +} + +function stepContextsHaveUntrustedArtifactExecution(stepContexts, scalarAnchors) { + const artifactPaths = []; + for (const { stepGroup, taintedBindings } of stepContexts) { + if (stepDownloadsUntrustedWorkflowArtifact( + stepGroup, + scalarAnchors, + taintedBindings, + )) { + artifactPaths.push(downloadedArtifactPath(stepGroup, scalarAnchors)); + continue; + } + if (artifactPaths.some((artifactPath) => stepExecutesArtifactPath( + stepGroup, + scalarAnchors, + artifactPath, + ))) return true; + } + return false; +} + +function stepDownloadsUntrustedWorkflowArtifact( + stepGroup, + scalarAnchors, + taintedBindings, +) { + const downloadsArtifact = stepGroup.properties + .filter(({ entry }) => entry.key.toLowerCase() === "uses") + .some(({ entry }) => /^actions\/download-artifact@/iu.test( + resolveYamlScalarValue(entry.value, scalarAnchors), + )); + if (!downloadsArtifact) return false; + return workflowMappingBindings(stepGroup, "with", "with", scalarAnchors) + .filter((binding) => binding.name === "run-id") + .some((binding) => isUntrustedReusableValue(binding.value, taintedBindings)); +} + +function downloadedArtifactPath(stepGroup, scalarAnchors) { + const value = workflowMappingBindings(stepGroup, "with", "with", scalarAnchors) + .find((binding) => binding.name === "path")?.value; + if (!value || /\$|%/u.test(value) || path.posix.isAbsolute(value)) return "."; + const normalized = path.posix.normalize(value).replace(/^\.\//u, ""); + return normalized === ".." || normalized.startsWith("../") ? "." : normalized; +} + +function stepExecutesArtifactPath(stepGroup, scalarAnchors, artifactPath) { + const localActionReference = stepGroup.properties + .filter(({ entry }) => entry.key.toLowerCase() === "uses") + .map(({ entry }) => resolveYamlScalarValue(entry.value, scalarAnchors)) + .find((reference) => reference.startsWith("./")); + if (localActionReference && artifactSourceMatchesPath(localActionReference, artifactPath)) { + return true; + } + return stepGroup.properties + .filter(({ entry }) => entry.key.toLowerCase() === "run") + .map(({ entry }) => resolveYamlScalarValue(entry.value, scalarAnchors)) + .some((runSource) => shellRunExecutesArtifactPath(runSource, artifactPath)); +} + +function shellRunExecutesArtifactPath(runSource, artifactPath) { + const executableCommand = /(?:^|[;&|]\s*)(?:sudo\s+|command\s+)?(?:(?:bash|deno|node|perl|php|python\d*|ruby|sh|zsh)\s+(?:-[^\s]+\s+)*|(?:source|\.)\s+|\.\.?\/)([^\s;&|]+)/iu; + return runSource + .replace(/\r\n?|\u0085|\u2028|\u2029/gu, "\n") + .replace(/\\\n[ \t]*/gu, " ") + .split("\n") + .filter((line) => !/^\s*#/u.test(line)) + .some((line) => { + const execution = executableCommand.exec(line); + return execution && artifactSourceMatchesPath(execution[1], artifactPath); + }); +} + +function artifactSourceMatchesPath(source, artifactPath) { + const normalized = source.replace(/^["']|["']$/gu, "").replace(/^\.\//u, ""); + if (artifactPath === ".") return !path.posix.isAbsolute(normalized); + return normalized === artifactPath || normalized.startsWith(`${artifactPath}/`); +} + function stepGroupHasUntrustedCheckout(stepGroup, scalarAnchors, stepTaintedBindings) { const hasUntrustedShellCheckout = stepGroup.properties .filter(({ entry }) => entry.key.toLowerCase() === "run") @@ -2224,7 +2607,16 @@ function stepGroupHasUntrustedCheckout(stepGroup, scalarAnchors, stepTaintedBind } function shellRunHasUntrustedCheckout(runSource, taintedBindings) { + return shellRunGitTaintAnalysis(runSource, taintedBindings).hasUntrustedCheckout; +} + +function shellRunTaintsFetchHead(runSource, taintedBindings) { + return shellRunGitTaintAnalysis(runSource, taintedBindings).taintsFetchHead; +} + +function shellRunGitTaintAnalysis(runSource, taintedBindings) { const checkoutCommand = /\b(?:gh\s+repo\s+clone|git(?:\s+--?[^\s]+(?:[=\s][^\s]+)?)*\s+(?:checkout|clone|pull|reset|switch|worktree))\b/iu; + const fetchCommand = /\bgit(?:\s+--?[^\s]+(?:[=\s][^\s]+)?)*\s+fetch\b/iu; const taintedVariables = new Set([...taintedBindings].flatMap((binding) => ( binding.startsWith("env.") && binding !== "env.*" ? [binding.slice("env.".length).toLowerCase()] @@ -2237,6 +2629,8 @@ function shellRunHasUntrustedCheckout(runSource, taintedBindings) { .replace(/\\\n[ \t]*/gu, " ") .split("\n") .filter((line) => !/^\s*#/u.test(line)); + let fetchedHeadTainted = taintedBindings.has("git.fetch_head"); + let taintsFetchHead = false; for (const line of lines) { const assignment = /^\s*(?:(?:export|local|readonly)\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=|^\s*\$([A-Za-z_][A-Za-z0-9_]*)\s*=/iu.exec(line); if (assignment && (isUntrustedReusableValue(line, taintedBindings) @@ -2247,15 +2641,22 @@ function shellRunHasUntrustedCheckout(runSource, taintedBindings) { ))) { taintedVariables.add((assignment[1] ?? assignment[2]).toLowerCase()); } - if (checkoutCommand.test(line) - && (isUntrustedReusableValue(line, taintedBindings) - || shellSourceReferencesTaintedVariable( - line, - taintedVariables, - anyEnvironmentVariableTainted, - ))) return true; + const lineIsTainted = isUntrustedReusableValue(line, taintedBindings) + || shellSourceReferencesTaintedVariable( + line, + taintedVariables, + anyEnvironmentVariableTainted, + ); + if (fetchCommand.test(line) && lineIsTainted) { + fetchedHeadTainted = true; + taintsFetchHead = true; + } + if (checkoutCommand.test(line) && (lineIsTainted + || (fetchedHeadTainted && /\bFETCH_HEAD\b/iu.test(line)))) { + return { hasUntrustedCheckout: true, taintsFetchHead }; + } } - return false; + return { hasUntrustedCheckout: false, taintsFetchHead }; } function shellSourceReferencesTaintedVariable(source, taintedVariables, anyTainted) { @@ -2289,7 +2690,7 @@ function contextTaintedBindings(bindings, inheritedTaintedBindings) { function isUntrustedCheckoutInput(key, value, taintedBindings = new Set()) { if (!["ref", "repository"].includes(key.toLowerCase())) return false; if (valueReferencesTaintedBinding(value, taintedBindings)) return true; - if (/github\.event\.(?:comment\.body|issue\.(?:body|title))(?:\.|\b)/iu.test( + if (/github\.event\.(?:comment\.body|discussion\.(?:body|title)|issue\.(?:body|title))(?:\.|\b)/iu.test( normalizeExpressionPropertyAccess(value), )) return true; if (key.toLowerCase() === "repository") { @@ -2300,7 +2701,7 @@ function isUntrustedCheckoutInput(key, value, taintedBindings = new Set()) { } function isUntrustedPullRequestRef(value) { - return /(?:github\.head_ref|github\.event\.(?:comment\.body|issue\.(?:body|number|title))|pull_request\.(?:head|merge_commit_sha)|head\.sha|refs\/pull\/|workflow_run\.head_sha)/iu + return /(?:github\.head_ref|github\.event\.(?:comment\.body|discussion\.(?:body|title)|issue\.(?:body|number|title))|pull_request\.(?:head|merge_commit_sha)|head\.sha|refs\/pull\/|workflow_run\.(?:head_sha|id))/iu .test(normalizeExpressionPropertyAccess(value)); } diff --git a/public-source-release-audit/tests/public-source-release-audit.test.mjs b/public-source-release-audit/tests/public-source-release-audit.test.mjs index ed67a28..1b37408 100644 --- a/public-source-release-audit/tests/public-source-release-audit.test.mjs +++ b/public-source-release-audit/tests/public-source-release-audit.test.mjs @@ -1266,6 +1266,81 @@ test("caller matrix taint propagates into reusable workflow inputs", () => { assertFinding(audit.result, "workflow-privileged-untrusted-checkout", ".github/workflows/matrix-callee.yml"); }); +test("reusable workflow outputs propagate taint back to callers", () => { + const repoRoot = makeRepository(); + const headExpression = ["$", "{{ github.head_ref }}"].join(""); + const jobOutputExpression = ["$", "{{ jobs.source.outputs.ref }}"].join(""); + const returnedExpression = ["$", "{{ needs.source.outputs.ref }}"].join(""); + write(repoRoot, ".github/workflows/output-caller.yml", [ + "name: output caller", + "on: pull_request_target", + "permissions: read-all", + "jobs:", + " source:", + " uses: ./.github/workflows/output-callee.yml", + " inspect:", + " needs: source", + " runs-on: ubuntu-latest", + " steps:", + " - uses: actions/checkout@" + "a".repeat(40), + " with:", + " ref: " + returnedExpression, + " inspect-action:", + " needs: source", + " runs-on: ubuntu-latest", + " steps:", + " - uses: ./.github/actions/returned-output-checkout", + " with:", + " ref: " + returnedExpression, + "", + ].join("\n")); + write(repoRoot, ".github/workflows/output-callee.yml", [ + "name: output callee", + "on:", + " workflow_call:", + " outputs:", + " ref:", + " value: " + jobOutputExpression, + "permissions: read-all", + "jobs:", + " source:", + " runs-on: ubuntu-latest", + " outputs:", + " ref: " + headExpression, + " steps:", + " - run: echo source", + "", + ].join("\n")); + write(repoRoot, ".github/actions/returned-output-checkout/action.yml", [ + "name: returned output checkout", + "inputs:", + " ref:", + " required: true", + "runs:", + " using: composite", + " steps:", + " - uses: actions/checkout@" + "a".repeat(40), + " with:", + " ref: ${{ inputs.ref }}", + "", + ].join("\n")); + commitAll(repoRoot, "add reusable output checkout"); + + const audit = runAudit(repoRoot); + + assert.equal(audit.status, 1); + assertFinding( + audit.result, + "workflow-privileged-untrusted-checkout", + ".github/workflows/output-caller.yml", + ); + assertFinding( + audit.result, + "workflow-privileged-untrusted-checkout", + ".github/actions/returned-output-checkout/action.yml", + ); +}); + test("untrusted refs propagate through workflow job and step environments", () => { const repoRoot = makeRepository(); const headExpression = ["$", "{{ github.head_ref }}"].join(""); @@ -1572,6 +1647,35 @@ test("literal runner expressions preserve blocking self-hosted labels", () => { ); }); +test("constructed constant runner expressions preserve blocking labels", () => { + const repoRoot = makeRepository(); + const runnerExpression = [ + "$", + "{{ format('{0}-{1}', 'self', 'hosted') }}", + ].join(""); + write(repoRoot, ".github/workflows/formatted-runner-expression.yml", [ + "name: formatted runner expression", + "on: push", + "permissions: read-all", + "jobs:", + " inspect:", + " runs-on: " + runnerExpression, + " steps:", + " - run: echo inspect", + "", + ].join("\n")); + commitAll(repoRoot, "add formatted runner expression"); + + const audit = runAudit(repoRoot); + + assert.equal(audit.status, 1); + assertFinding( + audit.result, + "workflow-self-hosted-runner", + ".github/workflows/formatted-runner-expression.yml", + ); +}); + test("aliased self-hosted runner labels remain release-blocking", () => { const repoRoot = makeRepository(); write(repoRoot, ".github/workflows/aliased-runner.yml", [ @@ -1784,6 +1888,40 @@ test("workflow_run head checkouts are privileged and untrusted", () => { assertFinding(audit.result, "workflow-privileged-untrusted-checkout", ".github/workflows/workflow-run-checkout.yml"); }); +test("workflow_run artifacts remain untrusted when later executed", () => { + const repoRoot = makeRepository(); + const runIdExpression = ["$", "{{ github.event.workflow_run.id }}"].join(""); + write(repoRoot, ".github/workflows/workflow-run-artifact.yml", [ + "name: workflow run artifact", + "on:", + " workflow_run:", + " workflows: [verify]", + " types: [completed]", + "permissions:", + " contents: write", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - uses: actions/download-artifact@" + "a".repeat(40), + " with:", + " run-id: " + runIdExpression, + " path: payload", + " - run: bash payload/run.sh", + "", + ].join("\n")); + commitAll(repoRoot, "add workflow artifact execution"); + + const audit = runAudit(repoRoot); + + assert.equal(audit.status, 1); + assertFinding( + audit.result, + "workflow-privileged-untrusted-artifact-execution", + ".github/workflows/workflow-run-artifact.yml", + ); +}); + test("issue_comment pull-request checkouts are privileged and untrusted", () => { const repoRoot = makeRepository(); const issueExpression = ["$", "{{ github.event.issue.number }}"].join(""); @@ -1952,6 +2090,43 @@ test("issue titles are privileged untrusted checkout coordinates", () => { ); }); +test("discussion bodies are privileged untrusted checkout coordinates", () => { + const repoRoot = makeRepository(); + const repositoryExpression = [ + "$", + "{{ fromJSON(github.event.discussion.body).repository }}", + ].join(""); + const refExpression = [ + "$", + "{{ fromJSON(github.event.discussion.body).ref }}", + ].join(""); + write(repoRoot, ".github/workflows/discussion-checkout.yml", [ + "name: discussion checkout", + "on: discussion", + "permissions:", + " contents: write", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - uses: actions/checkout@" + "a".repeat(40), + " with:", + " repository: " + repositoryExpression, + " ref: " + refExpression, + "", + ].join("\n")); + commitAll(repoRoot, "add discussion checkout"); + + const audit = runAudit(repoRoot); + + assert.equal(audit.status, 1); + assertFinding( + audit.result, + "workflow-privileged-untrusted-checkout", + ".github/workflows/discussion-checkout.yml", + ); +}); + test("privileged workflows reject shell-based untrusted checkouts", () => { const repoRoot = makeRepository(); const repositoryExpression = [ @@ -1986,6 +2161,35 @@ test("privileged workflows reject shell-based untrusted checkouts", () => { ); }); +test("tainted fetch state propagates through FETCH_HEAD checkouts", () => { + const repoRoot = makeRepository(); + const refExpression = ["$", "{{ github.head_ref }}"].join(""); + write(repoRoot, ".github/workflows/fetched-head-checkout.yml", [ + "name: fetched head checkout", + "on: pull_request_target", + "permissions: read-all", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - run: git fetch origin \"" + refExpression + "\"", + " - run: |", + " git checkout FETCH_HEAD", + " ./verify.sh", + "", + ].join("\n")); + commitAll(repoRoot, "add fetched head checkout"); + + const audit = runAudit(repoRoot); + + assert.equal(audit.status, 1); + assertFinding( + audit.result, + "workflow-privileged-untrusted-checkout", + ".github/workflows/fetched-head-checkout.yml", + ); +}); + test("untrusted shell values unrelated to a fixed checkout do not block", () => { const repoRoot = makeRepository(); const refExpression = ["$", "{{ github.head_ref }}"].join(""); From 63e2ae94e9786019dab87cd3f1de83f3d6eb1302 Mon Sep 17 00:00:00 2001 From: Vladimir Date: Sat, 22 Aug 2026 06:32:45 +0800 Subject: [PATCH 21/37] Audit public event and runtime execution inputs --- .../scripts/public-source-release-audit.mjs | 162 ++++++++++++- .../public-source-release-audit.test.mjs | 224 +++++++++++++++++- 2 files changed, 369 insertions(+), 17 deletions(-) diff --git a/public-source-release-audit/scripts/public-source-release-audit.mjs b/public-source-release-audit/scripts/public-source-release-audit.mjs index 363a9e9..448b330 100644 --- a/public-source-release-audit/scripts/public-source-release-audit.mjs +++ b/public-source-release-audit/scripts/public-source-release-audit.mjs @@ -19,6 +19,7 @@ const PRIVILEGED_WORKFLOW_TRIGGERS = new Set([ "issue_comment", "issues", "pull_request_target", + "watch", "workflow_run", ]); const WORKFLOW_PATH_PATTERN = /^\.github\/workflows\/[^/]+\.ya?ml$/u; @@ -411,6 +412,19 @@ function auditPrivilegedReusableWorkflowCalls(workflowSources) { source: callee.source, })); } + if (hasUntrustedScriptInterpolation( + calleeAnalysis.syntax.uncommented, + calleeAnalysis.syntax.scalarAnchors, + taintedBindings, + )) { + findings.push(workflowFinding({ + message: "A reusable workflow called from a privileged trigger must not interpolate untrusted values directly into scripts.", + path: callee.path, + ruleId: "workflow-privileged-untrusted-script-interpolation", + severity: "error", + source: callee.source, + })); + } pending.push(...calleeAnalysis.localCalls.map((nestedCall) => ({ depth: depth + 1, taintedBindings: reusableCallTaintedBindings(nestedCall, taintedBindings), @@ -630,6 +644,18 @@ function auditLocalCompositeActions(workflowSources, actionSources, dockerfileSo source: action.source, })); } + if (privileged && stepContextsHaveUntrustedScriptInterpolation( + actionStepContexts, + analysis.syntax.scalarAnchors, + )) { + findings.push(workflowFinding({ + message: "A local composite action called from a privileged workflow must not interpolate untrusted values directly into scripts.", + path: action.path, + ruleId: "workflow-privileged-untrusted-script-interpolation", + severity: "error", + source: action.source, + })); + } } } return findings; @@ -1208,7 +1234,8 @@ function localActionCallsFromStepGroup(stepGroup, scalarAnchors, stepTaintedBind function isUntrustedReusableValue(value, taintedBindings) { return isUntrustedPullRequestRef(value) - || /(?:pull_request\.head\.repo|workflow_run\.head_repository)(?:\.|\b)/iu + || isUntrustedPublicEventIdentity(value) + || /(?:pull_request\.head\.repo|workflow_run\.(?:head_repository|pull_requests\s*\[\s*\d+\s*\]\s*\.\s*head\s*\.\s*repo))(?:\.|\b)/iu .test(normalizeExpressionPropertyAccess(value)) || valueReferencesTaintedBinding(value, taintedBindings); } @@ -1418,6 +1445,17 @@ function auditWorkflowText(workflowPath, text, source = "tracked-file") { })); } + if (hasPrivilegedTrigger + && hasUntrustedScriptInterpolation(uncommented, scalarAnchors)) { + findings.push(workflowFinding({ + message: "A privileged workflow must not interpolate untrusted values directly into scripts; pass them through the step environment.", + path: workflowPath, + ruleId: "workflow-privileged-untrusted-script-interpolation", + severity: "error", + source, + })); + } + for (const actionRef of actionReferences(uncommented, scalarAnchors)) { if (isMutableRemoteActionReference(actionRef)) { findings.push(workflowFinding({ @@ -1430,6 +1468,17 @@ function auditWorkflowText(workflowPath, text, source = "tracked-file") { } } + if (workflowContainerImageReferences(uncommented, scalarAnchors) + .some(isMutableDockerBaseImage)) { + findings.push(workflowFinding({ + message: "Workflow job and service container images should use reviewed immutable digests.", + path: workflowPath, + ruleId: "workflow-mutable-container-image", + severity: "warning", + source, + })); + } + return findings; } @@ -1864,6 +1913,7 @@ function yamlBlockMappingEntries(text, scalarAnchors) { if (!parsedEntry) continue; const { entry, valueLineIndex } = parsedEntry; let value = entry.value; + const blockScalarValue = isYamlBlockScalarHeader(entry.value); for (let cursor = valueLineIndex + 1; cursor < lines.length; cursor += 1) { const line = lines[cursor]; if (line.trim().length === 0) continue; @@ -1871,6 +1921,8 @@ function yamlBlockMappingEntries(text, scalarAnchors) { if (nextIndentation <= entry.indentation) break; if (yamlDoubleQuotedScalarContinues(value)) { value = value.slice(0, -1) + line.trimStart(); + } else if (blockScalarValue) { + value += "\n" + line.trimStart(); } else { value += " " + line.trim(); } @@ -2509,6 +2561,28 @@ function hasUntrustedWorkflowArtifactExecution( )); } +function hasUntrustedScriptInterpolation(text, scalarAnchors, taintedBindings = new Set()) { + const jobGroups = workflowJobPropertyGroups(text, scalarAnchors); + const jobTaintAnalyses = workflowJobTaintAnalyses( + jobGroups, + workflowRootMappingBindings(text, "env", "env", scalarAnchors), + scalarAnchors, + taintedBindings, + ); + return jobGroups.some((jobGroup) => stepContextsHaveUntrustedScriptInterpolation( + jobTaintAnalyses.get(jobGroup)?.stepContexts ?? [], + scalarAnchors, + )); +} + +function stepContextsHaveUntrustedScriptInterpolation(stepContexts, scalarAnchors) { + return stepContexts.some(({ stepGroup, taintedBindings }) => stepGroup.properties + .filter(({ entry }) => entry.key.toLowerCase() === "run") + .map(({ entry }) => resolveYamlScalarValue(entry.value, scalarAnchors)) + .some((runSource) => /\$\{\{/u.test(runSource) + && isUntrustedReusableValue(runSource, taintedBindings))); +} + function stepContextsHaveUntrustedArtifactExecution(stepContexts, scalarAnchors) { const artifactPaths = []; for (const { stepGroup, taintedBindings } of stepContexts) { @@ -2561,13 +2635,21 @@ function stepExecutesArtifactPath(stepGroup, scalarAnchors, artifactPath) { if (localActionReference && artifactSourceMatchesPath(localActionReference, artifactPath)) { return true; } + const workingDirectory = stepGroup.properties + .filter(({ entry }) => entry.key.toLowerCase() === "working-directory") + .map(({ entry }) => resolveYamlScalarValue(entry.value, scalarAnchors)) + .find(Boolean) ?? "."; return stepGroup.properties .filter(({ entry }) => entry.key.toLowerCase() === "run") .map(({ entry }) => resolveYamlScalarValue(entry.value, scalarAnchors)) - .some((runSource) => shellRunExecutesArtifactPath(runSource, artifactPath)); + .some((runSource) => shellRunExecutesArtifactPath( + runSource, + artifactPath, + workingDirectory, + )); } -function shellRunExecutesArtifactPath(runSource, artifactPath) { +function shellRunExecutesArtifactPath(runSource, artifactPath, workingDirectory) { const executableCommand = /(?:^|[;&|]\s*)(?:sudo\s+|command\s+)?(?:(?:bash|deno|node|perl|php|python\d*|ruby|sh|zsh)\s+(?:-[^\s]+\s+)*|(?:source|\.)\s+|\.\.?\/)([^\s;&|]+)/iu; return runSource .replace(/\r\n?|\u0085|\u2028|\u2029/gu, "\n") @@ -2576,14 +2658,24 @@ function shellRunExecutesArtifactPath(runSource, artifactPath) { .filter((line) => !/^\s*#/u.test(line)) .some((line) => { const execution = executableCommand.exec(line); - return execution && artifactSourceMatchesPath(execution[1], artifactPath); + return execution && artifactSourceMatchesPath( + execution[1], + artifactPath, + workingDirectory, + ); }); } -function artifactSourceMatchesPath(source, artifactPath) { +function artifactSourceMatchesPath(source, artifactPath, workingDirectory = ".") { const normalized = source.replace(/^["']|["']$/gu, "").replace(/^\.\//u, ""); - if (artifactPath === ".") return !path.posix.isAbsolute(normalized); - return normalized === artifactPath || normalized.startsWith(`${artifactPath}/`); + if (path.posix.isAbsolute(normalized)) return artifactPath === "."; + if (/\$|%/u.test(workingDirectory)) return true; + const effectivePath = path.posix.normalize(path.posix.join( + workingDirectory.replace(/^\.\//u, ""), + normalized, + )); + if (artifactPath === ".") return !effectivePath.startsWith("../"); + return effectivePath === artifactPath || effectivePath.startsWith(`${artifactPath}/`); } function stepGroupHasUntrustedCheckout(stepGroup, scalarAnchors, stepTaintedBindings) { @@ -2690,18 +2782,24 @@ function contextTaintedBindings(bindings, inheritedTaintedBindings) { function isUntrustedCheckoutInput(key, value, taintedBindings = new Set()) { if (!["ref", "repository"].includes(key.toLowerCase())) return false; if (valueReferencesTaintedBinding(value, taintedBindings)) return true; + if (isUntrustedPublicEventIdentity(value)) return true; if (/github\.event\.(?:comment\.body|discussion\.(?:body|title)|issue\.(?:body|title))(?:\.|\b)/iu.test( normalizeExpressionPropertyAccess(value), )) return true; if (key.toLowerCase() === "repository") { - return /(?:pull_request\.head\.repo|workflow_run\.head_repository)(?:\.|\b)/iu + return /(?:pull_request\.head\.repo|workflow_run\.(?:head_repository|pull_requests\s*\[\s*\d+\s*\]\s*\.\s*head\s*\.\s*repo))(?:\.|\b)/iu .test(normalizeExpressionPropertyAccess(value)); } return isUntrustedPullRequestRef(value); } function isUntrustedPullRequestRef(value) { - return /(?:github\.head_ref|github\.event\.(?:comment\.body|discussion\.(?:body|title)|issue\.(?:body|number|title))|pull_request\.(?:head|merge_commit_sha)|head\.sha|refs\/pull\/|workflow_run\.(?:head_sha|id))/iu + return /(?:github\.head_ref|github\.event\.(?:comment\.body|discussion\.(?:body|title)|issue\.(?:body|number|title))|pull_request\.(?:head|merge_commit_sha)|refs\/pull\/|workflow_run\.(?:head_sha|id|pull_requests\s*\[\s*\d+\s*\]\s*\.\s*head))/iu + .test(normalizeExpressionPropertyAccess(value)); +} + +function isUntrustedPublicEventIdentity(value) { + return /(?:github\.(?:actor|triggering_actor)|github\.event\.(?:sender\.login|(?:comment|discussion|issue|pull_request)\.user\.login|workflow_run\.actor\.login))(?:\.|\b)/iu .test(normalizeExpressionPropertyAccess(value)); } @@ -2718,6 +2816,52 @@ function normalizeExpressionPropertyAccess(value) { return normalized; } +function workflowContainerImageReferences(text, scalarAnchors) { + const images = []; + for (const group of workflowJobPropertyGroups(text, scalarAnchors)) { + for (const property of group.properties) { + const propertyName = property.entry.key.toLowerCase(); + if (propertyName === "container") { + const containerGroup = { ...group, properties: [property] }; + const imageEntries = workflowPropertyMappingEntries( + containerGroup, + property, + scalarAnchors, + ).filter(({ entry }) => entry.key.toLowerCase() === "image"); + if (imageEntries.length > 0) { + images.push(...imageEntries.map(({ entry }) => ( + resolveYamlScalarValue(entry.value, scalarAnchors) + ))); + } else { + const inlineValue = resolveYamlScalarValue( + property.entry.inlineValue ?? property.entry.value, + scalarAnchors, + ); + if (inlineValue.length > 0 && !inlineValue.startsWith("{")) { + images.push(inlineValue); + } + } + } + if (propertyName !== "services") continue; + const servicesGroup = { ...group, properties: [property] }; + for (const serviceProperty of workflowPropertyMappingEntries( + servicesGroup, + property, + scalarAnchors, + )) { + const serviceGroup = { ...group, properties: [serviceProperty] }; + images.push(...workflowPropertyMappingEntries( + serviceGroup, + serviceProperty, + scalarAnchors, + ).filter(({ entry }) => entry.key.toLowerCase() === "image") + .map(({ entry }) => resolveYamlScalarValue(entry.value, scalarAnchors))); + } + } + } + return images; +} + function actionReferences(text, scalarAnchors) { text = maskYamlBlockScalarBodies(text); return [ diff --git a/public-source-release-audit/tests/public-source-release-audit.test.mjs b/public-source-release-audit/tests/public-source-release-audit.test.mjs index 1b37408..6702d2d 100644 --- a/public-source-release-audit/tests/public-source-release-audit.test.mjs +++ b/public-source-release-audit/tests/public-source-release-audit.test.mjs @@ -1399,8 +1399,10 @@ test("untrusted refs persisted through GITHUB_ENV reach later steps", () => { " inspect:", " runs-on: ubuntu-latest", " steps:", - " - run: |", - " echo \"PR_REF=" + headExpression + "\" >> \"$GITHUB_ENV\"", + " - env:", + " SOURCE_REF: " + headExpression, + " run: |", + " echo \"PR_REF=$SOURCE_REF\" >> \"$GITHUB_ENV\"", " - uses: actions/checkout@" + "a".repeat(40), " with:", " ref: " + envExpression, @@ -1435,8 +1437,10 @@ test("GITHUB_ENV taint does not flow backward to earlier steps", () => { " - uses: actions/checkout@" + "a".repeat(40), " with:", " ref: " + envExpression, - " - run: |", - " echo \"PR_REF=" + headExpression + "\" >> \"$GITHUB_ENV\"", + " - env:", + " SOURCE_REF: " + headExpression, + " run: |", + " echo \"PR_REF=$SOURCE_REF\" >> \"$GITHUB_ENV\"", "", ].join("\n")); commitAll(repoRoot, "add ordered environment workflow"); @@ -1888,6 +1892,41 @@ test("workflow_run head checkouts are privileged and untrusted", () => { assertFinding(audit.result, "workflow-privileged-untrusted-checkout", ".github/workflows/workflow-run-checkout.yml"); }); +test("workflow_run pull-request repositories remain untrusted", () => { + const repoRoot = makeRepository(); + const repositoryExpression = [ + "$", + "{{ github.event.workflow_run.pull_requests[0].head.repo.full_name }}", + ].join(""); + write(repoRoot, ".github/workflows/workflow-run-pr-repository.yml", [ + "name: workflow run PR repository", + "on:", + " workflow_run:", + " workflows: [verify]", + " types: [completed]", + "permissions: read-all", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - uses: actions/checkout@" + "a".repeat(40), + " with:", + " repository: " + repositoryExpression, + " ref: main", + "", + ].join("\n")); + commitAll(repoRoot, "add workflow run PR repository checkout"); + + const audit = runAudit(repoRoot); + + assert.equal(audit.status, 1); + assertFinding( + audit.result, + "workflow-privileged-untrusted-checkout", + ".github/workflows/workflow-run-pr-repository.yml", + ); +}); + test("workflow_run artifacts remain untrusted when later executed", () => { const repoRoot = makeRepository(); const runIdExpression = ["$", "{{ github.event.workflow_run.id }}"].join(""); @@ -1922,6 +1961,40 @@ test("workflow_run artifacts remain untrusted when later executed", () => { ); }); +test("artifact execution resolves step working directories", () => { + const repoRoot = makeRepository(); + const runIdExpression = ["$", "{{ github.event.workflow_run.id }}"].join(""); + write(repoRoot, ".github/workflows/workflow-run-artifact-directory.yml", [ + "name: workflow run artifact directory", + "on:", + " workflow_run:", + " workflows: [verify]", + " types: [completed]", + "permissions: read-all", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - uses: actions/download-artifact@" + "a".repeat(40), + " with:", + " run-id: " + runIdExpression, + " path: payload", + " - working-directory: payload", + " run: bash run.sh", + "", + ].join("\n")); + commitAll(repoRoot, "add artifact working directory execution"); + + const audit = runAudit(repoRoot); + + assert.equal(audit.status, 1); + assertFinding( + audit.result, + "workflow-privileged-untrusted-artifact-execution", + ".github/workflows/workflow-run-artifact-directory.yml", + ); +}); + test("issue_comment pull-request checkouts are privileged and untrusted", () => { const repoRoot = makeRepository(); const issueExpression = ["$", "{{ github.event.issue.number }}"].join(""); @@ -2127,6 +2200,89 @@ test("discussion bodies are privileged untrusted checkout coordinates", () => { ); }); +test("privileged scripts require environment indirection for untrusted values", () => { + const unsafeRepo = makeRepository(); + const safeRepo = makeRepository(); + const bodyExpression = ["$", "{{ github.event.comment.body }}"].join(""); + write(unsafeRepo, ".github/workflows/direct-comment-script.yml", [ + "name: direct comment script", + "on: issue_comment", + "permissions: read-all", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - run: echo \"comment=" + bodyExpression + "\"", + "", + ].join("\n")); + write(safeRepo, ".github/workflows/environment-comment-script.yml", [ + "name: environment comment script", + "on: issue_comment", + "permissions: read-all", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - env:", + " COMMENT_BODY: " + bodyExpression, + " run: printf '%s\\n' \"$COMMENT_BODY\"", + "", + ].join("\n")); + commitAll(unsafeRepo, "add direct comment interpolation"); + commitAll(safeRepo, "add environment comment handling"); + + const unsafeAudit = runAudit(unsafeRepo); + const safeAudit = runAudit(safeRepo); + + assert.equal(unsafeAudit.status, 1); + assertFinding( + unsafeAudit.result, + "workflow-privileged-untrusted-script-interpolation", + ".github/workflows/direct-comment-script.yml", + ); + assert.equal(safeAudit.status, 0); +}); + +test("public event author identities are untrusted checkout coordinates", () => { + const repoRoot = makeRepository(); + const issueAuthorExpression = [ + "$", + "{{ github.event.issue.user.login }}", + ].join(""); + const actorExpression = ["$", "{{ github.actor }}"].join(""); + for (const [name, trigger, ownerExpression] of [ + ["issue-author", "issues", issueAuthorExpression], + ["watch-actor", "watch", actorExpression], + ]) { + write(repoRoot, `.github/workflows/${name}-checkout.yml`, [ + "name: " + name + " checkout", + "on: " + trigger, + "permissions: read-all", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - uses: actions/checkout@" + "a".repeat(40), + " with:", + " repository: " + ownerExpression + "/public-payload", + " ref: main", + "", + ].join("\n")); + } + commitAll(repoRoot, "add public author checkouts"); + + const audit = runAudit(repoRoot); + + assert.equal(audit.status, 1); + for (const name of ["issue-author", "watch-actor"]) { + assertFinding( + audit.result, + "workflow-privileged-untrusted-checkout", + `.github/workflows/${name}-checkout.yml`, + ); + } +}); + test("privileged workflows reject shell-based untrusted checkouts", () => { const repoRoot = makeRepository(); const repositoryExpression = [ @@ -2201,10 +2357,12 @@ test("untrusted shell values unrelated to a fixed checkout do not block", () => " inspect:", " runs-on: ubuntu-latest", " steps:", - " - run: |", - " git fetch origin \"" + refExpression + "\"", + " - env:", + " FETCH_REF: " + refExpression, + " run: |", + " git fetch origin \"$FETCH_REF\"", " git checkout main", - " echo \"requested ref: " + refExpression + "\"", + " echo \"requested ref: $FETCH_REF\"", "", ].join("\n")); commitAll(repoRoot, "add fixed shell checkout workflow"); @@ -2217,6 +2375,54 @@ test("untrusted shell values unrelated to a fixed checkout do not block", () => )), false); }); +test("workflow container and service images require immutable digests", () => { + const repoRoot = makeRepository(); + write(repoRoot, ".github/workflows/mutable-containers.yml", [ + "name: mutable containers", + "on: push", + "permissions: read-all", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " container:", + " image: alpine:latest", + " services:", + " database:", + " image: postgres:latest", + " steps:", + " - run: echo inspect", + "", + ].join("\n")); + write(repoRoot, ".github/workflows/pinned-containers.yml", [ + "name: pinned containers", + "on: push", + "permissions: read-all", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + ` container: alpine@sha256:${"a".repeat(64)}`, + " services:", + " database:", + ` image: postgres@sha256:${"b".repeat(64)}`, + " steps:", + " - run: echo inspect", + "", + ].join("\n")); + commitAll(repoRoot, "add workflow container fixtures"); + + const audit = runAudit(repoRoot, ["--fail-on-warning"]); + + assert.equal(audit.status, 1); + assertFinding( + audit.result, + "workflow-mutable-container-image", + ".github/workflows/mutable-containers.yml", + ); + assert.equal(audit.result.findings.some((finding) => ( + finding.path === ".github/workflows/pinned-containers.yml" + )), false); +}); + test("mutable Docker actions warn while digest-pinned actions pass", () => { const repoRoot = makeRepository(); write(repoRoot, ".github/workflows/mutable-docker.yml", [ @@ -2503,8 +2709,10 @@ test("GITHUB_ENV taint propagates across local composite action steps", () => { " using: composite", " steps:", " - shell: bash", + " env:", + " SOURCE_REF: " + inputExpression, " run: |", - " echo \"PR_REF=" + inputExpression + "\" >> \"$GITHUB_ENV\"", + " echo \"PR_REF=$SOURCE_REF\" >> \"$GITHUB_ENV\"", " - uses: actions/checkout@" + "a".repeat(40), " with:", " ref: " + envExpression, From e96c521db094cdc41e76df803733c98c9c3cfd0f Mon Sep 17 00:00:00 2001 From: Vladimir Date: Sat, 22 Aug 2026 06:57:44 +0800 Subject: [PATCH 22/37] Audit remaining privileged workflow inputs --- .../scripts/public-source-release-audit.mjs | 245 ++++++++++++++---- .../public-source-release-audit.test.mjs | 172 ++++++++++++ 2 files changed, 365 insertions(+), 52 deletions(-) diff --git a/public-source-release-audit/scripts/public-source-release-audit.mjs b/public-source-release-audit/scripts/public-source-release-audit.mjs index 448b330..ca881c8 100644 --- a/public-source-release-audit/scripts/public-source-release-audit.mjs +++ b/public-source-release-audit/scripts/public-source-release-audit.mjs @@ -371,6 +371,19 @@ function auditPrivilegedReusableWorkflowCalls(workflowSources) { source: caller.source, })); } + if (hasUntrustedWorkflowContainerImage( + callerAnalysis.syntax.uncommented, + callerAnalysis.syntax.scalarAnchors, + returnedBindings, + )) { + findings.push(workflowFinding({ + message: "A privileged workflow must not select a job or service container image from untrusted reusable-workflow output.", + path: caller.path, + ruleId: "workflow-privileged-untrusted-container-image", + severity: "error", + source: caller.source, + })); + } const pending = callerAnalysis.localCalls.map((call) => ({ depth: 1, taintedBindings: reusableCallTaintedBindings(call, returnedBindings), @@ -425,6 +438,19 @@ function auditPrivilegedReusableWorkflowCalls(workflowSources) { source: callee.source, })); } + if (hasUntrustedWorkflowContainerImage( + calleeAnalysis.syntax.uncommented, + calleeAnalysis.syntax.scalarAnchors, + taintedBindings, + )) { + findings.push(workflowFinding({ + message: "A reusable workflow called from a privileged trigger must not select a job or service container image from untrusted input.", + path: callee.path, + ruleId: "workflow-privileged-untrusted-container-image", + severity: "error", + source: callee.source, + })); + } pending.push(...calleeAnalysis.localCalls.map((nestedCall) => ({ depth: depth + 1, taintedBindings: reusableCallTaintedBindings(nestedCall, taintedBindings), @@ -1106,6 +1132,7 @@ function workflowJobTaintAnalysis(group, scalarAnchors, inheritedTaintedBindings matrixTaintedBindings, ); return { + configurationTaintedBindings: matrixTaintedBindings, stepContexts: stepAnalysis.stepContexts, taintedBindings: mergeTaintedBindings( matrixTaintedBindings, @@ -1219,7 +1246,9 @@ function localActionCallsFromStepGroup(stepGroup, scalarAnchors, stepTaintedBind const providedBindings = new Set(inputBindings.map((binding) => ( `${binding.namespace}.${binding.name}` ))); - const taintedBindings = new Set(); + const taintedBindings = new Set([...stepTaintedBindings].filter((binding) => ( + binding.startsWith("env.") || binding.startsWith("git.") + ))); for (const binding of inputBindings) { if (isUntrustedReusableValue(binding.value, stepTaintedBindings)) { taintedBindings.add(`${binding.namespace}.${binding.name}`); @@ -1448,7 +1477,7 @@ function auditWorkflowText(workflowPath, text, source = "tracked-file") { if (hasPrivilegedTrigger && hasUntrustedScriptInterpolation(uncommented, scalarAnchors)) { findings.push(workflowFinding({ - message: "A privileged workflow must not interpolate untrusted values directly into scripts; pass them through the step environment.", + message: "A privileged workflow must not directly interpolate untrusted values into scripts or evaluate tainted environment values as code.", path: workflowPath, ruleId: "workflow-privileged-untrusted-script-interpolation", severity: "error", @@ -1456,6 +1485,17 @@ function auditWorkflowText(workflowPath, text, source = "tracked-file") { })); } + if (hasPrivilegedTrigger + && hasUntrustedWorkflowContainerImage(uncommented, scalarAnchors)) { + findings.push(workflowFinding({ + message: "A privileged workflow must not select a job or service container image from untrusted event data.", + path: workflowPath, + ruleId: "workflow-privileged-untrusted-container-image", + severity: "error", + source, + })); + } + for (const actionRef of actionReferences(uncommented, scalarAnchors)) { if (isMutableRemoteActionReference(actionRef)) { findings.push(workflowFinding({ @@ -2575,12 +2615,59 @@ function hasUntrustedScriptInterpolation(text, scalarAnchors, taintedBindings = )); } +function hasUntrustedWorkflowContainerImage( + text, + scalarAnchors, + taintedBindings = new Set(), +) { + const jobGroups = workflowJobPropertyGroups(text, scalarAnchors); + const jobTaintAnalyses = workflowJobTaintAnalyses( + jobGroups, + workflowRootMappingBindings(text, "env", "env", scalarAnchors), + scalarAnchors, + taintedBindings, + ); + return jobGroups.some((jobGroup) => workflowJobContainerImageReferences( + jobGroup, + scalarAnchors, + ).some((image) => isUntrustedReusableValue( + image, + jobTaintAnalyses.get(jobGroup)?.configurationTaintedBindings ?? taintedBindings, + ))); +} + function stepContextsHaveUntrustedScriptInterpolation(stepContexts, scalarAnchors) { return stepContexts.some(({ stepGroup, taintedBindings }) => stepGroup.properties .filter(({ entry }) => entry.key.toLowerCase() === "run") .map(({ entry }) => resolveYamlScalarValue(entry.value, scalarAnchors)) - .some((runSource) => /\$\{\{/u.test(runSource) - && isUntrustedReusableValue(runSource, taintedBindings))); + .some((runSource) => (/\$\{\{/u.test(runSource) + && isUntrustedReusableValue(runSource, taintedBindings)) + || shellRunEvaluatesTaintedVariable(runSource, taintedBindings))); +} + +function shellRunEvaluatesTaintedVariable(runSource, taintedBindings) { + const taintedVariables = new Set([...taintedBindings].flatMap((binding) => ( + binding.startsWith("env.") && binding !== "env.*" + ? [binding.slice("env.".length).toLowerCase()] + : [] + ))); + const anyEnvironmentVariableTainted = taintedBindings.has("env.*"); + const evaluatorCommand = /^\s*(?:(?:builtin|command)\s+)?(?:eval\b|(?:bash|dash|fish|ksh|sh|zsh)\b[^;&|]*\s-c(?:\s|$)|(?:iex|invoke-expression)\b|(?:powershell|pwsh)(?:\.exe)?\b[^;&|]*\s-(?:c|command)(?:\s|$))/iu; + for (const segment of shellCommandSegments(runSource)) { + if (/^\s*#/u.test(segment)) continue; + const assignment = /^\s*(?:(?:export|local|readonly)\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=|^\s*\$([A-Za-z_][A-Za-z0-9_]*)\s*=/iu.exec(segment); + const segmentReferencesTaint = isUntrustedReusableValue(segment, taintedBindings) + || shellSourceReferencesTaintedVariable( + segment, + taintedVariables, + anyEnvironmentVariableTainted, + ); + if (assignment && segmentReferencesTaint) { + taintedVariables.add((assignment[1] ?? assignment[2]).toLowerCase()); + } + if (evaluatorCommand.test(segment) && segmentReferencesTaint) return true; + } + return false; } function stepContextsHaveUntrustedArtifactExecution(stepContexts, scalarAnchors) { @@ -2651,19 +2738,69 @@ function stepExecutesArtifactPath(stepGroup, scalarAnchors, artifactPath) { function shellRunExecutesArtifactPath(runSource, artifactPath, workingDirectory) { const executableCommand = /(?:^|[;&|]\s*)(?:sudo\s+|command\s+)?(?:(?:bash|deno|node|perl|php|python\d*|ruby|sh|zsh)\s+(?:-[^\s]+\s+)*|(?:source|\.)\s+|\.\.?\/)([^\s;&|]+)/iu; - return runSource - .replace(/\r\n?|\u0085|\u2028|\u2029/gu, "\n") - .replace(/\\\n[ \t]*/gu, " ") - .split("\n") - .filter((line) => !/^\s*#/u.test(line)) - .some((line) => { - const execution = executableCommand.exec(line); - return execution && artifactSourceMatchesPath( - execution[1], - artifactPath, - workingDirectory, + let effectiveWorkingDirectory = workingDirectory; + for (const segment of shellCommandSegments(runSource)) { + if (/^\s*#/u.test(segment)) continue; + const directoryChange = /^\s*(?:(?:builtin|command)\s+)?(?:cd|pushd)\s+(?:--\s+)?("[^"]*"|'[^']*'|[^\s;&|]+)/iu.exec(segment); + if (directoryChange) { + effectiveWorkingDirectory = resolveShellWorkingDirectory( + effectiveWorkingDirectory, + directoryChange[1], ); - }); + continue; + } + const execution = executableCommand.exec(segment); + if (execution && artifactSourceMatchesPath( + execution[1], + artifactPath, + effectiveWorkingDirectory, + )) return true; + } + return false; +} + +function shellCommandSegments(runSource) { + const source = runSource + .replace(/\r\n?|\u0085|\u2028|\u2029/gu, "\n") + .replace(/\\\n[ \t]*/gu, " "); + const segments = []; + let current = ""; + let quote; + for (let index = 0; index < source.length; index += 1) { + const character = source[index]; + if (character === "\\" && quote !== "'") { + current += character; + if (index + 1 < source.length) { + current += source[index + 1]; + index += 1; + } + continue; + } + if (character === "'" || character === '"') { + if (quote === character) quote = undefined; + else if (!quote) quote = character; + current += character; + continue; + } + const doubleSeparator = (character === "&" && source[index + 1] === "&") + || (character === "|" && source[index + 1] === "|"); + if (!quote && (character === "\n" || character === ";" || doubleSeparator)) { + if (current.trim().length > 0) segments.push(current.trim()); + current = ""; + if (doubleSeparator) index += 1; + continue; + } + current += character; + } + if (current.trim().length > 0) segments.push(current.trim()); + return segments; +} + +function resolveShellWorkingDirectory(workingDirectory, target) { + const normalizedTarget = target.replace(/^(["'])(.*)\1$/u, "$2"); + if (/[$%`]|^~|^-$/u.test(normalizedTarget)) return "${dynamic-working-directory}"; + if (path.posix.isAbsolute(normalizedTarget)) return path.posix.normalize(normalizedTarget); + return path.posix.normalize(path.posix.join(workingDirectory, normalizedTarget)); } function artifactSourceMatchesPath(source, artifactPath, workingDirectory = ".") { @@ -2794,7 +2931,7 @@ function isUntrustedCheckoutInput(key, value, taintedBindings = new Set()) { } function isUntrustedPullRequestRef(value) { - return /(?:github\.head_ref|github\.event\.(?:comment\.body|discussion\.(?:body|title)|issue\.(?:body|number|title))|pull_request\.(?:head|merge_commit_sha)|refs\/pull\/|workflow_run\.(?:head_sha|id|pull_requests\s*\[\s*\d+\s*\]\s*\.\s*head))/iu + return /(?:github\.head_ref|github\.event\.(?:comment\.body|discussion\.(?:body|title)|issue\.(?:body|number|title))|pull_request\.(?:body|head|merge_commit_sha|title)|refs\/pull\/|workflow_run\.(?:head_sha|id|pull_requests\s*\[\s*\d+\s*\]\s*\.\s*head))/iu .test(normalizeExpressionPropertyAccess(value)); } @@ -2817,47 +2954,51 @@ function normalizeExpressionPropertyAccess(value) { } function workflowContainerImageReferences(text, scalarAnchors) { + return workflowJobPropertyGroups(text, scalarAnchors).flatMap((group) => ( + workflowJobContainerImageReferences(group, scalarAnchors) + )); +} + +function workflowJobContainerImageReferences(group, scalarAnchors) { const images = []; - for (const group of workflowJobPropertyGroups(text, scalarAnchors)) { - for (const property of group.properties) { - const propertyName = property.entry.key.toLowerCase(); - if (propertyName === "container") { - const containerGroup = { ...group, properties: [property] }; - const imageEntries = workflowPropertyMappingEntries( - containerGroup, - property, - scalarAnchors, - ).filter(({ entry }) => entry.key.toLowerCase() === "image"); - if (imageEntries.length > 0) { - images.push(...imageEntries.map(({ entry }) => ( - resolveYamlScalarValue(entry.value, scalarAnchors) - ))); - } else { - const inlineValue = resolveYamlScalarValue( - property.entry.inlineValue ?? property.entry.value, - scalarAnchors, - ); - if (inlineValue.length > 0 && !inlineValue.startsWith("{")) { - images.push(inlineValue); - } - } - } - if (propertyName !== "services") continue; - const servicesGroup = { ...group, properties: [property] }; - for (const serviceProperty of workflowPropertyMappingEntries( - servicesGroup, + for (const property of group.properties) { + const propertyName = property.entry.key.toLowerCase(); + if (propertyName === "container") { + const containerGroup = { ...group, properties: [property] }; + const imageEntries = workflowPropertyMappingEntries( + containerGroup, property, scalarAnchors, - )) { - const serviceGroup = { ...group, properties: [serviceProperty] }; - images.push(...workflowPropertyMappingEntries( - serviceGroup, - serviceProperty, + ).filter(({ entry }) => entry.key.toLowerCase() === "image"); + if (imageEntries.length > 0) { + images.push(...imageEntries.map(({ entry }) => ( + resolveYamlScalarValue(entry.value, scalarAnchors) + ))); + } else { + const inlineValue = resolveYamlScalarValue( + property.entry.inlineValue ?? property.entry.value, scalarAnchors, - ).filter(({ entry }) => entry.key.toLowerCase() === "image") - .map(({ entry }) => resolveYamlScalarValue(entry.value, scalarAnchors))); + ); + if (inlineValue.length > 0 && !inlineValue.startsWith("{")) { + images.push(inlineValue); + } } } + if (propertyName !== "services") continue; + const servicesGroup = { ...group, properties: [property] }; + for (const serviceProperty of workflowPropertyMappingEntries( + servicesGroup, + property, + scalarAnchors, + )) { + const serviceGroup = { ...group, properties: [serviceProperty] }; + images.push(...workflowPropertyMappingEntries( + serviceGroup, + serviceProperty, + scalarAnchors, + ).filter(({ entry }) => entry.key.toLowerCase() === "image") + .map(({ entry }) => resolveYamlScalarValue(entry.value, scalarAnchors))); + } } return images; } diff --git a/public-source-release-audit/tests/public-source-release-audit.test.mjs b/public-source-release-audit/tests/public-source-release-audit.test.mjs index 6702d2d..20855a4 100644 --- a/public-source-release-audit/tests/public-source-release-audit.test.mjs +++ b/public-source-release-audit/tests/public-source-release-audit.test.mjs @@ -1995,6 +1995,39 @@ test("artifact execution resolves step working directories", () => { ); }); +test("artifact execution tracks inline shell directory changes", () => { + const repoRoot = makeRepository(); + const runIdExpression = ["$", "{{ github.event.workflow_run.id }}"].join(""); + write(repoRoot, ".github/workflows/workflow-run-artifact-cd.yml", [ + "name: workflow run artifact cd", + "on:", + " workflow_run:", + " workflows: [verify]", + " types: [completed]", + "permissions: read-all", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - uses: actions/download-artifact@" + "a".repeat(40), + " with:", + " run-id: " + runIdExpression, + " path: payload", + " - run: cd payload && bash run.sh", + "", + ].join("\n")); + commitAll(repoRoot, "add inline artifact directory execution"); + + const audit = runAudit(repoRoot); + + assert.equal(audit.status, 1); + assertFinding( + audit.result, + "workflow-privileged-untrusted-artifact-execution", + ".github/workflows/workflow-run-artifact-cd.yml", + ); +}); + test("issue_comment pull-request checkouts are privileged and untrusted", () => { const repoRoot = makeRepository(); const issueExpression = ["$", "{{ github.event.issue.number }}"].join(""); @@ -2243,6 +2276,64 @@ test("privileged scripts require environment indirection for untrusted values", assert.equal(safeAudit.status, 0); }); +test("pull-request title and body values are untrusted script inputs", () => { + const repoRoot = makeRepository(); + for (const field of ["title", "body"]) { + const valueExpression = ["$", `{{ github.event.pull_request.${field} }}`].join(""); + write(repoRoot, `.github/workflows/pull-request-${field}-script.yml`, [ + `name: pull request ${field} script`, + "on: pull_request_target", + "permissions: read-all", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + ` - run: echo "value=${valueExpression}"`, + "", + ].join("\n")); + } + commitAll(repoRoot, "add pull request text scripts"); + + const audit = runAudit(repoRoot); + + assert.equal(audit.status, 1); + for (const field of ["title", "body"]) { + assertFinding( + audit.result, + "workflow-privileged-untrusted-script-interpolation", + `.github/workflows/pull-request-${field}-script.yml`, + ); + } +}); + +test("tainted environment values cannot reach shell evaluators", () => { + const repoRoot = makeRepository(); + const bodyExpression = ["$", "{{ github.event.comment.body }}"].join(""); + write(repoRoot, ".github/workflows/comment-eval.yml", [ + "name: comment eval", + "on: issue_comment", + "permissions: read-all", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - env:", + " COMMAND: " + bodyExpression, + " run: eval \"$COMMAND\"", + "", + ].join("\n")); + commitAll(repoRoot, "add tainted shell evaluator"); + + const audit = runAudit(repoRoot); + + assert.equal(audit.status, 1); + assertFinding( + audit.result, + "workflow-privileged-untrusted-script-interpolation", + ".github/workflows/comment-eval.yml", + ); +}); + test("public event author identities are untrusted checkout coordinates", () => { const repoRoot = makeRepository(); const issueAuthorExpression = [ @@ -2423,6 +2514,47 @@ test("workflow container and service images require immutable digests", () => { )), false); }); +test("privileged workflows reject attacker-selected container images", () => { + const repoRoot = makeRepository(); + const matrixExpression = ["$", "{{ fromJSON(github.event.comment.body) }}"].join(""); + const imageExpression = ["$", "{{ matrix.image }}"].join(""); + for (const [name, imageConfiguration] of [ + ["job", [" container: " + imageExpression]], + ["service", [ + " services:", + " payload:", + " image: " + imageExpression, + ]], + ]) { + write(repoRoot, `.github/workflows/dynamic-${name}-container.yml`, [ + `name: dynamic ${name} container`, + "on: issue_comment", + "permissions: read-all", + "jobs:", + " inspect:", + " strategy:", + " matrix: " + matrixExpression, + " runs-on: ubuntu-latest", + ...imageConfiguration, + " steps:", + " - run: echo inspect", + "", + ].join("\n")); + } + commitAll(repoRoot, "add attacker-selected containers"); + + const audit = runAudit(repoRoot); + + assert.equal(audit.status, 1); + for (const name of ["job", "service"]) { + assertFinding( + audit.result, + "workflow-privileged-untrusted-container-image", + `.github/workflows/dynamic-${name}-container.yml`, + ); + } +}); + test("mutable Docker actions warn while digest-pinned actions pass", () => { const repoRoot = makeRepository(); write(repoRoot, ".github/workflows/mutable-docker.yml", [ @@ -2730,6 +2862,46 @@ test("GITHUB_ENV taint propagates across local composite action steps", () => { ); }); +test("local actions inherit caller environment taint", () => { + const repoRoot = makeRepository(); + const headExpression = ["$", "{{ github.head_ref }}"].join(""); + const envExpression = ["$", "{{ env.PR_REF }}"].join(""); + write(repoRoot, ".github/workflows/inherited-action-environment.yml", [ + "name: inherited action environment", + "on: pull_request_target", + "permissions: read-all", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - env:", + " SOURCE_REF: " + headExpression, + " run: echo \"PR_REF=$SOURCE_REF\" >> \"$GITHUB_ENV\"", + " - uses: ./.github/actions/inherited-environment-checkout", + "", + ].join("\n")); + write(repoRoot, ".github/actions/inherited-environment-checkout/action.yml", [ + "name: inherited environment checkout", + "runs:", + " using: composite", + " steps:", + " - uses: actions/checkout@" + "a".repeat(40), + " with:", + " ref: " + envExpression, + "", + ].join("\n")); + commitAll(repoRoot, "add inherited composite environment checkout"); + + const audit = runAudit(repoRoot); + + assert.equal(audit.status, 1); + assertFinding( + audit.result, + "workflow-privileged-untrusted-checkout", + ".github/actions/inherited-environment-checkout/action.yml", + ); +}); + test("composite input defaults are tainted unless callers override them", () => { const unsafeRepo = makeRepository(); const safeRepo = makeRepository(); From 273bbe4cf79947bc307dc92ef9b88d6f91fd34c0 Mon Sep 17 00:00:00 2001 From: Vladimir Date: Sat, 22 Aug 2026 07:20:57 +0800 Subject: [PATCH 23/37] Close remaining privileged workflow gaps --- .../scripts/public-source-release-audit.mjs | 444 ++++++++++++++---- .../public-source-release-audit.test.mjs | 251 ++++++++++ 2 files changed, 613 insertions(+), 82 deletions(-) diff --git a/public-source-release-audit/scripts/public-source-release-audit.mjs b/public-source-release-audit/scripts/public-source-release-audit.mjs index ca881c8..0eceeda 100644 --- a/public-source-release-audit/scripts/public-source-release-audit.mjs +++ b/public-source-release-audit/scripts/public-source-release-audit.mjs @@ -16,6 +16,7 @@ const DOCKERFILE_PATH_PATTERN = /(?:^|\/)(?:Dockerfile|[^/]+\.dockerfile)$/iu; const PRIVILEGED_WORKFLOW_TRIGGERS = new Set([ "discussion", "discussion_comment", + "fork", "issue_comment", "issues", "pull_request_target", @@ -183,9 +184,25 @@ function auditWorkflowSources(repoRoot, { includeHistory = false } = {}) { const findings = []; const actionSources = []; const dockerfileSources = []; + const symlinkSources = []; const workflowSources = []; for (const entry of entries) { + if (entry.mode === "120000") { + symlinkSources.push(entry); + if (WORKFLOW_PATH_PATTERN.test(entry.path) + || ACTION_MANIFEST_PATH_PATTERN.test(entry.path) + || DOCKERFILE_PATH_PATTERN.test(entry.path)) { + findings.push(workflowFinding({ + message: "Workflow, local-action, and Dockerfile entrypoints must be regular tracked files.", + path: entry.path, + ruleId: "workflow-entrypoint-not-regular", + severity: "error", + source: entry.source, + })); + } + continue; + } if (entry.mode !== "100644" && entry.mode !== "100755") { findings.push(workflowFinding({ message: "Workflow, local-action, and Dockerfile entrypoints must be regular tracked files.", @@ -229,6 +246,7 @@ function auditWorkflowSources(repoRoot, { includeHistory = false } = {}) { workflowSources, actionSources, dockerfileSources, + symlinkSources, )); return findings; @@ -384,6 +402,19 @@ function auditPrivilegedReusableWorkflowCalls(workflowSources) { source: caller.source, })); } + if (hasUntrustedWorkflowRunnerSelection( + callerAnalysis.syntax.uncommented, + callerAnalysis.syntax.scalarAnchors, + returnedBindings, + )) { + findings.push(workflowFinding({ + message: "A privileged workflow must not select runner labels or groups from untrusted reusable-workflow output.", + path: caller.path, + ruleId: "workflow-privileged-untrusted-runner", + severity: "error", + source: caller.source, + })); + } const pending = callerAnalysis.localCalls.map((call) => ({ depth: 1, taintedBindings: reusableCallTaintedBindings(call, returnedBindings), @@ -392,17 +423,22 @@ function auditPrivilegedReusableWorkflowCalls(workflowSources) { const visited = new Set(); while (pending.length > 0) { const { depth, taintedBindings, workflowPath } = pending.pop(); - const stateKey = `${workflowPath}\0${[...taintedBindings].sort().join("\0")}`; - if (depth > 10 || visited.has(stateKey)) continue; - visited.add(stateKey); + if (depth > 10) continue; const callee = sourceBySnapshotPath.get(`${caller.snapshot}\0${workflowPath}`); if (!callee) continue; const calleeAnalysis = analysisFor(callee); if (!calleeAnalysis.isReusable) continue; + const effectiveTaintedBindings = mergeTaintedBindings( + taintedBindings, + returnedBindingsFor(callee, taintedBindings), + ); + const stateKey = `${workflowPath}\0${[...effectiveTaintedBindings].sort().join("\0")}`; + if (visited.has(stateKey)) continue; + visited.add(stateKey); if (hasUntrustedPullRequestCheckout( calleeAnalysis.syntax.uncommented, calleeAnalysis.syntax.scalarAnchors, - taintedBindings, + effectiveTaintedBindings, )) { findings.push(workflowFinding({ message: "A reusable workflow called from a privileged trigger must not execute an untrusted checkout.", @@ -415,7 +451,7 @@ function auditPrivilegedReusableWorkflowCalls(workflowSources) { if (hasUntrustedWorkflowArtifactExecution( calleeAnalysis.syntax.uncommented, calleeAnalysis.syntax.scalarAnchors, - taintedBindings, + effectiveTaintedBindings, )) { findings.push(workflowFinding({ message: "A reusable workflow called from a privileged trigger must not execute untrusted workflow artifacts.", @@ -428,10 +464,10 @@ function auditPrivilegedReusableWorkflowCalls(workflowSources) { if (hasUntrustedScriptInterpolation( calleeAnalysis.syntax.uncommented, calleeAnalysis.syntax.scalarAnchors, - taintedBindings, + effectiveTaintedBindings, )) { findings.push(workflowFinding({ - message: "A reusable workflow called from a privileged trigger must not interpolate untrusted values directly into scripts.", + message: "A reusable workflow called from a privileged trigger must not use untrusted script text, shell templates, or evaluated values.", path: callee.path, ruleId: "workflow-privileged-untrusted-script-interpolation", severity: "error", @@ -441,7 +477,7 @@ function auditPrivilegedReusableWorkflowCalls(workflowSources) { if (hasUntrustedWorkflowContainerImage( calleeAnalysis.syntax.uncommented, calleeAnalysis.syntax.scalarAnchors, - taintedBindings, + effectiveTaintedBindings, )) { findings.push(workflowFinding({ message: "A reusable workflow called from a privileged trigger must not select a job or service container image from untrusted input.", @@ -451,9 +487,25 @@ function auditPrivilegedReusableWorkflowCalls(workflowSources) { source: callee.source, })); } + if (hasUntrustedWorkflowRunnerSelection( + calleeAnalysis.syntax.uncommented, + calleeAnalysis.syntax.scalarAnchors, + effectiveTaintedBindings, + )) { + findings.push(workflowFinding({ + message: "A reusable workflow called from a privileged trigger must not select runner labels or groups from untrusted input.", + path: callee.path, + ruleId: "workflow-privileged-untrusted-runner", + severity: "error", + source: callee.source, + })); + } pending.push(...calleeAnalysis.localCalls.map((nestedCall) => ({ depth: depth + 1, - taintedBindings: reusableCallTaintedBindings(nestedCall, taintedBindings), + taintedBindings: reusableCallTaintedBindings( + nestedCall, + effectiveTaintedBindings, + ), workflowPath: nestedCall.workflowPath, }))); } @@ -505,7 +557,12 @@ function workflowExecutionStates(workflowSources) { return states; } -function auditLocalCompositeActions(workflowSources, actionSources, dockerfileSources) { +function auditLocalCompositeActions( + workflowSources, + actionSources, + dockerfileSources, + symlinkSources, +) { const actionBySnapshotPath = new Map(actionSources.map((source) => [ `${source.snapshot}\0${source.path}`, source, @@ -514,6 +571,13 @@ function auditLocalCompositeActions(workflowSources, actionSources, dockerfileSo `${source.snapshot}\0${source.path}`, source, ])); + const symlinkPathsBySnapshot = new Map(); + for (const source of symlinkSources) { + if (!symlinkPathsBySnapshot.has(source.snapshot)) { + symlinkPathsBySnapshot.set(source.snapshot, new Set()); + } + symlinkPathsBySnapshot.get(source.snapshot).add(source.path); + } const analysisBySource = new Map(); const analysisFor = (source) => { if (analysisBySource.has(source)) return analysisBySource.get(source); @@ -577,14 +641,26 @@ function auditLocalCompositeActions(workflowSources, actionSources, dockerfileSo reference, (manifestPath) => actionBySnapshotPath.has(`${workflow.snapshot}\0${manifestPath}`), ); - const pending = workflowLocalActionCalls( + const symlinkPaths = symlinkPathsBySnapshot.get(workflow.snapshot) ?? new Set(); + const pending = []; + for (const call of workflowLocalActionCalls( syntax.uncommented, syntax.scalarAnchors, workflowTaintedBindings, - ).flatMap((call) => { + )) { + if (localActionReferenceUsesSymlink(call.reference, symlinkPaths)) { + findings.push(workflowFinding({ + message: "Local action references must not traverse tracked symbolic links.", + path: workflow.path, + ruleId: "workflow-local-action-symlink", + severity: "error", + source: workflow.source, + })); + continue; + } const manifestPath = resolveManifestPath(call.reference); - return manifestPath ? [{ ...call, depth: 1, manifestPath }] : []; - }); + if (manifestPath) pending.push({ ...call, depth: 1, manifestPath }); + } const visited = new Set(); while (pending.length > 0) { const { depth, manifestPath, providedBindings, taintedBindings } = pending.pop(); @@ -626,18 +702,28 @@ function auditLocalCompositeActions(workflowSources, actionSources, dockerfileSo })); } } - pending.push(...compositeLocalActionCalls( + for (const call of compositeLocalActionCalls( analysis.stepGroups, analysis.syntax.scalarAnchors, effectiveTaintedBindings, - ).flatMap((call) => { + )) { + if (localActionReferenceUsesSymlink(call.reference, symlinkPaths)) { + findings.push(workflowFinding({ + message: "Local action references must not traverse tracked symbolic links.", + path: action.path, + ruleId: "workflow-local-action-symlink", + severity: "error", + source: action.source, + })); + continue; + } const nestedPath = resolveManifestPath(call.reference); - return nestedPath ? [{ + if (nestedPath) pending.push({ ...call, depth: depth + 1, manifestPath: nestedPath, - }] : []; - })); + }); + } const actionStepContexts = workflowStepTaintAnalysis( analysis.stepGroups, analysis.syntax.scalarAnchors, @@ -675,7 +761,7 @@ function auditLocalCompositeActions(workflowSources, actionSources, dockerfileSo analysis.syntax.scalarAnchors, )) { findings.push(workflowFinding({ - message: "A local composite action called from a privileged workflow must not interpolate untrusted values directly into scripts.", + message: "A local composite action called from a privileged workflow must not use untrusted script text, shell templates, or evaluated values.", path: action.path, ruleId: "workflow-privileged-untrusted-script-interpolation", severity: "error", @@ -691,6 +777,18 @@ function localActionManifestPath(reference, manifestExists) { return localActionManifestCandidates(reference).find(manifestExists); } +function localActionReferenceUsesSymlink(reference, symlinkPaths) { + return localActionManifestCandidates(reference).some((candidate) => { + const components = candidate.split("/"); + let current = ""; + for (const component of components) { + current = current.length > 0 ? `${current}/${component}` : component; + if (symlinkPaths.has(current)) return true; + } + return false; + }); +} + function localDockerfilePath(actionManifestPath, image) { if (!image || /\$\{\{/u.test(image) || path.posix.isAbsolute(image)) return undefined; const dockerfilePath = path.posix.normalize(path.posix.join( @@ -1264,7 +1362,7 @@ function localActionCallsFromStepGroup(stepGroup, scalarAnchors, stepTaintedBind function isUntrustedReusableValue(value, taintedBindings) { return isUntrustedPullRequestRef(value) || isUntrustedPublicEventIdentity(value) - || /(?:pull_request\.head\.repo|workflow_run\.(?:head_repository|pull_requests\s*\[\s*\d+\s*\]\s*\.\s*head\s*\.\s*repo))(?:\.|\b)/iu + || /(?:github\.event\.forkee|pull_request\.head\.repo|workflow_run\.(?:head_repository|pull_requests\s*\[\s*\d+\s*\]\s*\.\s*head\s*\.\s*repo))(?:\.|\b)/iu .test(normalizeExpressionPropertyAccess(value)) || valueReferencesTaintedBinding(value, taintedBindings); } @@ -1325,7 +1423,8 @@ function trackedWorkflowEntries(repoRoot, { includeHistory = false } = {}) { const unique = new Map(); for (const record of records) { - if (WORKFLOW_PATH_PATTERN.test(record.path) + if (record.mode === "120000" + || WORKFLOW_PATH_PATTERN.test(record.path) || ACTION_MANIFEST_PATH_PATTERN.test(record.path) || DOCKERFILE_PATH_PATTERN.test(record.path)) { const key = `${record.snapshot}\0${record.path}\0${record.objectId}\0${record.mode}`; @@ -1441,6 +1540,17 @@ function auditWorkflowText(workflowPath, text, source = "tracked-file") { } } + if (hasPrivilegedTrigger + && hasUntrustedWorkflowRunnerSelection(uncommented, scalarAnchors)) { + findings.push(workflowFinding({ + message: "A privileged workflow must not select runner labels or groups from untrusted event data.", + path: workflowPath, + ruleId: "workflow-privileged-untrusted-runner", + severity: "error", + source, + })); + } + if (hasPullRequestTarget) { findings.push(workflowFinding({ message: "pull_request_target requires an explicit trusted-base and untrusted-head threat-model review.", @@ -1477,7 +1587,7 @@ function auditWorkflowText(workflowPath, text, source = "tracked-file") { if (hasPrivilegedTrigger && hasUntrustedScriptInterpolation(uncommented, scalarAnchors)) { findings.push(workflowFinding({ - message: "A privileged workflow must not directly interpolate untrusted values into scripts or evaluate tainted environment values as code.", + message: "A privileged workflow must not use untrusted script text, shell templates, or evaluated values.", path: workflowPath, ruleId: "workflow-privileged-untrusted-script-interpolation", severity: "error", @@ -2251,56 +2361,60 @@ function splitGithubExpressionArguments(source) { } function workflowJobRunnerLabelSets(text, scalarAnchors) { + return workflowJobPropertyGroups(text, scalarAnchors).flatMap((group) => ( + workflowJobRunnerLabelSetsForGroup(group, scalarAnchors) + )); +} + +function workflowJobRunnerLabelSetsForGroup(group, scalarAnchors) { const runnerLabelSets = []; - for (const group of workflowJobPropertyGroups(text, scalarAnchors)) { - for (const property of group.properties) { - if (property.entry.key.toLowerCase() !== "runs-on") continue; - const inlineValue = property.entry.inlineValue ?? property.entry.value; - const mappingEntries = workflowPropertyMappingEntries( - group, - property, - scalarAnchors, - ); - if (mappingEntries.length > 0) { - const mappingRunnerValues = mappingEntries.flatMap((mappingEntry) => { - const key = resolveYamlScalarValue( - mappingEntry.entry.key, + for (const property of group.properties) { + if (property.entry.key.toLowerCase() !== "runs-on") continue; + const inlineValue = property.entry.inlineValue ?? property.entry.value; + const mappingEntries = workflowPropertyMappingEntries( + group, + property, + scalarAnchors, + ); + if (mappingEntries.length > 0) { + const mappingRunnerValues = mappingEntries.flatMap((mappingEntry) => { + const key = resolveYamlScalarValue( + mappingEntry.entry.key, + scalarAnchors, + ).toLowerCase(); + if (key === "group") { + return [`group: ${resolveYamlScalarValue( + mappingEntry.entry.value, scalarAnchors, - ).toLowerCase(); - if (key === "group") { - return [`group: ${resolveYamlScalarValue( - mappingEntry.entry.value, - scalarAnchors, - )}`]; - } - if (key !== "labels") return []; - const labelInlineValue = mappingEntry.entry.inlineValue - ?? mappingEntry.entry.value; - const blockLabels = group.entries - && mappingEntry.index !== undefined - && (labelInlineValue.length === 0 || yamlValueHasOnlyProperties(labelInlineValue)) - ? yamlBlockSequenceValues(group.text, mappingEntry.entry) - : undefined; - return (blockLabels ?? [mappingEntry.entry.value]).flatMap((value) => ( - workflowRunnerLabels(value, scalarAnchors) - )); - }); - if (mappingRunnerValues.length > 0) { - runnerLabelSets.push(mappingRunnerValues); - continue; + )}`]; } + if (key !== "labels") return []; + const labelInlineValue = mappingEntry.entry.inlineValue + ?? mappingEntry.entry.value; + const blockLabels = group.entries + && mappingEntry.index !== undefined + && (labelInlineValue.length === 0 || yamlValueHasOnlyProperties(labelInlineValue)) + ? yamlBlockSequenceValues(group.text, mappingEntry.entry) + : undefined; + return (blockLabels ?? [mappingEntry.entry.value]).flatMap((value) => ( + workflowRunnerLabels(value, scalarAnchors) + )); + }); + if (mappingRunnerValues.length > 0) { + runnerLabelSets.push(mappingRunnerValues); + continue; } - const blockValues = group.entries - && property.index !== undefined - && (inlineValue.length === 0 || yamlValueHasOnlyProperties(inlineValue)) - ? yamlBlockSequenceValues(group.text, property.entry) - : undefined; - const runnerValues = blockValues ?? [property.entry.value]; - runnerLabelSets.push(runnerValues.flatMap((value) => workflowRunnerLabels( - value, - scalarAnchors, - ))); } + const blockValues = group.entries + && property.index !== undefined + && (inlineValue.length === 0 || yamlValueHasOnlyProperties(inlineValue)) + ? yamlBlockSequenceValues(group.text, property.entry) + : undefined; + const runnerValues = blockValues ?? [property.entry.value]; + runnerLabelSets.push(runnerValues.flatMap((value) => workflowRunnerLabels( + value, + scalarAnchors, + ))); } return runnerLabelSets; } @@ -2636,13 +2750,41 @@ function hasUntrustedWorkflowContainerImage( ))); } +function hasUntrustedWorkflowRunnerSelection( + text, + scalarAnchors, + taintedBindings = new Set(), +) { + const jobGroups = workflowJobPropertyGroups(text, scalarAnchors); + const jobTaintAnalyses = workflowJobTaintAnalyses( + jobGroups, + workflowRootMappingBindings(text, "env", "env", scalarAnchors), + scalarAnchors, + taintedBindings, + ); + return jobGroups.some((jobGroup) => workflowJobRunnerLabelSetsForGroup( + jobGroup, + scalarAnchors, + ).flat().some((label) => isUntrustedReusableValue( + label, + jobTaintAnalyses.get(jobGroup)?.configurationTaintedBindings ?? taintedBindings, + ))); +} + function stepContextsHaveUntrustedScriptInterpolation(stepContexts, scalarAnchors) { - return stepContexts.some(({ stepGroup, taintedBindings }) => stepGroup.properties - .filter(({ entry }) => entry.key.toLowerCase() === "run") - .map(({ entry }) => resolveYamlScalarValue(entry.value, scalarAnchors)) - .some((runSource) => (/\$\{\{/u.test(runSource) - && isUntrustedReusableValue(runSource, taintedBindings)) - || shellRunEvaluatesTaintedVariable(runSource, taintedBindings))); + return stepContexts.some(({ stepGroup, taintedBindings }) => { + const hasUntrustedShellTemplate = stepGroup.properties + .filter(({ entry }) => entry.key.toLowerCase() === "shell") + .map(({ entry }) => resolveYamlScalarValue(entry.value, scalarAnchors)) + .some((shell) => isUntrustedReusableValue(shell, taintedBindings)); + if (hasUntrustedShellTemplate) return true; + return stepGroup.properties + .filter(({ entry }) => entry.key.toLowerCase() === "run") + .map(({ entry }) => resolveYamlScalarValue(entry.value, scalarAnchors)) + .some((runSource) => (/\$\{\{/u.test(runSource) + && isUntrustedReusableValue(runSource, taintedBindings)) + || shellRunEvaluatesTaintedVariable(runSource, taintedBindings)); + }); } function shellRunEvaluatesTaintedVariable(runSource, taintedBindings) { @@ -2652,7 +2794,8 @@ function shellRunEvaluatesTaintedVariable(runSource, taintedBindings) { : [] ))); const anyEnvironmentVariableTainted = taintedBindings.has("env.*"); - const evaluatorCommand = /^\s*(?:(?:builtin|command)\s+)?(?:eval\b|(?:bash|dash|fish|ksh|sh|zsh)\b[^;&|]*\s-c(?:\s|$)|(?:iex|invoke-expression)\b|(?:powershell|pwsh)(?:\.exe)?\b[^;&|]*\s-(?:c|command)(?:\s|$))/iu; + const evaluatorCommand = /^\s*(?:(?:builtin|command|exec)\s+)?(?:eval\b|(?:(?:\/[^/\s]+)*\/)?(?:bash|dash|fish|ksh|sh|zsh)\b[^;&|]*\s-c(?:\s|$)|(?:iex|invoke-expression)\b|(?:(?:\/[^/\s]+)*\/)?(?:powershell|pwsh)(?:\.exe)?\b[^;&|]*\s-(?:c|command)(?:\s|$))/iu; + const stdinInterpreterCommand = /^\s*(?:(?:command|exec)\s+)?(?:(?:\/usr\/bin\/)?env\s+(?:-[^\s]+\s+)*)?(?:(?:\/[^/\s]+)*\/)?(?:bash|dash|fish|ksh|powershell|pwsh|sh|zsh)(?:\.exe)?(?:\s|$)/iu; for (const segment of shellCommandSegments(runSource)) { if (/^\s*#/u.test(segment)) continue; const assignment = /^\s*(?:(?:export|local|readonly)\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=|^\s*\$([A-Za-z_][A-Za-z0-9_]*)\s*=/iu.exec(segment); @@ -2665,11 +2808,55 @@ function shellRunEvaluatesTaintedVariable(runSource, taintedBindings) { if (assignment && segmentReferencesTaint) { taintedVariables.add((assignment[1] ?? assignment[2]).toLowerCase()); } - if (evaluatorCommand.test(segment) && segmentReferencesTaint) return true; + let pipelineInputTainted = false; + for (const stage of shellPipelineStages(segment)) { + const stageReferencesTaint = isUntrustedReusableValue(stage, taintedBindings) + || shellSourceReferencesTaintedVariable( + stage, + taintedVariables, + anyEnvironmentVariableTainted, + ); + if (evaluatorCommand.test(stage) && (stageReferencesTaint || pipelineInputTainted)) { + return true; + } + if (stdinInterpreterCommand.test(stage) && pipelineInputTainted) return true; + pipelineInputTainted ||= stageReferencesTaint; + } } return false; } +function shellPipelineStages(segment) { + const stages = []; + let current = ""; + let quote; + for (let index = 0; index < segment.length; index += 1) { + const character = segment[index]; + if (character === "\\" && quote !== "'") { + current += character; + if (index + 1 < segment.length) { + current += segment[index + 1]; + index += 1; + } + continue; + } + if (character === "'" || character === '"') { + if (quote === character) quote = undefined; + else if (!quote) quote = character; + current += character; + continue; + } + if (!quote && character === "|" && segment[index + 1] !== "|") { + if (current.trim().length > 0) stages.push(current.trim()); + current = ""; + continue; + } + current += character; + } + if (current.trim().length > 0) stages.push(current.trim()); + return stages; +} + function stepContextsHaveUntrustedArtifactExecution(stepContexts, scalarAnchors) { const artifactPaths = []; for (const { stepGroup, taintedBindings } of stepContexts) { @@ -2737,7 +2924,6 @@ function stepExecutesArtifactPath(stepGroup, scalarAnchors, artifactPath) { } function shellRunExecutesArtifactPath(runSource, artifactPath, workingDirectory) { - const executableCommand = /(?:^|[;&|]\s*)(?:sudo\s+|command\s+)?(?:(?:bash|deno|node|perl|php|python\d*|ruby|sh|zsh)\s+(?:-[^\s]+\s+)*|(?:source|\.)\s+|\.\.?\/)([^\s;&|]+)/iu; let effectiveWorkingDirectory = workingDirectory; for (const segment of shellCommandSegments(runSource)) { if (/^\s*#/u.test(segment)) continue; @@ -2749,9 +2935,9 @@ function shellRunExecutesArtifactPath(runSource, artifactPath, workingDirectory) ); continue; } - const execution = executableCommand.exec(segment); - if (execution && artifactSourceMatchesPath( - execution[1], + const executionSource = shellArtifactExecutionSource(segment); + if (executionSource && artifactSourceMatchesPath( + executionSource, artifactPath, effectiveWorkingDirectory, )) return true; @@ -2759,6 +2945,96 @@ function shellRunExecutesArtifactPath(runSource, artifactPath, workingDirectory) return false; } +function shellArtifactExecutionSource(segment) { + const words = shellCommandWords(segment); + let cursor = 0; + while (cursor < words.length) { + if (/^[A-Za-z_][A-Za-z0-9_]*=/u.test(words[cursor])) { + cursor += 1; + continue; + } + const command = path.posix.basename(words[cursor]).toLowerCase(); + if (["builtin", "command", "exec", "nohup", "time"].includes(command)) { + cursor += 1; + while (words[cursor]?.startsWith("-")) cursor += 1; + continue; + } + if (command === "sudo") { + cursor += 1; + while (words[cursor]?.startsWith("-")) { + const option = words[cursor]; + cursor += 1; + if (["-c", "-g", "-h", "-p", "-r", "-t", "-u"].includes(option.toLowerCase())) { + cursor += 1; + } + } + continue; + } + if (command === "env") { + cursor += 1; + while (words[cursor]?.startsWith("-") + || /^[A-Za-z_][A-Za-z0-9_]*=/u.test(words[cursor] ?? "")) cursor += 1; + continue; + } + break; + } + const command = words[cursor]; + if (!command) return undefined; + cursor += 1; + const commandName = path.posix.basename(command).toLowerCase(); + if ([".", "source"].includes(commandName)) return words[cursor]; + const interpreters = new Set([ + "bash", "dash", "deno", "fish", "ksh", "node", "perl", "php", + "powershell", "powershell.exe", "pwsh", "pwsh.exe", "python", "python2", + "python3", "ruby", "sh", "zsh", + ]); + if (interpreters.has(commandName)) { + if (commandName === "deno" && words[cursor]?.toLowerCase() === "run") cursor += 1; + while (cursor < words.length) { + const argument = words[cursor]; + cursor += 1; + if (argument === "--") return words[cursor]; + if (["-c", "--command", "-e", "--eval"].includes(argument.toLowerCase())) { + return undefined; + } + if (argument.startsWith("-")) continue; + return argument; + } + return undefined; + } + return command.includes("/") ? command : undefined; +} + +function shellCommandWords(source) { + const words = []; + let current = ""; + let quote; + for (let index = 0; index < source.length; index += 1) { + const character = source[index]; + if (character === "\\" && quote !== "'") { + if (index + 1 < source.length) { + current += source[index + 1]; + index += 1; + } + continue; + } + if (character === "'" || character === '"') { + if (quote === character) quote = undefined; + else if (!quote) quote = character; + else current += character; + continue; + } + if (!quote && /\s/u.test(character)) { + if (current.length > 0) words.push(current); + current = ""; + continue; + } + current += character; + } + if (current.length > 0) words.push(current); + return words; +} + function shellCommandSegments(runSource) { const source = runSource .replace(/\r\n?|\u0085|\u2028|\u2029/gu, "\n") @@ -2805,7 +3081,11 @@ function resolveShellWorkingDirectory(workingDirectory, target) { function artifactSourceMatchesPath(source, artifactPath, workingDirectory = ".") { const normalized = source.replace(/^["']|["']$/gu, "").replace(/^\.\//u, ""); - if (path.posix.isAbsolute(normalized)) return artifactPath === "."; + if (path.posix.isAbsolute(normalized)) { + if (artifactPath === ".") return true; + return normalized.endsWith(`/${artifactPath}`) + || normalized.includes(`/${artifactPath}/`); + } if (/\$|%/u.test(workingDirectory)) return true; const effectivePath = path.posix.normalize(path.posix.join( workingDirectory.replace(/^\.\//u, ""), @@ -2924,7 +3204,7 @@ function isUntrustedCheckoutInput(key, value, taintedBindings = new Set()) { normalizeExpressionPropertyAccess(value), )) return true; if (key.toLowerCase() === "repository") { - return /(?:pull_request\.head\.repo|workflow_run\.(?:head_repository|pull_requests\s*\[\s*\d+\s*\]\s*\.\s*head\s*\.\s*repo))(?:\.|\b)/iu + return /(?:github\.event\.forkee|pull_request\.head\.repo|workflow_run\.(?:head_repository|pull_requests\s*\[\s*\d+\s*\]\s*\.\s*head\s*\.\s*repo))(?:\.|\b)/iu .test(normalizeExpressionPropertyAccess(value)); } return isUntrustedPullRequestRef(value); diff --git a/public-source-release-audit/tests/public-source-release-audit.test.mjs b/public-source-release-audit/tests/public-source-release-audit.test.mjs index 20855a4..672f2fc 100644 --- a/public-source-release-audit/tests/public-source-release-audit.test.mjs +++ b/public-source-release-audit/tests/public-source-release-audit.test.mjs @@ -1341,6 +1341,65 @@ test("reusable workflow outputs propagate taint back to callers", () => { ); }); +test("nested reusable outputs taint sinks in intermediate workflows", () => { + const repoRoot = makeRepository(); + const headExpression = ["$", "{{ github.head_ref }}"].join(""); + const jobOutputExpression = ["$", "{{ jobs.source.outputs.ref }}"].join(""); + const nestedOutputExpression = ["$", "{{ needs.nested.outputs.ref }}"].join(""); + write(repoRoot, ".github/workflows/nested-output-root.yml", [ + "name: nested output root", + "on: pull_request_target", + "permissions: read-all", + "jobs:", + " call:", + " uses: ./.github/workflows/nested-output-middle.yml", + "", + ].join("\n")); + write(repoRoot, ".github/workflows/nested-output-middle.yml", [ + "name: nested output middle", + "on: workflow_call", + "permissions: read-all", + "jobs:", + " nested:", + " uses: ./.github/workflows/nested-output-leaf.yml", + " inspect:", + " needs: nested", + " runs-on: ubuntu-latest", + " steps:", + " - uses: actions/checkout@" + "a".repeat(40), + " with:", + " ref: " + nestedOutputExpression, + "", + ].join("\n")); + write(repoRoot, ".github/workflows/nested-output-leaf.yml", [ + "name: nested output leaf", + "on:", + " workflow_call:", + " outputs:", + " ref:", + " value: " + jobOutputExpression, + "permissions: read-all", + "jobs:", + " source:", + " runs-on: ubuntu-latest", + " outputs:", + " ref: " + headExpression, + " steps:", + " - run: echo source", + "", + ].join("\n")); + commitAll(repoRoot, "add nested reusable output checkout"); + + const audit = runAudit(repoRoot); + + assert.equal(audit.status, 1); + assertFinding( + audit.result, + "workflow-privileged-untrusted-checkout", + ".github/workflows/nested-output-middle.yml", + ); +}); + test("untrusted refs propagate through workflow job and step environments", () => { const repoRoot = makeRepository(); const headExpression = ["$", "{{ github.head_ref }}"].join(""); @@ -2028,6 +2087,47 @@ test("artifact execution tracks inline shell directory changes", () => { ); }); +test("artifact execution recognizes common command wrappers and paths", () => { + const repoRoot = makeRepository(); + const runIdExpression = ["$", "{{ github.event.workflow_run.id }}"].join(""); + for (const [name, command] of [ + ["exec", "cd payload && exec bash run.sh"], + ["absolute-interpreter", "cd payload && /bin/bash run.sh"], + ["direct-path", "payload/run.sh"], + ]) { + write(repoRoot, `.github/workflows/workflow-run-artifact-${name}.yml`, [ + `name: workflow run artifact ${name}`, + "on:", + " workflow_run:", + " workflows: [verify]", + " types: [completed]", + "permissions: read-all", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - uses: actions/download-artifact@" + "a".repeat(40), + " with:", + " run-id: " + runIdExpression, + " path: payload", + " - run: " + command, + "", + ].join("\n")); + } + commitAll(repoRoot, "add common artifact execution commands"); + + const audit = runAudit(repoRoot); + + assert.equal(audit.status, 1); + for (const name of ["exec", "absolute-interpreter", "direct-path"]) { + assertFinding( + audit.result, + "workflow-privileged-untrusted-artifact-execution", + `.github/workflows/workflow-run-artifact-${name}.yml`, + ); + } +}); + test("issue_comment pull-request checkouts are privileged and untrusted", () => { const repoRoot = makeRepository(); const issueExpression = ["$", "{{ github.event.issue.number }}"].join(""); @@ -2334,6 +2434,61 @@ test("tainted environment values cannot reach shell evaluators", () => { ); }); +test("tainted script text cannot be piped into shell interpreters", () => { + const repoRoot = makeRepository(); + const bodyExpression = ["$", "{{ github.event.comment.body }}"].join(""); + write(repoRoot, ".github/workflows/comment-pipe-shell.yml", [ + "name: comment pipe shell", + "on: issue_comment", + "permissions: read-all", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - env:", + " COMMAND: " + bodyExpression, + " run: printf '%s' \"$COMMAND\" | bash", + "", + ].join("\n")); + commitAll(repoRoot, "add tainted shell pipeline"); + + const audit = runAudit(repoRoot); + + assert.equal(audit.status, 1); + assertFinding( + audit.result, + "workflow-privileged-untrusted-script-interpolation", + ".github/workflows/comment-pipe-shell.yml", + ); +}); + +test("privileged workflows reject attacker-selected shell templates", () => { + const repoRoot = makeRepository(); + const bodyExpression = ["$", "{{ github.event.comment.body }}"].join(""); + write(repoRoot, ".github/workflows/comment-shell-template.yml", [ + "name: comment shell template", + "on: issue_comment", + "permissions: read-all", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - shell: " + bodyExpression, + " run: echo trusted", + "", + ].join("\n")); + commitAll(repoRoot, "add attacker-selected shell template"); + + const audit = runAudit(repoRoot); + + assert.equal(audit.status, 1); + assertFinding( + audit.result, + "workflow-privileged-untrusted-script-interpolation", + ".github/workflows/comment-shell-template.yml", + ); +}); + test("public event author identities are untrusted checkout coordinates", () => { const repoRoot = makeRepository(); const issueAuthorExpression = [ @@ -2374,6 +2529,36 @@ test("public event author identities are untrusted checkout coordinates", () => } }); +test("fork events are privileged and expose untrusted repositories", () => { + const repoRoot = makeRepository(); + const repositoryExpression = ["$", "{{ github.event.forkee.full_name }}"].join(""); + write(repoRoot, ".github/workflows/fork-checkout.yml", [ + "name: fork checkout", + "on: fork", + "permissions:", + " contents: write", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - uses: actions/checkout@" + "a".repeat(40), + " with:", + " repository: " + repositoryExpression, + " ref: main", + "", + ].join("\n")); + commitAll(repoRoot, "add fork checkout workflow"); + + const audit = runAudit(repoRoot); + + assert.equal(audit.status, 1); + assertFinding( + audit.result, + "workflow-privileged-untrusted-checkout", + ".github/workflows/fork-checkout.yml", + ); +}); + test("privileged workflows reject shell-based untrusted checkouts", () => { const repoRoot = makeRepository(); const repositoryExpression = [ @@ -2555,6 +2740,35 @@ test("privileged workflows reject attacker-selected container images", () => { } }); +test("privileged workflows reject attacker-selected runners", () => { + const repoRoot = makeRepository(); + const matrixExpression = ["$", "{{ fromJSON(github.event.comment.body) }}"].join(""); + const runnerExpression = ["$", "{{ matrix.runner }}"].join(""); + write(repoRoot, ".github/workflows/dynamic-public-runner.yml", [ + "name: dynamic public runner", + "on: issue_comment", + "permissions: read-all", + "jobs:", + " inspect:", + " strategy:", + " matrix: " + matrixExpression, + " runs-on: " + runnerExpression, + " steps:", + " - run: echo inspect", + "", + ].join("\n")); + commitAll(repoRoot, "add attacker-selected runner"); + + const audit = runAudit(repoRoot); + + assert.equal(audit.status, 1); + assertFinding( + audit.result, + "workflow-privileged-untrusted-runner", + ".github/workflows/dynamic-public-runner.yml", + ); +}); + test("mutable Docker actions warn while digest-pinned actions pass", () => { const repoRoot = makeRepository(); write(repoRoot, ".github/workflows/mutable-docker.yml", [ @@ -2703,6 +2917,43 @@ test("local composite action dependencies are audited recursively", () => { ); }); +test("local action references cannot traverse tracked symlinks", () => { + const repoRoot = makeRepository(); + const headExpression = ["$", "{{ github.head_ref }}"].join(""); + write(repoRoot, ".github/workflows/symlink-action.yml", [ + "name: symlink action", + "on: pull_request_target", + "permissions: read-all", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - uses: ./.github/actions/link", + "", + ].join("\n")); + write(repoRoot, ".github/actions/target/action.yml", [ + "name: symlink target", + "runs:", + " using: composite", + " steps:", + " - uses: actions/checkout@" + "a".repeat(40), + " with:", + " ref: " + headExpression, + "", + ].join("\n")); + symlinkSync("target", path.join(repoRoot, ".github/actions/link")); + commitAll(repoRoot, "add symlinked local action"); + + const audit = runAudit(repoRoot); + + assert.equal(audit.status, 1); + assertFinding( + audit.result, + "workflow-local-action-symlink", + ".github/workflows/symlink-action.yml", + ); +}); + test("repository-root local actions are audited", () => { const repoRoot = makeRepository(); const expression = ["$", "{{ github.head_ref }}"].join(""); From 41488222a49668e1ec91d2def4f3ecbb85e76d35 Mon Sep 17 00:00:00 2001 From: Vladimir Date: Sat, 22 Aug 2026 07:43:13 +0800 Subject: [PATCH 24/37] Propagate workflow control taint --- .../scripts/public-source-release-audit.mjs | 508 +++++++++++++++++- .../public-source-release-audit.test.mjs | 219 ++++++++ 2 files changed, 710 insertions(+), 17 deletions(-) diff --git a/public-source-release-audit/scripts/public-source-release-audit.mjs b/public-source-release-audit/scripts/public-source-release-audit.mjs index 0eceeda..2f75bdc 100644 --- a/public-source-release-audit/scripts/public-source-release-audit.mjs +++ b/public-source-release-audit/scripts/public-source-release-audit.mjs @@ -415,6 +415,32 @@ function auditPrivilegedReusableWorkflowCalls(workflowSources) { source: caller.source, })); } + if (hasUntrustedWorkflowFailureHandling( + callerAnalysis.syntax.uncommented, + callerAnalysis.syntax.scalarAnchors, + returnedBindings, + )) { + findings.push(workflowFinding({ + message: "A privileged workflow must not let untrusted reusable-workflow output disable failure handling.", + path: caller.path, + ruleId: "workflow-privileged-untrusted-control-flow", + severity: "error", + source: caller.source, + })); + } + if (hasUntrustedWorkflowEnvironmentSelection( + callerAnalysis.syntax.uncommented, + callerAnalysis.syntax.scalarAnchors, + returnedBindings, + )) { + findings.push(workflowFinding({ + message: "A privileged workflow must not select a deployment environment from untrusted reusable-workflow output.", + path: caller.path, + ruleId: "workflow-privileged-untrusted-environment", + severity: "error", + source: caller.source, + })); + } const pending = callerAnalysis.localCalls.map((call) => ({ depth: 1, taintedBindings: reusableCallTaintedBindings(call, returnedBindings), @@ -500,6 +526,32 @@ function auditPrivilegedReusableWorkflowCalls(workflowSources) { source: callee.source, })); } + if (hasUntrustedWorkflowFailureHandling( + calleeAnalysis.syntax.uncommented, + calleeAnalysis.syntax.scalarAnchors, + effectiveTaintedBindings, + )) { + findings.push(workflowFinding({ + message: "A reusable workflow called from a privileged trigger must not let untrusted input disable failure handling.", + path: callee.path, + ruleId: "workflow-privileged-untrusted-control-flow", + severity: "error", + source: callee.source, + })); + } + if (hasUntrustedWorkflowEnvironmentSelection( + calleeAnalysis.syntax.uncommented, + calleeAnalysis.syntax.scalarAnchors, + effectiveTaintedBindings, + )) { + findings.push(workflowFinding({ + message: "A reusable workflow called from a privileged trigger must not select a deployment environment from untrusted input.", + path: callee.path, + ruleId: "workflow-privileged-untrusted-environment", + severity: "error", + source: callee.source, + })); + } pending.push(...calleeAnalysis.localCalls.map((nestedCall) => ({ depth: depth + 1, taintedBindings: reusableCallTaintedBindings( @@ -626,6 +678,10 @@ function auditLocalCompositeActions( syntax.uncommented, syntax.scalarAnchors, ), + outputBindings: actionOutputBindings( + syntax.uncommented, + syntax.scalarAnchors, + ), references, stepGroups, syntax, @@ -642,11 +698,170 @@ function auditLocalCompositeActions( (manifestPath) => actionBySnapshotPath.has(`${workflow.snapshot}\0${manifestPath}`), ); const symlinkPaths = symlinkPathsBySnapshot.get(workflow.snapshot) ?? new Set(); + const taintedActionOutputMemo = new Map(); + function stepOutputResolverFor(scalarAnchors, depth = 0, active = new Set()) { + return (stepGroup, stepTaintedBindings) => { + const outputs = new Set(); + for (const call of localActionCallsFromStepGroup( + stepGroup, + scalarAnchors, + stepTaintedBindings, + )) { + for (const output of taintedOutputsForCall(call, depth, active)) { + outputs.add(output); + } + } + return outputs; + }; + } + function taintedOutputsForCall(call, depth = 0, active = new Set()) { + if (depth > 10 || localActionReferenceUsesSymlink(call.reference, symlinkPaths)) { + return new Set(); + } + const manifestPath = resolveManifestPath(call.reference); + if (!manifestPath) return new Set(); + const action = actionBySnapshotPath.get(`${workflow.snapshot}\0${manifestPath}`); + if (!action) return new Set(); + const analysis = analysisFor(action); + const effectiveTaintedBindings = actionInputTaintedBindings( + analysis.inputDefaultBindings, + call.taintedBindings, + call.providedBindings, + ); + const stateKey = `${manifestPath}\0${[...effectiveTaintedBindings].sort().join("\0")}`; + if (active.has(stateKey)) return new Set(); + if (taintedActionOutputMemo.has(stateKey)) { + return taintedActionOutputMemo.get(stateKey); + } + const nestedActive = new Set([...active, stateKey]); + const stepAnalysis = workflowStepTaintAnalysis( + analysis.stepGroups, + analysis.syntax.scalarAnchors, + effectiveTaintedBindings, + stepOutputResolverFor( + analysis.syntax.scalarAnchors, + depth + 1, + nestedActive, + ), + ); + const outputEvaluationBindings = mergeTaintedBindings( + effectiveTaintedBindings, + stepAnalysis.derivedTaintedBindings, + ); + const outputs = new Set(analysis.outputBindings + .filter((binding) => isUntrustedReusableValue( + binding.value, + outputEvaluationBindings, + )) + .map((binding) => binding.name)); + taintedActionOutputMemo.set(stateKey, outputs); + return outputs; + } + const workflowStepOutputResolver = stepOutputResolverFor(syntax.scalarAnchors); + if (privileged && hasUntrustedPullRequestCheckout( + syntax.uncommented, + syntax.scalarAnchors, + workflowTaintedBindings, + workflowStepOutputResolver, + )) { + findings.push(workflowFinding({ + message: "A privileged workflow must not execute an untrusted checkout returned through a local action output.", + path: workflow.path, + ruleId: "workflow-privileged-untrusted-checkout", + severity: "error", + source: workflow.source, + })); + } + if (privileged && hasUntrustedWorkflowArtifactExecution( + syntax.uncommented, + syntax.scalarAnchors, + workflowTaintedBindings, + workflowStepOutputResolver, + )) { + findings.push(workflowFinding({ + message: "A privileged workflow must not execute untrusted artifacts returned through local action outputs.", + path: workflow.path, + ruleId: "workflow-privileged-untrusted-artifact-execution", + severity: "error", + source: workflow.source, + })); + } + if (privileged && hasUntrustedScriptInterpolation( + syntax.uncommented, + syntax.scalarAnchors, + workflowTaintedBindings, + workflowStepOutputResolver, + )) { + findings.push(workflowFinding({ + message: "A privileged workflow must not execute untrusted script data returned through local action outputs.", + path: workflow.path, + ruleId: "workflow-privileged-untrusted-script-interpolation", + severity: "error", + source: workflow.source, + })); + } + if (privileged && hasUntrustedWorkflowFailureHandling( + syntax.uncommented, + syntax.scalarAnchors, + workflowTaintedBindings, + workflowStepOutputResolver, + )) { + findings.push(workflowFinding({ + message: "A privileged workflow must not let local action output disable failure handling.", + path: workflow.path, + ruleId: "workflow-privileged-untrusted-control-flow", + severity: "error", + source: workflow.source, + })); + } + if (privileged && hasUntrustedWorkflowContainerImage( + syntax.uncommented, + syntax.scalarAnchors, + workflowTaintedBindings, + workflowStepOutputResolver, + )) { + findings.push(workflowFinding({ + message: "A privileged workflow must not select container images through untrusted local action outputs.", + path: workflow.path, + ruleId: "workflow-privileged-untrusted-container-image", + severity: "error", + source: workflow.source, + })); + } + if (privileged && hasUntrustedWorkflowRunnerSelection( + syntax.uncommented, + syntax.scalarAnchors, + workflowTaintedBindings, + workflowStepOutputResolver, + )) { + findings.push(workflowFinding({ + message: "A privileged workflow must not select runners through untrusted local action outputs.", + path: workflow.path, + ruleId: "workflow-privileged-untrusted-runner", + severity: "error", + source: workflow.source, + })); + } + if (privileged && hasUntrustedWorkflowEnvironmentSelection( + syntax.uncommented, + syntax.scalarAnchors, + workflowTaintedBindings, + workflowStepOutputResolver, + )) { + findings.push(workflowFinding({ + message: "A privileged workflow must not select deployment environments through untrusted local action outputs.", + path: workflow.path, + ruleId: "workflow-privileged-untrusted-environment", + severity: "error", + source: workflow.source, + })); + } const pending = []; for (const call of workflowLocalActionCalls( syntax.uncommented, syntax.scalarAnchors, workflowTaintedBindings, + workflowStepOutputResolver, )) { if (localActionReferenceUsesSymlink(call.reference, symlinkPaths)) { findings.push(workflowFinding({ @@ -706,6 +921,7 @@ function auditLocalCompositeActions( analysis.stepGroups, analysis.syntax.scalarAnchors, effectiveTaintedBindings, + stepOutputResolverFor(analysis.syntax.scalarAnchors, depth + 1), )) { if (localActionReferenceUsesSymlink(call.reference, symlinkPaths)) { findings.push(workflowFinding({ @@ -728,6 +944,7 @@ function auditLocalCompositeActions( analysis.stepGroups, analysis.syntax.scalarAnchors, effectiveTaintedBindings, + stepOutputResolverFor(analysis.syntax.scalarAnchors, depth + 1), ).stepContexts; if (privileged && actionStepContexts.some(({ stepGroup, taintedBindings }) => ( stepGroupHasUntrustedCheckout( @@ -768,6 +985,18 @@ function auditLocalCompositeActions( source: action.source, })); } + if (privileged && stepContextsHaveUntrustedFailureHandling( + actionStepContexts, + analysis.syntax.scalarAnchors, + )) { + findings.push(workflowFinding({ + message: "A local composite action called from a privileged workflow must not let untrusted input disable failure handling.", + path: action.path, + ruleId: "workflow-privileged-untrusted-control-flow", + severity: "error", + source: action.source, + })); + } } } return findings; @@ -841,6 +1070,21 @@ function actionInputDefaultBindings(text, scalarAnchors) { ))); } +function actionOutputBindings(text, scalarAnchors) { + return workflowRootContainerGroups(text, "outputs", scalarAnchors) + .flatMap((group) => group.properties.flatMap((outputProperty) => ( + workflowPropertyMappingEntries(group, outputProperty, scalarAnchors) + .filter(({ entry }) => entry.key.toLowerCase() === "value") + .map(({ entry }) => ({ + name: resolveYamlScalarValue( + outputProperty.entry.key, + scalarAnchors, + ).toLowerCase(), + value: resolveYamlScalarValue(entry.value, scalarAnchors), + })) + ))); +} + function actionInputTaintedBindings(defaultBindings, callerTaintedBindings, providedBindings) { const taintedBindings = new Set(callerTaintedBindings); const defaults = defaultBindings.filter((binding) => ( @@ -1147,12 +1391,14 @@ function workflowJobTaintContexts( workflowEnvBindings, scalarAnchors, inheritedTaintedBindings, + stepOutputResolver, ) { return new Map([...workflowJobTaintAnalyses( jobGroups, workflowEnvBindings, scalarAnchors, inheritedTaintedBindings, + stepOutputResolver, )].map(([group, analysis]) => [group, analysis.taintedBindings])); } @@ -1161,6 +1407,7 @@ function workflowJobTaintAnalyses( workflowEnvBindings, scalarAnchors, inheritedTaintedBindings, + stepOutputResolver, ) { const workflowTaintedBindings = contextTaintedBindings( workflowEnvBindings, @@ -1180,6 +1427,7 @@ function workflowJobTaintAnalyses( group, scalarAnchors, jobInheritedBindings, + stepOutputResolver, ); for (const binding of workflowMappingBindings( group, @@ -1203,19 +1451,35 @@ function workflowJobTaintAnalyses( ); return new Map(jobGroups.map((group) => [ group, - workflowJobTaintAnalysis(group, scalarAnchors, jobInheritedBindings), + workflowJobTaintAnalysis( + group, + scalarAnchors, + jobInheritedBindings, + stepOutputResolver, + ), ])); } -function workflowJobTaintedBindings(group, scalarAnchors, inheritedTaintedBindings) { +function workflowJobTaintedBindings( + group, + scalarAnchors, + inheritedTaintedBindings, + stepOutputResolver, +) { return workflowJobTaintAnalysis( group, scalarAnchors, inheritedTaintedBindings, + stepOutputResolver, ).taintedBindings; } -function workflowJobTaintAnalysis(group, scalarAnchors, inheritedTaintedBindings) { +function workflowJobTaintAnalysis( + group, + scalarAnchors, + inheritedTaintedBindings, + stepOutputResolver, +) { const jobTaintedBindings = contextTaintedBindings( workflowMappingBindings(group, "env", "env", scalarAnchors), inheritedTaintedBindings, @@ -1228,6 +1492,7 @@ function workflowJobTaintAnalysis(group, scalarAnchors, inheritedTaintedBindings mappingContainerStepPropertyGroups(group, scalarAnchors), scalarAnchors, matrixTaintedBindings, + stepOutputResolver, ); return { configurationTaintedBindings: matrixTaintedBindings, @@ -1239,7 +1504,12 @@ function workflowJobTaintAnalysis(group, scalarAnchors, inheritedTaintedBindings }; } -function workflowStepTaintAnalysis(stepGroups, scalarAnchors, inheritedTaintedBindings) { +function workflowStepTaintAnalysis( + stepGroups, + scalarAnchors, + inheritedTaintedBindings, + stepOutputResolver, +) { const derivedTaintedBindings = new Set(); const stepContexts = []; for (const stepGroup of stepGroups) { @@ -1268,17 +1538,37 @@ function workflowStepTaintAnalysis(stepGroups, scalarAnchors, inheritedTaintedBi } } if (!stepId) continue; - const outputSources = [ - ...runSources, - ...workflowMappingBindings(stepGroup, "with", "with", scalarAnchors) - .map((binding) => binding.value), - ]; - if (outputSources.some((value) => isUntrustedReusableValue( - value, + const taintedEnvironmentVariables = new Set([...stepTaintedBindings].flatMap((binding) => ( + binding.startsWith("env.") && binding !== "env.*" + ? [binding.slice("env.".length).toLowerCase()] + : [] + ))); + const runOutputIsTainted = runSources.some((runSource) => ( + isUntrustedReusableValue(runSource, stepTaintedBindings) + || shellSourceReferencesTaintedVariable( + runSource, + taintedEnvironmentVariables, + stepTaintedBindings.has("env.*"), + ) + )); + const actionOutputIsTainted = workflowMappingBindings( + stepGroup, + "with", + "with", + scalarAnchors, + ).some((binding) => isUntrustedReusableValue( + binding.value, stepTaintedBindings, - ))) { + )); + if (runOutputIsTainted || actionOutputIsTainted) { derivedTaintedBindings.add(`steps.${stepId}.outputs.*`); } + for (const outputName of stepOutputResolver?.( + stepGroup, + stepTaintedBindings, + ) ?? []) { + derivedTaintedBindings.add(`steps.${stepId}.outputs.${outputName}`); + } } return { derivedTaintedBindings, stepContexts }; } @@ -1312,13 +1602,19 @@ function mergeTaintedBindings(...bindingSets) { return new Set(bindingSets.flatMap((bindings) => [...bindings])); } -function workflowLocalActionCalls(text, scalarAnchors, inheritedTaintedBindings) { +function workflowLocalActionCalls( + text, + scalarAnchors, + inheritedTaintedBindings, + stepOutputResolver, +) { const jobGroups = workflowJobPropertyGroups(text, scalarAnchors); const analyses = workflowJobTaintAnalyses( jobGroups, workflowRootMappingBindings(text, "env", "env", scalarAnchors), scalarAnchors, inheritedTaintedBindings, + stepOutputResolver, ); return jobGroups.flatMap((group) => ( analyses.get(group)?.stepContexts ?? [] @@ -1329,11 +1625,17 @@ function workflowLocalActionCalls(text, scalarAnchors, inheritedTaintedBindings) ))); } -function compositeLocalActionCalls(stepGroups, scalarAnchors, inheritedTaintedBindings) { +function compositeLocalActionCalls( + stepGroups, + scalarAnchors, + inheritedTaintedBindings, + stepOutputResolver, +) { return workflowStepTaintAnalysis( stepGroups, scalarAnchors, inheritedTaintedBindings, + stepOutputResolver, ).stepContexts.flatMap(({ stepGroup, taintedBindings }) => ( localActionCallsFromStepGroup(stepGroup, scalarAnchors, taintedBindings) )); @@ -1551,6 +1853,28 @@ function auditWorkflowText(workflowPath, text, source = "tracked-file") { })); } + if (hasPrivilegedTrigger + && hasUntrustedWorkflowFailureHandling(uncommented, scalarAnchors)) { + findings.push(workflowFinding({ + message: "A privileged workflow must not let untrusted event data disable failure handling.", + path: workflowPath, + ruleId: "workflow-privileged-untrusted-control-flow", + severity: "error", + source, + })); + } + + if (hasPrivilegedTrigger + && hasUntrustedWorkflowEnvironmentSelection(uncommented, scalarAnchors)) { + findings.push(workflowFinding({ + message: "A privileged workflow must not select a deployment environment from untrusted event data.", + path: workflowPath, + ruleId: "workflow-privileged-untrusted-environment", + severity: "error", + source, + })); + } + if (hasPullRequestTarget) { findings.push(workflowFinding({ message: "pull_request_target requires an explicit trusted-base and untrusted-head threat-model review.", @@ -1939,8 +2263,18 @@ function workflowJobPropertyGroupsFromFlowJobs(value, scalarAnchors, blockNodeAn } function workflowTriggerNames(text, scalarAnchors, blockNodeAnchors) { - const lines = maskYamlBlockScalarBodies(text).split("\n"); const names = []; + for (const entry of yamlBlockMappingEntries(text, scalarAnchors)) { + if (entry.indentation !== 0 || entry.key.toLowerCase() !== "on" + || !isYamlBlockScalarHeader(entry.inlineValue)) continue; + names.push(...workflowTriggerNamesFromValue( + entry.value, + scalarAnchors, + blockNodeAnchors, + text, + )); + } + const lines = maskYamlBlockScalarBodies(text).split("\n"); for (let index = 0; index < lines.length; index += 1) { const parsedEntry = parseYamlMappingEntryAt(lines, index); const entry = parsedEntry?.entry; @@ -2673,13 +3007,19 @@ function readYamlFlowKey(text, startIndex) { return key.length > 0 ? { key, nextIndex: cursor } : undefined; } -function hasUntrustedPullRequestCheckout(text, scalarAnchors, taintedBindings = new Set()) { +function hasUntrustedPullRequestCheckout( + text, + scalarAnchors, + taintedBindings = new Set(), + stepOutputResolver, +) { const jobGroups = workflowJobPropertyGroups(text, scalarAnchors); const jobTaintAnalyses = workflowJobTaintAnalyses( jobGroups, workflowRootMappingBindings(text, "env", "env", scalarAnchors), scalarAnchors, taintedBindings, + stepOutputResolver, ); for (const jobGroup of jobGroups) { for (const { stepGroup, taintedBindings: stepTaintedBindings } of ( @@ -2701,6 +3041,7 @@ function hasUntrustedWorkflowArtifactExecution( text, scalarAnchors, taintedBindings = new Set(), + stepOutputResolver, ) { const jobGroups = workflowJobPropertyGroups(text, scalarAnchors); const jobTaintAnalyses = workflowJobTaintAnalyses( @@ -2708,6 +3049,7 @@ function hasUntrustedWorkflowArtifactExecution( workflowRootMappingBindings(text, "env", "env", scalarAnchors), scalarAnchors, taintedBindings, + stepOutputResolver, ); return jobGroups.some((jobGroup) => stepContextsHaveUntrustedArtifactExecution( jobTaintAnalyses.get(jobGroup)?.stepContexts ?? [], @@ -2715,13 +3057,19 @@ function hasUntrustedWorkflowArtifactExecution( )); } -function hasUntrustedScriptInterpolation(text, scalarAnchors, taintedBindings = new Set()) { +function hasUntrustedScriptInterpolation( + text, + scalarAnchors, + taintedBindings = new Set(), + stepOutputResolver, +) { const jobGroups = workflowJobPropertyGroups(text, scalarAnchors); const jobTaintAnalyses = workflowJobTaintAnalyses( jobGroups, workflowRootMappingBindings(text, "env", "env", scalarAnchors), scalarAnchors, taintedBindings, + stepOutputResolver, ); return jobGroups.some((jobGroup) => stepContextsHaveUntrustedScriptInterpolation( jobTaintAnalyses.get(jobGroup)?.stepContexts ?? [], @@ -2733,6 +3081,7 @@ function hasUntrustedWorkflowContainerImage( text, scalarAnchors, taintedBindings = new Set(), + stepOutputResolver, ) { const jobGroups = workflowJobPropertyGroups(text, scalarAnchors); const jobTaintAnalyses = workflowJobTaintAnalyses( @@ -2740,6 +3089,7 @@ function hasUntrustedWorkflowContainerImage( workflowRootMappingBindings(text, "env", "env", scalarAnchors), scalarAnchors, taintedBindings, + stepOutputResolver, ); return jobGroups.some((jobGroup) => workflowJobContainerImageReferences( jobGroup, @@ -2754,6 +3104,7 @@ function hasUntrustedWorkflowRunnerSelection( text, scalarAnchors, taintedBindings = new Set(), + stepOutputResolver, ) { const jobGroups = workflowJobPropertyGroups(text, scalarAnchors); const jobTaintAnalyses = workflowJobTaintAnalyses( @@ -2761,6 +3112,7 @@ function hasUntrustedWorkflowRunnerSelection( workflowRootMappingBindings(text, "env", "env", scalarAnchors), scalarAnchors, taintedBindings, + stepOutputResolver, ); return jobGroups.some((jobGroup) => workflowJobRunnerLabelSetsForGroup( jobGroup, @@ -2771,8 +3123,74 @@ function hasUntrustedWorkflowRunnerSelection( ))); } +function hasUntrustedWorkflowFailureHandling( + text, + scalarAnchors, + taintedBindings = new Set(), + stepOutputResolver, +) { + const jobGroups = workflowJobPropertyGroups(text, scalarAnchors); + const jobTaintAnalyses = workflowJobTaintAnalyses( + jobGroups, + workflowRootMappingBindings(text, "env", "env", scalarAnchors), + scalarAnchors, + taintedBindings, + stepOutputResolver, + ); + return jobGroups.some((jobGroup) => { + const analysis = jobTaintAnalyses.get(jobGroup); + const jobFailureHandlingIsUntrusted = jobGroup.properties + .filter(({ entry }) => entry.key.toLowerCase() === "continue-on-error") + .map(({ entry }) => resolveYamlScalarValue(entry.value, scalarAnchors)) + .some((value) => isUntrustedReusableValue( + value, + analysis?.configurationTaintedBindings ?? taintedBindings, + )); + return jobFailureHandlingIsUntrusted + || stepContextsHaveUntrustedFailureHandling( + analysis?.stepContexts ?? [], + scalarAnchors, + ); + }); +} + +function hasUntrustedWorkflowEnvironmentSelection( + text, + scalarAnchors, + taintedBindings = new Set(), + stepOutputResolver, +) { + const jobGroups = workflowJobPropertyGroups(text, scalarAnchors); + const jobTaintAnalyses = workflowJobTaintAnalyses( + jobGroups, + workflowRootMappingBindings(text, "env", "env", scalarAnchors), + scalarAnchors, + taintedBindings, + stepOutputResolver, + ); + return jobGroups.some((jobGroup) => workflowJobEnvironmentReferences( + jobGroup, + scalarAnchors, + ).some((environment) => isUntrustedReusableValue( + environment, + jobTaintAnalyses.get(jobGroup)?.configurationTaintedBindings ?? taintedBindings, + ))); +} + +function stepContextsHaveUntrustedFailureHandling(stepContexts, scalarAnchors) { + return stepContexts.some(({ stepGroup, taintedBindings }) => stepGroup.properties + .filter(({ entry }) => entry.key.toLowerCase() === "continue-on-error") + .map(({ entry }) => resolveYamlScalarValue(entry.value, scalarAnchors)) + .some((value) => isUntrustedReusableValue(value, taintedBindings))); +} + function stepContextsHaveUntrustedScriptInterpolation(stepContexts, scalarAnchors) { return stepContexts.some(({ stepGroup, taintedBindings }) => { + if (stepGroupHasUntrustedExecutableActionInput( + stepGroup, + scalarAnchors, + taintedBindings, + )) return true; const hasUntrustedShellTemplate = stepGroup.properties .filter(({ entry }) => entry.key.toLowerCase() === "shell") .map(({ entry }) => resolveYamlScalarValue(entry.value, scalarAnchors)) @@ -2787,6 +3205,30 @@ function stepContextsHaveUntrustedScriptInterpolation(stepContexts, scalarAnchor }); } +function stepGroupHasUntrustedExecutableActionInput( + stepGroup, + scalarAnchors, + taintedBindings, +) { + const reference = stepGroup.properties + .filter(({ entry }) => entry.key.toLowerCase() === "uses") + .map(({ entry }) => resolveYamlScalarValue(entry.value, scalarAnchors).toLowerCase()) + .find(Boolean); + let executableInputs; + if (/^actions\/github-script@/u.test(reference ?? "")) { + executableInputs = new Set(["script"]); + } else if (/^azure\/(?:cli|powershell)@/u.test(reference ?? "")) { + executableInputs = new Set(["inlinescript"]); + } else if (/^appleboy\/ssh-action@/u.test(reference ?? "")) { + executableInputs = new Set(["script"]); + } else { + return false; + } + return workflowMappingBindings(stepGroup, "with", "with", scalarAnchors) + .some((binding) => executableInputs.has(binding.name.toLowerCase()) + && isUntrustedReusableValue(binding.value, taintedBindings)); +} + function shellRunEvaluatesTaintedVariable(runSource, taintedBindings) { const taintedVariables = new Set([...taintedBindings].flatMap((binding) => ( binding.startsWith("env.") && binding !== "env.*" @@ -2796,6 +3238,7 @@ function shellRunEvaluatesTaintedVariable(runSource, taintedBindings) { const anyEnvironmentVariableTainted = taintedBindings.has("env.*"); const evaluatorCommand = /^\s*(?:(?:builtin|command|exec)\s+)?(?:eval\b|(?:(?:\/[^/\s]+)*\/)?(?:bash|dash|fish|ksh|sh|zsh)\b[^;&|]*\s-c(?:\s|$)|(?:iex|invoke-expression)\b|(?:(?:\/[^/\s]+)*\/)?(?:powershell|pwsh)(?:\.exe)?\b[^;&|]*\s-(?:c|command)(?:\s|$))/iu; const stdinInterpreterCommand = /^\s*(?:(?:command|exec)\s+)?(?:(?:\/usr\/bin\/)?env\s+(?:-[^\s]+\s+)*)?(?:(?:\/[^/\s]+)*\/)?(?:bash|dash|fish|ksh|powershell|pwsh|sh|zsh)(?:\.exe)?(?:\s|$)/iu; + const hereInputInterpreterCommand = /^\s*(?:(?:command|exec)\s+)?(?:(?:\/usr\/bin\/)?env\s+(?:-[^\s]+\s+)*)?(?:(?:\/[^/\s]+)*\/)?(?:bash|dash|fish|ksh|powershell|pwsh|sh|zsh)(?:\.exe)?(?:\s+-[^\s]+)*\s+<<<(?:\s|$)/iu; for (const segment of shellCommandSegments(runSource)) { if (/^\s*#/u.test(segment)) continue; const assignment = /^\s*(?:(?:export|local|readonly)\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=|^\s*\$([A-Za-z_][A-Za-z0-9_]*)\s*=/iu.exec(segment); @@ -2819,6 +3262,7 @@ function shellRunEvaluatesTaintedVariable(runSource, taintedBindings) { if (evaluatorCommand.test(stage) && (stageReferencesTaint || pipelineInputTainted)) { return true; } + if (hereInputInterpreterCommand.test(stage) && stageReferencesTaint) return true; if (stdinInterpreterCommand.test(stage) && pipelineInputTainted) return true; pipelineInputTainted ||= stageReferencesTaint; } @@ -2994,6 +3438,9 @@ function shellArtifactExecutionSource(segment) { const argument = words[cursor]; cursor += 1; if (argument === "--") return words[cursor]; + if (/^(?:\d*)<$/u.test(argument)) return words[cursor]; + const redirectedSource = /^(?:\d*)<([^<].*)$/u.exec(argument)?.[1]; + if (redirectedSource) return redirectedSource; if (["-c", "--command", "-e", "--eval"].includes(argument.toLowerCase())) { return undefined; } @@ -3233,6 +3680,33 @@ function normalizeExpressionPropertyAccess(value) { return normalized; } +function workflowJobEnvironmentReferences(group, scalarAnchors) { + const environments = []; + for (const property of group.properties) { + if (property.entry.key.toLowerCase() !== "environment") continue; + const environmentGroup = { ...group, properties: [property] }; + const nameEntries = workflowPropertyMappingEntries( + environmentGroup, + property, + scalarAnchors, + ).filter(({ entry }) => entry.key.toLowerCase() === "name"); + if (nameEntries.length > 0) { + environments.push(...nameEntries.map(({ entry }) => ( + resolveYamlScalarValue(entry.value, scalarAnchors) + ))); + continue; + } + const inlineValue = resolveYamlScalarValue( + property.entry.inlineValue ?? property.entry.value, + scalarAnchors, + ); + if (inlineValue.length > 0 && !inlineValue.startsWith("{")) { + environments.push(inlineValue); + } + } + return environments; +} + function workflowContainerImageReferences(text, scalarAnchors) { return workflowJobPropertyGroups(text, scalarAnchors).flatMap((group) => ( workflowJobContainerImageReferences(group, scalarAnchors) diff --git a/public-source-release-audit/tests/public-source-release-audit.test.mjs b/public-source-release-audit/tests/public-source-release-audit.test.mjs index 672f2fc..b80c501 100644 --- a/public-source-release-audit/tests/public-source-release-audit.test.mjs +++ b/public-source-release-audit/tests/public-source-release-audit.test.mjs @@ -519,6 +519,33 @@ test("block-scalar write-all permissions are release-blocking", () => { assertFinding(audit.result, "workflow-write-all", ".github/workflows/block-permissions.yml"); }); +test("block-scalar public triggers remain privileged", () => { + const repoRoot = makeRepository(); + const bodyExpression = ["$", "{{ github.event.comment.body }}"].join(""); + write(repoRoot, ".github/workflows/block-trigger.yml", [ + "name: block trigger", + "on: >-", + " issue_comment", + "permissions: read-all", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - run: echo \"comment=" + bodyExpression + "\"", + "", + ].join("\n")); + commitAll(repoRoot, "add block scalar trigger"); + + const audit = runAudit(repoRoot); + + assert.equal(audit.status, 1); + assertFinding( + audit.result, + "workflow-privileged-untrusted-script-interpolation", + ".github/workflows/block-trigger.yml", + ); +}); + test("anchored write-all permissions are release-blocking", () => { const repoRoot = makeRepository(); write(repoRoot, ".github/workflows/anchored-permissions.yml", [ @@ -2128,6 +2155,39 @@ test("artifact execution recognizes common command wrappers and paths", () => { } }); +test("artifact execution recognizes interpreter input redirection", () => { + const repoRoot = makeRepository(); + const runIdExpression = ["$", "{{ github.event.workflow_run.id }}"].join(""); + write(repoRoot, ".github/workflows/workflow-run-artifact-redirect.yml", [ + "name: workflow run artifact redirect", + "on:", + " workflow_run:", + " workflows: [verify]", + " types: [completed]", + "permissions: read-all", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - uses: actions/download-artifact@" + "a".repeat(40), + " with:", + " run-id: " + runIdExpression, + " path: payload", + " - run: bash < payload/run.sh", + "", + ].join("\n")); + commitAll(repoRoot, "add redirected artifact execution"); + + const audit = runAudit(repoRoot); + + assert.equal(audit.status, 1); + assertFinding( + audit.result, + "workflow-privileged-untrusted-artifact-execution", + ".github/workflows/workflow-run-artifact-redirect.yml", + ); +}); + test("issue_comment pull-request checkouts are privileged and untrusted", () => { const repoRoot = makeRepository(); const issueExpression = ["$", "{{ github.event.issue.number }}"].join(""); @@ -2462,6 +2522,34 @@ test("tainted script text cannot be piped into shell interpreters", () => { ); }); +test("tainted here-strings cannot feed shell interpreters", () => { + const repoRoot = makeRepository(); + const bodyExpression = ["$", "{{ github.event.comment.body }}"].join(""); + write(repoRoot, ".github/workflows/comment-here-shell.yml", [ + "name: comment here shell", + "on: issue_comment", + "permissions: read-all", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - env:", + " COMMAND: " + bodyExpression, + " run: bash <<< \"$COMMAND\"", + "", + ].join("\n")); + commitAll(repoRoot, "add tainted shell here-string"); + + const audit = runAudit(repoRoot); + + assert.equal(audit.status, 1); + assertFinding( + audit.result, + "workflow-privileged-untrusted-script-interpolation", + ".github/workflows/comment-here-shell.yml", + ); +}); + test("privileged workflows reject attacker-selected shell templates", () => { const repoRoot = makeRepository(); const bodyExpression = ["$", "{{ github.event.comment.body }}"].join(""); @@ -2489,6 +2577,62 @@ test("privileged workflows reject attacker-selected shell templates", () => { ); }); +test("privileged workflows reject tainted executable action inputs", () => { + const repoRoot = makeRepository(); + const bodyExpression = ["$", "{{ github.event.comment.body }}"].join(""); + write(repoRoot, ".github/workflows/comment-github-script.yml", [ + "name: comment github script", + "on: issue_comment", + "permissions: read-all", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - uses: actions/github-script@" + "a".repeat(40), + " with:", + " script: " + bodyExpression, + "", + ].join("\n")); + commitAll(repoRoot, "add tainted github script input"); + + const audit = runAudit(repoRoot); + + assert.equal(audit.status, 1); + assertFinding( + audit.result, + "workflow-privileged-untrusted-script-interpolation", + ".github/workflows/comment-github-script.yml", + ); +}); + +test("privileged workflows reject attacker-controlled failure handling", () => { + const repoRoot = makeRepository(); + const bodyExpression = ["$", "{{ fromJSON(github.event.comment.body) }}"].join(""); + write(repoRoot, ".github/workflows/comment-continue-error.yml", [ + "name: comment continue error", + "on: issue_comment", + "permissions: read-all", + "jobs:", + " verify:", + " runs-on: ubuntu-latest", + " steps:", + " - continue-on-error: " + bodyExpression, + " run: exit 1", + " - run: echo deploy", + "", + ].join("\n")); + commitAll(repoRoot, "add attacker-controlled failure handling"); + + const audit = runAudit(repoRoot); + + assert.equal(audit.status, 1); + assertFinding( + audit.result, + "workflow-privileged-untrusted-control-flow", + ".github/workflows/comment-continue-error.yml", + ); +}); + test("public event author identities are untrusted checkout coordinates", () => { const repoRoot = makeRepository(); const issueAuthorExpression = [ @@ -2769,6 +2913,33 @@ test("privileged workflows reject attacker-selected runners", () => { ); }); +test("privileged workflows reject attacker-selected deployment environments", () => { + const repoRoot = makeRepository(); + const bodyExpression = ["$", "{{ github.event.comment.body }}"].join(""); + write(repoRoot, ".github/workflows/comment-environment.yml", [ + "name: comment environment", + "on: issue_comment", + "permissions: read-all", + "jobs:", + " deploy:", + " runs-on: ubuntu-latest", + " environment: " + bodyExpression, + " steps:", + " - run: echo deploy", + "", + ].join("\n")); + commitAll(repoRoot, "add attacker-selected environment"); + + const audit = runAudit(repoRoot); + + assert.equal(audit.status, 1); + assertFinding( + audit.result, + "workflow-privileged-untrusted-environment", + ".github/workflows/comment-environment.yml", + ); +}); + test("mutable Docker actions warn while digest-pinned actions pass", () => { const repoRoot = makeRepository(); write(repoRoot, ".github/workflows/mutable-docker.yml", [ @@ -3153,6 +3324,54 @@ test("local actions inherit caller environment taint", () => { ); }); +test("local composite outputs return inherited taint to callers", () => { + const repoRoot = makeRepository(); + const headExpression = ["$", "{{ github.head_ref }}"].join(""); + const outputExpression = ["$", "{{ steps.source.outputs.ref }}"].join(""); + const actionOutputExpression = ["$", "{{ steps.export.outputs.ref }}"].join(""); + write(repoRoot, ".github/workflows/composite-output.yml", [ + "name: composite output", + "on: pull_request_target", + "permissions: read-all", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - env:", + " SOURCE_REF: " + headExpression, + " run: echo \"PR_REF=$SOURCE_REF\" >> \"$GITHUB_ENV\"", + " - id: source", + " uses: ./.github/actions/output-ref", + " - uses: actions/checkout@" + "a".repeat(40), + " with:", + " ref: " + outputExpression, + "", + ].join("\n")); + write(repoRoot, ".github/actions/output-ref/action.yml", [ + "name: output ref", + "outputs:", + " ref:", + " value: " + actionOutputExpression, + "runs:", + " using: composite", + " steps:", + " - id: export", + " shell: bash", + " run: echo \"ref=$PR_REF\" >> \"$GITHUB_OUTPUT\"", + "", + ].join("\n")); + commitAll(repoRoot, "add composite output checkout"); + + const audit = runAudit(repoRoot); + + assert.equal(audit.status, 1); + assertFinding( + audit.result, + "workflow-privileged-untrusted-checkout", + ".github/workflows/composite-output.yml", + ); +}); + test("composite input defaults are tainted unless callers override them", () => { const unsafeRepo = makeRepository(); const safeRepo = makeRepository(); From 520b6eee2dcbf33e45aa1314169045d03343884a Mon Sep 17 00:00:00 2001 From: Vladimir Date: Sat, 22 Aug 2026 08:12:25 +0800 Subject: [PATCH 25/37] Close reusable and control-flow taint gaps --- .../scripts/public-source-release-audit.mjs | 330 ++++++++++++---- .../public-source-release-audit.test.mjs | 351 +++++++++++++++++- 2 files changed, 611 insertions(+), 70 deletions(-) diff --git a/public-source-release-audit/scripts/public-source-release-audit.mjs b/public-source-release-audit/scripts/public-source-release-audit.mjs index 2f75bdc..b023148 100644 --- a/public-source-release-audit/scripts/public-source-release-audit.mjs +++ b/public-source-release-audit/scripts/public-source-release-audit.mjs @@ -252,7 +252,7 @@ function auditWorkflowSources(repoRoot, { includeHistory = false } = {}) { return findings; } -function reusableWorkflowGraph(workflowSources) { +function reusableWorkflowGraph(workflowSources, stepOutputResolverForSource) { const sourceBySnapshotPath = new Map(workflowSources.map((source) => [ `${source.snapshot}\0${source.path}`, source, @@ -286,6 +286,7 @@ function reusableWorkflowGraph(workflowSources) { if (taintedOutputMemo.has(stateKey)) return taintedOutputMemo.get(stateKey); const analysis = analysisFor(source); if (!analysis.isReusable) return new Set(); + const stepOutputResolver = stepOutputResolverForSource?.(source); const nestedReturnedBindings = new Set(); let changed; let iteration = 0; @@ -301,7 +302,11 @@ function reusableWorkflowGraph(workflowSources) { `${source.snapshot}\0${call.workflowPath}`, ); if (!callee || !analysisFor(callee).isReusable) continue; - const calleeInputs = reusableCallTaintedBindings(call, callerTaintedBindings); + const calleeInputs = reusableCallTaintedBindings( + call, + callerTaintedBindings, + stepOutputResolver, + ); const calleeOutputs = taintedOutputsFor( callee, calleeInputs, @@ -326,6 +331,7 @@ function reusableWorkflowGraph(workflowSources) { analysis.syntax.uncommented, analysis.syntax.scalarAnchors, effectiveTaintedBindings, + stepOutputResolver, ), ); const outputs = new Set(reusableWorkflowOutputBindings( @@ -340,6 +346,7 @@ function reusableWorkflowGraph(workflowSources) { }; const returnedBindingsFor = (source, inheritedTaintedBindings) => { const analysis = analysisFor(source); + const stepOutputResolver = stepOutputResolverForSource?.(source); const returnedBindings = new Set(); let changed; let iteration = 0; @@ -353,7 +360,11 @@ function reusableWorkflowGraph(workflowSources) { for (const call of analysis.localCalls) { const callee = sourceBySnapshotPath.get(`${source.snapshot}\0${call.workflowPath}`); if (!callee || !analysisFor(callee).isReusable) continue; - const calleeInputs = reusableCallTaintedBindings(call, callerTaintedBindings); + const calleeInputs = reusableCallTaintedBindings( + call, + callerTaintedBindings, + stepOutputResolver, + ); for (const outputName of taintedOutputsFor(callee, calleeInputs)) { const binding = `needs.${call.jobGroup.jobName}.outputs.${outputName}`; if (returnedBindings.has(binding)) continue; @@ -421,7 +432,7 @@ function auditPrivilegedReusableWorkflowCalls(workflowSources) { returnedBindings, )) { findings.push(workflowFinding({ - message: "A privileged workflow must not let untrusted reusable-workflow output disable failure handling.", + message: "A privileged workflow must not let untrusted reusable-workflow output control execution conditions or disable failure handling.", path: caller.path, ruleId: "workflow-privileged-untrusted-control-flow", severity: "error", @@ -532,7 +543,7 @@ function auditPrivilegedReusableWorkflowCalls(workflowSources) { effectiveTaintedBindings, )) { findings.push(workflowFinding({ - message: "A reusable workflow called from a privileged trigger must not let untrusted input disable failure handling.", + message: "A reusable workflow called from a privileged trigger must not let untrusted input control execution conditions or disable failure handling.", path: callee.path, ruleId: "workflow-privileged-untrusted-control-flow", severity: "error", @@ -565,9 +576,10 @@ function auditPrivilegedReusableWorkflowCalls(workflowSources) { return findings; } -function workflowExecutionStates(workflowSources) { +function workflowExecutionStates(workflowSources, stepOutputResolverForSource) { const { analysisFor, returnedBindingsFor, sourceBySnapshotPath } = reusableWorkflowGraph( workflowSources, + stepOutputResolverForSource, ); const pending = workflowSources.map((workflow) => ({ depth: 0, @@ -601,7 +613,11 @@ function workflowExecutionStates(workflowSources) { pending.push({ depth: state.depth + 1, privileged: state.privileged, - taintedBindings: reusableCallTaintedBindings(call, effectiveTaintedBindings), + taintedBindings: reusableCallTaintedBindings( + call, + effectiveTaintedBindings, + stepOutputResolverForSource?.(state.workflow), + ), workflow: callee, }); } @@ -689,38 +705,28 @@ function auditLocalCompositeActions( analysisBySource.set(source, analysis); return analysis; }; - const findings = []; - for (const execution of workflowExecutionStates(workflowSources)) { - const { privileged, taintedBindings: workflowTaintedBindings, workflow } = execution; - const syntax = workflowSyntax(workflow.text); + const taintedActionOutputMemo = new Map(); + function localActionStepOutputResolver( + snapshot, + scalarAnchors, + depth = 0, + active = new Set(), + ) { + const symlinkPaths = symlinkPathsBySnapshot.get(snapshot) ?? new Set(); const resolveManifestPath = (reference) => localActionManifestPath( reference, - (manifestPath) => actionBySnapshotPath.has(`${workflow.snapshot}\0${manifestPath}`), + (manifestPath) => actionBySnapshotPath.has(`${snapshot}\0${manifestPath}`), ); - const symlinkPaths = symlinkPathsBySnapshot.get(workflow.snapshot) ?? new Set(); - const taintedActionOutputMemo = new Map(); - function stepOutputResolverFor(scalarAnchors, depth = 0, active = new Set()) { - return (stepGroup, stepTaintedBindings) => { - const outputs = new Set(); - for (const call of localActionCallsFromStepGroup( - stepGroup, - scalarAnchors, - stepTaintedBindings, - )) { - for (const output of taintedOutputsForCall(call, depth, active)) { - outputs.add(output); - } - } - return outputs; - }; - } - function taintedOutputsForCall(call, depth = 0, active = new Set()) { - if (depth > 10 || localActionReferenceUsesSymlink(call.reference, symlinkPaths)) { + const taintedOutputsForCall = (call, callDepth = depth, callActive = active) => { + if ( + callDepth > 10 + || localActionReferenceUsesSymlink(call.reference, symlinkPaths) + ) { return new Set(); } const manifestPath = resolveManifestPath(call.reference); if (!manifestPath) return new Set(); - const action = actionBySnapshotPath.get(`${workflow.snapshot}\0${manifestPath}`); + const action = actionBySnapshotPath.get(`${snapshot}\0${manifestPath}`); if (!action) return new Set(); const analysis = analysisFor(action); const effectiveTaintedBindings = actionInputTaintedBindings( @@ -728,19 +734,24 @@ function auditLocalCompositeActions( call.taintedBindings, call.providedBindings, ); - const stateKey = `${manifestPath}\0${[...effectiveTaintedBindings].sort().join("\0")}`; - if (active.has(stateKey)) return new Set(); + const stateKey = [ + snapshot, + manifestPath, + ...[...effectiveTaintedBindings].sort(), + ].join("\0"); + if (callActive.has(stateKey)) return new Set(); if (taintedActionOutputMemo.has(stateKey)) { return taintedActionOutputMemo.get(stateKey); } - const nestedActive = new Set([...active, stateKey]); + const nestedActive = new Set([...callActive, stateKey]); const stepAnalysis = workflowStepTaintAnalysis( analysis.stepGroups, analysis.syntax.scalarAnchors, effectiveTaintedBindings, - stepOutputResolverFor( + localActionStepOutputResolver( + snapshot, analysis.syntax.scalarAnchors, - depth + 1, + callDepth + 1, nestedActive, ), ); @@ -756,8 +767,38 @@ function auditLocalCompositeActions( .map((binding) => binding.name)); taintedActionOutputMemo.set(stateKey, outputs); return outputs; - } - const workflowStepOutputResolver = stepOutputResolverFor(syntax.scalarAnchors); + }; + return (stepGroup, stepTaintedBindings) => { + const outputs = new Set(); + for (const call of localActionCallsFromStepGroup( + stepGroup, + scalarAnchors, + stepTaintedBindings, + )) { + for (const output of taintedOutputsForCall(call)) outputs.add(output); + } + return outputs; + }; + } + const findings = []; + for (const execution of workflowExecutionStates( + workflowSources, + (source) => localActionStepOutputResolver( + source.snapshot, + workflowSyntax(source.text).scalarAnchors, + ), + )) { + const { privileged, taintedBindings: workflowTaintedBindings, workflow } = execution; + const syntax = workflowSyntax(workflow.text); + const resolveManifestPath = (reference) => localActionManifestPath( + reference, + (manifestPath) => actionBySnapshotPath.has(`${workflow.snapshot}\0${manifestPath}`), + ); + const symlinkPaths = symlinkPathsBySnapshot.get(workflow.snapshot) ?? new Set(); + const workflowStepOutputResolver = localActionStepOutputResolver( + workflow.snapshot, + syntax.scalarAnchors, + ); if (privileged && hasUntrustedPullRequestCheckout( syntax.uncommented, syntax.scalarAnchors, @@ -807,7 +848,7 @@ function auditLocalCompositeActions( workflowStepOutputResolver, )) { findings.push(workflowFinding({ - message: "A privileged workflow must not let local action output disable failure handling.", + message: "A privileged workflow must not let local action output control execution conditions or disable failure handling.", path: workflow.path, ruleId: "workflow-privileged-untrusted-control-flow", severity: "error", @@ -921,7 +962,11 @@ function auditLocalCompositeActions( analysis.stepGroups, analysis.syntax.scalarAnchors, effectiveTaintedBindings, - stepOutputResolverFor(analysis.syntax.scalarAnchors, depth + 1), + localActionStepOutputResolver( + workflow.snapshot, + analysis.syntax.scalarAnchors, + depth + 1, + ), )) { if (localActionReferenceUsesSymlink(call.reference, symlinkPaths)) { findings.push(workflowFinding({ @@ -944,7 +989,11 @@ function auditLocalCompositeActions( analysis.stepGroups, analysis.syntax.scalarAnchors, effectiveTaintedBindings, - stepOutputResolverFor(analysis.syntax.scalarAnchors, depth + 1), + localActionStepOutputResolver( + workflow.snapshot, + analysis.syntax.scalarAnchors, + depth + 1, + ), ).stepContexts; if (privileged && actionStepContexts.some(({ stepGroup, taintedBindings }) => ( stepGroupHasUntrustedCheckout( @@ -990,7 +1039,7 @@ function auditLocalCompositeActions( analysis.syntax.scalarAnchors, )) { findings.push(workflowFinding({ - message: "A local composite action called from a privileged workflow must not let untrusted input disable failure handling.", + message: "A local composite action called from a privileged workflow must not let untrusted input control execution conditions or disable failure handling.", path: action.path, ruleId: "workflow-privileged-untrusted-control-flow", severity: "error", @@ -1203,13 +1252,19 @@ function reusableWorkflowOutputBindings(text, scalarAnchors) { return bindings; } -function workflowJobOutputTaintedBindings(text, scalarAnchors, inheritedTaintedBindings) { +function workflowJobOutputTaintedBindings( + text, + scalarAnchors, + inheritedTaintedBindings, + stepOutputResolver, +) { const jobGroups = workflowJobPropertyGroups(text, scalarAnchors); const contexts = workflowJobTaintContexts( jobGroups, workflowRootMappingBindings(text, "env", "env", scalarAnchors), scalarAnchors, inheritedTaintedBindings, + stepOutputResolver, ); const taintedBindings = new Set(); for (const group of jobGroups) { @@ -1365,12 +1420,13 @@ function workflowRootMappingBindings(text, propertyName, namespace, scalarAnchor )); } -function reusableCallTaintedBindings(call, callerTaintedBindings) { +function reusableCallTaintedBindings(call, callerTaintedBindings, stepOutputResolver) { const callSiteTaintedBindings = workflowJobTaintContexts( call.jobGroups, call.workflowEnvBindings, call.scalarAnchors, callerTaintedBindings, + stepOutputResolver, ).get(call.jobGroup) ?? new Set(callerTaintedBindings); const taintedBindings = new Set(); for (const binding of call.bindings) { @@ -1538,18 +1594,8 @@ function workflowStepTaintAnalysis( } } if (!stepId) continue; - const taintedEnvironmentVariables = new Set([...stepTaintedBindings].flatMap((binding) => ( - binding.startsWith("env.") && binding !== "env.*" - ? [binding.slice("env.".length).toLowerCase()] - : [] - ))); const runOutputIsTainted = runSources.some((runSource) => ( - isUntrustedReusableValue(runSource, stepTaintedBindings) - || shellSourceReferencesTaintedVariable( - runSource, - taintedEnvironmentVariables, - stepTaintedBindings.has("env.*"), - ) + githubOutputWriteIsTainted(runSource, stepTaintedBindings) )); const actionOutputIsTainted = workflowMappingBindings( stepGroup, @@ -1598,6 +1644,30 @@ function githubEnvironmentWriteBindings(runSource, taintedBindings) { return names.size > 0 ? [...names] : ["*"]; } +function githubOutputWriteIsTainted(runSource, taintedBindings) { + const taintedVariables = new Set([...taintedBindings].flatMap((binding) => ( + binding.startsWith("env.") && binding !== "env.*" + ? [binding.slice("env.".length).toLowerCase()] + : [] + ))); + for (const segment of shellCommandSegments(runSource)) { + const segmentReferencesTaint = isUntrustedReusableValue(segment, taintedBindings) + || shellSourceReferencesTaintedVariable( + segment, + taintedVariables, + taintedBindings.has("env.*"), + ); + const assignment = /^\s*(?:(?:export|local|readonly)\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=|^\s*\$([A-Za-z_][A-Za-z0-9_]*)\s*=/iu.exec(segment); + if (assignment && segmentReferencesTaint) { + taintedVariables.add((assignment[1] ?? assignment[2]).toLowerCase()); + } + if (/GITHUB_OUTPUT/iu.test(segment) + && /(?:>>?|\b(?:Add-Content|Out-File|Set-Content|tee)\b)/iu.test(segment) + && segmentReferencesTaint) return true; + } + return false; +} + function mergeTaintedBindings(...bindingSets) { return new Set(bindingSets.flatMap((bindings) => [...bindings])); } @@ -1856,7 +1926,7 @@ function auditWorkflowText(workflowPath, text, source = "tracked-file") { if (hasPrivilegedTrigger && hasUntrustedWorkflowFailureHandling(uncommented, scalarAnchors)) { findings.push(workflowFinding({ - message: "A privileged workflow must not let untrusted event data disable failure handling.", + message: "A privileged workflow must not let untrusted event data control execution conditions or disable failure handling.", path: workflowPath, ruleId: "workflow-privileged-untrusted-control-flow", severity: "error", @@ -3140,12 +3210,17 @@ function hasUntrustedWorkflowFailureHandling( return jobGroups.some((jobGroup) => { const analysis = jobTaintAnalyses.get(jobGroup); const jobFailureHandlingIsUntrusted = jobGroup.properties - .filter(({ entry }) => entry.key.toLowerCase() === "continue-on-error") - .map(({ entry }) => resolveYamlScalarValue(entry.value, scalarAnchors)) - .some((value) => isUntrustedReusableValue( - value, - analysis?.configurationTaintedBindings ?? taintedBindings, - )); + .filter(({ entry }) => ["continue-on-error", "if"].includes( + entry.key.toLowerCase(), + )) + .some(({ entry }) => { + const value = resolveYamlScalarValue(entry.value, scalarAnchors); + const effectiveTaintedBindings = analysis?.configurationTaintedBindings + ?? taintedBindings; + return entry.key.toLowerCase() === "if" + ? isUntrustedWorkflowCondition(value, effectiveTaintedBindings) + : isUntrustedReusableValue(value, effectiveTaintedBindings); + }); return jobFailureHandlingIsUntrusted || stepContextsHaveUntrustedFailureHandling( analysis?.stepContexts ?? [], @@ -3179,9 +3254,110 @@ function hasUntrustedWorkflowEnvironmentSelection( function stepContextsHaveUntrustedFailureHandling(stepContexts, scalarAnchors) { return stepContexts.some(({ stepGroup, taintedBindings }) => stepGroup.properties - .filter(({ entry }) => entry.key.toLowerCase() === "continue-on-error") - .map(({ entry }) => resolveYamlScalarValue(entry.value, scalarAnchors)) - .some((value) => isUntrustedReusableValue(value, taintedBindings))); + .filter(({ entry }) => ["continue-on-error", "if"].includes( + entry.key.toLowerCase(), + )) + .some(({ entry }) => { + const value = resolveYamlScalarValue(entry.value, scalarAnchors); + return entry.key.toLowerCase() === "if" + ? isUntrustedWorkflowCondition(value, taintedBindings) + : isUntrustedReusableValue(value, taintedBindings); + })); +} + +function isUntrustedWorkflowCondition(value, taintedBindings) { + if (!isUntrustedReusableValue(value, taintedBindings)) return false; + return !isAuthorizedIssueCommentCommandCondition(value, taintedBindings); +} + +function isAuthorizedIssueCommentCommandCondition(value, taintedBindings) { + const normalized = normalizeExpressionPropertyAccess(value) + .replace(/^\s*\$\{\{([\s\S]*)\}\}\s*$/u, "$1") + .trim(); + if (splitExpressionAtTopLevel(normalized, "||").length > 1) return false; + const conjuncts = splitExpressionAtTopLevel(normalized, "&&"); + const hasTrustedAssociationGuard = conjuncts.some((conjunct) => { + const clauses = splitExpressionAtTopLevel( + stripExpressionParentheses(conjunct), + "||", + ); + return clauses.length > 0 && clauses.every((clause) => ( + /^github\.event\.comment\.author_association\s*==\s*(["'])(?:MEMBER|OWNER|COLLABORATOR)\1$/iu + .test(stripExpressionParentheses(clause)) + )); + }); + if (!hasTrustedAssociationGuard) return false; + const withoutCommandFilters = normalized.replace( + /\bstartsWith\s*\(\s*github\.event\.comment\.body\s*,\s*(["'])(?:[/@][A-Za-z0-9][^"'\\\r\n]*)\1\s*\)/giu, + "true", + ); + return !isUntrustedReusableValue(withoutCommandFilters, taintedBindings); +} + +function splitExpressionAtTopLevel(value, operator) { + const parts = []; + let current = ""; + let depth = 0; + let quote; + for (let index = 0; index < value.length; index += 1) { + const character = value[index]; + if (character === "\\" && quote !== "'") { + current += character; + if (index + 1 < value.length) { + current += value[index + 1]; + index += 1; + } + continue; + } + if (character === "'" || character === '"') { + if (quote === character) quote = undefined; + else if (!quote) quote = character; + current += character; + continue; + } + if (!quote && character === "(") depth += 1; + if (!quote && character === ")") depth -= 1; + if (!quote && depth === 0 && value.startsWith(operator, index)) { + parts.push(current.trim()); + current = ""; + index += operator.length - 1; + continue; + } + current += character; + } + parts.push(current.trim()); + return parts.filter(Boolean); +} + +function stripExpressionParentheses(value) { + let stripped = value.trim(); + while (stripped.startsWith("(") && stripped.endsWith(")")) { + let depth = 0; + let quote; + let wrapsCompleteValue = true; + for (let index = 0; index < stripped.length; index += 1) { + const character = stripped[index]; + if (character === "\\" && quote !== "'") { + index += 1; + continue; + } + if (character === "'" || character === '"') { + if (quote === character) quote = undefined; + else if (!quote) quote = character; + continue; + } + if (quote) continue; + if (character === "(") depth += 1; + if (character === ")") depth -= 1; + if (depth === 0 && index < stripped.length - 1) { + wrapsCompleteValue = false; + break; + } + } + if (!wrapsCompleteValue || depth !== 0) break; + stripped = stripped.slice(1, -1).trim(); + } + return stripped; } function stepContextsHaveUntrustedScriptInterpolation(stepContexts, scalarAnchors) { @@ -3236,9 +3412,9 @@ function shellRunEvaluatesTaintedVariable(runSource, taintedBindings) { : [] ))); const anyEnvironmentVariableTainted = taintedBindings.has("env.*"); - const evaluatorCommand = /^\s*(?:(?:builtin|command|exec)\s+)?(?:eval\b|(?:(?:\/[^/\s]+)*\/)?(?:bash|dash|fish|ksh|sh|zsh)\b[^;&|]*\s-c(?:\s|$)|(?:iex|invoke-expression)\b|(?:(?:\/[^/\s]+)*\/)?(?:powershell|pwsh)(?:\.exe)?\b[^;&|]*\s-(?:c|command)(?:\s|$))/iu; + const evaluatorCommand = /^\s*(?:(?:builtin|command|exec)\s+)?(?:eval\b|(?:(?:\/[^/\s]+)*\/)?(?:bash|dash|fish|ksh|sh|zsh)\b[^;&|]*\s-c(?:\s|$)|(?:(?:\/[^/\s]+)*\/)?(?:node|perl|python\d*|ruby)\b[^;&|]*\s-(?:c|e)(?:\s|$)|(?:(?:\/[^/\s]+)*\/)?php\b[^;&|]*\s-r(?:\s|$)|(?:(?:\/[^/\s]+)*\/)?deno\b[^;&|]*\beval(?:\s|$)|(?:iex|invoke-expression)\b|(?:(?:\/[^/\s]+)*\/)?(?:powershell|pwsh)(?:\.exe)?\b[^;&|]*\s-(?:c|command)(?:\s|$))/iu; const stdinInterpreterCommand = /^\s*(?:(?:command|exec)\s+)?(?:(?:\/usr\/bin\/)?env\s+(?:-[^\s]+\s+)*)?(?:(?:\/[^/\s]+)*\/)?(?:bash|dash|fish|ksh|powershell|pwsh|sh|zsh)(?:\.exe)?(?:\s|$)/iu; - const hereInputInterpreterCommand = /^\s*(?:(?:command|exec)\s+)?(?:(?:\/usr\/bin\/)?env\s+(?:-[^\s]+\s+)*)?(?:(?:\/[^/\s]+)*\/)?(?:bash|dash|fish|ksh|powershell|pwsh|sh|zsh)(?:\.exe)?(?:\s+-[^\s]+)*\s+<<<(?:\s|$)/iu; + const hereInputInterpreterCommand = /^\s*(?:(?:command|exec)\s+)?(?:(?:\/usr\/bin\/)?env\s+(?:-[^\s]+\s+)*)?(?:(?:\/[^/\s]+)*\/)?(?:bash|dash|fish|ksh|powershell|pwsh|sh|zsh)(?:\.exe)?(?:\s+-[^\s]+)*\s+<< word.includes("/")).at(-1); + if (explicitPath) return explicitPath; + const reader = path.posix.basename(bodyWords[0] ?? "").toLowerCase(); + if (["awk", "cat", "grep", "head", "sed", "tail"].includes(reader)) { + return bodyWords.slice(1).filter((word) => ( + !word.startsWith("-") && !/^[<>&]/u.test(word) + )).at(-1); + } + } + return undefined; + } if (/^(?:\d*)<$/u.test(argument)) return words[cursor]; const redirectedSource = /^(?:\d*)<([^<].*)$/u.exec(argument)?.[1]; if (redirectedSource) return redirectedSource; @@ -3658,7 +3850,7 @@ function isUntrustedCheckoutInput(key, value, taintedBindings = new Set()) { } function isUntrustedPullRequestRef(value) { - return /(?:github\.head_ref|github\.event\.(?:comment\.body|discussion\.(?:body|title)|issue\.(?:body|number|title))|pull_request\.(?:body|head|merge_commit_sha|title)|refs\/pull\/|workflow_run\.(?:head_sha|id|pull_requests\s*\[\s*\d+\s*\]\s*\.\s*head))/iu + return /(?:github\.head_ref|github\.event\.(?:comment\.body|discussion\.(?:body|title)|issue\.(?:body|number|title))|pull_request\.(?:body|head|merge_commit_sha|title)|refs\/pull\/|workflow_run\.(?:head_branch|head_sha|id|pull_requests\s*\[\s*\d+\s*\]\s*\.\s*head))/iu .test(normalizeExpressionPropertyAccess(value)); } diff --git a/public-source-release-audit/tests/public-source-release-audit.test.mjs b/public-source-release-audit/tests/public-source-release-audit.test.mjs index b80c501..05a29bf 100644 --- a/public-source-release-audit/tests/public-source-release-audit.test.mjs +++ b/public-source-release-audit/tests/public-source-release-audit.test.mjs @@ -3357,7 +3357,9 @@ test("local composite outputs return inherited taint to callers", () => { " steps:", " - id: export", " shell: bash", - " run: echo \"ref=$PR_REF\" >> \"$GITHUB_OUTPUT\"", + " run: |", + " OUTPUT_REF=$PR_REF", + " echo \"ref=$OUTPUT_REF\" >> \"$GITHUB_OUTPUT\"", "", ].join("\n")); commitAll(repoRoot, "add composite output checkout"); @@ -3697,6 +3699,353 @@ test("standard GitHub-hosted runner labels remain trusted", () => { assert.equal(audit.result.warningCount, 0); }); +test("composite outputs propagate through reusable workflow returns", () => { + const repoRoot = makeRepository(); + const headExpression = ["$", "{{ github.head_ref }}"].join(""); + const inputExpression = ["$", "{{ inputs.ref }}"].join(""); + const jobOutputExpression = ["$", "{{ jobs.source.outputs.ref }}"].join(""); + const stepOutputExpression = ["$", "{{ steps.composite.outputs.ref }}"].join(""); + const actionOutputExpression = ["$", "{{ steps.export.outputs.ref }}"].join(""); + const returnedExpression = ["$", "{{ needs.source.outputs.ref }}"].join(""); + write(repoRoot, ".github/workflows/composite-return-caller.yml", [ + "name: composite return caller", + "on: pull_request_target", + "permissions: read-all", + "jobs:", + " source:", + " uses: ./.github/workflows/composite-return-callee.yml", + " with:", + " ref: " + headExpression, + " inspect:", + " needs: source", + " runs-on: ubuntu-latest", + " steps:", + " - uses: actions/checkout@" + "a".repeat(40), + " with:", + " ref: " + returnedExpression, + "", + ].join("\n")); + write(repoRoot, ".github/workflows/composite-return-callee.yml", [ + "name: composite return callee", + "on:", + " workflow_call:", + " inputs:", + " ref:", + " required: true", + " type: string", + " outputs:", + " ref:", + " value: " + jobOutputExpression, + "permissions: read-all", + "jobs:", + " source:", + " runs-on: ubuntu-latest", + " env:", + " PR_REF: " + inputExpression, + " outputs:", + " ref: " + stepOutputExpression, + " steps:", + " - id: composite", + " uses: ./.github/actions/reusable-output-ref", + "", + ].join("\n")); + write(repoRoot, ".github/actions/reusable-output-ref/action.yml", [ + "name: reusable output ref", + "outputs:", + " ref:", + " value: " + actionOutputExpression, + "runs:", + " using: composite", + " steps:", + " - id: export", + " shell: bash", + " run: echo \"ref=$PR_REF\" >> \"$GITHUB_OUTPUT\"", + "", + ].join("\n")); + commitAll(repoRoot, "add composite reusable output checkout"); + + const audit = runAudit(repoRoot); + + assert.equal(audit.status, 1); + assertFinding( + audit.result, + "workflow-privileged-untrusted-checkout", + ".github/workflows/composite-return-caller.yml", + ); +}); + +test("attached here-strings cannot feed tainted text into shell interpreters", () => { + const repoRoot = makeRepository(); + const bodyExpression = ["$", "{{ github.event.comment.body }}"].join(""); + write(repoRoot, ".github/workflows/comment-attached-here-shell.yml", [ + "name: comment attached here shell", + "on: issue_comment", + "permissions: read-all", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - env:", + " COMMAND: " + bodyExpression, + " run: bash <<<\"$COMMAND\"", + "", + ].join("\n")); + commitAll(repoRoot, "add attached tainted shell here-string"); + + const audit = runAudit(repoRoot); + + assert.equal(audit.status, 1); + assertFinding( + audit.result, + "workflow-privileged-untrusted-script-interpolation", + ".github/workflows/comment-attached-here-shell.yml", + ); +}); + +test("artifact execution recognizes interpreter process substitution", () => { + const repoRoot = makeRepository(); + const runIdExpression = ["$", "{{ github.event.workflow_run.id }}"].join(""); + write(repoRoot, ".github/workflows/workflow-run-artifact-process-substitution.yml", [ + "name: workflow run artifact process substitution", + "on:", + " workflow_run:", + " workflows: [verify]", + " types: [completed]", + "permissions: read-all", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - uses: actions/download-artifact@" + "a".repeat(40), + " with:", + " run-id: " + runIdExpression, + " path: payload", + " - run: bash <(cat payload/run.sh)", + "", + ].join("\n")); + write(repoRoot, ".github/workflows/workflow-run-artifact-inline-substitution.yml", [ + "name: workflow run artifact inline substitution", + "on:", + " workflow_run:", + " workflows: [verify]", + " types: [completed]", + "permissions: read-all", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - uses: actions/download-artifact@" + "a".repeat(40), + " with:", + " run-id: " + runIdExpression, + " - run: bash <(printf 'echo trusted')", + "", + ].join("\n")); + commitAll(repoRoot, "add artifact process substitution execution"); + + const audit = runAudit(repoRoot); + + assert.equal(audit.status, 1); + assertFinding( + audit.result, + "workflow-privileged-untrusted-artifact-execution", + ".github/workflows/workflow-run-artifact-process-substitution.yml", + ); + assert.equal(audit.result.findings.some((finding) => ( + finding.ruleId === "workflow-privileged-untrusted-artifact-execution" + && finding.path === ".github/workflows/workflow-run-artifact-inline-substitution.yml" + )), false); +}); + +test("privileged workflows reject attacker-controlled job and step conditions", () => { + const repoRoot = makeRepository(); + const conditionExpression = [ + "$", + "{{ !fromJSON(github.event.comment.body) }}", + ].join(""); + for (const scope of ["job", "step"]) { + write(repoRoot, `.github/workflows/comment-${scope}-condition.yml`, [ + `name: comment ${scope} condition`, + "on: issue_comment", + "permissions: read-all", + "jobs:", + " deploy:", + ...(scope === "job" ? [" if: " + conditionExpression] : []), + " runs-on: ubuntu-latest", + " steps:", + ...(scope === "step" ? [" - if: " + conditionExpression] : [" - run: echo verify"]), + ...(scope === "step" ? [" run: echo deploy"] : []), + "", + ].join("\n")); + } + write(repoRoot, ".github/workflows/comment-authorized-command.yml", [ + "name: comment authorized command", + "on: issue_comment", + "permissions: read-all", + "jobs:", + " review:", + " if: |", + " (", + " github.event.comment.author_association == 'MEMBER'", + " || github.event.comment.author_association == 'OWNER'", + " )", + " && startsWith(github.event.comment.body, '/review')", + " runs-on: ubuntu-latest", + " steps:", + " - run: echo review", + "", + ].join("\n")); + write(repoRoot, ".github/workflows/comment-command-or-bypass.yml", [ + "name: comment command or bypass", + "on: issue_comment", + "permissions: read-all", + "jobs:", + " deploy:", + " if: github.event.comment.author_association == 'MEMBER' && startsWith(github.event.comment.body, '/review') || true", + " runs-on: ubuntu-latest", + " steps:", + " - run: echo deploy", + "", + ].join("\n")); + commitAll(repoRoot, "add attacker-controlled workflow conditions"); + + const audit = runAudit(repoRoot); + + assert.equal(audit.status, 1); + for (const scope of ["job", "step"]) { + assertFinding( + audit.result, + "workflow-privileged-untrusted-control-flow", + `.github/workflows/comment-${scope}-condition.yml`, + ); + } + assertFinding( + audit.result, + "workflow-privileged-untrusted-control-flow", + ".github/workflows/comment-command-or-bypass.yml", + ); + assert.equal(audit.result.findings.some((finding) => ( + finding.ruleId === "workflow-privileged-untrusted-control-flow" + && finding.path === ".github/workflows/comment-authorized-command.yml" + )), false); +}); + +test("composite outputs only inherit taint from their actual output writes", () => { + const repoRoot = makeRepository(); + const headExpression = ["$", "{{ github.head_ref }}"].join(""); + const outputExpression = ["$", "{{ steps.source.outputs.ref }}"].join(""); + const actionOutputExpression = ["$", "{{ steps.export.outputs.ref }}"].join(""); + write(repoRoot, ".github/workflows/constant-composite-output.yml", [ + "name: constant composite output", + "on: pull_request_target", + "permissions: read-all", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - env:", + " SOURCE_REF: " + headExpression, + " run: echo \"PR_REF=$SOURCE_REF\" >> \"$GITHUB_ENV\"", + " - id: source", + " uses: ./.github/actions/constant-output-ref", + " - uses: actions/checkout@" + "a".repeat(40), + " with:", + " ref: " + outputExpression, + "", + ].join("\n")); + write(repoRoot, ".github/actions/constant-output-ref/action.yml", [ + "name: constant output ref", + "outputs:", + " ref:", + " value: " + actionOutputExpression, + "runs:", + " using: composite", + " steps:", + " - id: export", + " shell: bash", + " run: |", + " printf '%s\\n' \"$PR_REF\"", + " echo \"ref=main\" >> \"$GITHUB_OUTPUT\"", + "", + ].join("\n")); + commitAll(repoRoot, "add constant composite output checkout"); + + const audit = runAudit(repoRoot); + + assert.equal(audit.status, 0); + assert.equal(audit.result.findings.some((finding) => ( + finding.ruleId === "workflow-privileged-untrusted-checkout" + )), false); +}); + +test("tainted text cannot reach language runtime evaluators", () => { + const repoRoot = makeRepository(); + const bodyExpression = ["$", "{{ github.event.comment.body }}"].join(""); + for (const [runtime, command] of [ + ["python", "python -c \"$COMMAND\""], + ["node", "node -e \"$COMMAND\""], + ["perl", "perl -e \"$COMMAND\""], + ["ruby", "ruby -e \"$COMMAND\""], + ]) { + write(repoRoot, `.github/workflows/comment-${runtime}-eval.yml`, [ + `name: comment ${runtime} eval`, + "on: issue_comment", + "permissions: read-all", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - env:", + " COMMAND: " + bodyExpression, + " run: " + command, + "", + ].join("\n")); + } + commitAll(repoRoot, "add tainted language runtime evaluators"); + + const audit = runAudit(repoRoot); + + assert.equal(audit.status, 1); + for (const runtime of ["python", "node", "perl", "ruby"]) { + assertFinding( + audit.result, + "workflow-privileged-untrusted-script-interpolation", + `.github/workflows/comment-${runtime}-eval.yml`, + ); + } +}); + +test("workflow_run head branches are untrusted script data", () => { + const repoRoot = makeRepository(); + const headBranchExpression = [ + "$", + "{{ github.event.workflow_run.head_branch }}", + ].join(""); + write(repoRoot, ".github/workflows/workflow-run-head-branch-script.yml", [ + "name: workflow run head branch script", + "on:", + " workflow_run:", + " workflows: [verify]", + " types: [completed]", + "permissions: read-all", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - run: echo \"" + headBranchExpression + "\"", + "", + ].join("\n")); + commitAll(repoRoot, "add workflow run head branch script"); + + const audit = runAudit(repoRoot); + + assert.equal(audit.status, 1); + assertFinding( + audit.result, + "workflow-privileged-untrusted-script-interpolation", + ".github/workflows/workflow-run-head-branch-script.yml", + ); +}); + test("unsafe public workflow execution is release-blocking", () => { const repoRoot = makeRepository(); write(repoRoot, ".github/workflows/unsafe.yml", [ From bbeb73c171734ae843cdcbed5988acfd4439407d Mon Sep 17 00:00:00 2001 From: Vladimir Date: Sat, 22 Aug 2026 08:28:34 +0800 Subject: [PATCH 26/37] Close evaluator and event-object taint gaps --- .../scripts/public-source-release-audit.mjs | 74 ++++++++++++------- .../public-source-release-audit.test.mjs | 74 ++++++++++++++++++- 2 files changed, 119 insertions(+), 29 deletions(-) diff --git a/public-source-release-audit/scripts/public-source-release-audit.mjs b/public-source-release-audit/scripts/public-source-release-audit.mjs index b023148..c9d5159 100644 --- a/public-source-release-audit/scripts/public-source-release-audit.mjs +++ b/public-source-release-audit/scripts/public-source-release-audit.mjs @@ -1657,9 +1657,9 @@ function githubOutputWriteIsTainted(runSource, taintedBindings) { taintedVariables, taintedBindings.has("env.*"), ); - const assignment = /^\s*(?:(?:export|local|readonly)\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=|^\s*\$([A-Za-z_][A-Za-z0-9_]*)\s*=/iu.exec(segment); - if (assignment && segmentReferencesTaint) { - taintedVariables.add((assignment[1] ?? assignment[2]).toLowerCase()); + const assignmentName = shellAssignmentName(segment); + if (assignmentName && segmentReferencesTaint) { + taintedVariables.add(assignmentName); } if (/GITHUB_OUTPUT/iu.test(segment) && /(?:>>?|\b(?:Add-Content|Out-File|Set-Content|tee)\b)/iu.test(segment) @@ -1672,6 +1672,11 @@ function mergeTaintedBindings(...bindingSets) { return new Set(bindingSets.flatMap((bindings) => [...bindings])); } +function shellAssignmentName(source) { + const assignment = /^\s*(?:(?:declare|export|local|readonly|typeset)(?:\s+-[^\s]+)*\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=|^\s*\$([A-Za-z_][A-Za-z0-9_]*)\s*=/iu.exec(source); + return (assignment?.[1] ?? assignment?.[2])?.toLowerCase(); +} + function workflowLocalActionCalls( text, scalarAnchors, @@ -1734,6 +1739,7 @@ function localActionCallsFromStepGroup(stepGroup, scalarAnchors, stepTaintedBind function isUntrustedReusableValue(value, taintedBindings) { return isUntrustedPullRequestRef(value) || isUntrustedPublicEventIdentity(value) + || isUntrustedPublicEventObject(value) || /(?:github\.event\.forkee|pull_request\.head\.repo|workflow_run\.(?:head_repository|pull_requests\s*\[\s*\d+\s*\]\s*\.\s*head\s*\.\s*repo))(?:\.|\b)/iu .test(normalizeExpressionPropertyAccess(value)) || valueReferencesTaintedBinding(value, taintedBindings); @@ -3412,20 +3418,20 @@ function shellRunEvaluatesTaintedVariable(runSource, taintedBindings) { : [] ))); const anyEnvironmentVariableTainted = taintedBindings.has("env.*"); - const evaluatorCommand = /^\s*(?:(?:builtin|command|exec)\s+)?(?:eval\b|(?:(?:\/[^/\s]+)*\/)?(?:bash|dash|fish|ksh|sh|zsh)\b[^;&|]*\s-c(?:\s|$)|(?:(?:\/[^/\s]+)*\/)?(?:node|perl|python\d*|ruby)\b[^;&|]*\s-(?:c|e)(?:\s|$)|(?:(?:\/[^/\s]+)*\/)?php\b[^;&|]*\s-r(?:\s|$)|(?:(?:\/[^/\s]+)*\/)?deno\b[^;&|]*\beval(?:\s|$)|(?:iex|invoke-expression)\b|(?:(?:\/[^/\s]+)*\/)?(?:powershell|pwsh)(?:\.exe)?\b[^;&|]*\s-(?:c|command)(?:\s|$))/iu; + const evaluatorCommand = /^\s*(?:(?:builtin|command|exec)\s+)?(?:(?:\/usr\/bin\/)?env\s+(?:(?:-[^\s]+|[A-Za-z_][A-Za-z0-9_]*=[^\s]+)\s+)*)?(?:eval\b|(?:(?:\/[^/\s]+)*\/)?(?:bash|dash|fish|ksh|sh|zsh)\b[^;&|]*\s-c(?:\s+|(?=[^-\s])|$)|(?:(?:\/[^/\s]+)*\/)?(?:node|perl|python(?:\d+(?:\.\d+)*)?|ruby)\b[^;&|]*\s-(?:c|e)(?:\s+|(?=[^-\s])|$)|(?:(?:\/[^/\s]+)*\/)?php\b[^;&|]*\s-r(?:\s+|(?=[^-\s])|$)|(?:(?:\/[^/\s]+)*\/)?deno\b[^;&|]*\beval(?:\s|$)|(?:iex|invoke-expression)\b|(?:(?:\/[^/\s]+)*\/)?(?:powershell|pwsh)(?:\.exe)?\b[^;&|]*\s-(?:command|c)(?:\s+|(?=[^-\s])|$))/iu; const stdinInterpreterCommand = /^\s*(?:(?:command|exec)\s+)?(?:(?:\/usr\/bin\/)?env\s+(?:-[^\s]+\s+)*)?(?:(?:\/[^/\s]+)*\/)?(?:bash|dash|fish|ksh|powershell|pwsh|sh|zsh)(?:\.exe)?(?:\s|$)/iu; const hereInputInterpreterCommand = /^\s*(?:(?:command|exec)\s+)?(?:(?:\/usr\/bin\/)?env\s+(?:-[^\s]+\s+)*)?(?:(?:\/[^/\s]+)*\/)?(?:bash|dash|fish|ksh|powershell|pwsh|sh|zsh)(?:\.exe)?(?:\s+-[^\s]+)*\s+<< word.includes("/")).at(-1); - if (explicitPath) return explicitPath; - const reader = path.posix.basename(bodyWords[0] ?? "").toLowerCase(); - if (["awk", "cat", "grep", "head", "sed", "tail"].includes(reader)) { - return bodyWords.slice(1).filter((word) => ( - !word.startsWith("-") && !/^[<>&]/u.test(word) - )).at(-1); - } - } - return undefined; + return shellProcessSubstitutionArtifactSource([ + argument, + ...words.slice(cursor), + ]); } if (/^(?:\d*)<$/u.test(argument)) return words[cursor]; const redirectedSource = /^(?:\d*)<([^<].*)$/u.exec(argument)?.[1]; @@ -3644,6 +3644,21 @@ function shellArtifactExecutionSource(segment) { return command.includes("/") ? command : undefined; } +function shellProcessSubstitutionArtifactSource(words) { + const body = /^<\(([\s\S]*)\)$/u.exec(words.join(" "))?.[1]; + if (!body) return undefined; + const bodyWords = shellCommandWords(body); + const explicitPath = bodyWords.filter((word) => word.includes("/")).at(-1); + if (explicitPath) return explicitPath; + const reader = path.posix.basename(bodyWords[0] ?? "").toLowerCase(); + if (!["awk", "cat", "grep", "head", "sed", "tail"].includes(reader)) { + return undefined; + } + return bodyWords.slice(1).filter((word) => ( + !word.startsWith("-") && !/^[<>&]/u.test(word) + )).at(-1); +} + function shellCommandWords(source) { const words = []; let current = ""; @@ -3780,14 +3795,14 @@ function shellRunGitTaintAnalysis(runSource, taintedBindings) { let fetchedHeadTainted = taintedBindings.has("git.fetch_head"); let taintsFetchHead = false; for (const line of lines) { - const assignment = /^\s*(?:(?:export|local|readonly)\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=|^\s*\$([A-Za-z_][A-Za-z0-9_]*)\s*=/iu.exec(line); - if (assignment && (isUntrustedReusableValue(line, taintedBindings) + const assignmentName = shellAssignmentName(line); + if (assignmentName && (isUntrustedReusableValue(line, taintedBindings) || shellSourceReferencesTaintedVariable( line, taintedVariables, anyEnvironmentVariableTainted, ))) { - taintedVariables.add((assignment[1] ?? assignment[2]).toLowerCase()); + taintedVariables.add(assignmentName); } const lineIsTainted = isUntrustedReusableValue(line, taintedBindings) || shellSourceReferencesTaintedVariable( @@ -3839,6 +3854,7 @@ function isUntrustedCheckoutInput(key, value, taintedBindings = new Set()) { if (!["ref", "repository"].includes(key.toLowerCase())) return false; if (valueReferencesTaintedBinding(value, taintedBindings)) return true; if (isUntrustedPublicEventIdentity(value)) return true; + if (isUntrustedPublicEventObject(value)) return true; if (/github\.event\.(?:comment\.body|discussion\.(?:body|title)|issue\.(?:body|title))(?:\.|\b)/iu.test( normalizeExpressionPropertyAccess(value), )) return true; @@ -3850,10 +3866,16 @@ function isUntrustedCheckoutInput(key, value, taintedBindings = new Set()) { } function isUntrustedPullRequestRef(value) { - return /(?:github\.head_ref|github\.event\.(?:comment\.body|discussion\.(?:body|title)|issue\.(?:body|number|title))|pull_request\.(?:body|head|merge_commit_sha|title)|refs\/pull\/|workflow_run\.(?:head_branch|head_sha|id|pull_requests\s*\[\s*\d+\s*\]\s*\.\s*head))/iu + return /(?:github\.head_ref|github\.event\.(?:comment\.body|discussion\.(?:body|title)|issue\.(?:body|number|title))|pull_request\.(?:body|head|merge_commit_sha|title)|refs\/pull\/|workflow_run\.(?:head_branch|head_commit|head_sha|id|pull_requests\s*\[\s*\d+\s*\]\s*\.\s*head))/iu .test(normalizeExpressionPropertyAccess(value)); } +function isUntrustedPublicEventObject(value) { + const normalized = normalizeExpressionPropertyAccess(value); + return /(?:github\.event\b(?!\s*\.)|github\.event\.(?:comment|discussion|forkee|issue|pull_request|sender|workflow_run(?:\.head_commit)?)\b(?!\s*\.))/iu + .test(normalized); +} + function isUntrustedPublicEventIdentity(value) { return /(?:github\.(?:actor|triggering_actor)|github\.event\.(?:sender\.login|(?:comment|discussion|issue|pull_request)\.user\.login|workflow_run\.actor\.login))(?:\.|\b)/iu .test(normalizeExpressionPropertyAccess(value)); diff --git a/public-source-release-audit/tests/public-source-release-audit.test.mjs b/public-source-release-audit/tests/public-source-release-audit.test.mjs index 05a29bf..0271d6d 100644 --- a/public-source-release-audit/tests/public-source-release-audit.test.mjs +++ b/public-source-release-audit/tests/public-source-release-audit.test.mjs @@ -2397,6 +2397,10 @@ test("privileged scripts require environment indirection for untrusted values", const unsafeRepo = makeRepository(); const safeRepo = makeRepository(); const bodyExpression = ["$", "{{ github.event.comment.body }}"].join(""); + const roundTripExpression = [ + "$", + "{{ fromJSON(toJSON(github.event.comment)).body }}", + ].join(""); write(unsafeRepo, ".github/workflows/direct-comment-script.yml", [ "name: direct comment script", "on: issue_comment", @@ -2421,6 +2425,17 @@ test("privileged scripts require environment indirection for untrusted values", " run: printf '%s\\n' \"$COMMENT_BODY\"", "", ].join("\n")); + write(unsafeRepo, ".github/workflows/roundtrip-comment-script.yml", [ + "name: roundtrip comment script", + "on: issue_comment", + "permissions: read-all", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - run: echo \"comment=" + roundTripExpression + "\"", + "", + ].join("\n")); commitAll(unsafeRepo, "add direct comment interpolation"); commitAll(safeRepo, "add environment comment handling"); @@ -2433,6 +2448,11 @@ test("privileged scripts require environment indirection for untrusted values", "workflow-privileged-untrusted-script-interpolation", ".github/workflows/direct-comment-script.yml", ); + assertFinding( + unsafeAudit.result, + "workflow-privileged-untrusted-script-interpolation", + ".github/workflows/roundtrip-comment-script.yml", + ); assert.equal(safeAudit.status, 0); }); @@ -3358,7 +3378,8 @@ test("local composite outputs return inherited taint to callers", () => { " - id: export", " shell: bash", " run: |", - " OUTPUT_REF=$PR_REF", + " declare REF=\"$PR_REF\"", + " typeset OUTPUT_REF=\"$REF\"", " echo \"ref=$OUTPUT_REF\" >> \"$GITHUB_OUTPUT\"", "", ].join("\n")); @@ -3840,6 +3861,24 @@ test("artifact execution recognizes interpreter process substitution", () => { " - run: bash <(printf 'echo trusted')", "", ].join("\n")); + write(repoRoot, ".github/workflows/workflow-run-artifact-terminated-substitution.yml", [ + "name: workflow run artifact terminated substitution", + "on:", + " workflow_run:", + " workflows: [verify]", + " types: [completed]", + "permissions: read-all", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - uses: actions/download-artifact@" + "a".repeat(40), + " with:", + " run-id: " + runIdExpression, + " path: payload", + " - run: bash -- <(cat payload/run.sh)", + "", + ].join("\n")); commitAll(repoRoot, "add artifact process substitution execution"); const audit = runAudit(repoRoot); @@ -3850,6 +3889,11 @@ test("artifact execution recognizes interpreter process substitution", () => { "workflow-privileged-untrusted-artifact-execution", ".github/workflows/workflow-run-artifact-process-substitution.yml", ); + assertFinding( + audit.result, + "workflow-privileged-untrusted-artifact-execution", + ".github/workflows/workflow-run-artifact-terminated-substitution.yml", + ); assert.equal(audit.result.findings.some((finding) => ( finding.ruleId === "workflow-privileged-untrusted-artifact-execution" && finding.path === ".github/workflows/workflow-run-artifact-inline-substitution.yml" @@ -3982,6 +4026,7 @@ test("tainted text cannot reach language runtime evaluators", () => { const bodyExpression = ["$", "{{ github.event.comment.body }}"].join(""); for (const [runtime, command] of [ ["python", "python -c \"$COMMAND\""], + ["python-attached", "python -c\"$COMMAND\""], ["node", "node -e \"$COMMAND\""], ["perl", "perl -e \"$COMMAND\""], ["ruby", "ruby -e \"$COMMAND\""], @@ -4005,7 +4050,7 @@ test("tainted text cannot reach language runtime evaluators", () => { const audit = runAudit(repoRoot); assert.equal(audit.status, 1); - for (const runtime of ["python", "node", "perl", "ruby"]) { + for (const runtime of ["python", "python-attached", "node", "perl", "ruby"]) { assertFinding( audit.result, "workflow-privileged-untrusted-script-interpolation", @@ -4014,7 +4059,7 @@ test("tainted text cannot reach language runtime evaluators", () => { } }); -test("workflow_run head branches are untrusted script data", () => { +test("workflow_run head metadata is untrusted script data", () => { const repoRoot = makeRepository(); const headBranchExpression = [ "$", @@ -4034,6 +4079,24 @@ test("workflow_run head branches are untrusted script data", () => { " - run: echo \"" + headBranchExpression + "\"", "", ].join("\n")); + const headCommitMessageExpression = [ + "$", + "{{ github.event.workflow_run.head_commit.message }}", + ].join(""); + write(repoRoot, ".github/workflows/workflow-run-head-commit-script.yml", [ + "name: workflow run head commit script", + "on:", + " workflow_run:", + " workflows: [verify]", + " types: [completed]", + "permissions: read-all", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - run: echo \"" + headCommitMessageExpression + "\"", + "", + ].join("\n")); commitAll(repoRoot, "add workflow run head branch script"); const audit = runAudit(repoRoot); @@ -4044,6 +4107,11 @@ test("workflow_run head branches are untrusted script data", () => { "workflow-privileged-untrusted-script-interpolation", ".github/workflows/workflow-run-head-branch-script.yml", ); + assertFinding( + audit.result, + "workflow-privileged-untrusted-script-interpolation", + ".github/workflows/workflow-run-head-commit-script.yml", + ); }); test("unsafe public workflow execution is release-blocking", () => { From 28ab49f045367701ef52780adc3a3d1b9dbbe7ab Mon Sep 17 00:00:00 2001 From: Vladimir Date: Sat, 22 Aug 2026 08:48:43 +0800 Subject: [PATCH 27/37] Track artifact provenance across execution boundaries --- .../scripts/public-source-release-audit.mjs | 461 ++++++++++++++++-- .../public-source-release-audit.test.mjs | 215 +++++++- 2 files changed, 628 insertions(+), 48 deletions(-) diff --git a/public-source-release-audit/scripts/public-source-release-audit.mjs b/public-source-release-audit/scripts/public-source-release-audit.mjs index c9d5159..6ff4321 100644 --- a/public-source-release-audit/scripts/public-source-release-audit.mjs +++ b/public-source-release-audit/scripts/public-source-release-audit.mjs @@ -799,6 +799,69 @@ function auditLocalCompositeActions( workflow.snapshot, syntax.scalarAnchors, ); + function localActionArtifactExecution( + callScalarAnchors, + depth = 0, + active = new Set(), + ) { + return ( + stepGroup, + stepTaintedBindings, + artifactPaths, + environmentValues, + ) => { + if (depth > 10) return false; + for (const call of localActionCallsFromStepGroup( + stepGroup, + callScalarAnchors, + stepTaintedBindings, + )) { + if (localActionReferenceUsesSymlink(call.reference, symlinkPaths)) continue; + const manifestPath = resolveManifestPath(call.reference); + if (!manifestPath) continue; + const action = actionBySnapshotPath.get( + `${workflow.snapshot}\0${manifestPath}`, + ); + if (!action) continue; + const analysis = analysisFor(action); + const effectiveTaintedBindings = actionInputTaintedBindings( + analysis.inputDefaultBindings, + call.taintedBindings, + call.providedBindings, + ); + const stateKey = [ + workflow.snapshot, + manifestPath, + ...[...effectiveTaintedBindings].sort(), + ].join("\0"); + if (active.has(stateKey)) continue; + const actionStepContexts = workflowStepTaintAnalysis( + analysis.stepGroups, + analysis.syntax.scalarAnchors, + effectiveTaintedBindings, + localActionStepOutputResolver( + workflow.snapshot, + analysis.syntax.scalarAnchors, + depth + 1, + ), + ).stepContexts; + if (stepContextsHaveUntrustedArtifactExecution( + actionStepContexts, + analysis.syntax.scalarAnchors, + { + artifactPaths, + environmentValues, + localActionExecution: localActionArtifactExecution( + analysis.syntax.scalarAnchors, + depth + 1, + new Set([...active, stateKey]), + ), + }, + )) return true; + } + return false; + }; + } if (privileged && hasUntrustedPullRequestCheckout( syntax.uncommented, syntax.scalarAnchors, @@ -818,6 +881,7 @@ function auditLocalCompositeActions( syntax.scalarAnchors, workflowTaintedBindings, workflowStepOutputResolver, + localActionArtifactExecution(syntax.scalarAnchors), )) { findings.push(workflowFinding({ message: "A privileged workflow must not execute untrusted artifacts returned through local action outputs.", @@ -1620,28 +1684,33 @@ function workflowStepTaintAnalysis( } function githubEnvironmentWriteBindings(runSource, taintedBindings) { - const taintedEnvironmentVariables = new Set([...taintedBindings].flatMap((binding) => ( + const taintedVariables = new Set([...taintedBindings].flatMap((binding) => ( binding.startsWith("env.") && binding !== "env.*" ? [binding.slice("env.".length).toLowerCase()] : [] ))); - if (!isUntrustedReusableValue(runSource, taintedBindings) - && !shellSourceReferencesTaintedVariable( - runSource, - taintedEnvironmentVariables, - taintedBindings.has("env.*"), - )) return []; - const writeLines = runSource - .replace(/\r\n?|\u0085|\u2028|\u2029/gu, "\n") - .replace(/\\\n[ \t]*/gu, " ") - .split("\n") - .filter((line) => /GITHUB_ENV/iu.test(line) - && /(?:>>?|\b(?:Add-Content|Out-File|Set-Content|tee)\b)/iu.test(line)); - if (writeLines.length === 0) return []; - const names = new Set(writeLines.flatMap((line) => [ - ...line.matchAll(/(?:^|[\s"'`])([A-Za-z_][A-Za-z0-9_]*)\s*(?:=|<<)/gu), - ].map((match) => match[1].toLowerCase()))); - return names.size > 0 ? [...names] : ["*"]; + const names = new Set(); + for (const segment of shellCommandSegments(runSource)) { + const segmentReferencesTaint = isUntrustedReusableValue(segment, taintedBindings) + || shellSourceReferencesTaintedVariable( + segment, + taintedVariables, + taintedBindings.has("env.*"), + ); + const assignmentName = shellAssignmentName(segment); + if (assignmentName && segmentReferencesTaint) taintedVariables.add(assignmentName); + if (!segmentReferencesTaint + || !/GITHUB_ENV/iu.test(segment) + || !/(?:>>?|\b(?:Add-Content|Out-File|Set-Content|tee)\b)/iu.test(segment)) { + continue; + } + const writtenNames = [...segment.matchAll( + /(?:^|[\s"'`])([A-Za-z_][A-Za-z0-9_]*)\s*(?:=|<<)/gu, + )].map((match) => match[1].toLowerCase()); + if (writtenNames.length === 0) names.add("*"); + for (const name of writtenNames) names.add(name); + } + return [...names]; } function githubOutputWriteIsTainted(runSource, taintedBindings) { @@ -1673,7 +1742,7 @@ function mergeTaintedBindings(...bindingSets) { } function shellAssignmentName(source) { - const assignment = /^\s*(?:(?:declare|export|local|readonly|typeset)(?:\s+-[^\s]+)*\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=|^\s*\$([A-Za-z_][A-Za-z0-9_]*)\s*=/iu.exec(source); + const assignment = /^\s*(?:(?:declare|export|local|readonly|typeset)(?:\s+-[^\s]+)*\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*\+?=|^\s*\$([A-Za-z_][A-Za-z0-9_]*)\s*[+*/%\-]?=/iu.exec(source); return (assignment?.[1] ?? assignment?.[2])?.toLowerCase(); } @@ -3118,8 +3187,16 @@ function hasUntrustedWorkflowArtifactExecution( scalarAnchors, taintedBindings = new Set(), stepOutputResolver, + localActionExecution, ) { const jobGroups = workflowJobPropertyGroups(text, scalarAnchors); + const workflowEnvironmentValues = staticEnvironmentValues( + workflowRootMappingBindings(text, "env", "env", scalarAnchors), + ); + const workflowWorkingDirectory = workflowRunDefaultWorkingDirectory( + text, + scalarAnchors, + ); const jobTaintAnalyses = workflowJobTaintAnalyses( jobGroups, workflowRootMappingBindings(text, "env", "env", scalarAnchors), @@ -3127,10 +3204,24 @@ function hasUntrustedWorkflowArtifactExecution( taintedBindings, stepOutputResolver, ); - return jobGroups.some((jobGroup) => stepContextsHaveUntrustedArtifactExecution( - jobTaintAnalyses.get(jobGroup)?.stepContexts ?? [], - scalarAnchors, - )); + return jobGroups.some((jobGroup) => { + const environmentValues = staticEnvironmentValues( + workflowMappingBindings(jobGroup, "env", "env", scalarAnchors), + workflowEnvironmentValues, + ); + return stepContextsHaveUntrustedArtifactExecution( + jobTaintAnalyses.get(jobGroup)?.stepContexts ?? [], + scalarAnchors, + { + defaultWorkingDirectory: jobRunDefaultWorkingDirectory( + jobGroup, + scalarAnchors, + ) ?? workflowWorkingDirectory, + environmentValues, + localActionExecution, + }, + ); + }); } function hasUntrustedScriptInterpolation( @@ -3483,51 +3574,305 @@ function shellPipelineStages(segment) { return stages; } -function stepContextsHaveUntrustedArtifactExecution(stepContexts, scalarAnchors) { - const artifactPaths = []; +function stepContextsHaveUntrustedArtifactExecution( + stepContexts, + scalarAnchors, + { + artifactPaths = [], + defaultWorkingDirectory = ".", + environmentValues = new Map(), + localActionExecution, + } = {}, +) { for (const { stepGroup, taintedBindings } of stepContexts) { - if (stepDownloadsUntrustedWorkflowArtifact( + const stepEnvironmentValues = staticEnvironmentValues( + workflowMappingBindings(stepGroup, "env", "env", scalarAnchors), + environmentValues, + ); + const workingDirectory = stepRunWorkingDirectory( + stepGroup, + scalarAnchors, + defaultWorkingDirectory, + stepEnvironmentValues, + ); + for (const artifactPath of downloadedUntrustedArtifactPaths( stepGroup, scalarAnchors, taintedBindings, + workingDirectory, + stepEnvironmentValues, )) { - artifactPaths.push(downloadedArtifactPath(stepGroup, scalarAnchors)); - continue; + if (!artifactPaths.includes(artifactPath)) artifactPaths.push(artifactPath); } if (artifactPaths.some((artifactPath) => stepExecutesArtifactPath( stepGroup, scalarAnchors, artifactPath, + workingDirectory, + stepEnvironmentValues, ))) return true; + if (localActionExecution?.( + stepGroup, + taintedBindings, + artifactPaths, + stepEnvironmentValues, + )) return true; } return false; } -function stepDownloadsUntrustedWorkflowArtifact( +function downloadedUntrustedArtifactPaths( stepGroup, scalarAnchors, taintedBindings, + workingDirectory, + environmentValues, ) { const downloadsArtifact = stepGroup.properties .filter(({ entry }) => entry.key.toLowerCase() === "uses") .some(({ entry }) => /^actions\/download-artifact@/iu.test( resolveYamlScalarValue(entry.value, scalarAnchors), )); - if (!downloadsArtifact) return false; - return workflowMappingBindings(stepGroup, "with", "with", scalarAnchors) - .filter((binding) => binding.name === "run-id") - .some((binding) => isUntrustedReusableValue(binding.value, taintedBindings)); + const paths = []; + if (downloadsArtifact) { + const inputs = workflowMappingBindings(stepGroup, "with", "with", scalarAnchors); + const runId = inputs.find((binding) => binding.name === "run-id")?.value; + if (runId && isUntrustedReusableValue(runId, taintedBindings)) { + paths.push(normalizedArtifactPath( + inputs.find((binding) => binding.name === "path")?.value ?? ".", + ".", + environmentValues, + )); + } + } + for (const runSource of stepGroup.properties + .filter(({ entry }) => entry.key.toLowerCase() === "run") + .map(({ entry }) => resolveYamlScalarValue(entry.value, scalarAnchors))) { + paths.push(...shellGhRunDownloadPaths( + runSource, + taintedBindings, + workingDirectory, + environmentValues, + )); + } + return paths; +} + +function shellGhRunDownloadPaths( + runSource, + taintedBindings, + workingDirectory, + environmentValues, +) { + const paths = []; + const taintedVariables = new Set([...taintedBindings].flatMap((binding) => ( + binding.startsWith("env.") && binding !== "env.*" + ? [binding.slice("env.".length).toLowerCase()] + : [] + ))); + const localEnvironmentValues = new Map(environmentValues); + for (const segment of shellCommandSegments(runSource)) { + const segmentReferencesTaint = isUntrustedReusableValue(segment, taintedBindings) + || shellSourceReferencesTaintedVariable( + segment, + taintedVariables, + taintedBindings.has("env.*"), + ); + const assignmentName = shellAssignmentName(segment); + if (assignmentName && segmentReferencesTaint) taintedVariables.add(assignmentName); + applyStaticShellAssignment(segment, localEnvironmentValues); + const download = shellGhRunDownload(segment); + if (!download) continue; + const runIdIsTainted = isUntrustedReusableValue(download.runId, taintedBindings) + || shellSourceReferencesTaintedVariable( + download.runId, + taintedVariables, + taintedBindings.has("env.*"), + ); + if (!runIdIsTainted) continue; + paths.push(normalizedArtifactPath( + download.directory, + workingDirectory, + localEnvironmentValues, + )); + } + return paths; +} + +function shellGhRunDownload(segment) { + const words = shellCommandWords(segment); + let cursor = 0; + while (cursor < words.length) { + if (/^[A-Za-z_][A-Za-z0-9_]*=/u.test(words[cursor])) { + cursor += 1; + continue; + } + const command = path.posix.basename(words[cursor]).toLowerCase(); + if (["command", "exec"].includes(command)) { + cursor += 1; + while (words[cursor]?.startsWith("-")) cursor += 1; + continue; + } + if (command === "env") { + cursor += 1; + while (words[cursor]?.startsWith("-") + || /^[A-Za-z_][A-Za-z0-9_]*=/u.test(words[cursor] ?? "")) cursor += 1; + continue; + } + break; + } + if (path.posix.basename(words[cursor] ?? "").toLowerCase() !== "gh" + || words[cursor + 1]?.toLowerCase() !== "run" + || words[cursor + 2]?.toLowerCase() !== "download") return undefined; + cursor += 3; + let directory = "."; + let runId; + const optionsWithValues = new Set([ + "--dir", "--name", "--pattern", "--repo", "-d", "-n", "-p", "-r", + ]); + while (cursor < words.length) { + const argument = words[cursor]; + cursor += 1; + const directoryAssignment = /^--dir=(.*)$/iu.exec(argument); + if (directoryAssignment) { + directory = directoryAssignment[1]; + continue; + } + if (optionsWithValues.has(argument.toLowerCase())) { + const optionValue = words[cursor]; + cursor += 1; + if (["--dir", "-d"].includes(argument.toLowerCase()) && optionValue) { + directory = optionValue; + } + continue; + } + if (argument.startsWith("-")) continue; + runId ??= argument; + } + return runId ? { directory, runId } : undefined; } -function downloadedArtifactPath(stepGroup, scalarAnchors) { - const value = workflowMappingBindings(stepGroup, "with", "with", scalarAnchors) - .find((binding) => binding.name === "path")?.value; - if (!value || /\$|%/u.test(value) || path.posix.isAbsolute(value)) return "."; - const normalized = path.posix.normalize(value).replace(/^\.\//u, ""); +function normalizedArtifactPath(value, baseDirectory, environmentValues) { + const resolvedValue = resolveStaticEnvironmentReferences(value, environmentValues); + const resolvedBase = resolveStaticEnvironmentReferences(baseDirectory, environmentValues); + if (resolvedValue === undefined || resolvedBase === undefined + || path.posix.isAbsolute(resolvedValue) + || path.posix.isAbsolute(resolvedBase)) return "."; + const normalized = path.posix.normalize(path.posix.join( + resolvedBase.replace(/^\.\//u, ""), + resolvedValue.replace(/^\.\//u, ""), + )); return normalized === ".." || normalized.startsWith("../") ? "." : normalized; } -function stepExecutesArtifactPath(stepGroup, scalarAnchors, artifactPath) { +function staticEnvironmentValues(bindings, inheritedValues = new Map()) { + const values = new Map(inheritedValues); + for (const binding of bindings) { + const value = resolveStaticEnvironmentReferences(binding.value, values); + if (value === undefined) values.delete(binding.name.toLowerCase()); + else values.set(binding.name.toLowerCase(), value); + } + return values; +} + +function resolveStaticEnvironmentReferences(value, environmentValues) { + let resolved = value.trim().replace(/^(?:"([\s\S]*)"|'([\s\S]*)')$/u, "$1$2"); + if (/\$\{\{|\$\(|`/u.test(resolved)) return undefined; + let unresolved = false; + resolved = resolved.replace( + /\$env:([A-Za-z_][A-Za-z0-9_]*)|\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)|%([A-Za-z_][A-Za-z0-9_]*)%/giu, + (_match, powershellName, bracedName, shellName, windowsName) => { + const name = (powershellName ?? bracedName ?? shellName ?? windowsName).toLowerCase(); + if (!environmentValues.has(name)) { + unresolved = true; + return ""; + } + return environmentValues.get(name); + }, + ); + if (unresolved || /[$%]/u.test(resolved)) return undefined; + return resolved; +} + +function applyStaticShellAssignment(segment, environmentValues) { + const shellAssignment = /^\s*(?:(?:declare|export|local|readonly|typeset)(?:\s+-[^\s]+)*\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*(\+?=)\s*("[^"]*"|'[^']*'|[^\s;&|]+)/u.exec(segment); + const powershellAssignment = /^\s*\$([A-Za-z_][A-Za-z0-9_]*)\s*([+*/%\-]?=)\s*("[^"]*"|'[^']*'|[^\s;&|]+)/u.exec(segment); + const assignment = shellAssignment ?? powershellAssignment; + if (!assignment) return; + const name = assignment[1].toLowerCase(); + const value = resolveStaticEnvironmentReferences(assignment[3], environmentValues); + if (value === undefined) { + environmentValues.delete(name); + return; + } + environmentValues.set( + name, + assignment[2] === "+=" ? `${environmentValues.get(name) ?? ""}${value}` : value, + ); +} + +function workflowRunDefaultWorkingDirectory(text, scalarAnchors) { + return workflowRootContainerGroups(text, "defaults", scalarAnchors) + .flatMap((group) => workflowNestedMappingValues( + group, + ["run", "working-directory"], + scalarAnchors, + )) + .find(Boolean) ?? "."; +} + +function jobRunDefaultWorkingDirectory(jobGroup, scalarAnchors) { + return workflowNestedMappingValues( + jobGroup, + ["defaults", "run", "working-directory"], + scalarAnchors, + ).find(Boolean); +} + +function workflowNestedMappingValues(group, keys, scalarAnchors) { + const [key, ...remainingKeys] = keys; + const matchingProperties = group.properties.filter(({ entry }) => ( + resolveYamlScalarValue(entry.key, scalarAnchors).toLowerCase() === key + )); + if (remainingKeys.length === 0) { + return matchingProperties.map(({ entry }) => ( + resolveYamlScalarValue(entry.value, scalarAnchors) + )); + } + return matchingProperties.flatMap((property) => { + const properties = workflowPropertyMappingEntries( + group, + property, + scalarAnchors, + ); + return workflowNestedMappingValues( + { ...group, properties }, + remainingKeys, + scalarAnchors, + ); + }); +} + +function stepRunWorkingDirectory( + stepGroup, + scalarAnchors, + defaultWorkingDirectory, + environmentValues, +) { + const requested = stepGroup.properties + .filter(({ entry }) => entry.key.toLowerCase() === "working-directory") + .map(({ entry }) => resolveYamlScalarValue(entry.value, scalarAnchors)) + .find(Boolean) ?? defaultWorkingDirectory; + return resolveStaticEnvironmentReferences(requested, environmentValues) ?? requested; +} + +function stepExecutesArtifactPath( + stepGroup, + scalarAnchors, + artifactPath, + workingDirectory = ".", + environmentValues = new Map(), +) { const localActionReference = stepGroup.properties .filter(({ entry }) => entry.key.toLowerCase() === "uses") .map(({ entry }) => resolveYamlScalarValue(entry.value, scalarAnchors)) @@ -3535,10 +3880,6 @@ function stepExecutesArtifactPath(stepGroup, scalarAnchors, artifactPath) { if (localActionReference && artifactSourceMatchesPath(localActionReference, artifactPath)) { return true; } - const workingDirectory = stepGroup.properties - .filter(({ entry }) => entry.key.toLowerCase() === "working-directory") - .map(({ entry }) => resolveYamlScalarValue(entry.value, scalarAnchors)) - .find(Boolean) ?? "."; return stepGroup.properties .filter(({ entry }) => entry.key.toLowerCase() === "run") .map(({ entry }) => resolveYamlScalarValue(entry.value, scalarAnchors)) @@ -3546,22 +3887,38 @@ function stepExecutesArtifactPath(stepGroup, scalarAnchors, artifactPath) { runSource, artifactPath, workingDirectory, + environmentValues, )); } -function shellRunExecutesArtifactPath(runSource, artifactPath, workingDirectory) { +function shellRunExecutesArtifactPath( + runSource, + artifactPath, + workingDirectory, + environmentValues = new Map(), +) { let effectiveWorkingDirectory = workingDirectory; + const localEnvironmentValues = new Map(environmentValues); for (const segment of shellCommandSegments(runSource)) { if (/^\s*#/u.test(segment)) continue; + applyStaticShellAssignment(segment, localEnvironmentValues); const directoryChange = /^\s*(?:(?:builtin|command)\s+)?(?:cd|pushd)\s+(?:--\s+)?("[^"]*"|'[^']*'|[^\s;&|]+)/iu.exec(segment); if (directoryChange) { + const target = resolveStaticEnvironmentReferences( + directoryChange[1], + localEnvironmentValues, + ) ?? directoryChange[1]; effectiveWorkingDirectory = resolveShellWorkingDirectory( effectiveWorkingDirectory, - directoryChange[1], + target, ); continue; } - const executionSource = shellArtifactExecutionSource(segment); + const rawExecutionSource = shellArtifactExecutionSource(segment); + const executionSource = rawExecutionSource + ? resolveStaticEnvironmentReferences(rawExecutionSource, localEnvironmentValues) + ?? rawExecutionSource + : undefined; if (executionSource && artifactSourceMatchesPath( executionSource, artifactPath, @@ -3714,7 +4071,16 @@ function shellCommandSegments(runSource) { } const doubleSeparator = (character === "&" && source[index + 1] === "&") || (character === "|" && source[index + 1] === "|"); - if (!quote && (character === "\n" || character === ";" || doubleSeparator)) { + const backgroundSeparator = character === "&" + && source[index + 1] !== "&" + && !["<", ">"].includes(source[index - 1]) + && source[index + 1] !== ">"; + if (!quote && ( + character === "\n" + || character === ";" + || doubleSeparator + || backgroundSeparator + )) { if (current.trim().length > 0) segments.push(current.trim()); current = ""; if (doubleSeparator) index += 1; @@ -3735,6 +4101,7 @@ function resolveShellWorkingDirectory(workingDirectory, target) { function artifactSourceMatchesPath(source, artifactPath, workingDirectory = ".") { const normalized = source.replace(/^["']|["']$/gu, "").replace(/^\.\//u, ""); + if (/[$%`]/u.test(normalized)) return true; if (path.posix.isAbsolute(normalized)) { if (artifactPath === ".") return true; return normalized.endsWith(`/${artifactPath}`) diff --git a/public-source-release-audit/tests/public-source-release-audit.test.mjs b/public-source-release-audit/tests/public-source-release-audit.test.mjs index 0271d6d..739dffa 100644 --- a/public-source-release-audit/tests/public-source-release-audit.test.mjs +++ b/public-source-release-audit/tests/public-source-release-audit.test.mjs @@ -1529,6 +1529,24 @@ test("GITHUB_ENV taint does not flow backward to earlier steps", () => { " echo \"PR_REF=$SOURCE_REF\" >> \"$GITHUB_ENV\"", "", ].join("\n")); + write(repoRoot, ".github/workflows/github-env-constant-write.yml", [ + "name: GITHUB_ENV constant write", + "on: pull_request_target", + "permissions: read-all", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - env:", + " SOURCE_REF: " + headExpression, + " run: |", + " printf '%s\\n' \"$SOURCE_REF\"", + " echo \"SAFE_REF=main\" >> \"$GITHUB_ENV\"", + " - uses: actions/checkout@" + "a".repeat(40), + " with:", + " ref: ${{ env.SAFE_REF }}", + "", + ].join("\n")); commitAll(repoRoot, "add ordered environment workflow"); const audit = runAudit(repoRoot); @@ -2035,6 +2053,76 @@ test("workflow_run artifacts remain untrusted when later executed", () => { " - run: bash payload/run.sh", "", ].join("\n")); + write(repoRoot, ".github/workflows/workflow-run-gh-artifact.yml", [ + "name: workflow run gh artifact", + "on:", + " workflow_run:", + " workflows: [verify]", + " types: [completed]", + "permissions: read-all", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - env:", + " RUN_ID: " + runIdExpression, + " run: gh run download \"$RUN_ID\" --dir payload", + " - run: bash payload/run.sh", + "", + ].join("\n")); + write(repoRoot, ".github/workflows/workflow-download-composite-execute.yml", [ + "name: workflow download composite execute", + "on:", + " workflow_run:", + " workflows: [verify]", + " types: [completed]", + "permissions: read-all", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - uses: actions/download-artifact@" + "a".repeat(40), + " with:", + " run-id: " + runIdExpression, + " path: payload", + " - uses: ./.github/actions/run-downloaded-artifact", + "", + ].join("\n")); + write(repoRoot, ".github/actions/run-downloaded-artifact/action.yml", [ + "name: run downloaded artifact", + "runs:", + " using: composite", + " steps:", + " - shell: bash", + " run: bash payload/run.sh", + "", + ].join("\n")); + write(repoRoot, ".github/workflows/composite-download-workflow-execute.yml", [ + "name: composite download workflow execute", + "on:", + " workflow_run:", + " workflows: [verify]", + " types: [completed]", + "permissions: read-all", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - uses: ./.github/actions/download-untrusted-artifact", + " - run: bash payload/run.sh", + "", + ].join("\n")); + write(repoRoot, ".github/actions/download-untrusted-artifact/action.yml", [ + "name: download untrusted artifact", + "runs:", + " using: composite", + " steps:", + " - uses: actions/download-artifact@" + "a".repeat(40), + " with:", + " run-id: " + runIdExpression, + " path: payload", + "", + ].join("\n")); commitAll(repoRoot, "add workflow artifact execution"); const audit = runAudit(repoRoot); @@ -2045,6 +2133,17 @@ test("workflow_run artifacts remain untrusted when later executed", () => { "workflow-privileged-untrusted-artifact-execution", ".github/workflows/workflow-run-artifact.yml", ); + for (const workflowName of [ + "workflow-run-gh-artifact", + "workflow-download-composite-execute", + "composite-download-workflow-execute", + ]) { + assertFinding( + audit.result, + "workflow-privileged-untrusted-artifact-execution", + `.github/workflows/${workflowName}.yml`, + ); + } }); test("artifact execution resolves step working directories", () => { @@ -2069,6 +2168,36 @@ test("artifact execution resolves step working directories", () => { " run: bash run.sh", "", ].join("\n")); + for (const scope of ["job", "workflow"]) { + write(repoRoot, `.github/workflows/workflow-run-artifact-${scope}-default.yml`, [ + `name: workflow run artifact ${scope} default`, + "on:", + " workflow_run:", + " workflows: [verify]", + " types: [completed]", + "permissions: read-all", + ...(scope === "workflow" ? [ + "defaults:", + " run:", + " working-directory: payload", + ] : []), + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + ...(scope === "job" ? [ + " defaults:", + " run:", + " working-directory: payload", + ] : []), + " steps:", + " - uses: actions/download-artifact@" + "a".repeat(40), + " with:", + " run-id: " + runIdExpression, + " path: payload", + " - run: bash run.sh", + "", + ].join("\n")); + } commitAll(repoRoot, "add artifact working directory execution"); const audit = runAudit(repoRoot); @@ -2079,6 +2208,13 @@ test("artifact execution resolves step working directories", () => { "workflow-privileged-untrusted-artifact-execution", ".github/workflows/workflow-run-artifact-directory.yml", ); + for (const scope of ["job", "workflow"]) { + assertFinding( + audit.result, + "workflow-privileged-untrusted-artifact-execution", + `.github/workflows/workflow-run-artifact-${scope}-default.yml`, + ); + } }); test("artifact execution tracks inline shell directory changes", () => { @@ -2141,12 +2277,50 @@ test("artifact execution recognizes common command wrappers and paths", () => { "", ].join("\n")); } + for (const scope of ["workflow", "job", "step", "shell"]) { + write(repoRoot, `.github/workflows/workflow-run-artifact-${scope}-alias.yml`, [ + `name: workflow run artifact ${scope} alias`, + "on:", + " workflow_run:", + " workflows: [verify]", + " types: [completed]", + "permissions: read-all", + ...(scope === "workflow" ? ["env:", " SCRIPT: payload/run.sh"] : []), + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + ...(scope === "job" ? [" env:", " SCRIPT: payload/run.sh"] : []), + " steps:", + " - uses: actions/download-artifact@" + "a".repeat(40), + " with:", + " run-id: " + runIdExpression, + " path: payload", + ...(scope === "step" ? [ + " - env:", + " SCRIPT: payload/run.sh", + " run: bash \"$SCRIPT\"", + ] : scope === "shell" ? [ + " - run: |", + " SCRIPT=payload/run.sh", + " bash \"$SCRIPT\"", + ] : [" - run: bash \"$SCRIPT\""]), + "", + ].join("\n")); + } commitAll(repoRoot, "add common artifact execution commands"); const audit = runAudit(repoRoot); assert.equal(audit.status, 1); - for (const name of ["exec", "absolute-interpreter", "direct-path"]) { + for (const name of [ + "exec", + "absolute-interpreter", + "direct-path", + "workflow-alias", + "job-alias", + "step-alias", + "shell-alias", + ]) { assertFinding( audit.result, "workflow-privileged-untrusted-artifact-execution", @@ -2502,6 +2676,19 @@ test("tainted environment values cannot reach shell evaluators", () => { " run: eval \"$COMMAND\"", "", ].join("\n")); + write(repoRoot, ".github/workflows/comment-background-eval.yml", [ + "name: comment background eval", + "on: issue_comment", + "permissions: read-all", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - env:", + " COMMAND: " + bodyExpression, + " run: echo ready & eval \"$COMMAND\"", + "", + ].join("\n")); commitAll(repoRoot, "add tainted shell evaluator"); const audit = runAudit(repoRoot); @@ -2512,6 +2699,11 @@ test("tainted environment values cannot reach shell evaluators", () => { "workflow-privileged-untrusted-script-interpolation", ".github/workflows/comment-eval.yml", ); + assertFinding( + audit.result, + "workflow-privileged-untrusted-script-interpolation", + ".github/workflows/comment-background-eval.yml", + ); }); test("tainted script text cannot be piped into shell interpreters", () => { @@ -2745,6 +2937,22 @@ test("privileged workflows reject shell-based untrusted checkouts", () => { " ./source/verify.sh", "", ].join("\n")); + write(repoRoot, ".github/workflows/shell-append-checkout.yml", [ + "name: shell append checkout", + "on: pull_request_target", + "permissions: read-all", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - env:", + " PR_REF: " + refExpression, + " run: |", + " REF=refs/heads/", + " REF+=\"$PR_REF\"", + " git checkout \"$REF\"", + "", + ].join("\n")); commitAll(repoRoot, "add shell checkout workflow"); const audit = runAudit(repoRoot); @@ -2755,6 +2963,11 @@ test("privileged workflows reject shell-based untrusted checkouts", () => { "workflow-privileged-untrusted-checkout", ".github/workflows/shell-checkout.yml", ); + assertFinding( + audit.result, + "workflow-privileged-untrusted-checkout", + ".github/workflows/shell-append-checkout.yml", + ); }); test("tainted fetch state propagates through FETCH_HEAD checkouts", () => { From 32aa3a51297a92703aa2d2622ef5c9befa533b46 Mon Sep 17 00:00:00 2001 From: Vladimir Date: Sat, 22 Aug 2026 09:06:41 +0800 Subject: [PATCH 28/37] Handle heredoc and indirect execution taint --- .../scripts/public-source-release-audit.mjs | 167 ++++++++++++++++-- .../public-source-release-audit.test.mjs | 158 +++++++++++++++++ 2 files changed, 306 insertions(+), 19 deletions(-) diff --git a/public-source-release-audit/scripts/public-source-release-audit.mjs b/public-source-release-audit/scripts/public-source-release-audit.mjs index 6ff4321..7790336 100644 --- a/public-source-release-audit/scripts/public-source-release-audit.mjs +++ b/public-source-release-audit/scripts/public-source-release-audit.mjs @@ -3514,10 +3514,14 @@ function shellRunEvaluatesTaintedVariable(runSource, taintedBindings) { const hereInputInterpreterCommand = /^\s*(?:(?:command|exec)\s+)?(?:(?:\/usr\/bin\/)?env\s+(?:-[^\s]+\s+)*)?(?:(?:\/[^/\s]+)*\/)?(?:bash|dash|fish|ksh|powershell|pwsh|sh|zsh)(?:\.exe)?(?:\s+-[^\s]+)*\s+<< 0) stages.push(current.trim()); current = ""; + if (segment[index + 1] === "&") index += 1; continue; } current += character; @@ -3683,7 +3688,8 @@ function shellGhRunDownloadPaths( applyStaticShellAssignment(segment, localEnvironmentValues); const download = shellGhRunDownload(segment); if (!download) continue; - const runIdIsTainted = isUntrustedReusableValue(download.runId, taintedBindings) + const runIdIsTainted = download.runId === undefined + || isUntrustedReusableValue(download.runId, taintedBindings) || shellSourceReferencesTaintedVariable( download.runId, taintedVariables, @@ -3700,7 +3706,7 @@ function shellGhRunDownloadPaths( } function shellGhRunDownload(segment) { - const words = shellCommandWords(segment); + const words = shellCommandWords(stripShellCommandGrouping(segment)); let cursor = 0; while (cursor < words.length) { if (/^[A-Za-z_][A-Za-z0-9_]*=/u.test(words[cursor])) { @@ -3721,10 +3727,21 @@ function shellGhRunDownload(segment) { } break; } - if (path.posix.basename(words[cursor] ?? "").toLowerCase() !== "gh" - || words[cursor + 1]?.toLowerCase() !== "run" - || words[cursor + 2]?.toLowerCase() !== "download") return undefined; - cursor += 3; + if (path.posix.basename(words[cursor] ?? "").toLowerCase() !== "gh") return undefined; + cursor += 1; + const globalOptionsWithValues = new Set([ + "--config", "--hostname", "--jq", "--repo", "--template", "-r", + ]); + while (words[cursor]?.startsWith("-")) { + const option = words[cursor]; + const normalizedOption = option.toLowerCase(); + cursor += 1; + if (globalOptionsWithValues.has(normalizedOption)) cursor += 1; + else if (/^(?:--(?:config|hostname|jq|repo|template)=|-r.)/iu.test(option)) continue; + } + if (words[cursor]?.toLowerCase() !== "run" + || words[cursor + 1]?.toLowerCase() !== "download") return undefined; + cursor += 2; let directory = "."; let runId; const optionsWithValues = new Set([ @@ -3749,7 +3766,7 @@ function shellGhRunDownload(segment) { if (argument.startsWith("-")) continue; runId ??= argument; } - return runId ? { directory, runId } : undefined; + return { directory, runId }; } function normalizedArtifactPath(value, baseDirectory, environmentValues) { @@ -3901,8 +3918,9 @@ function shellRunExecutesArtifactPath( const localEnvironmentValues = new Map(environmentValues); for (const segment of shellCommandSegments(runSource)) { if (/^\s*#/u.test(segment)) continue; - applyStaticShellAssignment(segment, localEnvironmentValues); - const directoryChange = /^\s*(?:(?:builtin|command)\s+)?(?:cd|pushd)\s+(?:--\s+)?("[^"]*"|'[^']*'|[^\s;&|]+)/iu.exec(segment); + const executableSegment = stripShellCommandGrouping(segment); + applyStaticShellAssignment(executableSegment, localEnvironmentValues); + const directoryChange = /^\s*(?:(?:builtin|command)\s+)?(?:cd|pushd)\s+(?:--\s+)?("[^"]*"|'[^']*'|[^\s;&|]+)/iu.exec(executableSegment); if (directoryChange) { const target = resolveStaticEnvironmentReferences( directoryChange[1], @@ -3914,7 +3932,7 @@ function shellRunExecutesArtifactPath( ); continue; } - const rawExecutionSource = shellArtifactExecutionSource(segment); + const rawExecutionSource = shellArtifactExecutionSource(executableSegment); const executionSource = rawExecutionSource ? resolveStaticEnvironmentReferences(rawExecutionSource, localEnvironmentValues) ?? rawExecutionSource @@ -3971,7 +3989,7 @@ function shellArtifactExecutionSource(segment) { "powershell", "powershell.exe", "pwsh", "pwsh.exe", "python", "python2", "python3", "ruby", "sh", "zsh", ]); - if (interpreters.has(commandName)) { + if (interpreters.has(commandName) || /^python\d+(?:\.\d+)*$/u.test(commandName)) { if (commandName === "deno" && words[cursor]?.toLowerCase() === "run") cursor += 1; while (cursor < words.length) { const argument = words[cursor]; @@ -3990,9 +4008,13 @@ function shellArtifactExecutionSource(segment) { if (/^(?:\d*)<$/u.test(argument)) return words[cursor]; const redirectedSource = /^(?:\d*)<([^<].*)$/u.exec(argument)?.[1]; if (redirectedSource) return redirectedSource; - if (["-c", "--command", "-e", "--eval"].includes(argument.toLowerCase())) { + if (interpreterOptionIsExecutionMode(commandName, argument)) { return undefined; } + if (interpreterOptionConsumesValue(commandName, argument)) { + cursor += 1; + continue; + } if (argument.startsWith("-")) continue; return argument; } @@ -4001,6 +4023,28 @@ function shellArtifactExecutionSource(segment) { return command.includes("/") ? command : undefined; } +function interpreterOptionIsExecutionMode(commandName, argument) { + const option = argument.toLowerCase(); + if (/^python/u.test(commandName) && ["-c", "-m"].includes(option)) return true; + if (["node", "perl", "ruby"].includes(commandName) + && ["-e", "--eval", "-p", "--print"].includes(option)) return true; + return ["-c", "--command", "-e", "--eval"].includes(option); +} + +function interpreterOptionConsumesValue(commandName, argument) { + if (argument.length > 2 && !argument.startsWith("--")) return false; + if (/^python/u.test(commandName)) { + return ["-Q", "-W", "-X", "--check-hash-based-pycs"].includes(argument); + } + const options = new Map([ + ["bash", new Set(["-o", "-O", "--init-file", "--rcfile"])], + ["node", new Set(["-r", "--conditions", "--import", "--loader", "--require"])], + ["perl", new Set(["-f", "-i", "-m", "-M"])], + ["ruby", new Set(["-e", "-i", "-I", "-r", "--encoding", "--external-encoding", "--internal-encoding"])], + ]); + return options.get(commandName)?.has(argument) ?? false; +} + function shellProcessSubstitutionArtifactSource(words) { const body = /^<\(([\s\S]*)\)$/u.exec(words.join(" "))?.[1]; if (!body) return undefined; @@ -4046,9 +4090,21 @@ function shellCommandWords(source) { return words; } +function stripShellCommandGrouping(source) { + let stripped = source.trim(); + while (/^[({]/u.test(stripped)) { + const opening = stripped[0]; + stripped = stripped.slice(1).trimStart(); + const closing = opening === "(" ? ")" : "}"; + if (stripped.endsWith(closing)) stripped = stripped.slice(0, -1).trimEnd(); + } + if (!stripped.includes("<(")) stripped = stripped.replace(/[)}]+\s*$/u, "").trimEnd(); + return stripped; +} + function shellCommandSegments(runSource) { - const source = runSource - .replace(/\r\n?|\u0085|\u2028|\u2029/gu, "\n") + const source = joinShellHeredocBodies(runSource + .replace(/\r\n?|\u0085|\u2028|\u2029/gu, "\n")) .replace(/\\\n[ \t]*/gu, " "); const segments = []; let current = ""; @@ -4073,7 +4129,7 @@ function shellCommandSegments(runSource) { || (character === "|" && source[index + 1] === "|"); const backgroundSeparator = character === "&" && source[index + 1] !== "&" - && !["<", ">"].includes(source[index - 1]) + && !["<", ">", "|"].includes(source[index - 1]) && source[index + 1] !== ">"; if (!quote && ( character === "\n" @@ -4092,6 +4148,30 @@ function shellCommandSegments(runSource) { return segments; } +function joinShellHeredocBodies(source) { + const lines = source.split("\n"); + const joined = []; + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index]; + const heredoc = /(?:^|[^<])<<(-)?\s*(?:(["'])([^"']+)\2|([A-Za-z_][A-Za-z0-9_]*))/u.exec(line); + if (!heredoc) { + joined.push(line); + continue; + } + const delimiter = heredoc[3] ?? heredoc[4]; + const body = []; + let cursor = index + 1; + for (; cursor < lines.length; cursor += 1) { + const candidate = heredoc[1] ? lines[cursor].replace(/^\t+/u, "") : lines[cursor]; + if (candidate.trim() === delimiter) break; + body.push(lines[cursor]); + } + joined.push([line, ...body].join(" ")); + index = cursor < lines.length ? cursor : lines.length - 1; + } + return joined.join("\n"); +} + function resolveShellWorkingDirectory(workingDirectory, target) { const normalizedTarget = target.replace(/^(["'])(.*)\1$/u, "$2"); if (/[$%`]|^~|^-$/u.test(normalizedTarget)) return "${dynamic-working-directory}"; @@ -4161,6 +4241,8 @@ function shellRunGitTaintAnalysis(runSource, taintedBindings) { .filter((line) => !/^\s*#/u.test(line)); let fetchedHeadTainted = taintedBindings.has("git.fetch_head"); let taintsFetchHead = false; + let currentHeadTainted = false; + const taintedRefs = new Set(); for (const line of lines) { const assignmentName = shellAssignmentName(line); if (assignmentName && (isUntrustedReusableValue(line, taintedBindings) @@ -4181,7 +4263,27 @@ function shellRunGitTaintAnalysis(runSource, taintedBindings) { fetchedHeadTainted = true; taintsFetchHead = true; } + const persistedRef = shellGitPersistedRef(line); + if (persistedRef && (lineIsTainted + || (fetchedHeadTainted && /\bFETCH_HEAD\b/iu.test(persistedRef.source)) + || [...taintedRefs].some((ref) => shellSourceContainsToken( + persistedRef.source, + ref, + )))) { + if (persistedRef.target.toUpperCase() === "HEAD") currentHeadTainted = true; + else { + taintedRefs.add(persistedRef.target); + taintedRefs.add(path.posix.basename(persistedRef.target)); + } + } + const lineReferencesTaintedRef = [...taintedRefs].some((ref) => ( + shellSourceContainsToken(line, ref) + )); + const materializesTaintedHead = currentHeadTainted + && /\bgit(?:\s+--?[^\s]+(?:[=\s][^\s]+)?)*\s+reset\b[^\n]*\s--(?:hard|keep|merge)\b/iu.test(line); if (checkoutCommand.test(line) && (lineIsTainted + || lineReferencesTaintedRef + || materializesTaintedHead || (fetchedHeadTainted && /\bFETCH_HEAD\b/iu.test(line)))) { return { hasUntrustedCheckout: true, taintsFetchHead }; } @@ -4189,7 +4291,34 @@ function shellRunGitTaintAnalysis(runSource, taintedBindings) { return { hasUntrustedCheckout: false, taintsFetchHead }; } +function shellGitPersistedRef(source) { + for (const pattern of [ + /\bgit(?:\s+--?[^\s]+(?:[=\s][^\s]+)?)*\s+update-ref\s+(?:--?[^\s]+\s+)*([^\s]+)\s+([^\s]+)/iu, + /\bgit(?:\s+--?[^\s]+(?:[=\s][^\s]+)?)*\s+(?:branch|tag)\s+(?:--?[^\s]+\s+)*([^\s]+)\s+([^\s]+)/iu, + ]) { + const match = pattern.exec(source); + if (match) return { + source: match[2].replace(/^["']|["']$/gu, ""), + target: match[1].replace(/^["']|["']$/gu, ""), + }; + } + return undefined; +} + +function shellSourceContainsToken(source, token) { + return new RegExp( + String.raw`(?:^|[\s"'])${escapeRegExp(token)}(?:$|[\s"'])`, + "iu", + ).test(source); +} + function shellSourceReferencesTaintedVariable(source, taintedVariables, anyTainted) { + const nameref = /^\s*(?:declare|local|typeset)(?=[^\n]*\s-n(?:\s|$))[^\n]*\s+[A-Za-z_][A-Za-z0-9_]*\s*=\s*([A-Za-z_][A-Za-z0-9_]*)/iu.exec(source); + if (nameref && (anyTainted || taintedVariables.has(nameref[1].toLowerCase()))) { + return true; + } + if (/\$\{![A-Za-z_][A-Za-z0-9_]*(?:[*@])?\}/u.test(source) + && (anyTainted || taintedVariables.size > 0)) return true; const references = [ ...source.matchAll(/\$(?:env:)?(?:\{([A-Za-z_][A-Za-z0-9_]*)\}|([A-Za-z_][A-Za-z0-9_]*))/giu), ...source.matchAll(/%([A-Za-z_][A-Za-z0-9_]*)%/gu), diff --git a/public-source-release-audit/tests/public-source-release-audit.test.mjs b/public-source-release-audit/tests/public-source-release-audit.test.mjs index 739dffa..9b00c3b 100644 --- a/public-source-release-audit/tests/public-source-release-audit.test.mjs +++ b/public-source-release-audit/tests/public-source-release-audit.test.mjs @@ -1494,6 +1494,25 @@ test("untrusted refs persisted through GITHUB_ENV reach later steps", () => { " ref: " + envExpression, "", ].join("\n")); + write(repoRoot, ".github/workflows/github-env-heredoc-ref.yml", [ + "name: GITHUB_ENV heredoc ref", + "on: pull_request_target", + "permissions: read-all", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - env:", + " SOURCE_REF: " + headExpression, + " run: |", + " cat >> \"$GITHUB_ENV\" < { "workflow-privileged-untrusted-checkout", ".github/workflows/github-env-ref.yml", ); + assertFinding( + audit.result, + "workflow-privileged-untrusted-checkout", + ".github/workflows/github-env-heredoc-ref.yml", + ); }); test("GITHUB_ENV taint does not flow backward to earlier steps", () => { @@ -2070,6 +2094,38 @@ test("workflow_run artifacts remain untrusted when later executed", () => { " - run: bash payload/run.sh", "", ].join("\n")); + write(repoRoot, ".github/workflows/workflow-run-gh-global-artifact.yml", [ + "name: workflow run gh global artifact", + "on:", + " workflow_run:", + " workflows: [verify]", + " types: [completed]", + "permissions: read-all", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - env:", + " RUN_ID: " + runIdExpression, + " run: gh -R example/public run download \"$RUN_ID\" --dir payload", + " - run: bash payload/run.sh", + "", + ].join("\n")); + write(repoRoot, ".github/workflows/workflow-run-gh-repository-artifact.yml", [ + "name: workflow run gh repository artifact", + "on:", + " workflow_run:", + " workflows: [verify]", + " types: [completed]", + "permissions: read-all", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - run: gh run download -n payload --dir payload", + " - run: bash payload/run.sh", + "", + ].join("\n")); write(repoRoot, ".github/workflows/workflow-download-composite-execute.yml", [ "name: workflow download composite execute", "on:", @@ -2135,6 +2191,8 @@ test("workflow_run artifacts remain untrusted when later executed", () => { ); for (const workflowName of [ "workflow-run-gh-artifact", + "workflow-run-gh-global-artifact", + "workflow-run-gh-repository-artifact", "workflow-download-composite-execute", "composite-download-workflow-execute", ]) { @@ -2257,6 +2315,9 @@ test("artifact execution recognizes common command wrappers and paths", () => { ["exec", "cd payload && exec bash run.sh"], ["absolute-interpreter", "cd payload && /bin/bash run.sh"], ["direct-path", "payload/run.sh"], + ["shell-group", "(bash payload/run.sh)"], + ["shell-group-chain", "(cd payload && bash run.sh)"], + ["python-option", "python -W ignore payload/run.py"], ]) { write(repoRoot, `.github/workflows/workflow-run-artifact-${name}.yml`, [ `name: workflow run artifact ${name}`, @@ -2316,6 +2377,9 @@ test("artifact execution recognizes common command wrappers and paths", () => { "exec", "absolute-interpreter", "direct-path", + "shell-group", + "shell-group-chain", + "python-option", "workflow-alias", "job-alias", "step-alias", @@ -2689,6 +2753,36 @@ test("tainted environment values cannot reach shell evaluators", () => { " run: echo ready & eval \"$COMMAND\"", "", ].join("\n")); + write(repoRoot, ".github/workflows/comment-indirect-eval.yml", [ + "name: comment indirect eval", + "on: issue_comment", + "permissions: read-all", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - env:", + " CODE: " + bodyExpression, + " run: |", + " NAME=CODE", + " eval \"${!NAME}\"", + "", + ].join("\n")); + write(repoRoot, ".github/workflows/comment-nameref-eval.yml", [ + "name: comment nameref eval", + "on: issue_comment", + "permissions: read-all", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - env:", + " CODE: " + bodyExpression, + " run: |", + " declare -n REF=CODE", + " eval \"$REF\"", + "", + ].join("\n")); commitAll(repoRoot, "add tainted shell evaluator"); const audit = runAudit(repoRoot); @@ -2704,6 +2798,13 @@ test("tainted environment values cannot reach shell evaluators", () => { "workflow-privileged-untrusted-script-interpolation", ".github/workflows/comment-background-eval.yml", ); + for (const form of ["indirect", "nameref"]) { + assertFinding( + audit.result, + "workflow-privileged-untrusted-script-interpolation", + `.github/workflows/comment-${form}-eval.yml`, + ); + } }); test("tainted script text cannot be piped into shell interpreters", () => { @@ -2722,6 +2823,19 @@ test("tainted script text cannot be piped into shell interpreters", () => { " run: printf '%s' \"$COMMAND\" | bash", "", ].join("\n")); + write(repoRoot, ".github/workflows/comment-combined-pipe-shell.yml", [ + "name: comment combined pipe shell", + "on: issue_comment", + "permissions: read-all", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - env:", + " COMMAND: " + bodyExpression, + " run: printf '%s' \"$COMMAND\" |& bash", + "", + ].join("\n")); commitAll(repoRoot, "add tainted shell pipeline"); const audit = runAudit(repoRoot); @@ -2732,6 +2846,11 @@ test("tainted script text cannot be piped into shell interpreters", () => { "workflow-privileged-untrusted-script-interpolation", ".github/workflows/comment-pipe-shell.yml", ); + assertFinding( + audit.result, + "workflow-privileged-untrusted-script-interpolation", + ".github/workflows/comment-combined-pipe-shell.yml", + ); }); test("tainted here-strings cannot feed shell interpreters", () => { @@ -2987,6 +3106,38 @@ test("tainted fetch state propagates through FETCH_HEAD checkouts", () => { " ./verify.sh", "", ].join("\n")); + write(repoRoot, ".github/workflows/fetched-head-update-ref.yml", [ + "name: fetched head update ref", + "on: pull_request_target", + "permissions: read-all", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - env:", + " PR_SHA: " + refExpression, + " run: |", + " git fetch origin \"$PR_SHA\"", + " git update-ref HEAD FETCH_HEAD", + " git reset --hard", + "", + ].join("\n")); + write(repoRoot, ".github/workflows/fetched-head-branch.yml", [ + "name: fetched head branch", + "on: pull_request_target", + "permissions: read-all", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - env:", + " PR_SHA: " + refExpression, + " run: |", + " git fetch origin \"$PR_SHA\"", + " git branch review-head FETCH_HEAD", + " git checkout review-head", + "", + ].join("\n")); commitAll(repoRoot, "add fetched head checkout"); const audit = runAudit(repoRoot); @@ -2997,6 +3148,13 @@ test("tainted fetch state propagates through FETCH_HEAD checkouts", () => { "workflow-privileged-untrusted-checkout", ".github/workflows/fetched-head-checkout.yml", ); + for (const form of ["update-ref", "branch"]) { + assertFinding( + audit.result, + "workflow-privileged-untrusted-checkout", + `.github/workflows/fetched-head-${form}.yml`, + ); + } }); test("untrusted shell values unrelated to a fixed checkout do not block", () => { From c066120e0af75587f58a4a83be2b369466c21606 Mon Sep 17 00:00:00 2001 From: Vladimir Date: Sat, 22 Aug 2026 09:26:59 +0800 Subject: [PATCH 29/37] Close nested evaluator and preload bypasses Recursively inspect shell substitutions for tainted evaluators, recognize parameter-expansion taint and workflow-run display titles, treat interpreter preload modules as artifact execution sources, and reject tainted Git SSH command templates at remote operations. Add focused regression coverage while preserving safe controls. --- .../scripts/public-source-release-audit.mjs | 283 +++++++++++++++--- .../public-source-release-audit.test.mjs | 156 ++++++++++ 2 files changed, 391 insertions(+), 48 deletions(-) diff --git a/public-source-release-audit/scripts/public-source-release-audit.mjs b/public-source-release-audit/scripts/public-source-release-audit.mjs index 7790336..eda93dd 100644 --- a/public-source-release-audit/scripts/public-source-release-audit.mjs +++ b/public-source-release-audit/scripts/public-source-release-audit.mjs @@ -3512,38 +3512,185 @@ function shellRunEvaluatesTaintedVariable(runSource, taintedBindings) { const evaluatorCommand = /^\s*(?:(?:builtin|command|exec)\s+)?(?:(?:\/usr\/bin\/)?env\s+(?:(?:-[^\s]+|[A-Za-z_][A-Za-z0-9_]*=[^\s]+)\s+)*)?(?:eval\b|(?:(?:\/[^/\s]+)*\/)?(?:bash|dash|fish|ksh|sh|zsh)\b[^;&|]*\s-c(?:\s+|(?=[^-\s])|$)|(?:(?:\/[^/\s]+)*\/)?(?:node|perl|python(?:\d+(?:\.\d+)*)?|ruby)\b[^;&|]*\s-(?:c|e)(?:\s+|(?=[^-\s])|$)|(?:(?:\/[^/\s]+)*\/)?php\b[^;&|]*\s-r(?:\s+|(?=[^-\s])|$)|(?:(?:\/[^/\s]+)*\/)?deno\b[^;&|]*\beval(?:\s|$)|(?:iex|invoke-expression)\b|(?:(?:\/[^/\s]+)*\/)?(?:powershell|pwsh)(?:\.exe)?\b[^;&|]*\s-(?:command|c)(?:\s+|(?=[^-\s])|$))/iu; const stdinInterpreterCommand = /^\s*(?:(?:command|exec)\s+)?(?:(?:\/usr\/bin\/)?env\s+(?:-[^\s]+\s+)*)?(?:(?:\/[^/\s]+)*\/)?(?:bash|dash|fish|ksh|powershell|pwsh|sh|zsh)(?:\.exe)?(?:\s|$)/iu; const hereInputInterpreterCommand = /^\s*(?:(?:command|exec)\s+)?(?:(?:\/usr\/bin\/)?env\s+(?:-[^\s]+\s+)*)?(?:(?:\/[^/\s]+)*\/)?(?:bash|dash|fish|ksh|powershell|pwsh|sh|zsh)(?:\.exe)?(?:\s+-[^\s]+)*\s+<< { + const localTaintedVariables = new Set(inheritedTaintedVariables); + for (const segment of shellCommandSegments(source)) { + if (/^\s*#/u.test(segment)) continue; + const executableSegment = stripShellCommandGrouping(segment); + const assignmentName = shellAssignmentName(executableSegment); + const segmentReferencesTaint = isUntrustedReusableValue( executableSegment, - taintedVariables, - anyEnvironmentVariableTainted, - ); - if (assignmentName && segmentReferencesTaint) { - taintedVariables.add(assignmentName); - } - let pipelineInputTainted = false; - for (const stage of shellPipelineStages(executableSegment)) { - const stageReferencesTaint = isUntrustedReusableValue(stage, taintedBindings) + taintedBindings, + ) || shellSourceReferencesTaintedVariable( - stage, - taintedVariables, + executableSegment, + localTaintedVariables, anyEnvironmentVariableTainted, ); - if (evaluatorCommand.test(stage) && (stageReferencesTaint || pipelineInputTainted)) { - return true; + if (assignmentName && segmentReferencesTaint) { + localTaintedVariables.add(assignmentName); + } + for (const substitution of shellCommandSubstitutions(segment)) { + if (depth >= 32) { + if (isUntrustedReusableValue(substitution, taintedBindings) + || shellSourceReferencesTaintedVariable( + substitution, + localTaintedVariables, + anyEnvironmentVariableTainted, + )) return true; + continue; + } + if (sourceEvaluatesTaint( + substitution, + localTaintedVariables, + depth + 1, + )) return true; } - if (hereInputInterpreterCommand.test(stage) && stageReferencesTaint) return true; - if (stdinInterpreterCommand.test(stage) && pipelineInputTainted) return true; - pipelineInputTainted ||= stageReferencesTaint; + let pipelineInputTainted = false; + for (const stage of shellPipelineStages(executableSegment)) { + const stageReferencesTaint = isUntrustedReusableValue(stage, taintedBindings) + || shellSourceReferencesTaintedVariable( + stage, + localTaintedVariables, + anyEnvironmentVariableTainted, + ); + if (evaluatorCommand.test(stage) && (stageReferencesTaint || pipelineInputTainted)) { + return true; + } + if ((anyEnvironmentVariableTainted + || localTaintedVariables.has("git_ssh_command")) + && shellStageMayInvokeGitSsh(stage)) return true; + if (hereInputInterpreterCommand.test(stage) && stageReferencesTaint) return true; + if (stdinInterpreterCommand.test(stage) && pipelineInputTainted) return true; + pipelineInputTainted ||= stageReferencesTaint; + } + } + return false; + }; + return sourceEvaluatesTaint(runSource, taintedVariables, 0); +} + +function shellCommandSubstitutions(source) { + const substitutions = []; + let quote; + for (let index = 0; index < source.length; index += 1) { + const character = source[index]; + if (character === "\\" && quote !== "'") { + index += 1; + continue; + } + if (character === "'" || character === '"') { + if (quote === character) quote = undefined; + else if (!quote) quote = character; + continue; + } + if (quote === "'") continue; + const parenthesizedSubstitution = ["$", "<", ">"].includes(character) + && source[index + 1] === "(" + && !(character === "$" && source[index + 2] === "("); + if (parenthesizedSubstitution) { + const end = shellParenthesizedSubstitutionEnd(source, index); + if (end === undefined) continue; + substitutions.push(source.slice(index + 2, end)); + index = end; + continue; + } + if (character === "`") { + const end = shellBacktickSubstitutionEnd(source, index); + if (end === undefined) continue; + substitutions.push(source.slice(index + 1, end)); + index = end; } } + return substitutions; +} + +function shellParenthesizedSubstitutionEnd(source, openingIndex) { + let depth = 1; + let quote; + for (let index = openingIndex + 2; index < source.length; index += 1) { + const character = source[index]; + if (character === "\\" && quote !== "'") { + index += 1; + continue; + } + if (character === "'" || character === '"') { + if (quote === character) quote = undefined; + else if (!quote) quote = character; + continue; + } + if (quote === "'") continue; + const nestedSubstitution = ["$", "<", ">"].includes(character) + && source[index + 1] === "(" + && !(character === "$" && source[index + 2] === "("); + if (nestedSubstitution) { + const nestedEnd = shellParenthesizedSubstitutionEnd(source, index); + if (nestedEnd === undefined) return undefined; + index = nestedEnd; + continue; + } + if (!quote && character === "(") depth += 1; + if (!quote && character === ")") { + depth -= 1; + if (depth === 0) return index; + } + } + return undefined; +} + +function shellBacktickSubstitutionEnd(source, openingIndex) { + for (let index = openingIndex + 1; index < source.length; index += 1) { + if (source[index] === "\\") { + index += 1; + continue; + } + if (source[index] === "`") return index; + } + return undefined; +} + +function shellStageMayInvokeGitSsh(stage) { + const words = shellCommandWords(stripShellCommandGrouping(stage)); + let cursor = 0; + while (cursor < words.length) { + if (/^[A-Za-z_][A-Za-z0-9_]*=/u.test(words[cursor])) { + cursor += 1; + continue; + } + const command = path.posix.basename(words[cursor]).toLowerCase(); + if (["builtin", "command", "exec", "nohup", "time"].includes(command)) { + cursor += 1; + while (words[cursor]?.startsWith("-")) cursor += 1; + continue; + } + if (command === "env") { + cursor += 1; + while (words[cursor]?.startsWith("-") + || /^[A-Za-z_][A-Za-z0-9_]*=/u.test(words[cursor] ?? "")) cursor += 1; + continue; + } + break; + } + if (path.posix.basename(words[cursor] ?? "").toLowerCase() !== "git") return false; + cursor += 1; + const globalOptionsWithValues = new Set([ + "-c", "-C", "--config-env", "--exec-path", "--git-dir", "--namespace", + "--super-prefix", "--work-tree", + ]); + while (words[cursor]?.startsWith("-")) { + const option = words[cursor]; + cursor += 1; + if (globalOptionsWithValues.has(option)) cursor += 1; + } + const command = words[cursor]?.toLowerCase(); + if ([ + "clone", "fetch", "ls-remote", "pull", "push", "receive-pack", + "send-pack", "upload-archive", "upload-pack", + ].includes(command)) return true; + const remaining = words.slice(cursor + 1).map((word) => word.toLowerCase()); + if (command === "archive") return remaining.some((word) => word.startsWith("--remote")); + if (command === "remote") return remaining.some((word) => ["update"].includes(word)); + if (command === "submodule") return remaining.some((word) => ["add", "update"].includes(word)); return false; } @@ -3932,21 +4079,20 @@ function shellRunExecutesArtifactPath( ); continue; } - const rawExecutionSource = shellArtifactExecutionSource(executableSegment); - const executionSource = rawExecutionSource - ? resolveStaticEnvironmentReferences(rawExecutionSource, localEnvironmentValues) - ?? rawExecutionSource - : undefined; - if (executionSource && artifactSourceMatchesPath( - executionSource, + const executionSources = shellArtifactExecutionSources(executableSegment) + .map((source) => ( + resolveStaticEnvironmentReferences(source, localEnvironmentValues) ?? source + )); + if (executionSources.some((source) => artifactSourceMatchesPath( + source, artifactPath, effectiveWorkingDirectory, - )) return true; + ))) return true; } return false; } -function shellArtifactExecutionSource(segment) { +function shellArtifactExecutionSources(segment) { const words = shellCommandWords(segment); let cursor = 0; while (cursor < words.length) { @@ -3980,47 +4126,85 @@ function shellArtifactExecutionSource(segment) { break; } const command = words[cursor]; - if (!command) return undefined; + if (!command) return []; cursor += 1; const commandName = path.posix.basename(command).toLowerCase(); - if ([".", "source"].includes(commandName)) return words[cursor]; + if ([".", "source"].includes(commandName)) return words[cursor] ? [words[cursor]] : []; const interpreters = new Set([ "bash", "dash", "deno", "fish", "ksh", "node", "perl", "php", "powershell", "powershell.exe", "pwsh", "pwsh.exe", "python", "python2", "python3", "ruby", "sh", "zsh", ]); if (interpreters.has(commandName) || /^python\d+(?:\.\d+)*$/u.test(commandName)) { + const executionSources = []; if (commandName === "deno" && words[cursor]?.toLowerCase() === "run") cursor += 1; while (cursor < words.length) { const argument = words[cursor]; cursor += 1; if (argument === "--") { - return words[cursor]?.startsWith("<(") + const source = words[cursor]?.startsWith("<(") ? shellProcessSubstitutionArtifactSource(words.slice(cursor)) : words[cursor]; + if (source) executionSources.push(source); + return executionSources; } if (argument.startsWith("<(")) { - return shellProcessSubstitutionArtifactSource([ + const source = shellProcessSubstitutionArtifactSource([ argument, ...words.slice(cursor), ]); + if (source) executionSources.push(source); + return executionSources; + } + if (/^(?:\d*)<$/u.test(argument)) { + if (words[cursor]) executionSources.push(words[cursor]); + return executionSources; } - if (/^(?:\d*)<$/u.test(argument)) return words[cursor]; const redirectedSource = /^(?:\d*)<([^<].*)$/u.exec(argument)?.[1]; - if (redirectedSource) return redirectedSource; + if (redirectedSource) { + executionSources.push(redirectedSource); + return executionSources; + } if (interpreterOptionIsExecutionMode(commandName, argument)) { - return undefined; + return executionSources; + } + const loadedSource = interpreterOptionLoadedSource( + commandName, + argument, + words[cursor], + ); + if (loadedSource) { + if (loadedSource.source) executionSources.push(loadedSource.source); + if (loadedSource.consumesNext) cursor += 1; + continue; } if (interpreterOptionConsumesValue(commandName, argument)) { cursor += 1; continue; } if (argument.startsWith("-")) continue; - return argument; + executionSources.push(argument); + return executionSources; } - return undefined; + return executionSources; } - return command.includes("/") ? command : undefined; + return command.includes("/") ? [command] : []; +} + +function interpreterOptionLoadedSource(commandName, argument, nextArgument) { + if (commandName !== "node") return undefined; + const option = argument.toLowerCase(); + const options = new Set([ + "-r", "--experimental-loader", "--import", "--loader", "--require", + ]); + if (options.has(option)) return { consumesNext: true, source: nextArgument }; + const longAssignment = /^--(?:experimental-loader|import|loader|require)=(.+)$/iu.exec( + argument, + ); + if (longAssignment) return { consumesNext: false, source: longAssignment[1] }; + const shortAssignment = /^-r=?(.+)$/u.exec(argument); + if (shortAssignment) return { consumesNext: false, source: shortAssignment[1] }; + return undefined; } function interpreterOptionIsExecutionMode(commandName, argument) { @@ -4038,7 +4222,10 @@ function interpreterOptionConsumesValue(commandName, argument) { } const options = new Map([ ["bash", new Set(["-o", "-O", "--init-file", "--rcfile"])], - ["node", new Set(["-r", "--conditions", "--import", "--loader", "--require"])], + ["node", new Set([ + "-r", "--conditions", "--experimental-loader", "--import", "--loader", + "--require", + ])], ["perl", new Set(["-f", "-i", "-m", "-M"])], ["ruby", new Set(["-e", "-i", "-I", "-r", "--encoding", "--external-encoding", "--internal-encoding"])], ]); @@ -4320,9 +4507,9 @@ function shellSourceReferencesTaintedVariable(source, taintedVariables, anyTaint if (/\$\{![A-Za-z_][A-Za-z0-9_]*(?:[*@])?\}/u.test(source) && (anyTainted || taintedVariables.size > 0)) return true; const references = [ - ...source.matchAll(/\$(?:env:)?(?:\{([A-Za-z_][A-Za-z0-9_]*)\}|([A-Za-z_][A-Za-z0-9_]*))/giu), + ...source.matchAll(/\$(?:env:([A-Za-z_][A-Za-z0-9_]*)|\{(?:env:)?([A-Za-z_][A-Za-z0-9_]*)|([A-Za-z_][A-Za-z0-9_]*))/giu), ...source.matchAll(/%([A-Za-z_][A-Za-z0-9_]*)%/gu), - ].map((match) => (match[1] ?? match[2]).toLowerCase()); + ].map((match) => (match[1] ?? match[2] ?? match[3]).toLowerCase()); return references.some((name) => anyTainted || taintedVariables.has(name)); } @@ -4362,7 +4549,7 @@ function isUntrustedCheckoutInput(key, value, taintedBindings = new Set()) { } function isUntrustedPullRequestRef(value) { - return /(?:github\.head_ref|github\.event\.(?:comment\.body|discussion\.(?:body|title)|issue\.(?:body|number|title))|pull_request\.(?:body|head|merge_commit_sha|title)|refs\/pull\/|workflow_run\.(?:head_branch|head_commit|head_sha|id|pull_requests\s*\[\s*\d+\s*\]\s*\.\s*head))/iu + return /(?:github\.head_ref|github\.event\.(?:comment\.body|discussion\.(?:body|title)|issue\.(?:body|number|title))|pull_request\.(?:body|head|merge_commit_sha|title)|refs\/pull\/|workflow_run\.(?:display_title|head_branch|head_commit|head_sha|id|pull_requests\s*\[\s*\d+\s*\]\s*\.\s*head))/iu .test(normalizeExpressionPropertyAccess(value)); } diff --git a/public-source-release-audit/tests/public-source-release-audit.test.mjs b/public-source-release-audit/tests/public-source-release-audit.test.mjs index 9b00c3b..157edca 100644 --- a/public-source-release-audit/tests/public-source-release-audit.test.mjs +++ b/public-source-release-audit/tests/public-source-release-audit.test.mjs @@ -2318,6 +2318,9 @@ test("artifact execution recognizes common command wrappers and paths", () => { ["shell-group", "(bash payload/run.sh)"], ["shell-group-chain", "(cd payload && bash run.sh)"], ["python-option", "python -W ignore payload/run.py"], + ["node-require", "node --require payload/hook.js trusted.js"], + ["node-import", "node --import=payload/hook.mjs trusted.js"], + ["node-loader", "node --loader payload/loader.mjs trusted.js"], ]) { write(repoRoot, `.github/workflows/workflow-run-artifact-${name}.yml`, [ `name: workflow run artifact ${name}`, @@ -2380,6 +2383,9 @@ test("artifact execution recognizes common command wrappers and paths", () => { "shell-group", "shell-group-chain", "python-option", + "node-require", + "node-import", + "node-loader", "workflow-alias", "job-alias", "step-alias", @@ -2807,6 +2813,117 @@ test("tainted environment values cannot reach shell evaluators", () => { } }); +test("tainted shell values cannot hide in parameter operators or command substitutions", () => { + const repoRoot = makeRepository(); + const bodyExpression = ["$", "{{ github.event.comment.body }}"].join(""); + for (const [form, command] of [ + ["parameter-operator", "eval \"${CODE:-}\""], + ["parameter-substring", "eval \"${CODE:1}\""], + ["parameter-replacement", "eval \"${CODE//x/}\""], + ["command-substitution", "echo \"$(eval \"$CODE\")\""], + ["unquoted-command-substitution", "echo $(eval \"$CODE\")"], + ["nested-command-substitution", "echo \"$(printf '%s' \"$(eval \"$CODE\")\")\""], + ["backtick-substitution", "echo \"`eval \\\"$CODE\\\"`\""], + ["process-substitution", "cat <(eval \"$CODE\")"], + ]) { + write(repoRoot, `.github/workflows/comment-${form}-eval.yml`, [ + `name: comment ${form} eval`, + "on: issue_comment", + "permissions: read-all", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - env:", + " CODE: " + bodyExpression, + " run: |", + " " + command, + "", + ].join("\n")); + } + commitAll(repoRoot, "add hidden tainted shell evaluators"); + + const audit = runAudit(repoRoot); + + assert.equal(audit.status, 1); + for (const form of [ + "parameter-operator", + "parameter-substring", + "parameter-replacement", + "command-substitution", + "unquoted-command-substitution", + "nested-command-substitution", + "backtick-substitution", + "process-substitution", + ]) { + assertFinding( + audit.result, + "workflow-privileged-untrusted-script-interpolation", + `.github/workflows/comment-${form}-eval.yml`, + ); + } +}); + +test("tainted Git SSH command templates are implicit shell evaluators", () => { + const unsafeRepo = makeRepository(); + const safeRepo = makeRepository(); + const bodyExpression = ["$", "{{ github.event.comment.body }}"].join(""); + write(unsafeRepo, ".github/workflows/comment-git-ssh-command.yml", [ + "name: comment Git SSH command", + "on: issue_comment", + "permissions: read-all", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - env:", + " GIT_SSH_COMMAND: " + bodyExpression, + " run: git ls-remote ssh://example.invalid/repository", + "", + ].join("\n")); + write(unsafeRepo, ".github/workflows/comment-inline-git-ssh-command.yml", [ + "name: comment inline Git SSH command", + "on: issue_comment", + "permissions: read-all", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - env:", + " COMMAND: " + bodyExpression, + " run: GIT_SSH_COMMAND=\"$COMMAND\" git fetch origin", + "", + ].join("\n")); + write(safeRepo, ".github/workflows/comment-git-message.yml", [ + "name: comment Git message", + "on: issue_comment", + "permissions: read-all", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - env:", + " MESSAGE: " + bodyExpression, + " run: git ls-remote https://example.invalid/repository", + "", + ].join("\n")); + commitAll(unsafeRepo, "add tainted Git SSH commands"); + commitAll(safeRepo, "add safe Git command"); + + const unsafeAudit = runAudit(unsafeRepo); + const safeAudit = runAudit(safeRepo); + + assert.equal(unsafeAudit.status, 1); + for (const form of ["comment-git-ssh-command", "comment-inline-git-ssh-command"]) { + assertFinding( + unsafeAudit.result, + "workflow-privileged-untrusted-script-interpolation", + `.github/workflows/${form}.yml`, + ); + } + assert.equal(safeAudit.status, 0); +}); + test("tainted script text cannot be piped into shell interpreters", () => { const repoRoot = makeRepository(); const bodyExpression = ["$", "{{ github.event.comment.body }}"].join(""); @@ -4468,6 +4585,40 @@ test("workflow_run head metadata is untrusted script data", () => { " - run: echo \"" + headCommitMessageExpression + "\"", "", ].join("\n")); + const displayTitleExpression = [ + "$", + "{{ github.event.workflow_run.display_title }}", + ].join(""); + const pullRequestTitleExpression = [ + "$", + "{{ github.event.pull_request.title }}", + ].join(""); + write(repoRoot, ".github/workflows/verify-pull-request-title.yml", [ + "name: verify pull request title", + "run-name: " + pullRequestTitleExpression, + "on: pull_request", + "permissions: read-all", + "jobs:", + " verify:", + " runs-on: ubuntu-latest", + " steps:", + " - run: echo verified", + "", + ].join("\n")); + write(repoRoot, ".github/workflows/workflow-run-display-title-script.yml", [ + "name: workflow run display title script", + "on:", + " workflow_run:", + " workflows: [verify pull request title]", + " types: [completed]", + "permissions: read-all", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - run: echo \"" + displayTitleExpression + "\"", + "", + ].join("\n")); commitAll(repoRoot, "add workflow run head branch script"); const audit = runAudit(repoRoot); @@ -4483,6 +4634,11 @@ test("workflow_run head metadata is untrusted script data", () => { "workflow-privileged-untrusted-script-interpolation", ".github/workflows/workflow-run-head-commit-script.yml", ); + assertFinding( + audit.result, + "workflow-privileged-untrusted-script-interpolation", + ".github/workflows/workflow-run-display-title-script.yml", + ); }); test("unsafe public workflow execution is release-blocking", () => { From 3462dfd23b937fd9b49b086188d64417c88d6d79 Mon Sep 17 00:00:00 2001 From: Vladimir Date: Sat, 22 Aug 2026 09:50:02 +0800 Subject: [PATCH 30/37] Close implicit loader and output alias gaps Handle nested legacy substitutions, Git SSH configuration sinks, runtime stdin and awk evaluation, static Node preload options, makefile artifact sources, tainted archive extraction, and aliases of GitHub environment files. Add focused regressions and a matching GITHUB_ENV alias guard. --- .../scripts/public-source-release-audit.mjs | 357 ++++++++++++++++-- .../public-source-release-audit.test.mjs | 178 ++++++++- 2 files changed, 502 insertions(+), 33 deletions(-) diff --git a/public-source-release-audit/scripts/public-source-release-audit.mjs b/public-source-release-audit/scripts/public-source-release-audit.mjs index eda93dd..303c3e2 100644 --- a/public-source-release-audit/scripts/public-source-release-audit.mjs +++ b/public-source-release-audit/scripts/public-source-release-audit.mjs @@ -1689,6 +1689,7 @@ function githubEnvironmentWriteBindings(runSource, taintedBindings) { ? [binding.slice("env.".length).toLowerCase()] : [] ))); + const environmentFileAliases = new Set(["github_env"]); const names = new Set(); for (const segment of shellCommandSegments(runSource)) { const segmentReferencesTaint = isUntrustedReusableValue(segment, taintedBindings) @@ -1699,8 +1700,13 @@ function githubEnvironmentWriteBindings(runSource, taintedBindings) { ); const assignmentName = shellAssignmentName(segment); if (assignmentName && segmentReferencesTaint) taintedVariables.add(assignmentName); + updateShellEnvironmentFileAliases(segment, environmentFileAliases); if (!segmentReferencesTaint - || !/GITHUB_ENV/iu.test(segment) + || !shellSourceReferencesTaintedVariable( + segment, + environmentFileAliases, + false, + ) || !/(?:>>?|\b(?:Add-Content|Out-File|Set-Content|tee)\b)/iu.test(segment)) { continue; } @@ -1719,6 +1725,7 @@ function githubOutputWriteIsTainted(runSource, taintedBindings) { ? [binding.slice("env.".length).toLowerCase()] : [] ))); + const environmentFileAliases = new Set(["github_output"]); for (const segment of shellCommandSegments(runSource)) { const segmentReferencesTaint = isUntrustedReusableValue(segment, taintedBindings) || shellSourceReferencesTaintedVariable( @@ -1730,7 +1737,12 @@ function githubOutputWriteIsTainted(runSource, taintedBindings) { if (assignmentName && segmentReferencesTaint) { taintedVariables.add(assignmentName); } - if (/GITHUB_OUTPUT/iu.test(segment) + updateShellEnvironmentFileAliases(segment, environmentFileAliases); + if (shellSourceReferencesTaintedVariable( + segment, + environmentFileAliases, + false, + ) && /(?:>>?|\b(?:Add-Content|Out-File|Set-Content|tee)\b)/iu.test(segment) && segmentReferencesTaint) return true; } @@ -1742,8 +1754,31 @@ function mergeTaintedBindings(...bindingSets) { } function shellAssignmentName(source) { - const assignment = /^\s*(?:(?:declare|export|local|readonly|typeset)(?:\s+-[^\s]+)*\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*\+?=|^\s*\$([A-Za-z_][A-Za-z0-9_]*)\s*[+*/%\-]?=/iu.exec(source); - return (assignment?.[1] ?? assignment?.[2])?.toLowerCase(); + return shellAssignmentBinding(source)?.name; +} + +function shellAssignmentBinding(source) { + const shellAssignment = /^\s*(?:(?:declare|export|local|readonly|typeset)(?:\s+-[^\s]+)*\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*(\+?=)\s*("[^"]*"|'[^']*'|[^\s;&|]*)/u.exec(source); + const powershellAssignment = /^\s*\$([A-Za-z_][A-Za-z0-9_]*)\s*([+*/%\-]?=)\s*("[^"]*"|'[^']*'|[^\s;&|]*)/u.exec(source); + const assignment = shellAssignment ?? powershellAssignment; + if (!assignment) return undefined; + return { + name: assignment[1].toLowerCase(), + operator: assignment[2], + value: assignment[3], + }; +} + +function updateShellEnvironmentFileAliases(source, aliases) { + const assignment = shellAssignmentBinding(source); + if (!assignment) return; + const referencesAlias = shellSourceReferencesTaintedVariable( + assignment.value, + aliases, + false, + ); + if (referencesAlias) aliases.add(assignment.name); + else if (assignment.operator !== "+=") aliases.delete(assignment.name); } function workflowLocalActionCalls( @@ -3510,8 +3545,6 @@ function shellRunEvaluatesTaintedVariable(runSource, taintedBindings) { ))); const anyEnvironmentVariableTainted = taintedBindings.has("env.*"); const evaluatorCommand = /^\s*(?:(?:builtin|command|exec)\s+)?(?:(?:\/usr\/bin\/)?env\s+(?:(?:-[^\s]+|[A-Za-z_][A-Za-z0-9_]*=[^\s]+)\s+)*)?(?:eval\b|(?:(?:\/[^/\s]+)*\/)?(?:bash|dash|fish|ksh|sh|zsh)\b[^;&|]*\s-c(?:\s+|(?=[^-\s])|$)|(?:(?:\/[^/\s]+)*\/)?(?:node|perl|python(?:\d+(?:\.\d+)*)?|ruby)\b[^;&|]*\s-(?:c|e)(?:\s+|(?=[^-\s])|$)|(?:(?:\/[^/\s]+)*\/)?php\b[^;&|]*\s-r(?:\s+|(?=[^-\s])|$)|(?:(?:\/[^/\s]+)*\/)?deno\b[^;&|]*\beval(?:\s|$)|(?:iex|invoke-expression)\b|(?:(?:\/[^/\s]+)*\/)?(?:powershell|pwsh)(?:\.exe)?\b[^;&|]*\s-(?:command|c)(?:\s+|(?=[^-\s])|$))/iu; - const stdinInterpreterCommand = /^\s*(?:(?:command|exec)\s+)?(?:(?:\/usr\/bin\/)?env\s+(?:-[^\s]+\s+)*)?(?:(?:\/[^/\s]+)*\/)?(?:bash|dash|fish|ksh|powershell|pwsh|sh|zsh)(?:\.exe)?(?:\s|$)/iu; - const hereInputInterpreterCommand = /^\s*(?:(?:command|exec)\s+)?(?:(?:\/usr\/bin\/)?env\s+(?:-[^\s]+\s+)*)?(?:(?:\/[^/\s]+)*\/)?(?:bash|dash|fish|ksh|powershell|pwsh|sh|zsh)(?:\.exe)?(?:\s+-[^\s]+)*\s+<< { const localTaintedVariables = new Set(inheritedTaintedVariables); for (const segment of shellCommandSegments(source)) { @@ -3548,20 +3581,34 @@ function shellRunEvaluatesTaintedVariable(runSource, taintedBindings) { } let pipelineInputTainted = false; for (const stage of shellPipelineStages(executableSegment)) { + const sinkStage = shellStageWithoutCommandWrappers(stage); const stageReferencesTaint = isUntrustedReusableValue(stage, taintedBindings) || shellSourceReferencesTaintedVariable( stage, localTaintedVariables, anyEnvironmentVariableTainted, ); - if (evaluatorCommand.test(stage) && (stageReferencesTaint || pipelineInputTainted)) { + if (evaluatorCommand.test(sinkStage) + && (stageReferencesTaint || pipelineInputTainted)) { return true; } - if ((anyEnvironmentVariableTainted - || localTaintedVariables.has("git_ssh_command")) - && shellStageMayInvokeGitSsh(stage)) return true; - if (hereInputInterpreterCommand.test(stage) && stageReferencesTaint) return true; - if (stdinInterpreterCommand.test(stage) && pipelineInputTainted) return true; + if (shellStageEvaluatesTaintedAwkProgram( + sinkStage, + taintedBindings, + localTaintedVariables, + anyEnvironmentVariableTainted, + )) return true; + if (shellStageMayInvokeGitSsh(stage) + && (anyEnvironmentVariableTainted + || localTaintedVariables.has("git_ssh_command") + || shellStageHasTaintedGitSshOverride( + stage, + taintedBindings, + localTaintedVariables, + anyEnvironmentVariableTainted, + ))) return true; + if (shellStageHasProgramHereInput(sinkStage) && stageReferencesTaint) return true; + if (shellStageExecutesStdinAsProgram(sinkStage) && pipelineInputTainted) return true; pipelineInputTainted ||= stageReferencesTaint; } } @@ -3598,7 +3645,7 @@ function shellCommandSubstitutions(source) { if (character === "`") { const end = shellBacktickSubstitutionEnd(source, index); if (end === undefined) continue; - substitutions.push(source.slice(index + 1, end)); + substitutions.push(source.slice(index + 1, end).replace(/\\`/gu, "`")); index = end; } } @@ -3649,15 +3696,12 @@ function shellBacktickSubstitutionEnd(source, openingIndex) { return undefined; } -function shellStageMayInvokeGitSsh(stage) { +function shellStageWithoutCommandWrappers(stage) { const words = shellCommandWords(stripShellCommandGrouping(stage)); let cursor = 0; while (cursor < words.length) { - if (/^[A-Za-z_][A-Za-z0-9_]*=/u.test(words[cursor])) { - cursor += 1; - continue; - } - const command = path.posix.basename(words[cursor]).toLowerCase(); + while (/^[A-Za-z_][A-Za-z0-9_]*=/u.test(words[cursor] ?? "")) cursor += 1; + const command = path.posix.basename(words[cursor] ?? "").toLowerCase(); if (["builtin", "command", "exec", "nohup", "time"].includes(command)) { cursor += 1; while (words[cursor]?.startsWith("-")) cursor += 1; @@ -3671,16 +3715,113 @@ function shellStageMayInvokeGitSsh(stage) { } break; } - if (path.posix.basename(words[cursor] ?? "").toLowerCase() !== "git") return false; - cursor += 1; - const globalOptionsWithValues = new Set([ - "-c", "-C", "--config-env", "--exec-path", "--git-dir", "--namespace", - "--super-prefix", "--work-tree", - ]); + return words.slice(cursor).join(" "); +} + +function shellStageHasProgramHereInput(stage) { + const redirectIndex = stage.indexOf("<<<"); + return redirectIndex >= 0 + && shellStageExecutesStdinAsProgram(stage.slice(0, redirectIndex)); +} + +function shellStageExecutesStdinAsProgram(stage) { + const words = shellCommandWords(stage); + const commandName = path.posix.basename(words[0] ?? "").toLowerCase(); + if ([ + "bash", "dash", "fish", "ksh", "powershell", "powershell.exe", "pwsh", + "pwsh.exe", "sh", "zsh", + ].includes(commandName)) return true; + if (!["node", "perl", "php", "ruby"].includes(commandName) + && !/^python\d*(?:\.\d+)*$/u.test(commandName)) return false; + let cursor = 1; + while (cursor < words.length) { + const argument = words[cursor]; + cursor += 1; + if (argument === "-") return true; + if (["--help", "--version", "-h", "-v", "-V"].includes(argument)) return false; + if (interpreterOptionIsExecutionMode(commandName, argument) + || /^(?:-[cepr])[^-\s].*/u.test(argument)) return false; + const loadedSource = interpreterOptionLoadedSource( + commandName, + argument, + words[cursor], + ); + if (loadedSource?.consumesNext) { + cursor += 1; + continue; + } + if (interpreterOptionConsumesValue(commandName, argument)) { + cursor += 1; + continue; + } + if (argument.startsWith("-")) continue; + return false; + } + return true; +} + +function shellStageEvaluatesTaintedAwkProgram( + stage, + taintedBindings, + taintedVariables, + anyEnvironmentVariableTainted, +) { + const words = shellCommandWords(stage); + const commandName = path.posix.basename(words[0] ?? "").toLowerCase(); + if (!["awk", "gawk", "mawk", "nawk"].includes(commandName)) return false; + const programIsTainted = (program) => program + && (isUntrustedReusableValue(program, taintedBindings) + || shellSourceReferencesTaintedVariable( + program, + taintedVariables, + anyEnvironmentVariableTainted, + )); + let cursor = 1; + let hasProgramFile = false; + while (cursor < words.length) { + const argument = words[cursor]; + cursor += 1; + if (["-e", "--source"].includes(argument)) { + if (programIsTainted(words[cursor])) return true; + cursor += 1; + continue; + } + const inlineProgram = /^--source=(.*)$/iu.exec(argument)?.[1] + ?? /^-e(.+)$/u.exec(argument)?.[1]; + if (inlineProgram) { + if (programIsTainted(inlineProgram)) return true; + continue; + } + if (["-f", "--file"].includes(argument)) { + hasProgramFile = true; + cursor += 1; + continue; + } + if (/^--file=/iu.test(argument) || /^-f.+/u.test(argument)) { + hasProgramFile = true; + continue; + } + if (["-F", "-v", "--assign", "--field-separator"].includes(argument)) { + cursor += 1; + continue; + } + if (argument === "--") { + return !hasProgramFile && programIsTainted(words[cursor]); + } + if (argument.startsWith("-")) continue; + return !hasProgramFile && programIsTainted(argument); + } + return false; +} + +function shellStageMayInvokeGitSsh(stage) { + const words = shellGitArguments(stage); + if (!words) return false; + let cursor = 0; while (words[cursor]?.startsWith("-")) { const option = words[cursor]; cursor += 1; - if (globalOptionsWithValues.has(option)) cursor += 1; + if (gitGlobalOptionConsumesNext(option)) cursor += 1; } const command = words[cursor]?.toLowerCase(); if ([ @@ -3694,6 +3835,86 @@ function shellStageMayInvokeGitSsh(stage) { return false; } +function shellStageHasTaintedGitSshOverride( + stage, + taintedBindings, + taintedVariables, + anyEnvironmentVariableTainted, +) { + const words = shellGitArguments(stage); + if (!words) return false; + for (let cursor = 0; cursor < words.length;) { + const argument = words[cursor]; + if (!argument.startsWith("-")) break; + let configValue; + let configEnvironmentValue; + if (argument === "-c") { + configValue = words[cursor + 1]; + cursor += 2; + } else if (/^-c[^-]/u.test(argument)) { + configValue = argument.slice(2); + cursor += 1; + } else if (argument === "--config-env") { + configEnvironmentValue = words[cursor + 1]; + cursor += 2; + } else { + configEnvironmentValue = /^--config-env=(.*)$/iu.exec(argument)?.[1]; + cursor += gitGlobalOptionConsumesNext(argument) ? 2 : 1; + } + const sshCommand = /^core\.sshcommand=(.*)$/iu.exec(configValue ?? "")?.[1]; + if (sshCommand && (isUntrustedReusableValue(sshCommand, taintedBindings) + || shellSourceReferencesTaintedVariable( + sshCommand, + taintedVariables, + anyEnvironmentVariableTainted, + ))) return true; + const environmentMatch = /^core\.sshcommand=([A-Za-z_][A-Za-z0-9_]*)$/iu.exec( + configEnvironmentValue ?? "", + ); + const environmentName = environmentMatch?.[1].toLowerCase(); + if (environmentName + && (anyEnvironmentVariableTainted || taintedVariables.has(environmentName))) { + return true; + } + } + return false; +} + +function gitGlobalOptionConsumesNext(option) { + return new Set([ + "-c", "-C", "--config-env", "--exec-path", "--git-dir", "--namespace", + "--super-prefix", "--work-tree", + ]).has(option); +} + +function shellGitArguments(stage) { + const words = shellCommandWords(stripShellCommandGrouping(stage)); + let cursor = 0; + while (cursor < words.length) { + if (/^[A-Za-z_][A-Za-z0-9_]*=/u.test(words[cursor])) { + cursor += 1; + continue; + } + const command = path.posix.basename(words[cursor]).toLowerCase(); + if (["builtin", "command", "exec", "nohup", "time"].includes(command)) { + cursor += 1; + while (words[cursor]?.startsWith("-")) cursor += 1; + continue; + } + if (command === "env") { + cursor += 1; + while (words[cursor]?.startsWith("-") + || /^[A-Za-z_][A-Za-z0-9_]*=/u.test(words[cursor] ?? "")) cursor += 1; + continue; + } + break; + } + if (path.posix.basename(words[cursor] ?? "").toLowerCase() !== "git") { + return undefined; + } + return words.slice(cursor + 1); +} + function shellPipelineStages(segment) { const stages = []; let current = ""; @@ -4079,7 +4300,10 @@ function shellRunExecutesArtifactPath( ); continue; } - const executionSources = shellArtifactExecutionSources(executableSegment) + const executionSources = shellArtifactExecutionSources( + executableSegment, + localEnvironmentValues, + ) .map((source) => ( resolveStaticEnvironmentReferences(source, localEnvironmentValues) ?? source )); @@ -4092,7 +4316,7 @@ function shellRunExecutesArtifactPath( return false; } -function shellArtifactExecutionSources(segment) { +function shellArtifactExecutionSources(segment, environmentValues = new Map()) { const words = shellCommandWords(segment); let cursor = 0; while (cursor < words.length) { @@ -4130,13 +4354,18 @@ function shellArtifactExecutionSources(segment) { cursor += 1; const commandName = path.posix.basename(command).toLowerCase(); if ([".", "source"].includes(commandName)) return words[cursor] ? [words[cursor]] : []; + if (["gmake", "make"].includes(commandName)) { + return makefileExecutionSources(words.slice(cursor)); + } const interpreters = new Set([ "bash", "dash", "deno", "fish", "ksh", "node", "perl", "php", "powershell", "powershell.exe", "pwsh", "pwsh.exe", "python", "python2", "python3", "ruby", "sh", "zsh", ]); if (interpreters.has(commandName) || /^python\d+(?:\.\d+)*$/u.test(commandName)) { - const executionSources = []; + const executionSources = commandName === "node" + ? nodeOptionsLoadedSources(environmentValues.get("node_options")) + : []; if (commandName === "deno" && words[cursor]?.toLowerCase() === "run") cursor += 1; while (cursor < words.length) { const argument = words[cursor]; @@ -4191,6 +4420,58 @@ function shellArtifactExecutionSources(segment) { return command.includes("/") ? [command] : []; } +function makefileExecutionSources(arguments_) { + const sources = []; + let directory = "."; + for (let cursor = 0; cursor < arguments_.length;) { + const argument = arguments_[cursor]; + cursor += 1; + if (["-C", "--directory"].includes(argument)) { + directory = arguments_[cursor] ?? directory; + cursor += 1; + continue; + } + const inlineDirectory = /^(?:-C|--directory=)(.+)$/u.exec(argument)?.[1]; + if (inlineDirectory) { + directory = inlineDirectory; + continue; + } + if (["-f", "--file", "--makefile"].includes(argument)) { + if (arguments_[cursor]) sources.push(arguments_[cursor]); + cursor += 1; + continue; + } + const inlineMakefile = /^(?:-f|--file=|--makefile=)(.+)$/u.exec(argument)?.[1]; + if (inlineMakefile) sources.push(inlineMakefile); + } + const selectedSources = sources.length > 0 + ? sources + : ["GNUmakefile", "makefile", "Makefile"]; + return selectedSources.map((source) => ( + directory === "." || path.posix.isAbsolute(source) + ? source + : path.posix.join(directory, source) + )); +} + +function nodeOptionsLoadedSources(options) { + if (!options) return []; + const words = shellCommandWords(options); + const sources = []; + for (let cursor = 0; cursor < words.length;) { + const loadedSource = interpreterOptionLoadedSource( + "node", + words[cursor], + words[cursor + 1], + ); + cursor += 1; + if (!loadedSource) continue; + if (loadedSource.source) sources.push(loadedSource.source); + if (loadedSource.consumesNext) cursor += 1; + } + return sources; +} + function interpreterOptionLoadedSource(commandName, argument, nextArgument) { if (commandName !== "node") return undefined; const option = argument.toLowerCase(); @@ -4468,7 +4749,8 @@ function shellRunGitTaintAnalysis(runSource, taintedBindings) { )); const materializesTaintedHead = currentHeadTainted && /\bgit(?:\s+--?[^\s]+(?:[=\s][^\s]+)?)*\s+reset\b[^\n]*\s--(?:hard|keep|merge)\b/iu.test(line); - if (checkoutCommand.test(line) && (lineIsTainted + const materializesTaintedArchive = shellLineExtractsGitArchive(line); + if ((checkoutCommand.test(line) || materializesTaintedArchive) && (lineIsTainted || lineReferencesTaintedRef || materializesTaintedHead || (fetchedHeadTainted && /\bFETCH_HEAD\b/iu.test(line)))) { @@ -4478,6 +4760,21 @@ function shellRunGitTaintAnalysis(runSource, taintedBindings) { return { hasUntrustedCheckout: false, taintsFetchHead }; } +function shellLineExtractsGitArchive(source) { + const stages = shellPipelineStages(source); + return stages.some((stage, index) => ( + /\bgit(?:\s+--?[^\s]+(?:[=\s][^\s]+)?)*\s+archive\b/iu.test(stage) + && stages.slice(index + 1).some((candidate) => { + const words = shellCommandWords(shellStageWithoutCommandWrappers(candidate)); + const command = path.posix.basename(words[0] ?? "").toLowerCase(); + if (!["bsdtar", "gtar", "tar"].includes(command)) return false; + return words.slice(1).some((argument) => ( + argument === "--extract" || /^-?[A-Za-z]*x[A-Za-z]*$/u.test(argument) + )); + }) + )); +} + function shellGitPersistedRef(source) { for (const pattern of [ /\bgit(?:\s+--?[^\s]+(?:[=\s][^\s]+)?)*\s+update-ref\s+(?:--?[^\s]+\s+)*([^\s]+)\s+([^\s]+)/iu, diff --git a/public-source-release-audit/tests/public-source-release-audit.test.mjs b/public-source-release-audit/tests/public-source-release-audit.test.mjs index 157edca..f8510c7 100644 --- a/public-source-release-audit/tests/public-source-release-audit.test.mjs +++ b/public-source-release-audit/tests/public-source-release-audit.test.mjs @@ -1513,6 +1513,24 @@ test("untrusted refs persisted through GITHUB_ENV reach later steps", () => { " ref: " + envExpression, "", ].join("\n")); + write(repoRoot, ".github/workflows/github-env-alias-ref.yml", [ + "name: GITHUB_ENV alias ref", + "on: pull_request_target", + "permissions: read-all", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - env:", + " SOURCE_REF: " + headExpression, + " run: |", + " DESTINATION=\"$GITHUB_ENV\"", + " echo \"PR_REF=$SOURCE_REF\" >> \"$DESTINATION\"", + " - uses: actions/checkout@" + "a".repeat(40), + " with:", + " ref: " + envExpression, + "", + ].join("\n")); commitAll(repoRoot, "add persisted environment checkout workflow"); const audit = runAudit(repoRoot); @@ -1528,6 +1546,11 @@ test("untrusted refs persisted through GITHUB_ENV reach later steps", () => { "workflow-privileged-untrusted-checkout", ".github/workflows/github-env-heredoc-ref.yml", ); + assertFinding( + audit.result, + "workflow-privileged-untrusted-checkout", + ".github/workflows/github-env-alias-ref.yml", + ); }); test("GITHUB_ENV taint does not flow backward to earlier steps", () => { @@ -2321,6 +2344,7 @@ test("artifact execution recognizes common command wrappers and paths", () => { ["node-require", "node --require payload/hook.js trusted.js"], ["node-import", "node --import=payload/hook.mjs trusted.js"], ["node-loader", "node --loader payload/loader.mjs trusted.js"], + ["make-file", "make -f payload/Makefile"], ]) { write(repoRoot, `.github/workflows/workflow-run-artifact-${name}.yml`, [ `name: workflow run artifact ${name}`, @@ -2341,6 +2365,26 @@ test("artifact execution recognizes common command wrappers and paths", () => { "", ].join("\n")); } + write(repoRoot, ".github/workflows/workflow-run-artifact-node-options.yml", [ + "name: workflow run artifact Node options", + "on:", + " workflow_run:", + " workflows: [verify]", + " types: [completed]", + "permissions: read-all", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - uses: actions/download-artifact@" + "a".repeat(40), + " with:", + " run-id: " + runIdExpression, + " path: payload", + " - env:", + " NODE_OPTIONS: --require ./payload/hook.js", + " run: node trusted.js", + "", + ].join("\n")); for (const scope of ["workflow", "job", "step", "shell"]) { write(repoRoot, `.github/workflows/workflow-run-artifact-${scope}-alias.yml`, [ `name: workflow run artifact ${scope} alias`, @@ -2386,6 +2430,8 @@ test("artifact execution recognizes common command wrappers and paths", () => { "node-require", "node-import", "node-loader", + "make-file", + "node-options", "workflow-alias", "job-alias", "step-alias", @@ -2824,6 +2870,7 @@ test("tainted shell values cannot hide in parameter operators or command substit ["unquoted-command-substitution", "echo $(eval \"$CODE\")"], ["nested-command-substitution", "echo \"$(printf '%s' \"$(eval \"$CODE\")\")\""], ["backtick-substitution", "echo \"`eval \\\"$CODE\\\"`\""], + ["nested-backtick-substitution", "echo `echo \\`eval \"$CODE\"\\``"], ["process-substitution", "cat <(eval \"$CODE\")"], ]) { write(repoRoot, `.github/workflows/comment-${form}-eval.yml`, [ @@ -2854,6 +2901,7 @@ test("tainted shell values cannot hide in parameter operators or command substit "unquoted-command-substitution", "nested-command-substitution", "backtick-substitution", + "nested-backtick-substitution", "process-substitution", ]) { assertFinding( @@ -2894,6 +2942,32 @@ test("tainted Git SSH command templates are implicit shell evaluators", () => { " run: GIT_SSH_COMMAND=\"$COMMAND\" git fetch origin", "", ].join("\n")); + write(unsafeRepo, ".github/workflows/comment-git-core-ssh-command.yml", [ + "name: comment Git core SSH command", + "on: issue_comment", + "permissions: read-all", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - env:", + " COMMAND: " + bodyExpression, + " run: git -c core.sshCommand=\"$COMMAND\" ls-remote ssh://example.invalid/repository", + "", + ].join("\n")); + write(unsafeRepo, ".github/workflows/comment-git-config-env-ssh-command.yml", [ + "name: comment Git config env SSH command", + "on: issue_comment", + "permissions: read-all", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - env:", + " SSH_COMMAND: " + bodyExpression, + " run: git --config-env=core.sshCommand=SSH_COMMAND ls-remote ssh://example.invalid/repository", + "", + ].join("\n")); write(safeRepo, ".github/workflows/comment-git-message.yml", [ "name: comment Git message", "on: issue_comment", @@ -2914,7 +2988,12 @@ test("tainted Git SSH command templates are implicit shell evaluators", () => { const safeAudit = runAudit(safeRepo); assert.equal(unsafeAudit.status, 1); - for (const form of ["comment-git-ssh-command", "comment-inline-git-ssh-command"]) { + for (const form of [ + "comment-git-ssh-command", + "comment-inline-git-ssh-command", + "comment-git-core-ssh-command", + "comment-git-config-env-ssh-command", + ]) { assertFinding( unsafeAudit.result, "workflow-privileged-untrusted-script-interpolation", @@ -2953,6 +3032,21 @@ test("tainted script text cannot be piped into shell interpreters", () => { " run: printf '%s' \"$COMMAND\" |& bash", "", ].join("\n")); + for (const runtime of ["node", "perl", "php", "python3", "ruby"]) { + write(repoRoot, `.github/workflows/comment-pipe-${runtime}.yml`, [ + `name: comment pipe ${runtime}`, + "on: issue_comment", + "permissions: read-all", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - env:", + " COMMAND: " + bodyExpression, + ` run: printf '%s' "$COMMAND" | ${runtime}`, + "", + ].join("\n")); + } commitAll(repoRoot, "add tainted shell pipeline"); const audit = runAudit(repoRoot); @@ -2968,6 +3062,13 @@ test("tainted script text cannot be piped into shell interpreters", () => { "workflow-privileged-untrusted-script-interpolation", ".github/workflows/comment-combined-pipe-shell.yml", ); + for (const runtime of ["node", "perl", "php", "python3", "ruby"]) { + assertFinding( + audit.result, + "workflow-privileged-untrusted-script-interpolation", + `.github/workflows/comment-pipe-${runtime}.yml`, + ); + } }); test("tainted here-strings cannot feed shell interpreters", () => { @@ -3255,6 +3356,22 @@ test("tainted fetch state propagates through FETCH_HEAD checkouts", () => { " git checkout review-head", "", ].join("\n")); + write(repoRoot, ".github/workflows/fetched-head-archive.yml", [ + "name: fetched head archive", + "on: pull_request_target", + "permissions: read-all", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - env:", + " PR_SHA: " + refExpression, + " run: |", + " git fetch origin \"$PR_SHA\"", + " git archive FETCH_HEAD | tar -x", + " ./verify.sh", + "", + ].join("\n")); commitAll(repoRoot, "add fetched head checkout"); const audit = runAudit(repoRoot); @@ -3265,7 +3382,7 @@ test("tainted fetch state propagates through FETCH_HEAD checkouts", () => { "workflow-privileged-untrusted-checkout", ".github/workflows/fetched-head-checkout.yml", ); - for (const form of ["update-ref", "branch"]) { + for (const form of ["update-ref", "branch", "archive"]) { assertFinding( audit.result, "workflow-privileged-untrusted-checkout", @@ -4461,6 +4578,55 @@ test("privileged workflows reject attacker-controlled job and step conditions", )), false); }); +test("GITHUB_OUTPUT destination aliases preserve composite output taint", () => { + const repoRoot = makeRepository(); + const headExpression = ["$", "{{ github.head_ref }}"].join(""); + const outputExpression = ["$", "{{ steps.source.outputs.ref }}"].join(""); + const actionOutputExpression = ["$", "{{ steps.export.outputs.ref }}"].join(""); + write(repoRoot, ".github/workflows/aliased-composite-output.yml", [ + "name: aliased composite output", + "on: pull_request_target", + "permissions: read-all", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " env:", + " PR_REF: " + headExpression, + " steps:", + " - id: source", + " uses: ./.github/actions/aliased-output-ref", + " - uses: actions/checkout@" + "a".repeat(40), + " with:", + " ref: " + outputExpression, + "", + ].join("\n")); + write(repoRoot, ".github/actions/aliased-output-ref/action.yml", [ + "name: aliased output ref", + "outputs:", + " ref:", + " value: " + actionOutputExpression, + "runs:", + " using: composite", + " steps:", + " - id: export", + " shell: bash", + " run: |", + " DESTINATION=\"$GITHUB_OUTPUT\"", + " echo \"ref=$PR_REF\" >> \"$DESTINATION\"", + "", + ].join("\n")); + commitAll(repoRoot, "add aliased composite output checkout"); + + const audit = runAudit(repoRoot); + + assert.equal(audit.status, 1); + assertFinding( + audit.result, + "workflow-privileged-untrusted-checkout", + ".github/workflows/aliased-composite-output.yml", + ); +}); + test("composite outputs only inherit taint from their actual output writes", () => { const repoRoot = makeRepository(); const headExpression = ["$", "{{ github.head_ref }}"].join(""); @@ -4518,6 +4684,9 @@ test("tainted text cannot reach language runtime evaluators", () => { ["node", "node -e \"$COMMAND\""], ["perl", "perl -e \"$COMMAND\""], ["ruby", "ruby -e \"$COMMAND\""], + ["nohup-shell", "nohup bash -c \"$COMMAND\""], + ["awk", "awk \"$COMMAND\" /dev/null"], + ["awk-e", "awk -e \"$COMMAND\" /dev/null"], ]) { write(repoRoot, `.github/workflows/comment-${runtime}-eval.yml`, [ `name: comment ${runtime} eval`, @@ -4538,7 +4707,10 @@ test("tainted text cannot reach language runtime evaluators", () => { const audit = runAudit(repoRoot); assert.equal(audit.status, 1); - for (const runtime of ["python", "python-attached", "node", "perl", "ruby"]) { + for (const runtime of [ + "python", "python-attached", "node", "perl", "ruby", "nohup-shell", + "awk", "awk-e", + ]) { assertFinding( audit.result, "workflow-privileged-untrusted-script-interpolation", From 770e76dc410b109116ed3df4f438148d1900cc44 Mon Sep 17 00:00:00 2001 From: Vladimir Date: Sat, 22 Aug 2026 10:13:33 +0800 Subject: [PATCH 31/37] Preserve execution provenance across shell and jobs Track ordered shell reassignment and positional taint, static evaluator aliases, env-prefixed Git and Node loaders, runtime option operands, heredoc programs, AWK and sed sources, cumulative make directories, tar aliases, current-run artifact reuploads, and BuildKit RUN mount images. Add focused unsafe fixtures and safe overwrite coverage. --- .../scripts/public-source-release-audit.mjs | 447 +++++++++++++++++- .../public-source-release-audit.test.mjs | 257 +++++++++- 2 files changed, 680 insertions(+), 24 deletions(-) diff --git a/public-source-release-audit/scripts/public-source-release-audit.mjs b/public-source-release-audit/scripts/public-source-release-audit.mjs index 303c3e2..a27b0a1 100644 --- a/public-source-release-audit/scripts/public-source-release-audit.mjs +++ b/public-source-release-audit/scripts/public-source-release-audit.mjs @@ -1162,7 +1162,24 @@ function dockerfileReferencedImages(text) { if (!image || /^\d+$/u.test(image) || stageNames.has(image.toLowerCase())) return []; return [image]; }); - return [...baseImages, ...copyImages]; + const runMountImages = logicalLines.flatMap((line) => { + if (/^\s*#/u.test(line) || !/^\s*RUN\b/iu.test(line)) return []; + return [...line.matchAll( + /(?:^|\s)--mount=(?:"([^"]+)"|'([^']+)'|([^\s]+))/giu, + )].flatMap((match) => { + const mount = match[1] ?? match[2] ?? match[3]; + const image = mount.split(",") + .map((entry) => entry.trim()) + .find((entry) => /^from=/iu.test(entry)) + ?.replace(/^from=/iu, "") + .replace(/^(?:"([^"]+)"|'([^']+)')$/u, "$1$2"); + if (!image || /^\d+$/u.test(image) || stageNames.has(image.toLowerCase())) { + return []; + } + return [image]; + }); + }); + return [...baseImages, ...copyImages, ...runMountImages]; } function isMutableDockerBaseImage(image) { @@ -3239,6 +3256,13 @@ function hasUntrustedWorkflowArtifactExecution( taintedBindings, stepOutputResolver, ); + const taintedCurrentArtifactNames = currentRunTaintedArtifactNames( + jobGroups, + jobTaintAnalyses, + scalarAnchors, + workflowEnvironmentValues, + workflowWorkingDirectory, + ); return jobGroups.some((jobGroup) => { const environmentValues = staticEnvironmentValues( workflowMappingBindings(jobGroup, "env", "env", scalarAnchors), @@ -3254,11 +3278,105 @@ function hasUntrustedWorkflowArtifactExecution( ) ?? workflowWorkingDirectory, environmentValues, localActionExecution, + taintedCurrentArtifactNames, }, ); }); } +function currentRunTaintedArtifactNames( + jobGroups, + jobTaintAnalyses, + scalarAnchors, + workflowEnvironmentValues, + workflowWorkingDirectory, +) { + const names = new Set(); + let changed; + do { + changed = false; + for (const jobGroup of jobGroups) { + const jobEnvironmentValues = staticEnvironmentValues( + workflowMappingBindings(jobGroup, "env", "env", scalarAnchors), + workflowEnvironmentValues, + ); + const defaultWorkingDirectory = jobRunDefaultWorkingDirectory( + jobGroup, + scalarAnchors, + ) ?? workflowWorkingDirectory; + const artifactPaths = []; + for (const { stepGroup, taintedBindings } of ( + jobTaintAnalyses.get(jobGroup)?.stepContexts ?? [] + )) { + const stepEnvironmentValues = staticEnvironmentValues( + workflowMappingBindings(stepGroup, "env", "env", scalarAnchors), + jobEnvironmentValues, + ); + const workingDirectory = stepRunWorkingDirectory( + stepGroup, + scalarAnchors, + defaultWorkingDirectory, + stepEnvironmentValues, + ); + for (const artifactPath of downloadedUntrustedArtifactPaths( + stepGroup, + scalarAnchors, + taintedBindings, + workingDirectory, + stepEnvironmentValues, + names, + )) { + if (!artifactPaths.includes(artifactPath)) artifactPaths.push(artifactPath); + } + const upload = uploadedArtifact( + stepGroup, + scalarAnchors, + workingDirectory, + stepEnvironmentValues, + ); + if (!upload || !artifactPaths.some((artifactPath) => ( + artifactPathsOverlap(artifactPath, upload.path) + ))) continue; + if (!names.has(upload.name)) { + names.add(upload.name); + changed = true; + } + } + } + } while (changed); + return names; +} + +function uploadedArtifact( + stepGroup, + scalarAnchors, + workingDirectory, + environmentValues, +) { + const uploadsArtifact = stepGroup.properties + .filter(({ entry }) => entry.key.toLowerCase() === "uses") + .some(({ entry }) => /^actions\/upload-artifact@/iu.test( + resolveYamlScalarValue(entry.value, scalarAnchors), + )); + if (!uploadsArtifact) return undefined; + const inputs = workflowMappingBindings(stepGroup, "with", "with", scalarAnchors); + const rawName = inputs.find((binding) => binding.name === "name")?.value ?? "artifact"; + const name = resolveStaticEnvironmentReferences(rawName, environmentValues) ?? "*"; + const rawPath = inputs.find((binding) => binding.name === "path")?.value ?? "."; + return { + name, + path: normalizedArtifactPath(rawPath, workingDirectory, environmentValues), + }; +} + +function artifactPathsOverlap(first, second) { + return first === "." + || second === "." + || first === second + || first.startsWith(`${second}/`) + || second.startsWith(`${first}/`); +} + function hasUntrustedScriptInterpolation( text, scalarAnchors, @@ -3545,24 +3663,30 @@ function shellRunEvaluatesTaintedVariable(runSource, taintedBindings) { ))); const anyEnvironmentVariableTainted = taintedBindings.has("env.*"); const evaluatorCommand = /^\s*(?:(?:builtin|command|exec)\s+)?(?:(?:\/usr\/bin\/)?env\s+(?:(?:-[^\s]+|[A-Za-z_][A-Za-z0-9_]*=[^\s]+)\s+)*)?(?:eval\b|(?:(?:\/[^/\s]+)*\/)?(?:bash|dash|fish|ksh|sh|zsh)\b[^;&|]*\s-c(?:\s+|(?=[^-\s])|$)|(?:(?:\/[^/\s]+)*\/)?(?:node|perl|python(?:\d+(?:\.\d+)*)?|ruby)\b[^;&|]*\s-(?:c|e)(?:\s+|(?=[^-\s])|$)|(?:(?:\/[^/\s]+)*\/)?php\b[^;&|]*\s-r(?:\s+|(?=[^-\s])|$)|(?:(?:\/[^/\s]+)*\/)?deno\b[^;&|]*\beval(?:\s|$)|(?:iex|invoke-expression)\b|(?:(?:\/[^/\s]+)*\/)?(?:powershell|pwsh)(?:\.exe)?\b[^;&|]*\s-(?:command|c)(?:\s+|(?=[^-\s])|$))/iu; - const sourceEvaluatesTaint = (source, inheritedTaintedVariables, depth) => { + const sourceEvaluatesTaint = ( + source, + inheritedTaintedVariables, + inheritedStaticValues, + depth, + ) => { const localTaintedVariables = new Set(inheritedTaintedVariables); + const localStaticValues = new Map(inheritedStaticValues); for (const segment of shellCommandSegments(source)) { if (/^\s*#/u.test(segment)) continue; const executableSegment = stripShellCommandGrouping(segment); - const assignmentName = shellAssignmentName(executableSegment); - const segmentReferencesTaint = isUntrustedReusableValue( + updateShellVariableTaint( executableSegment, taintedBindings, - ) - || shellSourceReferencesTaintedVariable( - executableSegment, - localTaintedVariables, - anyEnvironmentVariableTainted, - ); - if (assignmentName && segmentReferencesTaint) { - localTaintedVariables.add(assignmentName); - } + localTaintedVariables, + anyEnvironmentVariableTainted, + ); + updateShellPositionalTaint( + executableSegment, + taintedBindings, + localTaintedVariables, + anyEnvironmentVariableTainted, + ); + applyStaticShellAssignment(executableSegment, localStaticValues); for (const substitution of shellCommandSubstitutions(segment)) { if (depth >= 32) { if (isUntrustedReusableValue(substitution, taintedBindings) @@ -3576,12 +3700,15 @@ function shellRunEvaluatesTaintedVariable(runSource, taintedBindings) { if (sourceEvaluatesTaint( substitution, localTaintedVariables, + localStaticValues, depth + 1, )) return true; } let pipelineInputTainted = false; for (const stage of shellPipelineStages(executableSegment)) { - const sinkStage = shellStageWithoutCommandWrappers(stage); + const sinkStage = shellStageWithoutCommandWrappers( + resolveStaticShellCommandAlias(stage, localStaticValues), + ); const stageReferencesTaint = isUntrustedReusableValue(stage, taintedBindings) || shellSourceReferencesTaintedVariable( stage, @@ -3598,9 +3725,22 @@ function shellRunEvaluatesTaintedVariable(runSource, taintedBindings) { localTaintedVariables, anyEnvironmentVariableTainted, )) return true; + if (shellStageEvaluatesTaintedSedProgram( + sinkStage, + taintedBindings, + localTaintedVariables, + anyEnvironmentVariableTainted, + )) return true; if (shellStageMayInvokeGitSsh(stage) && (anyEnvironmentVariableTainted || localTaintedVariables.has("git_ssh_command") + || shellStageHasTaintedEnvironmentBinding( + stage, + "git_ssh_command", + taintedBindings, + localTaintedVariables, + anyEnvironmentVariableTainted, + ) || shellStageHasTaintedGitSshOverride( stage, taintedBindings, @@ -3614,7 +3754,7 @@ function shellRunEvaluatesTaintedVariable(runSource, taintedBindings) { } return false; }; - return sourceEvaluatesTaint(runSource, taintedVariables, 0); + return sourceEvaluatesTaint(runSource, taintedVariables, new Map(), 0); } function shellCommandSubstitutions(source) { @@ -3696,12 +3836,140 @@ function shellBacktickSubstitutionEnd(source, openingIndex) { return undefined; } +function updateShellVariableTaint( + source, + taintedBindings, + taintedVariables, + anyEnvironmentVariableTainted, +) { + const assignment = shellAssignmentBinding(source); + if (!assignment) return; + const namerefTarget = /^\s*(?:declare|local|typeset)(?=[^\n]*\s-n(?:\s|$))[^\n]*\s+[A-Za-z_][A-Za-z0-9_]*\s*=\s*([A-Za-z_][A-Za-z0-9_]*)/iu.exec( + source, + )?.[1].toLowerCase(); + const valueIsTainted = (namerefTarget + && (anyEnvironmentVariableTainted || taintedVariables.has(namerefTarget))) + || isUntrustedReusableValue(assignment.value, taintedBindings) + || shellSourceReferencesTaintedVariable( + assignment.value, + taintedVariables, + anyEnvironmentVariableTainted, + ); + if (valueIsTainted) taintedVariables.add(assignment.name); + else if (assignment.operator !== "+=") taintedVariables.delete(assignment.name); +} + +function updateShellPositionalTaint( + source, + taintedBindings, + taintedVariables, + anyEnvironmentVariableTainted, +) { + const words = shellCommandWords(source); + if (path.posix.basename(words[0] ?? "").toLowerCase() !== "set" + || words[1] !== "--") return; + const argumentTaint = words.slice(2).map((argument) => ( + isUntrustedReusableValue(argument, taintedBindings) + || shellSourceReferencesTaintedVariable( + argument, + taintedVariables, + anyEnvironmentVariableTainted, + ) + )); + for (const name of [...taintedVariables]) { + if (/^\d+$/u.test(name) || ["*", "@"].includes(name)) { + taintedVariables.delete(name); + } + } + argumentTaint.forEach((isTainted, index) => { + if (isTainted) taintedVariables.add(String(index + 1)); + }); + if (argumentTaint.some(Boolean)) { + taintedVariables.add("*"); + taintedVariables.add("@"); + } +} + +function resolveStaticShellCommandAlias(stage, environmentValues) { + const words = shellCommandWords(stage); + let cursor = 0; + while (/^[A-Za-z_][A-Za-z0-9_]*=/u.test(words[cursor] ?? "")) cursor += 1; + if (!words[cursor]) return stage; + const resolved = resolveStaticEnvironmentReferences(words[cursor], environmentValues); + if (!resolved || /\s/u.test(resolved) || resolved === words[cursor]) return stage; + words[cursor] = resolved; + return words.join(" "); +} + +function shellCommandEnvironmentBindings(stage) { + const words = shellCommandWords(stripShellCommandGrouping(stage)); + const bindings = []; + let cursor = 0; + let acceptsBindings = true; + while (cursor < words.length) { + const assignment = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/u.exec(words[cursor]); + if (acceptsBindings && assignment) { + bindings.push({ name: assignment[1].toLowerCase(), value: assignment[2] }); + cursor += 1; + continue; + } + const command = path.posix.basename(words[cursor] ?? "").toLowerCase(); + if (["builtin", "command", "exec", "nohup", "time"].includes(command)) { + cursor += 1; + while (words[cursor]?.startsWith("-")) cursor += 1; + continue; + } + if (command === "env") { + cursor += 1; + acceptsBindings = true; + while (words[cursor]?.startsWith("-")) { + const option = words[cursor]; + cursor += 1; + if (["-C", "-S", "-u", "--chdir", "--split-string", "--unset"].includes( + option, + )) cursor += 1; + } + continue; + } + break; + } + return bindings; +} + +function shellStageHasTaintedEnvironmentBinding( + stage, + requestedName, + taintedBindings, + taintedVariables, + anyEnvironmentVariableTainted, +) { + return shellCommandEnvironmentBindings(stage).some(({ name, value }) => ( + name === requestedName + && (isUntrustedReusableValue(value, taintedBindings) + || shellSourceReferencesTaintedVariable( + value, + taintedVariables, + anyEnvironmentVariableTainted, + )) + )); +} + function shellStageWithoutCommandWrappers(stage) { const words = shellCommandWords(stripShellCommandGrouping(stage)); let cursor = 0; while (cursor < words.length) { while (/^[A-Za-z_][A-Za-z0-9_]*=/u.test(words[cursor] ?? "")) cursor += 1; const command = path.posix.basename(words[cursor] ?? "").toLowerCase(); + if (command === "timeout") { + cursor += 1; + while (words[cursor]?.startsWith("-")) { + const option = words[cursor]; + cursor += 1; + if (["-k", "-s", "--kill-after", "--signal"].includes(option)) cursor += 1; + } + if (words[cursor]) cursor += 1; + continue; + } if (["builtin", "command", "exec", "nohup", "time"].includes(command)) { cursor += 1; while (words[cursor]?.startsWith("-")) cursor += 1; @@ -3719,7 +3987,7 @@ function shellStageWithoutCommandWrappers(stage) { } function shellStageHasProgramHereInput(stage) { - const redirectIndex = stage.indexOf("<<<"); + const redirectIndex = stage.search(/(?:^|\s)(?:\d*)<<= 0 && shellStageExecutesStdinAsProgram(stage.slice(0, redirectIndex)); } @@ -3814,6 +4082,58 @@ function shellStageEvaluatesTaintedAwkProgram( return false; } +function shellStageEvaluatesTaintedSedProgram( + stage, + taintedBindings, + taintedVariables, + anyEnvironmentVariableTainted, +) { + const words = shellCommandWords(stage); + const commandName = path.posix.basename(words[0] ?? "").toLowerCase(); + if (!["gsed", "sed"].includes(commandName)) return false; + const programIsTainted = (program) => program + && (isUntrustedReusableValue(program, taintedBindings) + || shellSourceReferencesTaintedVariable( + program, + taintedVariables, + anyEnvironmentVariableTainted, + )); + let cursor = 1; + let hasExplicitProgram = false; + while (cursor < words.length) { + const argument = words[cursor]; + cursor += 1; + if (["-e", "--expression"].includes(argument)) { + hasExplicitProgram = true; + if (programIsTainted(words[cursor])) return true; + cursor += 1; + continue; + } + const inlineProgram = /^--expression=(.*)$/iu.exec(argument)?.[1] + ?? /^-e(.+)$/u.exec(argument)?.[1]; + if (inlineProgram) { + hasExplicitProgram = true; + if (programIsTainted(inlineProgram)) return true; + continue; + } + if (["-f", "--file"].includes(argument)) { + hasExplicitProgram = true; + cursor += 1; + continue; + } + if (/^--file=/iu.test(argument) || /^-f.+/u.test(argument)) { + hasExplicitProgram = true; + continue; + } + if (argument === "--") { + return !hasExplicitProgram && programIsTainted(words[cursor]); + } + if (argument.startsWith("-")) continue; + return !hasExplicitProgram && programIsTainted(argument); + } + return false; +} + function shellStageMayInvokeGitSsh(stage) { const words = shellGitArguments(stage); if (!words) return false; @@ -3955,6 +4275,7 @@ function stepContextsHaveUntrustedArtifactExecution( defaultWorkingDirectory = ".", environmentValues = new Map(), localActionExecution, + taintedCurrentArtifactNames = new Set(), } = {}, ) { for (const { stepGroup, taintedBindings } of stepContexts) { @@ -3974,6 +4295,7 @@ function stepContextsHaveUntrustedArtifactExecution( taintedBindings, workingDirectory, stepEnvironmentValues, + taintedCurrentArtifactNames, )) { if (!artifactPaths.includes(artifactPath)) artifactPaths.push(artifactPath); } @@ -4000,6 +4322,7 @@ function downloadedUntrustedArtifactPaths( taintedBindings, workingDirectory, environmentValues, + taintedCurrentArtifactNames = new Set(), ) { const downloadsArtifact = stepGroup.properties .filter(({ entry }) => entry.key.toLowerCase() === "uses") @@ -4010,7 +4333,14 @@ function downloadedUntrustedArtifactPaths( if (downloadsArtifact) { const inputs = workflowMappingBindings(stepGroup, "with", "with", scalarAnchors); const runId = inputs.find((binding) => binding.name === "run-id")?.value; - if (runId && isUntrustedReusableValue(runId, taintedBindings)) { + const downloadsTaintedCurrentArtifact = !runId + && currentArtifactDownloadIsTainted( + inputs, + taintedCurrentArtifactNames, + environmentValues, + ); + if ((runId && isUntrustedReusableValue(runId, taintedBindings)) + || downloadsTaintedCurrentArtifact) { paths.push(normalizedArtifactPath( inputs.find((binding) => binding.name === "path")?.value ?? ".", ".", @@ -4031,6 +4361,29 @@ function downloadedUntrustedArtifactPaths( return paths; } +function currentArtifactDownloadIsTainted( + inputs, + taintedArtifactNames, + environmentValues, +) { + if (taintedArtifactNames.size === 0) return false; + if (taintedArtifactNames.has("*")) return true; + const rawName = inputs.find((binding) => binding.name === "name")?.value; + const rawPattern = inputs.find((binding) => binding.name === "pattern")?.value; + if (!rawName && !rawPattern) return true; + if (rawName) { + const name = resolveStaticEnvironmentReferences(rawName, environmentValues); + return name === undefined || taintedArtifactNames.has(name); + } + const pattern = resolveStaticEnvironmentReferences(rawPattern, environmentValues); + if (pattern === undefined) return true; + const expression = new RegExp( + `^${escapeRegExp(pattern).replaceAll("\\*", ".*").replaceAll("\\?", ".")}$`, + "u", + ); + return [...taintedArtifactNames].some((name) => expression.test(name)); +} + function shellGhRunDownloadPaths( runSource, taintedBindings, @@ -4317,6 +4670,15 @@ function shellRunExecutesArtifactPath( } function shellArtifactExecutionSources(segment, environmentValues = new Map()) { + const commandEnvironmentValues = new Map(environmentValues); + for (const binding of shellCommandEnvironmentBindings(segment)) { + const value = resolveStaticEnvironmentReferences( + binding.value, + commandEnvironmentValues, + ); + if (value === undefined) commandEnvironmentValues.delete(binding.name); + else commandEnvironmentValues.set(binding.name, value); + } const words = shellCommandWords(segment); let cursor = 0; while (cursor < words.length) { @@ -4357,6 +4719,12 @@ function shellArtifactExecutionSources(segment, environmentValues = new Map()) { if (["gmake", "make"].includes(commandName)) { return makefileExecutionSources(words.slice(cursor)); } + if (["awk", "gawk", "mawk", "nawk"].includes(commandName)) { + return programFileExecutionSources(words.slice(cursor), ["-f", "--file"]); + } + if (["gsed", "sed"].includes(commandName)) { + return programFileExecutionSources(words.slice(cursor), ["-f", "--file"]); + } const interpreters = new Set([ "bash", "dash", "deno", "fish", "ksh", "node", "perl", "php", "powershell", "powershell.exe", "pwsh", "pwsh.exe", "python", "python2", @@ -4364,7 +4732,7 @@ function shellArtifactExecutionSources(segment, environmentValues = new Map()) { ]); if (interpreters.has(commandName) || /^python\d+(?:\.\d+)*$/u.test(commandName)) { const executionSources = commandName === "node" - ? nodeOptionsLoadedSources(environmentValues.get("node_options")) + ? nodeOptionsLoadedSources(commandEnvironmentValues.get("node_options")) : []; if (commandName === "deno" && words[cursor]?.toLowerCase() === "run") cursor += 1; while (cursor < words.length) { @@ -4420,6 +4788,28 @@ function shellArtifactExecutionSources(segment, environmentValues = new Map()) { return command.includes("/") ? [command] : []; } +function programFileExecutionSources(arguments_, options) { + const sources = []; + for (let cursor = 0; cursor < arguments_.length;) { + const argument = arguments_[cursor]; + cursor += 1; + if (options.includes(argument)) { + if (arguments_[cursor]) sources.push(arguments_[cursor]); + cursor += 1; + continue; + } + for (const option of options) { + const separator = option.startsWith("--") ? "=" : ""; + const prefix = option + separator; + if (argument.startsWith(prefix) && argument.length > prefix.length) { + sources.push(argument.slice(prefix.length)); + break; + } + } + } + return sources; +} + function makefileExecutionSources(arguments_) { const sources = []; let directory = "."; @@ -4427,13 +4817,13 @@ function makefileExecutionSources(arguments_) { const argument = arguments_[cursor]; cursor += 1; if (["-C", "--directory"].includes(argument)) { - directory = arguments_[cursor] ?? directory; + directory = cumulativeMakeDirectory(directory, arguments_[cursor]); cursor += 1; continue; } const inlineDirectory = /^(?:-C|--directory=)(.+)$/u.exec(argument)?.[1]; if (inlineDirectory) { - directory = inlineDirectory; + directory = cumulativeMakeDirectory(directory, inlineDirectory); continue; } if (["-f", "--file", "--makefile"].includes(argument)) { @@ -4454,6 +4844,14 @@ function makefileExecutionSources(arguments_) { )); } +function cumulativeMakeDirectory(currentDirectory, requestedDirectory) { + if (!requestedDirectory) return currentDirectory; + if (path.posix.isAbsolute(requestedDirectory)) { + return path.posix.normalize(requestedDirectory); + } + return path.posix.normalize(path.posix.join(currentDirectory, requestedDirectory)); +} + function nodeOptionsLoadedSources(options) { if (!options) return []; const words = shellCommandWords(options); @@ -4508,6 +4906,7 @@ function interpreterOptionConsumesValue(commandName, argument) { "--require", ])], ["perl", new Set(["-f", "-i", "-m", "-M"])], + ["php", new Set(["-d", "--define"])], ["ruby", new Set(["-e", "-i", "-I", "-r", "--encoding", "--external-encoding", "--internal-encoding"])], ]); return options.get(commandName)?.has(argument) ?? false; @@ -4769,7 +5168,8 @@ function shellLineExtractsGitArchive(source) { const command = path.posix.basename(words[0] ?? "").toLowerCase(); if (!["bsdtar", "gtar", "tar"].includes(command)) return false; return words.slice(1).some((argument) => ( - argument === "--extract" || /^-?[A-Za-z]*x[A-Za-z]*$/u.test(argument) + ["--extract", "--get"].includes(argument) + || /^-?[A-Za-z]*x[A-Za-z]*$/u.test(argument) )); }) )); @@ -4803,8 +5203,13 @@ function shellSourceReferencesTaintedVariable(source, taintedVariables, anyTaint } if (/\$\{![A-Za-z_][A-Za-z0-9_]*(?:[*@])?\}/u.test(source) && (anyTainted || taintedVariables.size > 0)) return true; + if (/\$(?:\{)?([@*])/u.test(source) + && (anyTainted + || taintedVariables.has("@") + || taintedVariables.has("*"))) return true; const references = [ ...source.matchAll(/\$(?:env:([A-Za-z_][A-Za-z0-9_]*)|\{(?:env:)?([A-Za-z_][A-Za-z0-9_]*)|([A-Za-z_][A-Za-z0-9_]*))/giu), + ...source.matchAll(/\$(?:\{([0-9]+)|([0-9]+))/gu), ...source.matchAll(/%([A-Za-z_][A-Za-z0-9_]*)%/gu), ].map((match) => (match[1] ?? match[2] ?? match[3]).toLowerCase()); return references.some((name) => anyTainted || taintedVariables.has(name)); diff --git a/public-source-release-audit/tests/public-source-release-audit.test.mjs b/public-source-release-audit/tests/public-source-release-audit.test.mjs index f8510c7..82d50c4 100644 --- a/public-source-release-audit/tests/public-source-release-audit.test.mjs +++ b/public-source-release-audit/tests/public-source-release-audit.test.mjs @@ -2227,6 +2227,51 @@ test("workflow_run artifacts remain untrusted when later executed", () => { } }); +test("workflow_run artifact provenance survives current-run reuploads", () => { + const repoRoot = makeRepository(); + const runIdExpression = ["$", "{{ github.event.workflow_run.id }}"].join(""); + write(repoRoot, ".github/workflows/workflow-run-reuploaded-artifact.yml", [ + "name: workflow run reuploaded artifact", + "on:", + " workflow_run:", + " workflows: [verify]", + " types: [completed]", + "permissions: read-all", + "jobs:", + " relay:", + " runs-on: ubuntu-latest", + " steps:", + " - uses: actions/download-artifact@" + "a".repeat(40), + " with:", + " run-id: " + runIdExpression, + " path: inbound", + " - uses: actions/upload-artifact@" + "b".repeat(40), + " with:", + " name: forwarded-payload", + " path: inbound", + " execute:", + " needs: relay", + " runs-on: ubuntu-latest", + " steps:", + " - uses: actions/download-artifact@" + "a".repeat(40), + " with:", + " name: forwarded-payload", + " path: payload", + " - run: bash payload/run.sh", + "", + ].join("\n")); + commitAll(repoRoot, "add reuploaded workflow artifact execution"); + + const audit = runAudit(repoRoot); + + assert.equal(audit.status, 1); + assertFinding( + audit.result, + "workflow-privileged-untrusted-artifact-execution", + ".github/workflows/workflow-run-reuploaded-artifact.yml", + ); +}); + test("artifact execution resolves step working directories", () => { const repoRoot = makeRepository(); const runIdExpression = ["$", "{{ github.event.workflow_run.id }}"].join(""); @@ -2345,6 +2390,8 @@ test("artifact execution recognizes common command wrappers and paths", () => { ["node-import", "node --import=payload/hook.mjs trusted.js"], ["node-loader", "node --loader payload/loader.mjs trusted.js"], ["make-file", "make -f payload/Makefile"], + ["make-nested-directory", "make -C payload -C nested"], + ["awk-file", "awk -f payload/run.awk /dev/null"], ]) { write(repoRoot, `.github/workflows/workflow-run-artifact-${name}.yml`, [ `name: workflow run artifact ${name}`, @@ -2385,6 +2432,24 @@ test("artifact execution recognizes common command wrappers and paths", () => { " run: node trusted.js", "", ].join("\n")); + write(repoRoot, ".github/workflows/workflow-run-artifact-env-node-options.yml", [ + "name: workflow run artifact env Node options", + "on:", + " workflow_run:", + " workflows: [verify]", + " types: [completed]", + "permissions: read-all", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - uses: actions/download-artifact@" + "a".repeat(40), + " with:", + " run-id: " + runIdExpression, + " path: payload", + " - run: env NODE_OPTIONS='--require payload/hook.js' node trusted.js", + "", + ].join("\n")); for (const scope of ["workflow", "job", "step", "shell"]) { write(repoRoot, `.github/workflows/workflow-run-artifact-${scope}-alias.yml`, [ `name: workflow run artifact ${scope} alias`, @@ -2431,7 +2496,10 @@ test("artifact execution recognizes common command wrappers and paths", () => { "node-import", "node-loader", "make-file", + "make-nested-directory", + "awk-file", "node-options", + "env-node-options", "workflow-alias", "job-alias", "step-alias", @@ -2835,6 +2903,52 @@ test("tainted environment values cannot reach shell evaluators", () => { " eval \"$REF\"", "", ].join("\n")); + write(repoRoot, ".github/workflows/comment-positional-eval.yml", [ + "name: comment positional eval", + "on: issue_comment", + "permissions: read-all", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - env:", + " CODE: " + bodyExpression, + " run: |", + " set -- \"$CODE\"", + " eval \"$1\"", + "", + ].join("\n")); + write(repoRoot, ".github/workflows/comment-command-alias-eval.yml", [ + "name: comment command alias eval", + "on: issue_comment", + "permissions: read-all", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - env:", + " CODE: " + bodyExpression, + " run: |", + " RUNNER=eval", + " \"$RUNNER\" \"$CODE\"", + "", + ].join("\n")); + write(repoRoot, ".github/workflows/comment-overwritten-eval.yml", [ + "name: comment overwritten eval", + "on: issue_comment", + "permissions: read-all", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - env:", + " CODE: " + bodyExpression, + " run: |", + " VALUE=\"$CODE\"", + " VALUE='echo fixed'", + " eval \"$VALUE\"", + "", + ].join("\n")); commitAll(repoRoot, "add tainted shell evaluator"); const audit = runAudit(repoRoot); @@ -2850,13 +2964,17 @@ test("tainted environment values cannot reach shell evaluators", () => { "workflow-privileged-untrusted-script-interpolation", ".github/workflows/comment-background-eval.yml", ); - for (const form of ["indirect", "nameref"]) { + for (const form of ["indirect", "nameref", "positional", "command-alias"]) { assertFinding( audit.result, "workflow-privileged-untrusted-script-interpolation", `.github/workflows/comment-${form}-eval.yml`, ); } + assert.equal(audit.result.findings.some((finding) => ( + finding.ruleId === "workflow-privileged-untrusted-script-interpolation" + && finding.path === ".github/workflows/comment-overwritten-eval.yml" + )), false); }); test("tainted shell values cannot hide in parameter operators or command substitutions", () => { @@ -2942,6 +3060,19 @@ test("tainted Git SSH command templates are implicit shell evaluators", () => { " run: GIT_SSH_COMMAND=\"$COMMAND\" git fetch origin", "", ].join("\n")); + write(unsafeRepo, ".github/workflows/comment-env-git-ssh-command.yml", [ + "name: comment env Git SSH command", + "on: issue_comment", + "permissions: read-all", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - env:", + " COMMAND: " + bodyExpression, + " run: env GIT_SSH_COMMAND=\"$COMMAND\" git ls-remote ssh://example.invalid/repository", + "", + ].join("\n")); write(unsafeRepo, ".github/workflows/comment-git-core-ssh-command.yml", [ "name: comment Git core SSH command", "on: issue_comment", @@ -2991,6 +3122,7 @@ test("tainted Git SSH command templates are implicit shell evaluators", () => { for (const form of [ "comment-git-ssh-command", "comment-inline-git-ssh-command", + "comment-env-git-ssh-command", "comment-git-core-ssh-command", "comment-git-config-env-ssh-command", ]) { @@ -3047,6 +3179,19 @@ test("tainted script text cannot be piped into shell interpreters", () => { "", ].join("\n")); } + write(repoRoot, ".github/workflows/comment-pipe-php-options.yml", [ + "name: comment pipe PHP options", + "on: issue_comment", + "permissions: read-all", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - env:", + " COMMAND: " + bodyExpression, + " run: printf '%s' \"$COMMAND\" | php -d display_errors=1", + "", + ].join("\n")); commitAll(repoRoot, "add tainted shell pipeline"); const audit = runAudit(repoRoot); @@ -3069,6 +3214,11 @@ test("tainted script text cannot be piped into shell interpreters", () => { `.github/workflows/comment-pipe-${runtime}.yml`, ); } + assertFinding( + audit.result, + "workflow-privileged-untrusted-script-interpolation", + ".github/workflows/comment-pipe-php-options.yml", + ); }); test("tainted here-strings cannot feed shell interpreters", () => { @@ -3087,6 +3237,22 @@ test("tainted here-strings cannot feed shell interpreters", () => { " run: bash <<< \"$COMMAND\"", "", ].join("\n")); + write(repoRoot, ".github/workflows/comment-heredoc-shell.yml", [ + "name: comment heredoc shell", + "on: issue_comment", + "permissions: read-all", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - env:", + " COMMAND: " + bodyExpression, + " run: |", + " bash < { "workflow-privileged-untrusted-script-interpolation", ".github/workflows/comment-here-shell.yml", ); + assertFinding( + audit.result, + "workflow-privileged-untrusted-script-interpolation", + ".github/workflows/comment-heredoc-shell.yml", + ); }); test("privileged workflows reject attacker-selected shell templates", () => { @@ -3372,6 +3543,22 @@ test("tainted fetch state propagates through FETCH_HEAD checkouts", () => { " ./verify.sh", "", ].join("\n")); + write(repoRoot, ".github/workflows/fetched-head-archive-get.yml", [ + "name: fetched head archive get", + "on: pull_request_target", + "permissions: read-all", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - env:", + " PR_SHA: " + refExpression, + " run: |", + " git fetch origin \"$PR_SHA\"", + " git archive FETCH_HEAD | tar --get", + " ./verify.sh", + "", + ].join("\n")); commitAll(repoRoot, "add fetched head checkout"); const audit = runAudit(repoRoot); @@ -3382,7 +3569,7 @@ test("tainted fetch state propagates through FETCH_HEAD checkouts", () => { "workflow-privileged-untrusted-checkout", ".github/workflows/fetched-head-checkout.yml", ); - for (const form of ["update-ref", "branch", "archive"]) { + for (const form of ["update-ref", "branch", "archive", "archive-get"]) { assertFinding( audit.result, "workflow-privileged-untrusted-checkout", @@ -4247,6 +4434,67 @@ test("Dockerfile-backed actions require immutable external COPY images", () => { } }); +test("Dockerfile-backed actions require immutable external RUN mount images", () => { + const repoRoot = makeRepository(); + write(repoRoot, ".github/workflows/dockerfile-run-mount-actions.yml", [ + "name: Dockerfile RUN mount actions", + "on: push", + "permissions: read-all", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - uses: ./.github/actions/mutable-run-mount", + " - uses: ./.github/actions/pinned-run-mount", + " - uses: ./.github/actions/staged-run-mount", + "", + ].join("\n")); + for (const actionName of [ + "mutable-run-mount", + "pinned-run-mount", + "staged-run-mount", + ]) { + write(repoRoot, `.github/actions/${actionName}/action.yml`, [ + "name: " + actionName, + "runs:", + " using: docker", + " image: Dockerfile", + "", + ].join("\n")); + } + write(repoRoot, ".github/actions/mutable-run-mount/Dockerfile", [ + "FROM scratch", + "RUN --mount=from=busybox:latest,source=/bin,target=/mnt /mnt/cp /mnt/sh /bin/other", + "", + ].join("\n")); + write(repoRoot, ".github/actions/pinned-run-mount/Dockerfile", [ + "FROM scratch", + `RUN --mount=from=busybox@sha256:${"c".repeat(64)},source=/bin,target=/mnt /mnt/cp /mnt/sh /bin/other`, + "", + ].join("\n")); + write(repoRoot, ".github/actions/staged-run-mount/Dockerfile", [ + `FROM busybox@sha256:${"d".repeat(64)} AS tools`, + "FROM scratch", + "RUN --mount=from=tools,source=/bin,target=/mnt /mnt/cp /mnt/sh /bin/other", + "", + ].join("\n")); + commitAll(repoRoot, "add Dockerfile RUN mount fixtures"); + + const audit = runAudit(repoRoot, ["--fail-on-warning"]); + + assert.equal(audit.status, 1); + assertFinding( + audit.result, + "workflow-mutable-action-ref", + ".github/actions/mutable-run-mount/Dockerfile", + ); + for (const actionName of ["pinned-run-mount", "staged-run-mount"]) { + assert.equal(audit.result.findings.some((finding) => ( + finding.path === `.github/actions/${actionName}/Dockerfile` + )), false); + } +}); + test("runner-group selectors require proof of hosted isolation", () => { const repoRoot = makeRepository(); write(repoRoot, ".github/workflows/flow-runner-group.yml", [ @@ -4685,8 +4933,11 @@ test("tainted text cannot reach language runtime evaluators", () => { ["perl", "perl -e \"$COMMAND\""], ["ruby", "ruby -e \"$COMMAND\""], ["nohup-shell", "nohup bash -c \"$COMMAND\""], + ["timeout-shell", "timeout 10 bash -c \"$COMMAND\""], ["awk", "awk \"$COMMAND\" /dev/null"], ["awk-e", "awk -e \"$COMMAND\" /dev/null"], + ["sed", "printf x | sed \"$COMMAND\""], + ["sed-e", "printf x | sed -e \"$COMMAND\""], ]) { write(repoRoot, `.github/workflows/comment-${runtime}-eval.yml`, [ `name: comment ${runtime} eval`, @@ -4709,7 +4960,7 @@ test("tainted text cannot reach language runtime evaluators", () => { assert.equal(audit.status, 1); for (const runtime of [ "python", "python-attached", "node", "perl", "ruby", "nohup-shell", - "awk", "awk-e", + "timeout-shell", "awk", "awk-e", "sed", "sed-e", ]) { assertFinding( audit.result, From 0134749784ca7cb1354af9ef7e1f5ba639eca743 Mon Sep 17 00:00:00 2001 From: Vladimir Date: Sat, 22 Aug 2026 10:40:37 +0800 Subject: [PATCH 32/37] Close remaining execution provenance gaps --- .../scripts/public-safety-audit-core.mjs | 30 +++ .../scripts/public-source-release-audit.mjs | 207 ++++++++++++------ .../public-source-release-audit.test.mjs | 159 +++++++++++++- 3 files changed, 331 insertions(+), 65 deletions(-) diff --git a/public-source-release-audit/scripts/public-safety-audit-core.mjs b/public-source-release-audit/scripts/public-safety-audit-core.mjs index 966dc28..35d4777 100644 --- a/public-source-release-audit/scripts/public-safety-audit-core.mjs +++ b/public-source-release-audit/scripts/public-safety-audit-core.mjs @@ -124,6 +124,7 @@ const PUBLIC_SAFETY_AUDIT_RULES = [ // -------------------------------------------------------------------------- // export function auditPublicSafety(options) { const repoRoot = path.resolve(options.repoRoot); + assertNoGitPartialClone(repoRoot); const trackedTreeResult = auditTrackedTree(repoRoot); const additionalSourceFindings = auditAdditionalSources(options.additionalSources ?? []); const historyResult = options.includeHistory === true @@ -213,6 +214,34 @@ function assertNoGitShallowBoundary(repoRoot) { throw new Error("Public safety audit cannot safely scan history while .git/shallow is present."); } } +function assertNoGitPartialClone(repoRoot) { + let output; + try { + output = execFileSync("git", ["config", "--local", "--null", "--list"], { + cwd: repoRoot, + encoding: "utf8", + env: auditGitEnvironment(), + stdio: ["ignore", "pipe", "pipe"], + }); + } + catch { + throw new Error("Public safety audit could not inspect the local Git configuration."); + } + const entries = output.split("\0").filter(Boolean).map((entry) => { + const separator = entry.indexOf("\n"); + return { + name: (separator < 0 ? entry : entry.slice(0, separator)).toLowerCase(), + value: separator < 0 ? "" : entry.slice(separator + 1), + }; + }); + const isPartialClone = entries.some(({ name, value }) => name === "extensions.partialclone" + || /^remote\..*\.partialclonefilter$/u.test(name) + || (/^remote\..*\.promisor$/u.test(name) + && !["0", "false", "no", "off"].includes(value.trim().toLowerCase()))); + if (isPartialClone) { + throw new Error("Public safety audit cannot safely scan a partial clone without risking lazy object fetches."); + } +} function auditRawCommitObjects(repoRoot) { const findings = []; scanRawCommitObjects(repoRoot, ({ commit, rawCommitObject, rawCommitObjectPath }) => { @@ -3297,6 +3326,7 @@ function auditGitEnvironment() { delete environment[name]; } environment.GIT_NO_REPLACE_OBJECTS = "1"; + environment.GIT_NO_LAZY_FETCH = "1"; return environment; } function scanLargeDecodedAuditTextFile(filePath, createScanner) { diff --git a/public-source-release-audit/scripts/public-source-release-audit.mjs b/public-source-release-audit/scripts/public-source-release-audit.mjs index a27b0a1..12e89d6 100644 --- a/public-source-release-audit/scripts/public-source-release-audit.mjs +++ b/public-source-release-audit/scripts/public-source-release-audit.mjs @@ -1708,7 +1708,7 @@ function githubEnvironmentWriteBindings(runSource, taintedBindings) { ))); const environmentFileAliases = new Set(["github_env"]); const names = new Set(); - for (const segment of shellCommandSegments(runSource)) { + for (const segment of shellCommandSegmentsPreservingGroupRedirections(runSource)) { const segmentReferencesTaint = isUntrustedReusableValue(segment, taintedBindings) || shellSourceReferencesTaintedVariable( segment, @@ -1743,7 +1743,7 @@ function githubOutputWriteIsTainted(runSource, taintedBindings) { : [] ))); const environmentFileAliases = new Set(["github_output"]); - for (const segment of shellCommandSegments(runSource)) { + for (const segment of shellCommandSegmentsPreservingGroupRedirections(runSource)) { const segmentReferencesTaint = isUntrustedReusableValue(segment, taintedBindings) || shellSourceReferencesTaintedVariable( segment, @@ -3335,7 +3335,10 @@ function currentRunTaintedArtifactNames( stepEnvironmentValues, ); if (!upload || !artifactPaths.some((artifactPath) => ( - artifactPathsOverlap(artifactPath, upload.path) + upload.paths.some((uploadPath) => artifactPathsOverlap( + artifactPath, + uploadPath, + )) ))) continue; if (!names.has(upload.name)) { names.add(upload.name); @@ -3363,9 +3366,23 @@ function uploadedArtifact( const rawName = inputs.find((binding) => binding.name === "name")?.value ?? "artifact"; const name = resolveStaticEnvironmentReferences(rawName, environmentValues) ?? "*"; const rawPath = inputs.find((binding) => binding.name === "path")?.value ?? "."; + const paths = rawPath + .replace(/\r\n?|\u0085|\u2028|\u2029/gu, "\n") + .split("\n") + .map((candidate) => candidate.trim()) + .filter((candidate) => candidate.length > 0 && !candidate.startsWith("!")) + .map((candidate) => normalizedArtifactPath( + candidate, + workingDirectory, + environmentValues, + )); return { name, - path: normalizedArtifactPath(rawPath, workingDirectory, environmentValues), + paths: paths.length > 0 ? paths : [normalizedArtifactPath( + ".", + workingDirectory, + environmentValues, + )], }; } @@ -3662,7 +3679,7 @@ function shellRunEvaluatesTaintedVariable(runSource, taintedBindings) { : [] ))); const anyEnvironmentVariableTainted = taintedBindings.has("env.*"); - const evaluatorCommand = /^\s*(?:(?:builtin|command|exec)\s+)?(?:(?:\/usr\/bin\/)?env\s+(?:(?:-[^\s]+|[A-Za-z_][A-Za-z0-9_]*=[^\s]+)\s+)*)?(?:eval\b|(?:(?:\/[^/\s]+)*\/)?(?:bash|dash|fish|ksh|sh|zsh)\b[^;&|]*\s-c(?:\s+|(?=[^-\s])|$)|(?:(?:\/[^/\s]+)*\/)?(?:node|perl|python(?:\d+(?:\.\d+)*)?|ruby)\b[^;&|]*\s-(?:c|e)(?:\s+|(?=[^-\s])|$)|(?:(?:\/[^/\s]+)*\/)?php\b[^;&|]*\s-r(?:\s+|(?=[^-\s])|$)|(?:(?:\/[^/\s]+)*\/)?deno\b[^;&|]*\beval(?:\s|$)|(?:iex|invoke-expression)\b|(?:(?:\/[^/\s]+)*\/)?(?:powershell|pwsh)(?:\.exe)?\b[^;&|]*\s-(?:command|c)(?:\s+|(?=[^-\s])|$))/iu; + const evaluatorCommand = /^\s*(?:(?:builtin|command|exec)\s+)?(?:(?:\/usr\/bin\/)?env\s+(?:(?:-[^\s]+|[A-Za-z_][A-Za-z0-9_]*=[^\s]+)\s+)*)?(?:eval\b|(?:(?:\/[^/\s]+)*\/)?(?:bash|dash|fish|ksh|sh|zsh)\b[^;&|]*\s-c(?:\s+|(?=[^-\s])|$)|(?:(?:\/[^/\s]+)*\/)?node\b[^;&|]*\s(?:-e|--eval|-p|--print)(?:\s+|(?=[^-\s])|$)|(?:(?:\/[^/\s]+)*\/)?(?:perl|python(?:\d+(?:\.\d+)*)?|ruby)\b[^;&|]*\s-(?:c|e)(?:\s+|(?=[^-\s])|$)|(?:(?:\/[^/\s]+)*\/)?php\b[^;&|]*\s-r(?:\s+|(?=[^-\s])|$)|(?:(?:\/[^/\s]+)*\/)?deno\b[^;&|]*\beval(?:\s|$)|(?:iex|invoke-expression)\b|(?:(?:\/[^/\s]+)*\/)?(?:powershell|pwsh)(?:\.exe)?\b[^;&|]*\s-(?:command|c)(?:\s+|(?=[^-\s])|$))/iu; const sourceEvaluatesTaint = ( source, inheritedTaintedVariables, @@ -3679,6 +3696,7 @@ function shellRunEvaluatesTaintedVariable(runSource, taintedBindings) { taintedBindings, localTaintedVariables, anyEnvironmentVariableTainted, + segment, ); updateShellPositionalTaint( executableSegment, @@ -3792,6 +3810,31 @@ function shellCommandSubstitutions(source) { return substitutions; } +function shellSubstitutionsReferenceTaint( + source, + taintedBindings, + taintedVariables, + anyEnvironmentVariableTainted, + depth = 0, +) { + if (depth > 32) return false; + return shellCommandSubstitutions(source).some((body) => ( + isUntrustedReusableValue(body, taintedBindings) + || shellSourceReferencesTaintedVariable( + body, + taintedVariables, + anyEnvironmentVariableTainted, + ) + || shellSubstitutionsReferenceTaint( + body, + taintedBindings, + taintedVariables, + anyEnvironmentVariableTainted, + depth + 1, + ) + )); +} + function shellParenthesizedSubstitutionEnd(source, openingIndex) { let depth = 1; let quote; @@ -3841,6 +3884,7 @@ function updateShellVariableTaint( taintedBindings, taintedVariables, anyEnvironmentVariableTainted, + completeSource = source, ) { const assignment = shellAssignmentBinding(source); if (!assignment) return; @@ -3854,6 +3898,12 @@ function updateShellVariableTaint( assignment.value, taintedVariables, anyEnvironmentVariableTainted, + ) + || shellSubstitutionsReferenceTaint( + completeSource, + taintedBindings, + taintedVariables, + anyEnvironmentVariableTainted, ); if (valueIsTainted) taintedVariables.add(assignment.name); else if (assignment.operator !== "+=") taintedVariables.delete(assignment.name); @@ -3956,6 +4006,10 @@ function shellStageHasTaintedEnvironmentBinding( function shellStageWithoutCommandWrappers(stage) { const words = shellCommandWords(stripShellCommandGrouping(stage)); + return words.slice(shellCommandWrapperCursor(words)).join(" "); +} + +function shellCommandWrapperCursor(words, { allowSudo = false } = {}) { let cursor = 0; while (cursor < words.length) { while (/^[A-Za-z_][A-Za-z0-9_]*=/u.test(words[cursor] ?? "")) cursor += 1; @@ -3977,13 +4031,38 @@ function shellStageWithoutCommandWrappers(stage) { } if (command === "env") { cursor += 1; - while (words[cursor]?.startsWith("-") - || /^[A-Za-z_][A-Za-z0-9_]*=/u.test(words[cursor] ?? "")) cursor += 1; + while (cursor < words.length) { + const argument = words[cursor] ?? ""; + if (/^[A-Za-z_][A-Za-z0-9_]*=/u.test(argument)) { + cursor += 1; + continue; + } + if (argument === "--") { + cursor += 1; + break; + } + if (!argument.startsWith("-")) break; + cursor += 1; + if (["-C", "-S", "-u", "--chdir", "--split-string", "--unset"].includes( + argument, + )) cursor += 1; + } + continue; + } + if (allowSudo && command === "sudo") { + cursor += 1; + while (words[cursor]?.startsWith("-")) { + const option = words[cursor]; + cursor += 1; + if (["-c", "-g", "-h", "-p", "-r", "-t", "-u"].includes( + option.toLowerCase(), + )) cursor += 1; + } continue; } break; } - return words.slice(cursor).join(" "); + return cursor; } function shellStageHasProgramHereInput(stage) { @@ -3995,10 +4074,29 @@ function shellStageHasProgramHereInput(stage) { function shellStageExecutesStdinAsProgram(stage) { const words = shellCommandWords(stage); const commandName = path.posix.basename(words[0] ?? "").toLowerCase(); - if ([ - "bash", "dash", "fish", "ksh", "powershell", "powershell.exe", "pwsh", - "pwsh.exe", "sh", "zsh", - ].includes(commandName)) return true; + if (["powershell", "powershell.exe", "pwsh", "pwsh.exe"].includes( + commandName, + )) return true; + if (["bash", "dash", "fish", "ksh", "sh", "zsh"].includes(commandName)) { + let cursor = 1; + while (cursor < words.length) { + const argument = words[cursor]; + cursor += 1; + if (argument === "-") return true; + if (["--help", "--version"].includes(argument)) return false; + if (interpreterOptionIsExecutionMode(commandName, argument) + || /^-[^-]*c/u.test(argument)) return false; + if (argument === "-s" || /^-[^-]*s/u.test(argument)) return true; + if (interpreterOptionConsumesValue(commandName, argument)) { + cursor += 1; + continue; + } + if (argument === "--") return words[cursor] === undefined; + if (argument.startsWith("-")) continue; + return false; + } + return true; + } if (!["node", "perl", "php", "ruby"].includes(commandName) && !/^python\d*(?:\.\d+)*$/u.test(commandName)) return false; let cursor = 1; @@ -4209,26 +4307,7 @@ function gitGlobalOptionConsumesNext(option) { function shellGitArguments(stage) { const words = shellCommandWords(stripShellCommandGrouping(stage)); - let cursor = 0; - while (cursor < words.length) { - if (/^[A-Za-z_][A-Za-z0-9_]*=/u.test(words[cursor])) { - cursor += 1; - continue; - } - const command = path.posix.basename(words[cursor]).toLowerCase(); - if (["builtin", "command", "exec", "nohup", "time"].includes(command)) { - cursor += 1; - while (words[cursor]?.startsWith("-")) cursor += 1; - continue; - } - if (command === "env") { - cursor += 1; - while (words[cursor]?.startsWith("-") - || /^[A-Za-z_][A-Za-z0-9_]*=/u.test(words[cursor] ?? "")) cursor += 1; - continue; - } - break; - } + const cursor = shellCommandWrapperCursor(words); if (path.posix.basename(words[cursor] ?? "").toLowerCase() !== "git") { return undefined; } @@ -4680,37 +4759,7 @@ function shellArtifactExecutionSources(segment, environmentValues = new Map()) { else commandEnvironmentValues.set(binding.name, value); } const words = shellCommandWords(segment); - let cursor = 0; - while (cursor < words.length) { - if (/^[A-Za-z_][A-Za-z0-9_]*=/u.test(words[cursor])) { - cursor += 1; - continue; - } - const command = path.posix.basename(words[cursor]).toLowerCase(); - if (["builtin", "command", "exec", "nohup", "time"].includes(command)) { - cursor += 1; - while (words[cursor]?.startsWith("-")) cursor += 1; - continue; - } - if (command === "sudo") { - cursor += 1; - while (words[cursor]?.startsWith("-")) { - const option = words[cursor]; - cursor += 1; - if (["-c", "-g", "-h", "-p", "-r", "-t", "-u"].includes(option.toLowerCase())) { - cursor += 1; - } - } - continue; - } - if (command === "env") { - cursor += 1; - while (words[cursor]?.startsWith("-") - || /^[A-Za-z_][A-Za-z0-9_]*=/u.test(words[cursor] ?? "")) cursor += 1; - continue; - } - break; - } + let cursor = shellCommandWrapperCursor(words, { allowSudo: true }); const command = words[cursor]; if (!command) return []; cursor += 1; @@ -5015,6 +5064,38 @@ function shellCommandSegments(runSource) { return segments; } +function shellCommandSegmentsPreservingGroupRedirections(runSource) { + const segments = shellCommandSegments(runSource); + const preserved = []; + let pending; + let closing; + for (const segment of segments) { + if (!pending) { + const opening = /^\s*(\{|\()/u.exec(segment)?.[1]; + if (!opening || (opening === "{" && !/^\s*\{(?:\s|$)/u.test(segment))) { + preserved.push(segment); + continue; + } + pending = [segment]; + closing = opening === "{" ? "}" : ")"; + } else { + pending.push(segment); + } + const closingPattern = closing === "}" + ? /(?:^|\s)\}(?=\s|$|[<>])/u + : /\)(?=\s|$|[<>])/u; + const closingMatch = closingPattern.exec(segment); + if (!closingMatch) continue; + const afterClosing = segment.slice(closingMatch.index + closingMatch[0].length); + if (/^\s*\d*>>?/u.test(afterClosing)) preserved.push(pending.join("; ")); + else preserved.push(...pending); + pending = undefined; + closing = undefined; + } + if (pending) preserved.push(...pending); + return preserved; +} + function joinShellHeredocBodies(source) { const lines = source.split("\n"); const joined = []; diff --git a/public-source-release-audit/tests/public-source-release-audit.test.mjs b/public-source-release-audit/tests/public-source-release-audit.test.mjs index 82d50c4..86e6ab2 100644 --- a/public-source-release-audit/tests/public-source-release-audit.test.mjs +++ b/public-source-release-audit/tests/public-source-release-audit.test.mjs @@ -317,6 +317,35 @@ test("history mode rejects shallow evidence", () => { assert.match(audit.result.error.message, /shallow/u); }); +test("partial clones fail before the audit can lazily fetch objects", () => { + const sourceRoot = makeRepository(); + write(sourceRoot, "source-only.txt", "source-only fixture\n"); + commitAll(sourceRoot, "add source-only fixture"); + git(sourceRoot, ["config", "uploadpack.allowFilter", "true"]); + git(sourceRoot, ["config", "uploadpack.allowAnySHA1InWant", "true"]); + const cloneParent = mkdtempSync(path.join(os.tmpdir(), "public-source-partial-clone-")); + temporaryRoots.add(cloneParent); + const cloneRoot = path.join(cloneParent, "clone"); + const clone = spawnSync("git", [ + "clone", "--quiet", "--filter=blob:none", "--no-checkout", + `file://${sourceRoot}`, cloneRoot, + ], { + encoding: "utf8", + env: process.env, + }); + assert.equal(clone.status, 0, clone.stderr); + const missingBefore = missingGitObjectIds(cloneRoot); + assert.ok(missingBefore.length > 0, "fixture partial clone must omit at least one object"); + + const audit = runAudit(cloneRoot); + + assert.equal(audit.status, 2); + assert.equal(audit.result.passed, false); + assert.equal(audit.result.error.code, "audit-error"); + assert.match(audit.result.error.message, /partial clone/u); + assert.deepEqual(missingGitObjectIds(cloneRoot), missingBefore); +}); + test("workflow advisories can be promoted to failures", () => { const repoRoot = makeRepository(); write(repoRoot, ".github/workflows/advisory.yml", [ @@ -1531,6 +1560,21 @@ test("untrusted refs persisted through GITHUB_ENV reach later steps", () => { " ref: " + envExpression, "", ].join("\n")); + write(repoRoot, ".github/workflows/github-env-group-eval.yml", [ + "name: GITHUB_ENV grouped evaluator", + "on: issue_comment", + "permissions: read-all", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - env:", + " CODE: " + ["$", "{{ github.event.comment.body }}"].join(""), + " run: |", + " { echo \"PAYLOAD=$CODE\"; } >> \"$GITHUB_ENV\"", + " - run: eval \"$PAYLOAD\"", + "", + ].join("\n")); commitAll(repoRoot, "add persisted environment checkout workflow"); const audit = runAudit(repoRoot); @@ -1551,6 +1595,11 @@ test("untrusted refs persisted through GITHUB_ENV reach later steps", () => { "workflow-privileged-untrusted-checkout", ".github/workflows/github-env-alias-ref.yml", ); + assertFinding( + audit.result, + "workflow-privileged-untrusted-script-interpolation", + ".github/workflows/github-env-group-eval.yml", + ); }); test("GITHUB_ENV taint does not flow backward to earlier steps", () => { @@ -2260,6 +2309,38 @@ test("workflow_run artifact provenance survives current-run reuploads", () => { " - run: bash payload/run.sh", "", ].join("\n")); + write(repoRoot, ".github/workflows/workflow-run-multiline-reuploaded-artifact.yml", [ + "name: workflow run multiline reuploaded artifact", + "on:", + " workflow_run:", + " workflows: [verify]", + " types: [completed]", + "permissions: read-all", + "jobs:", + " relay:", + " runs-on: ubuntu-latest", + " steps:", + " - uses: actions/download-artifact@" + "a".repeat(40), + " with:", + " run-id: " + runIdExpression, + " path: inbound", + " - uses: actions/upload-artifact@" + "b".repeat(40), + " with:", + " name: forwarded-multiline-payload", + " path: |", + " trusted", + " inbound", + " execute:", + " needs: relay", + " runs-on: ubuntu-latest", + " steps:", + " - uses: actions/download-artifact@" + "a".repeat(40), + " with:", + " name: forwarded-multiline-payload", + " path: payload", + " - run: bash payload/run.sh", + "", + ].join("\n")); commitAll(repoRoot, "add reuploaded workflow artifact execution"); const audit = runAudit(repoRoot); @@ -2270,6 +2351,11 @@ test("workflow_run artifact provenance survives current-run reuploads", () => { "workflow-privileged-untrusted-artifact-execution", ".github/workflows/workflow-run-reuploaded-artifact.yml", ); + assertFinding( + audit.result, + "workflow-privileged-untrusted-artifact-execution", + ".github/workflows/workflow-run-multiline-reuploaded-artifact.yml", + ); }); test("artifact execution resolves step working directories", () => { @@ -2392,6 +2478,7 @@ test("artifact execution recognizes common command wrappers and paths", () => { ["make-file", "make -f payload/Makefile"], ["make-nested-directory", "make -C payload -C nested"], ["awk-file", "awk -f payload/run.awk /dev/null"], + ["timeout", "timeout 10 bash payload/run.sh"], ]) { write(repoRoot, `.github/workflows/workflow-run-artifact-${name}.yml`, [ `name: workflow run artifact ${name}`, @@ -2498,6 +2585,7 @@ test("artifact execution recognizes common command wrappers and paths", () => { "make-file", "make-nested-directory", "awk-file", + "timeout", "node-options", "env-node-options", "workflow-alias", @@ -2933,6 +3021,21 @@ test("tainted environment values cannot reach shell evaluators", () => { " \"$RUNNER\" \"$CODE\"", "", ].join("\n")); + write(repoRoot, ".github/workflows/comment-substitution-assignment-eval.yml", [ + "name: comment substitution assignment eval", + "on: issue_comment", + "permissions: read-all", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - env:", + " CODE: " + bodyExpression, + " run: |", + " VALUE=$(printf '%s' \"$CODE\")", + " eval \"$VALUE\"", + "", + ].join("\n")); write(repoRoot, ".github/workflows/comment-overwritten-eval.yml", [ "name: comment overwritten eval", "on: issue_comment", @@ -2964,7 +3067,9 @@ test("tainted environment values cannot reach shell evaluators", () => { "workflow-privileged-untrusted-script-interpolation", ".github/workflows/comment-background-eval.yml", ); - for (const form of ["indirect", "nameref", "positional", "command-alias"]) { + for (const form of [ + "indirect", "nameref", "positional", "command-alias", "substitution-assignment", + ]) { assertFinding( audit.result, "workflow-privileged-untrusted-script-interpolation", @@ -3099,6 +3204,19 @@ test("tainted Git SSH command templates are implicit shell evaluators", () => { " run: git --config-env=core.sshCommand=SSH_COMMAND ls-remote ssh://example.invalid/repository", "", ].join("\n")); + write(unsafeRepo, ".github/workflows/comment-timeout-git-ssh-command.yml", [ + "name: comment timeout Git SSH command", + "on: issue_comment", + "permissions: read-all", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - env:", + " GIT_SSH_COMMAND: " + bodyExpression, + " run: timeout 10 git ls-remote ssh://example.invalid/repository", + "", + ].join("\n")); write(safeRepo, ".github/workflows/comment-git-message.yml", [ "name: comment Git message", "on: issue_comment", @@ -3125,6 +3243,7 @@ test("tainted Git SSH command templates are implicit shell evaluators", () => { "comment-env-git-ssh-command", "comment-git-core-ssh-command", "comment-git-config-env-ssh-command", + "comment-timeout-git-ssh-command", ]) { assertFinding( unsafeAudit.result, @@ -3137,6 +3256,7 @@ test("tainted Git SSH command templates are implicit shell evaluators", () => { test("tainted script text cannot be piped into shell interpreters", () => { const repoRoot = makeRepository(); + const safeRepo = makeRepository(); const bodyExpression = ["$", "{{ github.event.comment.body }}"].join(""); write(repoRoot, ".github/workflows/comment-pipe-shell.yml", [ "name: comment pipe shell", @@ -3192,9 +3312,24 @@ test("tainted script text cannot be piped into shell interpreters", () => { " run: printf '%s' \"$COMMAND\" | php -d display_errors=1", "", ].join("\n")); + write(safeRepo, ".github/workflows/comment-pipe-shell-script.yml", [ + "name: comment pipe shell script", + "on: issue_comment", + "permissions: read-all", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - env:", + " DATA: " + bodyExpression, + " run: printf '%s' \"$DATA\" | bash trusted.sh", + "", + ].join("\n")); commitAll(repoRoot, "add tainted shell pipeline"); + commitAll(safeRepo, "add shell script data pipeline"); const audit = runAudit(repoRoot); + const safeAudit = runAudit(safeRepo); assert.equal(audit.status, 1); assertFinding( @@ -3219,6 +3354,7 @@ test("tainted script text cannot be piped into shell interpreters", () => { "workflow-privileged-untrusted-script-interpolation", ".github/workflows/comment-pipe-php-options.yml", ); + assert.equal(safeAudit.status, 0); }); test("tainted here-strings cannot feed shell interpreters", () => { @@ -4930,6 +5066,9 @@ test("tainted text cannot reach language runtime evaluators", () => { ["python", "python -c \"$COMMAND\""], ["python-attached", "python -c\"$COMMAND\""], ["node", "node -e \"$COMMAND\""], + ["node-long-eval", "node --eval \"$COMMAND\""], + ["node-print", "node -p \"$COMMAND\""], + ["node-long-print", "node --print \"$COMMAND\""], ["perl", "perl -e \"$COMMAND\""], ["ruby", "ruby -e \"$COMMAND\""], ["nohup-shell", "nohup bash -c \"$COMMAND\""], @@ -4959,7 +5098,8 @@ test("tainted text cannot reach language runtime evaluators", () => { assert.equal(audit.status, 1); for (const runtime of [ - "python", "python-attached", "node", "perl", "ruby", "nohup-shell", + "python", "python-attached", "node", "node-long-eval", "node-print", + "node-long-print", "perl", "ruby", "nohup-shell", "timeout-shell", "awk", "awk-e", "sed", "sed-e", ]) { assertFinding( @@ -5500,6 +5640,21 @@ function git(repoRoot, args) { return result; } +function missingGitObjectIds(repoRoot) { + const result = spawnSync("git", [ + "rev-list", "--objects", "--missing=print", "HEAD", + ], { + cwd: repoRoot, + encoding: "utf8", + env: { ...process.env, GIT_NO_LAZY_FETCH: "1" }, + }); + assert.equal(result.status, 0, result.stderr); + return result.stdout + .split("\n") + .filter((line) => line.startsWith("?")) + .sort(); +} + function runAudit(repoRoot, args = [], { appendJsonFormat = true, env = process.env } = {}) { const commandArguments = [AUDIT_SCRIPT, "--repo", repoRoot, ...args]; if (appendJsonFormat) commandArguments.push("--format", "json"); From 848d5e2afbac42fe163cf73465fb4bfe6566575e Mon Sep 17 00:00:00 2001 From: Vladimir Date: Sat, 22 Aug 2026 11:03:28 +0800 Subject: [PATCH 33/37] Cover remaining dynamic execution sinks --- .../scripts/public-source-release-audit.mjs | 388 +++++++++++++++++- .../public-source-release-audit.test.mjs | 120 +++++- 2 files changed, 486 insertions(+), 22 deletions(-) diff --git a/public-source-release-audit/scripts/public-source-release-audit.mjs b/public-source-release-audit/scripts/public-source-release-audit.mjs index 12e89d6..a0bdf36 100644 --- a/public-source-release-audit/scripts/public-source-release-audit.mjs +++ b/public-source-release-audit/scripts/public-source-release-audit.mjs @@ -755,10 +755,7 @@ function auditLocalCompositeActions( nestedActive, ), ); - const outputEvaluationBindings = mergeTaintedBindings( - effectiveTaintedBindings, - stepAnalysis.derivedTaintedBindings, - ); + const outputEvaluationBindings = stepAnalysis.finalTaintedBindings; const outputs = new Set(analysis.outputBindings .filter((binding) => isUntrustedReusableValue( binding.value, @@ -1634,10 +1631,7 @@ function workflowJobTaintAnalysis( return { configurationTaintedBindings: matrixTaintedBindings, stepContexts: stepAnalysis.stepContexts, - taintedBindings: mergeTaintedBindings( - matrixTaintedBindings, - stepAnalysis.derivedTaintedBindings, - ), + taintedBindings: stepAnalysis.finalTaintedBindings, }; } @@ -1647,6 +1641,7 @@ function workflowStepTaintAnalysis( inheritedTaintedBindings, stepOutputResolver, ) { + const clearedTaintedBindings = new Set(); const derivedTaintedBindings = new Set(); const stepContexts = []; for (const stepGroup of stepGroups) { @@ -1654,6 +1649,9 @@ function workflowStepTaintAnalysis( inheritedTaintedBindings, derivedTaintedBindings, ); + for (const binding of clearedTaintedBindings) { + accumulatedTaintedBindings.delete(binding); + } const stepId = stepGroup.properties .filter(({ entry }) => entry.key.toLowerCase() === "id") .map(({ entry }) => resolveYamlScalarValue(entry.value, scalarAnchors).toLowerCase()) @@ -1670,8 +1668,16 @@ function workflowStepTaintAnalysis( for (const binding of githubEnvironmentWriteBindings(runSource, stepTaintedBindings)) { derivedTaintedBindings.add(`env.${binding}`); } - if (shellRunTaintsFetchHead(runSource, stepTaintedBindings)) { + const fetchHeadTaintUpdate = shellRunFetchHeadTaintUpdate( + runSource, + stepTaintedBindings, + ); + if (fetchHeadTaintUpdate === true) { derivedTaintedBindings.add("git.fetch_head"); + clearedTaintedBindings.delete("git.fetch_head"); + } else if (fetchHeadTaintUpdate === false) { + derivedTaintedBindings.delete("git.fetch_head"); + clearedTaintedBindings.add("git.fetch_head"); } } if (!stepId) continue; @@ -1697,7 +1703,17 @@ function workflowStepTaintAnalysis( derivedTaintedBindings.add(`steps.${stepId}.outputs.${outputName}`); } } - return { derivedTaintedBindings, stepContexts }; + const finalTaintedBindings = mergeTaintedBindings( + inheritedTaintedBindings, + derivedTaintedBindings, + ); + for (const binding of clearedTaintedBindings) finalTaintedBindings.delete(binding); + return { + clearedTaintedBindings, + derivedTaintedBindings, + finalTaintedBindings, + stepContexts, + }; } function githubEnvironmentWriteBindings(runSource, taintedBindings) { @@ -3733,6 +3749,23 @@ function shellRunEvaluatesTaintedVariable(runSource, taintedBindings) { localTaintedVariables, anyEnvironmentVariableTainted, ); + if (shellStageHasTaintedCommandPosition( + stage, + taintedBindings, + localTaintedVariables, + anyEnvironmentVariableTainted, + )) return true; + const redirectedInputTainted = shellStageRedirectedInputReferencesTaint( + stage, + taintedBindings, + localTaintedVariables, + anyEnvironmentVariableTainted, + ); + updateShellReadTaint( + stage, + localTaintedVariables, + pipelineInputTainted || redirectedInputTainted, + ); if (evaluatorCommand.test(sinkStage) && (stageReferencesTaint || pipelineInputTainted)) { return true; @@ -3766,6 +3799,14 @@ function shellRunEvaluatesTaintedVariable(runSource, taintedBindings) { anyEnvironmentVariableTainted, ))) return true; if (shellStageHasProgramHereInput(sinkStage) && stageReferencesTaint) return true; + if (shellStageExecutesTaintedProcessSubstitution( + sinkStage, + taintedBindings, + localTaintedVariables, + anyEnvironmentVariableTainted, + )) return true; + if (shellStageXargsExecutesInputAsProgram(sinkStage) + && (pipelineInputTainted || redirectedInputTainted)) return true; if (shellStageExecutesStdinAsProgram(sinkStage) && pipelineInputTainted) return true; pipelineInputTainted ||= stageReferencesTaint; } @@ -3775,6 +3816,162 @@ function shellRunEvaluatesTaintedVariable(runSource, taintedBindings) { return sourceEvaluatesTaint(runSource, taintedVariables, new Map(), 0); } +function shellStageHasTaintedCommandPosition( + stage, + taintedBindings, + taintedVariables, + anyEnvironmentVariableTainted, +) { + const words = shellCommandWords(stripShellCommandGrouping(stage)); + const command = words[shellCommandWrapperCursor(words, { allowSudo: true })]; + return Boolean(command) && ( + isUntrustedReusableValue(command, taintedBindings) + || shellSourceReferencesTaintedVariable( + command, + taintedVariables, + anyEnvironmentVariableTainted, + ) + ); +} + +function shellStageRedirectedInputReferencesTaint( + stage, + taintedBindings, + taintedVariables, + anyEnvironmentVariableTainted, +) { + const redirectIndex = stage.search(/(?:^|\s)\d*<(?:<<)?-?\s*/u); + if (redirectIndex < 0) return false; + const inputSource = stage.slice(redirectIndex); + return isUntrustedReusableValue(inputSource, taintedBindings) + || shellSourceReferencesTaintedVariable( + inputSource, + taintedVariables, + anyEnvironmentVariableTainted, + ); +} + +function updateShellReadTaint(stage, taintedVariables, inputTainted) { + if (!inputTainted) return; + const words = shellCommandWords(stripShellCommandGrouping(stage)); + let cursor = shellCommandWrapperCursor(words); + if (path.posix.basename(words[cursor] ?? "").toLowerCase() !== "read") return; + cursor += 1; + const targets = []; + while (cursor < words.length) { + const argument = words[cursor]; + cursor += 1; + if (argument === "--") continue; + if (/^(?:\d*) 0 ? targets : ["reply"]) { + taintedVariables.add(target); + } +} + +function shellStageExecutesTaintedProcessSubstitution( + stage, + taintedBindings, + taintedVariables, + anyEnvironmentVariableTainted, +) { + const words = shellCommandWords(stage); + const commandName = path.posix.basename(words[0] ?? "").toLowerCase(); + if (!new Set([ + "bash", "dash", "fish", "ksh", "node", "perl", "php", "python", + "python2", "python3", "ruby", "sh", "zsh", + ]).has(commandName) && !/^python\d+(?:\.\d+)*$/u.test(commandName)) return false; + let cursor = 1; + while (cursor < words.length) { + const argument = words[cursor]; + cursor += 1; + if (argument === "--") continue; + if (interpreterOptionIsExecutionMode(commandName, argument)) return false; + const loadedSource = interpreterOptionLoadedSource( + commandName, + argument, + words[cursor], + ); + if (loadedSource?.consumesNext) { + cursor += 1; + continue; + } + if (interpreterOptionConsumesValue(commandName, argument)) { + cursor += 1; + continue; + } + if (argument.startsWith("-") && !argument.startsWith("<(")) continue; + if (!argument.startsWith("<(")) return false; + const candidate = [argument, ...words.slice(cursor)].join(" "); + const body = /^<\(([\s\S]*?)\)(?:\s|$)/u.exec(candidate)?.[1]; + return Boolean(body) && ( + isUntrustedReusableValue(body, taintedBindings) + || shellSourceReferencesTaintedVariable( + body, + taintedVariables, + anyEnvironmentVariableTainted, + ) + ); + } + return false; +} + +function shellStageXargsExecutesInputAsProgram(stage) { + const words = shellCommandWords(stage); + if (path.posix.basename(words[0] ?? "").toLowerCase() !== "xargs") return false; + let cursor = 1; + while (cursor < words.length) { + const argument = words[cursor]; + if (argument === "--") { + cursor += 1; + break; + } + if (!argument.startsWith("-")) break; + cursor += 1; + if ([ + "-a", "--arg-file", "-d", "--delimiter", "-E", "-e", "--eof", + "-I", "-i", "--replace", "-L", "-l", "--max-lines", "-n", + "--max-args", "-P", "--max-procs", "-s", "--max-chars", + ].includes(argument)) cursor += 1; + } + const commandWords = words.slice(cursor); + cursor = shellCommandWrapperCursor(commandWords, { allowSudo: true }); + const commandName = path.posix.basename(commandWords[cursor] ?? "").toLowerCase(); + cursor += 1; + if (commandName === "eval") return cursor >= commandWords.length; + const interpreters = new Set([ + "bash", "dash", "fish", "ksh", "node", "perl", "php", "powershell", + "powershell.exe", "pwsh", "pwsh.exe", "python", "python2", "python3", + "ruby", "sh", "zsh", + ]); + if (!interpreters.has(commandName) + && !/^python\d+(?:\.\d+)*$/u.test(commandName)) return false; + while (cursor < commandWords.length) { + const argument = commandWords[cursor]; + cursor += 1; + if (interpreterOptionIsExecutionMode(commandName, argument)) { + return cursor >= commandWords.length; + } + if (interpreterOptionConsumesValue(commandName, argument)) cursor += 1; + } + return false; +} + function shellCommandSubstitutions(source) { const substitutions = []; let quote; @@ -4748,7 +4945,11 @@ function shellRunExecutesArtifactPath( return false; } -function shellArtifactExecutionSources(segment, environmentValues = new Map()) { +function shellArtifactExecutionSources( + segment, + environmentValues = new Map(), + depth = 0, +) { const commandEnvironmentValues = new Map(environmentValues); for (const binding of shellCommandEnvironmentBindings(segment)) { const value = resolveStaticEnvironmentReferences( @@ -4774,6 +4975,9 @@ function shellArtifactExecutionSources(segment, environmentValues = new Map()) { if (["gsed", "sed"].includes(commandName)) { return programFileExecutionSources(words.slice(cursor), ["-f", "--file"]); } + if (commandName === "npm") { + return npmPackageExecutionSources(words.slice(cursor)); + } const interpreters = new Set([ "bash", "dash", "deno", "fish", "ksh", "node", "perl", "php", "powershell", "powershell.exe", "pwsh", "pwsh.exe", "python", "python2", @@ -4811,6 +5015,26 @@ function shellArtifactExecutionSources(segment, environmentValues = new Map()) { executionSources.push(redirectedSource); return executionSources; } + const commandString = interpreterShellCommandString( + commandName, + argument, + words[cursor], + ); + if (commandString !== undefined) { + if (depth >= 16) return [...executionSources, "${dynamic-command-string}"]; + const resolvedCommandString = resolveStaticEnvironmentReferences( + commandString, + commandEnvironmentValues, + ) ?? commandString; + return [ + ...executionSources, + ...shellCommandStringArtifactExecutionSources( + resolvedCommandString, + commandEnvironmentValues, + depth + 1, + ), + ]; + } if (interpreterOptionIsExecutionMode(commandName, argument)) { return executionSources; } @@ -4837,6 +5061,81 @@ function shellArtifactExecutionSources(segment, environmentValues = new Map()) { return command.includes("/") ? [command] : []; } +function shellCommandStringArtifactExecutionSources( + commandString, + environmentValues, + depth, +) { + if (/[$%`]/u.test(commandString)) return [commandString]; + let workingDirectory = "."; + const localEnvironmentValues = new Map(environmentValues); + const sources = []; + for (const segment of shellCommandSegments(commandString)) { + const executableSegment = stripShellCommandGrouping(segment); + applyStaticShellAssignment(executableSegment, localEnvironmentValues); + const directoryChange = /^\s*(?:(?:builtin|command)\s+)?(?:cd|pushd)\s+(?:--\s+)?("[^"]*"|'[^']*'|[^\s;&|]+)/iu.exec( + executableSegment, + ); + if (directoryChange) { + const target = resolveStaticEnvironmentReferences( + directoryChange[1], + localEnvironmentValues, + ) ?? directoryChange[1]; + workingDirectory = resolveShellWorkingDirectory(workingDirectory, target); + continue; + } + for (const source of shellArtifactExecutionSources( + executableSegment, + localEnvironmentValues, + depth, + )) { + sources.push(workingDirectory === "." || path.posix.isAbsolute(source) + ? source + : path.posix.join(workingDirectory, source)); + } + } + return sources; +} + +function interpreterShellCommandString(commandName, argument, nextArgument) { + const posixShells = new Set(["bash", "dash", "fish", "ksh", "sh", "zsh"]); + if (posixShells.has(commandName)) { + if (argument === "-c") return nextArgument; + return /^-c(.+)$/u.exec(argument)?.[1]; + } + if (["powershell", "powershell.exe", "pwsh", "pwsh.exe"].includes( + commandName, + )) { + if (["-c", "-command", "--command"].includes(argument.toLowerCase())) { + return nextArgument; + } + } + return undefined; +} + +function npmPackageExecutionSources(arguments_) { + const lifecycleCommands = new Set([ + "ci", "i", "install", "pack", "publish", "rebuild", "remove", "restart", + "rm", "run", "run-script", "start", "stop", "t", "test", "uninstall", + "version", + ]); + if (!arguments_.some((argument) => lifecycleCommands.has( + argument.toLowerCase(), + ))) return []; + let prefix = "."; + for (let cursor = 0; cursor < arguments_.length; cursor += 1) { + const argument = arguments_[cursor]; + if (argument === "--prefix") { + prefix = arguments_[cursor + 1] ?? "${dynamic-npm-prefix}"; + cursor += 1; + continue; + } + const inlinePrefix = /^--prefix=(.*)$/iu.exec(argument)?.[1]; + if (inlinePrefix !== undefined) prefix = inlinePrefix || "${dynamic-npm-prefix}"; + } + return [prefix === "." ? "package.json" : path.posix.join(prefix, "package.json")]; +} + function programFileExecutionSources(arguments_, options) { const sources = []; for (let cursor = 0; cursor < arguments_.length;) { @@ -5168,13 +5467,12 @@ function shellRunHasUntrustedCheckout(runSource, taintedBindings) { return shellRunGitTaintAnalysis(runSource, taintedBindings).hasUntrustedCheckout; } -function shellRunTaintsFetchHead(runSource, taintedBindings) { - return shellRunGitTaintAnalysis(runSource, taintedBindings).taintsFetchHead; +function shellRunFetchHeadTaintUpdate(runSource, taintedBindings) { + return shellRunGitTaintAnalysis(runSource, taintedBindings).fetchHeadTaintUpdate; } function shellRunGitTaintAnalysis(runSource, taintedBindings) { const checkoutCommand = /\b(?:gh\s+repo\s+clone|git(?:\s+--?[^\s]+(?:[=\s][^\s]+)?)*\s+(?:checkout|clone|pull|reset|switch|worktree))\b/iu; - const fetchCommand = /\bgit(?:\s+--?[^\s]+(?:[=\s][^\s]+)?)*\s+fetch\b/iu; const taintedVariables = new Set([...taintedBindings].flatMap((binding) => ( binding.startsWith("env.") && binding !== "env.*" ? [binding.slice("env.".length).toLowerCase()] @@ -5188,7 +5486,7 @@ function shellRunGitTaintAnalysis(runSource, taintedBindings) { .split("\n") .filter((line) => !/^\s*#/u.test(line)); let fetchedHeadTainted = taintedBindings.has("git.fetch_head"); - let taintsFetchHead = false; + let fetchHeadTaintUpdate; let currentHeadTainted = false; const taintedRefs = new Set(); for (const line of lines) { @@ -5207,9 +5505,15 @@ function shellRunGitTaintAnalysis(runSource, taintedBindings) { taintedVariables, anyEnvironmentVariableTainted, ); - if (fetchCommand.test(line) && lineIsTainted) { - fetchedHeadTainted = true; - taintsFetchHead = true; + const fetchHeadWriteMode = shellFetchHeadWriteMode(line); + if (fetchHeadWriteMode !== undefined && fetchHeadWriteMode !== "none") { + if (lineIsTainted) { + fetchedHeadTainted = true; + fetchHeadTaintUpdate = true; + } else if (fetchHeadWriteMode === "overwrite") { + fetchedHeadTainted = false; + fetchHeadTaintUpdate = false; + } } const persistedRef = shellGitPersistedRef(line); if (persistedRef && (lineIsTainted @@ -5230,14 +5534,56 @@ function shellRunGitTaintAnalysis(runSource, taintedBindings) { const materializesTaintedHead = currentHeadTainted && /\bgit(?:\s+--?[^\s]+(?:[=\s][^\s]+)?)*\s+reset\b[^\n]*\s--(?:hard|keep|merge)\b/iu.test(line); const materializesTaintedArchive = shellLineExtractsGitArchive(line); - if ((checkoutCommand.test(line) || materializesTaintedArchive) && (lineIsTainted + const materializesTaintedRestore = shellLineRestoresGitWorktree(line); + if ((checkoutCommand.test(line) + || materializesTaintedArchive + || materializesTaintedRestore) && (lineIsTainted || lineReferencesTaintedRef || materializesTaintedHead || (fetchedHeadTainted && /\bFETCH_HEAD\b/iu.test(line)))) { - return { hasUntrustedCheckout: true, taintsFetchHead }; + return { fetchHeadTaintUpdate, hasUntrustedCheckout: true }; } } - return { hasUntrustedCheckout: false, taintsFetchHead }; + return { fetchHeadTaintUpdate, hasUntrustedCheckout: false }; +} + +function shellFetchHeadWriteMode(source) { + const fetchArguments = shellGitSubcommandArguments(source, "fetch"); + if (!fetchArguments) return undefined; + if (fetchArguments.some((argument) => [ + "--dry-run", "--no-write-fetch-head", + ].includes(argument.toLowerCase()))) return "none"; + if (fetchArguments.some((argument) => ["-a", "--append"].includes( + argument.toLowerCase(), + ))) return "append"; + return "overwrite"; +} + +function shellLineRestoresGitWorktree(source) { + const restoreArguments = shellGitSubcommandArguments(source, "restore"); + if (!restoreArguments) return false; + const staged = restoreArguments.some((argument) => ["-S", "--staged"].includes( + argument, + )); + const worktree = restoreArguments.some((argument) => ["-W", "--worktree"].includes( + argument, + )); + return !staged || worktree; +} + +function shellGitSubcommandArguments(source, requestedCommand) { + const arguments_ = shellGitArguments(source); + if (!arguments_) return undefined; + let cursor = 0; + while (cursor < arguments_.length && arguments_[cursor].startsWith("-")) { + const option = arguments_[cursor]; + cursor += 1; + if (gitGlobalOptionConsumesNext(option)) cursor += 1; + } + if ((arguments_[cursor] ?? "").toLowerCase() !== requestedCommand) { + return undefined; + } + return arguments_.slice(cursor + 1); } function shellLineExtractsGitArchive(source) { diff --git a/public-source-release-audit/tests/public-source-release-audit.test.mjs b/public-source-release-audit/tests/public-source-release-audit.test.mjs index 86e6ab2..f477a3b 100644 --- a/public-source-release-audit/tests/public-source-release-audit.test.mjs +++ b/public-source-release-audit/tests/public-source-release-audit.test.mjs @@ -2479,6 +2479,8 @@ test("artifact execution recognizes common command wrappers and paths", () => { ["make-nested-directory", "make -C payload -C nested"], ["awk-file", "awk -f payload/run.awk /dev/null"], ["timeout", "timeout 10 bash payload/run.sh"], + ["shell-command-string", "bash -c 'source payload/run.sh'"], + ["npm-prefix", "npm --prefix payload test"], ]) { write(repoRoot, `.github/workflows/workflow-run-artifact-${name}.yml`, [ `name: workflow run artifact ${name}`, @@ -2586,6 +2588,8 @@ test("artifact execution recognizes common command wrappers and paths", () => { "make-nested-directory", "awk-file", "timeout", + "shell-command-string", + "npm-prefix", "node-options", "env-node-options", "workflow-alias", @@ -3036,6 +3040,34 @@ test("tainted environment values cannot reach shell evaluators", () => { " eval \"$VALUE\"", "", ].join("\n")); + write(repoRoot, ".github/workflows/comment-command-position-eval.yml", [ + "name: comment command position eval", + "on: issue_comment", + "permissions: read-all", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - env:", + " CODE: " + bodyExpression, + " run: $CODE", + "", + ].join("\n")); + write(repoRoot, ".github/workflows/comment-read-eval.yml", [ + "name: comment read eval", + "on: issue_comment", + "permissions: read-all", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - env:", + " CODE: " + bodyExpression, + " run: |", + " read -r VALUE <<< \"$CODE\"", + " eval \"$VALUE\"", + "", + ].join("\n")); write(repoRoot, ".github/workflows/comment-overwritten-eval.yml", [ "name: comment overwritten eval", "on: issue_comment", @@ -3069,6 +3101,7 @@ test("tainted environment values cannot reach shell evaluators", () => { ); for (const form of [ "indirect", "nameref", "positional", "command-alias", "substitution-assignment", + "command-position", "read", ]) { assertFinding( audit.result, @@ -3095,6 +3128,7 @@ test("tainted shell values cannot hide in parameter operators or command substit ["backtick-substitution", "echo \"`eval \\\"$CODE\\\"`\""], ["nested-backtick-substitution", "echo `echo \\`eval \"$CODE\"\\``"], ["process-substitution", "cat <(eval \"$CODE\")"], + ["process-substitution-script", "bash <(printf '%s' \"$CODE\")"], ]) { write(repoRoot, `.github/workflows/comment-${form}-eval.yml`, [ `name: comment ${form} eval`, @@ -3126,6 +3160,7 @@ test("tainted shell values cannot hide in parameter operators or command substit "backtick-substitution", "nested-backtick-substitution", "process-substitution", + "process-substitution-script", ]) { assertFinding( audit.result, @@ -3312,6 +3347,19 @@ test("tainted script text cannot be piped into shell interpreters", () => { " run: printf '%s' \"$COMMAND\" | php -d display_errors=1", "", ].join("\n")); + write(repoRoot, ".github/workflows/comment-pipe-xargs-shell.yml", [ + "name: comment pipe xargs shell", + "on: issue_comment", + "permissions: read-all", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - env:", + " COMMAND: " + bodyExpression, + " run: printf '%s' \"$COMMAND\" | xargs bash -c", + "", + ].join("\n")); write(safeRepo, ".github/workflows/comment-pipe-shell-script.yml", [ "name: comment pipe shell script", "on: issue_comment", @@ -3354,6 +3402,11 @@ test("tainted script text cannot be piped into shell interpreters", () => { "workflow-privileged-untrusted-script-interpolation", ".github/workflows/comment-pipe-php-options.yml", ); + assertFinding( + audit.result, + "workflow-privileged-untrusted-script-interpolation", + ".github/workflows/comment-pipe-xargs-shell.yml", + ); assert.equal(safeAudit.status, 0); }); @@ -3695,6 +3748,38 @@ test("tainted fetch state propagates through FETCH_HEAD checkouts", () => { " ./verify.sh", "", ].join("\n")); + write(repoRoot, ".github/workflows/fetched-head-restore.yml", [ + "name: fetched head restore", + "on: pull_request_target", + "permissions: read-all", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - env:", + " PR_SHA: " + refExpression, + " run: |", + " git fetch origin \"$PR_SHA\"", + " git restore --source FETCH_HEAD -- .", + " ./verify.sh", + "", + ].join("\n")); + write(repoRoot, ".github/workflows/fetched-head-append.yml", [ + "name: fetched head append", + "on: pull_request_target", + "permissions: read-all", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - env:", + " PR_SHA: " + refExpression, + " run: |", + " git fetch origin \"$PR_SHA\"", + " git fetch --append origin refs/heads/main", + " git checkout FETCH_HEAD", + "", + ].join("\n")); commitAll(repoRoot, "add fetched head checkout"); const audit = runAudit(repoRoot); @@ -3705,7 +3790,9 @@ test("tainted fetch state propagates through FETCH_HEAD checkouts", () => { "workflow-privileged-untrusted-checkout", ".github/workflows/fetched-head-checkout.yml", ); - for (const form of ["update-ref", "branch", "archive", "archive-get"]) { + for (const form of [ + "update-ref", "branch", "archive", "archive-get", "restore", "append", + ]) { assertFinding( audit.result, "workflow-privileged-untrusted-checkout", @@ -3733,6 +3820,37 @@ test("untrusted shell values unrelated to a fixed checkout do not block", () => " echo \"requested ref: $FETCH_REF\"", "", ].join("\n")); + write(repoRoot, ".github/workflows/trusted-fetch-overwrite.yml", [ + "name: trusted fetch overwrite", + "on: pull_request_target", + "permissions: read-all", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - env:", + " FETCH_REF: " + refExpression, + " run: |", + " git fetch origin \"$FETCH_REF\"", + " git fetch origin refs/heads/main", + " git checkout FETCH_HEAD", + "", + ].join("\n")); + write(repoRoot, ".github/workflows/trusted-fetch-cross-step-overwrite.yml", [ + "name: trusted fetch cross-step overwrite", + "on: pull_request_target", + "permissions: read-all", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - env:", + " FETCH_REF: " + refExpression, + " run: git fetch origin \"$FETCH_REF\"", + " - run: git fetch origin refs/heads/main", + " - run: git checkout FETCH_HEAD", + "", + ].join("\n")); commitAll(repoRoot, "add fixed shell checkout workflow"); const audit = runAudit(repoRoot); From 244dc8113550780b8ac3f63f7d6d86bd7bed16a0 Mon Sep 17 00:00:00 2001 From: Vladimir Date: Sat, 22 Aug 2026 11:32:00 +0800 Subject: [PATCH 34/37] Resolve remaining workflow execution review gaps --- .../scripts/public-source-release-audit.mjs | 415 +++++++++++++++--- .../public-source-release-audit.test.mjs | 170 ++++++- 2 files changed, 516 insertions(+), 69 deletions(-) diff --git a/public-source-release-audit/scripts/public-source-release-audit.mjs b/public-source-release-audit/scripts/public-source-release-audit.mjs index a0bdf36..d9b4035 100644 --- a/public-source-release-audit/scripts/public-source-release-audit.mjs +++ b/public-source-release-audit/scripts/public-source-release-audit.mjs @@ -1665,8 +1665,18 @@ function workflowStepTaintAnalysis( .filter(({ entry }) => entry.key.toLowerCase() === "run") .map(({ entry }) => resolveYamlScalarValue(entry.value, scalarAnchors)); for (const runSource of runSources) { - for (const binding of githubEnvironmentWriteBindings(runSource, stepTaintedBindings)) { - derivedTaintedBindings.add(`env.${binding}`); + for (const [binding, isTainted] of githubEnvironmentWriteUpdates( + runSource, + stepTaintedBindings, + )) { + const environmentBinding = `env.${binding}`; + if (isTainted) { + derivedTaintedBindings.add(environmentBinding); + clearedTaintedBindings.delete(environmentBinding); + } else { + derivedTaintedBindings.delete(environmentBinding); + clearedTaintedBindings.add(environmentBinding); + } } const fetchHeadTaintUpdate = shellRunFetchHeadTaintUpdate( runSource, @@ -1681,9 +1691,16 @@ function workflowStepTaintAnalysis( } } if (!stepId) continue; - const runOutputIsTainted = runSources.some((runSource) => ( - githubOutputWriteIsTainted(runSource, stepTaintedBindings) - )); + const outputUpdates = new Map(); + for (const runSource of runSources) { + for (const [name, isTainted] of githubOutputWriteUpdates( + runSource, + stepTaintedBindings, + )) outputUpdates.set(name, isTainted); + } + for (const [name, isTainted] of outputUpdates) { + if (isTainted) derivedTaintedBindings.add(`steps.${stepId}.outputs.${name}`); + } const actionOutputIsTainted = workflowMappingBindings( stepGroup, "with", @@ -1693,7 +1710,7 @@ function workflowStepTaintAnalysis( binding.value, stepTaintedBindings, )); - if (runOutputIsTainted || actionOutputIsTainted) { + if (actionOutputIsTainted) { derivedTaintedBindings.add(`steps.${stepId}.outputs.*`); } for (const outputName of stepOutputResolver?.( @@ -1716,14 +1733,30 @@ function workflowStepTaintAnalysis( }; } -function githubEnvironmentWriteBindings(runSource, taintedBindings) { +function githubEnvironmentWriteUpdates(runSource, taintedBindings) { + return githubEnvironmentFileWriteUpdates(runSource, taintedBindings, "github_env"); +} + +function githubOutputWriteUpdates(runSource, taintedBindings) { + return githubEnvironmentFileWriteUpdates( + runSource, + taintedBindings, + "github_output", + ); +} + +function githubEnvironmentFileWriteUpdates( + runSource, + taintedBindings, + initialAlias, +) { const taintedVariables = new Set([...taintedBindings].flatMap((binding) => ( binding.startsWith("env.") && binding !== "env.*" ? [binding.slice("env.".length).toLowerCase()] : [] ))); - const environmentFileAliases = new Set(["github_env"]); - const names = new Set(); + const environmentFileAliases = new Set([initialAlias]); + const updates = new Map(); for (const segment of shellCommandSegmentsPreservingGroupRedirections(runSource)) { const segmentReferencesTaint = isUntrustedReusableValue(segment, taintedBindings) || shellSourceReferencesTaintedVariable( @@ -1731,11 +1764,13 @@ function githubEnvironmentWriteBindings(runSource, taintedBindings) { taintedVariables, taintedBindings.has("env.*"), ); - const assignmentName = shellAssignmentName(segment); - if (assignmentName && segmentReferencesTaint) taintedVariables.add(assignmentName); + const assignment = shellAssignmentBinding(segment); + if (assignment && segmentReferencesTaint) taintedVariables.add(assignment.name); + else if (assignment && assignment.operator !== "+=") { + taintedVariables.delete(assignment.name); + } updateShellEnvironmentFileAliases(segment, environmentFileAliases); - if (!segmentReferencesTaint - || !shellSourceReferencesTaintedVariable( + if (!shellSourceReferencesTaintedVariable( segment, environmentFileAliases, false, @@ -1746,40 +1781,12 @@ function githubEnvironmentWriteBindings(runSource, taintedBindings) { const writtenNames = [...segment.matchAll( /(?:^|[\s"'`])([A-Za-z_][A-Za-z0-9_]*)\s*(?:=|<<)/gu, )].map((match) => match[1].toLowerCase()); - if (writtenNames.length === 0) names.add("*"); - for (const name of writtenNames) names.add(name); - } - return [...names]; -} - -function githubOutputWriteIsTainted(runSource, taintedBindings) { - const taintedVariables = new Set([...taintedBindings].flatMap((binding) => ( - binding.startsWith("env.") && binding !== "env.*" - ? [binding.slice("env.".length).toLowerCase()] - : [] - ))); - const environmentFileAliases = new Set(["github_output"]); - for (const segment of shellCommandSegmentsPreservingGroupRedirections(runSource)) { - const segmentReferencesTaint = isUntrustedReusableValue(segment, taintedBindings) - || shellSourceReferencesTaintedVariable( - segment, - taintedVariables, - taintedBindings.has("env.*"), - ); - const assignmentName = shellAssignmentName(segment); - if (assignmentName && segmentReferencesTaint) { - taintedVariables.add(assignmentName); + if (writtenNames.length === 0 && segmentReferencesTaint) { + updates.set("*", true); } - updateShellEnvironmentFileAliases(segment, environmentFileAliases); - if (shellSourceReferencesTaintedVariable( - segment, - environmentFileAliases, - false, - ) - && /(?:>>?|\b(?:Add-Content|Out-File|Set-Content|tee)\b)/iu.test(segment) - && segmentReferencesTaint) return true; + for (const name of writtenNames) updates.set(name, segmentReferencesTaint); } - return false; + return updates; } function mergeTaintedBindings(...bindingSets) { @@ -3766,6 +3773,29 @@ function shellRunEvaluatesTaintedVariable(runSource, taintedBindings) { localTaintedVariables, pipelineInputTainted || redirectedInputTainted, ); + updateShellPrintfTaint( + stage, + taintedBindings, + localTaintedVariables, + anyEnvironmentVariableTainted, + ); + for (const childCommand of shellFindExecutedCommands(stage)) { + if (depth >= 32) { + if (isUntrustedReusableValue(childCommand, taintedBindings) + || shellSourceReferencesTaintedVariable( + childCommand, + localTaintedVariables, + anyEnvironmentVariableTainted, + )) return true; + continue; + } + if (sourceEvaluatesTaint( + childCommand, + localTaintedVariables, + localStaticValues, + depth + 1, + )) return true; + } if (evaluatorCommand.test(sinkStage) && (stageReferencesTaint || pipelineInputTainted)) { return true; @@ -3884,6 +3914,68 @@ function updateShellReadTaint(stage, taintedVariables, inputTainted) { } } +function updateShellPrintfTaint( + stage, + taintedBindings, + taintedVariables, + anyEnvironmentVariableTainted, +) { + const words = shellCommandWords(stripShellCommandGrouping(stage)); + let cursor = shellCommandWrapperCursor(words); + if (path.posix.basename(words[cursor] ?? "").toLowerCase() !== "printf") return; + cursor += 1; + let target; + while (cursor < words.length) { + const argument = words[cursor]; + if (argument === "-v") { + target = words[cursor + 1]?.toLowerCase(); + cursor += 2; + continue; + } + const attachedTarget = /^-v([A-Za-z_][A-Za-z0-9_]*)$/u.exec(argument)?.[1]; + if (attachedTarget) { + target = attachedTarget.toLowerCase(); + cursor += 1; + continue; + } + if (argument === "--") cursor += 1; + break; + } + if (!target || !/^[A-Za-z_][A-Za-z0-9_]*$/u.test(target) + || cursor >= words.length) return; + const assignedValue = words.slice(cursor).join(" "); + const valueIsTainted = isUntrustedReusableValue(assignedValue, taintedBindings) + || shellSourceReferencesTaintedVariable( + assignedValue, + taintedVariables, + anyEnvironmentVariableTainted, + ); + if (valueIsTainted) taintedVariables.add(target); + else taintedVariables.delete(target); +} + +function shellFindExecutedCommands(stage) { + const words = shellCommandWords(stripShellCommandGrouping(stage)); + let cursor = shellCommandWrapperCursor(words, { allowSudo: true }); + if (path.posix.basename(words[cursor] ?? "").toLowerCase() !== "find") return []; + cursor += 1; + const commands = []; + const executionPredicates = new Set(["-exec", "-execdir", "-ok", "-okdir"]); + while (cursor < words.length) { + const argument = words[cursor]; + cursor += 1; + if (!executionPredicates.has(argument.toLowerCase())) continue; + const childWords = []; + while (cursor < words.length && ![";", "+"].includes(words[cursor])) { + childWords.push(words[cursor]); + cursor += 1; + } + if (childWords.length > 0) commands.push(childWords.join(" ")); + cursor += 1; + } + return commands; +} + function shellStageExecutesTaintedProcessSubstitution( stage, taintedBindings, @@ -4271,6 +4363,12 @@ function shellStageHasProgramHereInput(stage) { function shellStageExecutesStdinAsProgram(stage) { const words = shellCommandWords(stage); const commandName = path.posix.basename(words[0] ?? "").toLowerCase(); + if ([".", "source"].includes(commandName)) { + let cursor = 1; + if (words[cursor] === "--") cursor += 1; + return new Set(["/dev/fd/0", "/dev/stdin", "/proc/self/fd/0"]) + .has(words[cursor]); + } if (["powershell", "powershell.exe", "pwsh", "pwsh.exe"].includes( commandName, )) return true; @@ -4575,13 +4673,17 @@ function stepContextsHaveUntrustedArtifactExecution( )) { if (!artifactPaths.includes(artifactPath)) artifactPaths.push(artifactPath); } - if (artifactPaths.some((artifactPath) => stepExecutesArtifactPath( + const artifactAnalysis = stepArtifactExecutionAnalysis( stepGroup, scalarAnchors, - artifactPath, + artifactPaths, workingDirectory, stepEnvironmentValues, - ))) return true; + ); + if (artifactAnalysis.executes) return true; + for (const artifactPath of artifactAnalysis.derivedArtifactPaths) { + if (!artifactPaths.includes(artifactPath)) artifactPaths.push(artifactPath); + } if (localActionExecution?.( stepGroup, taintedBindings, @@ -4880,10 +4982,10 @@ function stepRunWorkingDirectory( return resolveStaticEnvironmentReferences(requested, environmentValues) ?? requested; } -function stepExecutesArtifactPath( +function stepArtifactExecutionAnalysis( stepGroup, scalarAnchors, - artifactPath, + artifactPaths, workingDirectory = ".", environmentValues = new Map(), ) { @@ -4891,28 +4993,51 @@ function stepExecutesArtifactPath( .filter(({ entry }) => entry.key.toLowerCase() === "uses") .map(({ entry }) => resolveYamlScalarValue(entry.value, scalarAnchors)) .find((reference) => reference.startsWith("./")); - if (localActionReference && artifactSourceMatchesPath(localActionReference, artifactPath)) { - return true; - } - return stepGroup.properties + if (localActionReference && artifactPaths.some((artifactPath) => ( + artifactSourceMatchesPath(localActionReference, artifactPath) + ))) { + return { derivedArtifactPaths: [], executes: true }; + } + const bashEnvironmentSource = environmentValues.get("bash_env"); + if (bashEnvironmentSource && artifactPaths.some((artifactPath) => ( + artifactSourceMatchesPath( + bashEnvironmentSource, + artifactPath, + workingDirectory, + ) + ))) return { derivedArtifactPaths: [], executes: true }; + const knownArtifactPaths = [...artifactPaths]; + const derivedArtifactPaths = []; + for (const runSource of stepGroup.properties .filter(({ entry }) => entry.key.toLowerCase() === "run") - .map(({ entry }) => resolveYamlScalarValue(entry.value, scalarAnchors)) - .some((runSource) => shellRunExecutesArtifactPath( + .map(({ entry }) => resolveYamlScalarValue(entry.value, scalarAnchors))) { + const analysis = shellRunArtifactExecutionAnalysis( runSource, - artifactPath, + knownArtifactPaths, workingDirectory, environmentValues, - )); + ); + if (analysis.executes) return { derivedArtifactPaths, executes: true }; + for (const artifactPath of analysis.derivedArtifactPaths) { + if (!knownArtifactPaths.includes(artifactPath)) knownArtifactPaths.push(artifactPath); + if (!derivedArtifactPaths.includes(artifactPath)) { + derivedArtifactPaths.push(artifactPath); + } + } + } + return { derivedArtifactPaths, executes: false }; } -function shellRunExecutesArtifactPath( +function shellRunArtifactExecutionAnalysis( runSource, - artifactPath, + artifactPaths, workingDirectory, environmentValues = new Map(), ) { let effectiveWorkingDirectory = workingDirectory; const localEnvironmentValues = new Map(environmentValues); + const knownArtifactPaths = [...artifactPaths]; + const derivedArtifactPaths = []; for (const segment of shellCommandSegments(runSource)) { if (/^\s*#/u.test(segment)) continue; const executableSegment = stripShellCommandGrouping(segment); @@ -4936,13 +5061,78 @@ function shellRunExecutesArtifactPath( .map((source) => ( resolveStaticEnvironmentReferences(source, localEnvironmentValues) ?? source )); - if (executionSources.some((source) => artifactSourceMatchesPath( - source, - artifactPath, + if (executionSources.some((source) => knownArtifactPaths.some((artifactPath) => ( + artifactSourceMatchesPath(source, artifactPath, effectiveWorkingDirectory) + )))) return { derivedArtifactPaths, executes: true }; + const extraction = shellTarExtraction(executableSegment); + if (!extraction || !knownArtifactPaths.some((artifactPath) => ( + artifactSourceMatchesPath( + extraction.archive, + artifactPath, + effectiveWorkingDirectory, + ) + ))) continue; + const destination = normalizedArtifactPath( + extraction.destination, effectiveWorkingDirectory, - ))) return true; + localEnvironmentValues, + ); + if (!knownArtifactPaths.includes(destination)) knownArtifactPaths.push(destination); + if (!derivedArtifactPaths.includes(destination)) { + derivedArtifactPaths.push(destination); + } } - return false; + return { derivedArtifactPaths, executes: false }; +} + +function shellTarExtraction(segment) { + const words = shellCommandWords(stripShellCommandGrouping(segment)); + let cursor = shellCommandWrapperCursor(words, { allowSudo: true }); + if (!new Set(["bsdtar", "gtar", "tar"]).has( + path.posix.basename(words[cursor] ?? "").toLowerCase(), + )) return undefined; + cursor += 1; + let archive; + let destination = "."; + let extracts = false; + while (cursor < words.length) { + const argument = words[cursor]; + cursor += 1; + const option = argument.toLowerCase(); + if (["--extract", "--get"].includes(option)) { + extracts = true; + continue; + } + if (argument === "-C" || option === "--directory") { + destination = words[cursor] ?? "${dynamic-tar-directory}"; + cursor += 1; + continue; + } + const inlineDirectory = /^--directory=(.*)$/iu.exec(argument)?.[1]; + if (inlineDirectory !== undefined) { + destination = inlineDirectory || "${dynamic-tar-directory}"; + continue; + } + if (["-f", "--file"].includes(option)) { + archive = words[cursor]; + cursor += 1; + continue; + } + const inlineArchive = /^--file=(.*)$/iu.exec(argument)?.[1] + ?? /^-f(.+)$/u.exec(argument)?.[1]; + if (inlineArchive !== undefined) { + archive = inlineArchive || "${dynamic-tar-archive}"; + continue; + } + const shortOptions = /^-?([A-Za-z]+)$/u.exec(argument)?.[1]; + if (!shortOptions) continue; + if (shortOptions.includes("x")) extracts = true; + if (shortOptions.includes("f")) { + archive = words[cursor]; + cursor += 1; + } + } + return extracts && archive ? { archive, destination } : undefined; } function shellArtifactExecutionSources( @@ -4978,15 +5168,31 @@ function shellArtifactExecutionSources( if (commandName === "npm") { return npmPackageExecutionSources(words.slice(cursor)); } + if (/^pip\d*(?:\.\d+)*$/u.test(commandName)) { + return pipLocalProjectExecutionSources(words.slice(cursor)); + } const interpreters = new Set([ "bash", "dash", "deno", "fish", "ksh", "node", "perl", "php", "powershell", "powershell.exe", "pwsh", "pwsh.exe", "python", "python2", "python3", "ruby", "sh", "zsh", ]); if (interpreters.has(commandName) || /^python\d+(?:\.\d+)*$/u.test(commandName)) { - const executionSources = commandName === "node" - ? nodeOptionsLoadedSources(commandEnvironmentValues.get("node_options")) - : []; + const executionSources = []; + if (commandName === "node") { + executionSources.push(...nodeOptionsLoadedSources( + commandEnvironmentValues.get("node_options"), + )); + } + if (commandName === "bash" && commandEnvironmentValues.get("bash_env")) { + executionSources.push(commandEnvironmentValues.get("bash_env")); + } + const pipArguments = /^python/u.test(commandName) + ? pythonPipArguments(words, cursor) + : undefined; + if (pipArguments) { + executionSources.push(...pipLocalProjectExecutionSources(pipArguments)); + return executionSources; + } if (commandName === "deno" && words[cursor]?.toLowerCase() === "run") cursor += 1; while (cursor < words.length) { const argument = words[cursor]; @@ -5136,6 +5342,79 @@ function npmPackageExecutionSources(arguments_) { return [prefix === "." ? "package.json" : path.posix.join(prefix, "package.json")]; } +function pythonPipArguments(words, initialCursor) { + let cursor = initialCursor; + while (cursor < words.length) { + const argument = words[cursor]; + if (argument === "-m") { + return /^pip\d*(?:\.\d+)*$/u.test( + path.posix.basename(words[cursor + 1] ?? "").toLowerCase(), + ) ? words.slice(cursor + 2) : undefined; + } + if (interpreterOptionConsumesValue("python", argument)) { + cursor += 2; + continue; + } + if (argument.startsWith("-")) { + cursor += 1; + continue; + } + return undefined; + } + return undefined; +} + +function pipLocalProjectExecutionSources(arguments_) { + const lifecycleCommands = new Set(["build", "install", "wheel"]); + const commandIndex = arguments_.findIndex((argument) => ( + lifecycleCommands.has(argument.toLowerCase()) + )); + if (commandIndex < 0) return []; + const sources = []; + const sourceFileOptions = new Set(["-c", "--constraint", "-e", "--editable", "-r", "--requirement"]); + const optionsWithValues = new Set([ + "--abi", "--cache-dir", "--cert", "--client-cert", "--config-settings", + "--extra-index-url", "--find-links", "--implementation", "--index-url", + "--keyring-provider", "--log", "--platform", "--prefix", "--proxy", + "--python", "--python-version", "--report", "--root", "--src", + "--target", "--timeout", "--trusted-host", "--upgrade-strategy", + ]); + for (let cursor = commandIndex + 1; cursor < arguments_.length;) { + const argument = arguments_[cursor]; + cursor += 1; + if (sourceFileOptions.has(argument.toLowerCase())) { + const source = arguments_[cursor]; + cursor += 1; + if (source) sources.push(source); + continue; + } + const inlineSource = /^(?:--(?:constraint|editable|requirement))=(.*)$/iu.exec( + argument, + )?.[1]; + if (inlineSource !== undefined) { + if (inlineSource) sources.push(inlineSource); + continue; + } + if (optionsWithValues.has(argument.toLowerCase())) { + cursor += 1; + continue; + } + if (argument.startsWith("-")) continue; + if (isLocalPipProjectReference(argument)) sources.push(argument); + } + return sources; +} + +function isLocalPipProjectReference(reference) { + const normalized = reference.replace(/^(["'])(.*)\1$/u, "$2"); + return normalized === "." + || normalized === ".." + || normalized.startsWith("./") + || normalized.startsWith("../") + || normalized.startsWith("/") + || /^file:/iu.test(normalized); +} + function programFileExecutionSources(arguments_, options) { const sources = []; for (let cursor = 0; cursor < arguments_.length;) { diff --git a/public-source-release-audit/tests/public-source-release-audit.test.mjs b/public-source-release-audit/tests/public-source-release-audit.test.mjs index f477a3b..1a61879 100644 --- a/public-source-release-audit/tests/public-source-release-audit.test.mjs +++ b/public-source-release-audit/tests/public-source-release-audit.test.mjs @@ -1643,6 +1643,21 @@ test("GITHUB_ENV taint does not flow backward to earlier steps", () => { " ref: ${{ env.SAFE_REF }}", "", ].join("\n")); + write(repoRoot, ".github/workflows/github-env-tainted-overwrite.yml", [ + "name: GITHUB_ENV tainted overwrite", + "on: issue_comment", + "permissions: read-all", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - env:", + " CODE: " + ["$", "{{ github.event.comment.body }}"].join(""), + " run: echo \"PAYLOAD=$CODE\" >> \"$GITHUB_ENV\"", + " - run: echo \"PAYLOAD=echo fixed\" >> \"$GITHUB_ENV\"", + " - run: eval \"$PAYLOAD\"", + "", + ].join("\n")); commitAll(repoRoot, "add ordered environment workflow"); const audit = runAudit(repoRoot); @@ -2481,6 +2496,8 @@ test("artifact execution recognizes common command wrappers and paths", () => { ["timeout", "timeout 10 bash payload/run.sh"], ["shell-command-string", "bash -c 'source payload/run.sh'"], ["npm-prefix", "npm --prefix payload test"], + ["pip-local-project", "pip install ./payload"], + ["python-pip-local-project", "python -m pip install ./payload"], ]) { write(repoRoot, `.github/workflows/workflow-run-artifact-${name}.yml`, [ `name: workflow run artifact ${name}`, @@ -2539,6 +2556,26 @@ test("artifact execution recognizes common command wrappers and paths", () => { " - run: env NODE_OPTIONS='--require payload/hook.js' node trusted.js", "", ].join("\n")); + write(repoRoot, ".github/workflows/workflow-run-artifact-bash-env.yml", [ + "name: workflow run artifact Bash environment", + "on:", + " workflow_run:", + " workflows: [verify]", + " types: [completed]", + "permissions: read-all", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - uses: actions/download-artifact@" + "a".repeat(40), + " with:", + " run-id: " + runIdExpression, + " path: payload", + " - env:", + " BASH_ENV: payload/hook.sh", + " run: echo fixed", + "", + ].join("\n")); for (const scope of ["workflow", "job", "step", "shell"]) { write(repoRoot, `.github/workflows/workflow-run-artifact-${scope}-alias.yml`, [ `name: workflow run artifact ${scope} alias`, @@ -2590,8 +2627,11 @@ test("artifact execution recognizes common command wrappers and paths", () => { "timeout", "shell-command-string", "npm-prefix", + "pip-local-project", + "python-pip-local-project", "node-options", "env-node-options", + "bash-env", "workflow-alias", "job-alias", "step-alias", @@ -2638,6 +2678,62 @@ test("artifact execution recognizes interpreter input redirection", () => { ); }); +test("artifact execution follows archive extraction destinations", () => { + const repoRoot = makeRepository(); + const runIdExpression = ["$", "{{ github.event.workflow_run.id }}"].join(""); + write(repoRoot, ".github/workflows/workflow-run-artifact-tar-same-step.yml", [ + "name: workflow run artifact tar same step", + "on:", + " workflow_run:", + " workflows: [verify]", + " types: [completed]", + "permissions: read-all", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - uses: actions/download-artifact@" + "a".repeat(40), + " with:", + " run-id: " + runIdExpression, + " path: payload", + " - run: |", + " tar -xf payload/code.tar -C extracted", + " bash extracted/run.sh", + "", + ].join("\n")); + write(repoRoot, ".github/workflows/workflow-run-artifact-tar-cross-step.yml", [ + "name: workflow run artifact tar cross step", + "on:", + " workflow_run:", + " workflows: [verify]", + " types: [completed]", + "permissions: read-all", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - uses: actions/download-artifact@" + "a".repeat(40), + " with:", + " run-id: " + runIdExpression, + " path: payload", + " - run: tar --extract --file=payload/code.tar --directory=extracted", + " - run: bash extracted/run.sh", + "", + ].join("\n")); + commitAll(repoRoot, "add extracted artifact execution"); + + const audit = runAudit(repoRoot); + + assert.equal(audit.status, 1); + for (const name of ["same-step", "cross-step"]) { + assertFinding( + audit.result, + "workflow-privileged-untrusted-artifact-execution", + `.github/workflows/workflow-run-artifact-tar-${name}.yml`, + ); + } +}); + test("issue_comment pull-request checkouts are privileged and untrusted", () => { const repoRoot = makeRepository(); const issueExpression = ["$", "{{ github.event.issue.number }}"].join(""); @@ -3442,6 +3538,19 @@ test("tainted here-strings cannot feed shell interpreters", () => { " EOF", "", ].join("\n")); + write(repoRoot, ".github/workflows/comment-here-source.yml", [ + "name: comment here source", + "on: issue_comment", + "permissions: read-all", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - env:", + " COMMAND: " + bodyExpression, + " run: source /dev/stdin <<< \"$COMMAND\"", + "", + ].join("\n")); commitAll(repoRoot, "add tainted shell here-string"); const audit = runAudit(repoRoot); @@ -3457,6 +3566,11 @@ test("tainted here-strings cannot feed shell interpreters", () => { "workflow-privileged-untrusted-script-interpolation", ".github/workflows/comment-heredoc-shell.yml", ); + assertFinding( + audit.result, + "workflow-privileged-untrusted-script-interpolation", + ".github/workflows/comment-here-source.yml", + ); }); test("privileged workflows reject attacker-selected shell templates", () => { @@ -5177,6 +5291,39 @@ test("composite outputs only inherit taint from their actual output writes", () )), false); }); +test("step outputs only inherit taint from their named output writes", () => { + const repoRoot = makeRepository(); + const bodyExpression = ["$", "{{ github.event.comment.body }}"].join(""); + const safeOutputExpression = ["$", "{{ steps.source.outputs.ref }}"].join(""); + write(repoRoot, ".github/workflows/named-step-output.yml", [ + "name: named step output", + "on: issue_comment", + "permissions: read-all", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - id: source", + " env:", + " CODE: " + bodyExpression, + " run: |", + " echo \"unsafe=$CODE\" >> \"$GITHUB_OUTPUT\"", + " echo \"ref=main\" >> \"$GITHUB_OUTPUT\"", + " - uses: actions/checkout@" + "a".repeat(40), + " with:", + " ref: " + safeOutputExpression, + "", + ].join("\n")); + commitAll(repoRoot, "add named step output checkout"); + + const audit = runAudit(repoRoot); + + assert.equal(audit.status, 0); + assert.equal(audit.result.findings.some((finding) => ( + finding.ruleId === "workflow-privileged-untrusted-checkout" + )), false); +}); + test("tainted text cannot reach language runtime evaluators", () => { const repoRoot = makeRepository(); const bodyExpression = ["$", "{{ github.event.comment.body }}"].join(""); @@ -5195,6 +5342,7 @@ test("tainted text cannot reach language runtime evaluators", () => { ["awk-e", "awk -e \"$COMMAND\" /dev/null"], ["sed", "printf x | sed \"$COMMAND\""], ["sed-e", "printf x | sed -e \"$COMMAND\""], + ["find-exec-shell", "find . -maxdepth 0 -exec bash -c \"$COMMAND\" \\;"], ]) { write(repoRoot, `.github/workflows/comment-${runtime}-eval.yml`, [ `name: comment ${runtime} eval`, @@ -5210,6 +5358,21 @@ test("tainted text cannot reach language runtime evaluators", () => { "", ].join("\n")); } + write(repoRoot, ".github/workflows/comment-printf-assignment-eval.yml", [ + "name: comment printf assignment eval", + "on: issue_comment", + "permissions: read-all", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - env:", + " COMMAND: " + bodyExpression, + " run: |", + " printf -v VALUE '%s' \"$COMMAND\"", + " eval \"$VALUE\"", + "", + ].join("\n")); commitAll(repoRoot, "add tainted language runtime evaluators"); const audit = runAudit(repoRoot); @@ -5218,7 +5381,7 @@ test("tainted text cannot reach language runtime evaluators", () => { for (const runtime of [ "python", "python-attached", "node", "node-long-eval", "node-print", "node-long-print", "perl", "ruby", "nohup-shell", - "timeout-shell", "awk", "awk-e", "sed", "sed-e", + "timeout-shell", "awk", "awk-e", "sed", "sed-e", "find-exec-shell", ]) { assertFinding( audit.result, @@ -5226,6 +5389,11 @@ test("tainted text cannot reach language runtime evaluators", () => { `.github/workflows/comment-${runtime}-eval.yml`, ); } + assertFinding( + audit.result, + "workflow-privileged-untrusted-script-interpolation", + ".github/workflows/comment-printf-assignment-eval.yml", + ); }); test("workflow_run head metadata is untrusted script data", () => { From 015ac945abb951f95796f6d3372ec72fd8382122 Mon Sep 17 00:00:00 2001 From: Vladimir Date: Sat, 22 Aug 2026 11:55:43 +0800 Subject: [PATCH 35/37] Close latest workflow execution review gaps --- .../scripts/public-source-release-audit.mjs | 254 +++++++++++++++++- .../public-source-release-audit.test.mjs | 115 +++++++- 2 files changed, 355 insertions(+), 14 deletions(-) diff --git a/public-source-release-audit/scripts/public-source-release-audit.mjs b/public-source-release-audit/scripts/public-source-release-audit.mjs index d9b4035..b3393de 100644 --- a/public-source-release-audit/scripts/public-source-release-audit.mjs +++ b/public-source-release-audit/scripts/public-source-release-audit.mjs @@ -2827,6 +2827,11 @@ function workflowRunnerLabels(value, scalarAnchors) { const resolved = resolveYamlScalarValue(value, scalarAnchors); const constantExpression = githubConstantExpressionValue(resolved); if (typeof constantExpression === "string") return [constantExpression]; + if (Array.isArray(constantExpression)) { + const labels = constantExpression.flat(Infinity) + .filter((label) => typeof label === "string"); + return labels.length > 0 ? labels : [resolved]; + } const sequenceValues = yamlFlowSequenceValues(resolved); return sequenceValues ? sequenceValues.flatMap((item) => workflowRunnerLabels(item, scalarAnchors)) @@ -3708,10 +3713,17 @@ function shellRunEvaluatesTaintedVariable(runSource, taintedBindings) { inheritedTaintedVariables, inheritedStaticValues, depth, + inheritedFunctions, ) => { const localTaintedVariables = new Set(inheritedTaintedVariables); const localStaticValues = new Map(inheritedStaticValues); - for (const segment of shellCommandSegments(source)) { + const localFunctions = new Map(inheritedFunctions); + for (const unit of shellEvaluationUnits(source)) { + if (unit.definition) { + localFunctions.set(unit.definition.name, unit.definition.body); + continue; + } + const segment = unit.source; if (/^\s*#/u.test(segment)) continue; const executableSegment = stripShellCommandGrouping(segment); updateShellVariableTaint( @@ -3743,6 +3755,7 @@ function shellRunEvaluatesTaintedVariable(runSource, taintedBindings) { localTaintedVariables, localStaticValues, depth + 1, + localFunctions, )) return true; } let pipelineInputTainted = false; @@ -3779,21 +3792,52 @@ function shellRunEvaluatesTaintedVariable(runSource, taintedBindings) { localTaintedVariables, anyEnvironmentVariableTainted, ); - for (const childCommand of shellFindExecutedCommands(stage)) { + for (const childCommand of [ + ...shellFindExecutedCommands(stage), + ...shellEnvSplitCommands(stage), + ]) { + const resolvedChildCommand = resolveStaticEnvironmentReferences( + childCommand, + localStaticValues, + ) ?? childCommand; if (depth >= 32) { - if (isUntrustedReusableValue(childCommand, taintedBindings) + if (isUntrustedReusableValue(resolvedChildCommand, taintedBindings) || shellSourceReferencesTaintedVariable( - childCommand, + resolvedChildCommand, localTaintedVariables, anyEnvironmentVariableTainted, )) return true; continue; } if (sourceEvaluatesTaint( - childCommand, + resolvedChildCommand, localTaintedVariables, localStaticValues, depth + 1, + localFunctions, + )) return true; + } + const functionInvocation = shellFunctionInvocation(stage, localFunctions); + if (functionInvocation) { + const invokedTaintedVariables = shellFunctionInvocationTaintedVariables( + functionInvocation.arguments, + taintedBindings, + localTaintedVariables, + anyEnvironmentVariableTainted, + ); + if (depth >= 32) { + if (isUntrustedReusableValue(functionInvocation.body, taintedBindings) + || shellSourceReferencesTaintedVariable( + functionInvocation.body, + invokedTaintedVariables, + anyEnvironmentVariableTainted, + )) return true; + } else if (sourceEvaluatesTaint( + functionInvocation.body, + invokedTaintedVariables, + localStaticValues, + depth + 1, + localFunctions, )) return true; } if (evaluatorCommand.test(sinkStage) @@ -3843,7 +3887,106 @@ function shellRunEvaluatesTaintedVariable(runSource, taintedBindings) { } return false; }; - return sourceEvaluatesTaint(runSource, taintedVariables, new Map(), 0); + return sourceEvaluatesTaint(runSource, taintedVariables, new Map(), 0, new Map()); +} + +function shellEvaluationUnits(source) { + const definitions = shellFunctionDefinitions(source); + const units = []; + let cursor = 0; + for (const definition of definitions) { + for (const segment of shellCommandSegments(source.slice(cursor, definition.start))) { + units.push({ source: segment }); + } + units.push({ definition }); + cursor = definition.end; + } + for (const segment of shellCommandSegments(source.slice(cursor))) { + units.push({ source: segment }); + } + return units; +} + +function shellFunctionDefinitions(source) { + const definitions = []; + const pattern = /(^|[;\n])\s*(?:function\s+([A-Za-z_][A-Za-z0-9_]*)(?:\s*\(\s*\))?|([A-Za-z_][A-Za-z0-9_]*)\s*\(\s*\))\s*\{/gu; + let match; + while ((match = pattern.exec(source)) !== null) { + const openingBrace = match.index + match[0].lastIndexOf("{"); + const closingBrace = shellFunctionBodyEnd(source, openingBrace); + if (closingBrace === undefined) continue; + const start = match.index + match[1].length; + definitions.push({ + body: source.slice(openingBrace + 1, closingBrace), + end: closingBrace + 1, + name: (match[2] ?? match[3]).toLowerCase(), + start, + }); + pattern.lastIndex = closingBrace + 1; + } + return definitions; +} + +function shellFunctionBodyEnd(source, openingBrace) { + let depth = 1; + let quote; + for (let index = openingBrace + 1; index < source.length; index += 1) { + const character = source[index]; + if (character === "\\" && quote !== "'") { + index += 1; + continue; + } + if (["'", '"'].includes(character)) { + if (quote === character) quote = undefined; + else if (!quote) quote = character; + continue; + } + if (quote) continue; + if (character === "{") depth += 1; + if (character !== "}") continue; + depth -= 1; + if (depth === 0) return index; + } + return undefined; +} + +function shellFunctionInvocation(stage, functions) { + const words = shellCommandWords(stripShellCommandGrouping(stage)); + let cursor = 0; + while (/^[A-Za-z_][A-Za-z0-9_]*=/u.test(words[cursor] ?? "")) cursor += 1; + const name = words[cursor]?.toLowerCase(); + const body = name ? functions.get(name) : undefined; + return body === undefined ? undefined : { + arguments: words.slice(cursor + 1), + body, + }; +} + +function shellFunctionInvocationTaintedVariables( + arguments_, + taintedBindings, + inheritedTaintedVariables, + anyEnvironmentVariableTainted, +) { + const taintedVariables = new Set([...inheritedTaintedVariables].filter((name) => ( + !/^\d+$/u.test(name) && !["*", "@"].includes(name) + ))); + const argumentTaint = arguments_.map((argument) => ( + isUntrustedReusableValue(argument, taintedBindings) + || shellSourceReferencesTaintedVariable( + argument, + inheritedTaintedVariables, + anyEnvironmentVariableTainted, + ) + )); + argumentTaint.forEach((isTainted, index) => { + if (isTainted) taintedVariables.add(String(index + 1)); + }); + if (argumentTaint.some(Boolean)) { + taintedVariables.add("*"); + taintedVariables.add("@"); + } + return taintedVariables; } function shellStageHasTaintedCommandPosition( @@ -3976,6 +4119,40 @@ function shellFindExecutedCommands(stage) { return commands; } +function shellEnvSplitCommands(stage) { + const words = shellCommandWords(stripShellCommandGrouping(stage)); + let cursor = shellCommandWrapperCursor(words, { + allowSudo: true, + stopAtEnv: true, + }); + if (path.posix.basename(words[cursor] ?? "").toLowerCase() !== "env") return []; + cursor += 1; + const commands = []; + while (cursor < words.length) { + const argument = words[cursor]; + cursor += 1; + if (/^[A-Za-z_][A-Za-z0-9_]*=/u.test(argument)) continue; + if (["-S", "--split-string"].includes(argument)) { + if (words[cursor]) commands.push(words[cursor]); + cursor += 1; + continue; + } + const inlineSplit = /^(?:-S|--split-string=)(.+)$/u.exec(argument)?.[1]; + if (inlineSplit) { + commands.push(inlineSplit); + continue; + } + if (argument === "--") break; + if (["-C", "-u", "--chdir", "--unset"].includes(argument)) { + cursor += 1; + continue; + } + if (argument.startsWith("-")) continue; + break; + } + return commands; +} + function shellStageExecutesTaintedProcessSubstitution( stage, taintedBindings, @@ -4298,7 +4475,10 @@ function shellStageWithoutCommandWrappers(stage) { return words.slice(shellCommandWrapperCursor(words)).join(" "); } -function shellCommandWrapperCursor(words, { allowSudo = false } = {}) { +function shellCommandWrapperCursor( + words, + { allowSudo = false, stopAtEnv = false } = {}, +) { let cursor = 0; while (cursor < words.length) { while (/^[A-Za-z_][A-Za-z0-9_]*=/u.test(words[cursor] ?? "")) cursor += 1; @@ -4319,6 +4499,7 @@ function shellCommandWrapperCursor(words, { allowSudo = false } = {}) { continue; } if (command === "env") { + if (stopAtEnv) break; cursor += 1; while (cursor < words.length) { const argument = words[cursor] ?? ""; @@ -4998,6 +5179,16 @@ function stepArtifactExecutionAnalysis( ))) { return { derivedArtifactPaths: [], executes: true }; } + const shellExecutionSources = stepGroup.properties + .filter(({ entry }) => entry.key.toLowerCase() === "shell") + .map(({ entry }) => resolveYamlScalarValue(entry.value, scalarAnchors)) + .flatMap((shellTemplate) => shellArtifactExecutionSources( + shellTemplate, + environmentValues, + )); + if (shellExecutionSources.some((source) => artifactPaths.some((artifactPath) => ( + artifactSourceMatchesPath(source, artifactPath, workingDirectory) + )))) return { derivedArtifactPaths: [], executes: true }; const bashEnvironmentSource = environmentValues.get("bash_env"); if (bashEnvironmentSource && artifactPaths.some((artifactPath) => ( artifactSourceMatchesPath( @@ -5064,7 +5255,7 @@ function shellRunArtifactExecutionAnalysis( if (executionSources.some((source) => knownArtifactPaths.some((artifactPath) => ( artifactSourceMatchesPath(source, artifactPath, effectiveWorkingDirectory) )))) return { derivedArtifactPaths, executes: true }; - const extraction = shellTarExtraction(executableSegment); + const extraction = shellArchiveExtraction(executableSegment); if (!extraction || !knownArtifactPaths.some((artifactPath) => ( artifactSourceMatchesPath( extraction.archive, @@ -5085,6 +5276,10 @@ function shellRunArtifactExecutionAnalysis( return { derivedArtifactPaths, executes: false }; } +function shellArchiveExtraction(segment) { + return shellTarExtraction(segment) ?? shellZipExtraction(segment); +} + function shellTarExtraction(segment) { const words = shellCommandWords(stripShellCommandGrouping(segment)); let cursor = shellCommandWrapperCursor(words, { allowSudo: true }); @@ -5135,6 +5330,49 @@ function shellTarExtraction(segment) { return extracts && archive ? { archive, destination } : undefined; } +function shellZipExtraction(segment) { + const words = shellCommandWords(stripShellCommandGrouping(segment)); + let cursor = shellCommandWrapperCursor(words, { allowSudo: true }); + if (path.posix.basename(words[cursor] ?? "").toLowerCase() !== "unzip") { + return undefined; + } + cursor += 1; + let archive; + let destination = "."; + let extracts = true; + let optionsEnded = false; + while (cursor < words.length) { + const argument = words[cursor]; + cursor += 1; + if (!optionsEnded && argument === "--") { + optionsEnded = true; + continue; + } + if (!optionsEnded && argument === "-d") { + destination = words[cursor] ?? "${dynamic-zip-directory}"; + cursor += 1; + continue; + } + const inlineDirectory = !optionsEnded + ? /^-d(.+)$/u.exec(argument)?.[1] + : undefined; + if (inlineDirectory) { + destination = inlineDirectory; + continue; + } + if (!optionsEnded && argument === "-P") { + cursor += 1; + continue; + } + if (!optionsEnded && /^-[^-]/u.test(argument)) { + if (/[clptvZz]/u.test(argument.slice(1))) extracts = false; + continue; + } + archive ??= argument; + } + return extracts && archive ? { archive, destination } : undefined; +} + function shellArtifactExecutionSources( segment, environmentValues = new Map(), diff --git a/public-source-release-audit/tests/public-source-release-audit.test.mjs b/public-source-release-audit/tests/public-source-release-audit.test.mjs index 1a61879..39854ae 100644 --- a/public-source-release-audit/tests/public-source-release-audit.test.mjs +++ b/public-source-release-audit/tests/public-source-release-audit.test.mjs @@ -1883,6 +1883,21 @@ test("constructed constant runner expressions preserve blocking labels", () => { " - run: echo inspect", "", ].join("\n")); + const arrayExpression = [ + "$", + "{{ fromJSON('[\"self-hosted\", \"linux\"]') }}", + ].join(""); + write(repoRoot, ".github/workflows/array-runner-expression.yml", [ + "name: array runner expression", + "on: pull_request", + "permissions: read-all", + "jobs:", + " inspect:", + " runs-on: " + arrayExpression, + " steps:", + " - run: echo inspect", + "", + ].join("\n")); commitAll(repoRoot, "add formatted runner expression"); const audit = runAudit(repoRoot); @@ -1893,6 +1908,11 @@ test("constructed constant runner expressions preserve blocking labels", () => { "workflow-self-hosted-runner", ".github/workflows/formatted-runner-expression.yml", ); + assertFinding( + audit.result, + "workflow-self-hosted-runner", + ".github/workflows/array-runner-expression.yml", + ); }); test("aliased self-hosted runner labels remain release-blocking", () => { @@ -2576,6 +2596,25 @@ test("artifact execution recognizes common command wrappers and paths", () => { " run: echo fixed", "", ].join("\n")); + write(repoRoot, ".github/workflows/workflow-run-artifact-custom-shell-template.yml", [ + "name: workflow run artifact custom shell template", + "on:", + " workflow_run:", + " workflows: [verify]", + " types: [completed]", + "permissions: read-all", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - uses: actions/download-artifact@" + "a".repeat(40), + " with:", + " run-id: " + runIdExpression, + " path: payload", + " - shell: bash payload/wrapper.sh {0}", + " run: echo fixed", + "", + ].join("\n")); for (const scope of ["workflow", "job", "step", "shell"]) { write(repoRoot, `.github/workflows/workflow-run-artifact-${scope}-alias.yml`, [ `name: workflow run artifact ${scope} alias`, @@ -2632,6 +2671,7 @@ test("artifact execution recognizes common command wrappers and paths", () => { "node-options", "env-node-options", "bash-env", + "custom-shell-template", "workflow-alias", "job-alias", "step-alias", @@ -2720,17 +2760,58 @@ test("artifact execution follows archive extraction destinations", () => { " - run: bash extracted/run.sh", "", ].join("\n")); + write(repoRoot, ".github/workflows/workflow-run-artifact-zip-same-step.yml", [ + "name: workflow run artifact zip same step", + "on:", + " workflow_run:", + " workflows: [verify]", + " types: [completed]", + "permissions: read-all", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - uses: actions/download-artifact@" + "a".repeat(40), + " with:", + " run-id: " + runIdExpression, + " path: payload", + " - run: |", + " unzip payload/code.zip -d extracted", + " bash extracted/run.sh", + "", + ].join("\n")); + write(repoRoot, ".github/workflows/workflow-run-artifact-zip-cross-step.yml", [ + "name: workflow run artifact zip cross step", + "on:", + " workflow_run:", + " workflows: [verify]", + " types: [completed]", + "permissions: read-all", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - uses: actions/download-artifact@" + "a".repeat(40), + " with:", + " run-id: " + runIdExpression, + " path: payload", + " - run: unzip -q payload/code.zip -d extracted", + " - run: bash extracted/run.sh", + "", + ].join("\n")); commitAll(repoRoot, "add extracted artifact execution"); const audit = runAudit(repoRoot); assert.equal(audit.status, 1); - for (const name of ["same-step", "cross-step"]) { - assertFinding( - audit.result, - "workflow-privileged-untrusted-artifact-execution", - `.github/workflows/workflow-run-artifact-tar-${name}.yml`, - ); + for (const archive of ["tar", "zip"]) { + for (const scope of ["same-step", "cross-step"]) { + assertFinding( + audit.result, + "workflow-privileged-untrusted-artifact-execution", + `.github/workflows/workflow-run-artifact-${archive}-${scope}.yml`, + ); + } } }); @@ -5343,6 +5424,7 @@ test("tainted text cannot reach language runtime evaluators", () => { ["sed", "printf x | sed \"$COMMAND\""], ["sed-e", "printf x | sed -e \"$COMMAND\""], ["find-exec-shell", "find . -maxdepth 0 -exec bash -c \"$COMMAND\" \\;"], + ["env-split-shell", "env -S 'bash -c \"$COMMAND\"'"], ]) { write(repoRoot, `.github/workflows/comment-${runtime}-eval.yml`, [ `name: comment ${runtime} eval`, @@ -5373,6 +5455,21 @@ test("tainted text cannot reach language runtime evaluators", () => { " eval \"$VALUE\"", "", ].join("\n")); + write(repoRoot, ".github/workflows/comment-function-eval.yml", [ + "name: comment function eval", + "on: issue_comment", + "permissions: read-all", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - env:", + " COMMAND: " + bodyExpression, + " run: |", + " function execute { eval \"$COMMAND\"; }", + " execute", + "", + ].join("\n")); commitAll(repoRoot, "add tainted language runtime evaluators"); const audit = runAudit(repoRoot); @@ -5382,6 +5479,7 @@ test("tainted text cannot reach language runtime evaluators", () => { "python", "python-attached", "node", "node-long-eval", "node-print", "node-long-print", "perl", "ruby", "nohup-shell", "timeout-shell", "awk", "awk-e", "sed", "sed-e", "find-exec-shell", + "env-split-shell", ]) { assertFinding( audit.result, @@ -5394,6 +5492,11 @@ test("tainted text cannot reach language runtime evaluators", () => { "workflow-privileged-untrusted-script-interpolation", ".github/workflows/comment-printf-assignment-eval.yml", ); + assertFinding( + audit.result, + "workflow-privileged-untrusted-script-interpolation", + ".github/workflows/comment-function-eval.yml", + ); }); test("workflow_run head metadata is untrusted script data", () => { From bfff6cd53c0cad645332db65fbaa82da0990d47b Mon Sep 17 00:00:00 2001 From: Vladimir Date: Sat, 22 Aug 2026 12:15:22 +0800 Subject: [PATCH 36/37] Unify shell function execution analysis --- .../scripts/public-source-release-audit.mjs | 135 ++++++++++++++++-- .../public-source-release-audit.test.mjs | 76 ++++++++++ 2 files changed, 203 insertions(+), 8 deletions(-) diff --git a/public-source-release-audit/scripts/public-source-release-audit.mjs b/public-source-release-audit/scripts/public-source-release-audit.mjs index b3393de..8b9c3c4 100644 --- a/public-source-release-audit/scripts/public-source-release-audit.mjs +++ b/public-source-release-audit/scripts/public-source-release-audit.mjs @@ -3839,6 +3839,8 @@ function shellRunEvaluatesTaintedVariable(runSource, taintedBindings) { depth + 1, localFunctions, )) return true; + pipelineInputTainted ||= stageReferencesTaint; + continue; } if (evaluatorCommand.test(sinkStage) && (stageReferencesTaint || pipelineInputTainted)) { @@ -3919,7 +3921,7 @@ function shellFunctionDefinitions(source) { definitions.push({ body: source.slice(openingBrace + 1, closingBrace), end: closingBrace + 1, - name: (match[2] ?? match[3]).toLowerCase(), + name: match[2] ?? match[3], start, }); pattern.lastIndex = closingBrace + 1; @@ -3954,7 +3956,18 @@ function shellFunctionInvocation(stage, functions) { const words = shellCommandWords(stripShellCommandGrouping(stage)); let cursor = 0; while (/^[A-Za-z_][A-Za-z0-9_]*=/u.test(words[cursor] ?? "")) cursor += 1; - const name = words[cursor]?.toLowerCase(); + const controlWords = new Set([ + "!", "coproc", "do", "elif", "else", "if", "then", "time", "until", + "while", + ]); + while (controlWords.has(words[cursor] ?? "")) { + cursor += 1; + while (words[cursor]?.startsWith("-") && words[cursor - 1] === "time") { + cursor += 1; + } + while (/^[A-Za-z_][A-Za-z0-9_]*=/u.test(words[cursor] ?? "")) cursor += 1; + } + const name = words[cursor]; const body = name ? functions.get(name) : undefined; return body === undefined ? undefined : { arguments: words.slice(cursor + 1), @@ -5224,15 +5237,68 @@ function shellRunArtifactExecutionAnalysis( artifactPaths, workingDirectory, environmentValues = new Map(), + depth = 0, + inheritedFunctions = new Map(), ) { let effectiveWorkingDirectory = workingDirectory; - const localEnvironmentValues = new Map(environmentValues); + let localEnvironmentValues = new Map(environmentValues); + let localFunctions = new Map(inheritedFunctions); const knownArtifactPaths = [...artifactPaths]; const derivedArtifactPaths = []; - for (const segment of shellCommandSegments(runSource)) { + for (const unit of shellEvaluationUnits(runSource)) { + if (unit.definition) { + localFunctions.set(unit.definition.name, unit.definition.body); + continue; + } + const segment = unit.source; if (/^\s*#/u.test(segment)) continue; const executableSegment = stripShellCommandGrouping(segment); applyStaticShellAssignment(executableSegment, localEnvironmentValues); + const functionInvocation = shellFunctionInvocation( + executableSegment, + localFunctions, + ); + if (functionInvocation) { + if (depth >= 32) { + if (knownArtifactPaths.some((artifactPath) => artifactSourceMatchesPath( + functionInvocation.body, + artifactPath, + effectiveWorkingDirectory, + ))) return { + derivedArtifactPaths, + environmentValues: localEnvironmentValues, + executes: true, + functions: localFunctions, + workingDirectory: effectiveWorkingDirectory, + }; + } else { + const functionAnalysis = shellRunArtifactExecutionAnalysis( + functionInvocation.body, + knownArtifactPaths, + effectiveWorkingDirectory, + localEnvironmentValues, + depth + 1, + localFunctions, + ); + for (const artifactPath of functionAnalysis.derivedArtifactPaths) { + if (!knownArtifactPaths.includes(artifactPath)) { + knownArtifactPaths.push(artifactPath); + } + if (!derivedArtifactPaths.includes(artifactPath)) { + derivedArtifactPaths.push(artifactPath); + } + } + if (functionAnalysis.executes) return { + ...functionAnalysis, + derivedArtifactPaths, + executes: true, + }; + effectiveWorkingDirectory = functionAnalysis.workingDirectory; + localEnvironmentValues = functionAnalysis.environmentValues; + localFunctions = functionAnalysis.functions; + } + continue; + } const directoryChange = /^\s*(?:(?:builtin|command)\s+)?(?:cd|pushd)\s+(?:--\s+)?("[^"]*"|'[^']*'|[^\s;&|]+)/iu.exec(executableSegment); if (directoryChange) { const target = resolveStaticEnvironmentReferences( @@ -5254,7 +5320,13 @@ function shellRunArtifactExecutionAnalysis( )); if (executionSources.some((source) => knownArtifactPaths.some((artifactPath) => ( artifactSourceMatchesPath(source, artifactPath, effectiveWorkingDirectory) - )))) return { derivedArtifactPaths, executes: true }; + )))) return { + derivedArtifactPaths, + environmentValues: localEnvironmentValues, + executes: true, + functions: localFunctions, + workingDirectory: effectiveWorkingDirectory, + }; const extraction = shellArchiveExtraction(executableSegment); if (!extraction || !knownArtifactPaths.some((artifactPath) => ( artifactSourceMatchesPath( @@ -5273,7 +5345,13 @@ function shellRunArtifactExecutionAnalysis( derivedArtifactPaths.push(destination); } } - return { derivedArtifactPaths, executes: false }; + return { + derivedArtifactPaths, + environmentValues: localEnvironmentValues, + executes: false, + functions: localFunctions, + workingDirectory: effectiveWorkingDirectory, + }; } function shellArchiveExtraction(segment) { @@ -5365,7 +5443,7 @@ function shellZipExtraction(segment) { continue; } if (!optionsEnded && /^-[^-]/u.test(argument)) { - if (/[clptvZz]/u.test(argument.slice(1))) extracts = false; + if (/[clptTvZz]/u.test(argument.slice(1))) extracts = false; continue; } archive ??= argument; @@ -5387,6 +5465,21 @@ function shellArtifactExecutionSources( if (value === undefined) commandEnvironmentValues.delete(binding.name); else commandEnvironmentValues.set(binding.name, value); } + const envSplitCommands = shellEnvSplitCommands(segment); + if (envSplitCommands.length > 0) { + if (depth >= 16) return ["${dynamic-env-split-command}"]; + return envSplitCommands.flatMap((command) => { + const resolvedCommand = resolveStaticEnvironmentReferences( + command, + commandEnvironmentValues, + ) ?? command; + return shellCommandStringArtifactExecutionSources( + resolvedCommand, + commandEnvironmentValues, + depth + 1, + ); + }); + } const words = shellCommandWords(segment); let cursor = shellCommandWrapperCursor(words, { allowSudo: true }); const command = words[cursor]; @@ -5509,12 +5602,19 @@ function shellCommandStringArtifactExecutionSources( commandString, environmentValues, depth, + inheritedFunctions = new Map(), ) { if (/[$%`]/u.test(commandString)) return [commandString]; let workingDirectory = "."; const localEnvironmentValues = new Map(environmentValues); + const localFunctions = new Map(inheritedFunctions); const sources = []; - for (const segment of shellCommandSegments(commandString)) { + for (const unit of shellEvaluationUnits(commandString)) { + if (unit.definition) { + localFunctions.set(unit.definition.name, unit.definition.body); + continue; + } + const segment = unit.source; const executableSegment = stripShellCommandGrouping(segment); applyStaticShellAssignment(executableSegment, localEnvironmentValues); const directoryChange = /^\s*(?:(?:builtin|command)\s+)?(?:cd|pushd)\s+(?:--\s+)?("[^"]*"|'[^']*'|[^\s;&|]+)/iu.exec( @@ -5537,6 +5637,25 @@ function shellCommandStringArtifactExecutionSources( ? source : path.posix.join(workingDirectory, source)); } + const functionInvocation = shellFunctionInvocation( + executableSegment, + localFunctions, + ); + if (!functionInvocation) continue; + if (depth >= 16) { + sources.push("${dynamic-shell-function}"); + continue; + } + for (const source of shellCommandStringArtifactExecutionSources( + functionInvocation.body, + localEnvironmentValues, + depth + 1, + localFunctions, + )) { + sources.push(workingDirectory === "." || path.posix.isAbsolute(source) + ? source + : path.posix.join(workingDirectory, source)); + } } return sources; } diff --git a/public-source-release-audit/tests/public-source-release-audit.test.mjs b/public-source-release-audit/tests/public-source-release-audit.test.mjs index 39854ae..09b7a68 100644 --- a/public-source-release-audit/tests/public-source-release-audit.test.mjs +++ b/public-source-release-audit/tests/public-source-release-audit.test.mjs @@ -2518,6 +2518,9 @@ test("artifact execution recognizes common command wrappers and paths", () => { ["npm-prefix", "npm --prefix payload test"], ["pip-local-project", "pip install ./payload"], ["python-pip-local-project", "python -m pip install ./payload"], + ["env-split", "env -S 'bash payload/run.sh'"], + ["shell-function", "execute_payload() { bash payload/run.sh; }; execute_payload"], + ["command-string-function", "bash -c 'execute_payload() { bash payload/run.sh; }; execute_payload'"], ]) { write(repoRoot, `.github/workflows/workflow-run-artifact-${name}.yml`, [ `name: workflow run artifact ${name}`, @@ -2668,6 +2671,9 @@ test("artifact execution recognizes common command wrappers and paths", () => { "npm-prefix", "pip-local-project", "python-pip-local-project", + "env-split", + "shell-function", + "command-string-function", "node-options", "env-node-options", "bash-env", @@ -2799,6 +2805,25 @@ test("artifact execution follows archive extraction destinations", () => { " - run: bash extracted/run.sh", "", ].join("\n")); + write(repoRoot, ".github/workflows/workflow-run-artifact-zip-timestamp.yml", [ + "name: workflow run artifact zip timestamp", + "on:", + " workflow_run:", + " workflows: [verify]", + " types: [completed]", + "permissions: read-all", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - uses: actions/download-artifact@" + "a".repeat(40), + " with:", + " run-id: " + runIdExpression, + " path: payload", + " - run: unzip -T payload/code.zip", + " - run: bash trusted.sh", + "", + ].join("\n")); commitAll(repoRoot, "add extracted artifact execution"); const audit = runAudit(repoRoot); @@ -2813,6 +2838,10 @@ test("artifact execution follows archive extraction destinations", () => { ); } } + assert.equal(audit.result.findings.some((finding) => ( + finding.ruleId === "workflow-privileged-untrusted-artifact-execution" + && finding.path === ".github/workflows/workflow-run-artifact-zip-timestamp.yml" + )), false); }); test("issue_comment pull-request checkouts are privileged and untrusted", () => { @@ -5407,6 +5436,7 @@ test("step outputs only inherit taint from their named output writes", () => { test("tainted text cannot reach language runtime evaluators", () => { const repoRoot = makeRepository(); + const safeRepo = makeRepository(); const bodyExpression = ["$", "{{ github.event.comment.body }}"].join(""); for (const [runtime, command] of [ ["python", "python -c \"$COMMAND\""], @@ -5470,9 +5500,47 @@ test("tainted text cannot reach language runtime evaluators", () => { " execute", "", ].join("\n")); + for (const [control, invocation] of [ + ["if", "if danger; then :; fi"], + ["negated", "! danger"], + ["while", "while danger; do break; done"], + ]) { + write(repoRoot, `.github/workflows/comment-function-${control}-eval.yml`, [ + `name: comment function ${control} eval`, + "on: issue_comment", + "permissions: read-all", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - env:", + " COMMAND: " + bodyExpression, + " run: |", + " danger() { eval \"$COMMAND\"; }", + " " + invocation, + "", + ].join("\n")); + } + write(safeRepo, ".github/workflows/comment-function-case.yml", [ + "name: comment function case", + "on: issue_comment", + "permissions: read-all", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - env:", + " COMMAND: " + bodyExpression, + " run: |", + " DangerFunction() { eval \"$COMMAND\"; }", + " dangerfunction || true", + "", + ].join("\n")); commitAll(repoRoot, "add tainted language runtime evaluators"); + commitAll(safeRepo, "add case-sensitive shell function"); const audit = runAudit(repoRoot); + const safeAudit = runAudit(safeRepo); assert.equal(audit.status, 1); for (const runtime of [ @@ -5497,6 +5565,14 @@ test("tainted text cannot reach language runtime evaluators", () => { "workflow-privileged-untrusted-script-interpolation", ".github/workflows/comment-function-eval.yml", ); + for (const control of ["if", "negated", "while"]) { + assertFinding( + audit.result, + "workflow-privileged-untrusted-script-interpolation", + `.github/workflows/comment-function-${control}-eval.yml`, + ); + } + assert.equal(safeAudit.status, 0); }); test("workflow_run head metadata is untrusted script data", () => { From a22a84341ea1444d56191e6747c4b66aef7067b7 Mon Sep 17 00:00:00 2001 From: Vladimir Date: Sat, 22 Aug 2026 12:47:49 +0800 Subject: [PATCH 37/37] Harden workflow taint and artifact execution analysis --- .../scripts/public-source-release-audit.mjs | 360 ++++++++++++++++-- .../public-source-release-audit.test.mjs | 136 +++++++ 2 files changed, 461 insertions(+), 35 deletions(-) diff --git a/public-source-release-audit/scripts/public-source-release-audit.mjs b/public-source-release-audit/scripts/public-source-release-audit.mjs index 8b9c3c4..0c9be1c 100644 --- a/public-source-release-audit/scripts/public-source-release-audit.mjs +++ b/public-source-release-audit/scripts/public-source-release-audit.mjs @@ -1368,9 +1368,11 @@ function workflowMappingBindings(group, propertyName, namespace, scalarAnchors) if (property.entry.key.toLowerCase() !== propertyName) continue; const mappingEntries = workflowPropertyMappingEntries(group, property, scalarAnchors); for (const { entry } of mappingEntries) { + const originalName = resolveYamlScalarValue(entry.key, scalarAnchors); bindings.push({ - name: resolveYamlScalarValue(entry.key, scalarAnchors).toLowerCase(), + name: originalName.toLowerCase(), namespace, + originalName, value: resolveYamlScalarValue(entry.value, scalarAnchors), }); } @@ -1669,13 +1671,18 @@ function workflowStepTaintAnalysis( runSource, stepTaintedBindings, )) { - const environmentBinding = `env.${binding}`; + const environmentBinding = `env.${binding.toLowerCase()}`; + const shellEnvironmentBinding = `shell.env.${binding}`; if (isTainted) { derivedTaintedBindings.add(environmentBinding); + derivedTaintedBindings.add(shellEnvironmentBinding); clearedTaintedBindings.delete(environmentBinding); + clearedTaintedBindings.delete(shellEnvironmentBinding); } else { derivedTaintedBindings.delete(environmentBinding); + derivedTaintedBindings.delete(shellEnvironmentBinding); clearedTaintedBindings.add(environmentBinding); + clearedTaintedBindings.add(shellEnvironmentBinding); } } const fetchHeadTaintUpdate = shellRunFetchHeadTaintUpdate( @@ -1780,7 +1787,7 @@ function githubEnvironmentFileWriteUpdates( } const writtenNames = [...segment.matchAll( /(?:^|[\s"'`])([A-Za-z_][A-Za-z0-9_]*)\s*(?:=|<<)/gu, - )].map((match) => match[1].toLowerCase()); + )].map((match) => match[1]); if (writtenNames.length === 0 && segmentReferencesTaint) { updates.set("*", true); } @@ -1805,6 +1812,7 @@ function shellAssignmentBinding(source) { return { name: assignment[1].toLowerCase(), operator: assignment[2], + shellName: assignment[1], value: assignment[3], }; } @@ -1866,7 +1874,9 @@ function localActionCallsFromStepGroup(stepGroup, scalarAnchors, stepTaintedBind `${binding.namespace}.${binding.name}` ))); const taintedBindings = new Set([...stepTaintedBindings].filter((binding) => ( - binding.startsWith("env.") || binding.startsWith("git.") + binding.startsWith("env.") + || binding.startsWith("git.") + || binding.startsWith("shell.env.") ))); for (const binding of inputBindings) { if (isUntrustedReusableValue(binding.value, stepTaintedBindings)) { @@ -3277,6 +3287,7 @@ function hasUntrustedWorkflowArtifactExecution( text, scalarAnchors, ); + const workflowShell = workflowRunDefaultShell(text, scalarAnchors); const jobTaintAnalyses = workflowJobTaintAnalyses( jobGroups, workflowRootMappingBindings(text, "env", "env", scalarAnchors), @@ -3300,6 +3311,7 @@ function hasUntrustedWorkflowArtifactExecution( jobTaintAnalyses.get(jobGroup)?.stepContexts ?? [], scalarAnchors, { + defaultShell: jobRunDefaultShell(jobGroup, scalarAnchors) ?? workflowShell, defaultWorkingDirectory: jobRunDefaultWorkingDirectory( jobGroup, scalarAnchors, @@ -3701,11 +3713,27 @@ function stepGroupHasUntrustedExecutableActionInput( } function shellRunEvaluatesTaintedVariable(runSource, taintedBindings) { - const taintedVariables = new Set([...taintedBindings].flatMap((binding) => ( - binding.startsWith("env.") && binding !== "env.*" - ? [binding.slice("env.".length).toLowerCase()] + const shellEnvironmentVariables = [...taintedBindings].flatMap((binding) => ( + binding.startsWith("shell.env.") + ? [binding.slice("shell.env.".length)] : [] + )); + const variableNames = shellEnvironmentVariables.length > 0 + ? shellEnvironmentVariables + : [...taintedBindings].flatMap((binding) => ( + binding.startsWith("env.") && binding !== "env.*" + ? [binding.slice("env.".length).toLowerCase()] + : [] + )); + const usesCaseInsensitiveVariables = /\$env:[A-Za-z_]|%[A-Za-z_][A-Za-z0-9_]*%|\b(?:powershell|pwsh)(?:\.exe)?\b/iu.test( + runSource, + ); + const variablesAreCaseSensitive = shellEnvironmentVariables.length > 0 + && !usesCaseInsensitiveVariables; + const taintedVariables = new Set(variableNames.map((name) => ( + variablesAreCaseSensitive ? name : name.toLowerCase() ))); + taintedVariables.caseSensitive = variablesAreCaseSensitive; const anyEnvironmentVariableTainted = taintedBindings.has("env.*"); const evaluatorCommand = /^\s*(?:(?:builtin|command|exec)\s+)?(?:(?:\/usr\/bin\/)?env\s+(?:(?:-[^\s]+|[A-Za-z_][A-Za-z0-9_]*=[^\s]+)\s+)*)?(?:eval\b|(?:(?:\/[^/\s]+)*\/)?(?:bash|dash|fish|ksh|sh|zsh)\b[^;&|]*\s-c(?:\s+|(?=[^-\s])|$)|(?:(?:\/[^/\s]+)*\/)?node\b[^;&|]*\s(?:-e|--eval|-p|--print)(?:\s+|(?=[^-\s])|$)|(?:(?:\/[^/\s]+)*\/)?(?:perl|python(?:\d+(?:\.\d+)*)?|ruby)\b[^;&|]*\s-(?:c|e)(?:\s+|(?=[^-\s])|$)|(?:(?:\/[^/\s]+)*\/)?php\b[^;&|]*\s-r(?:\s+|(?=[^-\s])|$)|(?:(?:\/[^/\s]+)*\/)?deno\b[^;&|]*\beval(?:\s|$)|(?:iex|invoke-expression)\b|(?:(?:\/[^/\s]+)*\/)?(?:powershell|pwsh)(?:\.exe)?\b[^;&|]*\s-(?:command|c)(?:\s+|(?=[^-\s])|$))/iu; const sourceEvaluatesTaint = ( @@ -3714,10 +3742,13 @@ function shellRunEvaluatesTaintedVariable(runSource, taintedBindings) { inheritedStaticValues, depth, inheritedFunctions, + inheritedTaintedScriptPaths, ) => { const localTaintedVariables = new Set(inheritedTaintedVariables); + localTaintedVariables.caseSensitive = inheritedTaintedVariables.caseSensitive; const localStaticValues = new Map(inheritedStaticValues); const localFunctions = new Map(inheritedFunctions); + const localTaintedScriptPaths = inheritedTaintedScriptPaths; for (const unit of shellEvaluationUnits(source)) { if (unit.definition) { localFunctions.set(unit.definition.name, unit.definition.body); @@ -3739,6 +3770,7 @@ function shellRunEvaluatesTaintedVariable(runSource, taintedBindings) { localTaintedVariables, anyEnvironmentVariableTainted, ); + updateShellShiftTaint(executableSegment, localTaintedVariables); applyStaticShellAssignment(executableSegment, localStaticValues); for (const substitution of shellCommandSubstitutions(segment)) { if (depth >= 32) { @@ -3756,6 +3788,7 @@ function shellRunEvaluatesTaintedVariable(runSource, taintedBindings) { localStaticValues, depth + 1, localFunctions, + localTaintedScriptPaths, )) return true; } let pipelineInputTainted = false; @@ -3792,6 +3825,24 @@ function shellRunEvaluatesTaintedVariable(runSource, taintedBindings) { localTaintedVariables, anyEnvironmentVariableTainted, ); + const executedFileSources = shellArtifactExecutionSources( + sinkStage, + localStaticValues, + ).map((sourcePath) => ( + resolveStaticEnvironmentReferences(sourcePath, localStaticValues) + ?? sourcePath + )); + if (executedFileSources.some((sourcePath) => ( + [...localTaintedScriptPaths].some((taintedPath) => ( + artifactSourceMatchesPath(sourcePath, taintedPath) + )) + ))) return true; + updateShellTaintedFileWrites( + stage, + localTaintedScriptPaths, + stageReferencesTaint || pipelineInputTainted || redirectedInputTainted, + localStaticValues, + ); for (const childCommand of [ ...shellFindExecutedCommands(stage), ...shellEnvSplitCommands(stage), @@ -3815,6 +3866,7 @@ function shellRunEvaluatesTaintedVariable(runSource, taintedBindings) { localStaticValues, depth + 1, localFunctions, + localTaintedScriptPaths, )) return true; } const functionInvocation = shellFunctionInvocation(stage, localFunctions); @@ -3838,6 +3890,7 @@ function shellRunEvaluatesTaintedVariable(runSource, taintedBindings) { localStaticValues, depth + 1, localFunctions, + localTaintedScriptPaths, )) return true; pipelineInputTainted ||= stageReferencesTaint; continue; @@ -3860,7 +3913,11 @@ function shellRunEvaluatesTaintedVariable(runSource, taintedBindings) { )) return true; if (shellStageMayInvokeGitSsh(stage) && (anyEnvironmentVariableTainted - || localTaintedVariables.has("git_ssh_command") + || localTaintedVariables.has( + localTaintedVariables.caseSensitive + ? "GIT_SSH_COMMAND" + : "git_ssh_command", + ) || shellStageHasTaintedEnvironmentBinding( stage, "git_ssh_command", @@ -3889,7 +3946,40 @@ function shellRunEvaluatesTaintedVariable(runSource, taintedBindings) { } return false; }; - return sourceEvaluatesTaint(runSource, taintedVariables, new Map(), 0, new Map()); + return sourceEvaluatesTaint( + runSource, + taintedVariables, + new Map(), + 0, + new Map(), + new Set(), + ); +} + +function updateShellTaintedFileWrites( + stage, + taintedPaths, + outputIsTainted, + environmentValues, +) { + const redirections = [...stage.matchAll( + /(?:^|\s)(\d*)(>>?|>\|)\s*("[^"]*"|'[^']*'|[^\s;&|]+)/gu, + )]; + for (const redirection of redirections) { + if (redirection[1] && redirection[1] !== "1") continue; + const destination = resolveStaticEnvironmentReferences( + redirection[3], + environmentValues, + ); + if (!destination || /^\/dev\//u.test(destination)) continue; + const normalizedDestination = normalizedArtifactPath( + destination, + ".", + environmentValues, + ); + if (outputIsTainted) taintedPaths.add(normalizedDestination); + else if (redirection[2] !== ">>") taintedPaths.delete(normalizedDestination); + } } function shellEvaluationUnits(source) { @@ -3911,7 +4001,7 @@ function shellEvaluationUnits(source) { function shellFunctionDefinitions(source) { const definitions = []; - const pattern = /(^|[;\n])\s*(?:function\s+([A-Za-z_][A-Za-z0-9_]*)(?:\s*\(\s*\))?|([A-Za-z_][A-Za-z0-9_]*)\s*\(\s*\))\s*\{/gu; + const pattern = /(^|[;\n]|&&|\|\|)\s*(?:function\s+([A-Za-z_][A-Za-z0-9_]*)(?:\s*\(\s*\))?|([A-Za-z_][A-Za-z0-9_]*)\s*\(\s*\))\s*\{/gu; let match; while ((match = pattern.exec(source)) !== null) { const openingBrace = match.index + match[0].lastIndexOf("{"); @@ -3956,6 +4046,15 @@ function shellFunctionInvocation(stage, functions) { const words = shellCommandWords(stripShellCommandGrouping(stage)); let cursor = 0; while (/^[A-Za-z_][A-Za-z0-9_]*=/u.test(words[cursor] ?? "")) cursor += 1; + if (words[cursor] === "case") { + const inIndex = words.indexOf("in", cursor + 1); + const patternIndex = words.findIndex((word, index) => ( + index > inIndex && (word === ")" || word.endsWith(")")) + )); + if (inIndex >= 0 && patternIndex > inIndex) cursor = patternIndex + 1; + } else if (words[cursor] === ")" || words[cursor]?.endsWith(")")) { + cursor += 1; + } const controlWords = new Set([ "!", "coproc", "do", "elif", "else", "if", "then", "time", "until", "while", @@ -3984,6 +4083,7 @@ function shellFunctionInvocationTaintedVariables( const taintedVariables = new Set([...inheritedTaintedVariables].filter((name) => ( !/^\d+$/u.test(name) && !["*", "@"].includes(name) ))); + taintedVariables.caseSensitive = inheritedTaintedVariables.caseSensitive; const argumentTaint = arguments_.map((argument) => ( isUntrustedReusableValue(argument, taintedBindings) || shellSourceReferencesTaintedVariable( @@ -4051,7 +4151,7 @@ function updateShellReadTaint(stage, taintedVariables, inputTainted) { if (/^(?:\d*) 0 ? targets : ["reply"]) { - taintedVariables.add(target); + taintedVariables.add(taintedVariables.caseSensitive ? target : target.toLowerCase()); } } @@ -4084,13 +4184,13 @@ function updateShellPrintfTaint( while (cursor < words.length) { const argument = words[cursor]; if (argument === "-v") { - target = words[cursor + 1]?.toLowerCase(); + target = words[cursor + 1]; cursor += 2; continue; } const attachedTarget = /^-v([A-Za-z_][A-Za-z0-9_]*)$/u.exec(argument)?.[1]; if (attachedTarget) { - target = attachedTarget.toLowerCase(); + target = attachedTarget; cursor += 1; continue; } @@ -4106,8 +4206,9 @@ function updateShellPrintfTaint( taintedVariables, anyEnvironmentVariableTainted, ); - if (valueIsTainted) taintedVariables.add(target); - else taintedVariables.delete(target); + const taintName = taintedVariables.caseSensitive ? target : target.toLowerCase(); + if (valueIsTainted) taintedVariables.add(taintName); + else taintedVariables.delete(taintName); } function shellFindExecutedCommands(stage) { @@ -4367,9 +4468,12 @@ function updateShellVariableTaint( ) { const assignment = shellAssignmentBinding(source); if (!assignment) return; - const namerefTarget = /^\s*(?:declare|local|typeset)(?=[^\n]*\s-n(?:\s|$))[^\n]*\s+[A-Za-z_][A-Za-z0-9_]*\s*=\s*([A-Za-z_][A-Za-z0-9_]*)/iu.exec( + const rawNamerefTarget = /^\s*(?:declare|local|typeset)(?=[^\n]*\s-n(?:\s|$))[^\n]*\s+[A-Za-z_][A-Za-z0-9_]*\s*=\s*([A-Za-z_][A-Za-z0-9_]*)/iu.exec( source, - )?.[1].toLowerCase(); + )?.[1]; + const namerefTarget = taintedVariables.caseSensitive + ? rawNamerefTarget + : rawNamerefTarget?.toLowerCase(); const valueIsTainted = (namerefTarget && (anyEnvironmentVariableTainted || taintedVariables.has(namerefTarget))) || isUntrustedReusableValue(assignment.value, taintedBindings) @@ -4384,8 +4488,13 @@ function updateShellVariableTaint( taintedVariables, anyEnvironmentVariableTainted, ); - if (valueIsTainted) taintedVariables.add(assignment.name); - else if (assignment.operator !== "+=") taintedVariables.delete(assignment.name); + const taintName = taintedVariables.caseSensitive + ? assignment.shellName + : assignment.name; + if (valueIsTainted) taintedVariables.add(taintName); + else if (assignment.operator !== "+=") { + taintedVariables.delete(taintName); + } } function updateShellPositionalTaint( @@ -4419,6 +4528,36 @@ function updateShellPositionalTaint( } } +function updateShellShiftTaint(source, taintedVariables) { + const words = shellCommandWords(stripShellCommandGrouping(source)); + let cursor = shellCommandWrapperCursor(words); + if (path.posix.basename(words[cursor] ?? "").toLowerCase() !== "shift") return; + cursor += 1; + const requestedCount = words[cursor] ?? "1"; + const count = /^\d+$/u.test(requestedCount) + ? Number.parseInt(requestedCount, 10) + : undefined; + const positionalTaint = [...taintedVariables] + .filter((name) => /^\d+$/u.test(name)) + .map((name) => Number.parseInt(name, 10)); + for (const name of [...taintedVariables]) { + if (/^\d+$/u.test(name) || ["*", "@"].includes(name)) { + taintedVariables.delete(name); + } + } + if (count === undefined) { + if (positionalTaint.length > 0) taintedVariables.add("1"); + } else { + for (const position of positionalTaint) { + if (position > count) taintedVariables.add(String(position - count)); + } + } + if ([...taintedVariables].some((name) => /^\d+$/u.test(name))) { + taintedVariables.add("*"); + taintedVariables.add("@"); + } +} + function resolveStaticShellCommandAlias(stage, environmentValues) { const words = shellCommandWords(stage); let cursor = 0; @@ -4778,7 +4917,11 @@ function shellStageHasTaintedGitSshOverride( const environmentMatch = /^core\.sshcommand=([A-Za-z_][A-Za-z0-9_]*)$/iu.exec( configEnvironmentValue ?? "", ); - const environmentName = environmentMatch?.[1].toLowerCase(); + const environmentName = environmentMatch + ? (taintedVariables.caseSensitive + ? environmentMatch[1] + : environmentMatch[1].toLowerCase()) + : undefined; if (environmentName && (anyEnvironmentVariableTainted || taintedVariables.has(environmentName))) { return true; @@ -4840,6 +4983,7 @@ function stepContextsHaveUntrustedArtifactExecution( scalarAnchors, { artifactPaths = [], + defaultShell, defaultWorkingDirectory = ".", environmentValues = new Map(), localActionExecution, @@ -4873,6 +5017,7 @@ function stepContextsHaveUntrustedArtifactExecution( artifactPaths, workingDirectory, stepEnvironmentValues, + defaultShell, ); if (artifactAnalysis.executes) return true; for (const artifactPath of artifactAnalysis.derivedArtifactPaths) { @@ -5131,6 +5276,16 @@ function workflowRunDefaultWorkingDirectory(text, scalarAnchors) { .find(Boolean) ?? "."; } +function workflowRunDefaultShell(text, scalarAnchors) { + return workflowRootContainerGroups(text, "defaults", scalarAnchors) + .flatMap((group) => workflowNestedMappingValues( + group, + ["run", "shell"], + scalarAnchors, + )) + .find(Boolean); +} + function jobRunDefaultWorkingDirectory(jobGroup, scalarAnchors) { return workflowNestedMappingValues( jobGroup, @@ -5139,6 +5294,14 @@ function jobRunDefaultWorkingDirectory(jobGroup, scalarAnchors) { ).find(Boolean); } +function jobRunDefaultShell(jobGroup, scalarAnchors) { + return workflowNestedMappingValues( + jobGroup, + ["defaults", "run", "shell"], + scalarAnchors, + ).find(Boolean); +} + function workflowNestedMappingValues(group, keys, scalarAnchors) { const [key, ...remainingKeys] = keys; const matchingProperties = group.properties.filter(({ entry }) => ( @@ -5182,6 +5345,7 @@ function stepArtifactExecutionAnalysis( artifactPaths, workingDirectory = ".", environmentValues = new Map(), + defaultShell, ) { const localActionReference = stepGroup.properties .filter(({ entry }) => entry.key.toLowerCase() === "uses") @@ -5192,9 +5356,16 @@ function stepArtifactExecutionAnalysis( ))) { return { derivedArtifactPaths: [], executes: true }; } - const shellExecutionSources = stepGroup.properties + const runSources = stepGroup.properties + .filter(({ entry }) => entry.key.toLowerCase() === "run") + .map(({ entry }) => resolveYamlScalarValue(entry.value, scalarAnchors)); + const requestedShells = stepGroup.properties .filter(({ entry }) => entry.key.toLowerCase() === "shell") - .map(({ entry }) => resolveYamlScalarValue(entry.value, scalarAnchors)) + .map(({ entry }) => resolveYamlScalarValue(entry.value, scalarAnchors)); + if (requestedShells.length === 0 && runSources.length > 0 && defaultShell) { + requestedShells.push(defaultShell); + } + const shellExecutionSources = requestedShells .flatMap((shellTemplate) => shellArtifactExecutionSources( shellTemplate, environmentValues, @@ -5212,9 +5383,7 @@ function stepArtifactExecutionAnalysis( ))) return { derivedArtifactPaths: [], executes: true }; const knownArtifactPaths = [...artifactPaths]; const derivedArtifactPaths = []; - for (const runSource of stepGroup.properties - .filter(({ entry }) => entry.key.toLowerCase() === "run") - .map(({ entry }) => resolveYamlScalarValue(entry.value, scalarAnchors))) { + for (const runSource of runSources) { const analysis = shellRunArtifactExecutionAnalysis( runSource, knownArtifactPaths, @@ -5311,10 +5480,16 @@ function shellRunArtifactExecutionAnalysis( ); continue; } - const executionSources = shellArtifactExecutionSources( - executableSegment, - localEnvironmentValues, - ) + const executionSources = [ + ...shellArtifactExecutionSources( + executableSegment, + localEnvironmentValues, + ), + ...shellDynamicLoaderExecutionSources( + executableSegment, + localEnvironmentValues, + ), + ] .map((source) => ( resolveStaticEnvironmentReferences(source, localEnvironmentValues) ?? source )); @@ -5327,6 +5502,24 @@ function shellRunArtifactExecutionAnalysis( functions: localFunctions, workingDirectory: effectiveWorkingDirectory, }; + const transfer = shellFileTransfer(executableSegment); + if (transfer && transfer.sources.some((source) => ( + knownArtifactPaths.some((artifactPath) => artifactSourceMatchesPath( + source, + artifactPath, + effectiveWorkingDirectory, + )) + ))) { + const destination = normalizedArtifactPath( + transfer.destination, + effectiveWorkingDirectory, + localEnvironmentValues, + ); + if (!knownArtifactPaths.includes(destination)) knownArtifactPaths.push(destination); + if (!derivedArtifactPaths.includes(destination)) { + derivedArtifactPaths.push(destination); + } + } const extraction = shellArchiveExtraction(executableSegment); if (!extraction || !knownArtifactPaths.some((artifactPath) => ( artifactSourceMatchesPath( @@ -5354,6 +5547,77 @@ function shellRunArtifactExecutionAnalysis( }; } +function shellFileTransfer(segment) { + const words = shellCommandWords(stripShellCommandGrouping(segment)); + let cursor = shellCommandWrapperCursor(words, { allowSudo: true }); + const command = path.posix.basename(words[cursor] ?? "").toLowerCase(); + if (!["cp", "gcp", "install", "mv", "rsync"].includes(command)) { + return undefined; + } + cursor += 1; + const positionals = []; + let destination; + let optionsEnded = false; + const optionsWithValues = new Set([ + "--block-size", "--chmod", "--chown", "--compare-dest", "--copy-dest", + "--exclude", "--exclude-from", "--files-from", + "--filter", "--group", "--include", "--include-from", "--link-dest", + "--mode", "--owner", "--rsync-path", "--suffix", "--temp-dir", + "--usermap", "-B", "-e", "-f", "-g", "-m", "-o", "-S", + ]); + while (cursor < words.length) { + const argument = words[cursor]; + cursor += 1; + if (!optionsEnded && argument === "--") { + optionsEnded = true; + continue; + } + if (!optionsEnded && ["-t", "--target-directory"].includes(argument)) { + destination = words[cursor]; + cursor += 1; + continue; + } + if (!optionsEnded) { + const inlineDestination = /^--target-directory=(.*)$/iu.exec(argument)?.[1] + ?? /^-t(.+)$/u.exec(argument)?.[1]; + if (inlineDestination !== undefined) { + destination = inlineDestination; + continue; + } + if (optionsWithValues.has(argument)) { + cursor += 1; + continue; + } + if (argument.startsWith("-")) continue; + } + positionals.push(argument); + } + if (destination) { + return positionals.length > 0 ? { destination, sources: positionals } : undefined; + } + if (positionals.length < 2) return undefined; + return { + destination: positionals.at(-1), + sources: positionals.slice(0, -1), + }; +} + +function shellDynamicLoaderExecutionSources(segment, environmentValues) { + const commandEnvironmentValues = new Map(environmentValues); + for (const binding of shellCommandEnvironmentBindings(segment)) { + const value = resolveStaticEnvironmentReferences( + binding.value, + commandEnvironmentValues, + ); + if (value === undefined) commandEnvironmentValues.delete(binding.name); + else commandEnvironmentValues.set(binding.name, value); + } + return ["dyld_insert_libraries", "ld_audit", "ld_preload"] + .flatMap((name) => (commandEnvironmentValues.get(name) ?? "") + .split(/[\s:]+/u) + .filter(Boolean)); +} + function shellArchiveExtraction(segment) { return shellTarExtraction(segment) ?? shellZipExtraction(segment); } @@ -5595,7 +5859,22 @@ function shellArtifactExecutionSources( } return executionSources; } - return command.includes("/") ? [command] : []; + if (command.includes("/")) return [command]; + const configuredPath = commandEnvironmentValues.get("path"); + const shellBuiltins = new Set([ + ".", ":", "alias", "bg", "break", "cd", "command", "continue", "echo", + "eval", "exec", "exit", "export", "false", "fg", "getopts", "hash", + "jobs", "kill", "printf", "pwd", "read", "readonly", "return", "set", + "shift", "test", "times", "trap", "true", "type", "typeset", "ulimit", + "umask", "unalias", "unset", "wait", + ]); + if (!configuredPath || shellBuiltins.has(commandName)) return []; + return configuredPath.split(":").flatMap((directory) => { + const normalizedDirectory = directory || "."; + return path.posix.isAbsolute(normalizedDirectory) + ? [] + : [path.posix.join(normalizedDirectory, command)]; + }); } function shellCommandStringArtifactExecutionSources( @@ -6261,7 +6540,10 @@ function shellSourceContainsToken(source, token) { function shellSourceReferencesTaintedVariable(source, taintedVariables, anyTainted) { const nameref = /^\s*(?:declare|local|typeset)(?=[^\n]*\s-n(?:\s|$))[^\n]*\s+[A-Za-z_][A-Za-z0-9_]*\s*=\s*([A-Za-z_][A-Za-z0-9_]*)/iu.exec(source); - if (nameref && (anyTainted || taintedVariables.has(nameref[1].toLowerCase()))) { + if (nameref && (anyTainted + || taintedVariables.has(nameref[1]) + || (!taintedVariables.caseSensitive + && taintedVariables.has(nameref[1].toLowerCase())))) { return true; } if (/\$\{![A-Za-z_][A-Za-z0-9_]*(?:[*@])?\}/u.test(source) @@ -6274,14 +6556,19 @@ function shellSourceReferencesTaintedVariable(source, taintedVariables, anyTaint ...source.matchAll(/\$(?:env:([A-Za-z_][A-Za-z0-9_]*)|\{(?:env:)?([A-Za-z_][A-Za-z0-9_]*)|([A-Za-z_][A-Za-z0-9_]*))/giu), ...source.matchAll(/\$(?:\{([0-9]+)|([0-9]+))/gu), ...source.matchAll(/%([A-Za-z_][A-Za-z0-9_]*)%/gu), - ].map((match) => (match[1] ?? match[2] ?? match[3]).toLowerCase()); - return references.some((name) => anyTainted || taintedVariables.has(name)); + ].map((match) => match[1] ?? match[2] ?? match[3]); + return references.some((name) => anyTainted + || taintedVariables.has(name) + || (!taintedVariables.caseSensitive && taintedVariables.has(name.toLowerCase()))); } function contextTaintedBindings(bindings, inheritedTaintedBindings) { const taintedBindings = new Set(inheritedTaintedBindings); for (const binding of bindings) { taintedBindings.delete(binding.namespace + "." + binding.name); + if (binding.namespace === "env") { + taintedBindings.delete(`shell.env.${binding.originalName ?? binding.name}`); + } } let changed; do { @@ -6291,6 +6578,9 @@ function contextTaintedBindings(bindings, inheritedTaintedBindings) { if (!taintedBindings.has(name) && isUntrustedReusableValue(binding.value, taintedBindings)) { taintedBindings.add(name); + if (binding.namespace === "env") { + taintedBindings.add(`shell.env.${binding.originalName ?? binding.name}`); + } changed = true; } } diff --git a/public-source-release-audit/tests/public-source-release-audit.test.mjs b/public-source-release-audit/tests/public-source-release-audit.test.mjs index 09b7a68..e06f653 100644 --- a/public-source-release-audit/tests/public-source-release-audit.test.mjs +++ b/public-source-release-audit/tests/public-source-release-audit.test.mjs @@ -2521,6 +2521,9 @@ test("artifact execution recognizes common command wrappers and paths", () => { ["env-split", "env -S 'bash payload/run.sh'"], ["shell-function", "execute_payload() { bash payload/run.sh; }; execute_payload"], ["command-string-function", "bash -c 'execute_payload() { bash payload/run.sh; }; execute_payload'"], + ["copy", "cp -R payload relocated && bash relocated/run.sh"], + ["path-lookup", "chmod +x payload/run-tool && PATH=payload run-tool"], + ["loader-preload", "LD_PRELOAD=payload/hook.so /bin/true"], ]) { write(repoRoot, `.github/workflows/workflow-run-artifact-${name}.yml`, [ `name: workflow run artifact ${name}`, @@ -2618,6 +2621,36 @@ test("artifact execution recognizes common command wrappers and paths", () => { " run: echo fixed", "", ].join("\n")); + for (const scope of ["workflow", "job"]) { + write(repoRoot, `.github/workflows/workflow-run-artifact-${scope}-default-shell.yml`, [ + `name: workflow run artifact ${scope} default shell`, + "on:", + " workflow_run:", + " workflows: [verify]", + " types: [completed]", + "permissions: read-all", + ...(scope === "workflow" ? [ + "defaults:", + " run:", + " shell: bash payload/wrapper.sh {0}", + ] : []), + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + ...(scope === "job" ? [ + " defaults:", + " run:", + " shell: bash payload/wrapper.sh {0}", + ] : []), + " steps:", + " - uses: actions/download-artifact@" + "a".repeat(40), + " with:", + " run-id: " + runIdExpression, + " path: payload", + " - run: echo fixed", + "", + ].join("\n")); + } for (const scope of ["workflow", "job", "step", "shell"]) { write(repoRoot, `.github/workflows/workflow-run-artifact-${scope}-alias.yml`, [ `name: workflow run artifact ${scope} alias`, @@ -2674,10 +2707,15 @@ test("artifact execution recognizes common command wrappers and paths", () => { "env-split", "shell-function", "command-string-function", + "copy", + "path-lookup", + "loader-preload", "node-options", "env-node-options", "bash-env", "custom-shell-template", + "workflow-default-shell", + "job-default-shell", "workflow-alias", "job-alias", "step-alias", @@ -5521,6 +5559,66 @@ test("tainted text cannot reach language runtime evaluators", () => { "", ].join("\n")); } + write(repoRoot, ".github/workflows/comment-function-and-or-eval.yml", [ + "name: comment function and-or eval", + "on: issue_comment", + "permissions: read-all", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - env:", + " COMMAND: " + bodyExpression, + " run: |", + " true && danger() { eval \"$COMMAND\"; }", + " danger", + "", + ].join("\n")); + write(repoRoot, ".github/workflows/comment-function-case-arm-eval.yml", [ + "name: comment function case arm eval", + "on: issue_comment", + "permissions: read-all", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - env:", + " COMMAND: " + bodyExpression, + " run: |", + " danger() { eval \"$COMMAND\"; }", + " case yes in yes) danger ;; esac", + "", + ].join("\n")); + write(repoRoot, ".github/workflows/comment-generated-script-eval.yml", [ + "name: comment generated script eval", + "on: issue_comment", + "permissions: read-all", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - env:", + " COMMAND: " + bodyExpression, + " run: |", + " printf '%s' \"$COMMAND\" > generated.sh", + " bash generated.sh", + "", + ].join("\n")); + write(repoRoot, ".github/workflows/comment-function-shift-eval.yml", [ + "name: comment function shift eval", + "on: issue_comment", + "permissions: read-all", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - env:", + " COMMAND: " + bodyExpression, + " run: |", + " danger() { shift; eval \"$1\"; }", + " danger fixed \"$COMMAND\"", + "", + ].join("\n")); write(safeRepo, ".github/workflows/comment-function-case.yml", [ "name: comment function case", "on: issue_comment", @@ -5536,6 +5634,35 @@ test("tainted text cannot reach language runtime evaluators", () => { " dangerfunction || true", "", ].join("\n")); + write(safeRepo, ".github/workflows/comment-variable-case.yml", [ + "name: comment variable case", + "on: issue_comment", + "permissions: read-all", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - env:", + " CODE: " + bodyExpression, + " run: eval \"$code\"", + "", + ].join("\n")); + write(safeRepo, ".github/workflows/comment-generated-script-overwrite.yml", [ + "name: comment generated script overwrite", + "on: issue_comment", + "permissions: read-all", + "jobs:", + " inspect:", + " runs-on: ubuntu-latest", + " steps:", + " - env:", + " COMMAND: " + bodyExpression, + " run: |", + " printf '%s' \"$COMMAND\" > generated.sh", + " printf '%s' 'echo fixed' > generated.sh", + " bash generated.sh", + "", + ].join("\n")); commitAll(repoRoot, "add tainted language runtime evaluators"); commitAll(safeRepo, "add case-sensitive shell function"); @@ -5572,6 +5699,15 @@ test("tainted text cannot reach language runtime evaluators", () => { `.github/workflows/comment-function-${control}-eval.yml`, ); } + for (const name of [ + "function-and-or", "function-case-arm", "generated-script", "function-shift", + ]) { + assertFinding( + audit.result, + "workflow-privileged-untrusted-script-interpolation", + `.github/workflows/comment-${name}-eval.yml`, + ); + } assert.equal(safeAudit.status, 0); });