185 lines
7.1 KiB
YAML
185 lines
7.1 KiB
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,
|
|
});
|
|
}
|