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.
This commit is contained in:
@@ -0,0 +1,536 @@
|
||||
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);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Build a path matcher from a list of ignore patterns, so a scan can skip files
|
||||
* that are known-legitimate but would otherwise match (e.g. this very tool's own
|
||||
* signature definitions and payload fixtures). Dependency-free — a minimal glob.
|
||||
*
|
||||
* Patterns are matched against POSIX, repo-relative paths. Supported forms:
|
||||
* - exact file: src/signatures.js
|
||||
* - directory: dist (or dist/ — matches everything under it)
|
||||
* - glob: test/** **\/fixtures/** *.min.js
|
||||
* `*` matches within a path segment; `**` matches across segments.
|
||||
*/
|
||||
|
||||
const normalize = (p) =>
|
||||
(p || "").replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/+$/, "");
|
||||
|
||||
function globToRegExp(glob) {
|
||||
let re = "";
|
||||
for (let i = 0; i < glob.length; i++) {
|
||||
const c = glob[i];
|
||||
if (c === "*") {
|
||||
if (glob[i + 1] === "*") {
|
||||
re += ".*"; // ** → any chars, including "/"
|
||||
i++;
|
||||
} else {
|
||||
re += "[^/]*"; // * → any chars except "/"
|
||||
}
|
||||
} else if (c === "?") {
|
||||
re += "[^/]";
|
||||
} else if ("\\^$.|+()[]{}".includes(c)) {
|
||||
re += "\\" + c;
|
||||
} else {
|
||||
re += c;
|
||||
}
|
||||
}
|
||||
return new RegExp("^" + re + "$");
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string[]|string} patterns array, or comma/newline-separated string
|
||||
* @returns {(relPosixPath: string) => boolean}
|
||||
*/
|
||||
export function buildExcluder(patterns) {
|
||||
const list = Array.isArray(patterns)
|
||||
? patterns
|
||||
: String(patterns || "").split(/[,\n]/);
|
||||
const specs = list
|
||||
.map((p) => normalize(p.trim?.() ?? p))
|
||||
.filter(Boolean)
|
||||
.map((n) => ({ prefix: n, re: globToRegExp(n) }));
|
||||
|
||||
if (specs.length === 0) return () => false;
|
||||
return (rel) => {
|
||||
const r = (rel || "").replace(/\\/g, "/");
|
||||
return specs.some((s) => r === s.prefix || r.startsWith(s.prefix + "/") || s.re.test(r));
|
||||
};
|
||||
}
|
||||
+176
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* Font payload-carrier analysis.
|
||||
*
|
||||
* PolinRider has a variant that drops a malicious file into public/fonts (or
|
||||
* static/, assets/fonts/). We flag a font ONLY when it is both:
|
||||
* 1. unreferenced — no CSS/HTML/JS in the repo mentions its filename, and
|
||||
* 2. suspicious — it does NOT parse as a structurally valid font.
|
||||
*
|
||||
* "Structurally valid" is the primary signal: a real font has valid magic AND a
|
||||
* well-formed table directory / header whose declared extents stay inside the
|
||||
* file. A genuine font is trusted unconditionally — even when its bytes happen to
|
||||
* contain a license URL or a long base64-looking run of binary glyph data (both
|
||||
* of which previously caused false positives on legitimate fonts).
|
||||
*
|
||||
* The real carrier is the opposite of a font: it has no font magic at all — it is
|
||||
* readable JavaScript text (`global['!']=…; require(…); eval(…)`). Such a file
|
||||
* fails structural validation, and the presence of code-execution strings
|
||||
* (FONT_BADNESS_RES) confirms it is an appended payload rather than a merely
|
||||
* corrupt font.
|
||||
*
|
||||
* Reading the file as a Buffer and scanning bytes never executes anything.
|
||||
*/
|
||||
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { collectByExtension, DEFAULT_EXCLUDE } from "./walk.js";
|
||||
import { FONT_MAGIC, FONT_BADNESS_RES, FONT_REF_EXTENSIONS } from "./signatures.js";
|
||||
|
||||
// A reference inside .vscode (e.g. a malicious task pointing at its payload font)
|
||||
// must NOT count as legitimate usage.
|
||||
const REF_EXCLUDE = new Set([...DEFAULT_EXCLUDE, ".vscode"]);
|
||||
|
||||
const NO_RELIABLE_MAGIC = new Set([".eot"]); // EOT lacks a single stable magic
|
||||
const BYTE_SCAN_LIMIT = 256 * 1024; // scan at most 256 KB for embedded strings
|
||||
const REF_HAYSTACK_LIMIT = 8 * 1024 * 1024; // cap concatenated reference text
|
||||
|
||||
/** Identify a font format from the first 4 bytes, or 'unknown'. */
|
||||
export function readMagic(buf) {
|
||||
if (!Buffer.isBuffer(buf) || buf.length < 4) return "unknown";
|
||||
for (const [fmt, sig] of Object.entries(FONT_MAGIC)) {
|
||||
if (!sig) continue;
|
||||
if (sig.every((b, idx) => buf[idx] === b)) return fmt;
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a buffer's font structure from its header/table directory alone.
|
||||
* Pure and byte-only: never executes anything, never allocates based on the
|
||||
* file's own declared sizes.
|
||||
*
|
||||
* A recognized magic whose declared extents stay within the file is treated as a
|
||||
* genuine font. When the magic is recognized but the specific variant is one we
|
||||
* don't fully parse, we give the benefit of the doubt (valid) — the downstream
|
||||
* blast radius is file deletion, so we only reject when we are confident the
|
||||
* bytes are NOT a font.
|
||||
*
|
||||
* @param {Buffer} buf
|
||||
* @returns {{ valid: boolean, format: string, reason?: string }}
|
||||
*/
|
||||
export function parseFontStructure(buf) {
|
||||
const format = readMagic(buf);
|
||||
if (format === "unknown") {
|
||||
return { valid: false, format, reason: "no recognizable font magic bytes" };
|
||||
}
|
||||
|
||||
// WOFF / WOFF2: header declares the total file length at offset 8 and the table
|
||||
// count at offset 12 — both trivially cross-checkable against the actual bytes.
|
||||
if (format === "woff" || format === "woff2") {
|
||||
if (buf.length >= 14 && buf.readUInt32BE(8) === buf.length && buf.readUInt16BE(12) > 0) {
|
||||
return { valid: true, format };
|
||||
}
|
||||
return { valid: false, format, reason: `malformed ${format} header (declared length or table count invalid)` };
|
||||
}
|
||||
|
||||
// TrueType Collection: numFonts at offset 8, then that many sfnt-header offsets.
|
||||
if (format === "ttc") {
|
||||
if (buf.length >= 12) {
|
||||
const numFonts = buf.readUInt32BE(8);
|
||||
if (numFonts >= 1 && numFonts <= 256 && 12 + numFonts * 4 <= buf.length) {
|
||||
let ok = true;
|
||||
for (let i = 0; i < numFonts; i++) {
|
||||
if (buf.readUInt32BE(12 + i * 4) + 12 > buf.length) {
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (ok) return { valid: true, format };
|
||||
}
|
||||
}
|
||||
return { valid: false, format, reason: "malformed TrueType Collection header" };
|
||||
}
|
||||
|
||||
// sfnt (ttf / otf / legacy 'true'): numTables at offset 4, then a 16-byte table
|
||||
// directory. Every table's (offset + length) must stay within the file.
|
||||
const numTables = buf.length >= 6 ? buf.readUInt16BE(4) : 0;
|
||||
const dirEnd = 12 + numTables * 16;
|
||||
if (numTables < 1 || numTables > 4096 || dirEnd > buf.length) {
|
||||
return { valid: false, format, reason: "malformed font table directory" };
|
||||
}
|
||||
for (let i = 0; i < numTables; i++) {
|
||||
const rec = 12 + i * 16;
|
||||
if (buf.readUInt32BE(rec + 8) + buf.readUInt32BE(rec + 12) > buf.length) {
|
||||
return { valid: false, format, reason: "font table points past end of file" };
|
||||
}
|
||||
}
|
||||
return { valid: true, format };
|
||||
}
|
||||
|
||||
/** Scan a font's leading bytes for code-execution strings a real font never has. */
|
||||
function scanCodeStrings(buf) {
|
||||
const window = buf.subarray(0, Math.min(buf.length, BYTE_SCAN_LIMIT)).toString("latin1");
|
||||
return FONT_BADNESS_RES.some((re) => re.test(window))
|
||||
? ["contains embedded code-like strings a real font never has (eval/require/global[/etc.)"]
|
||||
: [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Inspect font bytes for signs it isn't a real font.
|
||||
*
|
||||
* A structurally valid font is trusted unconditionally (never `bad`). Otherwise
|
||||
* the file is flagged, and `hasCodeStrings` tells the caller whether it also
|
||||
* carries a code payload — the difference between a confirmed carrier (auto-remove)
|
||||
* and a merely unrecognized blob (manual review).
|
||||
*
|
||||
* @param {Buffer} buf
|
||||
* @param {string} [ext] file extension (e.g. ".woff2"). Formats without a reliable
|
||||
* magic number (.eot) can only be flagged by embedded code strings.
|
||||
* @returns {{ bad: boolean, magic: string, reasons: string[], hasCodeStrings: boolean }}
|
||||
*/
|
||||
export function looksSuspicious(buf, ext = "") {
|
||||
// EOT has no single stable magic; never reject it structurally — only an
|
||||
// embedded code payload makes an .eot suspicious.
|
||||
if (NO_RELIABLE_MAGIC.has(ext.toLowerCase())) {
|
||||
const reasons = scanCodeStrings(buf);
|
||||
return { bad: reasons.length > 0, magic: readMagic(buf), reasons, hasCodeStrings: reasons.length > 0 };
|
||||
}
|
||||
|
||||
const struct = parseFontStructure(buf);
|
||||
if (struct.valid) {
|
||||
return { bad: false, magic: struct.format, reasons: [], hasCodeStrings: false };
|
||||
}
|
||||
|
||||
const codeReasons = scanCodeStrings(buf);
|
||||
return {
|
||||
bad: true,
|
||||
magic: struct.format,
|
||||
reasons: [struct.reason, ...codeReasons],
|
||||
hasCodeStrings: codeReasons.length > 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a lowercased haystack of every file that could reference a font, so we
|
||||
* can cheaply test whether a font filename is used anywhere. Conservative by
|
||||
* design: a substring match counts as "referenced" (we'd rather keep a file
|
||||
* than wrongly delete a legitimate, referenced font).
|
||||
*/
|
||||
export async function collectFontReferences(repoDir) {
|
||||
const files = await collectByExtension(repoDir, FONT_REF_EXTENSIONS, { exclude: REF_EXCLUDE });
|
||||
let haystack = "";
|
||||
for (const f of files) {
|
||||
if (haystack.length > REF_HAYSTACK_LIMIT) break;
|
||||
try {
|
||||
haystack += "\n" + (await fs.readFile(f, "utf8")).toLowerCase();
|
||||
} catch {
|
||||
/* unreadable file → skip */
|
||||
}
|
||||
}
|
||||
return haystack;
|
||||
}
|
||||
|
||||
/** True if the font's basename appears anywhere in the reference haystack. */
|
||||
export function isReferenced(haystack, fontPath) {
|
||||
return haystack.includes(path.basename(fontPath).toLowerCase());
|
||||
}
|
||||
+376
@@ -0,0 +1,376 @@
|
||||
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);
|
||||
});
|
||||
}
|
||||
+231
@@ -0,0 +1,231 @@
|
||||
/**
|
||||
* Tolerant JSONC parser for VS Code config files (which legitimately contain
|
||||
* // and /* *\/ comments and trailing commas), plus an offset-based array
|
||||
* splicer used for surgical removal of malicious task/launch entries.
|
||||
*
|
||||
* SAFETY: this parser is hand-written and uses ONLY string scanning + the
|
||||
* built-in JSON.parse (on isolated string literals). It NEVER uses eval, the
|
||||
* Function constructor, or node:vm. Parsing an attacker-controlled .vscode file
|
||||
* therefore cannot execute anything.
|
||||
*
|
||||
* The AST records source offsets so the remediator can remove only the
|
||||
* malicious entries and leave the surrounding bytes (and other entries) intact.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} Node
|
||||
* @property {'object'|'array'|'string'|'number'|'boolean'|'null'} type
|
||||
* @property {*} value Decoded JS value of this node.
|
||||
* @property {number} start Offset of the first character (inclusive).
|
||||
* @property {number} end Offset just past the last character (exclusive).
|
||||
* @property {Array} [elements] For arrays: child value Nodes, in order.
|
||||
* @property {Array} [members] For objects: { key, keyNode, valueNode } entries.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Parse JSONC text.
|
||||
* @returns {{ ok: boolean, value: *, ast: Node|null, error: Error|null }}
|
||||
*/
|
||||
export function parseJsonc(text) {
|
||||
let i = 0;
|
||||
const n = text.length;
|
||||
|
||||
const fail = (msg) => {
|
||||
const e = new Error(`JSONC parse error at offset ${i}: ${msg}`);
|
||||
e.offset = i;
|
||||
return e;
|
||||
};
|
||||
|
||||
function skipWs() {
|
||||
while (i < n) {
|
||||
const c = text[i];
|
||||
if (c === " " || c === "\t" || c === "\n" || c === "\r" || c === "") {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (c === "/" && text[i + 1] === "/") {
|
||||
i += 2;
|
||||
while (i < n && text[i] !== "\n") i++;
|
||||
continue;
|
||||
}
|
||||
if (c === "/" && text[i + 1] === "*") {
|
||||
i += 2;
|
||||
while (i < n && !(text[i] === "*" && text[i + 1] === "/")) i++;
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function parseString() {
|
||||
const start = i;
|
||||
if (text[i] !== '"') throw fail("expected string");
|
||||
i++;
|
||||
while (i < n) {
|
||||
const c = text[i];
|
||||
if (c === "\\") {
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if (c === '"') {
|
||||
i++;
|
||||
break;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
const raw = text.slice(start, i);
|
||||
let value;
|
||||
try {
|
||||
value = JSON.parse(raw); // decode escapes safely; no code execution
|
||||
} catch {
|
||||
throw fail("invalid string literal");
|
||||
}
|
||||
return { type: "string", value, start, end: i };
|
||||
}
|
||||
|
||||
function parseNumber() {
|
||||
const start = i;
|
||||
const m = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/.exec(text.slice(i));
|
||||
if (!m) throw fail("invalid number");
|
||||
i += m[0].length;
|
||||
return { type: "number", value: Number(m[0]), start, end: i };
|
||||
}
|
||||
|
||||
function parseObject() {
|
||||
const start = i;
|
||||
i++; // {
|
||||
const members = [];
|
||||
const value = {};
|
||||
skipWs();
|
||||
while (text[i] !== "}") {
|
||||
if (i >= n) throw fail("unterminated object");
|
||||
const keyNode = parseString();
|
||||
skipWs();
|
||||
if (text[i] !== ":") throw fail("expected ':'");
|
||||
i++;
|
||||
const valueNode = parseValue();
|
||||
members.push({ key: keyNode.value, keyNode, valueNode });
|
||||
value[keyNode.value] = valueNode.value;
|
||||
skipWs();
|
||||
if (text[i] === ",") {
|
||||
i++;
|
||||
skipWs();
|
||||
} else break;
|
||||
}
|
||||
if (text[i] !== "}") throw fail("expected '}'");
|
||||
i++;
|
||||
return { type: "object", members, value, start, end: i };
|
||||
}
|
||||
|
||||
function parseArray() {
|
||||
const start = i;
|
||||
i++; // [
|
||||
const elements = [];
|
||||
const value = [];
|
||||
skipWs();
|
||||
while (text[i] !== "]") {
|
||||
if (i >= n) throw fail("unterminated array");
|
||||
const el = parseValue();
|
||||
elements.push(el);
|
||||
value.push(el.value);
|
||||
skipWs();
|
||||
if (text[i] === ",") {
|
||||
i++;
|
||||
skipWs();
|
||||
} else break;
|
||||
}
|
||||
if (text[i] !== "]") throw fail("expected ']'");
|
||||
i++;
|
||||
return { type: "array", elements, value, start, end: i };
|
||||
}
|
||||
|
||||
function parseValue() {
|
||||
skipWs();
|
||||
if (i >= n) throw fail("unexpected end of input");
|
||||
const c = text[i];
|
||||
if (c === "{") return parseObject();
|
||||
if (c === "[") return parseArray();
|
||||
if (c === '"') return parseString();
|
||||
if (c === "-" || (c >= "0" && c <= "9")) return parseNumber();
|
||||
if (text.startsWith("true", i)) {
|
||||
const s = i;
|
||||
i += 4;
|
||||
return { type: "boolean", value: true, start: s, end: i };
|
||||
}
|
||||
if (text.startsWith("false", i)) {
|
||||
const s = i;
|
||||
i += 5;
|
||||
return { type: "boolean", value: false, start: s, end: i };
|
||||
}
|
||||
if (text.startsWith("null", i)) {
|
||||
const s = i;
|
||||
i += 4;
|
||||
return { type: "null", value: null, start: s, end: i };
|
||||
}
|
||||
throw fail(`unexpected character ${JSON.stringify(c)}`);
|
||||
}
|
||||
|
||||
try {
|
||||
skipWs();
|
||||
const ast = parseValue();
|
||||
skipWs();
|
||||
if (i < n) throw fail("trailing content after top-level value");
|
||||
return { ok: true, value: ast.value, ast, error: null };
|
||||
} catch (error) {
|
||||
return { ok: false, value: undefined, ast: null, error };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the member's value Node for `key` on an object Node, or undefined.
|
||||
*/
|
||||
export function getMember(objectNode, key) {
|
||||
if (!objectNode || objectNode.type !== "object") return undefined;
|
||||
const m = objectNode.members.find((mem) => mem.key === key);
|
||||
return m ? m.valueNode : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the array elements at `indicesToRemove` from `text`, given the array's
|
||||
* AST Node. Removes each element together with one adjacent separator (the
|
||||
* comma + surrounding whitespace) by deleting the span up to the next element's
|
||||
* start — so the result stays valid JSON and the surviving elements keep their
|
||||
* exact source bytes. Overlapping spans (e.g. removing adjacent elements) are
|
||||
* merged before applying.
|
||||
*
|
||||
* @returns {string} the edited text
|
||||
*/
|
||||
export function removeArrayElements(text, arrayNode, indicesToRemove) {
|
||||
if (!arrayNode || arrayNode.type !== "array") {
|
||||
throw new Error("removeArrayElements: arrayNode must be an array Node");
|
||||
}
|
||||
const remove = new Set(indicesToRemove);
|
||||
const els = arrayNode.elements;
|
||||
const spans = [];
|
||||
for (let k = 0; k < els.length; k++) {
|
||||
if (!remove.has(k)) continue;
|
||||
if (k < els.length - 1) {
|
||||
spans.push([els[k].start, els[k + 1].start]); // element + trailing comma/ws
|
||||
} else if (k > 0) {
|
||||
spans.push([els[k - 1].end, els[k].end]); // leading comma/ws + last element
|
||||
} else {
|
||||
spans.push([els[k].start, els[k].end]); // sole element
|
||||
}
|
||||
}
|
||||
if (spans.length === 0) return text;
|
||||
|
||||
// Merge overlapping/adjacent spans, then delete from the end backward so
|
||||
// earlier offsets stay valid.
|
||||
spans.sort((a, b) => a[0] - b[0]);
|
||||
const merged = [spans[0].slice()];
|
||||
for (let s = 1; s < spans.length; s++) {
|
||||
const last = merged[merged.length - 1];
|
||||
if (spans[s][0] <= last[1]) last[1] = Math.max(last[1], spans[s][1]);
|
||||
else merged.push(spans[s].slice());
|
||||
}
|
||||
merged.sort((a, b) => b[0] - a[0]);
|
||||
let out = text;
|
||||
for (const [start, end] of merged) out = out.slice(0, start) + out.slice(end);
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
/**
|
||||
* Surgical remediation. Consumes a Findings report and removes ONLY what is
|
||||
* confirmed malicious, preserving legitimate code, tasks, launch configs, and
|
||||
* fonts. Every edit is re-verified against the current file content at apply
|
||||
* time, so a stale finding can never truncate the wrong bytes.
|
||||
*
|
||||
* dryRun=true records what WOULD change without touching any file.
|
||||
*
|
||||
* @typedef {Object} RemediationResult
|
||||
* @property {boolean} changed true iff files were actually modified/deleted
|
||||
* @property {boolean} dryRun
|
||||
* @property {Array} applied findings (or hygiene steps) that were acted on
|
||||
* @property {Array<{finding:Object, reason:string}>} skipped
|
||||
* @property {string[]} filesModified
|
||||
* @property {string[]} filesDeleted
|
||||
* @property {string[]} notes hygiene actions (gitignore, env untracking)
|
||||
*/
|
||||
|
||||
import fs from "node:fs/promises";
|
||||
import { existsSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import * as sig from "./signatures.js";
|
||||
import { locatePayloadOffset } from "./scanner.js";
|
||||
import { safeGit } from "./safe-exec.js";
|
||||
|
||||
/**
|
||||
* @param {string} repoDir
|
||||
* @param {import('./scanner.js').Findings} findings
|
||||
* @param {{ dryRun?: boolean, git?: Function }} [opts]
|
||||
* @returns {Promise<RemediationResult>}
|
||||
*/
|
||||
export async function remediate(repoDir, findings, opts = {}) {
|
||||
const { dryRun = false, git = safeGit } = opts;
|
||||
const result = {
|
||||
changed: false,
|
||||
dryRun,
|
||||
applied: [],
|
||||
skipped: [],
|
||||
filesModified: [],
|
||||
filesDeleted: [],
|
||||
notes: [],
|
||||
};
|
||||
|
||||
for (const f of findings.findings) {
|
||||
switch (f.action) {
|
||||
case "strip-js-payload":
|
||||
await stripJsPayload(repoDir, f, result, dryRun);
|
||||
break;
|
||||
case "remove-dir":
|
||||
await deleteDir(f, result, dryRun);
|
||||
break;
|
||||
case "delete-font":
|
||||
case "remove-artifact":
|
||||
await deleteFile(f, result, dryRun);
|
||||
break;
|
||||
case "remove-font-set":
|
||||
await deleteFileSet(f, result, dryRun);
|
||||
break;
|
||||
case "fix-gitignore":
|
||||
// handled by the always-on gitignore hygiene step below
|
||||
break;
|
||||
case "manual-review":
|
||||
result.skipped.push({ finding: f, reason: "manual review required — not auto-removed" });
|
||||
break;
|
||||
default:
|
||||
result.skipped.push({ finding: f, reason: `unknown action "${f.action}"` });
|
||||
}
|
||||
}
|
||||
|
||||
// Hygiene (idempotent): undo the malware's .gitignore tampering and unexpose
|
||||
// any committed secrets. Mirrors the original remediate.sh behavior.
|
||||
await hardenGitignore(repoDir, result, dryRun);
|
||||
await untrackEnvFiles(repoDir, result, dryRun, git);
|
||||
|
||||
result.changed =
|
||||
!dryRun && (result.filesModified.length > 0 || result.filesDeleted.length > 0);
|
||||
return result;
|
||||
}
|
||||
|
||||
function recordModified(result, file) {
|
||||
if (!result.filesModified.includes(file)) result.filesModified.push(file);
|
||||
}
|
||||
function recordDeleted(result, file) {
|
||||
if (!result.filesDeleted.includes(file)) result.filesDeleted.push(file);
|
||||
}
|
||||
|
||||
// ─── strip-js-payload ────────────────────────────────────────────────────────────
|
||||
|
||||
async function stripJsPayload(repoDir, f, result, dryRun) {
|
||||
const absPath = f.edit?.absPath ?? path.join(repoDir, f.file);
|
||||
let text;
|
||||
try {
|
||||
text = await fs.readFile(absPath, "utf8");
|
||||
} catch {
|
||||
result.skipped.push({ finding: f, reason: "file unreadable" });
|
||||
return;
|
||||
}
|
||||
// Re-locate against current content rather than trusting the stored offset.
|
||||
const variant = sig.JS_VARIANTS.find((v) => v.id === f.edit?.variantId);
|
||||
const offset = variant ? locatePayloadOffset(text, variant) : f.edit?.offset ?? -1;
|
||||
if (!(offset > 0) || offset > text.length) {
|
||||
result.skipped.push({ finding: f, reason: "could not locate payload start safely" });
|
||||
return;
|
||||
}
|
||||
const kept = text.slice(0, offset).replace(/\s+$/, "");
|
||||
if (kept.length === 0) {
|
||||
result.skipped.push({ finding: f, reason: "stripping would empty the file" });
|
||||
return;
|
||||
}
|
||||
if (!dryRun) await fs.writeFile(absPath, kept + "\n", "utf8");
|
||||
result.applied.push(f);
|
||||
recordModified(result, f.file);
|
||||
}
|
||||
|
||||
// ─── remove-dir (whole .vscode / fonts directory) ───────────────────────────────
|
||||
|
||||
async function deleteDir(f, result, dryRun) {
|
||||
const absPath = f.edit?.absPath;
|
||||
if (!absPath || !existsSync(absPath)) {
|
||||
result.skipped.push({ finding: f, reason: "already gone" });
|
||||
return;
|
||||
}
|
||||
if (!dryRun) await fs.rm(absPath, { recursive: true, force: true });
|
||||
result.applied.push(f);
|
||||
recordDeleted(result, f.file);
|
||||
}
|
||||
|
||||
// ─── delete-font / remove-artifact ───────────────────────────────────────────────
|
||||
|
||||
async function deleteFile(f, result, dryRun) {
|
||||
const absPath = f.edit?.absPath;
|
||||
if (!absPath || !existsSync(absPath)) {
|
||||
result.skipped.push({ finding: f, reason: "already gone" });
|
||||
return;
|
||||
}
|
||||
if (!dryRun) await fs.rm(absPath, { force: true });
|
||||
result.applied.push(f);
|
||||
recordDeleted(result, f.file);
|
||||
}
|
||||
|
||||
// ─── remove-font-set (carrier + its fa-* disguise siblings + README) ─────────────
|
||||
//
|
||||
// Removes a specific set of files inside a fonts/ dir while preserving the clean
|
||||
// fonts that share it. Each path is deleted individually and recorded by its
|
||||
// repo-relative name.
|
||||
|
||||
async function deleteFileSet(f, result, dryRun) {
|
||||
const removals = Array.isArray(f.edit?.removals) ? f.edit.removals : [];
|
||||
let any = false;
|
||||
for (const { abs, rel } of removals) {
|
||||
if (!abs || !existsSync(abs)) continue;
|
||||
if (!dryRun) await fs.rm(abs, { force: true });
|
||||
recordDeleted(result, rel);
|
||||
any = true;
|
||||
}
|
||||
if (any) result.applied.push(f);
|
||||
else result.skipped.push({ finding: f, reason: "already gone" });
|
||||
}
|
||||
|
||||
// ─── .gitignore hygiene ──────────────────────────────────────────────────────────
|
||||
|
||||
async function hardenGitignore(repoDir, result, dryRun) {
|
||||
const file = path.join(repoDir, ".gitignore");
|
||||
let content = "";
|
||||
try {
|
||||
content = await fs.readFile(file, "utf8");
|
||||
} catch {
|
||||
content = "";
|
||||
}
|
||||
let lines = content.length ? content.split(/\r?\n/) : [];
|
||||
const original = lines.join("\n");
|
||||
|
||||
// 1. Remove every malware-injected line (config.bat, temp_*.bat, branch_structure.json).
|
||||
lines = lines.filter((l) => !sig.GITIGNORE_INJECTED.includes(l.trim()));
|
||||
// 2. Ensure standard .env patterns are ignored (malware removes them to expose secrets).
|
||||
const present = new Set(lines.map((l) => l.trim()));
|
||||
for (const pat of sig.ENV_PATTERNS) {
|
||||
if (!present.has(pat)) {
|
||||
lines.push(pat);
|
||||
present.add(pat);
|
||||
}
|
||||
}
|
||||
const next = lines.join("\n");
|
||||
if (next === original) return; // nothing to change
|
||||
|
||||
if (!dryRun) {
|
||||
await fs.writeFile(file, next.endsWith("\n") ? next : next + "\n", "utf8");
|
||||
}
|
||||
recordModified(result, ".gitignore");
|
||||
result.notes.push(
|
||||
`${dryRun ? "would remove" : "removed"} malware-injected entries from .gitignore and ensure${
|
||||
dryRun ? "" : "d"
|
||||
} .env patterns are ignored`,
|
||||
);
|
||||
}
|
||||
|
||||
// ─── committed .env untracking ─────────────────────────────────────────────────────
|
||||
|
||||
async function untrackEnvFiles(repoDir, result, dryRun, git) {
|
||||
if (!existsSync(path.join(repoDir, ".git"))) return;
|
||||
const literals = sig.ENV_PATTERNS.filter((p) => !p.includes("*"));
|
||||
for (const pat of literals) {
|
||||
if (!existsSync(path.join(repoDir, pat))) continue;
|
||||
const tracked = await git(["-C", repoDir, "ls-files", "--error-unmatch", pat]);
|
||||
if (tracked.exitCode !== 0) continue; // not committed → nothing to untrack
|
||||
if (dryRun) {
|
||||
result.notes.push(`would untrack ${pat} from the git index`);
|
||||
continue;
|
||||
}
|
||||
const rm = await git(["-C", repoDir, "rm", "--cached", "--force", pat]);
|
||||
if (rm.exitCode === 0) {
|
||||
result.notes.push(`untracked ${pat} from the git index`);
|
||||
recordModified(result, pat);
|
||||
}
|
||||
}
|
||||
}
|
||||
+172
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* Rendering helpers: turn a Findings report + RemediationResult into console
|
||||
* lines and a Markdown PR body. Pure string building — no I/O, no styling
|
||||
* dependency (the orchestrator applies colors).
|
||||
*/
|
||||
|
||||
const PR_TITLE = "security: remove PolinRider malware artifacts";
|
||||
|
||||
/** Plain lines describing what the scanner found (for console output). */
|
||||
export function findingLines(findings) {
|
||||
return findings.findings.map((f) => {
|
||||
const tag = f.contentConfirmed ? "[confirmed]" : "[review]";
|
||||
return `${tag} ${f.file}: ${f.description}`;
|
||||
});
|
||||
}
|
||||
|
||||
/** Plain lines describing what remediation did (or would do). */
|
||||
export function resultLines(result) {
|
||||
const verb = result.dryRun ? "would " : "";
|
||||
const lines = [];
|
||||
for (const f of result.applied) {
|
||||
lines.push(`${verb}fixed ${f.file}: ${f.description ?? f.action}`);
|
||||
}
|
||||
for (const n of result.notes) lines.push(n);
|
||||
for (const s of result.skipped) {
|
||||
lines.push(`skipped ${s.finding.file}: ${s.reason}`);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a Markdown PR body that lists exactly what changed, plus a manual-review
|
||||
* section. Replaces the old static PR_BODY so reviewers see a precise diff.
|
||||
*/
|
||||
export function buildPrBody({ findings, result }) {
|
||||
const out = [];
|
||||
out.push("## PolinRider malware cleanup");
|
||||
out.push("");
|
||||
out.push(
|
||||
"Automatically generated by polinrider-remover. Every change below was confirmed by a known PolinRider signature. The scanner reads files as inert text and never executes them.",
|
||||
);
|
||||
out.push("");
|
||||
|
||||
const REMOVAL_ACTIONS = new Set(["remove-dir", "remove-font-set", "delete-font", "remove-artifact"]);
|
||||
const fixed = [];
|
||||
for (const f of result.applied) {
|
||||
const what = REMOVAL_ACTIONS.has(f.action) || result.filesDeleted.includes(f.file) ? "removed" : "cleaned";
|
||||
fixed.push(`- \`${f.file}\`: ${what} — ${f.description ?? f.action}`);
|
||||
}
|
||||
for (const n of result.notes) fixed.push(`- ${n}`);
|
||||
|
||||
if (fixed.length) {
|
||||
out.push("### Removed / fixed");
|
||||
out.push(...fixed);
|
||||
out.push("");
|
||||
}
|
||||
|
||||
const review = [
|
||||
...findings.manualReview.map((m) => `- ${m}`),
|
||||
...result.skipped.map((s) => `- \`${s.finding.file}\`: ${s.reason}`),
|
||||
];
|
||||
if (review.length) {
|
||||
out.push("### Needs manual review");
|
||||
out.push(...review);
|
||||
out.push("");
|
||||
}
|
||||
|
||||
out.push(
|
||||
"> ⚠️ Also check the affected developer's machine for the initial dropper (a malicious npm global package or VS Code extension), and rotate any secrets that may have been exposed via committed `.env` files.",
|
||||
);
|
||||
out.push("");
|
||||
return out.join("\n");
|
||||
}
|
||||
|
||||
export { PR_TITLE };
|
||||
|
||||
// ─── Whole-run report (persisted to the reports volume) ─────────────────────────
|
||||
//
|
||||
// A `run` object is assembled by the orchestrator:
|
||||
// { scannedAt, scannedAtHuman, account:{kind,owner}, dryRun, autoMerge, mergeMethod,
|
||||
// totals:{total,clean,infected,remediated,merged,prOpened,manualOnly,errors},
|
||||
// repos:[ { repo, severity, findings:[{file,action,confidence,contentConfirmed,description}],
|
||||
// deleted:[], modified:[], notes:[], manualReview:[],
|
||||
// pr:{url,merged,mergeError}|null, error:string|null } ] }
|
||||
|
||||
/** Machine-readable run report. */
|
||||
export function buildRunJson(run) {
|
||||
return { schema: "polinrider-run/1", ...run };
|
||||
}
|
||||
|
||||
function repoChangeSummary(r) {
|
||||
const parts = [];
|
||||
if (r.deleted?.length) parts.push(`removed: ${r.deleted.join(", ")}`);
|
||||
if (r.modified?.length) parts.push(`cleaned: ${r.modified.join(", ")}`);
|
||||
for (const n of r.notes ?? []) parts.push(n);
|
||||
return parts.join(" · ") || "—";
|
||||
}
|
||||
|
||||
/** Human-readable Markdown run report (clickable PR links + per-repo trace). */
|
||||
export function buildRunMarkdown(run) {
|
||||
const t = run.totals;
|
||||
const out = [];
|
||||
out.push("# PolinRider Malware Scan — Remediation Report");
|
||||
out.push("");
|
||||
out.push(`**Last scanned:** ${run.scannedAtHuman ?? run.scannedAt}`);
|
||||
out.push(`**Account:** ${run.account.kind} \`${run.account.owner}\``);
|
||||
const modes = [];
|
||||
if (run.dryRun) modes.push("DRY RUN — no changes written");
|
||||
modes.push(run.autoMerge ? `auto-merge: on (${run.mergeMethod})` : "auto-merge: off");
|
||||
out.push(`**Mode:** ${modes.join(" · ")}`);
|
||||
out.push("");
|
||||
|
||||
out.push("## Summary");
|
||||
out.push("");
|
||||
out.push("| Metric | Count |");
|
||||
out.push("| --- | ---: |");
|
||||
out.push(`| Repos scanned | ${t.total} |`);
|
||||
out.push(`| Clean | ${t.clean} |`);
|
||||
out.push(`| Infected | ${t.infected} |`);
|
||||
out.push(`| Remediated | ${t.remediated} |`);
|
||||
out.push(`| PRs opened | ${t.prOpened} |`);
|
||||
if (run.autoMerge) out.push(`| PRs merged | ${t.merged} |`);
|
||||
out.push(`| Needs manual review | ${t.manualOnly} |`);
|
||||
out.push(`| Errors | ${t.errors} |`);
|
||||
out.push("");
|
||||
|
||||
const withPr = run.repos.filter((r) => r.pr && r.pr.url);
|
||||
if (withPr.length) {
|
||||
out.push(`## Pull requests (${withPr.length})`);
|
||||
out.push("");
|
||||
out.push("Review and merge the PR for the project(s) you work on.");
|
||||
out.push("");
|
||||
out.push("| Repository | Pull request | Status | Changes |");
|
||||
out.push("| --- | --- | --- | --- |");
|
||||
for (const r of withPr) {
|
||||
const name = r.repo.split("/")[1] ?? r.repo;
|
||||
const num = r.pr.url.split("/pull/")[1] ?? "PR";
|
||||
const status = r.pr.merged ? "✅ merged" : r.pr.mergeError ? "⚠️ merge blocked" : "open";
|
||||
out.push(
|
||||
`| [${name}](https://github.com/${r.repo}) | [#${num}](${r.pr.url}) | ${status} | ${repoChangeSummary(r)} |`,
|
||||
);
|
||||
}
|
||||
out.push("");
|
||||
}
|
||||
|
||||
const attention = run.repos.filter(
|
||||
(r) =>
|
||||
r.error ||
|
||||
(r.pr && r.pr.mergeError) ||
|
||||
(r.manualReview && r.manualReview.length) ||
|
||||
(r.severity === "infected" && !(r.pr && r.pr.url) && !run.dryRun),
|
||||
);
|
||||
if (attention.length) {
|
||||
out.push("## Needs attention");
|
||||
out.push("");
|
||||
for (const r of attention) {
|
||||
const bits = [];
|
||||
if (r.error) bits.push(`error: ${r.error}`);
|
||||
if (r.pr && r.pr.mergeError) bits.push(`auto-merge blocked: ${r.pr.mergeError}`);
|
||||
if (r.manualReview?.length) bits.push(`manual review: ${r.manualReview.join("; ")}`);
|
||||
if (r.severity === "infected" && !(r.pr && r.pr.url) && !run.dryRun && !r.error)
|
||||
bits.push("infected but no automated fix applied");
|
||||
out.push(`- **${r.repo}** — ${bits.join(" · ").replace(/\s+/g, " ").trim()}`);
|
||||
}
|
||||
out.push("");
|
||||
}
|
||||
|
||||
out.push("---");
|
||||
out.push(`_Generated by polinrider-remover at ${run.scannedAt}._`);
|
||||
out.push("");
|
||||
return out.join("\n");
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* Subprocess allowlist — the single choke point for every external process.
|
||||
*
|
||||
* The V8 flag (--disallow-code-generation-from-strings) cannot stop a spawned
|
||||
* `node -e "<payload>"` or `bash -c "<payload>"`; this wrapper can, and does, by
|
||||
* permitting ONLY `git` and `gh` with an allowlisted set of subcommands. Every
|
||||
* subprocess in the tool MUST go through here — never call execa/spawn directly.
|
||||
*
|
||||
* It also hardens git itself: hooks disabled, system/global config ignored,
|
||||
* non-interactive, and `--no-verify` on commit/push — so a repo can never get
|
||||
* code to run via a git hook during clone/commit/push.
|
||||
*/
|
||||
|
||||
import { execa } from "execa";
|
||||
import os from "node:os";
|
||||
|
||||
// command → set of allowed first subcommands.
|
||||
const ALLOWED = new Map([
|
||||
[
|
||||
"git",
|
||||
new Set([
|
||||
"clone",
|
||||
"config",
|
||||
"checkout",
|
||||
"add",
|
||||
"diff",
|
||||
"commit",
|
||||
"push",
|
||||
"rm",
|
||||
"reflog",
|
||||
"rev-parse",
|
||||
"status",
|
||||
"ls-files",
|
||||
]),
|
||||
],
|
||||
["gh", new Set(["api", "pr"])],
|
||||
]);
|
||||
|
||||
// git global options that consume the FOLLOWING token as their value.
|
||||
const GIT_VALUE_OPTS = new Set([
|
||||
"-C",
|
||||
"-c",
|
||||
"--git-dir",
|
||||
"--work-tree",
|
||||
"--namespace",
|
||||
"--exec-path",
|
||||
]);
|
||||
|
||||
// Disable hooks on every git call. os.devNull is not a hooks directory, so git
|
||||
// finds no hooks and runs none. Cross-platform (/dev/null | \\.\nul).
|
||||
const HOOK_GUARD = ["-c", `core.hooksPath=${os.devNull}`];
|
||||
|
||||
export class CommandNotAllowedError extends Error {
|
||||
constructor(message) {
|
||||
super(message);
|
||||
this.name = "CommandNotAllowedError";
|
||||
}
|
||||
}
|
||||
|
||||
/** Return the first non-option token (the subcommand), skipping value-options. */
|
||||
function firstSubcommand(args, valueOpts) {
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const a = args[i];
|
||||
if (typeof a !== "string") continue;
|
||||
if (!a.startsWith("-")) return a;
|
||||
if (a.includes("=")) continue; // --opt=value is a single token
|
||||
if (valueOpts && valueOpts.has(a)) i++; // option consumes the next token
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a command + args against the allowlist. Throws CommandNotAllowedError
|
||||
* synchronously on any violation. Exported so callers/tests can pre-validate.
|
||||
*/
|
||||
export function assertAllowed(cmd, args = []) {
|
||||
if (typeof cmd !== "string" || cmd.length === 0) {
|
||||
throw new CommandNotAllowedError("command must be a non-empty string");
|
||||
}
|
||||
if (cmd.includes("/") || cmd.includes("\\")) {
|
||||
throw new CommandNotAllowedError(
|
||||
`refusing path-bearing command "${cmd}" — only the bare names "git"/"gh" are allowed`,
|
||||
);
|
||||
}
|
||||
if (!ALLOWED.has(cmd)) {
|
||||
throw new CommandNotAllowedError(
|
||||
`command "${cmd}" is not on the allowlist (allowed: git, gh)`,
|
||||
);
|
||||
}
|
||||
const sub = firstSubcommand(args, cmd === "git" ? GIT_VALUE_OPTS : null);
|
||||
if (!sub || !ALLOWED.get(cmd).has(sub)) {
|
||||
throw new CommandNotAllowedError(
|
||||
`subcommand "${sub ?? "(none)"}" is not allowed for "${cmd}"`,
|
||||
);
|
||||
}
|
||||
return { cmd, sub };
|
||||
}
|
||||
|
||||
/**
|
||||
* Run an allowlisted command. Not declared `async` so allowlist violations throw
|
||||
* synchronously (before any process is spawned); on success it returns execa's
|
||||
* promise. Defaults to reject:false so callers inspect `exitCode` themselves.
|
||||
*/
|
||||
export function safeExec(cmd, args = [], opts = {}) {
|
||||
assertAllowed(cmd, args);
|
||||
return execa(cmd, args, { reject: false, shell: false, ...opts });
|
||||
}
|
||||
|
||||
function gitEnv(extra) {
|
||||
return {
|
||||
...extra,
|
||||
GIT_CONFIG_NOSYSTEM: "1", // ignore /etc/gitconfig
|
||||
GIT_CONFIG_GLOBAL: os.devNull, // ignore ~/.gitconfig (no hijacked hooksPath)
|
||||
GIT_TERMINAL_PROMPT: "0", // never block on an interactive credential prompt
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the final git argv: prepend hook-disabling config and append
|
||||
* `--no-verify` to commit/push. Pure — exported for testing.
|
||||
*/
|
||||
export function buildGitArgs(args = []) {
|
||||
const finalArgs = [...HOOK_GUARD, ...args];
|
||||
const sub = firstSubcommand(finalArgs, GIT_VALUE_OPTS);
|
||||
if ((sub === "commit" || sub === "push") && !finalArgs.includes("--no-verify")) {
|
||||
finalArgs.push("--no-verify");
|
||||
}
|
||||
return finalArgs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run git with hooks disabled and config sanitized. The GH token is
|
||||
* intentionally NOT placed in git's environment — clone/push carry auth in the
|
||||
* tokenized URL instead.
|
||||
*/
|
||||
export function safeGit(args = [], opts = {}) {
|
||||
return safeExec("git", buildGitArgs(args), { ...opts, env: gitEnv(opts.env) });
|
||||
}
|
||||
|
||||
/**
|
||||
* Run gh with the GitHub token scoped to THIS process only (not leaked to git or
|
||||
* any other child). Token defaults to GH_TOKEN but can be injected for testing.
|
||||
*/
|
||||
export function safeGh(args = [], opts = {}, token = process.env.GH_TOKEN) {
|
||||
const env = { ...opts.env };
|
||||
if (token) {
|
||||
env.GH_TOKEN = token;
|
||||
env.GITHUB_TOKEN = token;
|
||||
}
|
||||
return safeExec("gh", args, { ...opts, env });
|
||||
}
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* Runtime safety guards — defense-in-depth so the PolinRider payload can never
|
||||
* execute, even if it somehow reaches a code path.
|
||||
*
|
||||
* This module installs its guards as an IMPORT SIDE EFFECT and MUST be the very
|
||||
* first import of the entry point (`src/index.js`). Because ESM hoists imports,
|
||||
* "first import line" is sufficient — the guards run before any other module's
|
||||
* top-level code.
|
||||
*
|
||||
* Layering (see the project plan):
|
||||
* • Layer 2 (the V8 flag `--disallow-code-generation-from-strings`, applied by
|
||||
* bin/polinrider.js) is the AUTHORITATIVE block for eval / Function. It is
|
||||
* verified to make eval(), new Function(), and the Async/Generator function
|
||||
* constructors throw EvalError.
|
||||
* • This module is the Layer-3 FALLBACK for when the process is launched
|
||||
* WITHOUT that flag (e.g. `node src/index.js`, or an `import` from a test
|
||||
* runner that wasn't started with the flag). It neutralizes the same escape
|
||||
* hatches in-process, plus `node:vm` and `process.binding`, which the flag
|
||||
* does NOT cover.
|
||||
*
|
||||
* The real protection is the architectural invariant: target file content is
|
||||
* only ever read as an inert string/Buffer and pattern-matched — never passed
|
||||
* to require/import/eval/Function/vm/child_process. These guards exist so a
|
||||
* mistake can't become an execution.
|
||||
*/
|
||||
|
||||
import Module from "node:module";
|
||||
|
||||
const ERR = "PolinRider safety:";
|
||||
|
||||
function makeBlocked(what) {
|
||||
return function blocked() {
|
||||
throw new EvalError(`${ERR} dynamic code generation via ${what} is disabled`);
|
||||
};
|
||||
}
|
||||
|
||||
function lockGlobal(name, value) {
|
||||
try {
|
||||
Object.defineProperty(globalThis, name, {
|
||||
value,
|
||||
configurable: false,
|
||||
writable: false,
|
||||
enumerable: false,
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 1. Block eval() (direct and indirect) ─────────────────────────────────────
|
||||
// Replacing globalThis.eval blocks both `eval(x)` and `(0, eval)(x)`, since both
|
||||
// resolve the global binding at call time.
|
||||
lockGlobal("eval", makeBlocked("eval"));
|
||||
|
||||
// ─── 2. Block the Function constructor family ──────────────────────────────────
|
||||
// Preserve Function.prototype so `instanceof`, `.bind`, `.call`, etc. keep
|
||||
// working; only the string→code construction paths are neutralized.
|
||||
const RealFunction = Function;
|
||||
const BlockedFunction = makeBlocked("Function");
|
||||
try {
|
||||
Object.defineProperty(BlockedFunction, "prototype", {
|
||||
value: RealFunction.prototype,
|
||||
writable: false,
|
||||
});
|
||||
} catch {
|
||||
/* prototype already non-configurable on some engines — fine */
|
||||
}
|
||||
lockGlobal("Function", BlockedFunction);
|
||||
|
||||
// Close the `(function(){}).constructor("code")` escape, plus the async /
|
||||
// generator equivalents, by pointing each prototype's `.constructor` at the
|
||||
// blocked stub. Wrapped per-item so one failure can't abort the rest.
|
||||
function blockConstructorOf(sample) {
|
||||
try {
|
||||
const proto = Object.getPrototypeOf(sample);
|
||||
Object.defineProperty(proto, "constructor", {
|
||||
value: BlockedFunction,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
blockConstructorOf(function () {});
|
||||
blockConstructorOf(async function () {});
|
||||
blockConstructorOf(function* () {});
|
||||
blockConstructorOf(async function* () {});
|
||||
|
||||
// ─── 3. Block the node:vm module ───────────────────────────────────────────────
|
||||
// The V8 flag does NOT cover vm.runInNewContext / runInThisContext /
|
||||
// compileFunction (verified). Reaching vm requires attacker code to already be
|
||||
// executing — which the allowlist + eval block prevent — but we close the most
|
||||
// likely path (a malicious CommonJS dependency calling require('vm')).
|
||||
const originalLoad = Module._load;
|
||||
Module._load = function patchedLoad(request, ...rest) {
|
||||
if (request === "vm" || request === "node:vm") {
|
||||
throw new Error(`${ERR} the node:vm module is blocked`);
|
||||
}
|
||||
return originalLoad.call(this, request, ...rest);
|
||||
};
|
||||
// NOTE: dynamic ESM `import('node:vm')` is not interceptable in-process without a
|
||||
// loader hook (cross-version-fragile on Node 20.0–20.5). Documented residual —
|
||||
// it is unreachable without already-executing attacker ESM, which the other
|
||||
// layers prevent.
|
||||
|
||||
// ─── 4. Neutralize the legacy process.binding escape hatch ──────────────────────
|
||||
const blockBinding = function blockedBinding() {
|
||||
throw new Error(`${ERR} process.binding is blocked`);
|
||||
};
|
||||
for (const key of ["binding", "_linkedBinding"]) {
|
||||
try {
|
||||
Object.defineProperty(process, key, {
|
||||
value: blockBinding,
|
||||
configurable: false,
|
||||
writable: false,
|
||||
});
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Diagnostic helper (used by tests and the orchestrator banner). Returns a
|
||||
* snapshot of which guards are active and whether the V8 codegen flag is on.
|
||||
*/
|
||||
export function hardeningStatus() {
|
||||
let evalBlocked = false;
|
||||
try {
|
||||
// eslint-disable-next-line no-eval
|
||||
globalThis.eval("1+1");
|
||||
} catch {
|
||||
evalBlocked = true;
|
||||
}
|
||||
return {
|
||||
evalBlocked,
|
||||
codegenFlag: process.execArgv.includes("--disallow-code-generation-from-strings") ||
|
||||
(process.env.NODE_OPTIONS || "").includes("--disallow-code-generation-from-strings"),
|
||||
hardenedLaunch: process.env.POLINRIDER_HARDENED === "1",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Build a minimal SARIF 2.1.0 report from a Findings object so consumers can
|
||||
* upload results to GitHub code scanning (github/codeql-action/upload-sarif) and
|
||||
* see them in the Security tab / inline on the PR.
|
||||
*
|
||||
* Pure with respect to the repo: it only reads files to convert a js-payload's
|
||||
* char offset into a 1-based line. Every other finding is file-level (startLine 1).
|
||||
*/
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const PROJECT_URL = "https://github.com/RMSoft-ltd/polinrider-cleanup";
|
||||
const levelOf = (f) => (f.contentConfirmed ? "error" : "warning");
|
||||
|
||||
/** 1-based start line for findings that carry a char offset, else 1. */
|
||||
function lineFor(repoDir, f) {
|
||||
const offset = f.edit?.offset;
|
||||
if (!(offset > 0) || !repoDir) 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, forward-slashed URI (SARIF artifactLocation). */
|
||||
function uriFor(repoRoot, repoDir, relFile) {
|
||||
const abs = path.join(repoDir || repoRoot, relFile);
|
||||
const rel = path.relative(repoRoot || repoDir, abs) || relFile;
|
||||
return rel.split(path.sep).join("/");
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('./scanner.js').Findings} findings
|
||||
* @param {{ repoRoot?: string, repoDir?: string }} [opts]
|
||||
*/
|
||||
export function buildSarif(findings, opts = {}) {
|
||||
const root = opts.repoRoot || opts.repoDir || findings.repoDir;
|
||||
const dir = opts.repoDir || findings.repoDir || root;
|
||||
|
||||
const rules = new Map();
|
||||
const results = findings.findings.map((f) => {
|
||||
if (!rules.has(f.id)) {
|
||||
rules.set(f.id, {
|
||||
id: f.id,
|
||||
name: f.id,
|
||||
shortDescription: { text: `PolinRider ${f.category} artifact` },
|
||||
fullDescription: { text: f.description },
|
||||
defaultConfiguration: { level: levelOf(f) },
|
||||
helpUri: PROJECT_URL,
|
||||
properties: { tags: ["security", "malware", "polinrider", f.category] },
|
||||
});
|
||||
}
|
||||
return {
|
||||
ruleId: f.id,
|
||||
level: levelOf(f),
|
||||
message: { text: f.description },
|
||||
locations: [
|
||||
{
|
||||
physicalLocation: {
|
||||
artifactLocation: { uri: uriFor(root, dir, f.file) },
|
||||
region: { startLine: lineFor(dir, f) },
|
||||
},
|
||||
},
|
||||
],
|
||||
properties: {
|
||||
confidence: f.confidence,
|
||||
contentConfirmed: f.contentConfirmed,
|
||||
action: f.action,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
$schema: "https://json.schemastore.org/sarif-2.1.0.json",
|
||||
version: "2.1.0",
|
||||
runs: [
|
||||
{
|
||||
tool: {
|
||||
driver: {
|
||||
name: "polinrider-cleanup",
|
||||
informationUri: PROJECT_URL,
|
||||
rules: [...rules.values()],
|
||||
},
|
||||
},
|
||||
results,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
+528
@@ -0,0 +1,528 @@
|
||||
/**
|
||||
* PolinRider detection. Produces a structured Findings report consumed by the
|
||||
* remediator and the reporter. PURE with respect to the target repo: it only
|
||||
* reads files (as inert text/bytes) and pattern-matches. It never executes
|
||||
* target content, spawns processes, or makes network calls.
|
||||
*
|
||||
* Detection is CONTENT-CONFIRMED: a repo is marked `infected` only when a known
|
||||
* signature / IOC actually matches. The mere co-presence of tasks.json +
|
||||
* launch.json + public/fonts raises `coPresenceAmplified` (a reporting hint) but
|
||||
* never, on its own, marks a repo infected or produces a removable finding.
|
||||
*
|
||||
* @typedef {Object} Finding
|
||||
* @property {string} id
|
||||
* @property {'js'|'vscode'|'font'|'package'|'artifact'|'gitignore'} category
|
||||
* @property {string} file repo-relative path
|
||||
* @property {'high'|'low'} confidence
|
||||
* @property {'strip-js-payload'|'edit-vscode'|'delete-font'|'remove-font-set'|'remove-dir'|'remove-artifact'|'fix-gitignore'|'manual-review'} action
|
||||
* @property {boolean} contentConfirmed true only when a known signature matched
|
||||
* @property {string} description
|
||||
* @property {Object} [edit] action parameters for the remediator
|
||||
*
|
||||
* @typedef {Object} Findings
|
||||
* @property {string} repoDir
|
||||
* @property {'clean'|'suspicious'|'infected'} severity
|
||||
* @property {boolean} hasContentConfirmed
|
||||
* @property {boolean} coPresenceAmplified
|
||||
* @property {Finding[]} findings
|
||||
* @property {string[]} manualReview
|
||||
*/
|
||||
|
||||
import fs from "node:fs/promises";
|
||||
import { existsSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import * as sig from "./signatures.js";
|
||||
import { parseJsonc, getMember } from "./jsonc.js";
|
||||
import { collectByExtension } from "./walk.js";
|
||||
import { looksSuspicious, collectFontReferences, isReferenced } from "./fonts.js";
|
||||
import { buildExcluder } from "./exclude.js";
|
||||
|
||||
/**
|
||||
* Scan a single repository directory.
|
||||
* @param {string} repoDir
|
||||
* @param {{ exclude?: string[]|string }} [opts] paths/globs to skip (repo-relative).
|
||||
* Useful so a repo can scan itself while ignoring files that legitimately
|
||||
* contain signatures (e.g. this tool's own src/signatures.js + fixtures).
|
||||
* @returns {Promise<Findings>}
|
||||
*/
|
||||
export async function scanRepo(repoDir, opts = {}) {
|
||||
const findings = [];
|
||||
const rel = (p) => path.relative(repoDir, p) || path.basename(p);
|
||||
const matchExcluded = buildExcluder(opts.exclude);
|
||||
const isExcluded = (abs) =>
|
||||
matchExcluded(path.relative(repoDir, abs).split(path.sep).join("/"));
|
||||
|
||||
await detectJsPayloads(repoDir, findings, rel, isExcluded);
|
||||
await detectVscode(repoDir, findings, rel, isExcluded);
|
||||
await detectFonts(repoDir, findings, rel, isExcluded);
|
||||
await detectPackageJson(repoDir, findings, isExcluded);
|
||||
await detectArtifacts(repoDir, findings, isExcluded);
|
||||
|
||||
const coPresenceAmplified =
|
||||
existsSync(path.join(repoDir, ".vscode", "tasks.json")) &&
|
||||
existsSync(path.join(repoDir, ".vscode", "launch.json")) &&
|
||||
sig.FONT_DIRS.some((d) => existsSync(path.join(repoDir, d)));
|
||||
|
||||
const hasContentConfirmed = findings.some((f) => f.contentConfirmed);
|
||||
let severity = "clean";
|
||||
if (hasContentConfirmed) severity = "infected";
|
||||
else if (findings.length > 0 || coPresenceAmplified) severity = "suspicious";
|
||||
|
||||
const manualReview = findings
|
||||
.filter((f) => f.action === "manual-review")
|
||||
.map((f) => `${f.file}: ${f.description}`);
|
||||
|
||||
return { repoDir, severity, hasContentConfirmed, coPresenceAmplified, findings, manualReview };
|
||||
}
|
||||
|
||||
// ─── JS payload (appended obfuscated blob) ──────────────────────────────────────
|
||||
|
||||
/** Find the byte offset where the appended payload begins, or -1. */
|
||||
export function locatePayloadOffset(text, variant) {
|
||||
let lastExport = -1;
|
||||
const re = new RegExp(sig.EXPORT_MARKER_RE.source, "g");
|
||||
let m;
|
||||
while ((m = re.exec(text))) lastExport = m.index;
|
||||
const from = lastExport >= 0 ? lastExport : 0;
|
||||
const tailMatch = new RegExp(variant.startRe.source).exec(text.slice(from));
|
||||
if (tailMatch) return from + tailMatch.index;
|
||||
const anyMatch = new RegExp(variant.startRe.source).exec(text);
|
||||
return anyMatch ? anyMatch.index : -1;
|
||||
}
|
||||
|
||||
async function detectJsPayloads(repoDir, findings, rel, isExcluded) {
|
||||
const files = await collectByExtension(repoDir, sig.JS_EXTENSIONS);
|
||||
for (const file of files) {
|
||||
if (isExcluded(file)) continue;
|
||||
let text;
|
||||
try {
|
||||
text = await fs.readFile(file, "utf8");
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 1. Known variants — content-confirmed via signature + a numeric seed.
|
||||
let matchedKnown = false;
|
||||
for (const variant of sig.JS_VARIANTS) {
|
||||
const confirmed =
|
||||
text.includes(variant.signature) && variant.seeds.some((s) => text.includes(s));
|
||||
if (!confirmed) continue;
|
||||
matchedKnown = true;
|
||||
const offset = locatePayloadOffset(text, variant);
|
||||
const canStrip = offset > 0;
|
||||
findings.push({
|
||||
id: `js.payload.${variant.id}`,
|
||||
category: "js",
|
||||
file: rel(file),
|
||||
confidence: "high",
|
||||
action: canStrip ? "strip-js-payload" : "manual-review",
|
||||
contentConfirmed: true,
|
||||
description: canStrip
|
||||
? `${variant.label} appended at offset ${offset} — will strip from there to EOF`
|
||||
: `${variant.label} detected but its start offset could not be located safely — strip manually`,
|
||||
edit: canStrip ? { absPath: file, offset, variantId: variant.id } : undefined,
|
||||
});
|
||||
break; // one variant finding per file is enough
|
||||
}
|
||||
if (matchedKnown) continue;
|
||||
|
||||
// 2. Generic heuristic for UNKNOWN variants → manual review only, never auto-strip.
|
||||
let lastExport = -1;
|
||||
const re = new RegExp(sig.EXPORT_MARKER_RE.source, "g");
|
||||
let m;
|
||||
while ((m = re.exec(text))) lastExport = m.index;
|
||||
const tail = lastExport >= 0 ? text.slice(lastExport) : text;
|
||||
const h = sig.GENERIC_HEURISTIC;
|
||||
if (h.globalAssignRe.test(tail) && h.obfArrayRe.test(tail) && h.evalRe.test(tail)) {
|
||||
findings.push({
|
||||
id: "js.payload.heuristic",
|
||||
category: "js",
|
||||
file: rel(file),
|
||||
confidence: "low",
|
||||
action: "manual-review",
|
||||
contentConfirmed: false,
|
||||
description:
|
||||
"Obfuscated code appended after the last export (global[...] assignment + obfuscated array + eval). Possible unknown PolinRider variant — review manually.",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── .vscode task / launch weaponization ────────────────────────────────────────
|
||||
|
||||
/** Decide whether one task/launch entry is malicious. */
|
||||
export function classifyVscodeEntry(entryValue) {
|
||||
const hay = JSON.stringify(entryValue ?? "");
|
||||
if (sig.isC2Host(hay)) return { bad: true, reason: "references a known PolinRider C2 host" };
|
||||
if (sig.isFetchToShell(hay))
|
||||
return { bad: true, reason: "fetches a remote script and pipes it to a shell" };
|
||||
if (sig.commandExecutesAsset(hay))
|
||||
return { bad: true, reason: "executes a font/asset file as code (e.g. `node ./public/fonts/…`)" };
|
||||
if (entryValue?.runOptions?.runOn === "folderOpen") {
|
||||
if (sig.ANY_URL_RE.test(hay))
|
||||
return { bad: true, reason: "auto-runs on folderOpen and contacts an external URL" };
|
||||
if (sig.INTERPRETER_RE.test(hay))
|
||||
return { bad: true, reason: "auto-runs a script interpreter on folderOpen" };
|
||||
}
|
||||
return { bad: false };
|
||||
}
|
||||
|
||||
async function detectVscode(repoDir, findings, rel, isExcluded) {
|
||||
const vscodeDir = path.join(repoDir, ".vscode");
|
||||
if (!existsSync(vscodeDir) || isExcluded(vscodeDir)) return;
|
||||
|
||||
const targets = [
|
||||
{ name: "tasks.json", arrayKey: "tasks" },
|
||||
{ name: "launch.json", arrayKey: "configurations" },
|
||||
];
|
||||
const reasons = new Set();
|
||||
|
||||
for (const { name, arrayKey } of targets) {
|
||||
const file = path.join(vscodeDir, name);
|
||||
if (!existsSync(file) || isExcluded(file)) continue;
|
||||
let text;
|
||||
try {
|
||||
text = await fs.readFile(file, "utf8");
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const parsed = parseJsonc(text);
|
||||
if (!parsed.ok) {
|
||||
// Can't parse → fall back to scanning the raw text for malicious markers.
|
||||
if (sig.isC2Host(text) || sig.isFetchToShell(text) || sig.commandExecutesAsset(text)) {
|
||||
reasons.add(`malicious content in ${name}`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const arr = Array.isArray(parsed.value?.[arrayKey]) ? parsed.value[arrayKey] : [];
|
||||
for (const entry of arr) {
|
||||
const c = classifyVscodeEntry(entry);
|
||||
if (c.bad) reasons.add(`${name}: ${c.reason}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (reasons.size === 0) return;
|
||||
// Policy: any malicious .vscode entry → remove the entire .vscode directory.
|
||||
findings.push({
|
||||
id: "vscode.malicious",
|
||||
category: "vscode",
|
||||
file: ".vscode",
|
||||
confidence: "high",
|
||||
action: "remove-dir",
|
||||
contentConfirmed: true,
|
||||
description: `Malicious .vscode configuration (${[...reasons].join("; ")}) — removing the entire .vscode directory`,
|
||||
edit: { absPath: vscodeDir },
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Font payload carriers ───────────────────────────────────────────────────────
|
||||
|
||||
/** Map a font file to the fonts directory that should be removed wholesale, or null. */
|
||||
export function fontDirToRemove(repoDir, fontAbsPath) {
|
||||
const parts = path.relative(repoDir, fontAbsPath).split(path.sep);
|
||||
const idx = parts.lastIndexOf("fonts");
|
||||
if (idx >= 0) return path.join(repoDir, ...parts.slice(0, idx + 1)); // .../fonts
|
||||
return null; // not inside a fonts/ dir → caller falls back to deleting the file
|
||||
}
|
||||
|
||||
async function detectFonts(repoDir, findings, rel, isExcluded) {
|
||||
const fontFiles = await collectByExtension(repoDir, sig.FONT_EXTENSIONS);
|
||||
if (fontFiles.length === 0) return;
|
||||
const haystack = await collectFontReferences(repoDir);
|
||||
|
||||
// Group fonts by their leaf `fonts/` dir. A dir is tracked when it holds either a
|
||||
// suspicious font OR a Font-Awesome-named font (the malware's disguise names).
|
||||
const groups = new Map(); // absDir → { confirmed:[], suspect:[] }
|
||||
const orphans = []; // suspicious/fa fonts with no fonts/ ancestor
|
||||
|
||||
const ensureGroup = (dir) => {
|
||||
if (!groups.has(dir)) groups.set(dir, { confirmed: [], suspect: [] });
|
||||
return groups.get(dir);
|
||||
};
|
||||
|
||||
for (const file of fontFiles) {
|
||||
if (isExcluded(file)) continue;
|
||||
const isFa = sig.isFaFamilyName(path.basename(file));
|
||||
let susp;
|
||||
try {
|
||||
susp = looksSuspicious(await fs.readFile(file), path.extname(file));
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const referenced = isReferenced(haystack, file);
|
||||
const isCarrier = susp.bad && susp.hasCodeStrings; // not a valid font + code payload
|
||||
const dir = fontDirToRemove(repoDir, file);
|
||||
|
||||
// Referenced fonts are wired into the build, so they are never auto-deleted.
|
||||
// But "referenced" is not a clean bill of health: a referenced file that is
|
||||
// NOT a valid font yet carries a code payload is a carrier hiding in plain
|
||||
// sight → surface it for manual review rather than silently trusting it.
|
||||
if (referenced) {
|
||||
if (isCarrier) {
|
||||
findings.push({
|
||||
id: "font.referenced-carrier",
|
||||
category: "font",
|
||||
file: rel(file),
|
||||
confidence: "high",
|
||||
action: "manual-review",
|
||||
contentConfirmed: false,
|
||||
description: `Referenced file that is not a valid font but contains a code payload (${susp.reasons.join("; ")}). It is imported by the build, so it is not auto-removed — review and remove it manually.`,
|
||||
});
|
||||
}
|
||||
// A referenced fa-named font still registers its dir so the disguise (medium)
|
||||
// check fires, but it is never treated as a deletable carrier.
|
||||
if (isFa && dir) ensureGroup(dir);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!susp.bad && !isFa) continue; // valid, inert, non-disguise, unreferenced → leave it
|
||||
|
||||
const entry = { file, reasons: susp.reasons };
|
||||
if (dir) {
|
||||
const g = ensureGroup(dir);
|
||||
if (isCarrier) g.confirmed.push(entry);
|
||||
else if (susp.bad) g.suspect.push(entry);
|
||||
// (fa-named-but-valid fonts add nothing here; emit() re-discovers them by name)
|
||||
} else if (isCarrier) {
|
||||
orphans.push({ ...entry, kind: "carrier" });
|
||||
} else if (susp.bad || isFa) {
|
||||
orphans.push({ ...entry, kind: "review", isFa });
|
||||
}
|
||||
}
|
||||
|
||||
for (const [dir, group] of groups) {
|
||||
await emitFontDirFinding(dir, group, findings, rel, isExcluded);
|
||||
}
|
||||
|
||||
// Fonts outside any `fonts/` dir: a confirmed carrier is deleted on its own; a
|
||||
// bare fa-named or unrecognized file is flagged for manual review only.
|
||||
for (const o of orphans) {
|
||||
if (o.kind === "carrier") {
|
||||
findings.push({
|
||||
id: "font.carrier",
|
||||
category: "font",
|
||||
file: rel(o.file),
|
||||
confidence: "high",
|
||||
action: "delete-font",
|
||||
contentConfirmed: true,
|
||||
description: `Unreferenced font carrier (JS payload): ${o.reasons.join("; ")}`,
|
||||
edit: { absPath: o.file },
|
||||
});
|
||||
} else {
|
||||
findings.push({
|
||||
id: "font.review",
|
||||
category: "font",
|
||||
file: rel(o.file),
|
||||
confidence: "low",
|
||||
action: "manual-review",
|
||||
contentConfirmed: false,
|
||||
description: o.isFa
|
||||
? "Font-Awesome-named font (a known PolinRider disguise) with no payload detected — review manually."
|
||||
: `Unreferenced file that is not a valid font (${o.reasons.join("; ")}) — review manually.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List everything under `dir`. `allEntries` includes files, sub-directories AND
|
||||
* symlinks (symlinks are recorded but never followed, matching the walker's safety
|
||||
* guarantee); `regularFiles` are the real files only — the pool we may remove.
|
||||
*/
|
||||
async function listTree(dir) {
|
||||
const allEntries = [];
|
||||
const regularFiles = [];
|
||||
const rec = async (d) => {
|
||||
let entries;
|
||||
try {
|
||||
entries = await fs.readdir(d, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const ent of entries) {
|
||||
const full = path.join(d, ent.name);
|
||||
allEntries.push(full);
|
||||
if (ent.isSymbolicLink()) continue; // counts as "present", but not followed or removed
|
||||
if (ent.isDirectory()) await rec(full);
|
||||
else if (ent.isFile()) regularFiles.push(full);
|
||||
}
|
||||
};
|
||||
await rec(dir);
|
||||
return { allEntries, regularFiles };
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide what to remove for one `fonts/` directory that contains a suspicious or
|
||||
* disguise-named font.
|
||||
*
|
||||
* - A confirmed carrier (unreferenced JS payload) makes the dir `infected`: remove
|
||||
* the carrier(s) PLUS the whole `fa-*` disguise set PLUS README sidecars. If that
|
||||
* set is the entire directory, remove the directory; otherwise remove just those
|
||||
* files and keep the clean fonts.
|
||||
* - No carrier but `fa-*` names present (or an unrecognized blob) → `suspicious`
|
||||
* manual-review only; nothing is auto-removed.
|
||||
*/
|
||||
async function emitFontDirFinding(dir, group, findings, rel, isExcluded) {
|
||||
// Enumerate the dir INCLUDING symlinks and sub-directory entries. `allEntries`
|
||||
// is used only to decide "is anything here besides the removal set?" — a symlink
|
||||
// or a sub-directory (e.g. a clean gill-sans/) must count so we never escalate to
|
||||
// a whole-dir removal that would take a clean font with it. `regularFiles` is the
|
||||
// pool of real files we may actually remove.
|
||||
const { allEntries, regularFiles } = await listTree(dir);
|
||||
const faAll = regularFiles.filter((f) => !isExcluded(f) && sig.isFaFamilyName(path.basename(f)));
|
||||
|
||||
if (group.confirmed.length > 0) {
|
||||
const carrierReasons = [...new Set(group.confirmed.flatMap((e) => e.reasons))].join("; ");
|
||||
// Scope the disguise sweep to the carrier's OWN directory: remove fa-* files and
|
||||
// README sidecars that sit beside a carrier, never ones in a nested clean subdir.
|
||||
const carrierDirs = new Set(group.confirmed.map((e) => path.dirname(e.file)));
|
||||
const inScope = (f) => carrierDirs.has(path.dirname(f)) && !isExcluded(f);
|
||||
const faFiles = faAll.filter(inScope);
|
||||
const sidecars = regularFiles.filter((f) => inScope(f) && sig.isFontDropSidecar(path.basename(f)));
|
||||
|
||||
const removalAbs = new Set([...group.confirmed.map((e) => e.file), ...faFiles, ...sidecars]);
|
||||
const remaining = allEntries.filter((f) => !removalAbs.has(f)); // anything clean to preserve
|
||||
|
||||
if (remaining.length === 0) {
|
||||
findings.push({
|
||||
id: "font.carrier-dir",
|
||||
category: "font",
|
||||
file: rel(dir),
|
||||
confidence: "high",
|
||||
action: "remove-dir",
|
||||
contentConfirmed: true,
|
||||
description: `Font carrier(s) found — removing the entire ${rel(dir)} directory (${carrierReasons})`,
|
||||
edit: { absPath: dir },
|
||||
});
|
||||
} else {
|
||||
const removals = [...removalAbs].map((abs) => ({ abs, rel: rel(abs) }));
|
||||
findings.push({
|
||||
id: "font.carrier-set",
|
||||
category: "font",
|
||||
file: rel(dir),
|
||||
confidence: "high",
|
||||
action: "remove-font-set",
|
||||
contentConfirmed: true,
|
||||
description:
|
||||
`Font carrier(s) in ${rel(dir)} (${carrierReasons}) — removing ${removals.length} ` +
|
||||
`malicious/disguise file(s): ${removals.map((r) => path.basename(r.rel)).join(", ")}. ` +
|
||||
`Preserving ${remaining.length} clean entr${remaining.length === 1 ? "y" : "ies"}.`,
|
||||
edit: { removals },
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// No confirmed carrier: fa-disguise names and/or an unrecognized blob → medium.
|
||||
const bits = [];
|
||||
if (faAll.length) {
|
||||
bits.push(
|
||||
`Font-Awesome-named font(s) present (${faAll.map((f) => path.basename(f)).join(", ")}) — a known PolinRider disguise`,
|
||||
);
|
||||
}
|
||||
if (group.suspect.length) {
|
||||
bits.push(`unrecognized non-font file(s): ${group.suspect.map((e) => path.basename(e.file)).join(", ")}`);
|
||||
}
|
||||
if (bits.length === 0) return;
|
||||
findings.push({
|
||||
id: "font.review",
|
||||
category: "font",
|
||||
file: rel(dir),
|
||||
confidence: "low",
|
||||
action: "manual-review",
|
||||
contentConfirmed: false,
|
||||
description: `${bits.join("; ")}. No payload detected — review manually to confirm these are legitimate.`,
|
||||
});
|
||||
}
|
||||
|
||||
// ─── package.json impostor deps / malicious lifecycle scripts ────────────────────
|
||||
|
||||
async function detectPackageJson(repoDir, findings, isExcluded) {
|
||||
const file = path.join(repoDir, "package.json");
|
||||
if (!existsSync(file) || isExcluded(file)) return;
|
||||
let pkg;
|
||||
try {
|
||||
pkg = JSON.parse(await fs.readFile(file, "utf8"));
|
||||
} catch {
|
||||
return; // malformed package.json — out of scope here
|
||||
}
|
||||
const depSections = ["dependencies", "devDependencies", "optionalDependencies", "peerDependencies"];
|
||||
const impostors = new Set();
|
||||
for (const section of depSections) {
|
||||
const deps = pkg[section];
|
||||
if (!deps || typeof deps !== "object") continue;
|
||||
for (const name of Object.keys(deps)) {
|
||||
if (sig.IMPOSTOR_DEPS.includes(name)) impostors.add(name);
|
||||
}
|
||||
}
|
||||
if (impostors.size > 0) {
|
||||
findings.push({
|
||||
id: "package.impostor-deps",
|
||||
category: "package",
|
||||
file: "package.json",
|
||||
confidence: "high",
|
||||
action: "manual-review",
|
||||
contentConfirmed: true,
|
||||
description: `Known PolinRider impostor dependenc${impostors.size === 1 ? "y" : "ies"}: ${[...impostors].join(", ")}. Remove the package(s) and audit lockfiles manually.`,
|
||||
});
|
||||
}
|
||||
|
||||
const scripts = pkg.scripts && typeof pkg.scripts === "object" ? pkg.scripts : {};
|
||||
for (const hook of sig.SUSPICIOUS_LIFECYCLE_SCRIPTS) {
|
||||
const cmd = scripts[hook];
|
||||
if (typeof cmd === "string" && (sig.isFetchToShell(cmd) || /\bnode\b\s+-e\b/i.test(cmd))) {
|
||||
findings.push({
|
||||
id: `package.script.${hook}`,
|
||||
category: "package",
|
||||
file: "package.json",
|
||||
confidence: "high",
|
||||
action: "manual-review",
|
||||
contentConfirmed: true,
|
||||
description: `"${hook}" lifecycle script fetches and executes remote code: ${cmd}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Standalone artifacts + .gitignore injection ─────────────────────────────────
|
||||
|
||||
async function detectArtifacts(repoDir, findings, isExcluded) {
|
||||
for (const name of sig.ARTIFACT_FILES) {
|
||||
if (existsSync(path.join(repoDir, name)) && !isExcluded(path.join(repoDir, name))) {
|
||||
findings.push({
|
||||
id: `artifact.${name}`,
|
||||
category: "artifact",
|
||||
file: name,
|
||||
confidence: "high",
|
||||
action: "remove-artifact",
|
||||
contentConfirmed: true,
|
||||
description:
|
||||
name === "config.bat"
|
||||
? "Hidden malware orchestrator"
|
||||
: "Malware propagation script",
|
||||
edit: { absPath: path.join(repoDir, name) },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const gitignore = path.join(repoDir, ".gitignore");
|
||||
if (existsSync(gitignore) && !isExcluded(gitignore)) {
|
||||
try {
|
||||
const lines = (await fs.readFile(gitignore, "utf8")).split(/\r?\n/).map((l) => l.trim());
|
||||
const injected = sig.GITIGNORE_INJECTED.filter((p) => lines.includes(p));
|
||||
if (injected.length) {
|
||||
findings.push({
|
||||
id: "gitignore.injected",
|
||||
category: "gitignore",
|
||||
file: ".gitignore",
|
||||
confidence: "high",
|
||||
action: "fix-gitignore",
|
||||
contentConfirmed: true,
|
||||
description: `malware-injected .gitignore entr${injected.length === 1 ? "y" : "ies"}: ${injected.join(", ")}`,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
/**
|
||||
* PolinRider indicator-of-compromise (IOC) catalog.
|
||||
*
|
||||
* This module is PURE DATA plus a few side-effect-free helpers. It performs
|
||||
* NO file I/O, NO network access, and NEVER executes any string. Detection and
|
||||
* remediation logic lives in scanner.js / remediator.js and consumes these.
|
||||
*
|
||||
* Update this file as the malware rotates its signatures — it is the single
|
||||
* source of truth that scanner, remediator, and the tests all read from.
|
||||
*/
|
||||
|
||||
// ─── JS payload variants ──────────────────────────────────────────────────────
|
||||
//
|
||||
// PolinRider appends one obfuscated blob to the END of a file, after the last
|
||||
// legitimate `export default` / `module.exports`. A variant is "content
|
||||
// confirmed" only when its unique `signature` AND at least one of its numeric
|
||||
// `seeds` are present — never on a bare `global[...]` substring (which appears
|
||||
// in plenty of legitimate code). `startRe` locates the first byte of the
|
||||
// appended blob so the remediator can strip exactly from there to EOF.
|
||||
|
||||
export const JS_VARIANTS = [
|
||||
{
|
||||
id: "original",
|
||||
label: "PolinRider payload (original variant)",
|
||||
confidence: "high",
|
||||
signature: "rmcej%otb%", // appears as ("rmcej%otb%",2857687)
|
||||
decoder: "_$_1e42",
|
||||
seeds: ["2857687", "2667686"],
|
||||
startRe: /global\s*\[\s*(['"])!\1\s*\]\s*=/,
|
||||
},
|
||||
{
|
||||
id: "rotated",
|
||||
label: "PolinRider payload (rotated variant)",
|
||||
confidence: "high",
|
||||
signature: "Cot%3t=shtP",
|
||||
decoder: "MDy",
|
||||
seeds: ["1111436", "3896884"],
|
||||
startRe: /global\s*\[\s*(['"])_V\1\s*\]\s*=/,
|
||||
},
|
||||
];
|
||||
|
||||
// Marks the end of legitimate code; the payload is appended after the LAST one.
|
||||
export const EXPORT_MARKER_RE = /(?:export\s+default|module\.exports)/g;
|
||||
|
||||
// Low-confidence heuristic for UNKNOWN future variants. When an obfuscated blob
|
||||
// is appended after the exports, we flag it for MANUAL REVIEW only — never an
|
||||
// automatic strip (avoids damaging legitimate-but-unusual code).
|
||||
export const GENERIC_HEURISTIC = {
|
||||
globalAssignRe: /global\s*\[\s*(['"]).{1,12}?\1\s*\]\s*=/,
|
||||
obfArrayRe: /\bvar\s+_\$?_?[A-Za-z0-9$_]+\s*=\s*\[/, // e.g. var _$_1e42=[...]
|
||||
evalRe: /\beval\s*\(/,
|
||||
};
|
||||
|
||||
export const JS_EXTENSIONS = [".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs"];
|
||||
|
||||
// Config files PolinRider is documented to target. Used for labeling/priority;
|
||||
// the scanner sweeps all JS/TS files regardless (content-confirmed = no FPs).
|
||||
export const CONFIG_FILES = [
|
||||
"postcss.config.mjs",
|
||||
"postcss.config.js",
|
||||
"tailwind.config.js",
|
||||
"eslint.config.mjs",
|
||||
"next.config.mjs",
|
||||
"next.config.ts",
|
||||
"babel.config.js",
|
||||
"jest.config.js",
|
||||
];
|
||||
|
||||
// ─── .vscode task/launch weaponization ─────────────────────────────────────────
|
||||
//
|
||||
// Malicious tasks fetch a remote script and pipe it to a shell, often auto-run
|
||||
// via runOptions.runOn = "folderOpen". Legitimate tasks run LOCAL commands
|
||||
// (npm run build, tsc, etc.) and are never flagged.
|
||||
|
||||
export const C2_HOSTS = [
|
||||
"default-configuration.vercel.app",
|
||||
"vscode-settings-bootstrap.vercel.app",
|
||||
"vscode-settings-config.vercel.app",
|
||||
"vscode-bootstrapper.vercel.app",
|
||||
"vscode-load-config.vercel.app",
|
||||
"260120.vercel.app",
|
||||
];
|
||||
|
||||
// Broader patterns for sibling C2 domains in the same family.
|
||||
export const C2_HOST_RES = [
|
||||
/vscode-settings-[a-z0-9-]*\.vercel\.app/i,
|
||||
/vscode-[a-z0-9-]*(?:config|bootstrap|loader?)[a-z0-9-]*\.vercel\.app/i,
|
||||
];
|
||||
|
||||
// A remote fetch (downloads code from the network).
|
||||
export const NET_FETCH_RES = [
|
||||
/\bcurl\b/i,
|
||||
/\bwget\b/i,
|
||||
/\bInvoke-WebRequest\b/i,
|
||||
/\biwr\b/i,
|
||||
/\bNew-Object\s+Net\.WebClient/i,
|
||||
/\bDownloadString\b/i,
|
||||
];
|
||||
|
||||
// Piping/handing fetched bytes to an interpreter (executes downloaded code).
|
||||
export const PIPE_SHELL_RES = [
|
||||
/\|\s*(?:bash|sh|zsh|dash)\b/i,
|
||||
/\b(?:bash|sh|zsh|dash)\s+-c\b/i,
|
||||
/\bIEX\b/i, // PowerShell Invoke-Expression
|
||||
/\bInvoke-Expression\b/i,
|
||||
/\bnode\b\s+-e\b/i,
|
||||
/\beval\b/i,
|
||||
];
|
||||
|
||||
export const ANY_URL_RE = /https?:\/\/[^\s"'`)]+/i;
|
||||
|
||||
// A raw script interpreter (as opposed to a package-manager wrapper like npm).
|
||||
export const INTERPRETER_RE =
|
||||
/\b(?:node|deno|bun|python3?|ruby|php|osascript|bash|sh|zsh|dash|pwsh|powershell|cmd)\b/i;
|
||||
|
||||
// Running an interpreter against an asset/font path — never legitimate. This is
|
||||
// the real PolinRider .vscode vector: a folderOpen task does `node ./public/fonts/x.woff2`.
|
||||
export const ASSET_EXEC_RE =
|
||||
/(?:public[\\/]+fonts|[\\/]fonts[\\/]|\bstatic[\\/]|\bassets[\\/])|\.(?:woff2?|ttf|eot|otf)\b/i;
|
||||
|
||||
// ─── Font payload carrier ──────────────────────────────────────────────────────
|
||||
//
|
||||
// A font dropped into public/fonts that no CSS/HTML/JS references AND whose
|
||||
// bytes don't look like a real font (bad magic) or contain JS-like strings.
|
||||
|
||||
export const FONT_EXTENSIONS = [".woff2", ".woff", ".ttf", ".otf", ".eot", ".ttc"];
|
||||
|
||||
// First 4 bytes of a legitimate font file, keyed by format.
|
||||
export const FONT_MAGIC = {
|
||||
woff2: [0x77, 0x4f, 0x46, 0x32], // wOF2
|
||||
woff: [0x77, 0x4f, 0x46, 0x46], // wOFF
|
||||
otf: [0x4f, 0x54, 0x54, 0x4f], // OTTO
|
||||
ttf: [0x00, 0x01, 0x00, 0x00],
|
||||
ttc: [0x74, 0x74, 0x63, 0x66], // ttcf
|
||||
true: [0x74, 0x72, 0x75, 0x65], // 'true' (legacy TrueType)
|
||||
eot: null, // EOT has no single stable magic; treat presence-only
|
||||
};
|
||||
|
||||
// Code-execution strings that should NEVER appear in a real font file. These are
|
||||
// the signal that an unparseable "font" is actually an appended JS payload.
|
||||
//
|
||||
// NOTE: plain URLs and long base64-looking runs are deliberately NOT here — real
|
||||
// fonts legitimately embed license/vendor URLs in their `name` table, and binary
|
||||
// glyph data trivially produces long [A-Za-z0-9+/] runs. Both caused false
|
||||
// positives on genuine fonts (e.g. commercial .otf files). Structural validation
|
||||
// in fonts.js is the primary "is this a real font?" signal now; these strings only
|
||||
// corroborate a file that already failed to parse as a font.
|
||||
export const FONT_BADNESS_RES = [
|
||||
/eval\s*\(/,
|
||||
/global\s*\[/,
|
||||
/require\s*\(/,
|
||||
/child_process/,
|
||||
/process\.(?:env|binding)/,
|
||||
/function\s*\(/,
|
||||
/\bnode\b\s+-e\b/,
|
||||
];
|
||||
|
||||
// PolinRider disguises its font carrier under Font-Awesome filenames (the exact
|
||||
// names real Font Awesome ships: fa-brands-400, fa-solid-900, fa-regular-400, …).
|
||||
// Presence of these names is a disguise indicator; content (see fonts.js) decides
|
||||
// whether a given file is an actual payload or just a legitimate Font Awesome font.
|
||||
export const FA_FONT_NAME_RE =
|
||||
/^fa-(?:brands|solid|regular|light|thin|duotone)-\d+\.(?:eot|svg|ttf|otf|woff2?)$/i;
|
||||
|
||||
// Files the malware drops alongside its font carrier that should go with it.
|
||||
export const FONT_DROP_SIDECARS = ["readme.md"];
|
||||
|
||||
// Directories where the font-carrier variant is dropped.
|
||||
export const FONT_DIRS = ["public/fonts", "static", "static/fonts", "assets/fonts", "src/assets/fonts"];
|
||||
|
||||
// File types that can reference a font via @font-face / url(...) / import.
|
||||
export const FONT_REF_EXTENSIONS = [
|
||||
".css",
|
||||
".scss",
|
||||
".sass",
|
||||
".less",
|
||||
".styl",
|
||||
".html",
|
||||
".htm",
|
||||
".vue",
|
||||
".svelte",
|
||||
".js",
|
||||
".jsx",
|
||||
".ts",
|
||||
".tsx",
|
||||
".mjs",
|
||||
".cjs",
|
||||
".json",
|
||||
];
|
||||
|
||||
// ─── package.json impostor dependencies ────────────────────────────────────────
|
||||
|
||||
export const IMPOSTOR_DEPS = [
|
||||
"tailwindcss-style-animate",
|
||||
"tailwind-mainanimation",
|
||||
"tailwind-autoanimation",
|
||||
];
|
||||
|
||||
// Lifecycle scripts that fetch + execute remote code during `npm install`.
|
||||
export const SUSPICIOUS_LIFECYCLE_SCRIPTS = ["preinstall", "install", "postinstall"];
|
||||
|
||||
// ─── Standalone artifacts ───────────────────────────────────────────────────────
|
||||
|
||||
export const ARTIFACT_FILES = [
|
||||
"temp_auto_push.bat",
|
||||
"temp_interactive_push.bat",
|
||||
"config.bat",
|
||||
"branch_structure.json",
|
||||
];
|
||||
|
||||
// Lines PolinRider injects into .gitignore to hide its own artifacts from git.
|
||||
export const GITIGNORE_INJECTED = [
|
||||
"config.bat",
|
||||
"temp_auto_push.bat",
|
||||
"temp_interactive_push.bat",
|
||||
"branch_structure.json",
|
||||
];
|
||||
|
||||
export const ENV_PATTERNS = [
|
||||
".env",
|
||||
".env.local",
|
||||
".env.*.local",
|
||||
".env.production",
|
||||
".env.development",
|
||||
];
|
||||
|
||||
// ─── Helpers (pure) ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** True if `host` matches any known C2 host or family regex. */
|
||||
export function isC2Host(text) {
|
||||
if (typeof text !== "string") return false;
|
||||
if (C2_HOSTS.some((h) => text.includes(h))) return true;
|
||||
return C2_HOST_RES.some((re) => re.test(text));
|
||||
}
|
||||
|
||||
/** True if `text` looks like a remote-fetch-piped-to-interpreter command. */
|
||||
export function isFetchToShell(text) {
|
||||
if (typeof text !== "string") return false;
|
||||
const fetches = NET_FETCH_RES.some((re) => re.test(text)) || ANY_URL_RE.test(text);
|
||||
const pipes = PIPE_SHELL_RES.some((re) => re.test(text));
|
||||
return fetches && pipes;
|
||||
}
|
||||
|
||||
/** True if `text` runs a script interpreter against a font/asset path (e.g. `node ./public/fonts/x.woff2`). */
|
||||
export function commandExecutesAsset(text) {
|
||||
if (typeof text !== "string") return false;
|
||||
return INTERPRETER_RE.test(text) && ASSET_EXEC_RE.test(text);
|
||||
}
|
||||
|
||||
/** True if `basename` matches the Font-Awesome family naming the malware disguises itself as. */
|
||||
export function isFaFamilyName(basename) {
|
||||
return typeof basename === "string" && FA_FONT_NAME_RE.test(basename);
|
||||
}
|
||||
|
||||
/** True if `basename` is a sidecar file the malware drops with its font carrier (e.g. README.md). */
|
||||
export function isFontDropSidecar(basename) {
|
||||
return typeof basename === "string" && FONT_DROP_SIDECARS.includes(basename.toLowerCase());
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Minimal recursive file walker shared by the scanner and font analysis.
|
||||
*
|
||||
* SAFETY: never follows symlinks — a malicious repo can't use a symlink to
|
||||
* redirect our reads outside the cloned working tree. Reads nothing here; it
|
||||
* only yields paths. Excludes .git and node_modules by default.
|
||||
*/
|
||||
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
export const DEFAULT_EXCLUDE = new Set([".git", "node_modules"]);
|
||||
|
||||
/**
|
||||
* Async-generate absolute file paths under `root`.
|
||||
* @param {string} root
|
||||
* @param {{ exclude?: Set<string> }} [opts]
|
||||
*/
|
||||
export async function* walkFiles(root, { exclude = DEFAULT_EXCLUDE } = {}) {
|
||||
let entries;
|
||||
try {
|
||||
entries = await fs.readdir(root, { withFileTypes: true });
|
||||
} catch {
|
||||
return; // unreadable directory → yield nothing
|
||||
}
|
||||
for (const ent of entries) {
|
||||
if (ent.isSymbolicLink()) continue;
|
||||
const full = path.join(root, ent.name);
|
||||
if (ent.isDirectory()) {
|
||||
if (exclude.has(ent.name)) continue;
|
||||
yield* walkFiles(full, { exclude });
|
||||
} else if (ent.isFile()) {
|
||||
yield full;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Collect paths whose extension is in `extensions` (lowercased, with dot). */
|
||||
export async function collectByExtension(root, extensions, opts) {
|
||||
const exts = new Set(extensions.map((e) => e.toLowerCase()));
|
||||
const out = [];
|
||||
for await (const file of walkFiles(root, opts)) {
|
||||
if (exts.has(path.extname(file).toLowerCase())) out.push(file);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
Reference in New Issue
Block a user