From c3319b24f8e69e9d7a5f032bbf734a581c47c7af Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Feb 2026 05:16:21 +0000 Subject: [PATCH 1/3] Initial plan From 91060933793d64e4f361f6fda5daf58496fbd139 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Feb 2026 05:20:00 +0000 Subject: [PATCH 2/3] Add checksum verification to curl and npx installers Co-authored-by: lionello <591860+lionello@users.noreply.github.com> --- pkgs/npm/src/clilib.ts | 85 ++++++++++++++++++++++++++ pkgs/npm/test/clilib.spec.ts | 115 +++++++++++++++++++++++++++++++++++ src/bin/install | 48 +++++++++++++++ 3 files changed, 248 insertions(+) diff --git a/pkgs/npm/src/clilib.ts b/pkgs/npm/src/clilib.ts index 712620697..887aea29b 100644 --- a/pkgs/npm/src/clilib.ts +++ b/pkgs/npm/src/clilib.ts @@ -73,6 +73,69 @@ async function downloadFile( } } +async function downloadChecksumFile( + version: string, + outputPath: string +): Promise { + const checksumFilename = `defang_v${version}_checksums.txt`; + const downloadUrl = `https://s.defang.io/${checksumFilename}?x-defang-source=npm`; + const downloadTargetFile = path.join(outputPath, checksumFilename); + + return await downloadFile(downloadUrl, downloadTargetFile); +} + +async function verifyChecksum( + archiveFilePath: string, + checksumFilePath: string +): Promise { + try { + // Read the checksum file + const checksumContent = await fs.promises.readFile(checksumFilePath, "utf8"); + + // Get the archive filename (without path) + const archiveFilename = path.basename(archiveFilePath); + + // Find the line with the checksum for our archive + const lines = checksumContent.split('\n'); + let expectedChecksum: string | null = null; + + for (const line of lines) { + // Format: + const parts = line.trim().split(/\s+/); + if (parts.length >= 2 && parts[parts.length - 1] === archiveFilename) { + expectedChecksum = parts[0]; + break; + } + } + + if (!expectedChecksum) { + console.warn(`Checksum for ${archiveFilename} not found in checksum file.`); + return false; + } + + // Calculate the actual checksum + const crypto = await import('crypto'); + const fileBuffer = await fs.promises.readFile(archiveFilePath); + const hash = crypto.createHash('sha256'); + hash.update(fileBuffer); + const actualChecksum = hash.digest('hex'); + + // Compare checksums + if (actualChecksum !== expectedChecksum) { + console.error('Checksum verification failed!'); + console.error(`Expected: ${expectedChecksum}`); + console.error(`Got: ${actualChecksum}`); + return false; + } + + console.log('Checksum verification passed.'); + return true; + } catch (error) { + console.error(`Error verifying checksum: ${error}`); + return false; + } +} + async function extractArchive( archiveFilePath: string, outputPath: string @@ -312,6 +375,26 @@ export async function install( throw new Error(`Failed to download ${filename}`); } + // Download and verify checksum + console.log('Downloading checksum file...'); + const checksumFile = await downloadChecksumFile(version, saveDirectory); + + if (checksumFile) { + console.log('Verifying checksum...'); + const isValid = await verifyChecksum(archiveFile, checksumFile); + + // Clean up checksum file + await deleteArchive(checksumFile); + + if (!isValid) { + // Clean up archive file if checksum verification failed + await deleteArchive(archiveFile); + throw new Error('Checksum verification failed! The downloaded file may be corrupted or tampered with.'); + } + } else { + console.warn('Warning: Could not download checksum file. Skipping checksum verification.'); + } + // Because the releases are compressed tar.gz or .zip we need to // uncompress them to the ./bin directory in the package in node_modules. const result = await extractArchive(archiveFile, saveDirectory); @@ -381,6 +464,7 @@ export async function run(): Promise { const clilib = { deleteArchive, downloadAppArchive, + downloadChecksumFile, downloadFile, extractArchive, extractCLIVersions, @@ -390,6 +474,7 @@ const clilib = { getLatestVersion, getVersionInfo, getPathToExecutable, + verifyChecksum, }; export default clilib; diff --git a/pkgs/npm/test/clilib.spec.ts b/pkgs/npm/test/clilib.spec.ts index bfec65657..7ea53a884 100644 --- a/pkgs/npm/test/clilib.spec.ts +++ b/pkgs/npm/test/clilib.spec.ts @@ -214,3 +214,118 @@ describe("Testing extractCLIVersions()", () => { }); }); + +describe("Testing verifyChecksum()", () => { + let readFileStub: sinon.SinonStub; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + }); + + afterEach(() => { + sandbox.restore(); + }); + + it("verifies valid checksum", async () => { + const archivePath = "/path/to/archive.tar.gz"; + const checksumPath = "/path/to/checksums.txt"; + + // Mock checksum file content + const checksumContent = "abc123def456 archive.tar.gz\n"; + + // Mock file reading for checksum file + readFileStub = sandbox.stub(fs.promises, "readFile"); + readFileStub.withArgs(checksumPath, "utf8").resolves(checksumContent); + + // Mock file reading for archive (return buffer that will hash to abc123def456) + const archiveBuffer = Buffer.from("test data"); + readFileStub.withArgs(archivePath).resolves(archiveBuffer); + + // Since we can't easily mock crypto, we need to calculate the actual hash + const crypto = await import('crypto'); + const hash = crypto.createHash('sha256'); + hash.update(archiveBuffer); + const actualHash = hash.digest('hex'); + + // Update the checksum content to match the actual hash + readFileStub.withArgs(checksumPath, "utf8").resolves(`${actualHash} archive.tar.gz\n`); + + const result = await clilib.verifyChecksum(archivePath, checksumPath); + expect(result).to.be.true; + }); + + it("fails verification with wrong checksum", async () => { + const archivePath = "/path/to/archive.tar.gz"; + const checksumPath = "/path/to/checksums.txt"; + + // Mock checksum file content with wrong hash + const checksumContent = "wronghash123 archive.tar.gz\n"; + + readFileStub = sandbox.stub(fs.promises, "readFile"); + readFileStub.withArgs(checksumPath, "utf8").resolves(checksumContent); + readFileStub.withArgs(archivePath).resolves(Buffer.from("test data")); + + const result = await clilib.verifyChecksum(archivePath, checksumPath); + expect(result).to.be.false; + }); + + it("returns false when checksum not found", async () => { + const archivePath = "/path/to/archive.tar.gz"; + const checksumPath = "/path/to/checksums.txt"; + + // Mock checksum file content without matching archive + const checksumContent = "abc123def456 other-file.tar.gz\n"; + + readFileStub = sandbox.stub(fs.promises, "readFile"); + readFileStub.withArgs(checksumPath, "utf8").resolves(checksumContent); + + const result = await clilib.verifyChecksum(archivePath, checksumPath); + expect(result).to.be.false; + }); + + it("handles errors gracefully", async () => { + const archivePath = "/path/to/archive.tar.gz"; + const checksumPath = "/path/to/checksums.txt"; + + readFileStub = sandbox.stub(fs.promises, "readFile"); + readFileStub.rejects(new Error("File read error")); + + const result = await clilib.verifyChecksum(archivePath, checksumPath); + expect(result).to.be.false; + }); +}); + +describe("Testing downloadChecksumFile()", () => { + let downloadFileStub: sinon.SinonStub; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + }); + + afterEach(() => { + sandbox.restore(); + }); + + it("downloads checksum file with correct URL", async () => { + const version = "0.5.32"; + const outputPath = "/tmp"; + const expectedFilename = "defang_v0.5.32_checksums.txt"; + const expectedUrl = `https://s.defang.io/${expectedFilename}?x-defang-source=npm`; + const expectedPath = `/tmp/${expectedFilename}`; + + // We need to stub downloadFile on the clilib object + const crypto = await import('crypto'); + const axiosStub = sandbox.stub(axios, "get"); + axiosStub.resolves({ + status: 200, + data: Buffer.from("test checksum data"), + }); + + const writeStub = sandbox.stub(fs.promises, "writeFile").resolves(); + + const result = await clilib.downloadChecksumFile(version, outputPath); + + sinon.assert.calledWith(axiosStub, expectedUrl, sinon.match.any); + expect(result).to.equal(expectedPath); + }); +}); diff --git a/src/bin/install b/src/bin/install index 1ac07d611..02f5753f0 100644 --- a/src/bin/install +++ b/src/bin/install @@ -111,6 +111,54 @@ if ! curl -fsSL "$DOWNLOAD_URL" -o "$FILENAME"; then return 4 fi +# Extract version from the release JSON +VERSION=$(echo "$RELEASE_JSON" | grep -o '"tag_name":[[:space:]]*"[^"]*"' | head -n 1 | sed 's/"tag_name":[[:space:]]*"\(.*\)"/\1/') + +# Download and verify checksum +CHECKSUM_FILENAME="defang_${VERSION}_checksums.txt" +CHECKSUM_URL="https://github.com/DefangLabs/defang/releases/download/${VERSION}/${CHECKSUM_FILENAME}" + +echo "Downloading checksums from $CHECKSUM_URL..." +if ! curl -fsSL "$CHECKSUM_URL" -o "$CHECKSUM_FILENAME"; then + echo "Warning: Failed to download checksum file. Skipping checksum verification." +else + # Extract the actual archive filename from the download URL + ARCHIVE_NAME=$(basename "$DOWNLOAD_URL") + + # Find the checksum for our file in the checksum file + EXPECTED_CHECKSUM=$(grep "$ARCHIVE_NAME" "$CHECKSUM_FILENAME" | awk '{print $1}') + + if [ -z "$EXPECTED_CHECKSUM" ]; then + echo "Warning: Checksum for $ARCHIVE_NAME not found in checksum file. Skipping verification." + else + echo "Verifying checksum..." + # Calculate the actual checksum of the downloaded file + if command -v sha256sum > /dev/null 2>&1; then + ACTUAL_CHECKSUM=$(sha256sum "$FILENAME" | awk '{print $1}') + elif command -v shasum > /dev/null 2>&1; then + ACTUAL_CHECKSUM=$(shasum -a 256 "$FILENAME" | awk '{print $1}') + else + echo "Warning: Neither sha256sum nor shasum found. Skipping checksum verification." + ACTUAL_CHECKSUM="" + fi + + if [ -n "$ACTUAL_CHECKSUM" ]; then + if [ "$ACTUAL_CHECKSUM" = "$EXPECTED_CHECKSUM" ]; then + echo "Checksum verification passed." + else + echo "Error: Checksum verification failed!" + echo "Expected: $EXPECTED_CHECKSUM" + echo "Got: $ACTUAL_CHECKSUM" + rm -f "$FILENAME" "$CHECKSUM_FILENAME" + return 11 + fi + fi + fi + + # Clean up checksum file + rm -f "$CHECKSUM_FILENAME" +fi + # Create a temporary directory for extraction EXTRACT_DIR=$(mktemp -d) From c611fba3ed1a645828ae3283a2286580207a085e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Feb 2026 05:21:54 +0000 Subject: [PATCH 3/3] Address code review feedback - use static crypto import Co-authored-by: lionello <591860+lionello@users.noreply.github.com> --- pkgs/npm/src/clilib.ts | 2 +- pkgs/npm/test/clilib.spec.ts | 6 ++---- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/pkgs/npm/src/clilib.ts b/pkgs/npm/src/clilib.ts index 887aea29b..917dfaa5a 100644 --- a/pkgs/npm/src/clilib.ts +++ b/pkgs/npm/src/clilib.ts @@ -1,6 +1,7 @@ import AdmZip from "adm-zip"; import axios from "axios"; import * as child_process from "child_process"; +import * as crypto from "crypto"; import * as fs from "fs"; import * as path from "path"; import * as tar from "tar"; @@ -114,7 +115,6 @@ async function verifyChecksum( } // Calculate the actual checksum - const crypto = await import('crypto'); const fileBuffer = await fs.promises.readFile(archiveFilePath); const hash = crypto.createHash('sha256'); hash.update(fileBuffer); diff --git a/pkgs/npm/test/clilib.spec.ts b/pkgs/npm/test/clilib.spec.ts index 7ea53a884..99ad0be17 100644 --- a/pkgs/npm/test/clilib.spec.ts +++ b/pkgs/npm/test/clilib.spec.ts @@ -1,6 +1,7 @@ import axios, {type AxiosResponse } from "axios"; import * as chai from "chai"; import chaiAsPromised from "chai-as-promised"; +import * as crypto from "crypto"; import fs from "fs"; import "mocha"; import * as sinon from "sinon"; @@ -241,8 +242,7 @@ describe("Testing verifyChecksum()", () => { const archiveBuffer = Buffer.from("test data"); readFileStub.withArgs(archivePath).resolves(archiveBuffer); - // Since we can't easily mock crypto, we need to calculate the actual hash - const crypto = await import('crypto'); + // Calculate the actual hash using the statically imported crypto const hash = crypto.createHash('sha256'); hash.update(archiveBuffer); const actualHash = hash.digest('hex'); @@ -313,8 +313,6 @@ describe("Testing downloadChecksumFile()", () => { const expectedUrl = `https://s.defang.io/${expectedFilename}?x-defang-source=npm`; const expectedPath = `/tmp/${expectedFilename}`; - // We need to stub downloadFile on the clilib object - const crypto = await import('crypto'); const axiosStub = sandbox.stub(axios, "get"); axiosStub.resolves({ status: 200,