A threat model, not a campaign · the 22 checks measured 19 September 2026 on the 100 most-starred gitlab.com projects, with GitHub's 100 as the control
What this is. No gitlab.com campaign of the FakeGit shape — fake maintainers, lookalike names, trojanised release assets at scale — exists in the 2024–2026 primary record. What the record holds is one pattern, in GitLab's own words: North Korean recruiter-lure projects whose loader fetches its payload from somewhere else, 131 accounts banned in 2025. So this page is not a list of campaigns with names and hashes, as the VS Code and Hugging Face pages are. It is four things: what GitLab itself has published, what gitlab.com's API tells an anonymous caller and what it withholds, how RepoGates runs the same 22 checks it runs on GitHub against a GitLab project, and what those checks found on the 100 most-starred projects — beside the same run on GitHub's 100, which is the control that makes the numbers mean anything. GitLab was added on customer demand, with that finding on the record; there is no catch rate to publish because there is no exemplar set to publish it against.
GitLab's threat-intelligence post of 19 February 2026 (Oliver Smith):
"We banned 131 accounts associated with North Korean nation-state
malware distribution in 2025" — "an average of 11 accounts per month".
The projects were the "Contagious Interview" lure: a candidate is asked
to clone and run a coding assignment. The loaders carried
BeaverTail or OtterCookie "in more than 95% of cases"; the accounts
signed up with Gmail "in almost 90% of cases", behind consumer VPNs.
The fact that shapes a scanner: "In more than 80% of instances, threat
actors did not store malware payloads on GitLab.com, instead storing a
concealed loader intended to source and execute remote content" — the
payload sat on "at least six legitimate services", most commonly
Vercel. The loader tradecraft, in GitLab's words: "A base64 encoded
next-stage URL, header key, and header value, all masquerading as
benign variables in a .env file"; "A global invocation of the trigger
function in a file executed as soon as the project is run"; a
Function.constructor call; the pieces "spread across up to
four files".
Two outside numbers. Cofense (8 April 2026), on phishing campaigns abusing code hosts: "95% of these malicious campaigns abuse GitHub compared to 5% abusing GitLab"; of the GitLab ones, "64% … deliver malware", and 2025 was the most abused year. Microsoft (11 March 2026), on the same North Korean campaign: victims "are instructed to clone and execute an NPM package hosted on popular code hosting platforms such as GitHub, GitLab, or Bitbucket", and a repository opened in VS Code with trust granted "automatically executes the repository's task configuration file" — the editor-config surface (G13) is the same on every host.
What is not in the record: any typosquat, fake-maintainer or trojanised-release-asset campaign specific to gitlab.com. A 2024 article showed that a comment upload on gitlab.com yields a permanent file link under a legitimate project's path; it was a proof of concept, not an observed abuse, and the link shape changed in GitLab 17.1. Nothing on this page is a GitHub incident relabelled.
The project record (GET /api/v4/projects/{path}) answers
an anonymous caller with what the provenance checks need: creation
date, stars, forks, last_activity_at, visibility, the
default branch, the namespace and whether it is a group or a user,
and — with ?license=true — a licence, which GitLab does
not always resolve to an SPDX id. Measured on 18 September 2026, the
unauthenticated record is a reduced one: it omits
forked_from_project, archived,
empty_repo and open_issues_count, on the
project and on every entry of its forks list. A group's
created_at is public; a user's record answers 403 without
a token, and the users list omits the date. Contributors come keyed by
git name and e-mail, unsorted, with no account id; the commits API
returns no totals. The whole file tree comes in one call —
repository/tree?recursive=true&per_page=10000 returned
inkscape's 7,182 entries in 0.36 s — an undocumented ceiling that held
to 100,000 entries. OpenSSF Scorecard's project list is GitHub-only and
its API answers 404 for a gitlab.com path.
Two more facts a gate has to design around. Anonymous traffic is 500
requests a minute per address today, with per-endpoint caps under it;
GitLab's proposed per-plan limits cut anonymous traffic to 60 an
hour, so a personal access token (2,000 a minute, and the fields
above) is a design requirement, not a nicety. And a release asset on
gitlab.com — /-/releases/<tag>/downloads/<file> —
is a redirect to a free-text URL the publisher wrote: commonly the
project's own package registry, allowed to be any host at all. The
Download button's archives (/-/archive/<ref>/…) and
raw files (/-/raw/…) are served in place.
RepoGates does not port the 22 to GitLab; it runs them. The adapter
builds, from gitlab.com's API, the same snapshot the GitHub checks
read — last_activity_at in the place of the push date,
star_count as stars, the licence key as an SPDX id, the
contributors sorted by commit count with their e-mails dropped, release
links as assets with the auto-generated source archives excluded —
and the check code runs unchanged. What the API withholds becomes a
listed skip, never an assumption: fork status
(G4) is not checked
on this tier; a user namespace's account age
(G1) is not checked
without a token, while a group's is; Scorecard
(G8) never runs. One
thing is GitLab's own: a Workspaces devfile
(.devfile.yaml, .devfile/*.yaml) lands
under G10,
because GitLab Workspaces run its postStart commands the
moment a workspace is created from the project — "Create a workspace
only from trusted projects", in GitLab's documentation. A
.gitlab-ci.yml is deliberately not a finding: a pipeline
runs on push and merge request, never on clone or open. The key is
gl/{namespace…}/{project} — the project's whole path,
because groups nest twenty deep — through the API, the MCP server
(platform="gl") and the preflight for
git clone, glab repo clone, pip
install git+…, go get, npm's gitlab:
shorthand and any gitlab.com URL.
Run on 19 September 2026, locally through the same code as
/v1/gl/score, anonymously: the 100 most-starred
gitlab.com projects (7,175 stars down to 350; 71 group-owned, 29
user-owned; youngest 627 days, median 3,025). 14 PASS, 84 REVIEW,
2 BLOCK; 0 errors; median 1.2 s, p90 2.2 s, the slowest 4.2 s for
the 155,411-entry fdroid/fdroiddata. Score median 90; 82
green, 16 amber, 2 red. Twelve of the 100 are flagged partial:
the file list is read in one 10,000-entry page, and twelve projects
(fdroiddata, gitlab-org/gitlab at 136,997, gitlab-foss,
tezos, kicad, mayan-edms, wget2, veloren, recalbox, qemu, baserow,
www-gitlab-com) have more, so their execution-surface checks ran on
the first page only. A partial scan keeps its verdict — four of the
fourteen PASS are partial — but is cached briefly, retried, and the
preflight asks rather than allows; it is never a clean PASS. The first
run of the day cached those twelve as clean, which is how the rule
was found and fixed before this page was written. The same engine, the same
day, on GitHub's 100 most-starred repositories: 0 PASS, 96 REVIEW,
4 BLOCK; one partial scan (the Linux kernel, at the deadline);
score median 86; 63 green, 31 amber, 6 red. The REVIEW
rate belongs to the roster, not to the host: on the largest, oldest,
busiest projects the 22 flag execution surface that is ordinary there,
and GitLab's hundred is the milder of the two.
| What the roster found | GitLab top-100 | GitHub top-100 (control) |
|---|---|---|
| A licence read (G6) · none declared | 90 · 10 | 90 · 10 |
| Latest release over a year old (G7) · no activity in the window | 30 · 16 | 9 · 6 |
| Executables committed to source (G15) | 26 | 20 |
| A binary release asset (G9) | 10 | 27 |
| Submodule definitions (G12) | 16 | 6 |
| A wrapper JAR · CMake at configure time (G14) | 13 · 9 | 7 · 2 |
| A devcontainer · a Workspaces devfile (G10) | 1 · 3 | 16 · — |
| Editor config that runs on open (G13) | 4 | 14 |
| Agent config files (G18) | 18 | 48 |
| Hidden Unicode · MCP shell launch · credential redirect (G19–G21) | 0 · 0 · 0 | 0 · 0 · 3 |
A .git directory inside the tree (G11, CRITICAL) | 2 | 0 |
| Single contributor · concentration (G5) | 5 · 1 | 0 · 1 |
| Owner account age not checked (G1: user namespaces, no token) | 29 | 0 |
| Fork status not checked (G4) · Scorecard not checked (G8) | 100 · 100 | 0 · 51 |
The two GitLab BLOCKs are GitLab's own gitlab-org/cli and
gitlab-org/gitaly, each carrying a committed
.git directory in test fixtures — the nested-repository
class the GitHub roster rates CRITICAL because a recursive clone can
run its hooks. The four GitHub BLOCKs are three credential-redirect
patterns inside AGENTS.md files and one owner account
under the critical age. On GitLab, a REVIEW had a single cause in 34
of the 84: an execution-surface file in 7, no activity in the window
in 7, a last release over a year old in 7, an agent config file in 6,
committed binaries in 4, and one each for a contributor concentration,
a missing licence and a binary release asset. Every G1 skip is a user namespace — the 29 of 100 — and that is the
place a token changes the reading. One project answered no file tree
at all, so its tree checks are listed as not checked rather than
passed.
Take the shape GitLab describes: an account registered with a
throwaway address a few weeks ago, one contributor, a project a few
days old with a handful of stars, a coding task the candidate is told
to clone and run, a loader in a file that executes when the project
runs, its next-stage URL hidden in .env-style variables.
The provenance half of the roster is built for exactly that shape:
repository age (G2) fires
HIGH under 30 days; a single contributor
(G5) and no
licence (G6) fire on almost every such project; the account's age
(G1) fires CRITICAL under 30 days — when it can be read. Here
is the honest edge: GitLab's own numbers put these projects under
personal namespaces, and a user namespace's creation date is exactly
the field gitlab.com withholds from an anonymous caller. Without a
token the strongest single signal of that shape is listed as not
checked; with a token it is read. That is why the token is wired in
from the first day. What no check on this tier sees: the loader
itself. The files are not read for a fetch-and-execute pattern beyond
the agent-config and devcontainer files the roster names; a
.env of base64 variables and a function invoked at
startup are the deep scan's job, and the deep scan does not read
GitLab projects yet. A PASS on a fresh, single-author project is not
a claim about its code.
GitLab's Security Dashboard and Vulnerability Report are Ultimate-tier
features fed by a project's own pipeline and shown to its owner; they
tell a person about to clone someone else's public project nothing,
and this page does not pretend to replace them. RepoGates runs the
same 22 checks on a gitlab.com project that it runs on a GitHub
repository, from gitlab.com's public API, under the key
gl/{namespace…}/{project}, on request — through the API,
the MCP server (platform="gl") and the preflight for a
git clone, glab repo clone or any gitlab.com
URL. What that API withholds from an anonymous caller — fork status,
a user namespace's account age, and OpenSSF Scorecard, which does not
cover gitlab.com — is listed as not checked, never assumed. A
self-managed GitLab is another hostname and is not assessed. The board
and the download gate on gitlab.com pages are not built yet; they come
as their own store release. The repository's code is not read on this
tier: a PASS means the provenance and the declared execution surface
are clean, never that the code was read. Every one of the 22 has its
own page under the docs, stating what it reads on
GitLab and what it does not.