diff --git a/.github/workflows/fork-ci.yml b/.github/workflows/fork-ci.yml new file mode 100644 index 0000000000..d836d8e528 --- /dev/null +++ b/.github/workflows/fork-ci.yml @@ -0,0 +1,110 @@ +name: Pinvou fork CI + +on: + pull_request: + branches: [pinvou3-clean] + push: + branches: [pinvou3-clean] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: fork-ci-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + check: + name: check + runs-on: ubuntu-latest + timeout-minutes: 120 + env: + CARGO_BUILD_JOBS: '1' + CARGO_PROFILE_DEV_DEBUG: '0' + CARGO_PROFILE_TEST_DEBUG: '0' + CARGO_INCREMENTAL: '0' + RUST_MIN_STACK: '16777216' + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - name: Install Linux dependencies + run: | + sudo apt-get update + sudo apt-get install -y libdbus-1-dev pkg-config + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + - uses: Swatinem/rust-cache@v2 + with: + cache-bin: false + save-if: ${{ github.event_name == 'push' }} + - name: Check formatting + run: cargo fmt --all -- --check + - name: Lint workspace + run: >- + cargo clippy --workspace --all-features --locked -- + -D warnings + -A clippy::uninlined_format_args + -A clippy::too_many_arguments + -A clippy::unnecessary_map_or + -A clippy::collapsible_if + -A clippy::assertions_on_constants + - uses: taiki-e/install-action@nextest + - name: Test workspace + run: cargo nextest run --workspace --all-features --locked --profile ci --test-threads 2 + - name: Test documentation + run: cargo test --workspace --all-features --locked --doc + - name: Check lockfile drift + run: git diff --exit-code -- Cargo.lock + + gitleaks: + name: Gitleaks + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + persist-credentials: false + - name: Install verified Gitleaks release + shell: bash + run: | + curl --fail --location --retry 3 --output "$RUNNER_TEMP/gitleaks.tar.gz" \ + https://github.com/gitleaks/gitleaks/releases/download/v8.30.1/gitleaks_8.30.1_linux_x64.tar.gz + printf '%s %s\n' \ + 551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb \ + "$RUNNER_TEMP/gitleaks.tar.gz" | sha256sum --check --strict + tar -xzf "$RUNNER_TEMP/gitleaks.tar.gz" -C "$RUNNER_TEMP" gitleaks + - name: Verify fixture exceptions remain narrow + run: python3 scripts/check-gitleaks-config.py "$RUNNER_TEMP/gitleaks" + - name: Scan checked-out tree + run: '"$RUNNER_TEMP/gitleaks" dir . --redact --no-banner' + - name: Scan introduced commits + env: + BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }} + shell: bash + run: | + # New branches and manual runs have no base: scan all reachable history. + if [[ -z "$BASE_SHA" || "$BASE_SHA" =~ ^0+$ ]]; then + range=HEAD + else + range="$BASE_SHA..HEAD" + fi + "$RUNNER_TEMP/gitleaks" git . --log-opts="$range" --redact --no-banner + + gate: + name: gate + if: always() + needs: [check, gitleaks] + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Require successful validation + env: + CHECK_RESULT: ${{ needs.check.result }} + GITLEAKS_RESULT: ${{ needs.gitleaks.result }} + run: | + test "$CHECK_RESULT" = success + test "$GITLEAKS_RESULT" = success diff --git a/.github/workflows/pr-gate.yml b/.github/workflows/pr-gate.yml index 7ace3d1af8..f0b189b449 100644 --- a/.github/workflows/pr-gate.yml +++ b/.github/workflows/pr-gate.yml @@ -16,6 +16,7 @@ env: jobs: gate: + name: Contribution intake runs-on: ubuntu-latest steps: - name: Gate unapproved external pull requests diff --git a/.gitleaks.toml b/.gitleaks.toml new file mode 100644 index 0000000000..7ffbf11507 --- /dev/null +++ b/.gitleaks.toml @@ -0,0 +1,58 @@ +title = "Codewhale fixture exceptions" + +[extend] +useDefault = true + +# Exact synthetic values, restricted to their test source files. Never exclude +# a whole test tree: these tests deliberately exercise credential redaction. +[[rules]] +id = "generic-api-key" + +[[rules.allowlists]] +description = "Synthetic redaction marker" +condition = "AND" +paths = ['''^crates/(telemetry/src/tests|tui/src/lib)\.rs$'''] +regexes = ['''^sk-live-abcdef0123456789abcdef$'''] + +[[rules.allowlists]] +description = "Public URL with explicit HTTPS port, not a credential" +condition = "AND" +paths = ['''^crates/config/src/provider\.rs$'''] +regexTarget = "line" +regexes = ['''^\s*"https://api\.minimax\.io:443/anthropic",\s*$'''] + +[[rules.allowlists]] +description = "Synthetic URL query credential for safe-label redaction" +condition = "AND" +paths = ['''^crates/tui/src/safe_label\.rs$'''] +regexes = ['''^sk-1234567890abcdef$'''] + +[[rules.allowlists]] +description = "Synthetic environment credential for auth diagnostics" +condition = "AND" +paths = ['''^crates/cli/src/lib\.rs$'''] +regexes = ['''^diagnostic-env-secret-9012$'''] + +[[rules.allowlists]] +description = "Synthetic workflow receipt credential for redaction" +condition = "AND" +paths = ['''^crates/tui/src/tui/widgets/workflow_panel\.rs$'''] +regexes = ['''^sk-receipt-secret-1234567890$'''] + +[[rules]] +id = "jwt" + +[[rules.allowlists]] +description = "Intentionally invalid JWT used by the structcopy redaction test" +condition = "AND" +paths = ['''^crates/tui/src/commands/groups/session/structcopy\.rs$'''] +regexes = ['''^eyJhbGciOiJIUzI1NiIsFAKE\.eyJGQUtFIjoiZml4dHVyZSJ9\.FAKEFIXTURESIGNATUREnotasecret000\\n\\$'''] + +[[rules]] +id = "curl-auth-header" + +[[rules.allowlists]] +description = "Synthetic curl authorization fixture for notification redaction" +condition = "AND" +paths = ['''^crates/tui/src/tui/notification_payload\.rs$'''] +regexes = ['''^abcdef123456$'''] diff --git a/docs/FORK_CI.md b/docs/FORK_CI.md new file mode 100644 index 0000000000..c1d88b3837 --- /dev/null +++ b/docs/FORK_CI.md @@ -0,0 +1,30 @@ +# Pinvou fork merge checks + +The `pinvou3-clean` branch uses `.github/workflows/fork-ci.yml`. Upstream +`ci.yml` targets `main` and `master`, so it does not provide this fork's PR +checks. Fork validation runs on every PR update without path filters, against +GitHub's proposed merge tree, and on pushes to the fork base branch. + +Branch protection requires these GitHub Actions checks: + +| Check | Producer and acceptance condition | +| --- | --- | +| `Check Signed-off-by` | Existing `dco.yml` contributor sign-off workflow (currently advisory internally). | +| `check` | Rust formatting, workspace Clippy, all-feature workspace tests using the existing nextest CI profile, doctests, and lockfile drift check. | +| `Gitleaks` | Checksum-verified Gitleaks CLI scans the complete checked-out tree and introduced commit history with redacted output. Manual runs scan all reachable history. | +| `gate` | Requires both `check` and `Gitleaks` to succeed; failures, cancellations and skipped jobs do not pass. | + +`Contribution intake` is the separate contributor welcome/admission workflow, +not the quality gate. DCO remains separately required by branch protection; +the fork workflow does not change its existing sign-off policy. + +The fork workflow has read-only repository permissions and no secrets. PR code +is never executed under `pull_request_target`. Checkout does not retain Git +credentials. Secret detection must not be bypassed with broad allowlists or +printed unredacted in public logs. Investigate scanner findings privately +before changing any exception. + +Strict branch protection requires checks against the current base. If a PR +predates this workflow, update its branch from `pinvou3-clean` after the CI +repair lands so its merge tree includes the checks. Do not synthesize passing +statuses or bypass protection to compensate for missing workflows. diff --git a/scripts/check-gitleaks-config.py b/scripts/check-gitleaks-config.py new file mode 100644 index 0000000000..4e49f5f4e6 --- /dev/null +++ b/scripts/check-gitleaks-config.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python3 +"""Prove fixture exceptions do not exempt whole files or matching values elsewhere.""" + +import json +from pathlib import Path +import subprocess +import sys +import tempfile + + +def main(): + config = Path(__file__).resolve().parents[1] / ".gitleaks.toml" + with tempfile.TemporaryDirectory(prefix="gitleaks-config-") as directory: + root = Path(directory) + fixture = root / "crates/tui/src/lib.rs" + fixture.parent.mkdir(parents=True) + # Artificial markers only; neither value is a usable credential. + allowed = "sk-live-abcdef0123456789abcdef" + changed = "sk-live-abcdef0123456789abcdea" + fixture.write_text( + f'let api_key = "{allowed}";\nlet api_key = "{changed}";\n' + ) + (root / "unrelated.rs").write_text(f'let api_key = "{allowed}";\n') + report = root / "report.json" + result = subprocess.run( + [sys.argv[1], "dir", ".", "--config", str(config), "--redact", + "--no-banner", "--report-format", "json", "--report-path", str(report)], + cwd=root, + check=False, + ) + if result.returncode != 1: + raise SystemExit("Negative control must report leaks (exit 1)") + findings = json.loads(report.read_text()) + locations = {(item["File"], item["StartLine"]) for item in findings} + if len(findings) != 2 or locations != { + ("crates/tui/src/lib.rs", 2), ("unrelated.rs", 1) + }: + raise SystemExit("Fixture exceptions must match both path and exact value") + print("PASS: exact fixture allowed; changed value and unrelated path blocked") + + +if __name__ == "__main__": + main()