GitLab

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.

What the record holds, in GitLab's own numbers

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.

What gitlab.com's API tells an anonymous caller — and what it withholds

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.

What the roster reads

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.

Measured on the 100 most-starred projects — and GitHub's, as the control

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 foundGitLab top-100GitHub top-100 (control)
A licence read (G6) · none declared90 · 1090 · 10
Latest release over a year old (G7) · no activity in the window30 · 169 · 6
Executables committed to source (G15)2620
A binary release asset (G9)1027
Submodule definitions (G12)166
A wrapper JAR · CMake at configure time (G14)13 · 97 · 2
A devcontainer · a Workspaces devfile (G10)1 · 316 · —
Editor config that runs on open (G13)414
Agent config files (G18)1848
Hidden Unicode · MCP shell launch · credential redirect (G19G21)0 · 0 · 00 · 0 · 3
A .git directory inside the tree (G11, CRITICAL)20
Single contributor · concentration (G5)5 · 10 · 1
Owner account age not checked (G1: user namespaces, no token)290
Fork status not checked (G4) · Scorecard not checked (G8)100 · 1000 · 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.

What the roster would see on a loader project — and would not

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.

What this doesn't mean

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.