integrations

GitHub

Use brin in GitHub Actions to check contributor trust and scan commits on every pull request

Integrate brin into your GitHub CI pipeline with Actions workflows that automatically check contributor trust and scan commits for security signals on every pull request.

##Contributor checks

Add a workflow that queries the brin contributor API for every PR author. It labels PRs as contributor:verified or contributor:flagged and posts a detailed comment when review is recommended.

###What it does

When a pull request is opened, reopened, or updated, the workflow:

  1. Queries the brin contributor API for the PR author's trust score
  2. Applies a label — contributor:verified (safe) or contributor:flagged (needs review)
  3. Posts a comment with threat signals, dimension breakdown, and a link to the full profile when flagged
  4. Cleans up the comment automatically when a previously-flagged contributor is re-evaluated as safe

###Workflow

Create .github/workflows/contributor-check.yml in your repository:

YAML
name: Contributor Trust Check
 
on:
  pull_request_target:
    types: [opened, reopened, synchronize]
 
permissions:
  contents: read
  pull-requests: write
 
jobs:
  check:
    name: Scan PR author
    runs-on: ubuntu-24.04
    concurrency:
      group: brin-check-${{ github.event.pull_request.number }}
      cancel-in-progress: true
    steps:
      - name: Query Brin API
        id: brin
        run: |
          AUTHOR="${{ github.event.pull_request.user.login }}"
          RESPONSE=$(curl -sf "https://api.brin.sh/contributor/${AUTHOR}?details=true&mode=full" || echo '{}')
          echo "response<<BRIN_EOF" >> "$GITHUB_OUTPUT"
          echo "$RESPONSE" >> "$GITHUB_OUTPUT"
          echo "BRIN_EOF" >> "$GITHUB_OUTPUT"
 
      - name: Apply label and comment
        uses: actions/github-script@v7
        env:
          BRIN_RESPONSE: ${{ steps.brin.outputs.response }}
        with:
          script: |
            const marker = "<!-- brin-check -->";
            const pr = context.payload.pull_request;
            let data;
            try {
              data = JSON.parse(process.env.BRIN_RESPONSE);
            } catch {
              core.warning("Failed to parse Brin API response");
              return;
            }
 
            if (!data.score && data.score !== 0) {
              core.warning("Brin API returned no score");
              return;
            }
 
            const score = data.score;
            const verdict = data.verdict ?? "unknown";
            const confidence = data.confidence ?? "unknown";
            const isSafe = verdict === "safe";
 
            const labels = [
              { name: "contributor:verified", color: "0969da", description: "Contributor passed trust analysis." },
              { name: "contributor:flagged",  color: "e16f24", description: "Contributor flagged for review by trust analysis." },
            ];
 
            for (const label of labels) {
              try {
                const { data: existing } = await github.rest.issues.getLabel({
                  owner: context.repo.owner,
                  repo: context.repo.repo,
                  name: label.name,
                });
                if (existing.color !== label.color || (existing.description ?? "") !== label.description) {
                  await github.rest.issues.updateLabel({
                    owner: context.repo.owner,
                    repo: context.repo.repo,
                    name: label.name,
                    color: label.color,
                    description: label.description,
                  });
                }
              } catch (error) {
                if (error.status !== 404) throw error;
                await github.rest.issues.createLabel({
                  owner: context.repo.owner,
                  repo: context.repo.repo,
                  name: label.name,
                  color: label.color,
                  description: label.description,
                });
              }
            }
 
            const nextLabel = isSafe ? "contributor:verified" : "contributor:flagged";
            const labelNames = labels.map((l) => l.name);
 
            const { data: currentLabels } = await github.rest.issues.listLabelsOnIssue({
              owner: context.repo.owner,
              repo: context.repo.repo,
              issue_number: pr.number,
            });
 
            const preserved = currentLabels
              .map((l) => l.name)
              .filter((name) => !labelNames.includes(name));
 
            await github.rest.issues.setLabels({
              owner: context.repo.owner,
              repo: context.repo.repo,
              issue_number: pr.number,
              labels: [...preserved, nextLabel],
            });
 
            core.info(`PR #${pr.number}: ${verdict} -> ${nextLabel}`);
 
            if (isSafe) {
              const { data: comments } = await github.rest.issues.listComments({
                owner: context.repo.owner,
                repo: context.repo.repo,
                issue_number: pr.number,
                per_page: 100,
              });
              const existing = comments.find((c) => c.body?.includes(marker));
              if (existing) {
                await github.rest.issues.deleteComment({
                  owner: context.repo.owner,
                  repo: context.repo.repo,
                  comment_id: existing.id,
                });
              }
              return;
            }
 
            const sub = data.sub_scores ?? {};
            const fmt = (v) => (v != null ? String(Math.round(v)) : "\u2014");
 
            const verdictEmoji = {
              caution: "\u26A0\uFE0F",
              suspicious: "\u26D4",
              dangerous: "\uD83D\uDEA8",
            }[verdict] ?? "\u2753";
 
            let body = `${marker}\n`;
            body += `### ${verdictEmoji} Contributor Trust Check \u2014 Review Recommended\n\n`;
            body += `This contributor's profile shows patterns that may warrant additional review. `;
            body += `This is based on their GitHub activity, not the contents of this PR.\n\n`;
            body += `**[${data.name}](https://github.com/${data.name})** \u00b7 Score: **${score}**/100\n\n`;
 
            if (data.threats && data.threats.length > 0) {
              body += `#### Why was this flagged?\n\n`;
              body += `| Signal | Severity | Detail |\n|--------|----------|--------|\n`;
              for (const t of data.threats) {
                body += `| ${t.type} | ${t.severity} | ${t.detail} |\n`;
              }
              body += `\n`;
            }
 
            body += `<details>\n<summary>Dimension breakdown</summary>\n\n`;
            body += `| Dimension | Score | What it measures |\n|-----------|-------|------------------|\n`;
            body += `| Identity | ${fmt(sub.identity)} | Account age, contribution history, GPG keys, org memberships |\n`;
            body += `| Behavior | ${fmt(sub.behavior)} | PR patterns, unsolicited contribution ratio, activity cadence |\n`;
            body += `| Content | ${fmt(sub.content)} | PR body substance, issue linkage, contribution quality |\n`;
            body += `| Graph | ${fmt(sub.graph)} | Cross-repo trust, co-contributor relationships |\n\n`;
            body += `</details>\n\n`;
 
            body += `<sub>Analyzed by [Brin](https://brin.sh) \u00b7 [Full profile](${data.url}?details=true)</sub>\n`;
 
            const { data: comments } = await github.rest.issues.listComments({
              owner: context.repo.owner,
              repo: context.repo.repo,
              issue_number: pr.number,
              per_page: 100,
            });
 
            const existingComment = comments.find((c) => c.body?.includes(marker));
 
            if (existingComment) {
              await github.rest.issues.updateComment({
                owner: context.repo.owner,
                repo: context.repo.repo,
                comment_id: existingComment.id,
                body,
              });
            } else {
              await github.rest.issues.createComment({
                owner: context.repo.owner,
                repo: context.repo.repo,
                issue_number: pr.number,
                body,
              });
            }

###How it works

The workflow uses pull_request_target so it has write access to add labels and comments, even on PRs from forks. It runs in two steps:

  1. Query the brin API — calls the contributor endpoint with details=true&mode=full to get the full trust profile for the PR author. If the API is unreachable, it falls back to an empty JSON object and exits gracefully.
  2. Label and comment — ensures contributor:verified and contributor:flagged labels exist, applies the appropriate one, and posts a detailed comment on flagged PRs with threat signals and a dimension breakdown. The comment uses an HTML marker to find and update itself on subsequent runs.

The concurrency setting ensures only one check runs per PR at a time. If a new commit is pushed while a check is in progress, the running check is cancelled and replaced.

###Blocking merges on flagged contributors

Add a branch protection rule that requires the "Scan PR author" status check to pass. The workflow always succeeds (it labels rather than fails), so to enforce a merge block you can add a step that exits with a non-zero code when the verdict is not safe:

YAML
      - name: Block if flagged
        if: steps.brin.outputs.response != '{}'
        run: |
          VERDICT=$(echo '${{ steps.brin.outputs.response }}' | jq -r '.verdict // "unknown"')
          if [ "$VERDICT" != "safe" ]; then
            echo "::error::Contributor verdict is $VERDICT — merge blocked"
            exit 1
          fi

##Commit scanning

Add a workflow that scans every commit in a pull request through the brin commit API. This catches malicious or suspicious code changes regardless of who authored them.

###What it does

When a pull request is opened or updated, the workflow:

  1. Checks out the PR commits and collects their SHAs
  2. Queries the brin commit API for each commit
  3. Fails the check if any commit is flagged as suspicious or dangerous
  4. Posts a summary comment listing flagged commits with their scores and threat details

###Workflow

Create .github/workflows/commit-scan.yml in your repository:

YAML
name: Commit Security Scan
 
on:
  pull_request_target:
    types: [opened, reopened, synchronize]
 
permissions:
  contents: read
  pull-requests: write
 
jobs:
  scan:
    name: Scan commits
    runs-on: ubuntu-24.04
    concurrency:
      group: brin-commit-${{ github.event.pull_request.number }}
      cancel-in-progress: true
    steps:
      - name: Checkout PR
        uses: actions/checkout@v4
        with:
          ref: ${{ github.event.pull_request.head.sha }}
          fetch-depth: 0
 
      - name: Scan commits with Brin
        id: scan
        env:
          PR_BASE: ${{ github.event.pull_request.base.sha }}
          PR_HEAD: ${{ github.event.pull_request.head.sha }}
          REPO: ${{ github.repository }}
        run: |
          COMMITS=$(git rev-list "$PR_BASE".."$PR_HEAD")
          FLAGGED=""
          OWNER=$(echo "$REPO" | cut -d/ -f1)
          REPO_NAME=$(echo "$REPO" | cut -d/ -f2)
 
          for SHA in $COMMITS; do
            RESPONSE=$(curl -sf "https://api.brin.sh/commit/${OWNER}/${REPO_NAME}@${SHA}?details=true" || echo '{}')
            VERDICT=$(echo "$RESPONSE" | jq -r '.verdict // "unknown"')
            SCORE=$(echo "$RESPONSE" | jq -r '.score // "—"')
 
            if [ "$VERDICT" = "suspicious" ] || [ "$VERDICT" = "dangerous" ]; then
              SHORT=$(echo "$SHA" | cut -c1-7)
              DETAIL=$(echo "$RESPONSE" | jq -r '(.threats // [])[] | "\(.type): \(.detail)"' | head -1)
              FLAGGED="${FLAGGED}| \`${SHORT}\` | ${SCORE} | ${VERDICT} | ${DETAIL:-—} |\n"
            fi
          done
 
          echo "flagged<<BRIN_EOF" >> "$GITHUB_OUTPUT"
          echo -e "$FLAGGED" >> "$GITHUB_OUTPUT"
          echo "BRIN_EOF" >> "$GITHUB_OUTPUT"
 
          if [ -n "$FLAGGED" ]; then
            echo "has_flagged=true" >> "$GITHUB_OUTPUT"
          else
            echo "has_flagged=false" >> "$GITHUB_OUTPUT"
          fi
 
      - name: Comment on flagged commits
        if: steps.scan.outputs.has_flagged == 'true'
        uses: actions/github-script@v7
        env:
          FLAGGED: ${{ steps.scan.outputs.flagged }}
        with:
          script: |
            const marker = "<!-- brin-commit-scan -->";
            const pr = context.payload.pull_request;
 
            let body = `${marker}\n`;
            body += `### Commit Security Scan — Flagged Commits\n\n`;
            body += `The following commits were flagged by brin for review:\n\n`;
            body += `| Commit | Score | Verdict | Detail |\n`;
            body += `|--------|-------|---------|--------|\n`;
            body += process.env.FLAGGED;
            body += `\n<sub>Scanned by [Brin](https://brin.sh)</sub>\n`;
 
            const { data: comments } = await github.rest.issues.listComments({
              owner: context.repo.owner,
              repo: context.repo.repo,
              issue_number: pr.number,
              per_page: 100,
            });
 
            const existing = comments.find((c) => c.body?.includes(marker));
 
            if (existing) {
              await github.rest.issues.updateComment({
                owner: context.repo.owner,
                repo: context.repo.repo,
                comment_id: existing.id,
                body,
              });
            } else {
              await github.rest.issues.createComment({
                owner: context.repo.owner,
                repo: context.repo.repo,
                issue_number: pr.number,
                body,
              });
            }
 
      - name: Clean up comment if all clear
        if: steps.scan.outputs.has_flagged == 'false'
        uses: actions/github-script@v7
        with:
          script: |
            const marker = "<!-- brin-commit-scan -->";
            const { data: comments } = await github.rest.issues.listComments({
              owner: context.repo.owner,
              repo: context.repo.repo,
              issue_number: context.payload.pull_request.number,
              per_page: 100,
            });
            const existing = comments.find((c) => c.body?.includes(marker));
            if (existing) {
              await github.rest.issues.deleteComment({
                owner: context.repo.owner,
                repo: context.repo.repo,
                comment_id: existing.id,
              });
            }
 
      - name: Fail if flagged
        if: steps.scan.outputs.has_flagged == 'true'
        run: |
          echo "::error::One or more commits were flagged by brin"
          exit 1

###How it works

The workflow checks out the full PR history, then iterates over each commit between the base and head SHAs. For each commit, it calls the brin commit API (/commit/owner/repo@sha) with details=true to get the full security analysis.

If any commit returns a suspicious or dangerous verdict, the workflow:

  • Posts a comment with a table of flagged commits, their scores, verdicts, and threat details
  • Fails the check so branch protection rules can block the merge

When all commits are clean on a subsequent run, the workflow removes any previous scan comment.

###Combining with contributor checks

You can run both workflows in the same repository. They operate independently — contributor checks evaluate the PR author's profile, while commit scanning evaluates the actual code changes. Together they provide defense in depth: even a trusted contributor's compromised account would be caught if the commits contain malicious patterns.