From ee693d06e42a58d41bcf3e685e4d2a9581250610 Mon Sep 17 00:00:00 2001 From: umpireboulianne-design Date: Sat, 24 Oct 2026 12:17:34 -0400 Subject: [PATCH] security(ci): scan tracked files for known repository-malware markers (#480) Add static text-only malware marker scanner and unit tests covering rmcej%otb%, Cot%3t=shtP, MicrosoftSystem64, .pcl-data, and .pcl-state, wire into CI, and document marker update workflow in docs/CI.md. Fixes #480 --- docs/CI.md | 23 +++ package.json | 4 +- scripts/check-malware-markers.mjs | 188 +++++++++++++++++++++++++ scripts/check-malware-markers.test.mjs | 88 ++++++++++++ 4 files changed, 302 insertions(+), 1 deletion(-) create mode 100644 scripts/check-malware-markers.mjs create mode 100644 scripts/check-malware-markers.test.mjs diff --git a/docs/CI.md b/docs/CI.md index 4a3af75..37feab0 100644 --- a/docs/CI.md +++ b/docs/CI.md @@ -83,3 +83,26 @@ print order are asserted by: ```bash node --test scripts/check-bindings-cli.test.mjs ``` + +## Repository malware marker scan + +`scripts/check-malware-markers.mjs` performs a static, text-only scan of tracked +source and configuration files for known repository-malware indicator tokens. +It excludes `.git`, `node_modules`, `.pnpm-store`, build/artifact directories +(`dist`, `build`, `target`, `artifacts`, `coverage`), and the scanner's own +implementation/test files, never evaluates matched files, and logs only the +matched marker name and relative file path (never the full line). + +Run locally: + +```bash +pnpm malware:test +pnpm malware:check +``` + +### Updating the marker list + +1. Add a `{ name, token }` entry to `MALWARE_MARKERS` in + `scripts/check-malware-markers.mjs`. +2. Add a positive detection case in `scripts/check-malware-markers.test.mjs`. +3. Run `pnpm malware:test && pnpm malware:check` before opening a PR. diff --git a/package.json b/package.json index 66e5b7c..50b8a04 100644 --- a/package.json +++ b/package.json @@ -76,7 +76,9 @@ "snapshot:check": "node scripts/check-snapshots.mjs", "docs:check": "node scripts/check-deploy-docs.mjs", "docs:check-links": "node scripts/check-links.js", - "coverage:test": "node --test scripts/run-ts-coverage.test.mjs && node scripts/run-ts-coverage.mjs", + "coverage:test": "node --test scripts/check-malware-markers.test.mjs && node scripts/check-malware-markers.mjs && node --test scripts/run-ts-coverage.test.mjs && node scripts/run-ts-coverage.mjs", + "malware:check": "node scripts/check-malware-markers.mjs", + "malware:test": "node --test scripts/check-malware-markers.test.mjs", "drand:test": "pnpm --filter @sub-rosa/drand-tools test", "drand:typecheck": "pnpm --filter @sub-rosa/drand-tools typecheck", "receipt:test": "pnpm --filter @sub-rosa/receipt-cli test", diff --git a/scripts/check-malware-markers.mjs b/scripts/check-malware-markers.mjs new file mode 100644 index 0000000..ffbd9c5 --- /dev/null +++ b/scripts/check-malware-markers.mjs @@ -0,0 +1,188 @@ +#!/usr/bin/env node +// Copyright (c) 2026 Sub Rosa contributors +// +// Text-only static scanner for known repository-malware marker strings. +// Never evaluates matched content and reports only the marker name and +// relative file path (never the complete line). +import { readdirSync, readFileSync } from "node:fs"; +import { extname, join, relative } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { createLogger } from "../packages/logging/src/index.cjs"; + +const diagnostics = createLogger("scripts.check-malware-markers"); + +const DEFAULT_ROOT = fileURLToPath(new URL("..", import.meta.url)); + +/** + * Known repository-malware markers. Each entry pairs a human-readable marker + * identifier (`name`) with the exact substring (`token`) to scan for. + */ +export const MALWARE_MARKERS = [ + { name: "rmcej%otb%", token: "rmcej%otb%" }, + { name: "Cot%3t=shtP", token: "Cot%3t=shtP" }, + { name: "MicrosoftSystem64", token: "MicrosoftSystem64" }, + { name: ".pcl-data", token: ".pcl-data" }, + { name: ".pcl-state", token: ".pcl-state" }, +]; + +export const SKIP_DIRS = new Set([ + ".git", + "node_modules", + ".pnpm-store", + "dist", + "build", + "target", + "artifacts", + "coverage", + ".turbo", + "tmp-bindings", + ".nyc_output", +]); + +export const EXCLUDED_FILES = new Set([ + "scripts/check-malware-markers.mjs", + "scripts/check-malware-markers.test.mjs", +]); + +const BINARY_EXTENSIONS = new Set([ + ".png", + ".jpg", + ".jpeg", + ".gif", + ".webp", + ".ico", + ".wasm", + ".woff", + ".woff2", + ".ttf", + ".otf", + ".pdf", + ".zip", + ".tar", + ".gz", +]); + +/** + * Scan a single file's text content and return matched marker names + path. + * Never returns or logs the matched line content. + * + * @param {string} content + * @param {string} relPath + * @param {typeof MALWARE_MARKERS} [markers] + */ +export function scanContentForMarkers( + content, + relPath, + markers = MALWARE_MARKERS, +) { + const normalizedPath = relPath.split("\\").join("/"); + if (EXCLUDED_FILES.has(normalizedPath)) { + return []; + } + + /** @type {{ path: string, marker: string }[]} */ + const matches = []; + for (const { name, token } of markers) { + if (content.includes(token)) { + matches.push({ path: normalizedPath, marker: name }); + } + } + return matches; +} + +/** + * Walk `rootDir` (excluding `.git`, dependencies, build output, and scanner + * test fixtures) and scan all text files for known malware markers. + * + * @param {string} [rootDir] + * @param {typeof MALWARE_MARKERS} [markers] + */ +export function scanRepositoryForMarkers( + rootDir = DEFAULT_ROOT, + markers = MALWARE_MARKERS, +) { + /** @type {{ path: string, marker: string }[]} */ + const matches = []; + + /** @param {string} currentDir */ + function walk(currentDir) { + let entries; + try { + entries = readdirSync(currentDir, { withFileTypes: true }); + } catch { + return; + } + + for (const entry of entries) { + const fullPath = join(currentDir, entry.name); + const relPath = relative(rootDir, fullPath).split("\\").join("/"); + + if (entry.isDirectory()) { + if (SKIP_DIRS.has(entry.name)) continue; + walk(fullPath); + continue; + } + + if (!entry.isFile()) continue; + if (EXCLUDED_FILES.has(relPath)) continue; + if (BINARY_EXTENSIONS.has(extname(entry.name).toLowerCase())) continue; + + for (const { name, token } of markers) { + if (entry.name.includes(token)) { + matches.push({ path: relPath, marker: name }); + } + } + + let content; + try { + content = readFileSync(fullPath, "utf8"); + } catch { + continue; + } + + for (const hit of scanContentForMarkers(content, relPath, markers)) { + if ( + !matches.some( + (m) => m.path === hit.path && m.marker === hit.marker, + ) + ) { + matches.push(hit); + } + } + } + } + + walk(rootDir); + return matches; +} + +/** + * Format a finding for CI output: only marker name and path, never the line. + * @param {{ path: string, marker: string }} finding + */ +export function formatFinding(finding) { + return `MATCH marker=${finding.marker} path=${finding.path}`; +} + +function main() { + const rootDir = process.env.MALWARE_SCAN_ROOT || DEFAULT_ROOT; + const matches = scanRepositoryForMarkers(rootDir); + + if (matches.length > 0) { + for (const finding of matches) { + const summary = formatFinding(finding); + process.stdout.write(`${summary}\n`); + diagnostics.error("malware-marker-detected", summary); + } + process.exit(1); + } + + diagnostics.info( + "malware-scan-clean", + `PASS: Scanned repository with ${MALWARE_MARKERS.length} malware markers; 0 matches found.`, + ); +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { + main(); +} diff --git a/scripts/check-malware-markers.test.mjs b/scripts/check-malware-markers.test.mjs new file mode 100644 index 0000000..68c7794 --- /dev/null +++ b/scripts/check-malware-markers.test.mjs @@ -0,0 +1,88 @@ +// Copyright (c) 2026 Sub Rosa contributors +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { + MALWARE_MARKERS, + formatFinding, + scanContentForMarkers, + scanRepositoryForMarkers, +} from "./check-malware-markers.mjs"; + +const REPO_ROOT = fileURLToPath(new URL("..", import.meta.url)); + +describe("check-malware-markers", () => { + it("passes with zero matches on the clean repository", () => { + const matches = scanRepositoryForMarkers(REPO_ROOT); + assert.deepEqual(matches, []); + }); + + it("detects all five required malware markers in tracked source and config files", () => { + const required = [ + "rmcej%otb%", + "Cot%3t=shtP", + "MicrosoftSystem64", + ".pcl-data", + ".pcl-state", + ]; + for (const marker of required) { + assert.ok( + MALWARE_MARKERS.some((m) => m.token === marker), + `MALWARE_MARKERS must include ${marker}`, + ); + const hits = scanContentForMarkers( + `const secretPayload = "${marker}";\n`, + "packages/sdk/src/infected.ts", + ); + assert.deepEqual(hits, [ + { path: "packages/sdk/src/infected.ts", marker }, + ]); + } + }); + + it("reports only the matched marker name and path, never the full line", () => { + const secretLine = "const hidden = 'rmcej%otb% SUPER_SECRET_PAYLOAD_DO_NOT_PRINT';"; + const hits = scanContentForMarkers(secretLine, "apps/web/vite.config.ts"); + assert.equal(hits.length, 1); + const formatted = formatFinding(hits[0]); + assert.equal(formatted, "MATCH marker=rmcej%otb% path=apps/web/vite.config.ts"); + assert.ok(!formatted.includes("SUPER_SECRET_PAYLOAD_DO_NOT_PRINT")); + }); + + it("excludes .git, node_modules, build output, and scanner test fixtures", () => { + const tmp = mkdtempSync(join(tmpdir(), "malware-scan-test-")); + try { + mkdirSync(join(tmp, ".git"), { recursive: true }); + mkdirSync(join(tmp, "node_modules/pkg"), { recursive: true }); + mkdirSync(join(tmp, "dist"), { recursive: true }); + mkdirSync(join(tmp, "target"), { recursive: true }); + mkdirSync(join(tmp, "scripts"), { recursive: true }); + mkdirSync(join(tmp, "src"), { recursive: true }); + + writeFileSync(join(tmp, ".git/config"), "rmcej%otb%"); + writeFileSync(join(tmp, "node_modules/pkg/index.js"), "Cot%3t=shtP"); + writeFileSync(join(tmp, "dist/bundle.js"), "MicrosoftSystem64"); + writeFileSync(join(tmp, "target/out.txt"), ".pcl-data"); + writeFileSync( + join(tmp, "scripts/check-malware-markers.test.mjs"), + ".pcl-state", + ); + writeFileSync(join(tmp, "src/clean.ts"), "export const ok = true;\n"); + + const cleanMatches = scanRepositoryForMarkers(tmp); + assert.deepEqual(cleanMatches, []); + + writeFileSync(join(tmp, "src/bad.ts"), "// .pcl-state marker"); + const infectedMatches = scanRepositoryForMarkers(tmp); + assert.deepEqual(infectedMatches, [ + { path: "src/bad.ts", marker: ".pcl-state" }, + ]); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } + }); +});