How the GitHub Investigation Was Verified
Pinned commits, fixed inclusion rules, exclusions, independent checks, evidence provenance and research limits.
Cristian Chiriac
- Published:
- 2 November 2026
- Version:
- 1.0
- Last observed:
- 30 October 2026
- Dataset frozen:
- 1 November 2026
- Collection cut-off:
- 31 October 2026, 17:59 UTC
- Tracked items rechecked:
- 1 November 2026, 18:23 UTC
- Time zone
- All dates UTC.
Contents
The headline count is built from fixed inclusion rules and pinned repository states, with a metadata-only exception for Shai-Hulud.
The research in 30 seconds
- What I found
- The headline count is built from fixed inclusion rules and pinned repository states, with a metadata-only exception for Shai-Hulud.
- What this adds
- The method records rejected candidates and an independent second-API recheck instead of treating search results as proof.
- Verified scope
- 43,448 unique repositories across the counted activities; an independent stratified sample of 555 was re-read.
- What remains unknown
- Proof establishes code or metadata at a recorded snapshot. It does not establish execution, compromise counts or operator identity.
The numbers, with their boundaries
Current count
43,448
Unique proven repositories
30–31 Oct 2026 · UTC
- Counts:
- Counted by repository id across the included activity sets.
Context · separate measure
555 / 555
Independent sample matches
30 Oct 2026 · UTC
- Counts:
- Stratified re-read through REST at pinned commits; not a full second audit.
This methodology supports the investigation series. Repository-level proof rows remain private because their permalinks identify affected accounts.
Where this stands (1 November 2026)
#The proven set was closed on 1 November 2026 with what had been found up to 31 October 2026, 17:59 UTC; repositories found after that are tracked separately and are not in these figures. The full proof pass at pinned commits ran on 30–31 October. On 1 November 2026, 18:23 UTC a read-only GitHub API recheck covered the 162 repositories, 73 accounts and 12 issues that are tracked one by one (operator repositories, Shai-Hulud accounts, phishing issues), not the whole set.
- Frozen set: 43,448 repositories under 4,250 accounts; no row is added or removed after the freeze.
- After the freeze: the daily tracker keeps proving new repositories with the same rules; they go to a separate post-freeze list, never into these figures.
- One judgement call kept: a repository whose loader sits under a
tests/path stays in the set because an.envin its git history decodes to a proven host; the dataset-path rule would otherwise have excluded it.
How every repository was proven
#The first searches produced candidates, not proof. On 30 October every candidate was read again from scratch:
- Pin the commit. Read the repository by its numeric id, so a rename cannot change the answer. Record the current default-branch commit.
- Read the file at that commit. Record its path, git object id and size. For a task that runs another file, read that target too.
- Apply a rule fixed in advance. Each activity's rule is stated in its dedicated article. A file that only resembles the pattern does not count.
- Keep a permalink to the commit. Anyone can open the same file at the same commit.
Shai-Hulud is the exception. Those repositories hold stolen secrets, so I never opened them. They are proven from repository metadata instead: the worm writes its own marker into the description.
No file contents were stored. The per-repository proof table stays private, because each permalink names a victim's account. This article gives the rules, the counts, the hashes and the indicators.
The re-check also caught a mistake in my first rule. Seven sampled PolinRider tasks pointed at an empty file, which cannot run anything. I tightened the rule: the target must exist, must not be empty and must look like JavaScript. That removed 806 repositories before the final count.
Later on 30 October three more corrections were made. A paged code search added 384 AUTH_API_KEY loader
repositories, each checked against the proven files with rules fixed before counting. Six research or
defensive files that had matched a rule were removed (five repositories, plus one loader label). Six
hook lures whose hook sits in .github/githooks/ were added. The 555-repository re-check was drawn before these corrections. On 31 October seven more were added from a
wider read of lure and tool repositories: three PolinRider build configs and four published Discord token
stealers. A removal tool and two crawler datasets matched the same rules and were left out. Later that day an
extended search added 421 more under the same rules and removed one false positive. On 1 November 32 more
were added from the daily tracker's run of 31 October evening (29 Shai-Hulud, 3 AUTH_API_KEY loaders), each
read again at a pinned commit. Each activity below says what it added.
The set was closed on 1 November 2026 with what had been found up to 31 October, 17:59 UTC.
Repositories found after that are tracked separately and are not part of the total.
30 October 2026 proof pass, PolinRider only. None of these is counted anywhere in this article.
Table view
| Item | Value | Note |
|---|---|---|
| Task points at an empty file | 806 | |
| No evidence at the current commit | 360 | Cleaned or moved |
| Task target deleted | 131 | |
| Security datasets and research copies | 78 | |
| Repository deleted | 25 | |
| Needs manual review | 3 |
Why it is malicious, and why it might not be
#Code that downloads or evaluates other code is not malicious by itself. Build tools do it, and so do security datasets and detection rules that quote malware. For each activity, the table gives the rule that made the call, the harmless explanation I checked for, and what stays unproven.
Full data table · 10 rows
| Activity | Why it is malicious | What could make it harmless, and how that was handled | Not proven |
|---|---|---|---|
| Contagious Interview | Loader byte-identical to analysed samples; decoded configs point to operator staging hosts; payloads are a stealer family | A template copied by a curious developer. Copies are counted separately from operator repositories | Whether a given candidate ran it |
| PolinRider | A "font" that is JavaScript, started on folder open; or a payload hidden after hundreds of spaces in a build config | Security datasets, detection rules, real fonts, empty targets. All excluded: 78 dataset repositories, 806 empty targets | Who pushed most infections; execution on any machine |
AUTH_API_KEY loader |
A committed .env value decodes to a web address; the file evaluates code with it |
A real API key, or a test fixture. Ruled out: the value decodes to a URL and is passed to eval or Function |
The payload itself; the fetch step file by file |
| Older families | Hard-coded Discord webhook with token plus code reading Discord's local storage; loaders identical in form to Contagious Interview | Educational repositories. Counted as published tools, not victims | Whether each stealer works |
| Shai-Hulud | The worm's own description marker on repositories created in bursts in victims' accounts | Hobby repositories named after the novel, detection tools. Excluded by exact marker matching | Which package infected each account |
| D1337 | A workflow downloads XMRig and mines to one wallet with a per-owner worker name | An owner mining on purpose. Unlikely: one identity planted it in many unrelated accounts | Whether the runs happened and what was earned |
| Fake CVE exploits | Archives contain no exploit, only a launcher, a renamed interpreter and an obfuscated script; 12 of 14 detected, 5 as SmartLoader | A real proof-of-concept. None of the archives contains one | Who downloaded them; the two archives unknown to VirusTotal |
| Lure hubs | Not yet decided | Thin repositories that link real downloads | Being verified |
| Trading-bot pages | Page code builds a hidden download link and refuses to for scanners | A legitimate download page. Cloaking from scanners argues against it | What the download installs |
| Fake security alerts | Fake "[GitHub]" alert issues that mention one user each, with shortened links | A real security notice. GitHub's own account alerts do not arrive as issues in a third-party repository | Where the links lead |
Data notes and reconciliation
#Expand technical detail · complete record
| Measure | Figures | Why they differ |
|---|---|---|
| Headline total | 42,604 → 42,983 → 42,989 → 42,996 → 43,416 → 43,448 | 30 Oct morning proof pass → +384 AUTH_API_KEY, −5 research datasets (30 Oct evening) → +6 armed hook lures → +7 from a wider read (31 Oct) → +421 extended search, −1 (31 Oct) → +32 daily-tracker finds up to the cut-off, added on 1 Nov; frozen |
| Accounts | 4,015 → 4,141 → 4,143 → 4,149 → 4,248 → 4,250 | Same steps |
| PolinRider | 39,995 → 39,998 → 40,136 | 30 Oct proof pass → +3 build configs → +138 from reading every repository of newly proven owners (31 Oct) |
| Shai-Hulud | 1,830 → 1,815 → 1,796 → 1,966 → 1,995 | 29 Oct inventory → final inventory → 30 Oct recheck → +170 (31 Oct) → +29 created on 31 Oct before the cut-off, added on 1 Nov |
| AUTH_API_KEY loader | 231 → 614 → 646 → 649 | See the loader article |
| Older families | 229 → 233 → 273 | +4 wider read, +39 Discord stealers and +1 Python loader (31 Oct) |
| Independent re-check | 555 of 555 | Drawn from the 30 Oct set; later additions were pinned again individually, not sampled |
The inclusion rules, in one place
#Each dedicated article states its rule; this is the same list side by side, as applied on 30 October and to every later addition. A file that only resembles a pattern does not count.
Full data table · 11 rows
| Activity | What must be true at the pinned commit |
|---|---|
| PolinRider, task form | .vscode/tasks.json has a task with "runOn": "folderOpen" that starts node on a file whose name looks like a font (.woff, .woff2, .ttf, .eot, .otf); that file exists, is not empty and is JavaScript |
| PolinRider, config form | A build configuration file (vite, next, nuxt, postcss, tailwind, astro) carries JavaScript after a long run of whitespace, with the loader's global-variable pattern |
AUTH_API_KEY loader |
A code file calls atob on process.env.<…>API_KEY<…> and passes the result to eval or Function; the committed .env value, or one in the git history, decodes to a web address |
| Contagious Interview | A loader file byte-identical to an analysed sample, a post-checkout hook under .githooks/ or .github/githooks/ that pipes a download into a shell, or the server source itself |
| Discord token stealer | A hard-coded Discord webhook address and code that reads Discord's local storage (leveldb, Local Storage) for tokens |
| errorHandler copy | A controller or bootstrap file with the loader in the same form as the Contagious Interview templates, outside the operators' template set |
| Python Fernet loader | exec(Fernet(<key in the file>).decrypt(<ciphertext>)), with the decrypted stage reading as a remote-execution loader |
| Shai-Hulud | The worm's exact description marker on the repository; contents never opened |
| D1337 | A workflow file that downloads XMRig and mines to the cluster's wallet |
| Fake CVE exploits | A release archive whose SHA-256 is known malware and whose file list has no exploit, only a launcher, a renamed interpreter and an obfuscated script |
| Cheat and crack lures | A README that links a release file whose hash is known malware; 1,422 lures that link only to hubs stay suspected |
Three exclusions apply to every rule: paths that mark tests, fixtures, samples, corpora, datasets,
documentation and research (tests/, fixtures/, samples/, corpus/, docs/, research/,
malware/, rules/, reports/), and files ending in .md, .txt, .json, .yaml or .html
unless the rule itself names that file. Two rules do: the PolinRider task form reads
.vscode/tasks.json, and the cheat and crack rule reads the README that links the archive; a match in
any other file of those types counts for nothing;
repositories whose only match is a security dataset, a detection rule or a removal tool; and matches
inside a coding-assistant notes file with no loader in any code file. One exception is kept and named in
"Where this stands".
Counting rules
#- One row per repository id. A repository that carries two activities (118 do, mostly PolinRider
with the
AUTH_API_KEYloader) is one row listing both; its file columns describe the PolinRider file. Per-activity figures can therefore add up to more than the total. - Accounts by owner id. Logins seen under the same id are listed together; 203 owners appear in two activities.
- Forks compared with their parent. 6,884 of the repositories are forks; in 719 the malicious file is byte-identical to the parent's and was not modified in the fork. They are counted and flagged, because removing the parent does not clean them. Accounts whose only infected repositories are such forks (53 for PolinRider) are listed as inherited, not compromised.
- Non-default branches are not counted. 22,870 infected branches in 6,530 repositories were recorded on 29 October and are counted separately.
- Dates come from the platform, not from commits: PolinRider operators rewrite history, so a commit's author date is not an infection date. "New" means first proven, except for Shai-Hulud, where the repository creation time is exact.
- Frozen at a cut-off. The set closed on 1 November 2026 with what was found up to 31 October 2026, 17:59 UTC. Later finds go to a dated post-freeze list.
Environment and tooling
#- Every read of the platform (GraphQL for repository and owner ids, REST for commits, trees and contents, code and repository search) ran inside an isolated virtual machine with no access to the host. The token entered the machine on standard input for each run and was never written to disk; caches were cleared after each run.
- File text was held in memory only. Stored per repository: ids, commit, path, object id, size, rule name, booleans and dates. No file contents, no decoded payloads, no victims' data.
- Decoding and deobfuscation (base64, hex, the Fernet stage, obfuscated JavaScript) were static, inside the same machine. Nothing was executed; no attacker host was contacted; no link from an issue or file was opened.
- The scripts that produced each count, the query lists and the rule texts are kept with the evidence, so a figure can be recomputed from the same inputs. The independent re-check used a recorded random seed.
Timeline of the proof work
#| Date (2026) | Step |
|---|---|
| 16 Oct | Antivirus alert on a security note: the starting point |
| 26–27 Oct | First sweeps; 16 server-source repositories and the hook lures identified |
| 29 Oct | First lists compiled (7,332 PolinRider candidates among them); Shai-Hulud burst recorded |
| 30 Oct | Proof pass: every candidate re-read at a pinned commit under fixed rules; 555-sample independent re-check; rule tightened after the sample; evening corrections |
| 31 Oct | Wider read of lure and tool repositories; extended searches and an owner round; daily tracker started |
| 1 Nov | Tracker finds (up to the cut-off) pinned again and added; the 162 tracked repositories, 73 accounts and 12 issues re-read; set frozen |
Detection rules and indicator export
#The inclusion rules above are also available as detection rules, written for source files rather than binaries. They are experimental. On 2 November 2026 each was run against a sample of the proven files and about 105,700 benign files. Four of the first versions missed most of their proven files and were rewritten the same day; the rewritten rules were also run on proven files they were not written against. Every rule now matches all, or all but a few, of its proven files, with no benign hit. For the PolinRider, AUTH_API_KEY and Discord rules, the proof pass used similar markers, so these runs show that the rules find the proven set, not variants without those markers. Each rule card lists the runs and their limits. The Contagious Interview payload rules stay on the infrastructure page.
| Rule | Activity | Download |
|---|---|---|
| VS Code task that runs node on a disguised file when the folder opens | PolinRider | polinrider-vscode-task-autorun.yar |
| Obfuscated loader appended to a build configuration file | PolinRider | polinrider-build-config-appended-loader.yar |
| atob on an environment variable, evaluated with eval or Function | AUTH_API_KEY loader | auth-api-key-env-eval-loader.yar |
| Semgrep rule: code fetched from an environment-variable URL and evaluated (JavaScript/TypeScript) | AUTH_API_KEY loader | auth-api-key-env-fetch-eval.yml |
| Repository-shipped post-checkout hook that pipes a download into a shell | Contagious Interview | git-hook-post-checkout-pipe-to-shell.yar |
| Published Discord token stealer reading local storage and posting to a webhook | Discord token stealer | discord-token-stealer-leveldb.yar |
| Python file that decrypts an embedded Fernet stage and executes it | Python Fernet loader | python-fernet-exec-stage.yar |
| Indicators of each article, defanged | all | indicators.json and indicators.csv in that article's research files |
Each article's indicator files are built from its indicator blocks at build time, so they never contain anything the article does not show.
Matches .vscode/tasks.json files with the PolinRider trigger: a folderOpen task whose command runs node on a file with a font, image or made-up extension (.woff2, .ttf, .orc, .lkf and others). Proof additionally requires the target to be a non-empty JavaScript file.
- Status
- experimental
- Family
- PolinRider
- Platforms
- none recorded
- Engines tested
- YARA 4.5.0
- Known benign cases
- none recorded
- Testing
- Rewritten on 2 November: the first version matched none of 199 proven tasks, because it expected node and the target as separate JSON strings and knew only font extensions. The proven tasks run node inside one shell command string, and many target made-up extensions such as .orc. This version looks for node followed by a path with a font, image or made-up extension in any folderOpen task. It matched all 199 files it was written against, all 300 held-out files, and none of 88 benign tasks.json files. The proof pass chose the proven files with a similar check (a folderOpen task running node on a non-JavaScript file), so these runs show that the rule finds the proven set; they do not show that it finds variants without those markers.
Check results
| Date | Engine | Corpus | Result |
|---|---|---|---|
| 2026-11-02 | YARA 4.5.0 | Proven PolinRider .vscode/tasks.json files the rule was written against (199) | 199 of 199 matched |
| 2026-11-02 | YARA 4.5.0 | Proven tasks.json files not used to write the rule, random sample (300) | 300 of 300 matched |
| 2026-11-02 | YARA 4.5.0 | The other proven sets of this investigation (1,194 files) | 0 matches |
| 2026-11-02 | YARA 4.5.0 | Benign source code: shallow clones of next.js, microsoft/vscode and vscode-extension-samples (56,864 files, 88 of them tasks.json) | 0 hits |
| 2026-11-02 | YARA 4.5.0 | Benign files in the analysis VM: npm tools (19,474), Python standard library and packages (29,360), git sample hooks (14) | 0 hits |
rule CMPC_PolinRider_VSCode_Task_Autorun
{
meta:
author = "Cristian Chiriac / CMPC Research"
description = "PolinRider trigger: VS Code folderOpen task that runs node on a file with a font, image or made-up extension"
status = "experimental"
date = "2026-11-02"
scope = "Run on .vscode/tasks.json; confirm the target file is JavaScript before acting"
targets = "woff2 woff ttf otf eot orc lkf lfk llf glb fml svg png jpeg sol: every extension in the proven tasks"
strings:
$open = "folderOpen" ascii
$node = /\bnode(\.exe)?\s+(\\?["'])?(\.{0,2}[\/\\]{1,2})?[\w.\-\/\\\[\]()@]+\.(woff2?|ttf|otf|eot|orc|lkf|lfk|llf|glb|fml|sol|svg|png|jpe?g|gif|ico|bmp|webp)\b/ ascii
condition:
filesize < 200KB and $open and $node
}
Matches files that carry the PolinRider loader on one long line: the global['_V'] or global['!'] marker, or an _$_xxxx obfuscator name, followed by at least 300 more characters on the same line. In build configurations the line is usually pushed off-screen by spaces or tabs; the rule does not depend on that padding.
- Status
- experimental
- Family
- PolinRider
- Platforms
- none recorded
- Engines tested
- YARA 4.5.0
- Known benign cases
- none recorded
- Testing
- Rewritten on 2 November: the first version found 8 of 200, because it required files over 20 KB and a 400-character run of spaces, and it hit 13 benign files. This version drops both conditions and requires the marker to be followed by at least 300 characters on the same line. It matched all 200 files it was written against, all 297 held-out files, and no benign file, 1,685 of them configuration files. The proof pass chose the proven files with the same markers plus a long line or a long run of spaces, so these runs show that the rule finds the proven set; they do not show that it finds variants without those markers.
Check results
| Date | Engine | Corpus | Result |
|---|---|---|---|
| 2026-11-02 | YARA 4.5.0 | Proven PolinRider files carrying the appended loader, mostly build configurations (200; the files the rule was written against) | 200 of 200 matched |
| 2026-11-02 | YARA 4.5.0 | Proven files of the same set not used to write the rule, random sample (297; 3 no longer available) | 297 of 297 matched |
| 2026-11-02 | YARA 4.5.0 | The other proven sets of this investigation (1,196 files) | 0 matches |
| 2026-11-02 | YARA 4.5.0 | Benign source code: shallow clones of next.js, microsoft/vscode and vscode-extension-samples (56,864 files, 1,673 of them *.config.* files) | 0 hits |
| 2026-11-02 | YARA 4.5.0 | Benign files in the analysis VM: npm tools (19,474), Python standard library and packages (29,360), git sample hooks (14) | 0 hits |
rule CMPC_PolinRider_BuildConfig_Appended_Loader
{
meta:
author = "Cristian Chiriac / CMPC Research"
description = "PolinRider loader appended to a file: global['_V'] / global['!'] or an _$_xxxx name, then the rest of the loader on the same line"
status = "experimental"
date = "2026-11-02"
scope = "Run on *.config.(js|ts|mjs|cjs), postcss/tailwind/vite/next/eslint configurations and font-named files that read as text"
strings:
$g = /global\[\s*['"](_V|!)['"]\s*\][^\n\r]{300}/ ascii
$s = /_\$_[0-9a-f]{4}[^\n\r]{300}/ ascii
condition:
filesize < 5MB and any of them
}
Matches server or build files that decode AUTH_API_KEY (or a sibling variable) from the environment and evaluate the result. The decoded value in .env is a URL; the article lists the hosts seen.
- Status
- experimental
- Family
- AUTH_API_KEY loader
- Platforms
- none recorded
- Engines tested
- YARA 4.5.0
- Known benign cases
- none recorded
- Testing
- Matched every proven file in both samples (200 and 172), no other proven set and no benign file. The proof pass chose the proven files with the same strings (atob on process.env plus eval or Function), so these runs show that the rule finds the proven set; they do not show that it finds variants without those markers.
Check results
| Date | Engine | Corpus | Result |
|---|---|---|---|
| 2026-11-02 | YARA 4.5.0 | Proven AUTH_API_KEY loader files, random sample (200) | 200 of 200 matched |
| 2026-11-02 | YARA 4.5.0 | Proven AUTH_API_KEY loader files not in that sample (172) | 172 of 172 matched |
| 2026-11-02 | YARA 4.5.0 | The other proven sets of this investigation (1,321 files) | 0 matches |
| 2026-11-02 | YARA 4.5.0 | Benign source code: shallow clones of next.js, microsoft/vscode and vscode-extension-samples (56,864 files) | 0 hits |
| 2026-11-02 | YARA 4.5.0 | Benign files in the analysis VM: npm tools (19,474), Python standard library and packages (29,360), git sample hooks (14) | 0 hits |
rule CMPC_AuthApiKey_Env_Eval_Loader
{
meta:
author = "Cristian Chiriac / CMPC Research"
description = "AUTH_API_KEY loader: atob(process.env.<VAR>) passed to eval or Function"
status = "experimental"
date = "2026-11-01"
scope = "Run on *.ts, *.js, *.mjs, *.cjs in application and build-config paths"
strings:
$decode = /atob\(\s*process\.env\.[A-Z_]*API_KEY[A-Z_]*\s*\)/ ascii
$eval = /\beval\s*\(/ ascii
$fn = /\bnew\s+Function\s*\(|\bFunction\s*\(/ ascii
condition:
filesize < 512KB and $decode and ($eval or $fn)
}
Taint rule: follows the value of atob(process.env.<VAR>) through the request that uses it and the response it returns, and reports when that data reaches eval, new Function or Function. For CI pipelines that scan source before build.
- Status
- experimental
- Family
- AUTH_API_KEY loader
- Platforms
- none recorded
- Engines tested
- Semgrep 1.183.0
- Known benign cases
- none recorded
- Testing
- Rewritten on 2 November: the first version matched none of 200, because the decoded value is a URL: it is fetched, and eval runs the response, not the decoded value. This version is a taint rule that follows the value through the request and its response to eval, new Function or Function. It matched 199 of the 200 files it was written against (the miss has the loader commented out, so not flagging it is right) and 166 of 172 held-out files (2 misses are Markdown files, 3 did not parse fully, 1 parsed file with the usual structure was missed for a reason not identified). No benign hits, but only 17,474 benign JavaScript and TypeScript files were scanned: Semgrep's default ignore list skips node_modules. The proof pass chose the proven files with atob on process.env plus eval or Function, so these runs show that the rule finds the proven set; they do not show that it finds variants without those markers.
Check results
| Date | Engine | Corpus | Result |
|---|---|---|---|
| 2026-11-02 | Semgrep 1.183.0 | Proven AUTH_API_KEY loader files the rule was written against (200; 3 parsed only partially) | 199 of 200 matched; the miss has the loader commented out |
| 2026-11-02 | Semgrep 1.183.0 | Proven AUTH_API_KEY loader files not used to write the rule (172) | 166 of 172 matched: 2 are Markdown files Semgrep does not scan, 3 did not parse fully, 1 missed for a reason not identified |
| 2026-11-02 | Semgrep 1.183.0 | The other proven sets of this investigation (1,321 files; 560 of them JavaScript or TypeScript that Semgrep scanned) | 0 matches |
| 2026-11-02 | Semgrep 1.183.0 | Benign source code: shallow clones of next.js, microsoft/vscode and vscode-extension-samples (Semgrep scanned 17,088 JavaScript/TypeScript files; 8 timed out) | 0 hits |
| 2026-11-02 | Semgrep 1.183.0 | Benign files in the analysis VM (Semgrep scanned 386: its default ignore list skips node_modules; 3 timed out or failed to parse) | 0 hits |
rules:
- id: cmpc-auth-api-key-env-fetch-eval
message: Code fetched from a URL decoded out of an environment variable reaches eval or Function (AUTH_API_KEY loader pattern)
severity: ERROR
languages: [javascript, typescript]
metadata:
author: Cristian Chiriac / CMPC Research
status: experimental
date: 2026-11-02
mode: taint
pattern-sources:
- pattern: atob(process.env.$VAR)
pattern-sinks:
- pattern: eval(...)
- pattern: new Function(...)
- pattern: Function(...)
Matches .githooks/post-checkout and .github/githooks/post-checkout files that fetch a script with curl or wget and run it. Git does not run hooks from these paths by itself; the repositories set core.hooksPath or ship a .git directory in an archive.
- Status
- experimental
- Family
- Contagious Interview
- Platforms
- none recorded
- Engines tested
- YARA 4.5.0
- Known benign cases
- none recorded
- Testing
- Matched every available proven hook and no benign file, including git's own sample hooks. Six files is a small set. YARA warns that $bg may slow scanning.
Check results
| Date | Engine | Corpus | Result |
|---|---|---|---|
| 2026-11-02 | YARA 4.5.0 | Proven repository-shipped post-checkout hooks (6 of the 7 sampled; 1 no longer available) | 6 of 6 matched |
| 2026-11-02 | YARA 4.5.0 | The other proven sets of this investigation (917 files) | 0 matches |
| 2026-11-02 | YARA 4.5.0 | Benign source code: shallow clones of next.js, microsoft/vscode and vscode-extension-samples (56,864 files) | 0 hits (includes the clones' .git/hooks samples) |
| 2026-11-02 | YARA 4.5.0 | Benign files in the analysis VM: npm tools (19,474), Python standard library and packages (29,360), git sample hooks (14) | 0 hits |
rule CMPC_Git_Hook_PostCheckout_Pipe_To_Shell
{
meta:
author = "Cristian Chiriac / CMPC Research"
description = "Committed post-checkout hook downloading and executing a remote script"
status = "experimental"
date = "2026-11-01"
scope = "Run on files named post-checkout under .githooks/ or .github/githooks/"
strings:
$fetch = /\b(curl|wget)\b[^\n]{0,200}(https?:\/\/|\/settings\/)/ ascii
$pipe = /\|\s*(sh|bash|zsh)\b/ ascii
$flag = "flag=" ascii
$bg = /(\bnohup\b|&\s*$|setsid)/ ascii
condition:
filesize < 8KB and $fetch and $pipe and ($flag or $bg)
}
Matches source files that hold a complete Discord webhook URL and read the Local Storage leveldb folder of Discord or a browser, where Discord keeps its login token. These are published tools; the owner is not necessarily a victim or an operator.
- Status
- experimental
- Family
- Discord token stealer
- Platforms
- none recorded
- Engines tested
- YARA 4.5.0
- Known benign cases
- none recorded
- Testing
- Rewritten on 2 November: the first version found 2 of 196, because it required a literal token-shaped string; most of these tools hold the token pattern as regular-expression source instead. This version requires a complete webhook URL and a Local Storage or leveldb read. It matched all 196 files it was written against, the one remaining proven file, and no benign file. With one held-out file, this is close to a test on the development set only. The proof pass chose the proven files with the same strings (a webhook URL plus Local Storage or leveldb), so these runs show that the rule finds the proven set; they do not show that it finds variants without those markers.
Check results
| Date | Engine | Corpus | Result |
|---|---|---|---|
| 2026-11-02 | YARA 4.5.0 | Proven Discord token stealer files the rule was written against (196) | 196 of 196 matched |
| 2026-11-02 | YARA 4.5.0 | The one other proven Discord token stealer file | 1 of 1 matched |
| 2026-11-02 | YARA 4.5.0 | The other proven sets of this investigation (1,496 files) | 0 matches |
| 2026-11-02 | YARA 4.5.0 | Benign source code: shallow clones of next.js, microsoft/vscode and vscode-extension-samples (56,864 files) | 0 hits |
| 2026-11-02 | YARA 4.5.0 | Benign files in the analysis VM: npm tools (19,474), Python standard library and packages (29,360), git sample hooks (14) | 0 hits |
rule CMPC_Discord_Token_Stealer_LevelDB_Webhook
{
meta:
author = "Cristian Chiriac / CMPC Research"
description = "Discord token stealer: hard-coded webhook URL plus a read of Discord or browser Local Storage (leveldb)"
status = "experimental"
date = "2026-11-02"
scope = "Run on source files (py, js, ps1, cs, java, rs); expect hits in research corpora too"
strings:
$hook = /discord(app)?\.com\/api\/webhooks\/\d{15,}\/[\w\-]{30,}/ ascii
$ls1 = "Local Storage" ascii
$ls2 = "leveldb" ascii nocase
condition:
filesize < 5MB and $hook and any of ($ls*)
}
Matches Python sources that call exec on Fernet(key).decrypt(ciphertext) with the key in the same file. One proven repository; the same shape is common in sample collections, so check the repository is not a research corpus.
- Status
- experimental
- Family
- Python Fernet loader
- Platforms
- none recorded
- Engines tested
- YARA 4.5.0
- Known benign cases
- none recorded
- Testing
- Matched the only proven file and no benign file. With one proven file, recall cannot be estimated.
Check results
| Date | Engine | Corpus | Result |
|---|---|---|---|
| 2026-11-02 | YARA 4.5.0 | The one proven Python Fernet loader file | 1 of 1 matched |
| 2026-11-02 | YARA 4.5.0 | The other proven sets of this investigation (922 files) | 0 matches |
| 2026-11-02 | YARA 4.5.0 | Benign source code: shallow clones of next.js, microsoft/vscode and vscode-extension-samples (56,864 files) | 0 hits |
| 2026-11-02 | YARA 4.5.0 | Benign files in the analysis VM: npm tools (19,474), Python standard library and packages (29,360), git sample hooks (14) | 0 hits, including 29,360 Python library files |
rule CMPC_Python_Fernet_Exec_Stage
{
meta:
author = "Cristian Chiriac / CMPC Research"
description = "exec(Fernet(<literal key>).decrypt(b'<ciphertext>')) in a Python source file"
status = "experimental"
date = "2026-11-01"
scope = "Run on *.py; exclude test, fixture, sample and dataset paths"
strings:
$import = "from cryptography.fernet import Fernet" ascii
$exec = /exec\s*\(\s*Fernet\s*\(\s*b?['\"][A-Za-z0-9_\-=]{40,48}['\"]\s*\)\s*\.decrypt\s*\(\s*b?['\"][A-Za-z0-9_\-=]{200,}/ ascii
condition:
filesize < 4MB and $import and $exec
}
Evidence for the whole investigation
#One row per repository with repository id, owner id, pinned commit and commit permalink; for code-based activities also file path, git object id, size, rule and task-target checks; for Shai-Hulud the description marker. No file contents. Kept private because permalinks name victims' accounts.
Source: GitHub GraphQL and REST, read from an isolated virtual machine · Collected 30 October 2026
555 proven repositories in a stratified random sample with a recorded seed, re-read through the REST contents API at the pinned commit and compared by object id and, for task targets, by size and file type.
Source: GitHub REST · Collected 30 October 2026
Limitations of the whole investigation
#- Totals are a floor. Code search covers default branches only. Seven accounts holding 7,500–89,800 repositories each, where every infected repository is a fork, were recorded but not scanned.
- Proof covers code, not execution. Whether a given loader ran on someone's machine is not known.
- Accounts are counted by owner id. One person can hold several accounts.
- Attribution is not mine. Where an activity is linked to North Korea, that link comes from public reporting and is labelled as such.
- The lure, trading-bot and fake-alert activities are not proven malware. Their chapters say what is established.
Claims, evidence and limits
| Claim | Assessment | Evidence | Limit |
|---|---|---|---|
| 43,448 repositories proven at a pinned commit | Confidence: Confirmed malicious | E-ALL-PROOF | Not stated in the finding |
| An independent re-check agreed on every sample | Confidence: Confirmed malicious | E-ALL-SPOT | Not stated in the finding |
Terms used on this page
- pinned commit
- A specific commit, identified by its hash, at which a repository was read. Every counted repository was proven at the default-branch commit recorded in the proof pass, so a later change to the repository does not change what was proven.
- git object id
- The hash Git assigns to a file's exact contents. Recording it identifies the precise file that matched, independently of its path or of later edits.
- default branch
- The branch a repository shows by default. Counts are taken on default branches only; infected non-default branches were recorded separately and are not counted.
- proof pass
- The 30–31 October 2026 re-read of every candidate at a pinned commit under fixed inclusion rules, followed by an independent re-read of a random sample of 555 repositories through a different API.
- defanged
- Written so it cannot be clicked or resolved as written, for example example[.]com or hxxps://. Every network indicator on this site is defanged.
All terms: glossary.
Corrections & discussion
Found an error, or have evidence that supports or contradicts a finding? Send a correction. Name the section and what you observed.