fix(deps): replace unmaintained decompress with targeted archive extraction - #2393
Conversation
ab9017b to
9176b9e
Compare
9176b9e to
29813b8
Compare
…action decompress@4.2.1 carries three critical advisories with no fix available; it has been unmaintained since 2017: GHSA-mp2f-45pm-3cg9 extraction can create files/links outside the target dir GHSA-h39j-r5qq-r9mm arbitrary file write via archive extraction (Zip Slip) GHSA-jwp9-9v96-94mx arbitrary hardlink creation during extraction Replaces it with tar (npm/isaacs) for .tar.gz and yauzl (the reader behind extract-zip, vsce and Electron) for .zip. Both already resolved in the tree transitively, so they dedupe and add no new packages. The vulnerability class is removed structurally rather than mitigated. The old code extracted the whole archive to disk and then moved the one file it wanted, so every path in the archive was written. This extracts only the requested entry, streamed directly to a path we compute ourselves, leaving a malicious archive no path it can influence. Two issues the rewrite would otherwise have introduced are handled: Archive entries are always '/'-separated, but pathToBinaryInArchive is built with path.join and yields '\' on Windows. toArchiveEntryPath() normalises before comparing; the old code sidestepped this by extracting to real filesystem paths. Writing straight to binaryFilePath would let a failed extraction leave a truncated binary for the existsSync cache check to return on the next run. Extraction now goes to a .partial file and renames only on success, with cleanup on failure. Archive format is inferred from the file extension, so both call sites are unchanged. An unrecognised extension fails with a clear error. Verified against real releases with the production getToolBinaryPath(): both kubelogin (.zip, nested entry) and kubectl-gadget (.tar.gz, root entry) extract byte-identical to unzip -p / tar xzO, land at mode 755, clean up the archive, and execute. npm audit drops from 3 criticals to 0.
29813b8 to
dc73ad7
Compare
There was a problem hiding this comment.
Pull request overview
This PR removes the unmaintained decompress dependency (with critical “no fix available” advisories) by rewriting binary archive extraction to use targeted, single-entry extraction via yauzl (zip) and tar (tar.gz/tgz), reducing the attack surface (no full-archive extraction to disk) and shrinking the dependency tree.
Changes:
- Replace full-archive extraction with streaming extraction of only the requested entry for
.zipand.tar.gz/.tgz. - Add a minimal local ambient type declaration for
yauzlto avoid adding@types/yauzl. - Update dependencies/lockfile: remove
decompress(+types) and add direct deps ontarandyauzl.
Reviewed changes
Copilot reviewed 2 out of 4 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| src/commands/utils/helper/binaryDownloadHelper.ts | Replaces decompress with single-entry extraction for zip/tar archives and adds .partial + rename flow for atomicity. |
| src/types/yauzl.d.ts | Adds minimal ambient typings for the subset of yauzl used by the helper. |
| package.json | Removes decompress/@types/decompress; adds tar and yauzl as direct dependencies. |
| package-lock.json | Reflects dependency tree reduction from removing decompress and promoting tar/yauzl to direct dependencies. |
Suppressed comments (2)
src/commands/utils/helper/binaryDownloadHelper.ts:88
- On
pipeline(...)rejection,zipFile.close()is never called (the failure handler isrejectonly). This can leak handles and keep the downloaded archive locked. Ensure the zip is closed in both success and failure cases (e.g.,pipeline(...).finally(() => zipFile.close())).
pipeline(readStream, fs.createWriteStream(destPath)).then(() => {
zipFile.close();
resolve();
}, reject);
src/commands/utils/helper/binaryDownloadHelper.ts:96
- When the zip ends without finding the entry, the code rejects but does not close the
ZipFile. Close the zip before rejecting here to avoid leaking handles / leaving the archive locked.
zipFile.on("end", () => {
if (!found) {
reject(new Error(`Archive does not contain an entry named ${wantedEntry}.`));
}
});
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| }; | ||
| } finally { | ||
| // Remove the archive whether or not extraction succeeded. | ||
| fs.rmSync(downloadFilePath, { force: true }); |
There was a problem hiding this comment.
| fs.rmSync(downloadFilePath, { force: true }); | |
| fs.rmSync(downloadFilePath, { force: true }); | |
| download.clear(downloadFilePath); |
There was a problem hiding this comment.
Applied — this is the same finding as your other comment, taking the suggestion as written. download.clear() now sits next to the removal in the finally so the file and its download-once marker always move together. Details and the before/after repro are in the reply on the other thread. Fixed in f7858c2.
| }; | ||
| } finally { | ||
| // Remove the archive whether or not extraction succeeded. | ||
| fs.rmSync(downloadFilePath, { force: true }); |
There was a problem hiding this comment.
the finally deletes the archive on failure too, but download.clear() at line 208 is skipped by the early return in the catch. That leaves download.once marked Completed with the file gone, so a retry skips the download and fails until VS Code restarts. On main the archive survived a failed extraction, so retries still worked.
There was a problem hiding this comment.
Confirmed, and you've got the mechanism exactly right — including that main behaved differently. Reproduced it before fixing:
attempt 1 (bad entry) succeeded: false
archive still on disk after failure: [] <- finally removed it
attempt 2 (good entry) succeeded: false
error: Failed to extract ... /download/kubelogin-darwin-arm64.zip
RETRY IS PERMANENTLY BROKEN
download.once keeps DOWNLOAD_ONCE_STATUS[dest] = Completed in module state, so the second call took the else branch, returned success without downloading, and then extraction failed on a file the finally had already deleted. Permanent for the life of the window, since that map is in-memory.
And your point about main is right: it called fs.unlinkSync only after a successful decompress, so a failed extraction left the archive in place and a retry could still use it. The finally I added is what broke that.
Fixed as you suggested — clearing the marker alongside the removal so the two always move together:
} finally {
fs.rmSync(downloadFilePath, { force: true });
download.clear(downloadFilePath);
}Same repro now:
attempt 1 (bad entry) succeeded: false
attempt 2 (good entry) succeeded: true
sha256: MATCH
RETRY RECOVERS
Thanks — this was the most consequential of the four and I'd missed it entirely. Fixed in f7858c2.
…eep retries working Three issues raised in review on Azure#2393. download.clear() was skipped by the early return in the catch, while the finally still deleted the archive. That left download.once() holding a Completed marker for a file that no longer existed, so every retry skipped the download and failed on the missing archive until the window was restarted. On main the archive survived a failed extraction, so retries still worked - this was a regression introduced by the finally block. Reproduced, then fixed by clearing the marker alongside the removal. The zip reader only closed the ZipFile on the success path. Every error path - openReadStream failure, pipeline rejection, and entry-not-found - left the handle open, leaking a descriptor and, on Windows, blocking deletion of the archive in the caller's finally. Settling now goes through a single idempotent finish() that always closes first. Verified with lsof: 12 consecutive failed extractions leave the descriptor count unchanged. extractFileFromTarball reassigned writeCompleted on every matching entry. A tar is append-only, so the same path may legally appear twice; attaching twice started two concurrent writes to the same destination. Built an archive containing "payload" twice: the previous code emitted the second copy, the guarded version deterministically takes the first. Reported by bosesuneha and the Copilot reviewer.
Why
decompress@4.2.1carries three critical advisories with no fix available. It was last published in 2017 and is unmaintained:npm auditreports these ascritical/No fix available, so no version bump resolves them — the dependency has to go.It is used in exactly one place,
binaryDownloadHelper.ts, to unpack thekubeloginandkubectl-gadgetrelease archives.What changed
.zipdecompressyauzl— the reader behindextract-zip,@vscode/vsceand Electron.tar.gzdecompresstar— npm's own implementationThis PR adds zero packages to the dependency tree and removes 51. Both
tar@7.5.21andyauzl@3.4.0were already resolving transitively (viacontainerization-assist-mcpand@vscode/vscerespectively) at exactly these versions, so they simply become direct dependencies and dedupe.npm ciproduces the same tree you're already running.yauzlships no types, and rather than pull in@types/yauzlthis addssrc/types/yauzl.d.ts— a ~50-line ambient declaration covering only the API actually used. Verified to type-check rather than degrade toany.The vulnerability class is removed, not mitigated
This is the part worth reviewing. The old code extracted the entire archive to disk, then moved the one file it wanted:
Every path inside the archive got written, which is exactly what makes Zip Slip and hardlink attacks work. The new code extracts only the requested entry, streamed directly to a path we compute ourselves:
A malicious archive has no path it can influence, so the bug class is structurally unreachable rather than patched.
Two bugs the rewrite would otherwise have introduced
Windows path separators. Archive entries are always
/-separated, butpathToBinaryInArchiveis built withpath.join, which yieldsbin\windows_amd64\kubelogin.exeon Windows. The old code sidestepped this by extracting to real filesystem paths; comparing entry names directly would have broken Windows silently.toArchiveEntryPath()normalises before comparing.Atomicity. Writing straight to
binaryFilePathwould let a failed extraction leave a truncated binary behind — andgetToolBinaryPath()opens with anfs.existsSync(binaryFilePath)cache check, so the next run would hand back the corrupt file. Extraction now goes to a.partialfile and renames only on success, with cleanup on failure.Call sites unchanged
Archive format is inferred from the file extension, so
kubeloginDownload.tsandkubectlGadgetDownload.tsare untouched. An unrecognised extension fails with a clear error.Making it explicit (
archiveFormat: "zip" | "tar.gz"onArchiveDownloadSpec) would move that to compile time and is probably worth doing if more tools get added — it just wasn't worth widening this change. Happy to add it here if preferred.Verification
tsc -p ./ --noEmiteslintprettier --checkwebpack --mode productionnpm ls decompress(empty)npm auditBeyond static checks, I exercised the real production
getToolBinaryPath()withvscodestubbed, downloading actual GitHub releases at the versions this extension pins (kubelogin v0.2.19,kubectl-gadget v0.53.2):Both digests are byte-identical to reference extraction via
unzip -pandtar xzO, and both binaries execute —kubelogin --versionreportsv0.2.19, matching the configured release tag. Cache-hit path, archive cleanup,.partialcleanup, missing-entry and unsupported-extension cases all verified.On the lockfile diff
package-lock.jsonloses ~580 lines. That's a genuine reduction, not churn —decompresspulled in 51 packages, many long-deprecated:decompress-tar,decompress-tarbz2,decompress-targz,decompress-unzip,seek-bzip,unbzip2-stream,tar-stream,buffer-alloc,buffer-fill,pinkie,pinkie-promise,is-natural-number,strip-dirs,fd-slicer, plus nested duplicate copies ofreadable-stream,safe-buffer,string_decoder,isarrayand an oldyauzl@2.10.0.The
package.jsondiff itself is six lines. Notenpm installalso wanted to reformat the unrelatedchatSkillsarray (~110 lines); since.prettierignoreexcludes**/*.jsonnothing enforces either style, so I reverted that and hand-applied only the dependency changes to keep this reviewable.Out of scope
npm auditstill reports findings againstundiciandhono, both pre-existing and unrelated. Separately,hono@4.13.3(pulled in by #2380) is currently unavailable on the Microsoft package feed proxy — its latest is4.13.2— which breaksnpm cilocally for anyone on that registry. Not caused by this PR, but worth a look.