mirror of
https://github.com/AikidoSec/safe-chain.git
synced 2026-05-26 12:10:49 +00:00
Adapt per review
This commit is contained in:
parent
9dacf5cff3
commit
190607de92
27 changed files with 191 additions and 114 deletions
|
|
@ -1,13 +1,13 @@
|
|||
import fetch from "make-fetch-happen";
|
||||
import { getEcoSystem } from "../config/settings.js";
|
||||
import { getEcoSystem, ECOSYSTEM_JS, ECOSYSTEM_PY } from "../config/settings.js";
|
||||
|
||||
const malwareDatabaseUrls = {
|
||||
js: "https://malware-list.aikido.dev/malware_predictions.json",
|
||||
py: "https://malware-list.aikido.dev/malware_pypi.json",
|
||||
[ECOSYSTEM_JS]: "https://malware-list.aikido.dev/malware_predictions.json",
|
||||
[ECOSYSTEM_PY]: "https://malware-list.aikido.dev/malware_pypi.json",
|
||||
};
|
||||
|
||||
export async function fetchMalwareDatabase() {
|
||||
const ecosystem = getEcoSystem() || "js";
|
||||
const ecosystem = getEcoSystem();
|
||||
const malwareDatabaseUrl = malwareDatabaseUrls[ecosystem];
|
||||
const response = await fetch(malwareDatabaseUrl);
|
||||
if (!response.ok) {
|
||||
|
|
@ -26,8 +26,7 @@ export async function fetchMalwareDatabase() {
|
|||
}
|
||||
|
||||
export async function fetchMalwareDatabaseVersion() {
|
||||
const ecosystem = getEcoSystem() || "js";
|
||||
|
||||
const ecosystem = getEcoSystem();
|
||||
const malwareDatabaseUrl = malwareDatabaseUrls[ecosystem];
|
||||
const response = await fetch(malwareDatabaseUrl, {
|
||||
method: "HEAD",
|
||||
|
|
|
|||
|
|
@ -69,13 +69,13 @@ function readConfigFile() {
|
|||
|
||||
function getDatabasePath() {
|
||||
const aikidoDir = getAikidoDirectory();
|
||||
const ecosystem = getEcoSystem() || "js";
|
||||
const ecosystem = getEcoSystem();
|
||||
return path.join(aikidoDir, `malwareDatabase_${ecosystem}.json`);
|
||||
}
|
||||
|
||||
function getDatabaseVersionPath() {
|
||||
const aikidoDir = getAikidoDirectory();
|
||||
const ecosystem = getEcoSystem() || "js";
|
||||
const ecosystem = getEcoSystem();
|
||||
return path.join(aikidoDir, `version_${ecosystem}.txt`);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -13,9 +13,12 @@ export function getMalwareAction() {
|
|||
export const MALWARE_ACTION_BLOCK = "block";
|
||||
export const MALWARE_ACTION_PROMPT = "prompt";
|
||||
|
||||
export const ECOSYSTEM_JS = "js";
|
||||
export const ECOSYSTEM_PY = "py";
|
||||
|
||||
// Default to JavaScript ecosystem
|
||||
const ecosystemSettings = {
|
||||
ecoSystem: "js",
|
||||
ecoSystem: ECOSYSTEM_JS,
|
||||
};
|
||||
|
||||
export function getEcoSystem() {
|
||||
|
|
|
|||
|
|
@ -19,34 +19,3 @@ export async function runPip(command, args) {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function dryRunPipCommandAndOutput(command, args) {
|
||||
try {
|
||||
// Note: pip supports --dry-run for the "install" command only; "download" and "wheel" do not.
|
||||
// We don't mutate args here — callers should include --dry-run when appropriate.
|
||||
const result = await safeSpawnPy(
|
||||
command,
|
||||
args,
|
||||
{
|
||||
stdio: "pipe",
|
||||
env: mergeSafeChainProxyEnvironmentVariables(process.env),
|
||||
}
|
||||
);
|
||||
return {
|
||||
status: result.status,
|
||||
output: result.status === 0 ? result.stdout : result.stderr,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error.status) {
|
||||
const output =
|
||||
error.stdout?.toString() ??
|
||||
error.stderr?.toString() ??
|
||||
error.message ??
|
||||
"";
|
||||
return { status: error.status, output };
|
||||
} else {
|
||||
ui.writeError("Error executing command:", error.message);
|
||||
return { status: 1 };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,20 +1,26 @@
|
|||
import { getEcoSystem, ECOSYSTEM_JS, ECOSYSTEM_PY } from "../config/settings.js";
|
||||
|
||||
export const knownJsRegistries = ["registry.npmjs.org","registry.yarnpkg.com"];
|
||||
export const knownPipRegistries = ["files.pythonhosted.org", "pypi.org", "pypi.python.org", "pythonhosted.org"];
|
||||
|
||||
export function parsePackageFromUrl(url) {
|
||||
const ecosystem = getEcoSystem();
|
||||
let registry;
|
||||
|
||||
for (const knownRegistry of knownJsRegistries) {
|
||||
if (url.includes(knownRegistry)) {
|
||||
registry = knownRegistry;
|
||||
return parseJsPackageFromUrl(url, registry);
|
||||
// Only check registries that match the current ecosystem
|
||||
if (ecosystem === ECOSYSTEM_JS) {
|
||||
for (const knownRegistry of knownJsRegistries) {
|
||||
if (url.includes(knownRegistry)) {
|
||||
registry = knownRegistry;
|
||||
return parseJsPackageFromUrl(url, registry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const knownRegistry of knownPipRegistries) {
|
||||
if (url.includes(knownRegistry)) {
|
||||
registry = knownRegistry;
|
||||
return parsePipPackageFromUrl(url, registry);
|
||||
} else if (ecosystem === ECOSYSTEM_PY) {
|
||||
for (const knownRegistry of knownPipRegistries) {
|
||||
if (url.includes(knownRegistry)) {
|
||||
registry = knownRegistry;
|
||||
return parsePipPackageFromUrl(url, registry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -70,21 +76,25 @@ function parsePipPackageFromUrl(url, registry) {
|
|||
}
|
||||
|
||||
// Quick sanity check on the URL + parse
|
||||
let u;
|
||||
let urlObj;
|
||||
try {
|
||||
u = new URL(url);
|
||||
urlObj = new URL(url);
|
||||
} catch {
|
||||
return { packageName, version};
|
||||
}
|
||||
|
||||
// Get the last path segment (filename) and decode it (strip query & fragment automatically)
|
||||
const lastSegment = u.pathname.split("/").filter(Boolean).pop();
|
||||
const lastSegment = urlObj.pathname.split("/").filter(Boolean).pop();
|
||||
if (!lastSegment){
|
||||
return { packageName, version};
|
||||
}
|
||||
|
||||
const filename = decodeURIComponent(lastSegment);
|
||||
|
||||
// Parse Python package downloads from PyPI/pythonhosted.org
|
||||
// Example wheel: https://files.pythonhosted.org/packages/xx/yy/requests-2.28.1-py3-none-any.whl
|
||||
// Example sdist: https://files.pythonhosted.org/packages/xx/yy/requests-2.28.1.tar.gz
|
||||
|
||||
// Wheel (.whl)
|
||||
if (filename.endsWith(".whl")) {
|
||||
const base = filename.slice(0, -4); // remove ".whl"
|
||||
|
|
@ -96,6 +106,9 @@ function parsePipPackageFromUrl(url, registry) {
|
|||
const rawVersion = secondDash >= 0 ? rest.slice(0, secondDash) : rest;
|
||||
packageName = dist; // preserve underscores
|
||||
version = rawVersion;
|
||||
// Reject "latest" as it's a placeholder, not a real version
|
||||
// When version is "latest", this signals the URL doesn't contain actual version info
|
||||
// Returning undefined allows the request (see registryProxy.js isAllowedUrl)
|
||||
if (version === "latest" || !packageName || !version) {
|
||||
return { packageName: undefined, version: undefined };
|
||||
}
|
||||
|
|
@ -111,6 +124,9 @@ function parsePipPackageFromUrl(url, registry) {
|
|||
if (lastDash > 0 && lastDash < base.length - 1) {
|
||||
packageName = base.slice(0, lastDash);
|
||||
version = base.slice(lastDash + 1);
|
||||
// Reject "latest" as it's a placeholder, not a real version
|
||||
// When version is "latest", this signals the URL doesn't contain actual version info
|
||||
// Returning undefined allows the request (see registryProxy.js isAllowedUrl)
|
||||
if (version === "latest" || !packageName || !version) {
|
||||
return { packageName: undefined, version: undefined };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,13 @@
|
|||
import { describe, it } from "node:test";
|
||||
import { describe, it, beforeEach } from "node:test";
|
||||
import assert from "node:assert";
|
||||
import { parsePackageFromUrl } from "./parsePackageFromUrl.js";
|
||||
import { setEcoSystem, ECOSYSTEM_JS, ECOSYSTEM_PY } from "../config/settings.js";
|
||||
|
||||
describe("parsePackageFromUrl", () => {
|
||||
beforeEach(() => {
|
||||
setEcoSystem(ECOSYSTEM_JS);
|
||||
});
|
||||
|
||||
const testCases = [
|
||||
// Regular packages
|
||||
{
|
||||
|
|
@ -114,6 +119,10 @@ describe("parsePackageFromUrl", () => {
|
|||
});
|
||||
|
||||
describe("parsePackageFromUrl - pip URLs", () => {
|
||||
beforeEach(() => {
|
||||
setEcoSystem(ECOSYSTEM_PY);
|
||||
});
|
||||
|
||||
const pipTestCases = [
|
||||
// Valid pip URLs
|
||||
{
|
||||
|
|
|
|||
|
|
@ -49,6 +49,32 @@ describe("registryProxy.connectTunnel", () => {
|
|||
socket.destroy();
|
||||
});
|
||||
|
||||
it("should use destination's real certificate (not safe-chain's self-signed CA)", async () => {
|
||||
const socket = await connectToProxy(proxyHost, proxyPort);
|
||||
await establishHttpsTunnel(socket, "postman-echo.com", 443);
|
||||
|
||||
// Verifies that tunnel requests pass through the destination's real certificate
|
||||
// without interception by the safe-chain MITM proxy.
|
||||
const certInfo = await getTlsCertificateInfo(
|
||||
socket,
|
||||
new URL("https://postman-echo.com")
|
||||
);
|
||||
|
||||
// Verify the certificate is NOT issued by our safe-chain CA
|
||||
// Our self-signed CA would have issuer: "Safe-Chain Proxy CA"
|
||||
assert.ok(certInfo.issuer !== undefined, "Certificate should have an issuer");
|
||||
assert.ok(
|
||||
!certInfo.issuer.includes("Safe-Chain"),
|
||||
`Tunnel should use destination's real certificate, not safe-chain CA. Issuer: ${certInfo.issuer}`
|
||||
);
|
||||
|
||||
// Verify it's a real certificate with proper hostname
|
||||
assert.strictEqual(certInfo.subject.includes("postman-echo.com"), true,
|
||||
`Certificate subject should include postman-echo.com, got: ${certInfo.subject}`);
|
||||
|
||||
socket.destroy();
|
||||
});
|
||||
|
||||
describe("Error Handling", () => {
|
||||
it("should return 502 Bad Gateway for invalid hostname", async () => {
|
||||
const socket = await connectToProxy(proxyHost, proxyPort);
|
||||
|
|
@ -141,7 +167,7 @@ function establishHttpsTunnel(socket, targetHost, targetPort) {
|
|||
});
|
||||
}
|
||||
|
||||
function sendHttpsRequestThroughTunnel(socket, verb, url) {
|
||||
function sendHttpsRequestThroughTunnel(socket, verb, url, rejectUnauthorized = false) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const tlsSocket = tls.connect(
|
||||
{
|
||||
|
|
@ -149,7 +175,7 @@ function sendHttpsRequestThroughTunnel(socket, verb, url) {
|
|||
servername: url.hostname,
|
||||
// Tests should focus on tunnel behavior, not system CA state;
|
||||
// disable CA verification to avoid flakiness on machines without full roots.
|
||||
rejectUnauthorized: false,
|
||||
rejectUnauthorized: rejectUnauthorized,
|
||||
},
|
||||
() => {
|
||||
tlsSocket.write(
|
||||
|
|
@ -173,3 +199,35 @@ function sendHttpsRequestThroughTunnel(socket, verb, url) {
|
|||
});
|
||||
});
|
||||
}
|
||||
|
||||
function getTlsCertificateInfo(socket, url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const tlsSocket = tls.connect(
|
||||
{
|
||||
socket: socket,
|
||||
servername: url.hostname,
|
||||
// Don't reject unauthorized to avoid system CA issues in CI
|
||||
// We just want to inspect the certificate
|
||||
rejectUnauthorized: false,
|
||||
},
|
||||
() => {
|
||||
const cert = tlsSocket.getPeerCertificate();
|
||||
|
||||
// Extract issuer and subject information
|
||||
const issuer = cert.issuer ?
|
||||
Object.entries(cert.issuer).map(([k, v]) => `${k}=${v}`).join(", ") :
|
||||
"unknown";
|
||||
const subject = cert.subject ?
|
||||
Object.entries(cert.subject).map(([k, v]) => `${k}=${v}`).join(", ") :
|
||||
"unknown";
|
||||
|
||||
tlsSocket.end();
|
||||
resolve({ issuer, subject });
|
||||
}
|
||||
);
|
||||
|
||||
tlsSocket.on("error", (err) => {
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { handleHttpProxyRequest } from "./plainHttpProxy.js";
|
|||
import { getCaCertPath } from "./certUtils.js";
|
||||
import { auditChanges } from "../scanning/audit/index.js";
|
||||
import { knownJsRegistries, knownPipRegistries, parsePackageFromUrl } from "./parsePackageFromUrl.js";
|
||||
import { getEcoSystem, ECOSYSTEM_JS, ECOSYSTEM_PY } from "../config/settings.js";
|
||||
import { ui } from "../environment/userInteraction.js";
|
||||
import chalk from "chalk";
|
||||
|
||||
|
|
@ -111,9 +112,18 @@ 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 ((knownJsRegistries.some((reg) => req.url.includes(reg)))
|
||||
|| (knownPipRegistries.some((reg) => req.url.includes(reg)))) {
|
||||
mitmConnect(req, clientSocket, isAllowedUrl);
|
||||
const ecosystem = getEcoSystem();
|
||||
const url = req.url || "";
|
||||
|
||||
let isKnownRegistry = false;
|
||||
if (ecosystem === ECOSYSTEM_JS) {
|
||||
isKnownRegistry = knownJsRegistries.some((reg) => url.includes(reg));
|
||||
} else if (ecosystem === ECOSYSTEM_PY) {
|
||||
isKnownRegistry = knownPipRegistries.some((reg) => url.includes(reg));
|
||||
}
|
||||
|
||||
if (isKnownRegistry) {
|
||||
mitmConnect(req, clientSocket, isAllowedUrl);
|
||||
} else {
|
||||
// For other hosts, just tunnel the request to the destination tcp socket
|
||||
tunnelRequest(req, clientSocket, head);
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
mergeSafeChainProxyEnvironmentVariables,
|
||||
} from "./registryProxy.js";
|
||||
import { getCaCertPath } from "./certUtils.js";
|
||||
import { setEcoSystem, ECOSYSTEM_JS, ECOSYSTEM_PY } from "../config/settings.js";
|
||||
import fs from "fs";
|
||||
|
||||
describe("registryProxy.mitm", () => {
|
||||
|
|
@ -19,6 +20,8 @@ describe("registryProxy.mitm", () => {
|
|||
const proxyUrl = new URL(envVars.HTTPS_PROXY);
|
||||
proxyHost = proxyUrl.hostname;
|
||||
proxyPort = parseInt(proxyUrl.port, 10);
|
||||
// Default to JS ecosystem for JS registry tests
|
||||
setEcoSystem(ECOSYSTEM_JS);
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
|
|
@ -151,6 +154,8 @@ describe("registryProxy.mitm", () => {
|
|||
});
|
||||
|
||||
it("should intercept HTTPS requests to pypi.org for pip package", async () => {
|
||||
// Switch to Python ecosystem for pip registry MITM tests
|
||||
setEcoSystem(ECOSYSTEM_PY);
|
||||
const response = await makeRegistryRequest(
|
||||
proxyHost,
|
||||
proxyPort,
|
||||
|
|
@ -162,6 +167,8 @@ describe("registryProxy.mitm", () => {
|
|||
});
|
||||
|
||||
it("should intercept HTTPS requests to files.pythonhosted.org for pip wheel", async () => {
|
||||
// Ensure Python ecosystem
|
||||
setEcoSystem(ECOSYSTEM_PY);
|
||||
const response = await makeRegistryRequest(
|
||||
proxyHost,
|
||||
proxyPort,
|
||||
|
|
@ -173,6 +180,8 @@ describe("registryProxy.mitm", () => {
|
|||
});
|
||||
|
||||
it("should handle pip package with a1 version", async () => {
|
||||
// Ensure Python ecosystem
|
||||
setEcoSystem(ECOSYSTEM_PY);
|
||||
const response = await makeRegistryRequest(
|
||||
proxyHost,
|
||||
proxyPort,
|
||||
|
|
@ -184,6 +193,8 @@ describe("registryProxy.mitm", () => {
|
|||
});
|
||||
|
||||
it("should handle pip package with latest version (should not block)", async () => {
|
||||
// Ensure Python ecosystem
|
||||
setEcoSystem(ECOSYSTEM_PY);
|
||||
const response = await makeRegistryRequest(
|
||||
proxyHost,
|
||||
proxyPort,
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import {
|
|||
writeDatabaseToLocalCache,
|
||||
} from "../config/configFile.js";
|
||||
import { ui } from "../environment/userInteraction.js";
|
||||
import { getEcoSystem } from "../config/settings.js";
|
||||
import { getEcoSystem, ECOSYSTEM_PY } from "../config/settings.js";
|
||||
|
||||
let cachedMalwareDatabase = null;
|
||||
|
||||
|
|
@ -18,7 +18,7 @@ let cachedMalwareDatabase = null;
|
|||
*/
|
||||
function normalizePackageName(name) {
|
||||
const ecosystem = getEcoSystem();
|
||||
if (ecosystem === "py") {
|
||||
if (ecosystem === ECOSYSTEM_PY) {
|
||||
return name.toLowerCase().replace(/[-_.]+/g, "-");
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -46,8 +46,7 @@ function createUnixShims(shimsDir) {
|
|||
|
||||
const template = fs.readFileSync(templatePath, "utf-8");
|
||||
|
||||
// Create a shim for each tool except pip for now.
|
||||
// TODO(pip): Enable pip and pip3 CI support
|
||||
// Create a shim for each tool except pip (CI support not yet implemented)
|
||||
let created = 0;
|
||||
for (const toolInfo of knownAikidoTools) {
|
||||
if (toolInfo.tool === "pip") {
|
||||
|
|
@ -89,8 +88,7 @@ function createWindowsShims(shimsDir) {
|
|||
|
||||
const template = fs.readFileSync(templatePath, "utf-8");
|
||||
|
||||
// Create a shim for each tool except pip for now.
|
||||
// TODO(pip): Enable pip and pip3 CI support
|
||||
// Create a shim for each tool except pip (CI support not yet implemented)
|
||||
let created = 0;
|
||||
for (const toolInfo of knownAikidoTools) {
|
||||
if (toolInfo.tool === "pip") {
|
||||
|
|
|
|||
|
|
@ -70,11 +70,9 @@ function npm
|
|||
end
|
||||
|
||||
function pip
|
||||
# Default to Python 2 major version when explicitly calling pip
|
||||
wrapSafeChainCommand "pip" "aikido-pip" --target-version-major "2" $argv
|
||||
wrapSafeChainCommand "pip" "aikido-pip" $argv
|
||||
end
|
||||
|
||||
function pip3
|
||||
# Route to Python 3 when calling pip3
|
||||
wrapSafeChainCommand "pip3" "aikido-pip" --target-version-major "3" $argv
|
||||
wrapSafeChainCommand "pip3" "aikido-pip3" $argv
|
||||
end
|
||||
|
|
|
|||
|
|
@ -62,9 +62,9 @@ function npm() {
|
|||
}
|
||||
|
||||
function pip() {
|
||||
wrapSafeChainCommand "pip" "aikido-pip" --target-version-major "2" "$@"
|
||||
wrapSafeChainCommand "pip" "aikido-pip" "$@"
|
||||
}
|
||||
|
||||
function pip3() {
|
||||
wrapSafeChainCommand "pip3" "aikido-pip" --target-version-major "3" "$@"
|
||||
wrapSafeChainCommand "pip3" "aikido-pip3" "$@"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -88,13 +88,9 @@ function npm {
|
|||
}
|
||||
|
||||
function pip {
|
||||
# Default to Python 2 major version when explicitly calling pip
|
||||
$forward = @("--target-version-major", "2") + $args
|
||||
Invoke-WrappedCommand "pip" "aikido-pip" $forward
|
||||
Invoke-WrappedCommand "pip" "aikido-pip" $args
|
||||
}
|
||||
|
||||
function pip3 {
|
||||
# Route to Python 3 when calling pip3
|
||||
$forward = @("--target-version-major", "3") + $args
|
||||
Invoke-WrappedCommand "pip3" "aikido-pip" $forward
|
||||
Invoke-WrappedCommand "pip3" "aikido-pip3" $args
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue