FIX: forked-pr-coverage.yml env-injection via untrusted artifact - #714
FIX: forked-pr-coverage.yml env-injection via untrusted artifact#714Sumit Sarabhai (sumitmsft) wants to merge 3 commits into
Conversation
Validate untrusted coverage artifacts before posting comments, bind comments to the triggering pull request, remove privileged environment propagation, and pin actions to immutable revisions. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Hardens the forked PR coverage-comment pipeline by treating coverage artifacts as untrusted input, validating/normalizing the data used to generate PR comments, and reducing the privileged workflow’s exposure to artifact-driven environment injection.
Changes:
- Introduces a Python validator/comment builder (
prepare_fork_coverage_comment.py) and a security-focused regression test suite. - Refactors
forked-pr-coverage.ymlto validate artifacts + resolve the target PR from the triggering workflow context (not artifact-supplied data), and post/update a single sticky comment. - Tightens the producer workflow (
pr-code-coverage.yml) by removing artifact-supplied PR targeting fields, hardening multilineGITHUB_ENVusage, and pinning actions to SHAs.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
tests/test_fork_coverage_security.py |
Adds regression tests for artifact schema validation, URL/markup injection, PR resolution, and workflow env-injection checks. |
.github/workflows/pr-code-coverage.yml |
Pins actions, disables persisted credentials, hardens multiline env export, and removes untrusted fields from the uploaded artifact payload. |
.github/workflows/forked-pr-coverage.yml |
Moves privileged workflow to validate downloaded artifacts + resolve PR from the event/commit association before commenting. |
.github/scripts/prepare_fork_coverage_comment.py |
Implements schema/value validation and safe comment construction for fork coverage artifacts. |
.github/actions/post-coverage-comment/action.yml |
Pins the sticky-comment action to an immutable SHA. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
📊 Code Coverage Report
Diff CoverageDiff: main...HEAD, staged and unstaged changesNo lines with coverage information in this diff. 📋 Files Needing Attention📉 Files with overall lowest coverage (click to expand)mssql_python.pybind.logger_bridge.cpp: 59.2%
mssql_python.pybind.ddbc_bindings.h: 59.9%
mssql_python.pybind.logger_bridge.hpp: 70.8%
mssql_python.pybind.ddbc_bindings.cpp: 76.6%
mssql_python.__init__.py: 77.6%
mssql_python.row.py: 77.6%
mssql_python.ddbc_bindings.py: 79.6%
mssql_python.pybind.connection.connection_pool.cpp: 81.4%
mssql_python.pybind.connection.connection.cpp: 84.3%
mssql_python.logging.py: 85.5%🔗 Quick Links
|
Enforce the one-file artifact schema without recursively traversing attacker-controlled directory trees. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Gaurav Sharma (bewithgaurav)
left a comment
There was a problem hiding this comment.
requesting changes - some issues wrt forked pr comments and refactoring suggestions to cut the PR short
| and isinstance(pull.get("number"), int) | ||
| and pull["number"] > 0 | ||
| ] | ||
| if len(matching_pulls) != 1: |
There was a problem hiding this comment.
forked PRs never get a comment. this line always fails for them.
GET /commits/{sha}/pulls only sees commits that live in the base repo, and a fork's head commit doesn't, so matching_pulls is empty every time. checked all open fork PRs I could find across mssql-python, every one returns 0. the same call returns 1 for a same-repo PR like this one, which is why it looks fine from here.
ran a real artifact plus the live api responses for 3 forked PRs through the script, all three exit 2 on workflow run must resolve to exactly one pull request.
GET /pulls?state=open matched on head.sha resolves all three. the exactly-one-match, base repo and base branch checks all carry over, so no protection is given up
There was a problem hiding this comment.
+1
/commits/{sha}/pulls can't see a fork's head commit, so it's empty every time. /pulls?state=open + head.sha match fixes it with no loss of protection; head_sha still comes from the trusted workflow_run event. Blocking.
| raise ValidationError("workflow run did not originate from a fork") | ||
|
|
||
| event_pulls = workflow_run.get("pull_requests") or [] | ||
| if len(event_pulls) == 1: |
There was a problem hiding this comment.
this is dead code actually
line 170 already bails out when the run isn't from a fork, and github always sends an empty pull_requests on fork runs, 8/8 real fork runs I sampled, so this never fires
the cost is that test_prepares_safe_comment_for_trusted_event_pr only exercises this branch. it goes green while the path that actually runs is untested and broken
There was a problem hiding this comment.
Agree- fork workflow_run events always send empty pull_requests, so this never fires, and it's the only branch the green happy-path test covers.
| steps: | ||
| - name: Checkout repo | ||
| uses: actions/checkout@v4 | ||
| uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 |
There was a problem hiding this comment.
| raise ValidationError("artifact must contain only pr-info.json") | ||
|
|
||
| data = _load_json(artifact_directory / "pr-info.json") | ||
| if not isinstance(data, dict) or set(data) != EXPECTED_FIELDS: |
There was a problem hiding this comment.
suggestion: check the six fields are there rather than requiring an exact match.
as it stands, anyone who adds a field to the artifact later turns fork comments off without knowing, and nothing on the PR says why - the extra field is harmless anyway, nothing here reads past the six it wants
| echo "This may indicate an issue with artifact upload" | ||
| set -euo pipefail | ||
| [[ "$RUN_ID" =~ ^[0-9]+$ ]] || { |
There was a problem hiding this comment.
suggestion: drop the RUN_ID, HEAD_SHA and PR_NUMBER guards, the script already covers all three.
the first two come straight from github, and the script re-checks the sha anyway
the PR_NUMBER one further down is checking this script's own output - same rule is written twice
There was a problem hiding this comment.
Agree - all three are re-validated in the script, safe to remove.
| return value | ||
|
|
||
|
|
||
| def _validate_ado_url(value) -> str: |
There was a problem hiding this comment.
ado_url comes from ADO's _links.web.href, but this only accepts dev.azure.com, an exact path, and a lone buildId param. If ADO returns extra query params or the *.visualstudio.com host, the comment is silently dropped. Please confirm a real build's href passes these rules
| gh api \ | ||
| -H "Accept: application/vnd.github+json" \ | ||
| "repos/${GITHUB_REPOSITORY}/commits/${HEAD_SHA}/pulls" > "$PULLS_FILE" |
There was a problem hiding this comment.
When this moves to /pulls?state=open (per the resolve fix), add --paginate — the default 30-per-page cap can miss the match on a repo with many open PRs.
| and isinstance(pull.get("number"), int) | ||
| and pull["number"] > 0 | ||
| ] | ||
| if len(matching_pulls) != 1: |
There was a problem hiding this comment.
+1
/commits/{sha}/pulls can't see a fork's head commit, so it's empty every time. /pulls?state=open + head.sha match fixes it with no loss of protection; head_sha still comes from the trusted workflow_run event. Blocking.
| raise ValidationError("workflow run did not originate from a fork") | ||
|
|
||
| event_pulls = workflow_run.get("pull_requests") or [] | ||
| if len(event_pulls) == 1: |
There was a problem hiding this comment.
Agree- fork workflow_run events always send empty pull_requests, so this never fires, and it's the only branch the green happy-path test covers.
| echo "This may indicate an issue with artifact upload" | ||
| set -euo pipefail | ||
| [[ "$RUN_ID" =~ ^[0-9]+$ ]] || { |
There was a problem hiding this comment.
Agree - all three are re-validated in the script, safe to remove.
- forked-pr-coverage.yml: query /pulls?state=open (paginated) instead of
/commits/{sha}/pulls, which never returns a fork's head commit, so fork
PRs never resolved and no coverage comment was ever posted.
- prepare_fork_coverage_comment.py: remove the workflow_run.pull_requests
fast-path; GitHub always sends it empty for fork runs, so it was dead
code that only the happy-path test exercised.
- tests: point the happy-path test at the real head-SHA resolution and add
a regression that an attacker-supplied event pull_requests entry is ignored.
- Revert action SHA pins (checkout, upload-artifact, sticky-comment) to tags
to avoid colliding with #716, which owns SHA pinning.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Work Item / Issue Reference
AB#46466
Summary
Validation