mirror of
https://github.com/AikidoSec/safe-chain.git
synced 2026-05-26 12:10:49 +00:00
Merge pull request #82 from AikidoSec/intercept-registry-http
Implement a proxy blocking tarball requests for packages with malware.
This commit is contained in:
commit
62a9339a71
34 changed files with 1033 additions and 119 deletions
6
.github/workflows/test-on-pr.yml
vendored
6
.github/workflows/test-on-pr.yml
vendored
|
|
@ -46,7 +46,7 @@ jobs:
|
||||||
include:
|
include:
|
||||||
# Common production setup
|
# Common production setup
|
||||||
- node_version: "20"
|
- node_version: "20"
|
||||||
npm_version: "10.0.0"
|
npm_version: "10.2.0"
|
||||||
yarn_version: "4.0.0"
|
yarn_version: "4.0.0"
|
||||||
pnpm_version: "9.0.0"
|
pnpm_version: "9.0.0"
|
||||||
# Current Active LTS with latest tools
|
# Current Active LTS with latest tools
|
||||||
|
|
@ -66,7 +66,7 @@ jobs:
|
||||||
pnpm_version: "latest"
|
pnpm_version: "latest"
|
||||||
# Version pinning scenario
|
# Version pinning scenario
|
||||||
- node_version: "22"
|
- node_version: "22"
|
||||||
npm_version: "10.0.0"
|
npm_version: "10.2.0"
|
||||||
yarn_version: "4.0.0"
|
yarn_version: "4.0.0"
|
||||||
pnpm_version: "9.0.0"
|
pnpm_version: "9.0.0"
|
||||||
# Backward compatibility testing
|
# Backward compatibility testing
|
||||||
|
|
@ -82,7 +82,7 @@ jobs:
|
||||||
# EOL compatibility testing - Node 16 (EOL Sept 2023)
|
# EOL compatibility testing - Node 16 (EOL Sept 2023)
|
||||||
- node_version: "16"
|
- node_version: "16"
|
||||||
npm_version: "8.0.0"
|
npm_version: "8.0.0"
|
||||||
yarn_version: "3.6.0"
|
yarn_version: "1.22.0"
|
||||||
pnpm_version: "8.0.0"
|
pnpm_version: "8.0.0"
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
|
|
|
||||||
11
package-lock.json
generated
11
package-lock.json
generated
|
|
@ -3323,6 +3323,15 @@
|
||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/node-forge": {
|
||||||
|
"version": "1.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.1.tgz",
|
||||||
|
"integrity": "sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==",
|
||||||
|
"license": "(BSD-3-Clause OR GPL-2.0)",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6.13.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/node-pty": {
|
"node_modules/node-pty": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.0.0.tgz",
|
||||||
|
|
@ -4877,7 +4886,9 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"abbrev": "3.0.1",
|
"abbrev": "3.0.1",
|
||||||
"chalk": "5.4.1",
|
"chalk": "5.4.1",
|
||||||
|
"https-proxy-agent": "7.0.6",
|
||||||
"make-fetch-happen": "14.0.3",
|
"make-fetch-happen": "14.0.3",
|
||||||
|
"node-forge": "1.3.1",
|
||||||
"npm-registry-fetch": "18.0.2",
|
"npm-registry-fetch": "18.0.2",
|
||||||
"ora": "8.2.0",
|
"ora": "8.2.0",
|
||||||
"semver": "7.7.2"
|
"semver": "7.7.2"
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,9 @@ import { initializePackageManager } from "../src/packagemanager/currentPackageMa
|
||||||
|
|
||||||
const packageManagerName = "npm";
|
const packageManagerName = "npm";
|
||||||
initializePackageManager(packageManagerName, getNpmVersion());
|
initializePackageManager(packageManagerName, getNpmVersion());
|
||||||
await main(process.argv.slice(2));
|
var exitCode = await main(process.argv.slice(2));
|
||||||
|
|
||||||
|
process.exit(exitCode);
|
||||||
|
|
||||||
function getNpmVersion() {
|
function getNpmVersion() {
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -5,4 +5,6 @@ import { initializePackageManager } from "../src/packagemanager/currentPackageMa
|
||||||
|
|
||||||
const packageManagerName = "npx";
|
const packageManagerName = "npx";
|
||||||
initializePackageManager(packageManagerName, process.versions.node);
|
initializePackageManager(packageManagerName, process.versions.node);
|
||||||
await main(process.argv.slice(2));
|
var exitCode = await main(process.argv.slice(2));
|
||||||
|
|
||||||
|
process.exit(exitCode);
|
||||||
|
|
|
||||||
|
|
@ -5,4 +5,6 @@ import { initializePackageManager } from "../src/packagemanager/currentPackageMa
|
||||||
|
|
||||||
const packageManagerName = "pnpm";
|
const packageManagerName = "pnpm";
|
||||||
initializePackageManager(packageManagerName, process.versions.node);
|
initializePackageManager(packageManagerName, process.versions.node);
|
||||||
await main(process.argv.slice(2));
|
var exitCode = await main(process.argv.slice(2));
|
||||||
|
|
||||||
|
process.exit(exitCode);
|
||||||
|
|
|
||||||
|
|
@ -5,4 +5,6 @@ import { initializePackageManager } from "../src/packagemanager/currentPackageMa
|
||||||
|
|
||||||
const packageManagerName = "pnpx";
|
const packageManagerName = "pnpx";
|
||||||
initializePackageManager(packageManagerName, process.versions.node);
|
initializePackageManager(packageManagerName, process.versions.node);
|
||||||
await main(process.argv.slice(2));
|
var exitCode = await main(process.argv.slice(2));
|
||||||
|
|
||||||
|
process.exit(exitCode);
|
||||||
|
|
|
||||||
|
|
@ -5,4 +5,6 @@ import { initializePackageManager } from "../src/packagemanager/currentPackageMa
|
||||||
|
|
||||||
const packageManagerName = "yarn";
|
const packageManagerName = "yarn";
|
||||||
initializePackageManager(packageManagerName, process.versions.node);
|
initializePackageManager(packageManagerName, process.versions.node);
|
||||||
await main(process.argv.slice(2));
|
var exitCode = await main(process.argv.slice(2));
|
||||||
|
|
||||||
|
process.exit(exitCode);
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,9 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"abbrev": "3.0.1",
|
"abbrev": "3.0.1",
|
||||||
"chalk": "5.4.1",
|
"chalk": "5.4.1",
|
||||||
|
"https-proxy-agent": "7.0.6",
|
||||||
"make-fetch-happen": "14.0.3",
|
"make-fetch-happen": "14.0.3",
|
||||||
|
"node-forge": "1.3.1",
|
||||||
"npm-registry-fetch": "18.0.2",
|
"npm-registry-fetch": "18.0.2",
|
||||||
"ora": "8.2.0",
|
"ora": "8.2.0",
|
||||||
"semver": "7.7.2"
|
"semver": "7.7.2"
|
||||||
|
|
|
||||||
|
|
@ -4,20 +4,50 @@ import { scanCommand, shouldScanCommand } from "./scanning/index.js";
|
||||||
import { ui } from "./environment/userInteraction.js";
|
import { ui } from "./environment/userInteraction.js";
|
||||||
import { getPackageManager } from "./packagemanager/currentPackageManager.js";
|
import { getPackageManager } from "./packagemanager/currentPackageManager.js";
|
||||||
import { initializeCliArguments } from "./config/cliArguments.js";
|
import { initializeCliArguments } from "./config/cliArguments.js";
|
||||||
|
import { createSafeChainProxy } from "./registryProxy/registryProxy.js";
|
||||||
|
import chalk from "chalk";
|
||||||
|
|
||||||
export async function main(args) {
|
export async function main(args) {
|
||||||
|
const proxy = createSafeChainProxy();
|
||||||
|
await proxy.startServer();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// This parses all the --safe-chain arguments and removes them from the args array
|
// This parses all the --safe-chain arguments and removes them from the args array
|
||||||
args = initializeCliArguments(args);
|
args = initializeCliArguments(args);
|
||||||
|
|
||||||
if (shouldScanCommand(args)) {
|
if (shouldScanCommand(args)) {
|
||||||
await scanCommand(args);
|
const commandScanResult = await scanCommand(args);
|
||||||
|
|
||||||
|
// Returning the exit code back to the caller allows the promise
|
||||||
|
// to be awaited in the bin files and return the correct exit code
|
||||||
|
if (commandScanResult !== 0) {
|
||||||
|
return commandScanResult;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const packageManagerResult = await getPackageManager().runCommand(args);
|
||||||
|
|
||||||
|
if (!proxy.verifyNoMaliciousPackages()) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.emptyLine();
|
||||||
|
ui.writeInformation(
|
||||||
|
`${chalk.green(
|
||||||
|
"✔"
|
||||||
|
)} Safe-chain: Command completed, no malicious packages found.`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Returning the exit code back to the caller allows the promise
|
||||||
|
// to be awaited in the bin files and return the correct exit code
|
||||||
|
return packageManagerResult.status;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ui.writeError("Failed to check for malicious packages:", error.message);
|
ui.writeError("Failed to check for malicious packages:", error.message);
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
var result = getPackageManager().runCommand(args);
|
// Returning the exit code back to the caller allows the promise
|
||||||
process.exit(result.status);
|
// to be awaited in the bin files and return the correct exit code
|
||||||
|
return 1;
|
||||||
|
} finally {
|
||||||
|
await proxy.stopServer();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ export function dryRunScanner(scannerOptions) {
|
||||||
shouldScan: (args) => shouldScanDependencies(scannerOptions, args),
|
shouldScan: (args) => shouldScanDependencies(scannerOptions, args),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function scanDependencies(scannerOptions, args) {
|
function scanDependencies(scannerOptions, args) {
|
||||||
let dryRunArgs = args;
|
let dryRunArgs = args;
|
||||||
|
|
||||||
|
|
@ -31,8 +32,8 @@ function shouldScanDependencies(scannerOptions, args) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function checkChangesWithDryRun(args) {
|
async function checkChangesWithDryRun(args) {
|
||||||
const dryRunOutput = dryRunNpmCommandAndOutput(args);
|
const dryRunOutput = await dryRunNpmCommandAndOutput(args);
|
||||||
|
|
||||||
// Dry-run can return a non-zero status code in some cases
|
// Dry-run can return a non-zero status code in some cases
|
||||||
// e.g., when running "npm audit fix --dry-run", it returns exit code 1
|
// e.g., when running "npm audit fix --dry-run", it returns exit code 1
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ describe("dryRunScanner", async () => {
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const scanner = dryRunScanner();
|
const scanner = dryRunScanner();
|
||||||
const result = scanner.scan(["audit", "fix"]);
|
const result = await scanner.scan(["audit", "fix"]);
|
||||||
|
|
||||||
// Should not throw an error for audit fix commands
|
// Should not throw an error for audit fix commands
|
||||||
assert.ok(Array.isArray(result));
|
assert.ok(Array.isArray(result));
|
||||||
|
|
@ -53,8 +53,8 @@ describe("dryRunScanner", async () => {
|
||||||
|
|
||||||
const scanner = dryRunScanner();
|
const scanner = dryRunScanner();
|
||||||
|
|
||||||
assert.throws(() => {
|
await assert.rejects(async () => {
|
||||||
scanner.scan(["install", "lodash"]);
|
await scanner.scan(["install", "lodash"]);
|
||||||
}, /Dry-run command failed with exit code 1/);
|
}, /Dry-run command failed with exit code 1/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -67,7 +67,7 @@ describe("dryRunScanner", async () => {
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const scanner = dryRunScanner();
|
const scanner = dryRunScanner();
|
||||||
const result = scanner.scan(["install", "lodash"]);
|
const result = await scanner.scan(["install", "lodash"]);
|
||||||
|
|
||||||
assert.ok(Array.isArray(result));
|
assert.ok(Array.isArray(result));
|
||||||
assert.equal(mockWriteError.mock.callCount(), 0);
|
assert.equal(mockWriteError.mock.callCount(), 0);
|
||||||
|
|
@ -83,8 +83,8 @@ describe("dryRunScanner", async () => {
|
||||||
|
|
||||||
const scanner = dryRunScanner();
|
const scanner = dryRunScanner();
|
||||||
|
|
||||||
assert.throws(() => {
|
await assert.rejects(async () => {
|
||||||
scanner.scan(["audit", "fix"]);
|
await scanner.scan(["audit", "fix"]);
|
||||||
}, /Dry-run command failed with exit code 1/);
|
}, /Dry-run command failed with exit code 1/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -99,7 +99,7 @@ describe("dryRunScanner", async () => {
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const scanner = dryRunScanner({ dryRunCommand: "install" });
|
const scanner = dryRunScanner({ dryRunCommand: "install" });
|
||||||
scanner.scan(["install-test", "lodash"]);
|
await scanner.scan(["install-test", "lodash"]);
|
||||||
|
|
||||||
// Should call with "install" instead of "install-test"
|
// Should call with "install" instead of "install-test"
|
||||||
assert.equal(mockDryRunNpmCommandAndOutput.mock.callCount(), 1);
|
assert.equal(mockDryRunNpmCommandAndOutput.mock.callCount(), 1);
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,14 @@
|
||||||
import { execSync } from "child_process";
|
|
||||||
import { ui } from "../../environment/userInteraction.js";
|
import { ui } from "../../environment/userInteraction.js";
|
||||||
|
import { safeSpawn } from "../../utils/safeSpawn.js";
|
||||||
|
import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js";
|
||||||
|
|
||||||
export function runNpm(args) {
|
export async function runNpm(args) {
|
||||||
try {
|
try {
|
||||||
const npmCommand = `npm ${args.join(" ")}`;
|
const result = await safeSpawn("npm", args, {
|
||||||
execSync(npmCommand, { stdio: "inherit" });
|
stdio: "inherit",
|
||||||
|
env: mergeSafeChainProxyEnvironmentVariables(process.env),
|
||||||
|
});
|
||||||
|
return { status: result.status };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.status) {
|
if (error.status) {
|
||||||
return { status: error.status };
|
return { status: error.status };
|
||||||
|
|
@ -13,17 +17,29 @@ export function runNpm(args) {
|
||||||
return { status: 1 };
|
return { status: 1 };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { status: 0 };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function dryRunNpmCommandAndOutput(args) {
|
export async function dryRunNpmCommandAndOutput(args) {
|
||||||
try {
|
try {
|
||||||
const npmCommand = `npm ${args.join(" ")} --ignore-scripts --dry-run`;
|
const result = await safeSpawn(
|
||||||
const output = execSync(npmCommand, { stdio: "pipe" });
|
"npm",
|
||||||
return { status: 0, output: output.toString() };
|
[...args, "--ignore-scripts", "--dry-run"],
|
||||||
|
{
|
||||||
|
stdio: "pipe",
|
||||||
|
env: mergeSafeChainProxyEnvironmentVariables(process.env),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
status: result.status,
|
||||||
|
output: result.status === 0 ? result.stdout : result.stderr,
|
||||||
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.status) {
|
if (error.status) {
|
||||||
const output = error.stdout ? error.stdout.toString() : "";
|
const output =
|
||||||
|
error.stdout?.toString() ??
|
||||||
|
error.stderr?.toString() ??
|
||||||
|
error.message ??
|
||||||
|
"";
|
||||||
return { status: error.status, output };
|
return { status: error.status, output };
|
||||||
} else {
|
} else {
|
||||||
ui.writeError("Error executing command:", error.message);
|
ui.writeError("Error executing command:", error.message);
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,14 @@
|
||||||
import { execSync } from "child_process";
|
|
||||||
import { ui } from "../../environment/userInteraction.js";
|
import { ui } from "../../environment/userInteraction.js";
|
||||||
|
import { safeSpawn } from "../../utils/safeSpawn.js";
|
||||||
|
import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js";
|
||||||
|
|
||||||
export function runNpx(args) {
|
export async function runNpx(args) {
|
||||||
try {
|
try {
|
||||||
const npxCommand = `npx ${args.join(" ")}`;
|
const result = await safeSpawn("npx", args, {
|
||||||
execSync(npxCommand, { stdio: "inherit" });
|
stdio: "inherit",
|
||||||
|
env: mergeSafeChainProxyEnvironmentVariables(process.env),
|
||||||
|
});
|
||||||
|
return { status: result.status };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.status) {
|
if (error.status) {
|
||||||
return { status: error.status };
|
return { status: error.status };
|
||||||
|
|
@ -13,5 +17,4 @@ export function runNpx(args) {
|
||||||
return { status: 1 };
|
return { status: 1 };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { status: 0 };
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,20 @@
|
||||||
import { ui } from "../../environment/userInteraction.js";
|
import { ui } from "../../environment/userInteraction.js";
|
||||||
import { safeSpawnSync } from "../../utils/safeSpawn.js";
|
import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js";
|
||||||
|
import { safeSpawn } from "../../utils/safeSpawn.js";
|
||||||
|
|
||||||
export function runPnpmCommand(args, toolName = "pnpm") {
|
export async function runPnpmCommand(args, toolName = "pnpm") {
|
||||||
try {
|
try {
|
||||||
let result;
|
let result;
|
||||||
if (toolName === "pnpm") {
|
if (toolName === "pnpm") {
|
||||||
result = safeSpawnSync("pnpm", args, { stdio: "inherit" });
|
result = await safeSpawn("pnpm", args, {
|
||||||
|
stdio: "inherit",
|
||||||
|
env: mergeSafeChainProxyEnvironmentVariables(process.env),
|
||||||
|
});
|
||||||
} else if (toolName === "pnpx") {
|
} else if (toolName === "pnpx") {
|
||||||
result = safeSpawnSync("pnpx", args, { stdio: "inherit" });
|
result = await safeSpawn("pnpx", args, {
|
||||||
|
stdio: "inherit",
|
||||||
|
env: mergeSafeChainProxyEnvironmentVariables(process.env),
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
throw new Error(`Unsupported tool name for aikido-pnpm: ${toolName}`);
|
throw new Error(`Unsupported tool name for aikido-pnpm: ${toolName}`);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,17 @@
|
||||||
import { execSync } from "child_process";
|
|
||||||
import { ui } from "../../environment/userInteraction.js";
|
import { ui } from "../../environment/userInteraction.js";
|
||||||
|
import { safeSpawn } from "../../utils/safeSpawn.js";
|
||||||
|
import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js";
|
||||||
|
|
||||||
export function runYarnCommand(args) {
|
export async function runYarnCommand(args) {
|
||||||
try {
|
try {
|
||||||
const npxCommand = `yarn ${args.join(" ")}`;
|
const env = mergeSafeChainProxyEnvironmentVariables(process.env);
|
||||||
execSync(npxCommand, { stdio: "inherit" });
|
await fixYarnProxyEnvironmentVariables(env);
|
||||||
|
|
||||||
|
const result = await safeSpawn("yarn", args, {
|
||||||
|
stdio: "inherit",
|
||||||
|
env,
|
||||||
|
});
|
||||||
|
return { status: result.status };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.status) {
|
if (error.status) {
|
||||||
return { status: error.status };
|
return { status: error.status };
|
||||||
|
|
@ -13,5 +20,34 @@ export function runYarnCommand(args) {
|
||||||
return { status: 1 };
|
return { status: 1 };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { status: 0 };
|
}
|
||||||
|
|
||||||
|
async function fixYarnProxyEnvironmentVariables(env) {
|
||||||
|
// Yarn ignores standard proxy environment variables HTTPS_PROXY and NODE_EXTRA_CA_CERTS
|
||||||
|
|
||||||
|
// Yarn v2/v3 and v4+ use different environment variables for proxy and CA certs
|
||||||
|
// When setting all variables, yarn returns an error about conflicting variables
|
||||||
|
// - v2/v3: "Usage Error: Unrecognized or legacy configuration settings found: httpsCaFilePath"
|
||||||
|
// - v4+: "Usage Error: Unrecognized or legacy configuration settings found: caFilePath"
|
||||||
|
|
||||||
|
const version = await yarnVersion();
|
||||||
|
const majorVersion = parseInt(version.split(".")[0]);
|
||||||
|
|
||||||
|
if (majorVersion >= 4) {
|
||||||
|
env.YARN_HTTPS_PROXY = env.HTTPS_PROXY;
|
||||||
|
env.YARN_HTTPS_CA_FILE_PATH = env.NODE_EXTRA_CA_CERTS;
|
||||||
|
} else if (majorVersion === 2 || majorVersion === 3) {
|
||||||
|
env.YARN_HTTPS_PROXY = env.HTTPS_PROXY;
|
||||||
|
env.YARN_CA_FILE_PATH = env.NODE_EXTRA_CA_CERTS;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function yarnVersion() {
|
||||||
|
const result = await safeSpawn("yarn", ["--version"], {
|
||||||
|
stdio: "pipe",
|
||||||
|
});
|
||||||
|
if (result.status !== 0) {
|
||||||
|
throw new Error("Failed to get yarn version");
|
||||||
|
}
|
||||||
|
return result.stdout.trim();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
114
packages/safe-chain/src/registryProxy/certUtils.js
Normal file
114
packages/safe-chain/src/registryProxy/certUtils.js
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
import forge from "node-forge";
|
||||||
|
import path from "path";
|
||||||
|
import fs from "fs";
|
||||||
|
import os from "os";
|
||||||
|
|
||||||
|
const certFolder = path.join(os.homedir(), ".safe-chain", "certs");
|
||||||
|
const ca = loadCa();
|
||||||
|
|
||||||
|
const certCache = new Map();
|
||||||
|
|
||||||
|
export function getCaCertPath() {
|
||||||
|
return path.join(certFolder, "ca-cert.pem");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateCertForHost(hostname) {
|
||||||
|
let existingCert = certCache.get(hostname);
|
||||||
|
if (existingCert) {
|
||||||
|
return existingCert;
|
||||||
|
}
|
||||||
|
|
||||||
|
const keys = forge.pki.rsa.generateKeyPair(2048);
|
||||||
|
const cert = forge.pki.createCertificate();
|
||||||
|
cert.publicKey = keys.publicKey;
|
||||||
|
cert.serialNumber = "01";
|
||||||
|
cert.validity.notBefore = new Date();
|
||||||
|
cert.validity.notAfter = new Date();
|
||||||
|
cert.validity.notAfter.setHours(cert.validity.notBefore.getHours() + 1);
|
||||||
|
|
||||||
|
const attrs = [{ name: "commonName", value: hostname }];
|
||||||
|
cert.setSubject(attrs);
|
||||||
|
cert.setIssuer(ca.certificate.subject.attributes);
|
||||||
|
cert.setExtensions([
|
||||||
|
{
|
||||||
|
name: "subjectAltName",
|
||||||
|
altNames: [
|
||||||
|
{
|
||||||
|
type: 2, // DNS
|
||||||
|
value: hostname,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "keyUsage",
|
||||||
|
digitalSignature: true,
|
||||||
|
keyEncipherment: true,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
cert.sign(ca.privateKey, forge.md.sha256.create());
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
privateKey: forge.pki.privateKeyToPem(keys.privateKey),
|
||||||
|
certificate: forge.pki.certificateToPem(cert),
|
||||||
|
};
|
||||||
|
|
||||||
|
certCache.set(hostname, result);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadCa() {
|
||||||
|
const keyPath = path.join(certFolder, "ca-key.pem");
|
||||||
|
const certPath = path.join(certFolder, "ca-cert.pem");
|
||||||
|
|
||||||
|
if (fs.existsSync(keyPath) && fs.existsSync(certPath)) {
|
||||||
|
const privateKeyPem = fs.readFileSync(keyPath, "utf8");
|
||||||
|
const certPem = fs.readFileSync(certPath, "utf8");
|
||||||
|
const privateKey = forge.pki.privateKeyFromPem(privateKeyPem);
|
||||||
|
const certificate = forge.pki.certificateFromPem(certPem);
|
||||||
|
|
||||||
|
// Don't return a cert that is valid for less than 1 hour
|
||||||
|
const oneHourFromNow = new Date(Date.now() + 60 * 60 * 1000);
|
||||||
|
if (certificate.validity.notAfter > oneHourFromNow) {
|
||||||
|
return { privateKey, certificate };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { privateKey, certificate } = generateCa();
|
||||||
|
fs.mkdirSync(certFolder, { recursive: true });
|
||||||
|
fs.writeFileSync(keyPath, forge.pki.privateKeyToPem(privateKey));
|
||||||
|
fs.writeFileSync(certPath, forge.pki.certificateToPem(certificate));
|
||||||
|
return { privateKey, certificate };
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateCa() {
|
||||||
|
const keys = forge.pki.rsa.generateKeyPair(2048);
|
||||||
|
const cert = forge.pki.createCertificate();
|
||||||
|
cert.publicKey = keys.publicKey;
|
||||||
|
cert.serialNumber = "01";
|
||||||
|
cert.validity.notBefore = new Date();
|
||||||
|
cert.validity.notAfter = new Date();
|
||||||
|
cert.validity.notAfter.setDate(cert.validity.notBefore.getDate() + 1);
|
||||||
|
|
||||||
|
const attrs = [{ name: "commonName", value: "safe-chain proxy" }];
|
||||||
|
cert.setSubject(attrs);
|
||||||
|
cert.setIssuer(attrs);
|
||||||
|
cert.setExtensions([
|
||||||
|
{
|
||||||
|
name: "basicConstraints",
|
||||||
|
cA: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "keyUsage",
|
||||||
|
keyCertSign: true,
|
||||||
|
digitalSignature: true,
|
||||||
|
keyEncipherment: true,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
cert.sign(keys.privateKey, forge.md.sha256.create());
|
||||||
|
|
||||||
|
return {
|
||||||
|
privateKey: keys.privateKey,
|
||||||
|
certificate: cert,
|
||||||
|
};
|
||||||
|
}
|
||||||
90
packages/safe-chain/src/registryProxy/mitmRequestHandler.js
Normal file
90
packages/safe-chain/src/registryProxy/mitmRequestHandler.js
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
import https from "https";
|
||||||
|
import { generateCertForHost } from "./certUtils.js";
|
||||||
|
import { HttpsProxyAgent } from "https-proxy-agent";
|
||||||
|
|
||||||
|
export function mitmConnect(req, clientSocket, isAllowed) {
|
||||||
|
const { hostname } = new URL(`http://${req.url}`);
|
||||||
|
|
||||||
|
const server = createHttpsServer(hostname, isAllowed);
|
||||||
|
|
||||||
|
// Establish the connection
|
||||||
|
clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
|
||||||
|
|
||||||
|
// Hand off the socket to the HTTPS server
|
||||||
|
server.emit("connection", clientSocket);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createHttpsServer(hostname, isAllowed) {
|
||||||
|
const cert = generateCertForHost(hostname);
|
||||||
|
|
||||||
|
async function handleRequest(req, res) {
|
||||||
|
const pathAndQuery = getRequestPathAndQuery(req.url);
|
||||||
|
const targetUrl = `https://${hostname}${pathAndQuery}`;
|
||||||
|
|
||||||
|
if (!(await isAllowed(targetUrl))) {
|
||||||
|
res.writeHead(403, "Forbidden - blocked by safe-chain");
|
||||||
|
res.end("Blocked by safe-chain");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect request body
|
||||||
|
forwardRequest(req, hostname, res);
|
||||||
|
}
|
||||||
|
|
||||||
|
return https.createServer(
|
||||||
|
{
|
||||||
|
key: cert.privateKey,
|
||||||
|
cert: cert.certificate,
|
||||||
|
},
|
||||||
|
handleRequest
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRequestPathAndQuery(url) {
|
||||||
|
if (url.startsWith("http://") || url.startsWith("https://")) {
|
||||||
|
const parsedUrl = new URL(url);
|
||||||
|
return parsedUrl.pathname + parsedUrl.search + parsedUrl.hash;
|
||||||
|
}
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
function forwardRequest(req, hostname, res) {
|
||||||
|
const proxyReq = createProxyRequest(hostname, req, res);
|
||||||
|
|
||||||
|
proxyReq.on("error", () => {
|
||||||
|
res.writeHead(502);
|
||||||
|
res.end("Bad Gateway");
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on("data", (chunk) => {
|
||||||
|
proxyReq.write(chunk);
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on("end", () => {
|
||||||
|
proxyReq.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createProxyRequest(hostname, req, res) {
|
||||||
|
const options = {
|
||||||
|
hostname: hostname,
|
||||||
|
port: 443,
|
||||||
|
path: req.url,
|
||||||
|
method: req.method,
|
||||||
|
headers: { ...req.headers },
|
||||||
|
};
|
||||||
|
|
||||||
|
delete options.headers.host;
|
||||||
|
|
||||||
|
const httpsProxy = process.env.HTTPS_PROXY || process.env.https_proxy;
|
||||||
|
if (httpsProxy) {
|
||||||
|
options.agent = new HttpsProxyAgent(httpsProxy);
|
||||||
|
}
|
||||||
|
|
||||||
|
const proxyReq = https.request(options, (proxyRes) => {
|
||||||
|
res.writeHead(proxyRes.statusCode, proxyRes.headers);
|
||||||
|
proxyRes.pipe(res);
|
||||||
|
});
|
||||||
|
|
||||||
|
return proxyReq;
|
||||||
|
}
|
||||||
48
packages/safe-chain/src/registryProxy/parsePackageFromUrl.js
Normal file
48
packages/safe-chain/src/registryProxy/parsePackageFromUrl.js
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
export const knownRegistries = ["registry.npmjs.org", "registry.yarnpkg.com"];
|
||||||
|
|
||||||
|
export function parsePackageFromUrl(url) {
|
||||||
|
let packageName, version, registry;
|
||||||
|
|
||||||
|
for (const knownRegistry of knownRegistries) {
|
||||||
|
if (url.includes(knownRegistry)) {
|
||||||
|
registry = knownRegistry;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!registry || !url.endsWith(".tgz")) {
|
||||||
|
return { packageName, version };
|
||||||
|
}
|
||||||
|
|
||||||
|
const registryIndex = url.indexOf(registry);
|
||||||
|
const afterRegistry = url.substring(registryIndex + registry.length + 1); // +1 to skip the slash
|
||||||
|
|
||||||
|
const separatorIndex = afterRegistry.indexOf("/-/");
|
||||||
|
if (separatorIndex === -1) {
|
||||||
|
return { packageName, version };
|
||||||
|
}
|
||||||
|
|
||||||
|
packageName = afterRegistry.substring(0, separatorIndex);
|
||||||
|
const filename = afterRegistry.substring(
|
||||||
|
separatorIndex + 3,
|
||||||
|
afterRegistry.length - 4
|
||||||
|
); // Remove /-/ and .tgz
|
||||||
|
|
||||||
|
// Extract version from filename
|
||||||
|
// For scoped packages like @babel/core, the filename is core-7.21.4.tgz
|
||||||
|
// For regular packages like lodash, the filename is lodash-4.17.21.tgz
|
||||||
|
if (packageName.startsWith("@")) {
|
||||||
|
const scopedPackageName = packageName.substring(
|
||||||
|
packageName.lastIndexOf("/") + 1
|
||||||
|
);
|
||||||
|
if (filename.startsWith(scopedPackageName + "-")) {
|
||||||
|
version = filename.substring(scopedPackageName.length + 1);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (filename.startsWith(packageName + "-")) {
|
||||||
|
version = filename.substring(packageName.length + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { packageName, version };
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,114 @@
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
import assert from "node:assert";
|
||||||
|
import { parsePackageFromUrl } from "./parsePackageFromUrl.js";
|
||||||
|
|
||||||
|
describe("parsePackageFromUrl", () => {
|
||||||
|
const testCases = [
|
||||||
|
// Regular packages
|
||||||
|
{
|
||||||
|
url: "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
||||||
|
expected: { packageName: "lodash", version: "4.17.21" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: "https://registry.npmjs.org/express/-/express-4.18.2.tgz",
|
||||||
|
expected: { packageName: "express", version: "4.18.2" },
|
||||||
|
},
|
||||||
|
// Packages with hyphens in name
|
||||||
|
{
|
||||||
|
url: "https://registry.npmjs.org/safe-chain-test/-/safe-chain-test-1.0.0.tgz",
|
||||||
|
expected: { packageName: "safe-chain-test", version: "1.0.0" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: "https://registry.npmjs.org/web-vitals/-/web-vitals-3.5.0.tgz",
|
||||||
|
expected: { packageName: "web-vitals", version: "3.5.0" },
|
||||||
|
},
|
||||||
|
// Preview/prerelease versions
|
||||||
|
{
|
||||||
|
url: "https://registry.npmjs.org/safe-chain-test/-/safe-chain-test-0.0.1-security.tgz",
|
||||||
|
expected: { packageName: "safe-chain-test", version: "0.0.1-security" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: "https://registry.npmjs.org/lodash/-/lodash-5.0.0-beta.1.tgz",
|
||||||
|
expected: { packageName: "lodash", version: "5.0.0-beta.1" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: "https://registry.npmjs.org/react/-/react-18.3.0-canary-abc123.tgz",
|
||||||
|
expected: { packageName: "react", version: "18.3.0-canary-abc123" },
|
||||||
|
},
|
||||||
|
// Scoped packages
|
||||||
|
{
|
||||||
|
url: "https://registry.npmjs.org/@babel/core/-/core-7.21.4.tgz",
|
||||||
|
expected: { packageName: "@babel/core", version: "7.21.4" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: "https://registry.npmjs.org/@types/node/-/node-20.10.5.tgz",
|
||||||
|
expected: { packageName: "@types/node", version: "20.10.5" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: "https://registry.npmjs.org/@angular/common/-/common-17.0.8.tgz",
|
||||||
|
expected: { packageName: "@angular/common", version: "17.0.8" },
|
||||||
|
},
|
||||||
|
// Scoped packages with hyphens
|
||||||
|
{
|
||||||
|
url: "https://registry.npmjs.org/@safe-chain/test-package/-/test-package-2.1.0.tgz",
|
||||||
|
expected: { packageName: "@safe-chain/test-package", version: "2.1.0" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.465.0.tgz",
|
||||||
|
expected: { packageName: "@aws-sdk/client-s3", version: "3.465.0" },
|
||||||
|
},
|
||||||
|
// Scoped packages with preview versions
|
||||||
|
{
|
||||||
|
url: "https://registry.npmjs.org/@babel/core/-/core-8.0.0-alpha.1.tgz",
|
||||||
|
expected: { packageName: "@babel/core", version: "8.0.0-alpha.1" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: "https://registry.npmjs.org/@safe-chain/security-test/-/security-test-1.0.0-security.tgz",
|
||||||
|
expected: {
|
||||||
|
packageName: "@safe-chain/security-test",
|
||||||
|
version: "1.0.0-security",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// Yarn registry
|
||||||
|
{
|
||||||
|
url: "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz",
|
||||||
|
expected: { packageName: "lodash", version: "4.17.21" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: "https://registry.yarnpkg.com/@babel/core/-/core-7.21.4.tgz",
|
||||||
|
expected: { packageName: "@babel/core", version: "7.21.4" },
|
||||||
|
},
|
||||||
|
// Invalid URLs should return undefined values
|
||||||
|
{
|
||||||
|
url: "https://example.com/package.tgz",
|
||||||
|
expected: { packageName: undefined, version: undefined },
|
||||||
|
},
|
||||||
|
// URL to get package info, not tarball
|
||||||
|
{
|
||||||
|
url: "https://registry.npmjs.org/lodash",
|
||||||
|
expected: { packageName: undefined, version: undefined },
|
||||||
|
},
|
||||||
|
// Complex version patterns
|
||||||
|
{
|
||||||
|
url: "https://registry.npmjs.org/package-with-many-hyphens/-/package-with-many-hyphens-1.0.0-rc.1+build.123.tgz",
|
||||||
|
expected: {
|
||||||
|
packageName: "package-with-many-hyphens",
|
||||||
|
version: "1.0.0-rc.1+build.123",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: "https://registry.npmjs.org/@scope/package-name-with-hyphens/-/package-name-with-hyphens-2.0.0-beta.2.tgz",
|
||||||
|
expected: {
|
||||||
|
packageName: "@scope/package-name-with-hyphens",
|
||||||
|
version: "2.0.0-beta.2",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
testCases.forEach(({ url, expected }, index) => {
|
||||||
|
it(`should parse URL ${index + 1}: ${url}`, () => {
|
||||||
|
const result = parsePackageFromUrl(url);
|
||||||
|
assert.deepEqual(result, expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
158
packages/safe-chain/src/registryProxy/registryProxy.js
Normal file
158
packages/safe-chain/src/registryProxy/registryProxy.js
Normal file
|
|
@ -0,0 +1,158 @@
|
||||||
|
import * as http from "http";
|
||||||
|
import { tunnelRequest } from "./tunnelRequestHandler.js";
|
||||||
|
import { mitmConnect } from "./mitmRequestHandler.js";
|
||||||
|
import { getCaCertPath } from "./certUtils.js";
|
||||||
|
import { auditChanges } from "../scanning/audit/index.js";
|
||||||
|
import { knownRegistries, parsePackageFromUrl } from "./parsePackageFromUrl.js";
|
||||||
|
import { ui } from "../environment/userInteraction.js";
|
||||||
|
import chalk from "chalk";
|
||||||
|
|
||||||
|
const SERVER_STOP_TIMEOUT_MS = 1000;
|
||||||
|
const state = {
|
||||||
|
port: null,
|
||||||
|
blockedRequests: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createSafeChainProxy() {
|
||||||
|
const server = createProxyServer();
|
||||||
|
server.on("connect", handleConnect);
|
||||||
|
|
||||||
|
return {
|
||||||
|
startServer: () => startServer(server),
|
||||||
|
stopServer: () => stopServer(server),
|
||||||
|
verifyNoMaliciousPackages,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSafeChainProxyEnvironmentVariables() {
|
||||||
|
if (!state.port) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
HTTPS_PROXY: `http://localhost:${state.port}`,
|
||||||
|
GLOBAL_AGENT_HTTP_PROXY: `http://localhost:${state.port}`,
|
||||||
|
NODE_EXTRA_CA_CERTS: getCaCertPath(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeSafeChainProxyEnvironmentVariables(env) {
|
||||||
|
const proxyEnv = getSafeChainProxyEnvironmentVariables();
|
||||||
|
|
||||||
|
for (const key of Object.keys(env)) {
|
||||||
|
// If we were to simply copy all env variables, we might overwrite
|
||||||
|
// the proxy settings set by safe-chain when casing varies (e.g. http_proxy vs HTTP_PROXY)
|
||||||
|
// So we only copy the variable if it's not already set in a different case
|
||||||
|
const upperKey = key.toUpperCase();
|
||||||
|
|
||||||
|
if (!proxyEnv[upperKey]) {
|
||||||
|
proxyEnv[key] = env[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return proxyEnv;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createProxyServer() {
|
||||||
|
const server = http.createServer((_, res) => {
|
||||||
|
res.writeHead(400, "Bad Request");
|
||||||
|
res.write(
|
||||||
|
"Safe-chain proxy: Direct http not supported. Only CONNECT requests are allowed."
|
||||||
|
);
|
||||||
|
res.end();
|
||||||
|
});
|
||||||
|
|
||||||
|
return server;
|
||||||
|
}
|
||||||
|
|
||||||
|
function startServer(server) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
// Passing port 0 makes the OS assign an available port
|
||||||
|
server.listen(0, () => {
|
||||||
|
const address = server.address();
|
||||||
|
if (address && typeof address === "object") {
|
||||||
|
state.port = address.port;
|
||||||
|
resolve();
|
||||||
|
} else {
|
||||||
|
reject(new Error("Failed to start proxy server"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
server.on("error", (err) => {
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopServer(server) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
try {
|
||||||
|
server.close(() => {
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
setTimeout(() => resolve(), SERVER_STOP_TIMEOUT_MS);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleConnect(req, clientSocket, head) {
|
||||||
|
// CONNECT method is used for HTTPS requests
|
||||||
|
// It establishes a tunnel to the server identified by the request URL
|
||||||
|
|
||||||
|
if (knownRegistries.some((reg) => req.url.includes(reg))) {
|
||||||
|
// For npm and yarn registries, we want to intercept and inspect the traffic
|
||||||
|
// so we can block packages with malware
|
||||||
|
mitmConnect(req, clientSocket, isAllowedUrl);
|
||||||
|
} else {
|
||||||
|
// For other hosts, just tunnel the request to the destination tcp socket
|
||||||
|
tunnelRequest(req, clientSocket, head);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function isAllowedUrl(url) {
|
||||||
|
const { packageName, version } = parsePackageFromUrl(url);
|
||||||
|
|
||||||
|
// packageName and version are undefined when the URL is not a package download
|
||||||
|
// In that case, we can allow the request to proceed
|
||||||
|
if (!packageName || !version) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const auditResult = await auditChanges([
|
||||||
|
{ name: packageName, version, type: "add" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!auditResult.isAllowed) {
|
||||||
|
state.blockedRequests.push({ packageName, version, url });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifyNoMaliciousPackages() {
|
||||||
|
if (state.blockedRequests.length === 0) {
|
||||||
|
// No malicious packages were blocked, so nothing to block
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.emptyLine();
|
||||||
|
|
||||||
|
ui.writeInformation(
|
||||||
|
`Safe-chain: ${chalk.bold(
|
||||||
|
`blocked ${state.blockedRequests.length} malicious package downloads`
|
||||||
|
)}:`
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const req of state.blockedRequests) {
|
||||||
|
ui.writeInformation(` - ${req.packageName}@${req.version} (${req.url})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.emptyLine();
|
||||||
|
ui.writeError("Exiting without installing malicious packages.");
|
||||||
|
ui.emptyLine();
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,98 @@
|
||||||
|
import * as net from "net";
|
||||||
|
import { ui } from "../environment/userInteraction.js";
|
||||||
|
|
||||||
|
export function tunnelRequest(req, clientSocket, head) {
|
||||||
|
const httpsProxy = process.env.HTTPS_PROXY || process.env.https_proxy;
|
||||||
|
|
||||||
|
if (httpsProxy) {
|
||||||
|
// If an HTTPS proxy is set, tunnel the request via the proxy
|
||||||
|
// This is the system proxy, not the safe-chain proxy
|
||||||
|
// The package manager will run via the safe-chain proxy
|
||||||
|
// The safe-chain proxy will then send the request to the system proxy
|
||||||
|
// Typical flow: package manager -> safe-chain proxy -> system proxy -> destination
|
||||||
|
|
||||||
|
// There are 2 processes involved in this:
|
||||||
|
// 1. Safe-chain process: has HTTPS_PROXY set to system proxy
|
||||||
|
// 2. Package manager process: has HTTPS_PROXY set to safe-chain proxy
|
||||||
|
|
||||||
|
tunnelRequestViaProxy(req, clientSocket, head, httpsProxy);
|
||||||
|
} else {
|
||||||
|
tunnelRequestToDestination(req, clientSocket, head);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function tunnelRequestToDestination(req, clientSocket, head) {
|
||||||
|
const { port, hostname } = new URL(`http://${req.url}`);
|
||||||
|
|
||||||
|
const serverSocket = net.connect(port || 443, hostname, () => {
|
||||||
|
clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
|
||||||
|
serverSocket.write(head);
|
||||||
|
serverSocket.pipe(clientSocket);
|
||||||
|
clientSocket.pipe(serverSocket);
|
||||||
|
});
|
||||||
|
|
||||||
|
serverSocket.on("error", (err) => {
|
||||||
|
ui.writeError(
|
||||||
|
`Safe-chain: error connecting to ${hostname}:${port} - ${err.message}`
|
||||||
|
);
|
||||||
|
clientSocket.end("HTTP/1.1 502 Bad Gateway\r\n\r\n");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function tunnelRequestViaProxy(req, clientSocket, head, proxyUrl) {
|
||||||
|
const { port, hostname } = new URL(`http://${req.url}`);
|
||||||
|
const proxy = new URL(proxyUrl);
|
||||||
|
|
||||||
|
// Connect to proxy server
|
||||||
|
const proxySocket = net.connect({
|
||||||
|
host: proxy.hostname,
|
||||||
|
port: proxy.port,
|
||||||
|
});
|
||||||
|
|
||||||
|
proxySocket.on("connect", () => {
|
||||||
|
// Send CONNECT request to proxy
|
||||||
|
const connectRequest = [
|
||||||
|
`CONNECT ${hostname}:${port || 443} HTTP/1.1`,
|
||||||
|
`Host: ${hostname}:${port || 443}`,
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
].join("\r\n");
|
||||||
|
|
||||||
|
proxySocket.write(connectRequest);
|
||||||
|
});
|
||||||
|
|
||||||
|
let isConnected = false;
|
||||||
|
proxySocket.once("data", (data) => {
|
||||||
|
const response = data.toString();
|
||||||
|
|
||||||
|
// Check if CONNECT succeeded (HTTP/1.1 200)
|
||||||
|
if (response.startsWith("HTTP/1.1 200")) {
|
||||||
|
isConnected = true;
|
||||||
|
clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
|
||||||
|
proxySocket.write(head);
|
||||||
|
proxySocket.pipe(clientSocket);
|
||||||
|
clientSocket.pipe(proxySocket);
|
||||||
|
} else {
|
||||||
|
ui.writeError(
|
||||||
|
`Safe-chain: proxy CONNECT failed: ${response.split("\r\n")[0]}`
|
||||||
|
);
|
||||||
|
clientSocket.end("HTTP/1.1 502 Bad Gateway\r\n\r\n");
|
||||||
|
proxySocket.end();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
proxySocket.on("error", (err) => {
|
||||||
|
if (!isConnected) {
|
||||||
|
ui.writeError(
|
||||||
|
`Safe-chain: error connecting to proxy ${proxy.hostname}:${
|
||||||
|
proxy.port || 8080
|
||||||
|
} - ${err.message}`
|
||||||
|
);
|
||||||
|
clientSocket.end("HTTP/1.1 502 Bad Gateway\r\n\r\n");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
clientSocket.on("error", () => {
|
||||||
|
proxySocket.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -61,10 +61,11 @@ export async function scanCommand(args) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!audit || audit.isAllowed) {
|
if (!audit || audit.isAllowed) {
|
||||||
spinner.succeed("Safe-chain: No malicious packages detected.");
|
spinner.stop();
|
||||||
|
return 0;
|
||||||
} else {
|
} else {
|
||||||
printMaliciousChanges(audit.disallowedChanges, spinner);
|
printMaliciousChanges(audit.disallowedChanges, spinner);
|
||||||
await onMalwareFound();
|
return await onMalwareFound();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -88,11 +89,11 @@ async function onMalwareFound() {
|
||||||
|
|
||||||
if (continueInstall) {
|
if (continueInstall) {
|
||||||
ui.writeWarning("Continuing with the installation despite the risks...");
|
ui.writeWarning("Continuing with the installation despite the risks...");
|
||||||
return;
|
return 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ui.writeError("Exiting without installing malicious packages.");
|
ui.writeError("Exiting without installing malicious packages.");
|
||||||
ui.emptyLine();
|
ui.emptyLine();
|
||||||
process.exit(1);
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { describe, it, mock } from "node:test";
|
import { beforeEach, describe, it, mock } from "node:test";
|
||||||
import { setTimeout } from "node:timers/promises";
|
import { setTimeout } from "node:timers/promises";
|
||||||
import {
|
import {
|
||||||
MALWARE_ACTION_PROMPT,
|
MALWARE_ACTION_PROMPT,
|
||||||
|
|
@ -13,6 +13,7 @@ describe("scanCommand", async () => {
|
||||||
setText: () => {},
|
setText: () => {},
|
||||||
succeed: () => {},
|
succeed: () => {},
|
||||||
fail: () => {},
|
fail: () => {},
|
||||||
|
stop: () => {},
|
||||||
}));
|
}));
|
||||||
const mockConfirm = mock.fn(() => true);
|
const mockConfirm = mock.fn(() => true);
|
||||||
let malwareAction = MALWARE_ACTION_PROMPT;
|
let malwareAction = MALWARE_ACTION_PROMPT;
|
||||||
|
|
@ -87,30 +88,37 @@ describe("scanCommand", async () => {
|
||||||
|
|
||||||
const { scanCommand } = await import("./index.js");
|
const { scanCommand } = await import("./index.js");
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Reset malware action back to prompt mode for other tests
|
||||||
|
malwareAction = MALWARE_ACTION_PROMPT;
|
||||||
|
});
|
||||||
|
|
||||||
it("should succeed when there are no changes", async () => {
|
it("should succeed when there are no changes", async () => {
|
||||||
let successMessageWasSet = false;
|
let progressWasStopped = false;
|
||||||
mockStartProcess.mock.mockImplementationOnce(() => ({
|
mockStartProcess.mock.mockImplementationOnce(() => ({
|
||||||
setText: () => {},
|
setText: () => {},
|
||||||
succeed: () => {
|
succeed: () => {},
|
||||||
successMessageWasSet = true;
|
|
||||||
},
|
|
||||||
fail: () => {},
|
fail: () => {},
|
||||||
|
stop: () => {
|
||||||
|
progressWasStopped = true;
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
mockGetDependencyUpdatesForCommand.mock.mockImplementation(() => []);
|
mockGetDependencyUpdatesForCommand.mock.mockImplementation(() => []);
|
||||||
|
|
||||||
await scanCommand(["install", "lodash"]);
|
await scanCommand(["install", "lodash"]);
|
||||||
|
|
||||||
assert.equal(successMessageWasSet, true);
|
assert.equal(progressWasStopped, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should succeed when changes are not malicious", async () => {
|
it("should succeed when changes are not malicious", async () => {
|
||||||
let successMessageWasSet = false;
|
let progressWasStopped = false;
|
||||||
mockStartProcess.mock.mockImplementationOnce(() => ({
|
mockStartProcess.mock.mockImplementationOnce(() => ({
|
||||||
setText: () => {},
|
setText: () => {},
|
||||||
succeed: () => {
|
succeed: () => {},
|
||||||
successMessageWasSet = true;
|
|
||||||
},
|
|
||||||
fail: () => {},
|
fail: () => {},
|
||||||
|
stop: () => {
|
||||||
|
progressWasStopped = true;
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
mockGetDependencyUpdatesForCommand.mock.mockImplementation(() => [
|
mockGetDependencyUpdatesForCommand.mock.mockImplementation(() => [
|
||||||
{ name: "lodash", version: "4.17.21" },
|
{ name: "lodash", version: "4.17.21" },
|
||||||
|
|
@ -118,7 +126,7 @@ describe("scanCommand", async () => {
|
||||||
|
|
||||||
await scanCommand(["install", "lodash"]);
|
await scanCommand(["install", "lodash"]);
|
||||||
|
|
||||||
assert.equal(successMessageWasSet, true);
|
assert.equal(progressWasStopped, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should throw an error when timing out", async () => {
|
it("should throw an error when timing out", async () => {
|
||||||
|
|
@ -129,6 +137,7 @@ describe("scanCommand", async () => {
|
||||||
fail: () => {
|
fail: () => {
|
||||||
failureMessageWasSet = true;
|
failureMessageWasSet = true;
|
||||||
},
|
},
|
||||||
|
stop: () => {},
|
||||||
}));
|
}));
|
||||||
getScanTimeoutMock.mock.mockImplementationOnce(() => 100);
|
getScanTimeoutMock.mock.mockImplementationOnce(() => 100);
|
||||||
mockGetDependencyUpdatesForCommand.mock.mockImplementation(async () => {
|
mockGetDependencyUpdatesForCommand.mock.mockImplementation(async () => {
|
||||||
|
|
@ -149,6 +158,7 @@ describe("scanCommand", async () => {
|
||||||
fail: () => {
|
fail: () => {
|
||||||
failureMessageWasSet = true;
|
failureMessageWasSet = true;
|
||||||
},
|
},
|
||||||
|
stop: () => {},
|
||||||
}));
|
}));
|
||||||
mockGetDependencyUpdatesForCommand.mock.mockImplementation(() => [
|
mockGetDependencyUpdatesForCommand.mock.mockImplementation(() => [
|
||||||
{ name: "malicious", version: "1.0.0" },
|
{ name: "malicious", version: "1.0.0" },
|
||||||
|
|
@ -173,6 +183,7 @@ describe("scanCommand", async () => {
|
||||||
fail: (message) => {
|
fail: (message) => {
|
||||||
failureMessages.push(message);
|
failureMessages.push(message);
|
||||||
},
|
},
|
||||||
|
stop: () => {},
|
||||||
}));
|
}));
|
||||||
getScanTimeoutMock.mock.mockImplementationOnce(() => 100);
|
getScanTimeoutMock.mock.mockImplementationOnce(() => 100);
|
||||||
mockGetDependencyUpdatesForCommand.mock.mockImplementation(async () => {
|
mockGetDependencyUpdatesForCommand.mock.mockImplementation(async () => {
|
||||||
|
|
@ -199,7 +210,6 @@ describe("scanCommand", async () => {
|
||||||
mockConfirm.mock.resetCalls();
|
mockConfirm.mock.resetCalls();
|
||||||
|
|
||||||
let failureMessageWasSet = false;
|
let failureMessageWasSet = false;
|
||||||
let exitCode = null;
|
|
||||||
|
|
||||||
mockStartProcess.mock.mockImplementationOnce(() => ({
|
mockStartProcess.mock.mockImplementationOnce(() => ({
|
||||||
setText: () => {},
|
setText: () => {},
|
||||||
|
|
@ -207,33 +217,17 @@ describe("scanCommand", async () => {
|
||||||
fail: () => {
|
fail: () => {
|
||||||
failureMessageWasSet = true;
|
failureMessageWasSet = true;
|
||||||
},
|
},
|
||||||
|
stop: () => {},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
mockGetDependencyUpdatesForCommand.mock.mockImplementation(() => [
|
mockGetDependencyUpdatesForCommand.mock.mockImplementation(() => [
|
||||||
{ name: "malicious", version: "1.0.0" },
|
{ name: "malicious", version: "1.0.0" },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Mock process.exit
|
const result = await scanCommand(["install", "malicious"]);
|
||||||
const originalExit = process.exit;
|
|
||||||
process.exit = mock.fn((code) => {
|
|
||||||
exitCode = code;
|
|
||||||
throw new Error("Process exit called"); // Prevent actual exit
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
await assert.rejects(
|
|
||||||
scanCommand(["install", "malicious"]),
|
|
||||||
/Process exit called/
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
// Restore original process.exit
|
|
||||||
process.exit = originalExit;
|
|
||||||
// Reset malware action back to prompt mode for other tests
|
|
||||||
malwareAction = MALWARE_ACTION_PROMPT;
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.equal(failureMessageWasSet, true);
|
assert.equal(failureMessageWasSet, true);
|
||||||
assert.equal(exitCode, 1);
|
assert.equal(result, 1);
|
||||||
// Confirm should not have been called in block mode
|
// Confirm should not have been called in block mode
|
||||||
assert.equal(mockConfirm.mock.callCount(), 0);
|
assert.equal(mockConfirm.mock.callCount(), 0);
|
||||||
});
|
});
|
||||||
|
|
@ -245,13 +239,13 @@ describe("scanCommand", async () => {
|
||||||
// Reset mock call count
|
// Reset mock call count
|
||||||
mockConfirm.mock.resetCalls();
|
mockConfirm.mock.resetCalls();
|
||||||
|
|
||||||
let processExited = false;
|
|
||||||
let userWasPrompted = false;
|
let userWasPrompted = false;
|
||||||
|
|
||||||
mockStartProcess.mock.mockImplementationOnce(() => ({
|
mockStartProcess.mock.mockImplementationOnce(() => ({
|
||||||
setText: () => {},
|
setText: () => {},
|
||||||
succeed: () => {},
|
succeed: () => {},
|
||||||
fail: () => {},
|
fail: () => {},
|
||||||
|
stop: () => {},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
mockGetDependencyUpdatesForCommand.mock.mockImplementation(() => [
|
mockGetDependencyUpdatesForCommand.mock.mockImplementation(() => [
|
||||||
|
|
@ -263,26 +257,9 @@ describe("scanCommand", async () => {
|
||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mock process.exit
|
const result = await scanCommand(["install", "malicious"]);
|
||||||
const originalExit = process.exit;
|
|
||||||
process.exit = mock.fn(() => {
|
|
||||||
processExited = true;
|
|
||||||
throw new Error("Process exit called"); // Prevent actual exit
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
assert.equal(result, 1);
|
||||||
await assert.rejects(
|
|
||||||
scanCommand(["install", "malicious"]),
|
|
||||||
/Process exit called/
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
// Restore original process.exit
|
|
||||||
process.exit = originalExit;
|
|
||||||
// Reset malware action back to prompt mode for other tests
|
|
||||||
malwareAction = MALWARE_ACTION_PROMPT;
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.equal(processExited, true);
|
|
||||||
assert.equal(userWasPrompted, false);
|
assert.equal(userWasPrompted, false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,13 @@ import {
|
||||||
} from "../config/configFile.js";
|
} from "../config/configFile.js";
|
||||||
import { ui } from "../environment/userInteraction.js";
|
import { ui } from "../environment/userInteraction.js";
|
||||||
|
|
||||||
|
let cachedMalwareDatabase = null;
|
||||||
|
|
||||||
export async function openMalwareDatabase() {
|
export async function openMalwareDatabase() {
|
||||||
|
if (cachedMalwareDatabase) {
|
||||||
|
return cachedMalwareDatabase;
|
||||||
|
}
|
||||||
|
|
||||||
const malwareDatabase = await getMalwareDatabase();
|
const malwareDatabase = await getMalwareDatabase();
|
||||||
|
|
||||||
function getPackageStatus(name, version) {
|
function getPackageStatus(name, version) {
|
||||||
|
|
@ -25,13 +31,16 @@ export async function openMalwareDatabase() {
|
||||||
return packageData.reason;
|
return packageData.reason;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
// This implicitely caches the malware database
|
||||||
|
// that's closed over by the getPackageStatus function
|
||||||
|
cachedMalwareDatabase = {
|
||||||
getPackageStatus,
|
getPackageStatus,
|
||||||
isMalware: (name, version) => {
|
isMalware: (name, version) => {
|
||||||
const status = getPackageStatus(name, version);
|
const status = getPackageStatus(name, version);
|
||||||
return isMalwareStatus(status);
|
return isMalwareStatus(status);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
return cachedMalwareDatabase;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getMalwareDatabase() {
|
async function getMalwareDatabase() {
|
||||||
|
|
|
||||||
|
|
@ -23,11 +23,23 @@ export async function safeSpawn(command, args, options = {}) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const child = spawn(fullCommand, { ...options, shell: true });
|
const child = spawn(fullCommand, { ...options, shell: true });
|
||||||
|
|
||||||
|
// When stdio is piped, we need to collect the output
|
||||||
|
let stdout = "";
|
||||||
|
let stderr = "";
|
||||||
|
|
||||||
|
child.stdout?.on("data", (data) => {
|
||||||
|
stdout += data.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
child.stderr?.on("data", (data) => {
|
||||||
|
stderr += data.toString();
|
||||||
|
});
|
||||||
|
|
||||||
child.on("close", (code) => {
|
child.on("close", (code) => {
|
||||||
resolve({
|
resolve({
|
||||||
status: code,
|
status: code,
|
||||||
stdout: Buffer.from(""),
|
stdout: stdout,
|
||||||
stderr: Buffer.from(""),
|
stderr: stderr,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,9 @@ ARG PNPM_VERSION=latest
|
||||||
SHELL ["/bin/bash", "-c"]
|
SHELL ["/bin/bash", "-c"]
|
||||||
ENV BASH_ENV=~/.bashrc
|
ENV BASH_ENV=~/.bashrc
|
||||||
|
|
||||||
|
# Install a proxy
|
||||||
|
RUN apt-get update && apt-get install tinyproxy -y
|
||||||
|
|
||||||
# Install zsh
|
# Install zsh
|
||||||
RUN sh -c "$(wget -O- https://github.com/deluan/zsh-in-docker/releases/download/v1.2.1/zsh-in-docker.sh)"
|
RUN sh -c "$(wget -O- https://github.com/deluan/zsh-in-docker/releases/download/v1.2.1/zsh-in-docker.sh)"
|
||||||
# Install fish
|
# Install fish
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ describe("E2E: npm coverage using PATH", () => {
|
||||||
const result = await shell.runCommand("npm i axios");
|
const result = await shell.runCommand("npm i axios");
|
||||||
|
|
||||||
assert.ok(
|
assert.ok(
|
||||||
result.output.includes("No malicious packages detected."),
|
result.output.includes("no malicious packages found."),
|
||||||
`Output did not include expected text. Output was:\n${result.output}`
|
`Output did not include expected text. Output was:\n${result.output}`
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ describe("E2E: npm coverage", () => {
|
||||||
const result = await shell.runCommand("npm i axios");
|
const result = await shell.runCommand("npm i axios");
|
||||||
|
|
||||||
assert.ok(
|
assert.ok(
|
||||||
result.output.includes("No malicious packages detected."),
|
result.output.includes("no malicious packages found."),
|
||||||
`Output did not include expected text. Output was:\n${result.output}`
|
`Output did not include expected text. Output was:\n${result.output}`
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
@ -60,6 +60,52 @@ describe("E2E: npm coverage", () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it(`safe-chain blocks download of malicious packages already in package.json`, async () => {
|
||||||
|
const shell = await container.openShell("zsh");
|
||||||
|
const npmVersion = (await shell.runCommand("npm --version")).output.trim();
|
||||||
|
const majorVersion = parseInt(npmVersion.split(".")[0]);
|
||||||
|
const minorVersion = parseInt(npmVersion.split(".")[1]);
|
||||||
|
const isBelow10_4 =
|
||||||
|
majorVersion < 10 || (majorVersion === 10 && minorVersion < 4);
|
||||||
|
await shell.runCommand(
|
||||||
|
'echo \'{"name":"test-project","version":"1.0.0","dependencies":{"safe-chain-test":"0.0.1-security"}}\' > package.json'
|
||||||
|
);
|
||||||
|
|
||||||
|
var result = await shell.runCommand("npm install");
|
||||||
|
|
||||||
|
if (isBelow10_4) {
|
||||||
|
assert.ok(
|
||||||
|
result.output.includes("blocked 1 malicious package downloads"),
|
||||||
|
`Output did not include expected text. Output was:\n${result.output}`
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
result.output.includes("- safe-chain-test"),
|
||||||
|
`Output did not include expected text. Output was:\n${result.output}`
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
result.output.includes(
|
||||||
|
"Exiting without installing malicious packages."
|
||||||
|
),
|
||||||
|
`Output did not include expected text. Output was:\n${result.output}`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
assert.ok(
|
||||||
|
result.output.includes("Malicious changes detected:"),
|
||||||
|
`Output did not include expected text. Output was:\n${result.output}`
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
result.output.includes("- safe-chain-test"),
|
||||||
|
`Output did not include expected text. Output was:\n${result.output}`
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
result.output.includes(
|
||||||
|
"Exiting without installing malicious packages."
|
||||||
|
),
|
||||||
|
`Output did not include expected text. Output was:\n${result.output}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("safe-chain blocks npx from executing malicious packages", async () => {
|
it("safe-chain blocks npx from executing malicious packages", async () => {
|
||||||
const shell = await container.openShell("zsh");
|
const shell = await container.openShell("zsh");
|
||||||
const result = await shell.runCommand("npx safe-chain-test");
|
const result = await shell.runCommand("npx safe-chain-test");
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "End-to-end tests for the Aikido Safe Chain",
|
"description": "End-to-end tests for the Aikido Safe Chain",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "node --test **/*.spec.js"
|
"test": "node --test --test-concurrency=1 **/*.spec.js"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "Aikido Security",
|
"author": "Aikido Security",
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ describe("E2E: pnpm coverage", () => {
|
||||||
const result = await shell.runCommand("pnpm add axios");
|
const result = await shell.runCommand("pnpm add axios");
|
||||||
|
|
||||||
assert.ok(
|
assert.ok(
|
||||||
result.output.includes("No malicious packages detected."),
|
result.output.includes("no malicious packages found."),
|
||||||
`Output did not include expected text. Output was:\n${result.output}`
|
`Output did not include expected text. Output was:\n${result.output}`
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ describe("E2E: pnpm coverage", () => {
|
||||||
const result = await shell.runCommand("pnpm add axios");
|
const result = await shell.runCommand("pnpm add axios");
|
||||||
|
|
||||||
assert.ok(
|
assert.ok(
|
||||||
result.output.includes("No malicious packages detected."),
|
result.output.includes("no malicious packages found."),
|
||||||
`Output did not include expected text. Output was:\n${result.output}`
|
`Output did not include expected text. Output was:\n${result.output}`
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
@ -60,6 +60,28 @@ describe("E2E: pnpm coverage", () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it(`safe-chain blocks download of malicious packages already in package.json`, async () => {
|
||||||
|
const shell = await container.openShell("zsh");
|
||||||
|
await shell.runCommand(
|
||||||
|
'echo \'{"name":"test-project","version":"1.0.0","dependencies":{"safe-chain-test":"0.0.1-security"}}\' > package.json'
|
||||||
|
);
|
||||||
|
|
||||||
|
var result = await shell.runCommand("pnpm install");
|
||||||
|
|
||||||
|
assert.ok(
|
||||||
|
result.output.includes("blocked 1 malicious package downloads"),
|
||||||
|
`Output did not include expected text. Output was:\n${result.output}`
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
result.output.includes("- safe-chain-test"),
|
||||||
|
`Output did not include expected text. Output was:\n${result.output}`
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
result.output.includes("Exiting without installing malicious packages."),
|
||||||
|
`Output did not include expected text. Output was:\n${result.output}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("safe-chain blocks pnpx from executing malicious packages", async () => {
|
it("safe-chain blocks pnpx from executing malicious packages", async () => {
|
||||||
const shell = await container.openShell("zsh");
|
const shell = await container.openShell("zsh");
|
||||||
const result = await shell.runCommand("pnpx safe-chain-test");
|
const result = await shell.runCommand("pnpx safe-chain-test");
|
||||||
|
|
|
||||||
60
test/e2e/safe-chain-proxy.e2e.spec.js
Normal file
60
test/e2e/safe-chain-proxy.e2e.spec.js
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
import { describe, it, before, beforeEach, afterEach } from "node:test";
|
||||||
|
import { DockerTestContainer } from "./DockerTestContainer.js";
|
||||||
|
import assert from "node:assert";
|
||||||
|
|
||||||
|
describe("E2E: Safe chain proxy", () => {
|
||||||
|
let container;
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
DockerTestContainer.buildImage();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
// Run a new Docker container for each test
|
||||||
|
container = new DockerTestContainer();
|
||||||
|
await container.start();
|
||||||
|
|
||||||
|
const installationShell = await container.openShell("zsh");
|
||||||
|
await installationShell.runCommand("safe-chain setup");
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
// Stop and clean up the container after each test
|
||||||
|
if (container) {
|
||||||
|
await container.stop();
|
||||||
|
container = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it(`safe-chain proxy respects upstream proxy settings`, async () => {
|
||||||
|
// Configure and start a proxy inside the container
|
||||||
|
const proxy = await container.openShell("zsh");
|
||||||
|
await proxy.runCommand(
|
||||||
|
`echo 'BasicAuth user password' >> /etc/tinyproxy/tinyproxy.conf`
|
||||||
|
);
|
||||||
|
await proxy.runCommand("tinyproxy");
|
||||||
|
|
||||||
|
const shell = await container.openShell("zsh");
|
||||||
|
await shell.runCommand(
|
||||||
|
'export HTTPS_PROXY="http://user:password@localhost:8888"'
|
||||||
|
);
|
||||||
|
const { output } = await shell.runCommand("npm install axios");
|
||||||
|
|
||||||
|
// Check if the installation was successful
|
||||||
|
assert(
|
||||||
|
output.includes("added") || output.includes("up to date"),
|
||||||
|
"npm install did not complete successfully"
|
||||||
|
);
|
||||||
|
|
||||||
|
const proxyLog = await container.openShell("zsh");
|
||||||
|
const { output: logOutput } = await proxyLog.runCommand(
|
||||||
|
"cat /var/log/tinyproxy/tinyproxy.log"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check if the proxy log contains entries for the npm install
|
||||||
|
assert(
|
||||||
|
logOutput.includes("CONNECT registry.npmjs.org:443"),
|
||||||
|
"Proxy log does not contain expected entries"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -36,7 +36,7 @@ describe("E2E: yarn coverage", () => {
|
||||||
const result = await shell.runCommand("yarn add axios");
|
const result = await shell.runCommand("yarn add axios");
|
||||||
|
|
||||||
assert.ok(
|
assert.ok(
|
||||||
result.output.includes("No malicious packages detected."),
|
result.output.includes("no malicious packages found."),
|
||||||
`Output did not include expected text. Output was:\n${result.output}`
|
`Output did not include expected text. Output was:\n${result.output}`
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,53 @@ describe("E2E: yarn coverage", () => {
|
||||||
const result = await shell.runCommand("yarn add axios");
|
const result = await shell.runCommand("yarn add axios");
|
||||||
|
|
||||||
assert.ok(
|
assert.ok(
|
||||||
result.output.includes("No malicious packages detected."),
|
result.output.includes("no malicious packages found."),
|
||||||
|
`Output did not include expected text. Output was:\n${result.output}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it(`safe-chain blocks installation of malicious packages`, async () => {
|
||||||
|
const shell = await container.openShell("zsh");
|
||||||
|
const result = await shell.runCommand("yarn add safe-chain-test");
|
||||||
|
|
||||||
|
assert.ok(
|
||||||
|
result.output.includes("Malicious changes detected:"),
|
||||||
|
`Output did not include expected text. Output was:\n${result.output}`
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
result.output.includes("- safe-chain-test"),
|
||||||
|
`Output did not include expected text. Output was:\n${result.output}`
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
result.output.includes("Exiting without installing malicious packages."),
|
||||||
|
`Output did not include expected text. Output was:\n${result.output}`
|
||||||
|
);
|
||||||
|
|
||||||
|
const listResult = await shell.runCommand("yarn list");
|
||||||
|
assert.ok(
|
||||||
|
!listResult.output.includes("safe-chain-test"),
|
||||||
|
`Malicious package was installed despite safe-chain protection. Output of 'yarn list' was:\n${listResult.output}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it(`safe-chain blocks download of malicious packages already in package.json`, async () => {
|
||||||
|
const shell = await container.openShell("zsh");
|
||||||
|
await shell.runCommand(
|
||||||
|
'echo \'{"name":"test-project","version":"1.0.0","dependencies":{"safe-chain-test":"0.0.1-security"}}\' > package.json'
|
||||||
|
);
|
||||||
|
|
||||||
|
var result = await shell.runCommand("yarn");
|
||||||
|
|
||||||
|
assert.ok(
|
||||||
|
result.output.includes("blocked 1 malicious package downloads"),
|
||||||
|
`Output did not include expected text. Output was:\n${result.output}`
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
result.output.includes("- safe-chain-test"),
|
||||||
|
`Output did not include expected text. Output was:\n${result.output}`
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
result.output.includes("Exiting without installing malicious packages."),
|
||||||
`Output did not include expected text. Output was:\n${result.output}`
|
`Output did not include expected text. Output was:\n${result.output}`
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue