diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..800498342 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,7 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 5 diff --git a/.github/scripts/semgrep_ci.py b/.github/scripts/semgrep_ci.py new file mode 100644 index 000000000..ee916582e --- /dev/null +++ b/.github/scripts/semgrep_ci.py @@ -0,0 +1,176 @@ +#!/usr/bin/env python3 + +import argparse +import json +import subprocess +from pathlib import Path + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Run Semgrep for a pull request and generate reviewer-facing output." + ) + parser.add_argument("--repo", required=True, help="GitHub repository in owner/name form.") + parser.add_argument("--head-sha", required=True, help="Head commit SHA for the pull request.") + parser.add_argument("--base-sha", required=True, help="Base commit SHA for the pull request.") + parser.add_argument("--report", required=True, help="Path to write the Semgrep JSON report.") + parser.add_argument("--comment", required=True, help="Path to write the PR comment markdown.") + parser.add_argument( + "--github-output", + required=True, + help="Path to the GitHub Actions output file.", + ) + parser.add_argument( + "--job-summary", + required=True, + help="Path to the GitHub Actions job summary file.", + ) + parser.add_argument( + "--finding-limit", + type=int, + default=10, + help="Maximum number of findings to include in the PR comment.", + ) + return parser.parse_args() + + +def run_semgrep(report_path: Path, base_sha: str) -> int: + command = [ + "semgrep", + "scan", + "--config", + "p/default", + "--error", + "--disable-version-check", + "--baseline-commit", + base_sha, + "--json", + "--output", + str(report_path), + ".", + ] + return subprocess.run(command, check=False).returncode + + +def load_results(report_path: Path) -> list[dict]: + if not report_path.exists(): + report_path.write_text('{"results":[]}\n', encoding="utf-8") + return [] + + try: + payload = json.loads(report_path.read_text(encoding="utf-8")) + except json.JSONDecodeError: + report_path.write_text('{"results":[]}\n', encoding="utf-8") + return [] + + return payload.get("results", []) + + +def changed_files_count(base_sha: str) -> int: + result = subprocess.run( + ["git", "diff", "--name-only", f"{base_sha}...HEAD"], + check=False, + capture_output=True, + text=True, + ) + if result.returncode != 0: + return 0 + return len([line for line in result.stdout.splitlines() if line.strip()]) + + +def status_line(exit_code: int, findings: int) -> str: + if exit_code == 0: + return "no blocking findings" + if findings > 0: + return "blocking findings detected" + return f"Semgrep exited with status {exit_code}" + + +def finding_lines( + results: list[dict], repo: str, head_sha: str, finding_limit: int +) -> list[str]: + lines = [] + for result in results[:finding_limit]: + path = result.get("path", "unknown") + start = result.get("start", {}) + line = start.get("line", 1) + check_id = result.get("check_id", "semgrep") + message = result.get("extra", {}).get("message", "Semgrep finding") + url = f"https://github.com/{repo}/blob/{head_sha}/{path}#L{line}" + lines.append(f"- [`{path}:{line}`]({url}) - **{check_id}**: {message}") + return lines + + +def build_comment( + repo: str, + head_sha: str, + base_sha: str, + results: list[dict], + exit_code: int, + changed_files: int, + finding_limit: int, +) -> str: + findings = len(results) + lines = [ + "## Semgrep Results", + "", + f"- Findings in changed files: {findings}", + f"- Changed files in pull request: {changed_files}", + f"- Baseline commit: `{base_sha}`", + f"- Status: {status_line(exit_code, findings)}", + "", + ] + + if findings > 0: + lines.append("### Findings") + lines.extend(finding_lines(results, repo, head_sha, finding_limit)) + if findings > finding_limit: + lines.extend( + [ + "", + f"_Showing first {finding_limit} findings. Full report is attached as `semgrep-results`._", + ] + ) + elif exit_code != 0: + lines.append("Semgrep did not return findings, but the scan exited unsuccessfully. See the workflow logs.") + else: + lines.append("No Semgrep findings were introduced in the files changed by this pull request.") + + return "\n".join(lines) + "\n" + + +def write_github_outputs(output_path: Path, exit_code: int, findings: int) -> None: + with output_path.open("a", encoding="utf-8") as handle: + handle.write(f"exit_code={exit_code}\n") + handle.write(f"findings={findings}\n") + + +def main() -> int: + args = parse_args() + report_path = Path(args.report) + comment_path = Path(args.comment) + + exit_code = run_semgrep(report_path, args.base_sha) + results = load_results(report_path) + changed_files = changed_files_count(args.base_sha) + findings = len(results) + + comment = build_comment( + repo=args.repo, + head_sha=args.head_sha, + base_sha=args.base_sha, + results=results, + exit_code=exit_code, + changed_files=changed_files, + finding_limit=args.finding_limit, + ) + + comment_path.write_text(comment, encoding="utf-8") + with Path(args.job_summary).open("a", encoding="utf-8") as handle: + handle.write(comment) + write_github_outputs(Path(args.github_output), exit_code, findings) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/workflows/gradle-dependency-submission.yml b/.github/workflows/gradle-dependency-submission.yml new file mode 100644 index 000000000..d4cd8d8c6 --- /dev/null +++ b/.github/workflows/gradle-dependency-submission.yml @@ -0,0 +1,37 @@ +name: Gradle Dependency Submission + +on: + push: + branches: + - main + paths: + - server/** + - .github/workflows/gradle-dependency-submission.yml + +permissions: + contents: write + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + dependency-submission: + name: Submit Server Dependency Graph + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Setup Java + uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0 + with: + distribution: temurin + java-version: '21' + + - name: Submit Gradle dependency graph + uses: gradle/actions/dependency-submission@39e147cb9de83bb9910b8ef8bd7fff0ee20fcd6f # v6.0.1 + with: + build-root-directory: server + dependency-graph: generate-and-submit + validate-wrappers: true diff --git a/.github/workflows/semgrep.yml b/.github/workflows/semgrep.yml new file mode 100644 index 000000000..5bdfbd5c2 --- /dev/null +++ b/.github/workflows/semgrep.yml @@ -0,0 +1,66 @@ +name: Semgrep + +on: + pull_request: + branches: + - main + +permissions: + contents: read + pull-requests: write + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + semgrep: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + + - name: Setup Python + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: '3.13' + cache: pip + + - name: Install Semgrep + run: python -m pip install --upgrade pip semgrep==1.156.0 + + - name: Run Semgrep + id: semgrep + env: + SEMGREP_SEND_METRICS: off + run: >- + python .github/scripts/semgrep_ci.py + --repo "${{ github.repository }}" + --head-sha "${{ github.event.pull_request.head.sha }}" + --base-sha "${{ github.event.pull_request.base.sha }}" + --report semgrep.json + --comment semgrep-comment.md + --github-output "$GITHUB_OUTPUT" + --job-summary "$GITHUB_STEP_SUMMARY" + + - name: Comment on pull request + if: always() + uses: marocchino/sticky-pull-request-comment@70d2764d1a7d5d9560b100cbea0077fc8f633987 # v3.0.2 + with: + header: semgrep + path: semgrep-comment.md + skip_unchanged: true + + - name: Upload Semgrep report + if: always() + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: semgrep-results + path: semgrep.json + + - name: Enforce findings + if: always() + run: exit "${{ steps.semgrep.outputs.exit_code }}"