- 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.
237 lines
9.9 KiB
JavaScript
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/);
|
|
});
|