diff --git a/packages/safe-chain/bin/safe-chain.js b/packages/safe-chain/bin/safe-chain.js index 841ccee..9a07657 100755 --- a/packages/safe-chain/bin/safe-chain.js +++ b/packages/safe-chain/bin/safe-chain.js @@ -16,6 +16,10 @@ import path from "path"; import { fileURLToPath } from "url"; import fs from "fs"; import { knownAikidoTools } from "../src/shell-integration/helpers.js"; +import { + installUltimate, + uninstallUltimate, +} from "../src/installation/installUltimate.js"; /** @type {string} */ // This checks the current file's dirname in a way that's compatible with: @@ -62,6 +66,17 @@ if (tool) { process.exit(0); } else if (command === "setup") { setup(); +} else if (command === "ultimate") { + const subCommand = process.argv[3]; + if (subCommand === "uninstall") { + (async () => { + await uninstallUltimate(); + })(); + } else { + (async () => { + await installUltimate(); + })(); + } } else if (command === "teardown") { teardownDirectories(); teardown(); @@ -82,36 +97,49 @@ if (tool) { function writeHelp() { ui.writeInformation( - chalk.bold("Usage: ") + chalk.cyan("safe-chain ") + chalk.bold("Usage: ") + chalk.cyan("safe-chain "), ); ui.emptyLine(); ui.writeInformation( `Available commands: ${chalk.cyan("setup")}, ${chalk.cyan( - "teardown" - )}, ${chalk.cyan("setup-ci")}, ${chalk.cyan("help")}, ${chalk.cyan( - "--version" - )}` + "teardown", + )}, ${chalk.cyan("setup-ci")}, ${chalk.cyan("ultimate")}, ${chalk.cyan("help")}, ${chalk.cyan( + "--version", + )}`, ); ui.emptyLine(); ui.writeInformation( `- ${chalk.cyan( - "safe-chain setup" - )}: This will setup your shell to wrap safe-chain around npm, npx, yarn, pnpm, pnpx, bun, bunx, pip and pip3.` + "safe-chain setup", + )}: This will setup your shell to wrap safe-chain around npm, npx, yarn, pnpm, pnpx, bun, bunx, pip and pip3.`, ); ui.writeInformation( `- ${chalk.cyan( - "safe-chain teardown" - )}: This will remove safe-chain aliases from your shell configuration.` + "safe-chain teardown", + )}: This will remove safe-chain aliases from your shell configuration.`, ); ui.writeInformation( `- ${chalk.cyan( - "safe-chain setup-ci" - )}: This will setup safe-chain for CI environments by creating shims and modifying the PATH.` + "safe-chain setup-ci", + )}: This will setup safe-chain for CI environments by creating shims and modifying the PATH.`, ); ui.writeInformation( `- ${chalk.cyan("safe-chain --version")} (or ${chalk.cyan( - "-v" - )}): Display the current version of safe-chain.` + "-v", + )}): Display the current version of safe-chain.`, + ); + ui.emptyLine(); + ui.writeInformation(chalk.bold("Ultimate commands:")); + ui.emptyLine(); + ui.writeInformation( + `- ${chalk.cyan( + "safe-chain ultimate", + )}: Install the ultimate version of safe-chain, enabling protection for more eco-systems.`, + ); + ui.writeInformation( + `- ${chalk.cyan( + "safe-chain ultimate uninstall", + )}: Uninstall the ultimate version of safe-chain.`, ); ui.emptyLine(); } diff --git a/packages/safe-chain/src/installation/downloadAgent.js b/packages/safe-chain/src/installation/downloadAgent.js new file mode 100644 index 0000000..a5dcb0d --- /dev/null +++ b/packages/safe-chain/src/installation/downloadAgent.js @@ -0,0 +1,125 @@ +import { createWriteStream, createReadStream } from "fs"; +import { createHash } from "crypto"; +import { pipeline } from "stream/promises"; +import fetch from "make-fetch-happen"; + +const ULTIMATE_VERSION = "v0.2.3"; + +export const DOWNLOAD_URLS = { + win32: { + x64: { + url: `https://github.com/AikidoSec/safechain-internals/releases/download/${ULTIMATE_VERSION}/SafeChainUltimate-windows-amd64.msi`, + checksum: + "sha256:bd196ae05b876588f828a57c4d19b3e7ad96ba40007cf2b36693dc6e792d28cc", + }, + arm64: { + url: `https://github.com/AikidoSec/safechain-internals/releases/download/${ULTIMATE_VERSION}/SafeChainUltimate-windows-arm64.msi`, + checksum: + "sha256:79e046f24405e869494291e77c6d8640c8dc58d2ac1db87d3038e9eb8afbdc8b", + }, + }, + darwin: { + x64: { + url: `https://github.com/AikidoSec/safechain-internals/releases/download/${ULTIMATE_VERSION}/SafeChainUltimate-darwin-amd64.pkg`, + checksum: + "sha256:99868cb663eef44d063d995d2dcc063f55b10eb719ee945d05fe8cf5fef5e2a5", + }, + arm64: { + url: `https://github.com/AikidoSec/safechain-internals/releases/download/${ULTIMATE_VERSION}/SafeChainUltimate-darwin-arm64.pkg`, + checksum: + "sha256:000b334c2eb85d8692be5d23af73f8b9fb686c9db726992223187b341ea79306", + }, + }, +}; + +/** + * Builds the download URL for the SafeChain Agent installer. + * @param {string} fileName + */ +export function getAgentDownloadUrl(fileName) { + return `https://github.com/AikidoSec/safechain-internals/releases/download/${ULTIMATE_VERSION}/${fileName}`; +} + +/** + * Downloads a file from a URL to a local path. + * @param {string} url + * @param {string} destPath + */ +export async function downloadFile(url, destPath) { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Download failed: ${response.statusText}`); + } + await pipeline(response.body, createWriteStream(destPath)); +} + +/** + * Returns the current agent version. + */ +export function getAgentVersion() { + return ULTIMATE_VERSION; +} + +/** + * Returns download info (url, checksum) for the current OS and architecture. + * @returns {{ url: string, checksum: string } | null} + */ +export function getDownloadInfoForCurrentPlatform() { + const platform = process.platform; + const arch = process.arch; + + if (!Object.hasOwn(DOWNLOAD_URLS, platform)) { + return null; + } + const platformUrls = + DOWNLOAD_URLS[/** @type {keyof typeof DOWNLOAD_URLS} */ (platform)]; + + if (!Object.hasOwn(platformUrls, arch)) { + return null; + } + + return platformUrls[/** @type {keyof typeof platformUrls} */ (arch)]; +} + +/** + * Verifies the checksum of a file. + * @param {string} filePath + * @param {string} expectedChecksum - Format: "algorithm:hash" (e.g., "sha256:abc123...") + * @returns {Promise} + */ +export async function verifyChecksum(filePath, expectedChecksum) { + const [algorithm, expected] = expectedChecksum.split(":"); + + const hash = createHash(algorithm); + + if (filePath.includes("..")) throw new Error("Invalid file path"); + const stream = createReadStream(filePath); + + for await (const chunk of stream) { + hash.update(chunk); + } + + const actual = hash.digest("hex"); + return actual === expected; +} + +/** + * Downloads the SafeChain agent for the current OS/arch and verifies its checksum. + * @param {string} fileName - Destination file path + * @returns {Promise} The file path if successful, null if no download URL for current platform + */ +export async function downloadAgentToFile(fileName) { + const info = getDownloadInfoForCurrentPlatform(); + if (!info) { + return null; + } + + await downloadFile(info.url, fileName); + + const isValid = await verifyChecksum(fileName, info.checksum); + if (!isValid) { + throw new Error("Checksum verification failed"); + } + + return fileName; +} diff --git a/packages/safe-chain/src/installation/downloadAgent.spec.js b/packages/safe-chain/src/installation/downloadAgent.spec.js new file mode 100644 index 0000000..17aecb9 --- /dev/null +++ b/packages/safe-chain/src/installation/downloadAgent.spec.js @@ -0,0 +1,45 @@ +import { describe, it, after } from "node:test"; +import assert from "node:assert"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { unlinkSync } from "node:fs"; +import { + DOWNLOAD_URLS, + downloadFile, + verifyChecksum, +} from "./downloadAgent.js"; + +describe("downloadAgent checksums", { timeout: 120_000 }, () => { + const downloadedFiles = []; + + after(() => { + for (const file of downloadedFiles) { + try { + unlinkSync(file); + } catch { + // ignore cleanup errors + } + } + }); + + for (const [platform, architectures] of Object.entries(DOWNLOAD_URLS)) { + for (const [arch, { url, checksum }] of Object.entries(architectures)) { + it(`${platform}/${arch} checksum matches`, async () => { + const destPath = join( + tmpdir(), + `safe-chain-test-${platform}-${arch}-${Date.now()}` + ); + downloadedFiles.push(destPath); + + await downloadFile(url, destPath); + + const isValid = await verifyChecksum(destPath, checksum); + assert.strictEqual( + isValid, + true, + `Checksum mismatch for ${platform}/${arch} (${url})` + ); + }); + } + } +}); diff --git a/packages/safe-chain/src/installation/installOnMacOS.js b/packages/safe-chain/src/installation/installOnMacOS.js new file mode 100644 index 0000000..22ce1a8 --- /dev/null +++ b/packages/safe-chain/src/installation/installOnMacOS.js @@ -0,0 +1,155 @@ +import { tmpdir } from "os"; +import { unlinkSync } from "fs"; +import { join } from "path"; +import { execSync, spawnSync } from "child_process"; +import { ui } from "../environment/userInteraction.js"; +import { printVerboseAndSafeSpawn } from "../utils/safeSpawn.js"; +import { downloadAgentToFile, getAgentVersion } from "./downloadAgent.js"; +import chalk from "chalk"; + +const MACOS_PKG_IDENTIFIER = "com.aikidosecurity.safechainultimate"; + +/** + * Checks if root privileges are available and displays error message if not. + * @param {string} command - The sudo command to show in the error message + * @returns {boolean} True if running as root, false otherwise. + */ +function requireRootPrivileges(command) { + if (isRunningAsRoot()) { + return true; + } + + ui.writeError("Root privileges required."); + ui.writeInformation("Please run this command with sudo:"); + ui.writeInformation(` ${command}`); + return false; +} + +function isRunningAsRoot() { + const rootUserUid = 0; + return process.getuid?.() === rootUserUid; +} + +export async function installOnMacOS() { + if (!requireRootPrivileges("sudo safe-chain ultimate")) { + return; + } + + const pkgPath = join(tmpdir(), `SafeChainUltimate-${Date.now()}.pkg`); + + ui.emptyLine(); + ui.writeInformation(`đŸ“Ĩ Downloading SafeChain Ultimate ${getAgentVersion()}`); + ui.writeVerbose(`Destination: ${pkgPath}`); + + const result = await downloadAgentToFile(pkgPath); + if (!result) { + ui.writeError("No download available for this platform/architecture."); + return; + } + + try { + ui.writeInformation("âš™ī¸ Installing SafeChain Ultimate..."); + await runPkgInstaller(pkgPath); + + ui.emptyLine(); + ui.writeInformation( + "✅ SafeChain Ultimate installed and started successfully!", + ); + ui.emptyLine(); + ui.writeInformation( + chalk.cyan("🔐 ") + + chalk.bold("ACTION REQUIRED: ") + + "macOS will show a popup to install our certificate.", + ); + ui.writeInformation( + " " + + chalk.bold("Please accept the certificate") + + " to complete the installation.", + ); + ui.emptyLine(); + } finally { + ui.writeVerbose(`Cleaning up temporary file: ${pkgPath}`); + cleanup(pkgPath); + } +} + +const MACOS_UNINSTALL_SCRIPT = + "/Library/Application\\ Support/AikidoSecurity/SafeChainUltimate/scripts/uninstall"; + +export async function uninstallOnMacOS() { + if (!requireRootPrivileges("sudo safe-chain ultimate uninstall")) { + return; + } + + ui.emptyLine(); + + if (!isPackageInstalled()) { + ui.writeInformation("SafeChain Ultimate is not installed."); + return; + } + + ui.writeInformation("đŸ—‘ī¸ Uninstalling SafeChain Ultimate..."); + ui.writeVerbose(`Running: ${MACOS_UNINSTALL_SCRIPT}`); + + const result = spawnSync(MACOS_UNINSTALL_SCRIPT, { + stdio: "inherit", + shell: true, + }); + + if (result.status !== 0) { + ui.writeError( + `Uninstall script failed (exit code: ${result.status}). Please try again or remove manually.`, + ); + return; + } + + ui.emptyLine(); + ui.writeInformation("✅ SafeChain Ultimate has been uninstalled."); + ui.emptyLine(); +} + +function isPackageInstalled() { + try { + const output = execSync(`pkgutil --pkg-info ${MACOS_PKG_IDENTIFIER}`, { + encoding: "utf8", + stdio: "pipe", + }); + return output.includes(MACOS_PKG_IDENTIFIER); + } catch { + return false; + } +} + +/** + * @param {string} pkgPath + */ +async function runPkgInstaller(pkgPath) { + // Uses installer to install the package (https://ss64.com/mac/installer.html) + // Options: + // -pkg (required): The package to be installed. + // -target (required): The target volume is specified with the -target parameter. + // --> "-target /" installs to the current boot volume. + + const result = await printVerboseAndSafeSpawn( + "installer", + ["-pkg", pkgPath, "-target", "/"], + { + stdio: "inherit", + }, + ); + + if (result.status !== 0) { + throw new Error(`PKG installer failed (exit code: ${result.status})`); + } +} + +/** + * @param {string} pkgPath + */ +function cleanup(pkgPath) { + try { + unlinkSync(pkgPath); + } catch { + ui.writeVerbose("Failed to clean up temporary installer file."); + } +} diff --git a/packages/safe-chain/src/installation/installOnWindows.js b/packages/safe-chain/src/installation/installOnWindows.js new file mode 100644 index 0000000..4cee911 --- /dev/null +++ b/packages/safe-chain/src/installation/installOnWindows.js @@ -0,0 +1,203 @@ +import { tmpdir } from "os"; +import { unlinkSync } from "fs"; +import { join } from "path"; +import { execSync } from "child_process"; +import { ui } from "../environment/userInteraction.js"; +import { printVerboseAndSafeSpawn, safeSpawn } from "../utils/safeSpawn.js"; +import { downloadAgentToFile, getAgentVersion } from "./downloadAgent.js"; + +const WINDOWS_SERVICE_NAME = "SafeChainUltimate"; +const WINDOWS_APP_NAME = "SafeChain Ultimate"; + +export async function uninstallOnWindows() { + if (!(await requireAdminPrivileges())) { + return; + } + + ui.emptyLine(); + + const productCode = getInstalledProductCode(); + if (!productCode) { + ui.writeInformation("SafeChain Ultimate is not installed."); + return; + } + + await stopServiceIfRunning(); + + ui.writeInformation("đŸ—‘ī¸ Uninstalling SafeChain Ultimate..."); + await uninstallByProductCode(productCode); + + ui.emptyLine(); + ui.writeInformation("✅ SafeChain Ultimate has been uninstalled."); + ui.emptyLine(); +} + +export async function installOnWindows() { + if (!(await requireAdminPrivileges())) { + return; + } + + const msiPath = join(tmpdir(), `SafeChainUltimate-${Date.now()}.msi`); + + ui.emptyLine(); + ui.writeInformation(`đŸ“Ĩ Downloading SafeChain Ultimate ${getAgentVersion()}`); + ui.writeVerbose(`Destination: ${msiPath}`); + + const result = await downloadAgentToFile(msiPath); + if (!result) { + ui.writeError("No download available for this platform/architecture."); + return; + } + + try { + ui.emptyLine(); + await stopServiceIfRunning(); + await uninstallIfInstalled(); + + ui.writeInformation("âš™ī¸ Installing SafeChain Ultimate..."); + await runMsiInstaller(msiPath); + + ui.emptyLine(); + ui.writeInformation( + "✅ SafeChain Ultimate installed and started successfully!", + ); + ui.emptyLine(); + } finally { + ui.writeVerbose(`Cleaning up temporary file: ${msiPath}`); + cleanup(msiPath); + } +} + +/** + * Checks if admin privileges are available and displays error message if not. + * @returns {Promise} True if running as admin, false otherwise. + */ +async function requireAdminPrivileges() { + if (await isRunningAsAdmin()) { + return true; + } + + ui.writeError("Administrator privileges required."); + ui.writeInformation( + "Please run this command in an elevated terminal (Run as Administrator).", + ); + return false; +} + +async function isRunningAsAdmin() { + // Uses Windows Security API to check if current process has admin privileges. + // Returns "True" or "False" as a string. + const result = await safeSpawn( + "powershell", + [ + "-Command", + "([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)", + ], + { stdio: "pipe" }, + ); + + return result.status === 0 && result.stdout.trim() === "True"; +} + +/** + * Returns the MSI product code for SafeChain Ultimate, or null if not installed. + * @returns {string | null} + */ +function getInstalledProductCode() { + // Query Win32_Product via WMI to find the installed SafeChain Agent. + // If found, outputs the product GUID (e.g., "{12345678-1234-...}") needed for msiexec uninstall. + ui.writeVerbose(`Finding product code with PowerShell`); + + let productCode; + try { + productCode = execSync( + `powershell -Command "$app = Get-WmiObject -Class Win32_Product -Filter \\"Name='${WINDOWS_APP_NAME}'\\"; if ($app) { Write-Output $app.IdentifyingNumber }"`, + { encoding: "utf8" }, + ).trim(); + } catch { + return null; + } + return productCode || null; +} + +/** + * @param {string} productCode + */ +async function uninstallByProductCode(productCode) { + ui.writeVerbose(`Found product code: ${productCode}`); + + // Use msiexec to run the msi installer quitely (https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/msiexec) + // Options: + // - /x: Uninstalls the package. + // - /qn: Specifies there's no UI during the installation process. + // - /norestart: Stops the device from restarting after the installation completes. + const uninstallResult = await printVerboseAndSafeSpawn( + "msiexec", + ["/x", productCode, "/qn", "/norestart"], + { stdio: "inherit" }, + ); + + if (uninstallResult.status !== 0) { + throw new Error(`Uninstall failed (exit code: ${uninstallResult.status})`); + } +} + +async function uninstallIfInstalled() { + const productCode = getInstalledProductCode(); + if (!productCode) { + ui.writeVerbose("No existing installation found (fresh install)."); + return; + } + + ui.writeInformation("đŸ—‘ī¸ Removing previous installation..."); + await uninstallByProductCode(productCode); +} + +/** + * @param {string} msiPath + */ +async function runMsiInstaller(msiPath) { + // Use msiexec to run the msi installer quitely (https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/msiexec) + // Options: + // - /i: Specifies normal installation + // - /qn: Specifies there's no UI during the installation process. + + const result = await printVerboseAndSafeSpawn( + "msiexec", + ["/i", msiPath, "/qn"], + { + stdio: "inherit", + }, + ); + + if (result.status !== 0) { + throw new Error(`MSI installer failed (exit code: ${result.status})`); + } +} + +async function stopServiceIfRunning() { + ui.writeInformation("âšī¸ Stopping running service..."); + + const result = await printVerboseAndSafeSpawn( + "net", + ["stop", WINDOWS_SERVICE_NAME], + { + stdio: "pipe", + }, + ); + + if (result.status !== 0) { + ui.writeVerbose("Service not running (will start after installation)."); + } +} + +/** + * @param {string} msiPath + */ +function cleanup(msiPath) { + try { + unlinkSync(msiPath); + } catch { + ui.writeVerbose("Failed to clean up temporary installer file."); + } +} diff --git a/packages/safe-chain/src/installation/installUltimate.js b/packages/safe-chain/src/installation/installUltimate.js new file mode 100644 index 0000000..cfcdcca --- /dev/null +++ b/packages/safe-chain/src/installation/installUltimate.js @@ -0,0 +1,37 @@ +import { platform } from "os"; +import { ui } from "../environment/userInteraction.js"; +import { initializeCliArguments } from "../config/cliArguments.js"; +import { installOnWindows, uninstallOnWindows } from "./installOnWindows.js"; +import { installOnMacOS, uninstallOnMacOS } from "./installOnMacOS.js"; + +export async function uninstallUltimate() { + initializeCliArguments(process.argv); + + const operatingSystem = platform(); + + if (operatingSystem === "win32") { + await uninstallOnWindows(); + } else if (operatingSystem === "darwin") { + await uninstallOnMacOS(); + } else { + ui.writeInformation( + `Uninstall is not yet supported on ${operatingSystem}.`, + ); + } +} + +export async function installUltimate() { + initializeCliArguments(process.argv); + + const operatingSystem = platform(); + + if (operatingSystem === "win32") { + await installOnWindows(); + } else if (operatingSystem === "darwin") { + await installOnMacOS(); + } else { + ui.writeInformation( + `${operatingSystem} is not supported yet by SafeChain's ultimate version.`, + ); + } +} diff --git a/packages/safe-chain/src/main.js b/packages/safe-chain/src/main.js index 9b7ba53..0b37eba 100644 --- a/packages/safe-chain/src/main.js +++ b/packages/safe-chain/src/main.js @@ -73,20 +73,20 @@ export async function main(args) { ui.writeVerbose( `${chalk.green("✔")} Safe-chain: Scanned ${ auditStats.totalPackages - } packages, no malware found.` + } packages, no malware found.`, ); } if (proxy.hasSuppressedVersions()) { ui.writeInformation( `${chalk.yellow( - "ℹ" - )} Safe-chain: Some package versions were suppressed due to minimum age requirement.` + "ℹ", + )} Safe-chain: Some package versions were suppressed due to minimum age requirement.`, ); ui.writeInformation( ` To disable this check, use: ${chalk.cyan( - "--safe-chain-skip-minimum-package-age" - )}` + "--safe-chain-skip-minimum-package-age", + )}`, ); } diff --git a/packages/safe-chain/src/registryProxy/registryProxy.mitm.spec.js b/packages/safe-chain/src/registryProxy/registryProxy.mitm.spec.js index df4332e..407aa3c 100644 --- a/packages/safe-chain/src/registryProxy/registryProxy.mitm.spec.js +++ b/packages/safe-chain/src/registryProxy/registryProxy.mitm.spec.js @@ -2,12 +2,17 @@ import { before, after, describe, it } from "node:test"; import assert from "node:assert"; import net from "net"; import tls from "tls"; +import { gunzipSync } from "zlib"; import { createSafeChainProxy, mergeSafeChainProxyEnvironmentVariables, } from "./registryProxy.js"; import { getCaCertPath } from "./certUtils.js"; -import { setEcoSystem, ECOSYSTEM_JS, ECOSYSTEM_PY } from "../config/settings.js"; +import { + setEcoSystem, + ECOSYSTEM_JS, + ECOSYSTEM_PY, +} from "../config/settings.js"; import fs from "fs"; describe("registryProxy.mitm", () => { @@ -33,7 +38,7 @@ describe("registryProxy.mitm", () => { proxyHost, proxyPort, "registry.npmjs.org", - "/lodash" + "/lodash", ); assert.strictEqual(response.statusCode, 200); @@ -45,7 +50,7 @@ describe("registryProxy.mitm", () => { proxyHost, proxyPort, "registry.npmjs.org", - "/lodash/-/lodash-4.17.21.tgz" + "/lodash/-/lodash-4.17.21.tgz", ); // Should get a response (200 or redirect, but not 403 blocked) @@ -57,7 +62,7 @@ describe("registryProxy.mitm", () => { proxyHost, proxyPort, "registry.npmjs.org", - "/this-package-definitely-does-not-exist-12345" + "/this-package-definitely-does-not-exist-12345", ); assert.strictEqual(response.statusCode, 404); @@ -68,7 +73,7 @@ describe("registryProxy.mitm", () => { proxyHost, proxyPort, "registry.npmjs.org", - "/lodash?write=true" + "/lodash?write=true", ); assert.strictEqual(response.statusCode, 200); @@ -79,7 +84,7 @@ describe("registryProxy.mitm", () => { proxyHost, proxyPort, "registry.yarnpkg.com", - "/lodash" + "/lodash", ); assert.strictEqual(response.statusCode, 200); @@ -90,7 +95,7 @@ describe("registryProxy.mitm", () => { proxyHost, proxyPort, "registry.npmjs.org", - "/lodash" + "/lodash", ); // Check certificate common name matches the target hostname @@ -109,14 +114,14 @@ describe("registryProxy.mitm", () => { proxyHost, proxyPort, "registry.npmjs.org", - "/lodash" + "/lodash", ); const { cert: cert2 } = await makeRegistryRequestAndGetCert( proxyHost, proxyPort, "registry.yarnpkg.com", - "/lodash" + "/lodash", ); // Different hostnames should have different certificates @@ -130,14 +135,14 @@ describe("registryProxy.mitm", () => { proxyHost, proxyPort, "registry.npmjs.org", - "/lodash" + "/lodash", ); const { cert: cert2 } = await makeRegistryRequestAndGetCert( proxyHost, proxyPort, "registry.npmjs.org", - "/package/lodash" + "/package/lodash", ); // Same hostname should get the same certificate (fingerprint) @@ -159,7 +164,7 @@ describe("registryProxy.mitm", () => { proxyHost, proxyPort, "pypi.org", - "/packages/source/f/foo_bar/foo_bar-2.0.0.tar.gz" + "/packages/source/f/foo_bar/foo_bar-2.0.0.tar.gz", ); assert.notStrictEqual(response.statusCode, 403); assert.ok(typeof response.body === "string"); @@ -172,7 +177,7 @@ describe("registryProxy.mitm", () => { proxyHost, proxyPort, "files.pythonhosted.org", - "/packages/xx/yy/foo_bar-2.0.0-py3-none-any.whl" + "/packages/xx/yy/foo_bar-2.0.0-py3-none-any.whl", ); assert.notStrictEqual(response.statusCode, 403); assert.ok(typeof response.body === "string"); @@ -185,7 +190,7 @@ describe("registryProxy.mitm", () => { proxyHost, proxyPort, "pypi.org", - "/packages/source/f/foo_bar/foo_bar-2.0.0a1.tar.gz" + "/packages/source/f/foo_bar/foo_bar-2.0.0a1.tar.gz", ); assert.notStrictEqual(response.statusCode, 403); assert.ok(typeof response.body === "string"); @@ -198,7 +203,7 @@ describe("registryProxy.mitm", () => { proxyHost, proxyPort, "pypi.org", - "/packages/source/f/foo_bar/foo_bar-latest.tar.gz" + "/packages/source/f/foo_bar/foo_bar-latest.tar.gz", ); assert.notStrictEqual(response.statusCode, 403); assert.ok(typeof response.body === "string"); @@ -234,34 +239,73 @@ async function makeRegistryRequest(proxyHost, proxyPort, targetHost, path) { }); // Step 4: Send HTTP request over TLS - const httpRequest = `GET ${path} HTTP/1.1\r\nHost: ${targetHost}\r\nConnection: close\r\n\r\n`; + const httpRequest = `GET ${path} HTTP/1.1\r\nHost: ${targetHost}\r\nConnection: close\r\nAccept-encoding: gzip\r\n\r\n`; tlsSocket.write(httpRequest); - // Step 5: Read response + // Step 5: Read response as binary chunks return new Promise((resolve, reject) => { - let data = ""; + const chunks = []; tlsSocket.on("data", (chunk) => { - data += chunk.toString(); + chunks.push(chunk); }); tlsSocket.on("end", () => { - const lines = data.split("\r\n"); - const statusLine = lines[0]; + const buffer = Buffer.concat(chunks); + + // Find the header/body separator (\r\n\r\n) in binary + const separator = Buffer.from("\r\n\r\n"); + let separatorIndex = buffer.indexOf(separator); + if (separatorIndex === -1) { + return reject( + new Error("Invalid HTTP response: no header/body separator"), + ); + } + + // Extract headers as text + const headersText = buffer.subarray(0, separatorIndex).toString("utf8"); + const headerLines = headersText.split("\r\n"); + const statusLine = headerLines[0]; const statusCode = parseInt(statusLine.split(" ")[1]); - // Find body after empty line - const emptyLineIndex = lines.findIndex(line => line === ""); - const body = lines.slice(emptyLineIndex + 1).join("\r\n"); + // Parse headers into object + const headers = {}; + for (let i = 1; i < headerLines.length; i++) { + const colonIndex = headerLines[i].indexOf(":"); + if (colonIndex > 0) { + const key = headerLines[i].substring(0, colonIndex).toLowerCase(); + const value = headerLines[i].substring(colonIndex + 1).trim(); + headers[key] = value; + } + } - resolve({ statusCode, body }); + // Extract body as binary + let bodyBuffer = buffer.subarray(separatorIndex + separator.length); + + // Decode chunked transfer encoding if present + if (headers["transfer-encoding"] === "chunked") { + bodyBuffer = decodeChunked(bodyBuffer); + } + + // Decompress if gzip encoded + if (headers["content-encoding"] === "gzip" && bodyBuffer.length > 0) { + bodyBuffer = gunzipSync(bodyBuffer); + } + + const body = bodyBuffer.toString("utf8"); + resolve({ statusCode, body, headers }); }); tlsSocket.on("error", reject); }); } -async function makeRegistryRequestAndGetCert(proxyHost, proxyPort, targetHost, path) { +async function makeRegistryRequestAndGetCert( + proxyHost, + proxyPort, + targetHost, + path, +) { // Step 1: Connect to proxy const socket = await new Promise((resolve, reject) => { const sock = net.connect({ host: proxyHost, port: proxyPort }, () => { @@ -311,7 +355,7 @@ async function makeRegistryRequestAndGetCert(proxyHost, proxyPort, targetHost, p const statusCode = parseInt(statusLine.split(" ")[1]); // Find body after empty line - const emptyLineIndex = lines.findIndex(line => line === ""); + const emptyLineIndex = lines.findIndex((line) => line === ""); const body = lines.slice(emptyLineIndex + 1).join("\r\n"); resolve({ statusCode, body }); @@ -322,3 +366,37 @@ async function makeRegistryRequestAndGetCert(proxyHost, proxyPort, targetHost, p return { cert: peerCert, response }; } + +/** + * Decode HTTP chunked transfer encoding + * Format: \r\n\r\n ... 0\r\n\r\n + * @param {Buffer} buffer + * @returns {Buffer} + */ +function decodeChunked(buffer) { + const chunks = []; + let offset = 0; + + while (offset < buffer.length) { + // Find the end of the chunk size line + const lineEnd = buffer.indexOf(Buffer.from("\r\n"), offset); + if (lineEnd === -1) break; + + // Parse chunk size (hex) + const sizeHex = buffer.subarray(offset, lineEnd).toString("utf8"); + const chunkSize = parseInt(sizeHex, 16); + + // End of chunks + if (chunkSize === 0) break; + + // Extract chunk data + const dataStart = lineEnd + 2; + const dataEnd = dataStart + chunkSize; + chunks.push(buffer.subarray(dataStart, dataEnd)); + + // Move past chunk data and trailing \r\n + offset = dataEnd + 2; + } + + return Buffer.concat(chunks); +} diff --git a/packages/safe-chain/src/shell-integration/helpers.js b/packages/safe-chain/src/shell-integration/helpers.js index 064aca1..3e71d71 100644 --- a/packages/safe-chain/src/shell-integration/helpers.js +++ b/packages/safe-chain/src/shell-integration/helpers.js @@ -99,7 +99,7 @@ export const knownAikidoTools = [ aikidoCommand: "aikido-pipx", ecoSystem: ECOSYSTEM_PY, internalPackageManagerName: "pipx", - } + }, // When adding a new tool here, also update the documentation for the new tool in the README.md ]; @@ -216,7 +216,13 @@ export function addLineToFile(filePath, line, eol) { eol = eol || os.EOL; const fileContent = fs.readFileSync(filePath, "utf-8"); - const updatedContent = fileContent + eol + line + eol; + let updatedContent = fileContent; + + if (!fileContent.endsWith(eol)) { + updatedContent += eol; + } + + updatedContent += line + eol; fs.writeFileSync(filePath, updatedContent, "utf-8"); } diff --git a/packages/safe-chain/src/utils/safeSpawn.js b/packages/safe-chain/src/utils/safeSpawn.js index e17bdb5..69c827a 100644 --- a/packages/safe-chain/src/utils/safeSpawn.js +++ b/packages/safe-chain/src/utils/safeSpawn.js @@ -1,5 +1,6 @@ import { spawn, execSync } from "child_process"; import os from "os"; +import { ui } from "../environment/userInteraction.js"; /** * @param {string} arg @@ -135,3 +136,18 @@ export async function safeSpawn(command, args, options = {}) { }); }); } + +/** + * @param {string} command + * @param {string[]} args + * @param {import("child_process").SpawnOptions} options + * + * @returns {Promise<{status: number, stdout: string, stderr: string}>} + */ +export async function printVerboseAndSafeSpawn(command, args, options = {}) { + ui.writeVerbose(`Running: ${command} ${args.join(" ")}`); + + const result = await safeSpawn(command, args, options); + + return result; +}