diff --git a/.github/workflows/public-text-audit.yml b/.github/workflows/public-text-audit.yml new file mode 100644 index 0000000..d01a86a --- /dev/null +++ b/.github/workflows/public-text-audit.yml @@ -0,0 +1,29 @@ +# run-tree-ok: text edits need independent audit concurrency and results; +# CLA and application checks have different event and permission contracts. +# Separate workflow and job name: comment success must not satisfy the PR gate. +name: Public text audit + +on: + issues: + types: [opened, edited] + issue_comment: + types: [created, edited] + pull_request_review: + types: [submitted, edited] + pull_request_review_comment: + types: [created, edited] + +permissions: + contents: read + pull-requests: read + issues: read + +concurrency: + group: public-text-audit-${{ github.event_name }}-${{ github.event.comment.id || github.event.review.id || github.event.issue.number }} + cancel-in-progress: true + +jobs: + public-text-audit: + uses: mindsdb/github-actions/.github/workflows/public-text-check.yml@main + secrets: + PUBLIC_TEXT_DENYLIST: ${{ secrets.PUBLIC_TEXT_DENYLIST }} diff --git a/.github/workflows/public-text-check.yml b/.github/workflows/public-text-check.yml new file mode 100644 index 0000000..695ab7d --- /dev/null +++ b/.github/workflows/public-text-check.yml @@ -0,0 +1,36 @@ +name: Public text reusable + +on: + workflow_call: + secrets: + PUBLIC_TEXT_DENYLIST: + description: Private regular expression for restricted public text + required: false + +permissions: + contents: read + pull-requests: read + issues: read + +jobs: + check: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Check out the trusted scanner + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + repository: mindsdb/github-actions + ref: ${{ job.workflow_sha }} + path: .ci-shared + persist-credentials: false + sparse-checkout: scripts/public_text_check.py + sparse-checkout-cone-mode: false + - name: Scan current public text + env: + GH_TOKEN: ${{ github.token }} + PUBLIC_TEXT_DENYLIST: ${{ secrets.PUBLIC_TEXT_DENYLIST }} + REPO: ${{ github.repository }} + EVENT_NAME: ${{ github.event_name }} + EVENT_PATH: ${{ github.event_path }} + run: python3 -I .ci-shared/scripts/public_text_check.py diff --git a/.github/workflows/public-text.yml b/.github/workflows/public-text.yml new file mode 100644 index 0000000..2020a3f --- /dev/null +++ b/.github/workflows/public-text.yml @@ -0,0 +1,22 @@ +# run-tree-ok: PR metadata edits must rerun this stable text gate without +# retriggering application build or deployment pipelines. +name: Public text + +on: + pull_request: + types: [opened, edited, synchronize, reopened] + +permissions: + contents: read + pull-requests: read + issues: read + +concurrency: + group: public-text-pr-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + public-text: + uses: mindsdb/github-actions/.github/workflows/public-text-check.yml@main + secrets: + PUBLIC_TEXT_DENYLIST: ${{ secrets.PUBLIC_TEXT_DENYLIST }} diff --git a/README.md b/README.md index cea57bb..4667a96 100644 --- a/README.md +++ b/README.md @@ -742,3 +742,9 @@ jobs: uses: mindsdb/github-actions/.github/workflows/release-pr.yml@main secrets: inherit ``` + +## Public text guard + +[`public-text-check.yml`](.github/workflows/public-text-check.yml) checks PR text, commit messages, current added content and supported issue/comment/review text against a private regular expression. It checks only API data, runs the trusted script at the reusable workflow's commit, and never prints matching text or paths. Separate PR and audit callers prevent a comment's result from satisfying the merge check. + +See [coverage, exceptions and rollout](docs/public-text.md) before enabling the required check. Missing configuration and incomplete scans fail; fork events carry an explicit unscanned exception. The guard runs after publication and cannot remove history. The implementation and this repository's callers must merge before application callers use it. diff --git a/docs/public-text.md b/docs/public-text.md new file mode 100644 index 0000000..f5016dd --- /dev/null +++ b/docs/public-text.md @@ -0,0 +1,75 @@ +# Public text guard + +The guard fails a PR or text-audit run when current public text matches the private `PUBLIC_TEXT_DENYLIST` regular expression. It reports a field, ordinal and line number without quoting the text or its filename. A failure catches a disclosure after GitHub saves it; the guard cannot prevent publication or erase older revisions. + +## Coverage + +| Event | Text read from the API | Result context | +| --- | --- | --- | +| PR opened, edited, synchronized or reopened | Current title, description, head branch, all commit messages, current added lines and paths | `public-text / check` (verify on the first live run) | +| Issue opened or edited | Current title and description | `public-text-audit / check` | +| Discussion comment created or edited | Current comment body | `public-text-audit / check` | +| Review submitted or edited | Current review summary | `public-text-audit / check` | +| Inline review comment created or edited | Current comment body | `public-text-audit / check` | + +The PR and audit callers have separate workflow names, job names and concurrency groups. A successful comment audit cannot satisfy the PR check. Reruns read current text. The scanner reads the same fields again before reporting success, and fails if they change. A PR run for an older head fails instead of reporting on a newer head under the old check. + +Release trains receive the full scan. The scanner compares paginated collection sizes with the PR's counts. More than 250 commits or 3,000 files exceeds the REST endpoints' limits and fails as incomplete. Duplicate data, malformed hunks and added-line count mismatches also fail. + +Removed paths are excluded so a cleanup can remove a disclosure. Renames check only the new path and added content. A zero-addition, zero-deletion change can omit its patch only when the scanner verifies that its blob equals the base file's blob, or verifies empty/binary content. This permits mode-only changes and unchanged renames without trusting line counts alone. A missing base path fails conservatively. + +For other missing patches, the scanner reads the blob as data, accepts an empty file or a NUL byte within the first 8,000 bytes as binary, and checks only the binary file's path. Blobs larger than 1 MiB fail this fallback and need maintainer review. Git attributes can classify other files as binary; these conservatively fail when no text patch is available. + +Added text is checked in contiguous blocks so a wrapped phrase can match. The scanner does not join additions across intervening unchanged lines. + +## Trust and exceptions + +The reusable checks out only `mindsdb/github-actions` at `job.workflow_sha`, the commit of the called workflow. It never checks out contributor code. The token has only contents, issues and pull-request read permissions. Callers pass only the named secret. Existing repository convention makes callers track `@main`; a changed shared workflow takes effect for every caller after merge. + +| Situation | Behavior | +| --- | --- | +| Same-repository PR with missing or invalid secret | Fails, unscanned | +| Dependabot event without the configured secret | Fails, unscanned; maintainer action required | +| Fork PR, review or inline comment | Explicit successful exception labeled `NOT SCANNED`; maintainer inspection required | +| Discussion comment on a fork PR | Default-branch audit reads and checks the comment | +| API failure, deleted text, unavailable head, incomplete data or concurrent edit | Fails, incomplete or superseded | + +The guard protects against accidental disclosures. Contributors who can alter trusted workflows or use privileged repository settings can bypass it. A fork exception is not a clean scan. This design does not add a privileged `pull_request_target` workflow. + +## Public-writing policy + +Use neutral roles and public component contracts. Do not identify customers, partners or users, or disclose private repository names, links, PR or issue references, release versions or schedules. Generic deployment terminology and public configuration values remain allowed. + +Maintainers supply restricted terms and aliases through the private regular expression. Do not put real terms in this repository's code, tests, workflow configuration, examples or logs. Fixtures use synthetic text only. The regular expression detects configured strings; it cannot infer that an otherwise ordinary version number or schedule refers to a private project. Human review must check that context. + +The secret keeps the configured list out of source, but public pass/fail results can reveal whether a guessed term matches. The check is not a secrecy guarantee for the list. Use a bounded, reviewed expression; pathological regular expressions can exhaust the job timeout. + +## Rollout + +1. Merge the shared scanner, tests, reusable and this repository's callers first. The initial implementation PR cannot resolve its new `@main` reusable until that merge; use the existing CI and local checks for bootstrap review. Do not require the new context before it exists. +2. Merge each application caller into its integration branch, then promote it to the default branch. Issues and discussion-comment events depend on default-branch workflow availability. +3. An operator configures `PUBLIC_TEXT_DENYLIST` as an organization Actions secret visible only to selected repositories. The pattern includes a synthetic canary such as `zqcanary7731`. Never put the pattern into a public ruleset or shell command history. +4. Verify a same-repository PR fails on the canary and passes after an edit removes it. Observe the actual Actions check context before requiring it for default-branch and staging merges. +5. Verify an audit can fail without replacing the PR check. Edit the audited text, rerun the old event, and verify that the latest body is checked. Test the explicit fork exception and missing-secret failure. +6. The operator installs the verified PR context as a required GitHub Actions check. Required-workflow rules alone do not cover edited PR descriptions. + +Workflow authors do not run these operator steps automatically. Missing configuration deliberately blocks same-repository runs. Coordinate the rollout before making the check required. A large release train or historical commit disclosure can block a merge even when the current diff is clean. + +## Limits and response + +GitHub may not deliver these events in every case. PR workflows do not run on merge conflicts; supported CI-skip directives can suppress runs; most writes made with `GITHUB_TOKEN` do not trigger a new workflow. Review these cases manually before merge. A required check cannot make an event happen. + +The scanner does not read intermediate commit file content, binary content, release descriptions, old text revisions or every existing issue and comment. Added-then-removed content can remain in commit history. A clean result concerns the configured pattern and scanned fields only. Editing a current description leaves older revisions visible; the repository owner handles history cleanup and pushed-commit rewrites separately. + +Actions notifications depend on each person's notification settings. A failed audit creates a failed run, not a guaranteed message to its author. This workflow posts no public comments and sends no chat messages. + +## Local verification + +```sh +uv run --with pyyaml --with pytest --python 3.12 -m pytest tests/ -q +actionlint +uv run --with pyyaml --python 3.12 scripts/workflow_graph.py --workflow-dir .github/workflows --allow-external-reusables +zizmor .github/workflows +``` + +Unit tests use synthetic API responses and patterns, exercise incomplete and stale data, and assert that failures never echo sensitive text. The live event matrix remains an operator acceptance step after publication. diff --git a/scripts/public_text_check.py b/scripts/public_text_check.py new file mode 100644 index 0000000..7822b4f --- /dev/null +++ b/scripts/public_text_check.py @@ -0,0 +1,291 @@ +#!/usr/bin/env python3 +"""Check public GitHub text without echoing text, paths or the private pattern. + +Exit 0: clean or the explicit fork exception. Exit 1: match. Exit 2: incomplete. +Only API data is read. No contributor code is checked out or executed. +""" +from __future__ import annotations + +import base64 +import json +import os +import re +import subprocess +from dataclasses import dataclass +from urllib.parse import quote + + +class ScanError(Exception): + """A scan cannot establish a complete, stable result.""" + + +@dataclass(frozen=True) +class Finding: + where: str + + +@dataclass(frozen=True) +class PullRequest: + title: str + body: str + branch: str + head: str + base: str + head_repo: str + commits: int + files: int + + @classmethod + def read(cls, value: dict) -> PullRequest: + return cls( + text(value["title"]), text(value.get("body")), text(value["head"]["ref"]), + sha(value["head"]["sha"]), sha(value["base"]["sha"]), + text(value["head"]["repo"]["full_name"]), + count(value["commits"]), count(value["changed_files"]), + ) + + +def text(value: object) -> str: + if value is None: + return "" + if not isinstance(value, str): + raise ScanError("invalid text") + return value + + +def count(value: object) -> int: + if type(value) is not int or value < 0: + raise ScanError("invalid count") + return value + + +def identifier(value: object) -> int: + if count(value) == 0: + raise ScanError("invalid identifier") + return value + + +def sha(value: object) -> str: + if not isinstance(value, str) or not re.fullmatch(r"[a-f0-9]{40}", value): + raise ScanError("invalid revision") + return value + + +def gh_api(path: str) -> list | dict: + result = subprocess.run( + ["gh", "api", "--paginate", "--slurp", path], + check=True, capture_output=True, text=True, timeout=90, + ) + pages = json.loads(result.stdout) + if not isinstance(pages, list) or not pages: + raise ScanError("empty response") + if all(isinstance(page, list) for page in pages): + return [item for page in pages for item in page] + if len(pages) == 1 and isinstance(pages[0], dict): + return pages[0] + raise ScanError("invalid pagination") + + +def check_text(rx: re.Pattern[str], *, label: str, text: str) -> list[Finding]: + lines = sorted({text.count("\n", 0, match.start()) + 1 for match in rx.finditer(text)}) + return [Finding(f"{label}, line {line}") for line in lines] + + +def added_lines(patch: str) -> list[tuple[int, str]]: + """Parse complete unified hunks, rejecting omitted or malformed content.""" + added: list[tuple[int, str]] = [] + old_left = new_left = 0 + new_line = 0 + in_hunk = False + for line in patch.split("\n"): + hunk = re.fullmatch(r"@@ -\d+(?:,(\d+))? \+(\d+)(?:,(\d+))? @@.*", line) + if hunk: + if old_left or new_left: + raise ScanError("truncated hunk") + old_left = int(hunk[1] or 1) + new_line = int(hunk[2]) + new_left = int(hunk[3] or 1) + in_hunk = True + elif line == r"\ No newline at end of file": + continue + elif not line and not old_left and not new_left: + continue + elif not in_hunk or not line: + raise ScanError("invalid hunk") + elif line[0] == "+": + added.append((new_line, line[1:])) + new_line += 1 + new_left -= 1 + elif line[0] == "-": + old_left -= 1 + elif line[0] == " ": + old_left -= 1 + new_left -= 1 + new_line += 1 + else: + raise ScanError("invalid hunk line") + if old_left < 0 or new_left < 0: + raise ScanError("hunk overflow") + if old_left or new_left or not in_hunk: + raise ScanError("incomplete patch") + return added + + +def missing_patch_is_empty_or_binary(repo: str, file: dict) -> bool: + # Never trust the extension. NUL in the first 8 KiB follows Git's binary + # heuristic; other missing patches fail closed. Large blobs need human review. + blob = gh_api(f"repos/{repo}/git/blobs/{sha(file['sha'])}") + size = count(blob["size"]) + if size > 1_048_576 or blob["encoding"] != "base64": + return False + content = base64.b64decode("".join(text(blob["content"]).split()), validate=True) + if len(content) != size: + return False + return (not content and count(file["additions"]) == 0) or b"\0" in content[:8000] + + +def check_added(rx: re.Pattern[str], lines: list[tuple[int, str]], ordinal: int) -> list[Finding]: + blocks: list[list[tuple[int, str]]] = [] + for line in lines: + if not blocks or line[0] != blocks[-1][-1][0] + 1: + blocks.append([]) + blocks[-1].append(line) + findings = [] + for block in blocks: + content = "\n".join(value for _, value in block) + numbers = {block[0][0] + content.count("\n", 0, match.start()) for match in rx.finditer(content)} + findings.extend(Finding(f"file {ordinal}, added line {line}") for line in sorted(numbers)) + return findings + + +def content_unchanged(repo: str, base: str, file: dict) -> bool: + if (file["status"] not in {"modified", "renamed", "changed"} + or count(file["additions"]) or count(file["deletions"])): + return False + path = text(file["previous_filename"] if file["status"] == "renamed" else file["filename"]) + original = gh_api(f"repos/{repo}/contents/{quote(path, safe='/')}?ref={base}") + return original["type"] == "file" and sha(original["sha"]) == sha(file["sha"]) + + +def check_pr(rx: re.Pattern[str], *, repo: str, number: int, expected_head: str | None = None) -> list[Finding]: + endpoint = f"repos/{repo}/pulls/{identifier(number)}" + before = PullRequest.read(gh_api(endpoint)) + if before.head_repo != repo or (expected_head and before.head != expected_head): + raise ScanError("superseded or foreign head") + # REST caps these collections even when pagination follows every page. + if before.commits > 250 or before.files > 3000: + raise ScanError("collection cap") + findings = check_text(rx, label="PR title", text=before.title) + findings += check_text(rx, label="PR description", text=before.body) + findings += check_text(rx, label="head branch name", text=before.branch) + commits = gh_api(endpoint + "/commits?per_page=100") + files = gh_api(endpoint + "/files?per_page=100") + if not isinstance(commits, list) or len(commits) != before.commits: + raise ScanError("incomplete commits") + if not isinstance(files, list) or len(files) != before.files: + raise ScanError("incomplete files") + if len({sha(commit["sha"]) for commit in commits}) != len(commits): + raise ScanError("duplicate commits") + if len({text(file["filename"]) for file in files}) != len(files): + raise ScanError("duplicate files") + for i, commit in enumerate(commits, 1): + findings += check_text(rx, label=f"commit {i} message", text=text(commit["commit"]["message"])) + for i, file in enumerate(files, 1): + if file["status"] == "removed": + continue + if file["status"] not in {"added", "modified", "renamed", "copied", "changed", "unchanged"}: + raise ScanError("unknown file status") + findings += check_text(rx, label=f"file {i} path", text=text(file["filename"])) + patch = file.get("patch") + if not patch: + if content_unchanged(repo, before.base, file): + continue + if missing_patch_is_empty_or_binary(repo, file): + continue + raise ScanError("missing text patch") + lines = added_lines(text(patch)) + if len(lines) != count(file["additions"]): + raise ScanError("incomplete added lines") + findings += check_added(rx, lines, i) + if before != PullRequest.read(gh_api(endpoint)): + raise ScanError("PR changes during scan") + return findings + + +def is_fork_event(repo: str, event_name: str, event: dict) -> bool: + if event_name not in {"pull_request", "pull_request_review", "pull_request_review_comment"}: + return False + pull = event["pull_request"] + # A deleted head repository is not evidence of a fork exception. + head_repo = pull["head"]["repo"]["full_name"] + base_repo = pull["base"]["repo"]["full_name"] + if not head_repo or base_repo != repo: + raise ScanError("invalid repository identity") + return head_repo != repo + + +def check_event(rx: re.Pattern[str], *, repo: str, event_name: str, event: dict) -> list[Finding]: + if event_name == "pull_request": + pull = event["pull_request"] + return check_pr(rx, repo=repo, number=pull["number"], expected_head=sha(pull["head"]["sha"])) + if event_name == "issues": + endpoint = f"repos/{repo}/issues/{identifier(event['issue']['number'])}" + labels = (("title", "issue title"), ("body", "issue description")) + elif event_name == "issue_comment": + endpoint = f"repos/{repo}/issues/comments/{identifier(event['comment']['id'])}" + labels = (("body", "comment"),) + elif event_name == "pull_request_review": + endpoint = (f"repos/{repo}/pulls/{identifier(event['pull_request']['number'])}" + f"/reviews/{identifier(event['review']['id'])}") + labels = (("body", "review summary"),) + elif event_name == "pull_request_review_comment": + endpoint = f"repos/{repo}/pulls/comments/{identifier(event['comment']['id'])}" + labels = (("body", "review comment"),) + else: + raise ScanError("unsupported event") + before = gh_api(endpoint) + fields = tuple(text(before[key]) for key, _ in labels) + findings = [finding for (_, label), value in zip(labels, fields) + for finding in check_text(rx, label=label, text=value)] + after = gh_api(endpoint) + if fields != tuple(text(after[key]) for key, _ in labels): + raise ScanError("text changes during scan") + return findings + + +def main() -> int: + try: + repo, event_name = os.environ["REPO"], os.environ["EVENT_NAME"] + if not re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", repo): + raise ScanError("invalid repository") + with open(os.environ["EVENT_PATH"], encoding="utf-8") as handle: + event = json.load(handle) + if is_fork_event(repo, event_name, event): + print("::notice::Fork event: NOT SCANNED. A maintainer must inspect public text before merging.") + return 0 + pattern = os.environ.get("PUBLIC_TEXT_DENYLIST", "") + if not pattern.strip(): + print("::error::Public-text configuration is missing. This event is NOT SCANNED.") + return 2 + try: + rx = re.compile(pattern, re.I) + except re.error: + print("::error::Public-text configuration is invalid. This event is NOT SCANNED.") + return 2 + findings = check_event(rx, repo=repo, event_name=event_name, event=event) + except Exception: + # API stderr, malformed input and exception messages can contain restricted + # text or annotation syntax. Do not print any part of them, even on failure. + print("::error::Public-text scan is incomplete or superseded. Review configuration, API access and data limits; rerun on current text.") + return 2 + for finding in findings: + print(f"::error::Restricted text in {finding.where}") + if findings: + print("Replace restricted text with a neutral description. Older revisions and pushed history need separate owner review.") + return 1 + print("Current public text passes the configured scan. Binary content and intermediate commit file content are outside its scope.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_public_text_check.py b/tests/test_public_text_check.py new file mode 100644 index 0000000..05804da --- /dev/null +++ b/tests/test_public_text_check.py @@ -0,0 +1,356 @@ +"""Synthetic fixtures only: never put actual restricted terms in this file.""" + +import copy +import base64 +import json +import re +import subprocess +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts")) +import public_text_check as ptc + +RX = re.compile("zqcanary7731", re.I) +PR_PATH = "repos/o/r/pulls/7" +SHA = "a" * 40 + + +def pr(): + return { + "title": "fix", "body": "", "number": 7, "commits": 1, "changed_files": 1, + "head": {"ref": "topic", "sha": SHA, "repo": {"full_name": "o/r"}}, + "base": {"sha": "b" * 40, "repo": {"full_name": "o/r"}}, + } + + +def responses(): + return { + PR_PATH: pr(), + PR_PATH + "/commits?per_page=100": [ + {"sha": SHA, "commit": {"message": "clean"}} + ], + PR_PATH + "/files?per_page=100": [ + {"filename": "file.txt", "status": "added", "additions": 1, "deletions": 0, + "sha": "c" * 40, "patch": "@@ -0,0 +1 @@\n+clean"} + ], + } + + +def mock_api(monkeypatch, data): + monkeypatch.setattr(ptc, "gh_api", lambda path: copy.deepcopy(data[path])) + + +def run_main(monkeypatch, tmp_path, event_name="pull_request", event=None, pattern="zqcanary7731"): + path = tmp_path / "event.json" + path.write_text(json.dumps(event or {"pull_request": pr()})) + for name, value in { + "REPO": "o/r", "EVENT_NAME": event_name, "EVENT_PATH": str(path), + "PUBLIC_TEXT_DENYLIST": pattern, + }.items(): + monkeypatch.setenv(name, value) + return ptc.main() + + +def test_missing_secret_fails_same_repository(monkeypatch, tmp_path): + assert run_main(monkeypatch, tmp_path, pattern="") == 2 + + +def test_release_train_scans_commits(monkeypatch): + data = responses() + data[PR_PATH]["head"]["ref"] = "staging" + data[PR_PATH + "/commits?per_page=100"][0]["commit"]["message"] = "zqcanary7731" + mock_api(monkeypatch, data) + assert any("message" in f.where for f in ptc.check_pr(RX, repo="o/r", number=7)) + + +def test_missing_text_patch_never_passes(monkeypatch, tmp_path): + data = responses() + del data[PR_PATH + "/files?per_page=100"][0]["patch"] + mock_api(monkeypatch, data) + assert run_main(monkeypatch, tmp_path) == 2 + + +def test_commit_cap_never_passes(monkeypatch, tmp_path): + data = responses() + data[PR_PATH]["commits"] = 251 + mock_api(monkeypatch, data) + assert run_main(monkeypatch, tmp_path) == 2 + + +def test_clean_paths_are_not_logged(monkeypatch): + data = responses() + data[PR_PATH + "/files?per_page=100"][0].update( + filename="attacker%0A::notice::secret.txt", patch="@@ -0,0 +1 @@\n+zqcanary7731" + ) + mock_api(monkeypatch, data) + found = ptc.check_pr(RX, repo="o/r", number=7) + assert found and all("attacker" not in f.where for f in found) + + +def test_rerun_reads_current_comment(monkeypatch, tmp_path): + mock_api(monkeypatch, {"repos/o/r/issues/comments/12": {"body": "zqcanary7731"}}) + assert run_main(monkeypatch, tmp_path, "issue_comment", + {"comment": {"id": 12, "body": "formerly clean"}}) == 1 + + +def test_removed_path_can_clean_existing_disclosure(monkeypatch): + data = responses() + data[PR_PATH + "/files?per_page=100"][0].update( + filename="zqcanary7731.txt", status="removed", additions=0, + patch="@@ -1 +0,0 @@\n-zqcanary7731" + ) + mock_api(monkeypatch, data) + assert ptc.check_pr(RX, repo="o/r", number=7) == [] + + +@pytest.mark.parametrize("field", ["title", "body", "branch", "commit", "path", "added"]) +def test_all_pr_fields_fail_without_echoing_text(monkeypatch, tmp_path, capsys, field): + data = responses() + if field in {"title", "body"}: + data[PR_PATH][field] = "zqcanary7731" + elif field == "branch": + data[PR_PATH]["head"]["ref"] = "fix/zqcanary7731" + elif field == "commit": + data[PR_PATH + "/commits?per_page=100"][0]["commit"]["message"] = "zqcanary7731" + elif field == "path": + data[PR_PATH + "/files?per_page=100"][0]["filename"] = "zqcanary7731" + else: + data[PR_PATH + "/files?per_page=100"][0]["patch"] = "@@ -0,0 +1 @@\n+zqcanary7731" + mock_api(monkeypatch, data) + assert run_main(monkeypatch, tmp_path) == 1 + output = capsys.readouterr() + assert "zqcanary7731" not in output.out + output.err + + +def test_clean_pr_and_generic_deployment_terminology_pass(monkeypatch, tmp_path): + data = responses() + data[PR_PATH]["body"] = "self-hosted deployment with COWORK_DEPLOYMENT_KIND=self-hosted" + mock_api(monkeypatch, data) + assert run_main(monkeypatch, tmp_path) == 0 + + +@pytest.mark.parametrize("event_name", ["pull_request", "pull_request_review", "pull_request_review_comment"]) +def test_explicit_fork_exception_without_secret(monkeypatch, tmp_path, capsys, event_name): + pull = pr() + pull["head"]["repo"]["full_name"] = "outside/fork" + assert run_main(monkeypatch, tmp_path, event_name, {"pull_request": pull}, pattern="") == 0 + assert "NOT SCANNED" in capsys.readouterr().out + + +def test_fork_discussion_comment_is_scanned(monkeypatch, tmp_path): + mock_api(monkeypatch, {"repos/o/r/issues/comments/12": {"body": "zqcanary7731"}}) + event = {"comment": {"id": 12}, "issue": {"pull_request": {}}} + assert run_main(monkeypatch, tmp_path, "issue_comment", event) == 1 + + +def test_dependabot_without_configuration_fails(monkeypatch, tmp_path): + event = {"pull_request": pr(), "sender": {"login": "dependabot[bot]"}} + assert run_main(monkeypatch, tmp_path, event=event, pattern="") == 2 + + +@pytest.mark.parametrize("pattern", ["", " ", "zqcanary7731("]) +def test_invalid_configuration_never_echoes_pattern(monkeypatch, tmp_path, capsys, pattern): + assert run_main(monkeypatch, tmp_path, pattern=pattern) == 2 + assert "zqcanary7731" not in capsys.readouterr().out + + +@pytest.mark.parametrize("field,value", [("commits", 2), ("changed_files", 2), ("changed_files", 3001)]) +def test_incomplete_collections_fail(monkeypatch, tmp_path, field, value): + data = responses() + data[PR_PATH][field] = value + mock_api(monkeypatch, data) + assert run_main(monkeypatch, tmp_path) == 2 + + +@pytest.mark.parametrize("collection,field", [("commits", "commits"), ("files", "changed_files")]) +def test_duplicate_page_data_fails(monkeypatch, tmp_path, collection, field): + data = responses() + data[PR_PATH][field] = 2 + data[PR_PATH + f"/{collection}?per_page=100"] *= 2 + mock_api(monkeypatch, data) + assert run_main(monkeypatch, tmp_path) == 2 + + +def test_truncated_patch_fails(monkeypatch, tmp_path): + data = responses() + data[PR_PATH + "/files?per_page=100"][0]["additions"] = 2 + mock_api(monkeypatch, data) + assert run_main(monkeypatch, tmp_path) == 2 + + +@pytest.mark.parametrize("patch", [ + "@@ -0,0 +1,2 @@\n+clean", "truncated", "@@ -0,0 +1 @@\n+one\n+extra", + "@@ -1 +1 @@\n", "@@ -0,0 +1,2 @@\n+clean\n@@ -0,0 +3 @@\n+clean", +]) +def test_malformed_hunks_fail(patch): + with pytest.raises(ptc.ScanError): + ptc.added_lines(patch) + + +def test_hunks_handle_literal_prefixes_crlf_and_no_newline(): + patch = "@@ -1,2 +1,3 @@\n ctx\r\n-old\r\n+++new\r\n+tail\n\\ No newline at end of file" + assert ptc.added_lines(patch) == [(2, "++new\r"), (3, "tail")] + + +def blob(data, content): + data["repos/o/r/git/blobs/" + "c" * 40] = { + "size": len(content), "encoding": "base64", + "content": base64.b64encode(content).decode(), + } + data[PR_PATH + "/files?per_page=100"][0].pop("patch") + + +def test_binary_content_is_excluded_but_path_is_checked(monkeypatch, tmp_path): + data = responses() + blob(data, b"binary\0zqcanary7731") + mock_api(monkeypatch, data) + assert run_main(monkeypatch, tmp_path) == 0 + data[PR_PATH + "/files?per_page=100"][0]["filename"] = "zqcanary7731.png" + assert run_main(monkeypatch, tmp_path) == 1 + + +def test_text_with_binary_extension_is_not_exempt(monkeypatch, tmp_path): + data = responses() + blob(data, b"zqcanary7731") + data[PR_PATH + "/files?per_page=100"][0]["filename"] = "logo.png" + mock_api(monkeypatch, data) + assert run_main(monkeypatch, tmp_path) == 2 + + +def test_empty_file_passes(monkeypatch, tmp_path): + data = responses() + blob(data, b"") + data[PR_PATH + "/files?per_page=100"][0]["additions"] = 0 + mock_api(monkeypatch, data) + assert run_main(monkeypatch, tmp_path) == 0 + + +def test_rename_away_from_restricted_path_passes(monkeypatch, tmp_path): + data = responses() + file = data[PR_PATH + "/files?per_page=100"][0] + file.update(status="renamed", previous_filename="zqcanary7731.txt", additions=0, deletions=0) + file.pop("patch") + data["repos/o/r/contents/zqcanary7731.txt?ref=" + "b" * 40] = {"type": "file", "sha": "c" * 40} + mock_api(monkeypatch, data) + assert run_main(monkeypatch, tmp_path) == 0 + + +@pytest.mark.parametrize("change", ["title", "body", "head", "base", "branch"]) +def test_pr_changes_during_scan_fail(monkeypatch, tmp_path, change): + data = responses() + reads = 0 + + def api(path): + nonlocal reads + value = copy.deepcopy(data[path]) + if path == PR_PATH: + reads += 1 + if reads > 1: + if change in {"title", "body"}: + value[change] = "zqcanary7731" + elif change == "branch": + value["head"]["ref"] = "changed" + else: + value[change]["sha"] = "d" * 40 + return value + + monkeypatch.setattr(ptc, "gh_api", api) + assert run_main(monkeypatch, tmp_path) == 2 + + +def test_old_head_rerun_cannot_pass_for_a_new_head(monkeypatch, tmp_path): + data = responses() + data[PR_PATH]["head"]["sha"] = "d" * 40 + mock_api(monkeypatch, data) + assert run_main(monkeypatch, tmp_path) == 2 + + +@pytest.mark.parametrize("name,event,endpoint,value", [ + ("issues", {"issue": {"number": 3}}, "issues/3", {"title": "zqcanary7731", "body": ""}), + ("pull_request_review", {"pull_request": pr(), "review": {"id": 4}}, + "pulls/7/reviews/4", {"body": "zqcanary7731"}), + ("pull_request_review_comment", {"pull_request": pr(), "comment": {"id": 5}}, + "pulls/comments/5", {"body": "zqcanary7731"}), +]) +def test_current_text_audits(monkeypatch, tmp_path, name, event, endpoint, value): + mock_api(monkeypatch, {"repos/o/r/" + endpoint: value}) + assert run_main(monkeypatch, tmp_path, name, event) == 1 + + +def test_comment_changes_during_scan_fail(monkeypatch, tmp_path): + values = iter([{"body": "clean"}, {"body": "zqcanary7731"}]) + monkeypatch.setattr(ptc, "gh_api", lambda _: next(values)) + assert run_main(monkeypatch, tmp_path, "issue_comment", {"comment": {"id": 12}}) == 2 + + +@pytest.mark.parametrize("error", [ + subprocess.CalledProcessError(1, ["gh"], stderr="zqcanary7731\n::notice::poison"), + ValueError("zqcanary7731"), + KeyError("zqcanary7731"), + subprocess.TimeoutExpired("zqcanary7731", 90), +]) +def test_failures_never_echo_data(monkeypatch, tmp_path, capsys, error): + def fail(_): + raise error + monkeypatch.setattr(ptc, "gh_api", fail) + assert run_main(monkeypatch, tmp_path) == 2 + output = capsys.readouterr() + assert "zqcanary7731" not in output.out + output.err + assert "poison" not in output.out + output.err + + +def test_pagination_flattens_every_page(monkeypatch): + def run(args, **kwargs): + assert args[:4] == ["gh", "api", "--paginate", "--slurp"] + assert kwargs["capture_output"] and kwargs["timeout"] + return subprocess.CompletedProcess(args, 0, json.dumps([[{"id": 1}], [{"id": 2}]])) + monkeypatch.setattr(ptc.subprocess, "run", run) + assert ptc.gh_api("repos/o/r/issues") == [{"id": 1}, {"id": 2}] + + +def test_mode_only_text_change_passes(monkeypatch, tmp_path): + data = responses() + blob(data, b"#!/bin/sh\necho clean\n") + data[PR_PATH + "/files?per_page=100"][0].update(status="modified", additions=0, deletions=0) + data["repos/o/r/contents/file.txt?ref=" + "b" * 40] = {"type": "file", "sha": "c" * 40} + mock_api(monkeypatch, data) + assert run_main(monkeypatch, tmp_path) == 0 + + +def test_multiline_pattern_matches_contiguous_added_lines(monkeypatch): + data = responses() + data[PR_PATH + "/files?per_page=100"][0].update( + additions=2, patch="@@ -0,0 +1,2 @@\n+zqcanary\n+7731" + ) + mock_api(monkeypatch, data) + found = ptc.check_pr(re.compile(r"zqcanary\s+7731"), repo="o/r", number=7) + assert [f.where for f in found] == ["file 1, added line 1"] + + +def test_multiline_pattern_does_not_join_across_context(monkeypatch): + data = responses() + data[PR_PATH + "/files?per_page=100"][0].update( + additions=2, patch="@@ -1 +1,3 @@\n+zqcanary\n context\n+7731" + ) + mock_api(monkeypatch, data) + assert ptc.check_pr(re.compile(r"zqcanary\s+7731"), repo="o/r", number=7) == [] + + +def test_zero_counts_do_not_exempt_new_text_classified_as_binary(monkeypatch, tmp_path): + data = responses() + blob(data, b"zqcanary7731") + data[PR_PATH + "/files?per_page=100"][0].update(additions=0, deletions=0) + mock_api(monkeypatch, data) + assert run_main(monkeypatch, tmp_path) == 2 + + +def test_zero_counts_with_changed_blob_fail(monkeypatch, tmp_path): + data = responses() + blob(data, b"zqcanary7731") + data[PR_PATH + "/files?per_page=100"][0].update(status="modified", additions=0, deletions=0) + data["repos/o/r/contents/file.txt?ref=" + "b" * 40] = {"type": "file", "sha": "d" * 40} + mock_api(monkeypatch, data) + assert run_main(monkeypatch, tmp_path) == 2