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
34 changes: 34 additions & 0 deletions .github/workflows/malware-markers.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
name: Malware marker scan

on:
push:
branches: [main]
pull_request:

permissions:
# This workflow only reads the repo: it clones and runs a dependency-free
# text scan. It does not push, comment, publish, or touch secrets.
contents: read

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
check:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
with:
node-version: "22"

# No install step: neither the scanner nor the shared logger has
# external dependencies, so nothing has to be downloaded before the scan.
- name: Run marker scanner tests
run: node --test scripts/check-malware-markers.test.mjs

- name: Scan tracked files for known malware markers
run: node scripts/check-malware-markers.mjs
111 changes: 111 additions & 0 deletions docs/MALWARE_MARKERS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
# Malware marker scan

A dependency-free, text-only scanner that looks for stable marker strings left
behind by known repository malware families. Malware families keep these marker
strings even when they rename files, so a tiny static scan gives maintainers an
early warning **without running untrusted code**.

Scope from issue [#480](https://github.com/Sub-Rosa-Issue/sub-rosa-issue/issues/480).

## Guarantees

- **Text only.** Files are read as UTF-8 and searched with a fixed-string
`includes` test. No content is parsed as a regular expression, imported,
evaluated or executed, so scanning a malicious file cannot run it.
- **No content in reports.** CI prints the marker name, the path and the line
number, never the matched line.
- **No production matches from non-source files.** `.git`, dependencies, build
output, and this scanner's own fixtures are excluded.

## Running it

```bash
pnpm markers:check # scan the working tree; exits 1 on any match
pnpm markers:test # positive and negative tests
```

CI runs both steps in `.github/workflows/malware-markers.yml` on every push to
`main` and on every pull request. The job is named `check`, the same job name
the other lightweight gates use, so it lands in the required status-check
context that the branch ruleset already enforces.

## Report format

Each match is one line on stdout:

```
path/to/file.ext:LINE: <marker name>
```

The summary goes to the structured log on stderr. Neither ever includes the
matched line, so a marker cannot be read back out of a CI log.

## What is scanned

Every file in the working tree is read as text — source files, configuration
files, YAML, JSON, Markdown and assets alike, so a marker cannot hide in a file
type the scanner has never heard of. Two groups are excluded:

| Exclusion | Reason |
| --- | --- |
| `.git` | VCS metadata, never production content |
| Dependencies (`node_modules`, `.pnpm-store`) | Third-party code we do not audit |
| Build output (`dist`, `build`, `out`, `.next`, `.turbo`, `coverage`, `.nyc_output`, `target`, `artifacts`, `tmp-bindings`, `test_snapshots`) | Generated, not shipped from this repo |
| `scripts/check-malware-markers.mjs`, `scripts/check-malware-markers.test.mjs`, `scripts/fixtures/malware-markers/` | The scanner, its tests and fixtures necessarily contain the markers |

## Updating the marker list

Markers live in exactly one place: the `MARKERS` array in
[`scripts/check-malware-markers.mjs`](../scripts/check-malware-markers.mjs).

The marker strings are deliberately **not** copied into this document. The
documented file is itself scanned in production, so repeating them here would
make the scan fail on its own documentation. The scanner, its test and its
fixtures are the only paths the scan skips.

To **add** a marker:

1. Add the exact string to `MARKERS` in `scripts/check-malware-markers.mjs`.
Matching is fixed-string and case-sensitive, so write it exactly as the
family emits it.
2. Add the same string to `REQUIRED` in
`scripts/check-malware-markers.test.mjs`.
3. Add it, on its own line, to
`scripts/fixtures/malware-markers/positive-markers.txt` — the test asserts
one hit per line and checks the line numbers, so append at the end and update
the expected line numbers.
4. If the marker has a plausible near-miss (a one-character or case variant),
add that lookalike to
`scripts/fixtures/malware-markers/negative-lookalikes.txt` so the negative
test keeps proving the search does not over-match.
5. Run `pnpm markers:check && pnpm markers:test` and commit.

To **remove** a marker, delete it from `MARKERS` and from `REQUIRED`, and drop
the corresponding fixture lines.

A marker must be a stable literal, not a pattern. If a family's marker varies
per host, it does not belong in this list; encode the invariant part instead or
handle it in a dedicated rule.

## Tests

`scripts/check-malware-markers.test.mjs` covers:

- every marker required by the issue is present in the list;
- positive detection of each marker on the line where it appears;
- negative near-misses that must not match;
- markers planted under `.git`, dependency and build directories are not
reported;
- the scanner's own source, tests and fixtures are not reported, while an
unrelated file in the same directory still is;
- this repository scans clean end to end;
- CLI output carries the marker name and path and never the complete line;
- matched content is never evaluated — a fixture that would set a global if
executed leaves it untouched, and the scanner source is asserted to contain no
dynamic-evaluation primitives.

## Limits

Fixed-string matching only. A marker that is split across lines, encoded,
obfuscated or compressed will not be detected. This scan is an early-warning
signal for known families, not a substitute for reviewing untrusted changes.
162 changes: 162 additions & 0 deletions scripts/check-malware-markers.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
#!/usr/bin/env node
// Copyright (c) 2026 Sub Rosa contributors
//
// Text-only marker scanner for known repository malware families. It reads
// bytes as text and performs a fixed-string search; it never parses, imports,
// evaluates or executes what it matches. How to update the marker list is
// documented in docs/MALWARE_MARKERS.md.
import { createLogger } from '../packages/logging/src/index.cjs';
const diagnostics = createLogger("scripts.check-malware-markers");

import { readdirSync, readFileSync, statSync } from "node:fs";
import { join, relative } from "node:path";
import { pathToFileURL } from "node:url";

const ROOT = new URL("..", import.meta.url).pathname;

/**
* Stable marker strings left behind by known repository malware families.
* Matched as fixed, case-sensitive substrings — never as regular expressions,
* so no untrusted content is ever parsed as a pattern.
*
* To add or remove a marker, edit this list and follow docs/MALWARE_MARKERS.md.
*/
export const MARKERS = [
"rmcej%otb%",
"Cot%3t=shtP",
"MicrosoftSystem64",
".pcl-data",
".pcl-state",
];

// VCS metadata, dependencies and build output are never production matches.
export const SKIP_DIRS = new Set([
".git",
"node_modules",
".pnpm-store",
"dist",
"build",
"out",
".next",
".turbo",
"coverage",
".nyc_output",
"target",
"artifacts",
"tmp-bindings",
"test_snapshots",
]);

// Scanner-owned files: the marker list lives in this script, and its tests and
// fixtures necessarily repeat it, so they would otherwise match themselves.
export const SKIP_PATHS = new Set([
"scripts/check-malware-markers.mjs",
"scripts/check-malware-markers.test.mjs",
"scripts/fixtures/malware-markers",
]);

/** @param {string} relPath */
export function isSkipped(relPath) {
for (const prefix of SKIP_PATHS) {
if (relPath === prefix || relPath.startsWith(`${prefix}/`)) return true;
}
return false;
}

/**
* Find marker occurrences in text. Reports only the path, line number and
* marker name — the matched content itself is never returned or logged.
*
* @param {string} content
* @param {string} relPath
*/
export function scanText(content, relPath) {
/** @type {{ relPath: string, line: number, marker: string }[]} */
const hits = [];
if (isSkipped(relPath)) return hits;

const lines = content.split("\n");
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
for (const marker of MARKERS) {
if (line.includes(marker)) {
hits.push({ relPath, line: i + 1, marker });
}
}
}
return hits;
}

/** @param {string} dir @param {string} rootDir @param {string[]} out */
function walk(dir, rootDir, out = []) {
let entries;
try {
entries = readdirSync(dir, { withFileTypes: true });
} catch (error) {
if (error.code === "ENOENT" || error.code === "EACCES" || error.code === "ENOTDIR") return out;
throw error;
}

for (const entry of entries) {
const full = join(dir, entry.name);
const relPath = relative(rootDir, full).split("\\").join("/");
if (entry.isDirectory()) {
if (SKIP_DIRS.has(entry.name) || isSkipped(relPath)) continue;
walk(full, rootDir, out);
continue;
}
if (entry.isFile() && !isSkipped(relPath)) out.push(full);
}
return out;
}

/**
* Scan a working tree for marker strings.
*
* @param {string} rootDir
*/
export function scanTree(rootDir = ROOT) {
/** @type {{ relPath: string, line: number, marker: string }[]} */
const all = [];
for (const file of walk(rootDir, rootDir)) {
const relPath = relative(rootDir, file).split("\\").join("/");
let content;
try {
content = readFileSync(file, "utf-8");
} catch (error) {
if (error.code === "EACCES" || error.code === "ENOENT" || error.code === "EISDIR") continue;
throw error;
}
all.push(...scanText(content, relPath));
}
return all;
}

/** @param {string[]} argv */
function main(argv) {
let rootDir = ROOT;
for (let i = 0; i < argv.length; i++) {
if (argv[i] === "--root" && i + 1 < argv.length) {
rootDir = argv[i + 1];
i++;
}
}

const matches = scanTree(rootDir);

if (matches.length === 0) {
diagnostics.info("pass", `No known malware markers found in tracked source and configuration files.`);
process.exit(0);
}

// CI prints the marker name and path only (to stdout), never the matched line.
for (const match of matches) {
process.stdout.write(`${match.relPath}:${match.line}: ${match.marker}\n`);
}
diagnostics.error("fail", `Found ${matches.length} known malware marker match(es). See docs/MALWARE_MARKERS.md.`);
process.exit(1);
}

if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
main(process.argv.slice(2));
}
Loading