diff --git a/.github/scripts/validate-submission.sh b/.github/scripts/validate-submission.sh index a744fa6..aeb128b 100755 --- a/.github/scripts/validate-submission.sh +++ b/.github/scripts/validate-submission.sh @@ -4,8 +4,11 @@ # # Runtime contract (from validate-submission.yml): # - Runs on ubuntu-latest (GNU grep / sed / coreutils assumed). -# - `base/` contains the trusted base-branch checkout. -# - `pr/` contains the PR-head checkout, treated as untrusted data only. +# - `base/` contains the trusted base-branch checkout, and is the only +# source for repo-owned files such as the prize specs. +# - `pr/solutions/` contains just the submission markdown the PR changed, +# fetched by path from the API and treated as untrusted data only. The +# fork's working tree is never checked out. # - Env: PR_TITLE, PR_REPO, BASE_REPO, CHANGED_FILES. set -euo pipefail @@ -230,7 +233,9 @@ if $IS_SOLUTION; then # ------------------------------------------------------------------------- # 3h. Prize exists and is open # ------------------------------------------------------------------------- - PRIZE_FILE="pr/prizes/${PRIZE_ID}.md" + # Read from base/: the prize spec is repo-owned, so the authoritative copy + # is the base branch's, not whatever stale revision the fork branched from. + PRIZE_FILE="base/prizes/${PRIZE_ID}.md" if [[ ! -f "$PRIZE_FILE" ]]; then err "Prize \`${PRIZE_ID}\` not found in \`prizes/\`. Check the ID." else @@ -261,7 +266,12 @@ if $IS_SOLUTION; then CLONE_DIR="/tmp/submission-repo" rm -rf "$CLONE_DIR" - if git clone --depth=1 "$REPO_URL" "$CLONE_DIR" 2>/dev/null; then + # `REPO_URL` is submitter-controlled, so the clone is bounded: no auth + # prompts, no submodules, one shallow branch, and a wall-clock ceiling so + # an oversized or slow repo cannot consume the whole job. + if GIT_TERMINAL_PROMPT=0 timeout "${LP_CLONE_TIMEOUT:-300}" \ + git clone --depth=1 --single-branch --no-tags \ + --no-recurse-submodules "$REPO_URL" "$CLONE_DIR" 2>/dev/null; then # 4a. AI workspace artifacts in the external repo AI_ARTIFACTS=() @@ -336,7 +346,9 @@ if $IS_SOLUTION; then # Logos Messaging / Waku integration if echo "$PRIZE_CONTENT" | grep -qi 'Logos Messaging\|Logos Chat\|Waku'; then - waku_ref=$(grep -ril 'waku\|logos.messaging\|logos.chat' "$CLONE_DIR" \ + # Time-bounded: this is the one full-content scan of the clone. + waku_ref=$(timeout "${LP_SCAN_TIMEOUT:-60}" \ + grep -ril 'waku\|logos.messaging\|logos.chat' "$CLONE_DIR" \ --include='*.rs' --include='*.go' --include='*.ts' --include='*.js' \ --include='*.toml' --include='*.json' 2>/dev/null | head -1 || true) if [[ -z "$waku_ref" ]]; then diff --git a/.github/workflows/validate-submission.yml b/.github/workflows/validate-submission.yml index 5c93604..6633dda 100644 --- a/.github/workflows/validate-submission.yml +++ b/.github/workflows/validate-submission.yml @@ -2,8 +2,8 @@ name: Validate Submission # `pull_request_target` is used so the workflow can comment on PRs from forks # (which is how solutions are submitted). It runs on the base branch with -# write perms; the PR-head checkout is treated as untrusted data only and -# never executed. +# write perms, so the fork's tree is never checked out here: the only +# submission content the validator reads is fetched by path as data. on: pull_request_target: types: [opened, synchronize, reopened, edited] @@ -26,13 +26,8 @@ jobs: with: ref: ${{ github.event.pull_request.base.sha }} path: base - - - name: Checkout PR head (untrusted data, never executed) - uses: actions/checkout@v4 - with: - ref: ${{ github.event.pull_request.head.sha }} - repository: ${{ github.event.pull_request.head.repo.full_name }} - path: pr + # Nothing in this job pushes, so the token has no reason to sit in + # base/.git/config for the life of the run. persist-credentials: false - name: Get changed files @@ -52,6 +47,31 @@ jobs: echo "__LP_EOF__" } >> "$GITHUB_OUTPUT" + - name: Fetch changed solution files (untrusted data, never executed) + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + CHANGED_FILES: ${{ steps.changed.outputs.files }} + run: | + # The validator reads exactly one thing from the PR: the submission + # markdown. Fetching those blobs by path keeps the fork's working + # tree off the runner entirely, so there is nothing here to execute + # and no `allow-unsafe-pr-checkout` opt-in to carry. Paths come from + # the PR files API and are constrained to a single markdown file + # directly under `solutions/`. + mkdir -p pr/solutions + solution_files=$(printf '%s\n' "$CHANGED_FILES" \ + | grep -E '^solutions/[A-Za-z0-9._-]+\.md$' || true) + # Unquoted on purpose: the pattern above admits no spaces or globs. + for f in $solution_files; do + if ! gh api -H "Accept: application/vnd.github.raw" \ + "/repos/${REPO}/contents/${f}?ref=${HEAD_SHA}" > "pr/${f}"; then + rm -f "pr/${f}" + echo "::warning::Could not fetch ${f} at ${HEAD_SHA}." + fi + done + - name: Run validation id: validate env: