Files
polinrider-cleanup/src/index.js
T
jodosjodos 39f03add71 Add initial implementation of PolinRider malware scanner
- Introduced core files including Dockerfile, action.yml, and entry point script (bin/polinrider.js) for the PolinRider malware scanner.
- Created configuration files: .env.example, .gitignore, and .npmrc for environment setup and dependency management.
- Added CI workflows for testing, building, and releasing the action, along with examples for usage in GitHub Actions.
- Implemented a README.md to provide documentation on usage, features, and setup instructions.
- Included necessary scripts for building the action and handling security measures during execution.
- Established a structured directory for reports and workspace management within Docker.
- Ensured all files are set up for a secure and efficient scanning process of GitHub repositories for PolinRider malware.
2026-08-11 13:59:03 +02:00

377 lines
16 KiB
JavaScript

import "./safety.js"; // MUST be the first import — installs runtime guards.
/**
* PolinRider Org Cleanup CLI (consolidated, hardened).
*
* Flow per repo:
* 1. Clone default branch into WORKSPACE/<repo> (shallow, hooks disabled)
* 2. Scan in-process (reads files as inert text — never executes them)
* 3. If infected → surgically remediate
* 4. Commit changes on a new branch and open a PR via gh
*
* Required env: GH_TOKEN, and exactly one of GH_ORG (organization) or GH_USER
* (personal account). Optional: DRY_RUN, WORKSPACE, BRANCH_PREFIX.
*
* SAFETY: every subprocess goes through src/safe-exec.js (git/gh only). No
* target file content is ever require()'d, import()'d, eval()'d, or exec()'d.
*/
import chalk from "chalk";
import ora from "ora";
import path from "node:path";
import fs from "node:fs/promises";
import { existsSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { safeGit, safeGh } from "./safe-exec.js";
import { scanRepo } from "./scanner.js";
import { remediate } from "./remediator.js";
import { buildPrBody, PR_TITLE, findingLines, resultLines, buildRunJson, buildRunMarkdown } from "./report.js";
import { hardeningStatus } from "./safety.js";
// ─── Config (resolved inside main() so importing this module is side-effect-free) ─
let ACCOUNT, TOKEN, DRY_RUN, WORKSPACE, BRANCH_PREFIX;
let AUTO_MERGE, MERGE_METHOD, REPORTS_DIR, reportBase, reportStamp;
let reportWarned = false;
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
function env(name) {
const val = process.env[name];
if (!val) {
console.error(chalk.red(`✖ Missing required env var: ${name}`));
process.exit(2);
}
return val;
}
// The target account is EITHER a GitHub org (GH_ORG) or a personal user account
// (GH_USER). Set exactly one — comment out the other in your .env. The resolved
// `owner` is used both to list repos and to prefix bare GH_REPO entries.
function resolveAccount() {
const org = (process.env.GH_ORG || "").trim();
const user = (process.env.GH_USER || "").trim();
if (org && user) {
console.error(chalk.red("✖ Set only one of GH_ORG or GH_USER, not both"));
process.exit(2);
}
if (!org && !user) {
console.error(
chalk.red("✖ Set GH_ORG (organization) or GH_USER (personal account)"),
);
process.exit(2);
}
return user ? { kind: "user", owner: user } : { kind: "org", owner: org };
}
function section(title) {
console.log("\n" + chalk.bold.underline(title));
}
function indent(lines, color = chalk.gray) {
for (const l of lines) console.log(" " + color(l));
}
// ─── Clone ───────────────────────────────────────────────────────────────────
async function cloneRepo(fullName) {
const repoName = fullName.split("/")[1];
const dest = path.join(WORKSPACE, repoName);
if (existsSync(dest)) await fs.rm(dest, { recursive: true, force: true });
const url = `https://x-access-token:${TOKEN}@github.com/${fullName}.git`;
const clone = await safeGit(["clone", "--depth=1", url, dest]);
if (clone.exitCode !== 0) throw new Error(`clone failed: ${clone.stderr}`);
await safeGit(["-C", dest, "config", "user.email", "polinrider-cleanup-bot@users.noreply.github.com"]);
await safeGit(["-C", dest, "config", "user.name", "PolinRider Cleanup Bot"]);
return dest;
}
// ─── Commit + push + PR ────────────────────────────────────────────────────────
async function openPR(repoDir, fullName, findings, result) {
const branch = `${BRANCH_PREFIX}-${Date.now()}`;
const checkout = await safeGit(["-C", repoDir, "checkout", "-b", branch]);
if (checkout.exitCode !== 0) throw new Error(`branch creation failed: ${checkout.stderr}`);
await safeGit(["-C", repoDir, "add", "-A"]);
const staged = await safeGit(["-C", repoDir, "diff", "--cached", "--name-only"]);
if (!staged.stdout.trim()) return { skipped: true, reason: "no staged changes after remediation" };
const commit = await safeGit([
"-C", repoDir, "commit",
"-m", "security: remove PolinRider malware artifacts\n\nAutomated surgical cleanup by polinrider-remover.",
]);
if (commit.exitCode !== 0) throw new Error(`commit failed: ${commit.stderr}`);
const push = await safeGit([
"-C", repoDir, "push",
`https://x-access-token:${TOKEN}@github.com/${fullName}.git`,
branch,
]);
if (push.exitCode !== 0) throw new Error(`push failed: ${push.stderr}`);
const body = buildPrBody({ findings, result });
const baseArgs = ["pr", "create", "--repo", fullName, "--title", PR_TITLE, "--body", body, "--head", branch];
let pr = await safeGh([...baseArgs, "--label", "security"]);
if (pr.exitCode !== 0) pr = await safeGh(baseArgs); // retry without label (may not exist)
if (pr.exitCode !== 0) throw new Error(`PR creation failed: ${pr.stderr}`);
const url = pr.stdout.trim();
if (!AUTO_MERGE) return { url, merged: false, mergeError: null };
// Respect branch protection: a direct merge fails where reviews/checks are
// required — that's reported, not bypassed. Retry once for mergeability lag.
const mergeArgs = ["pr", "merge", url, `--${MERGE_METHOD}`, "--delete-branch"];
let merge = await safeGh(mergeArgs);
if (merge.exitCode !== 0) {
await sleep(3000);
merge = await safeGh(mergeArgs);
}
if (merge.exitCode === 0) return { url, merged: true, mergeError: null };
return { url, merged: false, mergeError: (merge.stderr || merge.stdout || "merge failed").trim().split("\n")[0] };
}
// ─── Resolve target repos ──────────────────────────────────────────────────────
//
// Set GH_REPO (alias GH_REPOS) to a comma-separated list to scope a pilot run to
// specific repos and skip the listing. Each entry may be "name" (the account
// owner — GH_ORG or GH_USER — is prefixed) or "owner/name". Unset → scan every
// repo for the configured account (org repos, or the user's owned repos).
const REPO_RE = /^[\w.-]+\/[\w.-]+$/;
async function resolveTargetRepos() {
const scope = (process.env.GH_REPOS || process.env.GH_REPO || "").trim();
if (scope) {
const repos = scope
.split(",")
.map((s) => s.trim())
.filter(Boolean)
.map((r) => (r.includes("/") ? r : `${ACCOUNT.owner}/${r}`));
const invalid = repos.filter((r) => !REPO_RE.test(r));
if (invalid.length) {
console.error(chalk.red(`✖ Invalid repo name(s) in GH_REPO: ${invalid.join(", ")}`));
process.exit(2);
}
console.log(
chalk.yellow(` Scoped run — ${repos.length} repo(s) from GH_REPO: ${chalk.bold(repos.join(", "))}`),
);
return repos;
}
const spinner = ora(
`Fetching repos for ${ACCOUNT.kind}: ${chalk.bold(ACCOUNT.owner)}`,
).start();
// Org → all repos in the org. User → repos OWNED by the authenticated user
// (affiliation=owner excludes repos you're only a collaborator/org member on).
const endpoint =
ACCOUNT.kind === "org"
? `orgs/${ACCOUNT.owner}/repos`
: "user/repos?affiliation=owner";
const list = await safeGh(["api", endpoint, "--paginate", "--jq", ".[].full_name"]);
if (list.exitCode !== 0) {
spinner.fail(`Failed to list ${ACCOUNT.kind} repos`);
console.error(list.stderr);
process.exit(2);
}
const repos = list.stdout.split("\n").map((r) => r.trim()).filter(Boolean);
spinner.succeed(`Found ${chalk.bold(repos.length)} repositories`);
return repos;
}
// ─── Main ──────────────────────────────────────────────────────────────────────
export async function main() {
ACCOUNT = resolveAccount();
TOKEN = env("GH_TOKEN");
DRY_RUN = process.env.DRY_RUN === "true";
WORKSPACE = process.env.WORKSPACE || "/workspace";
BRANCH_PREFIX = process.env.BRANCH_PREFIX || "fix/polinrider-cleanup";
AUTO_MERGE = process.env.AUTO_MERGE === "true";
MERGE_METHOD = (process.env.MERGE_METHOD || "squash").toLowerCase();
if (!["squash", "merge", "rebase"].includes(MERGE_METHOD)) {
console.error(chalk.red(`✖ MERGE_METHOD must be squash|merge|rebase (got "${MERGE_METHOD}")`));
process.exit(2);
}
REPORTS_DIR = process.env.REPORTS_DIR ?? "reports"; // set empty to disable
reportStamp = new Date();
reportBase = REPORTS_DIR
? path.join(REPORTS_DIR, `polinrider-${reportStamp.toISOString().replace(/[:.]/g, "-")}`)
: null;
console.log(chalk.bold.cyan("\n╔══════════════════════════════════════╗"));
console.log(chalk.bold.cyan("║ PolinRider Org Cleanup Tool v2.0 ║"));
console.log(chalk.bold.cyan("╚══════════════════════════════════════╝"));
const h = hardeningStatus();
console.log(
chalk.gray(
` hardening: eval ${h.evalBlocked ? "blocked" : chalk.red("OPEN")}, codegen-flag ${
h.codegenFlag ? "on" : chalk.yellow("off")
}, launcher ${h.hardenedLaunch ? "on" : chalk.yellow("off")}`,
),
);
if (DRY_RUN) console.log(chalk.yellow.bold("\n ⚠ DRY RUN — no files written, no pushes or PRs\n"));
await fs.mkdir(WORKSPACE, { recursive: true });
const results = { total: 0, clean: 0, infected: 0, remediated: 0, prOpened: 0, merged: 0, manualOnly: 0, errors: [], prs: [], repos: [] };
const repos = await resolveTargetRepos();
results.total = repos.length;
for (const fullName of repos) {
const repoName = fullName.split("/")[1];
section(fullName);
let repoDir;
const record = { repo: fullName, severity: null, findings: [], deleted: [], modified: [], notes: [], manualReview: [], pr: null, error: null };
try {
const cloneSpinner = ora(" Cloning...").start();
repoDir = await cloneRepo(fullName);
cloneSpinner.succeed(chalk.gray(" Cloned"));
const scanSpinner = ora(" Scanning...").start();
const findings = await scanRepo(repoDir);
record.severity = findings.severity;
record.findings = findings.findings.map((f) => ({
file: f.file, action: f.action, confidence: f.confidence, contentConfirmed: f.contentConfirmed, description: f.description,
}));
record.manualReview = findings.manualReview;
if (findings.severity !== "infected") {
const note = findings.coPresenceAmplified ? " (suspicious file layout — no confirmed payload)" : "";
scanSpinner.succeed(" " + chalk.green(`Clean — no PolinRider signatures found${note}`));
results.clean++;
continue;
}
scanSpinner.warn(" " + chalk.red.bold("INFECTED — malware signatures detected"));
results.infected++;
indent(findingLines(findings));
const remSpinner = ora(" Remediating...").start();
const result = await remediate(repoDir, findings, { dryRun: DRY_RUN });
record.deleted = result.filesDeleted;
record.modified = result.filesModified;
record.notes = result.notes;
remSpinner.succeed(" " + chalk.magenta(DRY_RUN ? "Planned remediation (dry run)" : "Remediated"));
indent(resultLines(result));
if (DRY_RUN) continue;
if (!result.changed) {
results.manualOnly++;
results.errors.push({ repo: fullName, reason: "infected but only manual-review items — no automated fix" });
continue;
}
results.remediated++;
const prSpinner = ora(" Creating PR...").start();
const pr = await openPR(repoDir, fullName, findings, result);
if (pr.skipped) {
prSpinner.warn(` Skipped PR: ${pr.reason}`);
} else {
record.pr = { url: pr.url, merged: pr.merged, mergeError: pr.mergeError };
results.prOpened++;
results.prs.push({ repo: fullName, url: pr.url, merged: pr.merged });
if (pr.merged) {
results.merged++;
prSpinner.succeed(" " + chalk.green.bold(`PR merged: ${pr.url}`));
} else if (pr.mergeError) {
prSpinner.warn(" " + chalk.yellow(`PR opened — auto-merge blocked (${pr.mergeError}); merge manually: ${pr.url}`));
} else {
prSpinner.succeed(" " + chalk.blue.bold(`PR opened: ${pr.url}`));
}
}
} catch (err) {
console.log(" " + chalk.red("✖ ") + chalk.bold(repoName) + " " + err.message);
record.error = err.message;
results.errors.push({ repo: fullName, reason: err.message });
} finally {
if (repoDir) await fs.rm(repoDir, { recursive: true, force: true }).catch(() => {});
results.repos.push(record);
await writeReports(results); // rewrite after every repo → live, crash-resilient trace
}
}
await writeReports(results);
printSummary(results);
process.exit(results.infected > 0 && results.remediated < results.infected && !DRY_RUN ? 1 : 0);
}
// Persist the run report (JSON + Markdown) to REPORTS_DIR. Rewritten after every
// repo so a trace survives even if the run is interrupted. Never fails the run.
async function writeReports(results) {
if (!REPORTS_DIR || !reportBase) return;
const run = {
scannedAt: reportStamp.toISOString(),
scannedAtHuman: reportStamp.toString(),
account: { kind: ACCOUNT.kind, owner: ACCOUNT.owner },
dryRun: DRY_RUN,
autoMerge: AUTO_MERGE,
mergeMethod: MERGE_METHOD,
totals: {
total: results.total, clean: results.clean, infected: results.infected,
remediated: results.remediated, merged: results.merged, prOpened: results.prOpened,
manualOnly: results.manualOnly, errors: results.errors.length,
},
repos: results.repos,
};
try {
await fs.mkdir(REPORTS_DIR, { recursive: true });
const json = JSON.stringify(buildRunJson(run), null, 2);
const md = buildRunMarkdown(run);
await fs.writeFile(`${reportBase}.json`, json);
await fs.writeFile(`${reportBase}.md`, md);
await fs.writeFile(path.join(REPORTS_DIR, "latest.json"), json);
await fs.writeFile(path.join(REPORTS_DIR, "latest.md"), md);
} catch (e) {
if (!reportWarned) {
reportWarned = true;
console.log(chalk.yellow(` ⚠ could not write reports to ${REPORTS_DIR}: ${e.message}`));
}
}
}
function printSummary(results) {
console.log("\n" + chalk.bold.cyan("══════════════════════════════════════"));
console.log(chalk.bold(" SUMMARY"));
console.log(chalk.bold.cyan("══════════════════════════════════════"));
console.log(` ${chalk.bold("Repos scanned:")} ${results.total}`);
console.log(` ${chalk.green.bold("Clean:")} ${results.clean}`);
console.log(` ${chalk.red.bold("Infected:")} ${results.infected}`);
console.log(` ${chalk.magenta.bold("Remediated:")} ${results.remediated}`);
console.log(` ${chalk.blue.bold("PRs opened:")} ${results.prOpened}`);
if (AUTO_MERGE) console.log(` ${chalk.green.bold("PRs merged:")} ${results.merged}`);
if (results.manualOnly) console.log(` ${chalk.yellow.bold("Manual review:")} ${results.manualOnly}`);
if (results.prs.length) {
console.log("\n" + chalk.bold(" Pull Requests:"));
for (const { repo, url, merged } of results.prs) {
const mark = merged ? chalk.green("✔ merged") : chalk.blue("⎇ open");
console.log(` ${mark} ${chalk.bold(repo)}\n ${url}`);
}
}
if (results.errors.length) {
console.log("\n" + chalk.yellow.bold(" Errors / needs manual review:"));
for (const { repo, reason } of results.errors) console.log(` ${chalk.yellow("⚠")} ${chalk.bold(repo)}: ${reason}`);
}
if (REPORTS_DIR && reportBase) {
console.log("\n " + chalk.gray(`Report written: ${reportBase}.md (and latest.md / .json in ${REPORTS_DIR}/)`));
}
console.log(chalk.bold.cyan("══════════════════════════════════════\n"));
}
// Auto-run only when executed directly (`node src/index.js`). When imported —
// by the launcher or a test — call main() explicitly instead. This keeps import
// side-effect-free (no env reads, no process.exit) so the module is testable.
const invokedDirectly =
process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
if (invokedDirectly) {
main().catch((err) => {
console.error(chalk.red("\nFatal error:"), err.message);
process.exit(2);
});
}