Skip to content

Update SharpCompress to 0.49.1 and re-arm its vulnerability alerting (SF-1864) - #2087

Open
NickJosevski wants to merge 1 commit into
mainfrom
nj/sf-1864-0.49-sharpcompress
Open

Update SharpCompress to 0.49.1 and re-arm its vulnerability alerting (SF-1864)#2087
NickJosevski wants to merge 1 commit into
mainfrom
nj/sf-1864-0.49-sharpcompress

Conversation

@NickJosevski

@NickJosevski NickJosevski commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Resolves SF-1864.

CVE advisory's says <= 0.47.4 range impacted.

0.49.1 is where Octopus Server is at

0.49.0 flipped WriterOptions.LeaveStreamOpen from true to false, so a writer now closes the stream it was handed.

Remove <NoWarn>NU1902</NoWarn>, so we get the warnings again and decide then if we want it back, or we fix right away.

Was not vulnerable

  • vulnerable path is the archive-level IArchive.WriteToDirectory(). Calamari iterates entries itself and calls the per-entry APIs — the latter named in the advisory as already guarded.
  • ThrowIfPathTraversalAttempted independently resolves and bounds-checks every entry key, directory entries included, before anything is written.
  • The TAR symlink escalation is blocked because PackageExtractionOptions supplies a WriteSymbolicLink handler that refuses to create links.

Automated tests

ExtractBlocksZipSlipViaDirectoryEntry crafts a zip containing a traversing directory entry and asserts the extractor throws and nothing lands outside the root. It passes on 0.37.2 too — so it evidences that Calamari's own guard blocks this vector.

How to review this PR

Do not merge on green CI alone. #1913 passed Calamari's full suite, merged, then broke Server unpacking a consolidated package (An item with the same key has already been added in ExtractCalamariPackage). This is the one area of the repo where the suite is a known-inadequate gate.

Verification done so far:

Before this leaves draft:

  • Exercise the GitHub package downloader path with credentialsGitHubPackageDownloader.DeNestContents is the one shipping site the 0.49.0 LeaveStreamOpen flip lands on, and it is the only genuinely uncovered path. Best done via a deployment sourced from a GitHub repository feed.

@NickJosevski

Copy link
Copy Markdown
Contributor Author

Verification against the shipped linux-x64 artifact

Ran the packaged Calamari — not dotnet test output — on a clean Linux image, because the tests build a different artifact from what ships.

Setup: published source/Calamari with the real settings (-r linux-x64 --self-contained), confirmed the output carries SharpCompress.dll 0.49.1, and ran it under mcr.microsoft.com/dotnet/runtime-deps:8.0 (linux/amd64). No SDK in the image, so this exercises the self-contained bundle rather than SDK-resolved assemblies. The binary reports commit 4f909e58ab92…, matching this branch.

Every supported format extracts correctly

Format Result
.zip PASS — content + nested directory verified
.tar PASS
.tar.gz PASS
.tar.bz2 PASS
.nupkg PASS
.whl PASS (extracts to 1.0.0-py3-none-any/ — the wheel tag becomes the version)

Driven via deploy-package --package=<file>, asserting on extracted file content including a nested directory, not just exit codes.

Controls, so the above isn't vacuous

Negative control — a deliberately corrupt archive was correctly rejected:

SharpCompress.Common.ArchiveException: Failed to locate the Zip Header

That exception type confirms the shipped 0.49.1 assembly is doing the work, not some fallback path.

Positive control for the highest-risk API. The three decorators that call ArchiveFactory.OpenArchive() — SharpCompress's runtime format-sniffing entry point, and the API most exposed by an 11-minor-version jump — turned out to be opt-in behind Octopus.Calamari.ArchiveLimits.*, so an ordinary extraction never touches them. Re-ran with limits enabled:

  • Realistic thresholds → all four formats extracted with zero exceptions.
  • MaximumUncompressedSize forced to 5 → extraction correctly aborted with ArchiveLimitException, files_extracted=0, on both .zip and .tar.gz.

So ArchiveFactory.OpenArchive works on 0.49.1 in the shipped bundle, and the archive-limit protections still fire.

Correction to earlier reasoning in this thread

I previously argued the packaged-artifact check mattered because Calamari ships trimmed, and trimming plus a library upgrade could silently remove a reflection-reachable path. That is wrong. Reading build/Build.PackageCalamariProjects.cs, the main Calamari flavours publish with EnableSelfContained() only — PublishSingleFile and PublishTrimmed apply solely to docker-credential-octopus, which has zero SharpCompress references.

There is therefore no trim risk for this change. The check retained value for a different reason (it runs the real shipped assemblies on real Linux), but the original justification does not hold.

Still not covered

  • GitHubPackageDownloader.DeNestContents — the one shipping site the 0.49.0 LeaveStreamOpen default flip actually lands on. Its fixtures need GitHub credentials (403 locally). Best exercised by a deployment sourced from a GitHub repository feed; assessed by code reading only so far.
  • ARM RIDs (CI covers Windows and x64 Linux).
  • Large or pathological archives — the ~6s vs ~500ms extraction perf question from the original discussion remains open and unbenchmarked.

Given the trimming concern evaporated, a full local Server run is now optional for this change rather than important. The genuinely uncovered path is the GitHub feed one.

@YuKitsune

Copy link
Copy Markdown

I've run this locally and tested the GitHub path. Works as expected!
I'm running on macOS, so that should also cover the ARM path you mentioned.

Large or pathological archives.

Can you add some more context to this?

protected override Stream GetCompressionStream(Stream stream)
{
return new BZip2Stream(stream, CompressionMode.Decompress, false);
return BZip2Stream.Create(stream, CompressionMode.Decompress, false, false);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Use named arguments to avoid ambiguity:

Suggested change
return BZip2Stream.Create(stream, CompressionMode.Decompress, false, false);
return BZip2Stream.Create(stream, CompressionMode.Decompress, decompressConcatenated: false, leaveOpen: false);

…(SF-1864)

Alternative to #2085, which takes the same change to 0.48.0. Same
adaptations (WriterFactory.Open -> WriterFactory.OpenWriter,
ExtractionOptions) and the same removal of the five <NoWarn>NU1902</NoWarn>
elements, but landing on 0.49.1 instead.

Why 0.49.1 rather than 0.48.0: 0.48.0 was never a considered floor. When
the CVE surfaced on 11 May, 0.48.0 (published 6 May) was simply the newest
release and the first version outside the advisory's <= 0.47.4 range, so
Renovate raised it as the security bump and that number got baked into the
ticket, the branch name and the original WIP commit. 0.48.1 through 0.50.1
were all published afterwards. 0.49.1 is where Octopus Server independently
landed.

Why not further: 0.50.0 changes Tar so it no longer attempts to decompress
streams, and alters the detection API. Calamari's TarGzipPackageExtractor
and TarBzipPackageExtractor are built on layered decompression, so that is
a materially riskier step for no additional security benefit - nothing
security-related shipped after 0.48.0. 1.0.0 is unlisted (NuGet reports its
publish date as the 1900 sentinel) and is not a candidate.

The one behaviour change in range that touches Calamari is 0.49.0 flipping
WriterOptions.LeaveStreamOpen from true to false, so a writer now closes the
stream it was given. Reviewed per call site:

- GitHubPackageDownloader.DeNestContents - writer and stream are nested
  usings, so the stream is disposed twice; Stream.Dispose is idempotent, and
  nothing touches the stream after the writer is disposed.
- UploadAwsS3Convention - returns targetArchivePath and never reads the
  stream after SaveTo, and the data is flushed to disk under either default.
- ZipPackageProvider / NugetPackageProvider - use the path overload of
  SaveTo, so no stream ownership is involved.
- TarGzBuilder (tests) - already set LeaveStreamOpen = false explicitly, so
  it is unaffected by the change in default.

Verified: all affected projects build with 0 errors, NuGet's audit raises no
advisory for 0.49.1, and 83 tests pass across PackageExtractorFixture and
PackageExtractorUtilsFixture.

Not covered by executed tests: GitHubPackageDownloader's own fixtures need
GitHub credentials (they fail with 403 locally), and the Kubernetes EKS
fixture that calls SaveTo is a live test. Both were assessed by reading the
code rather than by running it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@NickJosevski
NickJosevski force-pushed the nj/sf-1864-0.49-sharpcompress branch from 4f909e5 to f7f8af1 Compare July 31, 2026 08:48
@NickJosevski

Copy link
Copy Markdown
Contributor Author

Thanks — that closes the gap I was most concerned about. GitHubPackageDownloader.DeNestContents is the one shipping site the 0.49.0 LeaveStreamOpen default flip actually lands on, and I could only assess it by reading the code.

One small correction on the ARM point, so the coverage claim stays accurate: macOS on Apple Silicon exercises an osx RID, whereas the ARM gap I meant is linux-arm / linux-arm64 — separate self-contained binaries, and the ones Kubernetes agents and ARM Tentacles actually run. CI does cover Test: Ubuntu 22.04 LTS on ARM64, so it isn't uncovered; your run adds osx coverage on top, which CI doesn't have. Both useful, just different rows.

Applied your BZip2Stream.Create nit — agreed, four positional booleans was asking for trouble. Rebased and squashed, so it's still one commit.

On "large or pathological archives"

Fair question — that line was doing too much work. Two distinct things, neither addressed here:

1. Performance, which is a pre-existing open question. In the original thread Orion noted extracting from the ConsolidatedPackage took ~6s where ~500ms would be expected, and that there's no benchmark anywhere in the repo to measure against. That was never resolved. My fixtures use tiny archives — a couple of files, a few hundred bytes — so this PR neither improves nor regresses that, and provides no evidence either way. A 13-minor-version jump could plausibly change extraction throughput in either direction and we would not notice.

2. Adversarial inputs, where we have partial coverage. The archive-limit decorators (EnforceDecompressionLimitDecorator, EnforceCompressionRatioDecorator) exist for zip bombs and I did verify they still fire on 0.49.1 — forcing MaximumUncompressedSize to 5 bytes correctly aborted with ArchiveLimitException. But note they're opt-in behind Octopus.Calamari.ArchiveLimits.Enabled, so a default deployment doesn't get them.

What's untested in both categories: deeply nested paths, very long entry names, high entry counts, truncated or partially-corrupt archives beyond the single "not an archive at all" case I used, and unusual encodings beyond the existing UTF-8 fixtures.

My take: not a blocker for this PR. The change is a version bump whose behavioural risk is concentrated in API surface, and that's covered — CI is green across every platform, all six formats extract correctly from the shipped linux-x64 binary, and the limit protections still work. Performance and fuzz-style robustness are properties of the extraction code that were unmeasured before this change and remain unmeasured after.

They're worth a ticket though, and the perf one has a concrete starting point already: a benchmark for ConsolidatedPackage extraction would let us confirm or dismiss Orion's ~6s observation, and would then also tell us whether a future SharpCompress bump moves it. Happy to raise that separately if you agree it's worth tracking rather than leaving in a PR comment.

@NickJosevski

Copy link
Copy Markdown
Contributor Author

I'll tidy up what claude posted later

NickJosevski added a commit that referenced this pull request Jul 31, 2026
The bundled dotnet-script 1.6.0 is a framework-dependent application targeting
Microsoft.NETCore.App 8.0.0. A framework-dependent app does not roll forward
across a major version by default, so once the build agents were provisioned
with only .NET 10 it failed to launch:

    App: .../Binaries/dotnet-script/dotnet-script.dll
    Framework: 'Microsoft.NETCore.App', version '8.0.0' (x64)
    The following frameworks were found:
      10.0.10 at [.../shared/Microsoft.NETCore.App]

That is the cause of the DotnetScriptProxyFixture failures on the Linux
netcore-testing agents in PR #2091.

Calamari itself is published self-contained and carries no such requirement -
this affects only the separate dotnet-script process. Setting
DOTNET_ROLL_FORWARD=Major on that invocation lets it run on whatever newer
runtime is present. An explicit value already set in the environment is
respected rather than overwritten.

This is a customer-facing fix as much as a CI one: without it, a target with
.NET 10 but no .NET 8 runtime cannot run C# script steps at all. It should also
reduce the need to provision a specific runtime alongside Calamari on workers.

EnvironmentVars is applied additively by SilentProcessRunner
(process.StartInfo.EnvironmentVariables[key] = value), so passing a dictionary
where one was previously null does not discard the inherited environment.

Not done here: upgrading the bundled dotnet-script. 2.0.1 was evaluated and
rejected for now - the GitHub release zip that Calamari vendors is still
net8.0-targeted (only the NuGet package ships a net10.0 build), so it would not
have fixed this, and it changes the NuGet version dotnet-script bundles
(6.10.1.5 -> 6.14.3.1) which invalidates the premise of
UsingIsolatedAssemblyLoadContext. Worth doing as its own piece of work.

Verified: solution builds 171 warnings / 0 errors; all 10 DotnetScriptFixture
tests pass. The 6 DotnetScriptProxyFixture failures seen locally are
environmental - they need the proxy-initializer-fixture-good-proxy host, which
is NXDOMAIN off CI, and they pass on baseline PR #2087's Debian agent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
NickJosevski added a commit that referenced this pull request Aug 3, 2026
The bundled dotnet-script 1.6.0 is a framework-dependent application targeting
Microsoft.NETCore.App 8.0.0. A framework-dependent app does not roll forward
across a major version by default, so once the build agents were provisioned
with only .NET 10 it failed to launch:

    App: .../Binaries/dotnet-script/dotnet-script.dll
    Framework: 'Microsoft.NETCore.App', version '8.0.0' (x64)
    The following frameworks were found:
      10.0.10 at [.../shared/Microsoft.NETCore.App]

That is the cause of the DotnetScriptProxyFixture failures on the Linux
netcore-testing agents in PR #2091.

Calamari itself is published self-contained and carries no such requirement -
this affects only the separate dotnet-script process. Setting
DOTNET_ROLL_FORWARD=Major on that invocation lets it run on whatever newer
runtime is present. An explicit value already set in the environment is
respected rather than overwritten.

This is a customer-facing fix as much as a CI one: without it, a target with
.NET 10 but no .NET 8 runtime cannot run C# script steps at all. It should also
reduce the need to provision a specific runtime alongside Calamari on workers.

EnvironmentVars is applied additively by SilentProcessRunner
(process.StartInfo.EnvironmentVariables[key] = value), so passing a dictionary
where one was previously null does not discard the inherited environment.

Not done here: upgrading the bundled dotnet-script. 2.0.1 was evaluated and
rejected for now - the GitHub release zip that Calamari vendors is still
net8.0-targeted (only the NuGet package ships a net10.0 build), so it would not
have fixed this, and it changes the NuGet version dotnet-script bundles
(6.10.1.5 -> 6.14.3.1) which invalidates the premise of
UsingIsolatedAssemblyLoadContext. Worth doing as its own piece of work.

Verified: solution builds 171 warnings / 0 errors; all 10 DotnetScriptFixture
tests pass. The 6 DotnetScriptProxyFixture failures seen locally are
environmental - they need the proxy-initializer-fixture-good-proxy host, which
is NXDOMAIN off CI, and they pass on baseline PR #2087's Debian agent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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