Files
polinrider-cleanup/src/ci.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

537 lines
18 KiB
JavaScript

import "./safety.js"; // MUST be first — neutralizes eval/Function/vm at import time.
/**
* GitHub Action frontend (CI mode).
*
* Scans the ALREADY-CHECKED-OUT repository (no clone, no org listing) and, based
* on `mode`, fails the check, cleans the working tree, or opens a cleanup PR.
* Reuses the same engine as the org-sweep CLI — scanRepo() + remediate() + the
* report builders — all of which operate on a plain local directory.
*
* mode=check Scan only. Exit non-zero when severity meets `fail-on` so a
* required status check blocks an infected merge. No token needed.
* mode=fix Scan → surgically remediate the working tree. With commit=true
* (and on a push event) also commit + push the cleanup back.
* mode=pr Scan → remediate → open a cleanup PR (mirrors the CLI's openPR).
*
* Inputs arrive as INPUT_* env vars (GitHub Actions convention). Each setting
* also falls back to its legacy env name (GH_TOKEN, DRY_RUN, AUTO_MERGE,
* MERGE_METHOD, BRANCH_PREFIX) so consumers can configure via `with:` or `env:`.
*
* SAFETY: no target file content is ever require()'d/import()'d/eval()'d; every
* subprocess still goes through src/safe-exec.js (git/gh only).
*/
import fs from "node:fs";
import fsp from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { scanRepo } from "./scanner.js";
import { remediate } from "./remediator.js";
import { buildPrBody, PR_TITLE, findingLines, resultLines } from "./report.js";
import { safeGit, safeGh } from "./safe-exec.js";
import { buildSarif } from "./sarif.js";
const BOT_EMAIL = "polinrider-cleanup-bot@users.noreply.github.com";
const BOT_NAME = "PolinRider Cleanup Bot";
const PROJECT_URL = "https://github.com/RMSoft-ltd/polinrider-cleanup";
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const oneLine = (s) => (s || "").trim().split("\n")[0];
// ─── Inputs (INPUT_* with legacy-env fallback) ───────────────────────────────
/** Mirror @actions/core.getInput: INPUT_<NAME>, uppercased, spaces→underscore. */
function input(name) {
const v = process.env[`INPUT_${name.replace(/ /g, "_").toUpperCase()}`];
return v === undefined ? "" : v.trim();
}
const bool = (v) => /^(true|1|yes|on)$/i.test(String(v).trim());
export function resolveSettings() {
const commentRaw = input("comment-on-pr");
return {
mode: (input("mode") || "check").toLowerCase(),
scanPath: input("path") || ".",
exclude: input("exclude") || process.env.POLINRIDER_EXCLUDE || "",
token:
input("token") || process.env.GH_TOKEN || process.env.GITHUB_TOKEN || "",
failOn: (input("fail-on") || "infected").toLowerCase(),
commit: bool(input("commit")), // default false
commentOnPr: commentRaw === "" ? true : bool(commentRaw),
sarifFile: input("sarif-file") || process.env.SARIF_FILE || "",
dryRun: input("dry-run")
? bool(input("dry-run"))
: process.env.DRY_RUN === "true",
autoMerge: input("auto-merge")
? bool(input("auto-merge"))
: process.env.AUTO_MERGE === "true",
mergeMethod: (
input("merge-method") ||
process.env.MERGE_METHOD ||
"squash"
).toLowerCase(),
branchPrefix:
input("branch-prefix") ||
process.env.BRANCH_PREFIX ||
"fix/polinrider-cleanup",
};
}
// ─── GitHub Actions I/O (outputs, summary, annotations) ──────────────────────
function setOutput(name, value) {
const file = process.env.GITHUB_OUTPUT;
if (!file) return;
try {
fs.appendFileSync(file, `${name}=${value}\n`);
} catch {
/* ignore — outputs are best-effort */
}
}
function appendSummary(md) {
const file = process.env.GITHUB_STEP_SUMMARY;
if (!file) return;
try {
fs.appendFileSync(file, md.endsWith("\n") ? md : md + "\n");
} catch {
/* ignore */
}
}
// Workflow-command escaping differs between the message and property values.
const escData = (s) =>
String(s).replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A");
const escProp = (s) => escData(s).replace(/,/g, "%2C").replace(/:/g, "%3A");
function annotate(level, { file, line, title, message }) {
const props = [];
if (file) props.push(`file=${escProp(file)}`);
if (line) props.push(`line=${line}`);
if (title) props.push(`title=${escProp(title)}`);
console.log(
`::${level}${props.length ? " " + props.join(",") : ""}::${escData(message)}`,
);
}
const notice = (m) => console.log(`::notice::${escData(m)}`);
const warn = (m) => console.log(`::warning::${escData(m)}`);
// ─── Severity / line helpers ─────────────────────────────────────────────────
const SEVERITY_RANK = { clean: 0, suspicious: 1, infected: 2 };
export function failThreshold(failOn) {
if (failOn === "never") return Infinity;
if (failOn === "suspicious") return SEVERITY_RANK.suspicious;
return SEVERITY_RANK.infected; // default + "infected"
}
/** 1-based line for a finding that carries a char offset (js payloads), else 1. */
function findingLine(repoDir, f) {
const offset = f.edit?.offset;
if (!(offset > 0)) return 1;
try {
const text = fs.readFileSync(path.join(repoDir, f.file), "utf8");
if (offset > text.length) return 1;
return text.slice(0, offset).split("\n").length;
} catch {
return 1;
}
}
/** Repo-root-relative path so GitHub maps the annotation onto the PR diff. */
function annotationPath(workspace, repoDir, relFile) {
return path.relative(workspace, path.join(repoDir, relFile)) || relFile;
}
function emitAnnotations(workspace, repoDir, findings) {
for (const f of findings.findings) {
annotate(f.contentConfirmed ? "error" : "warning", {
file: annotationPath(workspace, repoDir, f.file),
line: findingLine(repoDir, f),
title: f.contentConfirmed ? "PolinRider malware" : "PolinRider (review)",
message: f.description,
});
}
}
// ─── Run report (job summary + PR comment body) ──────────────────────────────
export function buildSummary({ settings, findings, result }) {
const icon =
findings.severity === "infected"
? "🔴"
: findings.severity === "suspicious"
? "🟡"
: "🟢";
const out = [
"## 🛡️ PolinRider Malware Scan",
"",
`**Mode:** \`${settings.mode}\`${settings.dryRun ? " (dry run)" : ""} · **Severity:** ${icon} \`${findings.severity}\` · **Findings:** ${findings.findings.length}`,
"",
];
if (findings.findings.length) {
out.push("### Findings");
for (const l of findingLines(findings)) out.push(`- ${l}`);
out.push("");
} else {
out.push("No PolinRider signatures found. ✅", "");
}
if (result) {
out.push(
settings.dryRun ? "### Planned remediation (dry run)" : "### Remediation",
);
const lines = resultLines(result);
if (lines.length) for (const l of lines) out.push(`- ${l}`);
else out.push("- (no changes)");
out.push("");
}
if (findings.manualReview.length) {
out.push("### Needs manual review");
for (const m of findings.manualReview) out.push(`- ${m}`);
out.push("");
}
out.push("---", `_Generated by [polinrider-cleanup](${PROJECT_URL})._`);
return out.join("\n");
}
// ─── git identity / commit-back / PR (fix & pr modes) ────────────────────────
async function configureIdentity(repoDir) {
await safeGit(["-C", repoDir, "config", "user.email", BOT_EMAIL]);
await safeGit(["-C", repoDir, "config", "user.name", BOT_NAME]);
}
const tokenUrl = (fullName, token) =>
`https://x-access-token:${token}@github.com/${fullName}.git`;
function readEvent() {
const p = process.env.GITHUB_EVENT_PATH;
if (!p) return null;
try {
return JSON.parse(fs.readFileSync(p, "utf8"));
} catch {
return null;
}
}
/** fix + commit=true → commit the cleaned tree and push it back to the branch. */
async function commitBack(repoDir, settings) {
const event = process.env.GITHUB_EVENT_NAME || "";
if (event.startsWith("pull_request")) {
warn(
"commit skipped on pull_request events (detached HEAD / forks can't be pushed). Use mode: pr, or run fix on push events.",
);
return false;
}
const fullName = process.env.GITHUB_REPOSITORY;
if (!fullName || !settings.token) {
warn("commit skipped — GITHUB_REPOSITORY or token unavailable.");
return false;
}
let branch = process.env.GITHUB_REF_NAME;
if (!branch) {
const rp = await safeGit([
"-C",
repoDir,
"rev-parse",
"--abbrev-ref",
"HEAD",
]);
branch = rp.stdout.trim();
}
if (!branch || branch === "HEAD") {
warn(
"commit skipped — could not resolve a branch to push to (detached HEAD).",
);
return false;
}
await configureIdentity(repoDir);
await safeGit(["-C", repoDir, "add", "-A"]);
const staged = await safeGit([
"-C",
repoDir,
"diff",
"--cached",
"--name-only",
]);
if (!staged.stdout.trim()) return false;
const commit = await safeGit([
"-C",
repoDir,
"commit",
"-m",
"security: remove PolinRider malware artifacts\n\nAutomated cleanup by the polinrider-cleanup action.",
]);
if (commit.exitCode !== 0) {
warn(`commit failed: ${oneLine(commit.stderr)}`);
return false;
}
const push = await safeGit([
"-C",
repoDir,
"push",
tokenUrl(fullName, settings.token),
`HEAD:${branch}`,
]);
if (push.exitCode !== 0) {
warn(
`push to ${branch} failed (protected branch?): ${oneLine(push.stderr)}`,
);
return false;
}
notice(`Cleaned files committed and pushed to ${branch}.`);
return true;
}
/** pr mode → new branch, commit, push, open PR. Mirrors index.js openPR(). */
async function openCleanupPr(repoDir, settings, findings, result) {
const fullName = process.env.GITHUB_REPOSITORY;
if (!fullName) throw new Error("GITHUB_REPOSITORY is not set");
if (!settings.token)
throw new Error(
"a token is required for mode: pr (set `token:` or GH_TOKEN)",
);
const branch = `${settings.branchPrefix}-${Date.now()}`;
await configureIdentity(repoDir);
const co = await safeGit(["-C", repoDir, "checkout", "-b", branch]);
if (co.exitCode !== 0)
throw new Error(`branch creation failed: ${oneLine(co.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",
`${PR_TITLE}\n\nAutomated surgical cleanup by the polinrider-cleanup action.`,
]);
if (commit.exitCode !== 0)
throw new Error(`commit failed: ${oneLine(commit.stderr)}`);
const push = await safeGit([
"-C",
repoDir,
"push",
tokenUrl(fullName, settings.token),
branch,
]);
if (push.exitCode !== 0)
throw new Error(`push failed: ${oneLine(push.stderr)}`);
const body = buildPrBody({ findings, result });
const base = [
"pr",
"create",
"--repo",
fullName,
"--title",
PR_TITLE,
"--body",
body,
"--head",
branch,
];
let pr = await safeGh([...base, "--label", "security"], {}, settings.token);
if (pr.exitCode !== 0) pr = await safeGh(base, {}, settings.token); // retry without label (may not exist)
if (pr.exitCode !== 0)
throw new Error(`PR creation failed: ${oneLine(pr.stderr)}`);
const url = pr.stdout.trim();
if (!settings.autoMerge) return { url, merged: false, mergeError: null };
const mergeArgs = [
"pr",
"merge",
url,
`--${settings.mergeMethod}`,
"--delete-branch",
];
let merge = await safeGh(mergeArgs, {}, settings.token);
if (merge.exitCode !== 0) {
await sleep(3000);
merge = await safeGh(mergeArgs, {}, settings.token);
}
if (merge.exitCode === 0) return { url, merged: true, mergeError: null };
return {
url,
merged: false,
mergeError: oneLine(merge.stderr || merge.stdout) || "merge failed",
};
}
/** Post the run report as a PR comment when on a pull_request event. */
async function postPrComment(settings, summaryMd) {
if (!settings.commentOnPr) return;
if (!(process.env.GITHUB_EVENT_NAME || "").startsWith("pull_request")) return;
if (!settings.token) return;
const ev = readEvent();
const num = ev?.pull_request?.number ?? ev?.number;
const fullName = process.env.GITHUB_REPOSITORY;
if (!num || !fullName) return;
const res = await safeGh(
["pr", "comment", String(num), "--repo", fullName, "--body", summaryMd],
{},
settings.token,
);
if (res.exitCode !== 0)
warn(
`could not post PR comment (needs pull-requests: write): ${oneLine(res.stderr)}`,
);
}
// ─── Main ────────────────────────────────────────────────────────────────────
export async function run() {
const settings = resolveSettings();
if (!["check", "fix", "pr"].includes(settings.mode)) {
console.log(
`::error::invalid mode "${settings.mode}" (expected check | fix | pr)`,
);
return 2;
}
const workspace = process.env.GITHUB_WORKSPACE || process.cwd();
const repoDir = path.resolve(workspace, settings.scanPath);
console.log(
`PolinRider scan — mode=${settings.mode} path=${path.relative(workspace, repoDir) || "."}` +
`${settings.dryRun ? " (dry run)" : ""}`,
);
const findings = await scanRepo(repoDir, { exclude: settings.exclude });
emitAnnotations(workspace, repoDir, findings);
let result = null;
let prUrl = "";
let changed = false;
if (
(settings.mode === "fix" || settings.mode === "pr") &&
findings.severity === "infected"
) {
result = await remediate(repoDir, findings, { dryRun: settings.dryRun });
changed = result.changed;
if (
settings.mode === "fix" &&
settings.commit &&
!settings.dryRun &&
changed
) {
await commitBack(repoDir, settings);
}
if (settings.mode === "pr" && !settings.dryRun) {
if (changed) {
try {
const pr = await openCleanupPr(repoDir, settings, findings, result);
if (pr.skipped) {
warn(`PR skipped: ${pr.reason}`);
} else {
prUrl = pr.url;
if (pr.merged) notice(`Cleanup PR merged: ${prUrl}`);
else if (pr.mergeError)
warn(
`Cleanup PR opened (auto-merge blocked: ${pr.mergeError}): ${prUrl}`,
);
else notice(`Cleanup PR opened: ${prUrl}`);
}
} catch (e) {
warn(`PR mode failed: ${e.message}`);
}
} else {
warn(
"Infected, but only manual-review items remain — no automated changes to open a PR for.",
);
}
}
}
// Optional SARIF for code-scanning upload.
if (settings.sarifFile) {
try {
const sarif = buildSarif(findings, { repoRoot: workspace, repoDir });
const dest = path.resolve(settings.sarifFile);
await fsp.mkdir(path.dirname(dest), { recursive: true });
await fsp.writeFile(dest, JSON.stringify(sarif, null, 2));
setOutput("sarif-file", settings.sarifFile);
} catch (e) {
warn(`could not write SARIF: ${e.message}`);
}
}
// Report: job summary + PR comment.
const summaryMd = buildSummary({ settings, findings, result });
appendSummary(summaryMd);
await postPrComment(settings, summaryMd);
// Outputs.
const remediated = !!(
result &&
(result.filesModified.length || result.filesDeleted.length)
);
setOutput("severity", findings.severity);
setOutput("infected", String(findings.severity === "infected"));
setOutput("findings-count", String(findings.findings.length));
setOutput("changed", String(changed));
setOutput("remediated", String(remediated));
if (prUrl) setOutput("pr-url", prUrl);
// Exit code.
const threshold = failThreshold(settings.failOn);
const sev = SEVERITY_RANK[findings.severity] ?? 0;
const wouldFailSeverity = threshold !== Infinity && sev >= threshold;
let code = 0;
if (settings.mode === "check" || settings.dryRun) {
code = wouldFailSeverity ? 1 : 0;
} else if (settings.mode === "fix") {
// Cleaned what it could; fail only if confirmed items remain unresolved.
const unresolved =
findings.findings.filter(
(f) => f.contentConfirmed && f.action === "manual-review",
).length +
(result?.skipped?.filter((s) => s.finding.contentConfirmed).length ?? 0);
code =
threshold !== Infinity &&
findings.severity === "infected" &&
unresolved > 0
? 1
: 0;
} else if (settings.mode === "pr") {
// PR opened (or nothing to fix). Fail only if infected and no PR was opened.
code =
threshold !== Infinity && findings.severity === "infected" && !prUrl
? 1
: 0;
}
console.log(
findings.severity === "infected"
? `Result: INFECTED — ${findings.findings.length} finding(s).`
: findings.severity === "suspicious"
? "Result: suspicious file layout — no confirmed payload."
: "Result: clean.",
);
return code;
}
// Auto-run when invoked directly (the bundled action, or `node src/ci.js`), but
// NOT when imported by a test.
const invokedDirectly =
process.argv[1] &&
path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
if (invokedDirectly) {
run()
.then((code) => process.exit(code))
.catch((err) => {
console.log(`::error::${escData(err.message)}`);
process.exit(1);
});
}