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

237 lines
9.9 KiB
JavaScript

/**
* Tests for the GitHub Action frontend (src/ci.js). Exercises input parsing,
* exit-code policy, reporting, and the SARIF builder against real fixtures.
* The git/gh-dependent paths (fix+commit, pr mode) are validated by the
* integration workflow, not here.
*/
import { test, after, beforeEach } from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import { run, resolveSettings, failThreshold, buildSummary } from "../src/ci.js";
import { buildSarif } from "../src/sarif.js";
import { scanRepo } from "../src/scanner.js";
import {
makeRepo,
mkTmpDir,
cleanupAll,
LEGIT_CONFIG,
INFECTED_TASKS,
ORIGINAL_PAYLOAD,
GENERIC_PAYLOAD,
infectedConfig,
} from "./helpers.js";
after(cleanupAll);
// Every env key run() / resolveSettings() may read — cleared between tests so
// values can't leak across cases.
const MANAGED = [
"GITHUB_WORKSPACE", "GITHUB_OUTPUT", "GITHUB_STEP_SUMMARY", "GITHUB_EVENT_NAME",
"GITHUB_EVENT_PATH", "GITHUB_REPOSITORY", "GITHUB_REF_NAME",
"DRY_RUN", "AUTO_MERGE", "MERGE_METHOD", "BRANCH_PREFIX", "GH_TOKEN", "GITHUB_TOKEN", "SARIF_FILE",
];
function clearEnv() {
for (const k of Object.keys(process.env)) if (k.startsWith("INPUT_")) delete process.env[k];
for (const k of MANAGED) delete process.env[k];
}
beforeEach(clearEnv);
after(clearEnv);
/** Set INPUT_* env from a {name: value} map (GitHub Actions convention). */
function setInputs(inputs) {
for (const [k, v] of Object.entries(inputs)) {
process.env[`INPUT_${k.replace(/ /g, "_").toUpperCase()}`] = String(v);
}
}
/** Run the action against a workspace dir; returns { code, outputs, summary }. */
async function runAction(workspace, inputs = {}, extraEnv = {}) {
const io = mkTmpDir("ci-io-");
const outFile = path.join(io, "out");
const sumFile = path.join(io, "sum");
fs.writeFileSync(outFile, "");
fs.writeFileSync(sumFile, "");
process.env.GITHUB_WORKSPACE = workspace;
process.env.GITHUB_OUTPUT = outFile;
process.env.GITHUB_STEP_SUMMARY = sumFile;
Object.assign(process.env, extraEnv);
setInputs(inputs);
const code = await run();
const outputs = Object.fromEntries(
fs.readFileSync(outFile, "utf8").split("\n").filter(Boolean).map((l) => {
const i = l.indexOf("=");
return [l.slice(0, i), l.slice(i + 1)];
}),
);
return { code, outputs, summary: fs.readFileSync(sumFile, "utf8") };
}
const INFECTED_FILES = {
"App.js": infectedConfig(ORIGINAL_PAYLOAD),
".vscode/tasks.json": INFECTED_TASKS,
"config.bat": "",
};
// ─── resolveSettings ───────────────────────────────────────────────────────────
test("resolveSettings: defaults", () => {
const s = resolveSettings();
assert.equal(s.mode, "check");
assert.equal(s.scanPath, ".");
assert.equal(s.failOn, "infected");
assert.equal(s.commit, false);
assert.equal(s.commentOnPr, true);
assert.equal(s.mergeMethod, "squash");
assert.equal(s.branchPrefix, "fix/polinrider-cleanup");
});
test("resolveSettings: INPUT_* parsing incl. hyphenated names", () => {
setInputs({ mode: "FIX", "fail-on": "Suspicious", commit: "true", "comment-on-pr": "false", "merge-method": "REBASE" });
const s = resolveSettings();
assert.equal(s.mode, "fix");
assert.equal(s.failOn, "suspicious");
assert.equal(s.commit, true);
assert.equal(s.commentOnPr, false);
assert.equal(s.mergeMethod, "rebase");
});
test("resolveSettings: legacy env fallback + INPUT precedence", () => {
process.env.DRY_RUN = "true";
process.env.AUTO_MERGE = "true";
process.env.GH_TOKEN = "legacy-token";
let s = resolveSettings();
assert.equal(s.dryRun, true);
assert.equal(s.autoMerge, true);
assert.equal(s.token, "legacy-token");
// An explicit input wins over the token env var.
setInputs({ token: "input-token" });
s = resolveSettings();
assert.equal(s.token, "input-token");
});
// ─── failThreshold ─────────────────────────────────────────────────────────────
test("failThreshold ranks", () => {
assert.equal(failThreshold("never"), Infinity);
assert.equal(failThreshold("suspicious"), 1);
assert.equal(failThreshold("infected"), 2);
assert.equal(failThreshold("anything-else"), 2);
});
// ─── check mode exit codes ─────────────────────────────────────────────────────
test("check: infected → exit 1 + outputs", async () => {
const repo = await makeRepo(INFECTED_FILES);
const { code, outputs, summary } = await runAction(repo, { mode: "check" });
assert.equal(code, 1);
assert.equal(outputs.severity, "infected");
assert.equal(outputs.infected, "true");
assert.equal(outputs["findings-count"], "3");
assert.equal(outputs.changed, "false");
assert.match(summary, /PolinRider Malware Scan/);
assert.match(summary, /infected/);
});
test("check: clean → exit 0", async () => {
const repo = await makeRepo({ "postcss.config.js": LEGIT_CONFIG });
const { code, outputs } = await runAction(repo, { mode: "check" });
assert.equal(code, 0);
assert.equal(outputs.severity, "clean");
assert.equal(outputs.infected, "false");
});
test("check: fail-on=never never fails, even when infected", async () => {
const repo = await makeRepo(INFECTED_FILES);
const { code, outputs } = await runAction(repo, { mode: "check", "fail-on": "never" });
assert.equal(code, 0);
assert.equal(outputs.infected, "true");
});
test("check: exclude skips flagged files", async () => {
// A vendored copy that legitimately carries a signature would false-positive;
// excluding it makes the scan clean, while a real infection elsewhere still fails.
const repo = await makeRepo({
"vendor/sig-sample.js": infectedConfig(ORIGINAL_PAYLOAD),
});
const withoutExclude = await runAction(repo, { mode: "check" });
assert.equal(withoutExclude.code, 1);
assert.equal(withoutExclude.outputs.severity, "infected");
const withExclude = await runAction(repo, { mode: "check", exclude: "vendor/**" });
assert.equal(withExclude.code, 0);
assert.equal(withExclude.outputs.severity, "clean");
});
test("check: suspicious repo fails only at fail-on=suspicious", async () => {
const repo = await makeRepo({ "weird.js": `export const x = 1;\n${GENERIC_PAYLOAD}` });
const def = await runAction(repo, { mode: "check" }); // fail-on=infected
assert.equal(def.outputs.severity, "suspicious");
assert.equal(def.code, 0);
const strict = await runAction(repo, { mode: "check", "fail-on": "suspicious" });
assert.equal(strict.code, 1);
});
// ─── fix mode (no commit) ──────────────────────────────────────────────────────
test("fix: cleans the working tree, exit 0, re-scan clean", async () => {
const repo = await makeRepo(INFECTED_FILES);
const { code, outputs } = await runAction(repo, { mode: "fix" });
assert.equal(code, 0);
assert.equal(outputs.changed, "true");
assert.equal(outputs.remediated, "true");
// Payload stripped, legit config kept; malware artifacts gone.
assert.match(fs.readFileSync(path.join(repo, "App.js"), "utf8"), /tailwindcss/);
assert.ok(!fs.existsSync(path.join(repo, ".vscode")));
assert.ok(!fs.existsSync(path.join(repo, "config.bat")));
const rescan = await scanRepo(repo);
assert.equal(rescan.severity, "clean");
});
test("fix: confirmed-but-manual-review item leaves exit 1", async () => {
// An impostor dep is content-confirmed but action=manual-review — fix can't
// auto-remove it, so the check must still fail. (Note: remediate also runs
// .gitignore hygiene, so `changed` may be true; the contract under test is the
// exit code and that the impostor dep is NOT silently stripped.)
const repo = await makeRepo({
"package.json": JSON.stringify({ name: "x", dependencies: { "tailwindcss-style-animate": "^1.0.0" } }),
});
const { code, outputs } = await runAction(repo, { mode: "fix" });
assert.equal(outputs.severity, "infected");
assert.equal(code, 1); // unresolved confirmed item → fail
assert.match(fs.readFileSync(path.join(repo, "package.json"), "utf8"), /tailwindcss-style-animate/);
});
// ─── SARIF ─────────────────────────────────────────────────────────────────────
test("buildSarif: shape + js-payload line from offset", async () => {
const repo = await makeRepo(INFECTED_FILES);
const findings = await scanRepo(repo);
const sarif = buildSarif(findings, { repoRoot: repo, repoDir: repo });
assert.equal(sarif.version, "2.1.0");
const driver = sarif.runs[0].tool.driver;
assert.equal(driver.name, "polinrider-cleanup");
assert.equal(sarif.runs[0].results.length, findings.findings.length);
const js = sarif.runs[0].results.find((r) => r.ruleId.startsWith("js.payload"));
assert.equal(js.level, "error");
assert.equal(js.locations[0].physicalLocation.artifactLocation.uri, "App.js");
assert.equal(js.locations[0].physicalLocation.region.startLine, 5);
});
// ─── buildSummary ──────────────────────────────────────────────────────────────
test("buildSummary: includes remediation section when result present", async () => {
const repo = await makeRepo(INFECTED_FILES);
const findings = await scanRepo(repo);
const md = buildSummary({
settings: { mode: "fix", dryRun: false },
findings,
result: { dryRun: false, applied: [], notes: ["did a thing"], skipped: [], filesModified: [], filesDeleted: [] },
});
assert.match(md, /### Findings/);
assert.match(md, /### Remediation/);
assert.match(md, /did a thing/);
});