Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions docs/CI.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
188 changes: 188 additions & 0 deletions scripts/check-malware-markers.mjs
Original file line number Diff line number Diff line change
@@ -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();
}
88 changes: 88 additions & 0 deletions scripts/check-malware-markers.test.mjs
Original file line number Diff line number Diff line change
@@ -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 });
}
});
});