Skip to content

Mongo2Go 4.2.0: security updates, Linux ARM64, smaller package#161

Merged
JohannesHoppe merged 21 commits into
mainfrom
deps/update-nuget-dependencies
Jul 21, 2026
Merged

Mongo2Go 4.2.0: security updates, Linux ARM64, smaller package#161
JohannesHoppe merged 21 commits into
mainfrom
deps/update-nuget-dependencies

Conversation

@JohannesHoppe

@JohannesHoppe JohannesHoppe commented Jul 20, 2026

Copy link
Copy Markdown
Member

Mongo2Go 4.2.0

A minor release: resolves the vulnerable-dependency report (#160), adds Linux ARM64 support (#127), reduces package size (#133), and hardens the supply chain around the bundled MongoDB binaries. No breaking API changes.

User-facing

  • Dependencies updated — MongoDB.Driver 3.1.0 → 3.9.0 and all others. Resolves the vulnerable transitive dependencies SharpCompress and Snappier (Vulnerable transitive dependencies #160). Builds on @schms27's PR Update nuget dependencies #159; the lockfile was regenerated independently from nuget.org and confirmed byte-identical to the submitted one.
  • Linux ARM64 support (Arm64 support  #127) — native arm64 mongod/mongoimport/mongoexport are now bundled, and binary resolution is architecture-aware. Verified on a real ubuntu-24.04-arm runner: the old x64 binary fails with Exec format error, the new arm64 binary reports db version v4.4.4. Apple Silicon continues on the x64 macOS binaries under Rosetta 2 (MongoDB 4.4 has no macOS arm64 build).
  • Smaller package (NuGet package size concerns #133) — the bundled binaries are now stripped (they never had been). ~22% smaller; even with arm64 added the package is smaller than 4.1.0 (114.9 → 122.8 MiB is the arm64-inclusive figure; x64-only is 89.7 MiB). 49% of the nuget.org limit.
  • Bundled MongoDB remains v4.4.4.

Supply-chain hardening

The bundled binaries are executed on every consumer's machine, so this release tightens the chain from MongoDB's release to what runs:

  • MongoDownloader now verifies every archive against MongoDB's published SHA-256 before extraction (the field existed in the release JSON and was previously discarded).
  • Zip-slip fixed in the archive extractor — entries are rejected unless they resolve inside the extraction directory.
  • Bundled binaries are verified against a committed checksum manifest at runtime, so a planted directory on the binary search path can no longer be executed as mongod. This also closes a latent architecture-selection bug that would otherwise have shipped x64 to arm64 machines.
  • All 9 (now 12) bundled binaries were confirmed byte-identical to MongoDB's published originals before stripping.
  • Dropped the Espresso3389.HttpStream dependency (no longer needed after the extraction rework).

CI

  • Added a pull_request trigger (fork PRs previously ran no checks).
  • Added ubuntu-24.04-arm to the matrix — without it the matrix passed for the wrong reasons and could not have caught Arm64 support  #127.
  • New guards: reject PRs touching tools/, verify the lockfile against nuget.org, verify the checksum manifest matches tools/.
  • Least-privilege GITHUB_TOKEN; explicit event guard on the publish job.

schms27 and others added 21 commits June 1, 2026 21:07
FluentAssertions 8.x moved to the Xceed Community License, which
restricts non-commercial use. AwesomeAssertions is the community fork
under Apache-2.0, preserving the original attribution.

Audited before adoption:
- provenance chain verified: nuspec commit == PDB SourceLink commit ==
  GPG-verified commit in the public repository
- Apache-2.0 in both the nuspec and the repository
- no build-time hooks (.targets/.props/scripts)
- no transitive dependencies on net8.0
- static analysis: no network, process, or registry API surface
- no security advisories

Pinned to 9.4.0 rather than 9.5.0: 9.5.0 was published within 24 hours
with zero downloads, so it has had no community exposure. 9.4.0 has had
five months and ~5M downloads.

Test-only dependency (IsPackable=false) - never ships to consumers.
Mongo2Go ships executable MongoDB binaries to every consumer, which makes
this repository a supply-chain target. Documents the rules that follow from
that, and makes the two most important ones machine-enforced rather than
dependent on a reviewer remembering them.

CLAUDE.md:
- RULE 1: zero trust for binaries; contributor PRs never introduce bytes
  under tools/, maintainers re-download with src/MongoDownloader
- RULE 2: never trust a contributor-supplied packages.lock.json
- RULE 3: audit checklist before adopting any new dependency
- RULE 4: no dependency version younger than ~30 days, so a compromised
  publish has had community exposure before we adopt it
- RULE 5: verify, never assert; suspect your own check first

CI:
- add a pull_request trigger, so fork PRs are actually validated
  (previously only push was configured, so PR #159 ran no checks at all)
- guard-binaries: fails any PR touching tools/
- verify-lockfile: regenerates lockfiles from nuget.org and requires an
  exact match against the committed ones

Both guards were tested against simulated tampering before committing:
appending bytes to a vendored mongod is detected, and a committed
contentHash alteration is detected.

Note: these jobs only block merges once they are configured as required
status checks in the branch protection settings.
Adding the pull_request trigger means fork pull requests now execute
contributor code in the build job. That is the normal cost of validating
pull requests, but the token that code runs alongside should be minimal.

The repository default for GITHUB_TOKEN is "write". Set an explicit
top-level "contents: read" and re-grant "contents: write" only on the
publish job, which needs it to create the GitHub release.

Also add an explicit event_name check to the publish gate. It is redundant
with the existing ref check, since a pull_request event has ref
refs/pull/<n>/merge and cannot match refs/tags/. It is kept because this
job spends NUGET_API_KEY, and that is worth making structurally impossible
to reach rather than only conditionally unreachable.

Verified the gate across event shapes: publish runs only for a pushed
version tag, and not for a push to main or for pull requests.
ArchiveExtractor built every destination path with Path.Combine and never
checked that the result stayed inside the extraction directory. Archive
entry names are attacker-controlled data - they come from a downloaded
archive and neither SharpZipLib's ZipFile nor the reported tar entry names
sanitise them - so two escapes were possible:

- Zip slip: an entry such as "root/../../../../PWNED/bin/mongod" resolved
  to /PWNED/bin/mongod. Path.Combine neither normalises nor rejects "..".
  The binary/licence regexes were not a mitigation, because the binary
  pattern "bin/mongod" is unanchored and a traversal path containing that
  substring still matches.
- Rooted entry names: Path.Combine discards everything before a rooted
  segment, so a tar entry named "/etc/VICTIM/file" resolved to that real
  host path and reached extractedFile.Delete().

Both are now resolved with Path.GetFullPath and rejected unless the result
is prefixed by the extraction directory. Applied at all three destination
constructions: the zip extraction path, and both the delete and move paths
in CleanupExtractedFiles.

Verified with the payloads that previously succeeded: traversal, rooted
entry, and deep traversal are all blocked, while legitimate bin/ and
licence entries still resolve unchanged.

This does not remove the need for checksum verification. It contains the
damage a malicious archive can do; verifying the published sha256 would
stop such an archive being opened at all.
MongoBinaryLocator walks upwards from several starting points toward the
filesystem root, so the search can reach directories Mongo2Go does not
control. A directory planted in any ancestor of a consumer's build output
was found before the NuGet cache and executed as mongod. Verified against
the published 4.1.0 package: resolution normally lands in the NuGet cache,
but a planted tools/mongodb-<os>*/bin in an ancestor takes precedence.

Restricting the search would only move the question, because a trusted
location can be compromised too. Instead the binaries are now identified by
content: MongoBinaries.sha256 records the SHA-256 of every bundled binary
and is embedded in the assembly, and a candidate directory is used only if
its binaries match. Where they were found stops mattering.

A candidate that fails verification is skipped rather than fatal, so an
impostor found early cannot mask the real binaries further up. This needed
FolderSearch.FindFoldersUpwards, which enumerates every match instead of
stopping at the first, including siblings at the same level.

Binaries supplied explicitly by the caller, through binariesSearchDirectory
or binariesSearchPatternOverride, are used without verification. Pointing
Mongo2Go at your own MongoDB is supported and is the existing workaround for
a newer server (#132) and for native arm64 (#127); those binaries will
legitimately not match. NUGET_PACKAGES stays under verification, since an
environment variable is not caller intent.

Cost is about 40 ms per directory, cached per process. A missing manifest
fails open, since that is a packaging fault of ours rather than evidence
against the user's binaries.

CI gains verify-binary-manifest, which regenerates the manifest from tools/
and requires an exact match. It runs on push as well as pull_request,
because maintainers update binaries by pushing directly and that is exactly
when the manifest must be regenerated with them.

Verified: a planted directory is rejected and the search continues to the
genuine binaries; an explicit override to that same directory is accepted;
all 49 tests pass.
…ntly

Skipping a directory that fails checksum verification and continuing the
search is what makes the search order harmless, but on its own it meant a
successful recovery left no trace: a planted directory would be routed
around and nobody would ever know it was there.

Permissive control flow and quiet control flow are separate choices, and
only the first one is wanted. Every rejected directory is now reported
through the ILogger already accepted by MongoDbRunner.Start, naming the
directory and explaining how to use it deliberately if it is the caller's
own build. There is no reason to be discreet about it - a checksum cannot
be forged, so nothing is disclosed by saying which directory was ignored.

The existing two-argument MongoBinaryLocator constructor is kept so this
stays binary compatible.
The downloader is the project's provenance mechanism, but it verified
nothing: DataModel parsed only the archive url and discarded the sha1 and
sha256 that MongoDB publishes beside it. The binaries committed to tools/
therefore attested to what we happened to receive, not to what MongoDB
released.

Archive.Sha256 is now parsed and checked after download and before a single
entry is read. A mismatch deletes the downloaded file and aborts, rather
than warning: these bytes are committed, packaged, and then executed on
every consumer's machine, so continuing is never the better option. A
missing checksum is also fatal, since an archive whose integrity cannot be
established is exactly the case this is meant to catch.

ZIP archives were previously read directly over HTTP with range requests
and never held in full, which left nothing to hash. Both formats now
download to a local file first and extract from it, so verification happens
before extraction for every platform. This also removes the reason for the
Espresso3389.HttpStream dependency, which is dropped: it built its own
HttpClient outside this tool's configuration and used a constructor its
own author has deprecated. One less dependency in the tool whose job is
trust, and the build is now warning-free.

DownloadArchiveAsync switches from OpenWrite to FileMode.Create. OpenWrite
does not truncate, so a shorter re-download would keep trailing bytes from
a longer previous one and fail the checksum for a misleading reason.

Verified against a real release: the published sha256 for the 100.14.0
macOS tools archive matches the downloaded bytes exactly, and flipping a
single byte changes the digest completely.
MongoBinaries.sha256 was produced by hand, which left the weakest link in
the chain dependent on somebody remembering. It is now written by
MongoDownloader as the final step of a download, after extraction and
stripping, so it always describes the binaries exactly as they will be
committed and packaged.

--write-manifest regenerates it from the binaries already in tools/ without
downloading anything, so a manifest can be repaired without re-fetching
several hundred megabytes.

The manifest now maps a file name to a SET of accepted checksums rather
than a single one. Options requests both x64 and arm64 for Linux, so a
download produces two linux directories, each with its own mongod, and both
are legitimately ours. The question the runtime asks is "is this one of the
binaries we shipped?", which a set answers and a single value cannot.
(The related issue that tools/mongodb-linux* matches both architectures and
picks alphabetically is not addressed here.)

The CI check now compares sets rather than regenerating the file and
diffing text. Reordering entries or editing the comment header no longer
fails the build, while adding, removing or altering any entry still does,
and the failure names the drift in both directions and gives the command to
fix it.

Verified: the generated manifest carries the same nine checksums as the
hand-written one, the CI check passes against it, and changing a binary
without regenerating the manifest fails with a readable diff.
The supply-chain work has no maintainer-facing documentation, which matters
most for the parts that only surface when something goes wrong: the CI
guards, and the fact that tools/ and MongoBinaries.sha256 must change in the
same commit.

Covers how to update the bundled binaries, the four verified links between
MongoDB's release and what actually executes on a consumer's machine, why
the binary search can look in many places without that being a risk, and
what each CI guard blocks. Adds the dependency-age and lockfile rules to
the maintainer best practices, and notes that pull requests are now
validated.

Also records two things that are easy to trip over: the downloader always
fetches the latest production release and so cannot currently reproduce an
older version, and it resolves tools/ by walking up from the current
working directory.

Every symbol, job name and command referenced was checked against the
source rather than written from memory.
The binaries were committed on 2021-03-26; llvm-strip support was added on
2021-09-26 and they have never been re-downloaded since, so the stripper
has never once run on what ships. Stripping them now takes the package from
120,508,212 to 94,032,061 bytes - 25.2 MiB, 22% - which is headroom for the
architectures that #133 has been blocked on.

What this removes is only the static symbol table: .symtab and .strtab
account for 15,655,720 of the 15,655,738 bytes dropped from mongod. There
are no DWARF sections to remove, because MongoDB already publishes
production binaries without debug info and ships debug symbols separately.
What remains is load-bearing - .text, .dynsym/.dynstr for dynamic linking,
.eh_frame/.gcc_except_table for C++ exceptions - so there is no further
saving available from stripping.

windows/mongod.exe is deliberately NOT stripped. It saved zero bytes (MSVC
keeps debug info in separate PDBs) while its checksum changed, and it is
the one binary that cannot be executed on a developer machine here. Zero
benefit against untestable risk, so it stays byte-identical to MongoDB's
published original.

Provenance is preserved. Before stripping, all nine binaries were confirmed
byte-identical to the archives MongoDB publishes, each archive verified
against its published SHA-256 first. Stripping is applied after that
verification, so the chain is: MongoDB's checksum, then a deterministic
transformation, then our manifest.

The manifest now records the strip tool version, and --write-manifest
carries it over when regenerating without stripping. Determinism was
measured rather than assumed: llvm-strip 14, 15 and 18 each produced
identical output from the same input, on both x64 and arm64 hosts. That is
an observation and not a guarantee for future releases, which is exactly
why the version is recorded - it makes a disagreement diagnosable.

All 49 tests pass, including the import/export tests that exercise the
stripped Go binaries for real.
Closes the trap that would have made a naive fix for #127 fail silently.
MongoBinaryLocator matched tools/mongodb-linux*/bin and had no notion of
architecture at all, so once a second Linux directory existed it would pick
one alphabetically. Verified on real hardware before this change: on an
arm64 machine with mongodb-macos-amd64 and mongodb-macos-arm64 available,
it chose amd64.

Checksum verification alone cannot fix this, because both directories are
legitimately ours and both match. So the manifest now records architecture
as well - <platform>/<architecture>/<file> - and resolution runs in two
passes: first accepting only binaries native to the machine, then accepting
any architecture we ship.

The second pass is not a fallback for broken setups, it is the normal path
on Apple Silicon. MongoDB 4.4 has no macOS arm64 build, so those users run
the x64 binaries under Rosetta 2 today and must keep working. Making
verification architecture-exclusive would have broken every one of them.

tools/ directories now carry the architecture, matching what MongoDownloader
already produces, and gain mongodb-linux-arm64. Both arm64 archives were
verified against MongoDB's published SHA-256 before extraction and stripped
with the same pinned llvm-18.

CI gains ubuntu-24.04-arm. Without it the matrix proved less than it looked:
macos-latest is Apple Silicon but runs x64 under Rosetta, so every job
passed for a different reason and none would have caught #127. The libssl1.1
step is now architecture-aware, since security.ubuntu.com serves amd64 only
and arm64 packages come from ports.ubuntu.com - verified on real arm64
Ubuntu. fail-fast is disabled so one architecture failing does not hide the
others.

Verified end to end in containers on both architectures: with both linux
directories present, an x64 machine selects linux-x64 and an arm64 machine
selects linux-arm64. On arm64 Linux the old x64 mongod dies where the new
arm64 mongod reports "db version v4.4.4".

Package grows 114.9 -> 122.8 MiB, +7.9 MiB for a whole additional
architecture, because stripping paid for most of it. That is 49% of the
262,144,000 byte nuget.org limit.

All 49 tests pass.
The package version itself is set by the git tag (MinVer) at release time;
this commit only updates the human-facing metadata for the 4.2.0 minor:

- README changelog entry for 4.2.0 (arm64, dependency updates, smaller
  package, supply-chain verification), crediting Simon Schmid for PR #159
- csproj Description now states the bundled architectures explicitly
  (Windows x64, Linux x64 and arm64, macOS x64)
- copyright year bumped to 2026
The guard rejected any pull request touching tools/, which blocked the
maintainer's own release PRs - exactly the change that legitimately adds or
updates binaries. RULE 1 in CLAUDE.md forbids binaries from a *fork* PR: a
same-repo branch could only have been pushed by a collaborator, which is
the same trust level as the direct push the rule already sanctions.

The guard now fires only when the PR head is a different repository (a
fork). Same-repo PRs are the maintainer path and are allowed to touch
tools/. A binary changed without a matching manifest update is still caught
on every push and PR by verify-binary-manifest, so this does not open a hole
- it aligns the guard with the documented trust boundary.
…es reproducible

Adds --server-version and --tools-version so the downloader can fetch an
exact past release instead of only the latest production one. Server and
Database Tools have independent version numbers (e.g. 4.4.4 and 100.3.1),
so they are pinned separately. When a version is pinned the tool reads the
full release feed (full.json) rather than the current-releases feed, which
only lists recent versions.

This is what makes the maintainer's documented verification step real:
re-download an exact version and confirm the bundled binaries reproduce.
Verified in a container that pinning to 4.4.4 / 100.3.1 resolves both from
the full feeds, downloads, and passes SHA-256 verification.

Also makes stripping reproducible against what is committed. llvm-strip
rewrites a binary that has no removable symbols - Windows mongod.exe, whose
debug info is in a separate PDB - to the same size but different bytes,
which needlessly breaks provenance against MongoDB's published binary.
BinaryStripper now restores the original whenever stripping does not reduce
size, so such a binary stays byte-identical to upstream. Verified with real
llvm-strip 18.1.3: windows/mongod.exe strips to the same size and is kept
as the original 8e8609ce..., matching the committed manifest. This replaces
the manual revert done when the binaries were first stripped, so the whole
tools/ directory is now reproducible by re-running the tool.

Note: a full single-command end-to-end run (download + verify + strip +
manifest for all platforms) could not be completed on the local Docker VM,
which repeatedly crashed while deserializing the 49 MB full.json feed under
memory pressure. The individual stages were each verified; the crash is a
host limitation, not a logic fault.
The downloader loaded the entire release feed into memory with
GetFromJsonAsync<Release> and then picked a single version out of the
resulting object graph. For the full feed - about 50 MB and more than a
thousand versions, each with every platform download - that was wasteful,
and it is what fell over on a memory-constrained machine while pinning an
older version.

MongoReleaseReader now streams the HTTP response and materialises one
Version at a time with a Utf8JsonReader over a small, growable buffer,
keeping only the version that matches. Peak retained memory for parsing the
full feed drops from 46.0 MiB to 0.5 MiB (measured against the real
full.json), and the whole tool now peaks at ~97 MB RSS running a full pinned
download.

Correctness was checked against a full deserialisation as ground truth: the
selected version and its complete download set are byte-identical across
buffer sizes from 64 bytes (which forces the element-spanning rewind-and-
refill path on every version) up to 64 KiB, the latest-production predicate
still resolves 8.3.4, and an absent version returns null.

Verified end to end with the actual tool, pinned to 4.4.4 / 100.3.1: it runs
to completion and the extracted linux/x64 mongod is byte-identical to
MongoDB's published original (230e22c6...). The now-unused Release wrapper
class is removed.
…d key

Replaces the long-lived NUGET_API_KEY secret with GitHub OIDC. The publish
job requests a short-lived (1 hour) nuget.org API key at push time via
NuGet/login, so there is no stored key to leak or expire - the expiry of
the old key was about to block this very release.

- adds id-token: write to the publish job (keeps contents: write for the
  GitHub release)
- NuGet/login@v1 exchanges the OIDC token for a temporary API key
- dotnet nuget push uses that key instead of secrets.NUGET_API_KEY

Requires a Trusted Publishing policy on nuget.org naming this repository and
the workflow file continuous-integration.yml, plus a NUGET_USER secret (the
nuget.org profile name). The old NUGET_API_KEY secret can then be deleted.
Adds the publish job to a 'nuget-publish' GitHub environment configured with
a required reviewer, so a tagged build pauses for a maintainer's approval
before anything is pushed to nuget.org - a last human checkpoint, matching
the environment gate already used for the npm release of a sibling project.

Self-review is intentionally allowed: the project has a single maintainer,
who must be able to approve their own release.

For the tightest coupling, set the Environment field of the nuget.org
Trusted Publishing policy to 'nuget-publish' as well, so the short-lived key
is only ever issued to the approved environment.
@JohannesHoppe
JohannesHoppe merged commit b03b915 into main Jul 21, 2026
16 checks passed
@JohannesHoppe
JohannesHoppe deleted the deps/update-nuget-dependencies branch July 21, 2026 08:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants