From 1ea164ffadf124e9d0d0d34880e2ff729155b750 Mon Sep 17 00:00:00 2001 From: Yuriy Andamasov Date: Sun, 10 May 2026 22:19:25 +0300 Subject: fix(ci): bundle .md via git-show blobs, not filesystem cp (symlink exfil) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot finding on .github/workflows/ai-validation.yml:50 — high severity: A fork PR can commit a symlink at docs/x.md (mode 120000) pointing to an absolute path on the self-hosted runner. The previous `xargs cp` would follow the symlink and copy the *target's* content (/etc/passwd, runner secrets, ssh keys, the App's PEM if accessible) into the pr-input artifact. The validate job downloads that artifact and exposes it to the Pass 2 claude-code-action step (which has Read,Glob,Grep tools), so a prompt-injection attempt could exfiltrate runner state via inline review comments. Mitigation: replace the cp loop with `git show HEAD:` extraction. This pulls bytes directly from the merge commit's tree blob. For a symlink entry, git show returns the textual target path (a string like "/etc/passwd"), NOT the target's filesystem content. The artifact's worst-case is a text file containing a path string, which has no exfiltration value. Implementation: - Read NUL-delimited paths from changed-md.z (preserves filename safety). - For each path, check ls-tree mode: 100644/100755 = normal file, accept; 120000 = symlink, skip with ::warning::; 160000 = submodule, skip; anything else, skip. - Use `git show HEAD:` to write blob content into _changed_md/. - All variable expansions are within double-quotes (no word-splitting, no glob, no recursive parse of $() in the substituted value). The xargs cp option-injection defense (`-- ` end-of-options) is no longer needed since cp is gone. The --diff-filter=ACMRT and persist-credentials fixes from edd903d remain. Suppressed-by-Copilot finding (line 68, id-token: write): Same as previous round — pushback. claude-code-action@v1 uses OIDC internally (verified via vyos/vyos-documentation commit b18a399c, where the permission was removed and immediately restored after the action broke). Keeping as-is. 🤖 Generated by [robots](https://vyos.io) --- .github/workflows/ai-validation.yml | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ai-validation.yml b/.github/workflows/ai-validation.yml index f4a0997a..57cd12a1 100644 --- a/.github/workflows/ai-validation.yml +++ b/.github/workflows/ai-validation.yml @@ -43,11 +43,28 @@ jobs: tr '\0' '\n' < changed-md.z > changed-md.txt tr '\0' '\n' < changed-rst.z > changed-rst.txt git diff "$BASE...HEAD" -- 'docs/**/*.md' > diff-md.patch + # Bundle .md files via git's blob store (NOT the filesystem). + # The fork's merge ref can contain symlinks (mode 120000) committed + # to docs/**/*.md that resolve to absolute paths on this self-hosted + # runner. `cp` would dereference and copy the target's content + # (/etc/passwd, runner secrets, ssh keys) into the artifact, + # exfiltrating runner state to the validate job's claude-code-action + # input. `git show HEAD:` returns the blob directly from the + # object database; for a symlink-mode entry it returns the textual + # target path, never the target's content. mkdir _changed_md - # `--` after `-t _changed_md/` ends cp's option parsing, so a - # filename starting with `-` (which git won't emit from a tree, but - # defense in depth) cannot be misinterpreted as a cp option. - xargs -0 -a changed-md.z -r cp --parents -t _changed_md/ -- + while IFS= read -r -d '' path; do + mode=$(git ls-tree HEAD -- "$path" | awk '{print $1}') + case "$mode" in + 100644|100755) ;; + *) + echo "::warning::Skipping non-regular file in PR diff: $path (mode=$mode)" + continue + ;; + esac + mkdir -p "_changed_md/$(dirname -- "$path")" + git show "HEAD:$path" > "_changed_md/$path" + done < changed-md.z - name: Upload PR input artifact uses: actions/upload-artifact@v4 -- cgit v1.2.3