mirror of
https://github.com/AikidoSec/safe-chain.git
synced 2026-05-26 20:20:49 +00:00
Merge remote-tracking branch 'origin/main' into feature/handle-handrequest-unhandled-promise
This commit is contained in:
commit
7795ed1a03
10 changed files with 740 additions and 47 deletions
|
|
@ -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 <command>")
|
||||
chalk.bold("Usage: ") + chalk.cyan("safe-chain <command>"),
|
||||
);
|
||||
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();
|
||||
}
|
||||
|
|
|
|||
125
packages/safe-chain/src/installation/downloadAgent.js
Normal file
125
packages/safe-chain/src/installation/downloadAgent.js
Normal file
|
|
@ -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<boolean>}
|
||||
*/
|
||||
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<string | null>} 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;
|
||||
}
|
||||
45
packages/safe-chain/src/installation/downloadAgent.spec.js
Normal file
45
packages/safe-chain/src/installation/downloadAgent.spec.js
Normal file
|
|
@ -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})`
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
155
packages/safe-chain/src/installation/installOnMacOS.js
Normal file
155
packages/safe-chain/src/installation/installOnMacOS.js
Normal file
|
|
@ -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.");
|
||||
}
|
||||
}
|
||||
203
packages/safe-chain/src/installation/installOnWindows.js
Normal file
203
packages/safe-chain/src/installation/installOnWindows.js
Normal file
|
|
@ -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<boolean>} 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.");
|
||||
}
|
||||
}
|
||||
37
packages/safe-chain/src/installation/installUltimate.js
Normal file
37
packages/safe-chain/src/installation/installUltimate.js
Normal file
|
|
@ -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.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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: <chunk-size-hex>\r\n<chunk-data>\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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue