Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions reputation-sybil-cluster-guard/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Reputation Sybil Cluster Guard

This slice adds a dependency-free guard for SCIBASE's Community & User Reputation System. It audits synthetic reputation events for sybil-cluster patterns before points affect leaderboards, bounty eligibility, trust badges, or payout review.

The scope is distinct from existing submissions for probation reinstatement, leaderboard eligibility/privacy, reputation review abuse, identity impersonation, badge renewal, credit-ledger reputation, and completion reputation.

## What It Checks

- Shared payout handles across multiple accounts.
- Recycled device fingerprints across high-velocity accounts.
- Burst endorsements shortly after signup.
- Suspicious invitation chains.
- Cross-project high-score review rings.
- High reputation deltas while risk signals are present.
- Missing quarantine, leaderboard suppression, payout pause, or manual review controls.

## Reviewer Output

Running the demo creates:

- `reports/sybil-cluster-report.json`
- `reports/sybil-cluster-report.md`
- `reports/summary.svg`
- `reports/demo-script.txt`
- `reports/demo.mp4`

All data is synthetic. The guard uses no credentials, private user records, payment processors, wallets, social media accounts, or external services.

## Commands

```bash
npm test
npm run demo
npm run demo:video
```
24 changes: 24 additions & 0 deletions reputation-sybil-cluster-guard/demo.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { mkdir, writeFile } from "node:fs/promises";
import { auditReputationSybilCluster, buildReviewerMarkdown, buildSummarySvg } from "./index.js";
import { riskyReputationPacket } from "./sample-data.js";

const reportsDir = new URL("./reports/", import.meta.url);
await mkdir(reportsDir, { recursive: true });

const report = auditReputationSybilCluster(riskyReputationPacket);
await writeFile(new URL("sybil-cluster-report.json", reportsDir), `${JSON.stringify(report, null, 2)}\n`);
await writeFile(new URL("sybil-cluster-report.md", reportsDir), buildReviewerMarkdown(report));
await writeFile(new URL("summary.svg", reportsDir), buildSummarySvg(report));
await writeFile(
new URL("demo-script.txt", reportsDir),
[
"Demo: Reputation Sybil Cluster Guard",
`Cluster: ${report.clusterId}`,
`Decision: ${report.decision}`,
`Risk score: ${report.riskScore}/100`,
`Blockers: ${report.summary.blockCount}`,
"Action: quarantine cluster before reputation points affect rankings, bounties, or payout eligibility.",
].join("\n"),
);

console.log(JSON.stringify(report.summary, null, 2));
45 changes: 45 additions & 0 deletions reputation-sybil-cluster-guard/demo_video.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
from pathlib import Path

import imageio.v3 as iio
import numpy as np
from PIL import Image, ImageDraw, ImageFont


ROOT = Path(__file__).resolve().parent
REPORTS = ROOT / "reports"
REPORTS.mkdir(exist_ok=True)


def font(size):
for name in ("arial.ttf", "segoeui.ttf"):
try:
return ImageFont.truetype(name, size)
except OSError:
pass
return ImageFont.load_default()


slides = [
("Reputation Sybil Cluster Guard", "Synthetic trust-and-safety control for SCIBASE #15"),
("Decision", "quarantine-cluster · risk score 100/100"),
("Signals", "shared payout · recycled device · review ring"),
("Action", "Pause reputation, rankings, and payout eligibility until review"),
]

frames = []
for title, subtitle in slides:
image = Image.new("RGB", (960, 544), "#111827")
draw = ImageDraw.Draw(image)
draw.rectangle((48, 58, 912, 486), outline="#374151", width=3)
draw.text((82, 136), title, fill="#f9fafb", font=font(42))
draw.text((82, 220), subtitle, fill="#d1d5db", font=font(24))
draw.rectangle((82, 350, 700, 392), fill="#dc2626")
draw.text((82, 424), "Synthetic data only. No social media, wallets, or private user records.", fill="#9ca3af", font=font(20))
frames.extend([image] * 14)

gif_path = REPORTS / "demo.gif"
mp4_path = REPORTS / "demo.mp4"
frames[0].save(gif_path, save_all=True, append_images=frames[1:], duration=120, loop=0)
iio.imwrite(mp4_path, [np.asarray(frame) for frame in frames], fps=8, codec="libx264")
print(f"wrote {gif_path}")
print(f"wrote {mp4_path}")
197 changes: 197 additions & 0 deletions reputation-sybil-cluster-guard/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
const SEVERITY = { block: 3, warn: 2 };

function finding(code, severity, message, evidence, remediation) {
return { code, severity, message, evidence, remediation };
}

function groupsBy(items, key) {
const groups = new Map();
for (const item of items) {
const value = item[key];
if (!value) continue;
groups.set(value, [...(groups.get(value) || []), item]);
}
return groups;
}

export function auditReputationSybilCluster(packet) {
if (!packet || typeof packet !== "object") {
throw new TypeError("reputation packet must be an object");
}

const users = packet.users || [];
const endorsements = packet.endorsements || [];
const reviews = packet.reviews || [];
const controls = packet.controls || {};
const findings = [];

for (const [payoutHandle, members] of groupsBy(users, "payoutHandle")) {
if (members.length > 1) {
findings.push(
finding(
"SHARED_PAYOUT_HANDLE",
"block",
"Multiple reputation accounts share the same payout handle.",
{ payoutHandle, userIds: members.map((member) => member.userId) },
"Pause payout eligibility and require identity review before reputation points affect bounty access.",
),
);
}
}

for (const [fingerprint, members] of groupsBy(users, "deviceFingerprint")) {
if (members.length >= 3) {
findings.push(
finding(
"RECYCLED_DEVICE_FINGERPRINT",
"block",
"Three or more accounts share a device fingerprint inside the same review window.",
{ fingerprint, userIds: members.map((member) => member.userId) },
"Quarantine the cluster and suppress leaderboard movement until reviewed.",
),
);
}
}

const burstEndorsements = endorsements.filter((endorsement) => endorsement.createdMinutesAfterSignup <= 30);
if (burstEndorsements.length >= 3) {
findings.push(
finding(
"BURST_ENDORSEMENT_RING",
"warn",
"Several endorsements were created immediately after signup.",
{ count: burstEndorsements.length, edges: burstEndorsements.map(({ from, to }) => `${from}->${to}`) },
"Delay endorsement credit until accounts age past the configured trust window.",
),
);
}

const invitationCounts = groupsBy(users, "invitedBy");
for (const [inviter, members] of invitationCounts) {
if (inviter !== "seed" && members.length >= 2) {
findings.push(
finding(
"SUSPICIOUS_INVITATION_CHAIN",
"warn",
"A non-seed account invited multiple high-velocity reputation accounts.",
{ inviter, invitees: members.map((member) => member.userId) },
"Require manual review before granting invitation-derived reputation boosts.",
),
);
}
}

const reciprocalReviewEdges = new Set(reviews.map((review) => `${review.reviewer}:${review.submitter}`));
const ringEdges = reviews.filter((review) => reciprocalReviewEdges.has(`${review.submitter}:${review.reviewer}`) || review.score >= 5);
if (ringEdges.length >= 3) {
findings.push(
finding(
"SELF_REVIEW_RING_RISK",
"block",
"Reviewer and submitter relationships form a high-score ring across bounty projects.",
{ edges: ringEdges.map(({ reviewer, submitter, projectId }) => `${reviewer}->${submitter}@${projectId}`) },
"Suppress review-derived reputation and route the cluster to trust-and-safety review.",
),
);
}

const totalReputationDelta = users.reduce((total, user) => total + Number(user.reputationDelta || 0), 0);
if (totalReputationDelta >= 100 && findings.length > 0) {
findings.push(
finding(
"HIGH_REPUTATION_DELTA_UNDER_RISK",
"warn",
"The cluster gained enough reputation to affect rankings or bounty eligibility while risk signals are present.",
{ totalReputationDelta },
"Hold reputation deltas until the cluster review is resolved.",
),
);
}

if (findings.some((item) => item.severity === "block") && (!controls.quarantineApplied || !controls.leaderboardSuppressed || !controls.payoutEligibilityPaused)) {
findings.push(
finding(
"MISSING_CLUSTER_QUARANTINE",
"block",
"Blocking sybil signals exist but reputation, ranking, or payout controls are not fully paused.",
controls,
"Apply quarantine, suppress leaderboard rank, pause payout eligibility, and open a manual review ticket.",
),
);
}

const blockCount = findings.filter((item) => item.severity === "block").length;
const warnCount = findings.filter((item) => item.severity === "warn").length;
return {
clusterId: packet.clusterId,
reviewWindow: packet.reviewWindow,
decision: blockCount > 0 ? "quarantine-cluster" : warnCount > 0 ? "manual-review" : "reputation-clear",
riskScore: Math.min(100, blockCount * 25 + warnCount * 10),
summary: {
blockCount,
warnCount,
findingCount: findings.length,
userCount: users.length,
endorsementCount: endorsements.length,
reviewCount: reviews.length,
},
findings: findings.sort((a, b) => SEVERITY[b.severity] - SEVERITY[a.severity] || a.code.localeCompare(b.code)),
};
}

export function buildReviewerMarkdown(report) {
const lines = [
`# Reputation Sybil Cluster Guard: ${report.clusterId}`,
"",
`Review window: **${report.reviewWindow}**`,
`Decision: **${report.decision}**`,
`Risk score: **${report.riskScore}/100**`,
"",
`Findings: ${report.summary.blockCount} blockers, ${report.summary.warnCount} warnings.`,
"",
];

for (const item of report.findings) {
lines.push(`## ${item.severity.toUpperCase()}: ${item.code}`);
lines.push(item.message);
lines.push("");
lines.push(`Evidence: \`${JSON.stringify(item.evidence)}\``);
lines.push("");
lines.push(`Remediation: ${item.remediation}`);
lines.push("");
}

if (report.findings.length === 0) lines.push("No sybil-cluster reputation issues were detected.\n");
return lines.join("\n");
}

export function buildSummarySvg(report) {
return `<svg xmlns="http://www.w3.org/2000/svg" width="960" height="544" viewBox="0 0 960 544" role="img" aria-label="Reputation sybil cluster guard summary">
<rect width="960" height="544" fill="#111827"/>
<text x="58" y="78" fill="#f9fafb" font-family="Arial, sans-serif" font-size="32" font-weight="700">Reputation Sybil Cluster Guard</text>
<text x="58" y="120" fill="#d1d5db" font-family="Arial, sans-serif" font-size="18">${escapeXml(report.clusterId)} · ${escapeXml(report.decision)}</text>
<rect x="58" y="160" width="790" height="36" rx="6" fill="#1f2937" stroke="#374151"/>
<rect x="58" y="160" width="${Math.min(790, report.riskScore * 7.9)}" height="36" rx="6" fill="#dc2626"/>
<text x="875" y="186" fill="#f9fafb" font-family="Arial, sans-serif" font-size="20" text-anchor="end">${report.riskScore}/100</text>
<g transform="translate(58 260)">
<rect width="250" height="126" rx="9" fill="#1f2937"/>
<text x="28" y="52" fill="#fecaca" font-family="Arial, sans-serif" font-size="34" font-weight="700">${report.summary.blockCount}</text>
<text x="28" y="88" fill="#d1d5db" font-family="Arial, sans-serif" font-size="17">blocking signals</text>
</g>
<g transform="translate(356 260)">
<rect width="250" height="126" rx="9" fill="#1f2937"/>
<text x="28" y="52" fill="#bfdbfe" font-family="Arial, sans-serif" font-size="34" font-weight="700">${report.summary.userCount}</text>
<text x="28" y="88" fill="#d1d5db" font-family="Arial, sans-serif" font-size="17">accounts reviewed</text>
</g>
<g transform="translate(654 260)">
<rect width="250" height="126" rx="9" fill="#1f2937"/>
<text x="28" y="52" fill="#fde68a" font-family="Arial, sans-serif" font-size="34" font-weight="700">${report.summary.endorsementCount}</text>
<text x="28" y="88" fill="#d1d5db" font-family="Arial, sans-serif" font-size="17">endorsement edges</text>
</g>
</svg>
`;
}

function escapeXml(value) {
return String(value).replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;");
}
12 changes: 12 additions & 0 deletions reputation-sybil-cluster-guard/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"name": "reputation-sybil-cluster-guard",
"version": "1.0.0",
"description": "Synthetic sybil-cluster reputation guard for SCIBASE community reputation workflows.",
"type": "module",
"private": true,
"scripts": {
"test": "node test.js",
"demo": "node demo.js",
"demo:video": "python demo_video.py"
}
}
6 changes: 6 additions & 0 deletions reputation-sybil-cluster-guard/reports/demo-script.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
Demo: Reputation Sybil Cluster Guard
Cluster: cluster-rho-17
Decision: quarantine-cluster
Risk score: 100/100
Blockers: 4
Action: quarantine cluster before reputation points affect rankings, bounties, or payout eligibility.
Binary file added reputation-sybil-cluster-guard/reports/demo.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added reputation-sybil-cluster-guard/reports/demo.mp4
Binary file not shown.
23 changes: 23 additions & 0 deletions reputation-sybil-cluster-guard/reports/summary.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading