mirror of
https://github.com/AikidoSec/safe-chain.git
synced 2026-05-26 20:20:49 +00:00
Move existing proxy files to builtInProxy folder
This commit is contained in:
parent
03ecd0dfb9
commit
ca071729be
31 changed files with 766 additions and 397 deletions
186
packages/safe-chain/src/registryProxy/builtInProxy/certBundle.js
Normal file
186
packages/safe-chain/src/registryProxy/builtInProxy/certBundle.js
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
// @ts-ignore - certifi has no type definitions
|
||||
import certifi from "certifi";
|
||||
import tls from "node:tls";
|
||||
import { X509Certificate } from "node:crypto";
|
||||
import { getCaCertPath } from "./certUtils.js";
|
||||
import { ui } from "../../environment/userInteraction.js";
|
||||
|
||||
/**
|
||||
* Check if a PEM string contains only parsable cert blocks.
|
||||
* @param {string} pem - PEM-encoded certificate string
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isParsable(pem) {
|
||||
if (!pem || typeof pem !== "string") return false;
|
||||
pem = normalizeLineEndings(pem);
|
||||
const begin = "-----BEGIN CERTIFICATE-----";
|
||||
const end = "-----END CERTIFICATE-----";
|
||||
const blocks = [];
|
||||
|
||||
let idx = 0;
|
||||
while (idx < pem.length) {
|
||||
const start = pem.indexOf(begin, idx);
|
||||
if (start === -1) break;
|
||||
const stop = pem.indexOf(end, start + begin.length);
|
||||
if (stop === -1) break;
|
||||
const blockEnd = stop + end.length;
|
||||
blocks.push(pem.slice(start, blockEnd));
|
||||
idx = blockEnd;
|
||||
}
|
||||
|
||||
if (blocks.length === 0) return false;
|
||||
try {
|
||||
for (const b of blocks) {
|
||||
// throw if invalid
|
||||
new X509Certificate(b);
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a combined CA bundle.
|
||||
* Automatically includes:
|
||||
* - Safe Chain CA (for MITM of known registries)
|
||||
* - Mozilla roots via certifi (for public HTTPS)
|
||||
* - Node's built-in root certificates (fallback)
|
||||
* - User's custom certificates (if NODE_EXTRA_CA_CERTS environment variable is set)
|
||||
*
|
||||
* @returns {string} Path to the combined CA bundle PEM file
|
||||
*/
|
||||
export function getCombinedCaBundlePath() {
|
||||
const parts = [];
|
||||
|
||||
// 1) Safe Chain CA (for MITM'd registries)
|
||||
const safeChainPath = getCaCertPath();
|
||||
try {
|
||||
const safeChainPem = fs.readFileSync(safeChainPath, "utf8");
|
||||
if (isParsable(safeChainPem)) parts.push(safeChainPem.trim());
|
||||
} catch {
|
||||
// Ignore if Safe Chain CA is not available
|
||||
}
|
||||
|
||||
// 2) certifi (Mozilla CA bundle for all public HTTPS)
|
||||
try {
|
||||
const certifiPem = fs.readFileSync(certifi, "utf8");
|
||||
if (isParsable(certifiPem)) parts.push(certifiPem.trim());
|
||||
} catch {
|
||||
// Ignore if certifi bundle is not available
|
||||
}
|
||||
|
||||
// 3) Node's built-in root certificates
|
||||
try {
|
||||
const nodeRoots = tls.rootCertificates;
|
||||
if (Array.isArray(nodeRoots) && nodeRoots.length) {
|
||||
for (const rootPem of nodeRoots) {
|
||||
if (typeof rootPem !== "string") continue;
|
||||
if (isParsable(rootPem)) parts.push(rootPem.trim());
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore if unavailable
|
||||
}
|
||||
|
||||
// 4) User's NODE_EXTRA_CA_CERTS (if set)
|
||||
const userCertPath = process.env.NODE_EXTRA_CA_CERTS;
|
||||
if (userCertPath) {
|
||||
const userPem = readUserCertificateFile(userCertPath);
|
||||
if (userPem) {
|
||||
parts.push(userPem.trim());
|
||||
ui.writeVerbose(
|
||||
`Safe-chain: Merging user's NODE_EXTRA_CA_CERTS from ${userCertPath}`,
|
||||
);
|
||||
} else {
|
||||
ui.writeWarning(
|
||||
`Safe-chain: Could not read or parse user's NODE_EXTRA_CA_CERTS from ${userCertPath}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const combined = parts.filter(Boolean).join("\n");
|
||||
const target = path.join(
|
||||
os.tmpdir(),
|
||||
`safe-chain-ca-bundle-${Date.now()}.pem`,
|
||||
);
|
||||
fs.writeFileSync(target, combined, { encoding: "utf8" });
|
||||
return target;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize path
|
||||
* @param {string} p - Path to normalize
|
||||
* @returns {string}
|
||||
*/
|
||||
function normalizePathF(p) {
|
||||
return p.replace(/\\/g, "/");
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize line endings to LF
|
||||
* @param {string} text - Text with mixed line endings
|
||||
* @returns {string}
|
||||
*/
|
||||
function normalizeLineEndings(text) {
|
||||
return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Read and validate user certificate file
|
||||
* @param {string} certPath - Path to certificate file
|
||||
* @returns {string | null} Certificate PEM content or null if invalid/unreadable
|
||||
*/
|
||||
function readUserCertificateFile(certPath) {
|
||||
try {
|
||||
// 1) Basic validation
|
||||
if (typeof certPath !== "string" || certPath.trim().length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 2) Reject path traversal attempts (normalize backslashes first for Windows paths)
|
||||
const normalizedPath = normalizePathF(certPath);
|
||||
if (normalizedPath.includes("..")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 3) Check if file exists and is not a directory or symlink
|
||||
let stats;
|
||||
try {
|
||||
stats = fs.lstatSync(certPath);
|
||||
} catch {
|
||||
// File doesn't exist or can't be accessed
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!stats.isFile()) {
|
||||
// Reject directories and symlinks
|
||||
return null;
|
||||
}
|
||||
|
||||
// 4) Read file content
|
||||
let content;
|
||||
try {
|
||||
content = fs.readFileSync(certPath, "utf8");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!content || typeof content !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 5) Validate PEM format
|
||||
if (!isParsable(content)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return content;
|
||||
} catch {
|
||||
// Silently fail on any errors
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,379 @@
|
|||
import { describe, it, beforeEach, mock } from "node:test";
|
||||
import assert from "node:assert";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import tls from "node:tls";
|
||||
|
||||
// Utility to remove the generated bundle so the module rebuilds it on demand
|
||||
function removeBundleIfExists() {
|
||||
const target = path.join(os.tmpdir(), "safe-chain-ca-bundle.pem");
|
||||
try {
|
||||
if (fs.existsSync(target)) fs.unlinkSync(target);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
// Utility to get a valid PEM certificate for testing
|
||||
function getValidCert() {
|
||||
const cert = typeof tls.rootCertificates?.[0] === "string" ? tls.rootCertificates[0] : "";
|
||||
assert.ok(cert.includes("BEGIN CERTIFICATE"), "Environment lacks Node root certificates for test");
|
||||
return cert;
|
||||
}
|
||||
|
||||
describe("certBundle.getCombinedCaBundlePath", () => {
|
||||
beforeEach(() => {
|
||||
mock.restoreAll();
|
||||
removeBundleIfExists();
|
||||
});
|
||||
|
||||
it("includes Safe Chain CA when parsable and produces a PEM bundle", async () => {
|
||||
// Prepare a temporary Safe Chain CA file with a recognizable marker and a valid cert block
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "pipcabundle-"));
|
||||
const safeChainPath = path.join(tmpDir, "safechain-ca.pem");
|
||||
const marker = "# SAFE_CHAIN_TEST_MARKER";
|
||||
const rootPem = typeof tls.rootCertificates?.[0] === "string" ? tls.rootCertificates[0] : "";
|
||||
assert.ok(rootPem.includes("BEGIN CERTIFICATE"), "Environment lacks Node root certificates for test");
|
||||
fs.writeFileSync(safeChainPath, `${marker}\n${rootPem}`, "utf8");
|
||||
|
||||
// Mock the certUtils.getCaCertPath to return our temp file
|
||||
mock.module("./certUtils.js", {
|
||||
namedExports: {
|
||||
getCaCertPath: () => safeChainPath,
|
||||
},
|
||||
});
|
||||
|
||||
const { getCombinedCaBundlePath } = await import("./certBundle.js");
|
||||
const bundlePath = getCombinedCaBundlePath();
|
||||
assert.ok(fs.existsSync(bundlePath), "Bundle path should exist");
|
||||
const contents = fs.readFileSync(bundlePath, "utf8");
|
||||
assert.match(contents, /-----BEGIN CERTIFICATE-----/);
|
||||
assert.ok(contents.includes(marker), "Bundle should include Safe Chain CA content when parsable");
|
||||
});
|
||||
|
||||
it("ignores invalid Safe Chain CA but still builds from other sources", async () => {
|
||||
// Write an invalid file (no cert blocks)
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "pipcabundle-"));
|
||||
const safeChainPath = path.join(tmpDir, "safechain-invalid.pem");
|
||||
const invalidMarker = "INVALID_SAFE_CHAIN_CONTENT";
|
||||
fs.writeFileSync(safeChainPath, invalidMarker, "utf8");
|
||||
|
||||
// Mock the certUtils.getCaCertPath to return our invalid file
|
||||
mock.module("./certUtils.js", {
|
||||
namedExports: {
|
||||
getCaCertPath: () => safeChainPath,
|
||||
},
|
||||
});
|
||||
|
||||
// Ensure fresh build
|
||||
removeBundleIfExists();
|
||||
const { getCombinedCaBundlePath } = await import("./certBundle.js");
|
||||
const bundlePath = getCombinedCaBundlePath();
|
||||
assert.ok(fs.existsSync(bundlePath), "Bundle path should exist");
|
||||
const contents = fs.readFileSync(bundlePath, "utf8");
|
||||
assert.match(contents, /-----BEGIN CERTIFICATE-----/, "Bundle should contain certificate blocks from certifi/Node roots");
|
||||
assert.ok(!contents.includes(invalidMarker), "Bundle should not include invalid Safe Chain content");
|
||||
});
|
||||
});
|
||||
|
||||
describe("certBundle.getCombinedCaBundlePath with user certs", () => {
|
||||
beforeEach(() => {
|
||||
mock.restoreAll();
|
||||
delete process.env.NODE_EXTRA_CA_CERTS;
|
||||
});
|
||||
|
||||
it("returns a path with full CA bundle (Safe Chain + Mozilla + Node roots) when no user cert in env", async () => {
|
||||
// Mock getCaCertPath to return valid cert
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "certtest-"));
|
||||
const safeChainPath = path.join(tmpDir, "safechain.pem");
|
||||
fs.writeFileSync(safeChainPath, getValidCert(), "utf8");
|
||||
|
||||
mock.module("./certUtils.js", {
|
||||
namedExports: {
|
||||
getCaCertPath: () => safeChainPath,
|
||||
},
|
||||
});
|
||||
|
||||
const { getCombinedCaBundlePath } = await import("./certBundle.js");
|
||||
const bundlePath = getCombinedCaBundlePath();
|
||||
|
||||
assert.ok(fs.existsSync(bundlePath), "Bundle path should exist");
|
||||
const contents = fs.readFileSync(bundlePath, "utf8");
|
||||
assert.match(contents, /-----BEGIN CERTIFICATE-----/, "Should contain certificate blocks");
|
||||
// Should include base bundle (Safe Chain + Mozilla/Node roots)
|
||||
assert.ok(contents.length > 1000, "Bundle should be substantial with Mozilla/Node roots included");
|
||||
});
|
||||
|
||||
it("merges user cert with full base bundle (Safe Chain CA + Mozilla + Node roots)", async () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "certtest-"));
|
||||
|
||||
// Create Safe Chain CA
|
||||
const safeChainPath = path.join(tmpDir, "safechain.pem");
|
||||
const safeChainCert = getValidCert();
|
||||
fs.writeFileSync(safeChainPath, safeChainCert, "utf8");
|
||||
|
||||
// Create user cert file
|
||||
const userCertPath = path.join(tmpDir, "user-cert.pem");
|
||||
const userCert = getValidCert();
|
||||
fs.writeFileSync(userCertPath, userCert, "utf8");
|
||||
process.env.NODE_EXTRA_CA_CERTS = userCertPath;
|
||||
|
||||
mock.module("./certUtils.js", {
|
||||
namedExports: {
|
||||
getCaCertPath: () => safeChainPath,
|
||||
},
|
||||
});
|
||||
|
||||
const { getCombinedCaBundlePath } = await import("./certBundle.js");
|
||||
const bundlePath = getCombinedCaBundlePath();
|
||||
|
||||
assert.ok(fs.existsSync(bundlePath), "Bundle path should exist");
|
||||
const contents = fs.readFileSync(bundlePath, "utf8");
|
||||
|
||||
// Both certs should be in the bundle
|
||||
const certCount = (contents.match(/-----BEGIN CERTIFICATE-----/g) || []).length;
|
||||
assert.ok(certCount >= 2, "Bundle should contain both Safe Chain and user certificates");
|
||||
});
|
||||
|
||||
it("ignores non-existent user cert path", async () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "certtest-"));
|
||||
const safeChainPath = path.join(tmpDir, "safechain.pem");
|
||||
fs.writeFileSync(safeChainPath, getValidCert(), "utf8");
|
||||
process.env.NODE_EXTRA_CA_CERTS = "/nonexistent/path.pem";
|
||||
|
||||
mock.module("./certUtils.js", {
|
||||
namedExports: {
|
||||
getCaCertPath: () => safeChainPath,
|
||||
},
|
||||
});
|
||||
|
||||
const { getCombinedCaBundlePath } = await import("./certBundle.js");
|
||||
const bundlePath = getCombinedCaBundlePath();
|
||||
|
||||
assert.ok(fs.existsSync(bundlePath), "Bundle path should exist");
|
||||
const contents = fs.readFileSync(bundlePath, "utf8");
|
||||
// Should still have Safe Chain CA
|
||||
assert.match(contents, /-----BEGIN CERTIFICATE-----/, "Should contain Safe Chain CA");
|
||||
});
|
||||
|
||||
it("ignores invalid PEM user cert", async () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "certtest-"));
|
||||
|
||||
const safeChainPath = path.join(tmpDir, "safechain.pem");
|
||||
fs.writeFileSync(safeChainPath, getValidCert(), "utf8");
|
||||
|
||||
const userCertPath = path.join(tmpDir, "invalid.pem");
|
||||
fs.writeFileSync(userCertPath, "NOT A VALID PEM", "utf8");
|
||||
process.env.NODE_EXTRA_CA_CERTS = userCertPath;
|
||||
|
||||
mock.module("./certUtils.js", {
|
||||
namedExports: {
|
||||
getCaCertPath: () => safeChainPath,
|
||||
},
|
||||
});
|
||||
|
||||
const { getCombinedCaBundlePath } = await import("./certBundle.js");
|
||||
const bundlePath = getCombinedCaBundlePath();
|
||||
|
||||
assert.ok(fs.existsSync(bundlePath), "Bundle path should exist");
|
||||
const contents = fs.readFileSync(bundlePath, "utf8");
|
||||
// Should still have Safe Chain CA only
|
||||
assert.match(contents, /-----BEGIN CERTIFICATE-----/, "Should contain Safe Chain CA");
|
||||
assert.ok(!contents.includes("NOT A VALID"), "Should not include invalid cert");
|
||||
});
|
||||
|
||||
it("rejects user cert with path traversal attempts", async () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "certtest-"));
|
||||
const safeChainPath = path.join(tmpDir, "safechain.pem");
|
||||
fs.writeFileSync(safeChainPath, getValidCert(), "utf8");
|
||||
|
||||
mock.module("./certUtils.js", {
|
||||
namedExports: {
|
||||
getCaCertPath: () => safeChainPath,
|
||||
},
|
||||
});
|
||||
|
||||
const { getCombinedCaBundlePath } = await import("./certBundle.js");
|
||||
process.env.NODE_EXTRA_CA_CERTS = "../../../etc/passwd";
|
||||
const bundlePath = getCombinedCaBundlePath();
|
||||
|
||||
assert.ok(fs.existsSync(bundlePath), "Bundle path should exist");
|
||||
const contents = fs.readFileSync(bundlePath, "utf8");
|
||||
// Should only have Safe Chain CA, rejected the traversal path
|
||||
assert.match(contents, /-----BEGIN CERTIFICATE-----/, "Should contain Safe Chain CA");
|
||||
});
|
||||
|
||||
it("rejects user cert with symlink", async () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "certtest-"));
|
||||
|
||||
const safeChainPath = path.join(tmpDir, "safechain.pem");
|
||||
fs.writeFileSync(safeChainPath, getValidCert(), "utf8");
|
||||
|
||||
// Create a target file and a symlink to it
|
||||
const targetCert = path.join(tmpDir, "target.pem");
|
||||
fs.writeFileSync(targetCert, getValidCert(), "utf8");
|
||||
|
||||
const symlinkPath = path.join(tmpDir, "symlink.pem");
|
||||
try {
|
||||
fs.symlinkSync(targetCert, symlinkPath);
|
||||
} catch {
|
||||
// Skip test if symlinks are not supported (e.g., on Windows without admin)
|
||||
return;
|
||||
}
|
||||
|
||||
mock.module("./certUtils.js", {
|
||||
namedExports: {
|
||||
getCaCertPath: () => safeChainPath,
|
||||
},
|
||||
});
|
||||
|
||||
const { getCombinedCaBundlePath } = await import("./certBundle.js");
|
||||
process.env.NODE_EXTRA_CA_CERTS = symlinkPath;
|
||||
const bundlePath = getCombinedCaBundlePath();
|
||||
|
||||
assert.ok(fs.existsSync(bundlePath), "Bundle path should exist");
|
||||
const contents = fs.readFileSync(bundlePath, "utf8");
|
||||
// Should only have Safe Chain CA, symlinks are rejected
|
||||
assert.match(contents, /-----BEGIN CERTIFICATE-----/, "Should contain Safe Chain CA");
|
||||
});
|
||||
|
||||
it("rejects user cert that is a directory", async () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "certtest-"));
|
||||
|
||||
const safeChainPath = path.join(tmpDir, "safechain.pem");
|
||||
fs.writeFileSync(safeChainPath, getValidCert(), "utf8");
|
||||
|
||||
const certDir = path.join(tmpDir, "certs");
|
||||
fs.mkdirSync(certDir);
|
||||
|
||||
mock.module("./certUtils.js", {
|
||||
namedExports: {
|
||||
getCaCertPath: () => safeChainPath,
|
||||
},
|
||||
});
|
||||
|
||||
const { getCombinedCaBundlePath } = await import("./certBundle.js");
|
||||
process.env.NODE_EXTRA_CA_CERTS = certDir;
|
||||
const bundlePath = getCombinedCaBundlePath();
|
||||
|
||||
assert.ok(fs.existsSync(bundlePath), "Bundle path should exist");
|
||||
const contents = fs.readFileSync(bundlePath, "utf8");
|
||||
// Should only have Safe Chain CA
|
||||
assert.match(contents, /-----BEGIN CERTIFICATE-----/, "Should contain Safe Chain CA");
|
||||
});
|
||||
|
||||
it("handles empty string user cert path", async () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "certtest-"));
|
||||
const safeChainPath = path.join(tmpDir, "safechain.pem");
|
||||
fs.writeFileSync(safeChainPath, getValidCert(), "utf8");
|
||||
|
||||
mock.module("./certUtils.js", {
|
||||
namedExports: {
|
||||
getCaCertPath: () => safeChainPath,
|
||||
},
|
||||
});
|
||||
|
||||
const { getCombinedCaBundlePath } = await import("./certBundle.js");
|
||||
process.env.NODE_EXTRA_CA_CERTS = " ";
|
||||
const bundlePath = getCombinedCaBundlePath();
|
||||
|
||||
assert.ok(fs.existsSync(bundlePath), "Bundle path should exist");
|
||||
const contents = fs.readFileSync(bundlePath, "utf8");
|
||||
assert.match(contents, /-----BEGIN CERTIFICATE-----/, "Should contain Safe Chain CA");
|
||||
});
|
||||
|
||||
it("accepts files with CRLF line endings (Windows-style)", async () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "certtest-"));
|
||||
const safeChainPath = path.join(tmpDir, "safechain.pem");
|
||||
fs.writeFileSync(safeChainPath, getValidCert(), "utf8");
|
||||
|
||||
// Create a real file with CRLF content to test Windows line ending support
|
||||
const userCertPath = path.join(tmpDir, "user-cert-crlf.pem");
|
||||
const userCert = getValidCert();
|
||||
const certWithCRLF = userCert.replace(/\n/g, "\r\n");
|
||||
fs.writeFileSync(userCertPath, certWithCRLF, "utf8");
|
||||
process.env.NODE_EXTRA_CA_CERTS = userCertPath;
|
||||
|
||||
mock.module("./certUtils.js", {
|
||||
namedExports: {
|
||||
getCaCertPath: () => safeChainPath,
|
||||
},
|
||||
});
|
||||
|
||||
const { getCombinedCaBundlePath } = await import("./certBundle.js");
|
||||
const bundlePath = getCombinedCaBundlePath();
|
||||
assert.ok(fs.existsSync(bundlePath), "Bundle path should exist");
|
||||
const contents = fs.readFileSync(bundlePath, "utf8");
|
||||
const certCount = (contents.match(/-----BEGIN CERTIFICATE-----/g) || []).length;
|
||||
assert.ok(certCount >= 2, "Bundle should contain Safe Chain and user certificates with CRLF");
|
||||
});
|
||||
|
||||
it("detects and handles Windows-style path syntax (drive letters and UNC)", async () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "certtest-"));
|
||||
const safeChainPath = path.join(tmpDir, "safechain.pem");
|
||||
fs.writeFileSync(safeChainPath, getValidCert(), "utf8");
|
||||
|
||||
mock.module("./certUtils.js", {
|
||||
namedExports: {
|
||||
getCaCertPath: () => safeChainPath,
|
||||
},
|
||||
});
|
||||
|
||||
const { getCombinedCaBundlePath } = await import("./certBundle.js");
|
||||
|
||||
// Test that Windows path syntax is recognized (even if files don't exist on macOS/Linux)
|
||||
// These should gracefully fail (return Safe Chain CA only) rather than crash
|
||||
const winPaths = [
|
||||
"C:\\temp\\cert.pem",
|
||||
"D:\\Users\\name\\certs\\ca.pem",
|
||||
"\\\\server\\share\\cert.pem"
|
||||
];
|
||||
|
||||
for (const winPath of winPaths) {
|
||||
process.env.NODE_EXTRA_CA_CERTS = winPath;
|
||||
const bundlePath = getCombinedCaBundlePath();
|
||||
assert.ok(fs.existsSync(bundlePath), `Bundle should exist for ${winPath}`);
|
||||
const contents = fs.readFileSync(bundlePath, "utf8");
|
||||
assert.match(contents, /-----BEGIN CERTIFICATE-----/, "Should contain Safe Chain CA");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects path traversal with Windows-style paths (C:\\temp\\..\\etc\\passwd)", async () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "certtest-"));
|
||||
const safeChainPath = path.join(tmpDir, "safechain.pem");
|
||||
fs.writeFileSync(safeChainPath, getValidCert(), "utf8");
|
||||
|
||||
mock.module("./certUtils.js", {
|
||||
namedExports: {
|
||||
getCaCertPath: () => safeChainPath,
|
||||
},
|
||||
});
|
||||
|
||||
const { getCombinedCaBundlePath } = await import("./certBundle.js");
|
||||
|
||||
// Test various Windows-style traversal attempts
|
||||
const traversalPaths = [
|
||||
"C:\\temp\\..\\etc\\passwd",
|
||||
"D:\\Users\\..\\..\\Windows\\System32",
|
||||
"\\\\server\\share\\..\\admin",
|
||||
"../../../etc/passwd", // Unix-style for comparison
|
||||
];
|
||||
|
||||
// First, get baseline bundle without user certs to know expected cert count
|
||||
delete process.env.NODE_EXTRA_CA_CERTS;
|
||||
const baselineBundlePath = getCombinedCaBundlePath();
|
||||
const baselineContents = fs.readFileSync(baselineBundlePath, "utf8");
|
||||
const baselineCertCount = (baselineContents.match(/-----BEGIN CERTIFICATE-----/g) || []).length;
|
||||
|
||||
for (const badPath of traversalPaths) {
|
||||
process.env.NODE_EXTRA_CA_CERTS = badPath;
|
||||
const bundlePath = getCombinedCaBundlePath();
|
||||
assert.ok(fs.existsSync(bundlePath), "Bundle path should exist");
|
||||
const contents = fs.readFileSync(bundlePath, "utf8");
|
||||
// Should contain base bundle (Safe Chain + Mozilla + Node roots) but NOT user cert
|
||||
const certCount = (contents.match(/-----BEGIN CERTIFICATE-----/g) || []).length;
|
||||
assert.strictEqual(certCount, baselineCertCount, `Traversal path ${badPath} should be rejected; base bundle only (no user cert added)`);
|
||||
}
|
||||
});
|
||||
});
|
||||
178
packages/safe-chain/src/registryProxy/builtInProxy/certUtils.js
Normal file
178
packages/safe-chain/src/registryProxy/builtInProxy/certUtils.js
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
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();
|
||||
|
||||
/**
|
||||
* @param {forge.pki.PublicKey} publicKey
|
||||
* @returns {string}
|
||||
*/
|
||||
function createKeyIdentifier(publicKey) {
|
||||
return forge.pki.getPublicKeyFingerprint(publicKey, {
|
||||
encoding: "binary",
|
||||
md: forge.md.sha1.create(),
|
||||
});
|
||||
}
|
||||
|
||||
export function getCaCertPath() {
|
||||
return path.join(certFolder, "ca-cert.pem");
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} hostname
|
||||
* @returns {{privateKey: string, certificate: string}}
|
||||
*/
|
||||
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);
|
||||
const authorityKeyIdentifier = createKeyIdentifier(ca.certificate.publicKey);
|
||||
cert.setExtensions([
|
||||
{
|
||||
name: "subjectAltName",
|
||||
altNames: [
|
||||
{
|
||||
type: 2, // DNS
|
||||
value: hostname,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "keyUsage",
|
||||
digitalSignature: true,
|
||||
keyEncipherment: true,
|
||||
},
|
||||
{
|
||||
/*
|
||||
Extended Key Usage (EKU) serverAuth extension
|
||||
|
||||
Needed for TLS server authentication. This extension indicates the certificate's
|
||||
public key may be used for TLS WWW server authentication.
|
||||
Python virtualenv environments (like pipx-installed Poetry) enforce this strictly
|
||||
https://www.rfc-editor.org/rfc/rfc5280#section-4.2.1.12
|
||||
*/
|
||||
name: "extKeyUsage",
|
||||
serverAuth: true,
|
||||
},
|
||||
{
|
||||
/*
|
||||
Subject Key Identifier (SKI)
|
||||
|
||||
Needed for Python virtualenv SSL validation and certificate chain building.
|
||||
This extension provides a means of identifying certificates containing a particular public key.
|
||||
Python virtualenv environments require this for proper certificate chain validation.
|
||||
System Python installations may be more lenient.
|
||||
https://www.rfc-editor.org/rfc/rfc5280#section-4.2.1.2
|
||||
*/
|
||||
name: "subjectKeyIdentifier",
|
||||
subjectKeyIdentifier: createKeyIdentifier(cert.publicKey),
|
||||
},
|
||||
{
|
||||
/*
|
||||
Authority Key Identifier (AKI)
|
||||
|
||||
Needed for Python virtualenv SSL validation and certificate path validation.
|
||||
This extension identifies the public key corresponding to the private key used to sign
|
||||
this certificate. It links this certificate to its issuing CA certificate.
|
||||
Without this, Python virtualenv certificate validation might fail (for instance for Poetry)
|
||||
https://www.rfc-editor.org/rfc/rfc5280#section-4.2.1.1
|
||||
*/
|
||||
name: "authorityKeyIdentifier",
|
||||
keyIdentifier: authorityKeyIdentifier,
|
||||
},
|
||||
]);
|
||||
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); // Self-signed: issuer === subject
|
||||
const keyIdentifier = createKeyIdentifier(cert.publicKey);
|
||||
cert.setExtensions([
|
||||
{
|
||||
name: "basicConstraints",
|
||||
cA: true,
|
||||
critical: true, // Marking basicConstraints as critical is required for CA certificates so clients must process it to trust the cert as a CA
|
||||
},
|
||||
{
|
||||
name: "keyUsage",
|
||||
keyCertSign: true,
|
||||
digitalSignature: true,
|
||||
keyEncipherment: true,
|
||||
},
|
||||
{
|
||||
name: "subjectKeyIdentifier",
|
||||
subjectKeyIdentifier: keyIdentifier,
|
||||
},
|
||||
{
|
||||
name: "authorityKeyIdentifier",
|
||||
keyIdentifier,
|
||||
},
|
||||
]);
|
||||
cert.sign(keys.privateKey, forge.md.sha256.create());
|
||||
|
||||
return {
|
||||
privateKey: keys.privateKey,
|
||||
certificate: cert,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,150 @@
|
|||
import * as http from "http";
|
||||
import { tunnelRequest } from "./tunnelRequestHandler.js";
|
||||
import { mitmConnect } from "./mitmRequestHandler.js";
|
||||
import { handleHttpProxyRequest } from "./plainHttpProxy.js";
|
||||
import { getCombinedCaBundlePath } from "./certBundle.js";
|
||||
import { ui } from "../../environment/userInteraction.js";
|
||||
import chalk from "chalk";
|
||||
import { createInterceptorForUrl } from "./interceptors/createInterceptorForEcoSystem.js";
|
||||
import { getHasSuppressedVersions } from "./interceptors/npm/modifyNpmInfo.js";
|
||||
|
||||
/** *
|
||||
* @returns {import("../registryProxy.js").SafeChainProxy} */
|
||||
export function createBuiltInProxyServer() {
|
||||
const SERVER_STOP_TIMEOUT_MS = 1000;
|
||||
/**
|
||||
* @type {{port: number | null, blockedRequests: {packageName: string, version: string, url: string}[]}}
|
||||
*/
|
||||
const state = {
|
||||
port: null,
|
||||
blockedRequests: [],
|
||||
};
|
||||
|
||||
const server = http.createServer(
|
||||
// This handles direct HTTP requests (non-CONNECT requests)
|
||||
// This is normally http-only traffic, but we also handle
|
||||
// https for clients that don't properly use CONNECT
|
||||
handleHttpProxyRequest,
|
||||
);
|
||||
|
||||
// This handles HTTPS requests via the CONNECT method
|
||||
server.on("connect", handleConnect);
|
||||
|
||||
return {
|
||||
startServer: () => startServer(server),
|
||||
stopServer: () => stopServer(server),
|
||||
verifyNoMaliciousPackages,
|
||||
hasSuppressedVersions: getHasSuppressedVersions,
|
||||
getServerPort: () => state.port,
|
||||
getCombinedCaBundlePath,
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {import("http").Server} server
|
||||
*
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("http").Server} server
|
||||
*
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
function stopServer(server) {
|
||||
return new Promise((resolve) => {
|
||||
try {
|
||||
server.close(() => {
|
||||
resolve();
|
||||
});
|
||||
} catch {
|
||||
resolve();
|
||||
}
|
||||
setTimeout(() => resolve(), SERVER_STOP_TIMEOUT_MS);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("http").IncomingMessage} req
|
||||
* @param {import("http").ServerResponse} clientSocket
|
||||
* @param {Buffer} head
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
function handleConnect(req, clientSocket, head) {
|
||||
// CONNECT method is used for HTTPS requests
|
||||
// It establishes a tunnel to the server identified by the request URL
|
||||
|
||||
const interceptor = createInterceptorForUrl(req.url || "");
|
||||
|
||||
if (interceptor) {
|
||||
// Subscribe to malware blocked events
|
||||
interceptor.on(
|
||||
"malwareBlocked",
|
||||
(
|
||||
/** @type {import("./interceptors/interceptorBuilder.js").MalwareBlockedEvent} */ event,
|
||||
) => {
|
||||
onMalwareBlocked(event.packageName, event.version, event.targetUrl);
|
||||
},
|
||||
);
|
||||
|
||||
mitmConnect(req, clientSocket, interceptor);
|
||||
} else {
|
||||
// For other hosts, just tunnel the request to the destination tcp socket
|
||||
ui.writeVerbose(`Safe-chain: Tunneling request to ${req.url}`);
|
||||
tunnelRequest(req, clientSocket, head);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {string} packageName
|
||||
* @param {string} version
|
||||
* @param {string} url
|
||||
*/
|
||||
function onMalwareBlocked(packageName, version, url) {
|
||||
state.blockedRequests.push({ packageName, version, url });
|
||||
}
|
||||
|
||||
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.writeExitWithoutInstallingMaliciousPackages();
|
||||
ui.emptyLine();
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
import { isImdsEndpoint } from "./isImdsEndpoint.js";
|
||||
|
||||
/**
|
||||
* Returns appropriate connection timeout for a host.
|
||||
* - IMDS endpoints: 3s (fail fast when outside cloud, reduce 5min delay to ~20s)
|
||||
* - Other endpoints: 30s (allow for slow networks while preventing indefinite hangs)
|
||||
*/
|
||||
export function getConnectTimeout(/** @type {string} */ host) {
|
||||
if (isImdsEndpoint(host)) {
|
||||
return 3000;
|
||||
}
|
||||
return 30000;
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
/**
|
||||
* @param {NodeJS.Dict<string | string[]> | undefined} headers
|
||||
* @param {string} headerName
|
||||
*/
|
||||
export function getHeaderValueAsString(headers, headerName) {
|
||||
if (!headers) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let header = headers[headerName];
|
||||
|
||||
if (Array.isArray(header)) {
|
||||
return header.join(", ");
|
||||
}
|
||||
|
||||
return header;
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
import {
|
||||
ECOSYSTEM_JS,
|
||||
ECOSYSTEM_PY,
|
||||
getEcoSystem,
|
||||
} from "../../../config/settings.js";
|
||||
import { npmInterceptorForUrl } from "./npm/npmInterceptor.js";
|
||||
import { pipInterceptorForUrl } from "./pipInterceptor.js";
|
||||
|
||||
/**
|
||||
* @param {string} url
|
||||
* @returns {import("./interceptorBuilder.js").Interceptor | undefined}
|
||||
*/
|
||||
export function createInterceptorForUrl(url) {
|
||||
const ecosystem = getEcoSystem();
|
||||
|
||||
if (ecosystem === ECOSYSTEM_JS) {
|
||||
return npmInterceptorForUrl(url);
|
||||
}
|
||||
|
||||
if (ecosystem === ECOSYSTEM_PY) {
|
||||
return pipInterceptorForUrl(url);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
|
@ -0,0 +1,146 @@
|
|||
import { EventEmitter } from "events";
|
||||
|
||||
/**
|
||||
* @typedef {Object} Interceptor
|
||||
* @property {(targetUrl: string) => Promise<RequestInterceptionHandler>} handleRequest
|
||||
* @property {(event: string, listener: (...args: any[]) => void) => Interceptor} on
|
||||
* @property {(event: string, ...args: any[]) => boolean} emit
|
||||
*
|
||||
*
|
||||
* @typedef {Object} RequestInterceptionContext
|
||||
* @property {string} targetUrl
|
||||
* @property {(packageName: string | undefined, version: string | undefined) => void} blockMalware
|
||||
* @property {(modificationFunc: (headers: NodeJS.Dict<string | string[]>) => NodeJS.Dict<string | string[]>) => void} modifyRequestHeaders
|
||||
* @property {(modificationFunc: (body: Buffer, headers: NodeJS.Dict<string | string[]> | undefined) => Buffer) => void} modifyBody
|
||||
* @property {() => RequestInterceptionHandler} build
|
||||
*
|
||||
*
|
||||
* @typedef {Object} RequestInterceptionHandler
|
||||
* @property {{statusCode: number, message: string} | undefined} blockResponse
|
||||
* @property {(headers: NodeJS.Dict<string | string[]> | undefined) => NodeJS.Dict<string | string[]> | undefined} modifyRequestHeaders
|
||||
* @property {() => boolean} modifiesResponse
|
||||
* @property {(body: Buffer, headers: NodeJS.Dict<string | string[]> | undefined) => Buffer} modifyBody
|
||||
*
|
||||
* @typedef {Object} MalwareBlockedEvent
|
||||
* @property {string} packageName
|
||||
* @property {string} version
|
||||
* @property {string} targetUrl
|
||||
* @property {number} timestamp
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {(requestHandlerBuilder: RequestInterceptionContext) => Promise<void>} requestInterceptionFunc
|
||||
* @returns {Interceptor}
|
||||
*/
|
||||
export function interceptRequests(requestInterceptionFunc) {
|
||||
return buildInterceptor([requestInterceptionFunc]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Array<(requestHandlerBuilder: RequestInterceptionContext) => Promise<void>>} requestHandlers
|
||||
* @returns {Interceptor}
|
||||
*/
|
||||
function buildInterceptor(requestHandlers) {
|
||||
const eventEmitter = new EventEmitter();
|
||||
|
||||
return {
|
||||
async handleRequest(targetUrl) {
|
||||
const requestContext = createRequestContext(targetUrl, eventEmitter);
|
||||
|
||||
for (const handler of requestHandlers) {
|
||||
await handler(requestContext);
|
||||
}
|
||||
|
||||
return requestContext.build();
|
||||
},
|
||||
on(event, listener) {
|
||||
eventEmitter.on(event, listener);
|
||||
return this;
|
||||
},
|
||||
emit(event, ...args) {
|
||||
return eventEmitter.emit(event, ...args);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} targetUrl
|
||||
* @param {import('events').EventEmitter} eventEmitter
|
||||
* @returns {RequestInterceptionContext}
|
||||
*/
|
||||
function createRequestContext(targetUrl, eventEmitter) {
|
||||
/** @type {{statusCode: number, message: string} | undefined} */
|
||||
let blockResponse = undefined;
|
||||
/** @type {Array<(headers: NodeJS.Dict<string | string[]>) => NodeJS.Dict<string | string[]>>} */
|
||||
let reqheaderModificationFuncs = [];
|
||||
/** @type {Array<(body: Buffer, headers: NodeJS.Dict<string | string[]> | undefined) => Buffer>} */
|
||||
let modifyBodyFuncs = [];
|
||||
|
||||
/**
|
||||
* @param {string | undefined} packageName
|
||||
* @param {string | undefined} version
|
||||
*/
|
||||
function blockMalwareSetup(packageName, version) {
|
||||
blockResponse = {
|
||||
statusCode: 403,
|
||||
message: "Forbidden - blocked by safe-chain",
|
||||
};
|
||||
|
||||
// Emit the malwareBlocked event
|
||||
eventEmitter.emit("malwareBlocked", {
|
||||
packageName,
|
||||
version,
|
||||
targetUrl,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
/** @returns {RequestInterceptionHandler} */
|
||||
function build() {
|
||||
/**
|
||||
* @param {NodeJS.Dict<string | string[]> | undefined} headers
|
||||
* @returns {NodeJS.Dict<string | string[]> | undefined}
|
||||
*/
|
||||
function modifyRequestHeaders(headers) {
|
||||
if (headers) {
|
||||
for (const func of reqheaderModificationFuncs) {
|
||||
func(headers);
|
||||
}
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Buffer} body
|
||||
* @param {NodeJS.Dict<string | string[]> | undefined} headers
|
||||
* @returns {Buffer}
|
||||
*/
|
||||
function modifyBody(body, headers) {
|
||||
let modifiedBody = body;
|
||||
|
||||
for (var func of modifyBodyFuncs) {
|
||||
modifiedBody = func(body, headers);
|
||||
}
|
||||
|
||||
return modifiedBody;
|
||||
}
|
||||
|
||||
// These functions are invoked in the proxy, allowing to apply the configured modifications
|
||||
return {
|
||||
blockResponse,
|
||||
modifyRequestHeaders: modifyRequestHeaders,
|
||||
modifiesResponse: () => modifyBodyFuncs.length > 0,
|
||||
modifyBody,
|
||||
};
|
||||
}
|
||||
|
||||
// These functions are used to setup the modifications
|
||||
return {
|
||||
targetUrl,
|
||||
blockMalware: blockMalwareSetup,
|
||||
modifyRequestHeaders: (func) => reqheaderModificationFuncs.push(func),
|
||||
modifyBody: (func) => modifyBodyFuncs.push(func),
|
||||
build,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,213 @@
|
|||
import {
|
||||
getMinimumPackageAgeHours,
|
||||
getNpmMinimumPackageAgeExclusions,
|
||||
} from "../../../../config/settings.js";
|
||||
import { ui } from "../../../../environment/userInteraction.js";
|
||||
import { getHeaderValueAsString } from "../../http-utils.js";
|
||||
|
||||
const state = {
|
||||
hasSuppressedVersions: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {NodeJS.Dict<string | string[]>} headers
|
||||
* @returns {NodeJS.Dict<string | string[]>}
|
||||
*/
|
||||
export function modifyNpmInfoRequestHeaders(headers) {
|
||||
const accept = getHeaderValueAsString(headers, "accept");
|
||||
if (accept?.includes("application/vnd.npm.install-v1+json")) {
|
||||
// The npm registry sometimes serves a more compact format that lacks
|
||||
// the time metadata we need to filter out too new packages.
|
||||
// Force the registry to return the full metadata by changing the Accept header.
|
||||
headers["accept"] = "application/json";
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} url
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isPackageInfoUrl(url) {
|
||||
// Remove query string and fragment to get the actual path
|
||||
const urlWithoutParams = url.split("?")[0].split("#")[0];
|
||||
|
||||
// Tarball downloads end with .tgz
|
||||
if (urlWithoutParams.endsWith(".tgz")) return false;
|
||||
|
||||
// Special endpoints start with /-/ and should not be modified
|
||||
// Examples: /-/npm/v1/security/advisories/bulk, /-/v1/search, /-/package/foo/access
|
||||
if (urlWithoutParams.includes("/-/")) return false;
|
||||
|
||||
// Everything else is package metadata that can be modified
|
||||
return true;
|
||||
}
|
||||
/**
|
||||
*
|
||||
* @param {Buffer} body
|
||||
* @param {NodeJS.Dict<string | string[]> | undefined} headers
|
||||
* @returns Buffer
|
||||
*/
|
||||
export function modifyNpmInfoResponse(body, headers) {
|
||||
try {
|
||||
const contentType = getHeaderValueAsString(headers, "content-type");
|
||||
if (!contentType?.toLowerCase().includes("application/json")) {
|
||||
return body;
|
||||
}
|
||||
|
||||
if (body.byteLength === 0) {
|
||||
return body;
|
||||
}
|
||||
|
||||
// utf-8 is default encoding for JSON, so we don't check if charset is defined in content-type header
|
||||
const bodyContent = body.toString("utf8");
|
||||
const bodyJson = JSON.parse(bodyContent);
|
||||
|
||||
if (!bodyJson.time || !bodyJson["dist-tags"] || !bodyJson.versions) {
|
||||
// Just return the current body if the format is not
|
||||
return body;
|
||||
}
|
||||
|
||||
// Check if this package is excluded from minimum age filtering
|
||||
const packageName = bodyJson.name;
|
||||
const exclusions = getNpmMinimumPackageAgeExclusions();
|
||||
if (
|
||||
packageName &&
|
||||
exclusions.some((pattern) =>
|
||||
matchesExclusionPattern(packageName, pattern),
|
||||
)
|
||||
) {
|
||||
ui.writeVerbose(
|
||||
`Safe-chain: ${packageName} is excluded from minimum package age filtering (minimumPackageAgeExclusions setting).`,
|
||||
);
|
||||
return body;
|
||||
}
|
||||
|
||||
const cutOff = new Date(
|
||||
new Date().getTime() - getMinimumPackageAgeHours() * 3600 * 1000,
|
||||
);
|
||||
|
||||
const hasLatestTag = !!bodyJson["dist-tags"]["latest"];
|
||||
|
||||
const versions = Object.entries(bodyJson.time)
|
||||
.map(([version, timestamp]) => ({
|
||||
version,
|
||||
timestamp,
|
||||
}))
|
||||
.filter((x) => x.version !== "created" && x.version !== "modified");
|
||||
|
||||
for (const { version, timestamp } of versions) {
|
||||
const timestampValue = new Date(timestamp);
|
||||
if (timestampValue > cutOff) {
|
||||
deleteVersionFromJson(bodyJson, version);
|
||||
if (headers) {
|
||||
// When modifying the response, the etag and last-modified headers
|
||||
// no longer match the content so they needs to be removed before sending the response.
|
||||
delete headers["etag"];
|
||||
delete headers["last-modified"];
|
||||
// Removing the cache-control header will prevent the package manager from caching
|
||||
// the modified response.
|
||||
delete headers["cache-control"];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (hasLatestTag && !bodyJson["dist-tags"]["latest"]) {
|
||||
// The latest tag was removed because it contained a package younger than the treshold.
|
||||
// A new latest tag needs to be calculated
|
||||
bodyJson["dist-tags"]["latest"] = calculateLatestTag(bodyJson.time);
|
||||
}
|
||||
|
||||
return Buffer.from(JSON.stringify(bodyJson));
|
||||
} catch (/** @type {any} */ err) {
|
||||
ui.writeVerbose(
|
||||
`Safe-chain: Package metadata not in expected format - bypassing modification. Error: ${err.message}`,
|
||||
);
|
||||
return body;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {any} json
|
||||
* @param {string} version
|
||||
*/
|
||||
function deleteVersionFromJson(json, version) {
|
||||
state.hasSuppressedVersions = true;
|
||||
|
||||
const packageName = typeof json?.name === "string" ? json.name : "(unknown)";
|
||||
|
||||
ui.writeVerbose(
|
||||
`Safe-chain: ${packageName}@${version} is newer than ${getMinimumPackageAgeHours()} hours and was removed (minimumPackageAgeInHours setting).`,
|
||||
);
|
||||
|
||||
delete json.time[version];
|
||||
delete json.versions[version];
|
||||
|
||||
for (const [tag, distVersion] of Object.entries(json["dist-tags"])) {
|
||||
if (version == distVersion) {
|
||||
delete json["dist-tags"][tag];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, string>} tagList
|
||||
* @returns {string | undefined}
|
||||
*/
|
||||
function calculateLatestTag(tagList) {
|
||||
const entries = Object.entries(tagList).filter(
|
||||
([version, _]) => version !== "created" && version !== "modified",
|
||||
);
|
||||
|
||||
const latestFullRelease = getMostRecentTag(
|
||||
Object.fromEntries(
|
||||
entries.filter(([version, _]) => !version.includes("-")),
|
||||
),
|
||||
);
|
||||
if (latestFullRelease) {
|
||||
return latestFullRelease;
|
||||
}
|
||||
|
||||
const latestPrerelease = getMostRecentTag(
|
||||
Object.fromEntries(entries.filter(([version, _]) => version.includes("-"))),
|
||||
);
|
||||
return latestPrerelease;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, string>} tagList
|
||||
* @returns {string | undefined}
|
||||
*/
|
||||
function getMostRecentTag(tagList) {
|
||||
let current, currentDate;
|
||||
|
||||
for (const [version, timestamp] of Object.entries(tagList)) {
|
||||
if (!currentDate || currentDate < timestamp) {
|
||||
current = version;
|
||||
currentDate = timestamp;
|
||||
}
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function getHasSuppressedVersions() {
|
||||
return state.hasSuppressedVersions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a package name matches an exclusion pattern.
|
||||
* Supports trailing wildcard (*) for prefix matching.
|
||||
* @param {string} packageName
|
||||
* @param {string} pattern
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function matchesExclusionPattern(packageName, pattern) {
|
||||
if (pattern.endsWith("/*")) {
|
||||
return packageName.startsWith(pattern.slice(0, -1));
|
||||
}
|
||||
return packageName === pattern;
|
||||
}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
import {
|
||||
getNpmCustomRegistries,
|
||||
skipMinimumPackageAge,
|
||||
} from "../../../../config/settings.js";
|
||||
import { isMalwarePackage } from "../../../../scanning/audit/index.js";
|
||||
import { interceptRequests } from "../interceptorBuilder.js";
|
||||
import {
|
||||
isPackageInfoUrl,
|
||||
modifyNpmInfoRequestHeaders,
|
||||
modifyNpmInfoResponse,
|
||||
} from "./modifyNpmInfo.js";
|
||||
import { parseNpmPackageUrl } from "./parseNpmPackageUrl.js";
|
||||
|
||||
const knownJsRegistries = [
|
||||
"registry.npmjs.org",
|
||||
"registry.yarnpkg.com",
|
||||
"registry.npmjs.com",
|
||||
];
|
||||
|
||||
/**
|
||||
* @param {string} url
|
||||
* @returns {import("../interceptorBuilder.js").Interceptor | undefined}
|
||||
*/
|
||||
export function npmInterceptorForUrl(url) {
|
||||
const registry = [...knownJsRegistries, ...getNpmCustomRegistries()].find(
|
||||
(reg) => url.includes(reg),
|
||||
);
|
||||
|
||||
if (registry) {
|
||||
return buildNpmInterceptor(registry);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} registry
|
||||
* @returns {import("../interceptorBuilder.js").Interceptor}
|
||||
*/
|
||||
function buildNpmInterceptor(registry) {
|
||||
return interceptRequests(async (reqContext) => {
|
||||
const { packageName, version } = parseNpmPackageUrl(
|
||||
reqContext.targetUrl,
|
||||
registry,
|
||||
);
|
||||
|
||||
if (await isMalwarePackage(packageName, version)) {
|
||||
reqContext.blockMalware(packageName, version);
|
||||
}
|
||||
|
||||
if (!skipMinimumPackageAge() && isPackageInfoUrl(reqContext.targetUrl)) {
|
||||
reqContext.modifyRequestHeaders(modifyNpmInfoRequestHeaders);
|
||||
reqContext.modifyBody(modifyNpmInfoResponse);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,607 @@
|
|||
import { describe, it, mock } from "node:test";
|
||||
import assert from "node:assert";
|
||||
|
||||
describe("npmInterceptor minimum package age", async () => {
|
||||
let minimumPackageAgeSettings = 48;
|
||||
let skipMinimumPackageAgeSetting = false;
|
||||
let minimumPackageAgeExclusionsSetting = [];
|
||||
|
||||
mock.module("../../../../config/settings.js", {
|
||||
namedExports: {
|
||||
getMinimumPackageAgeHours: () => minimumPackageAgeSettings,
|
||||
skipMinimumPackageAge: () => skipMinimumPackageAgeSetting,
|
||||
getNpmCustomRegistries: () => [],
|
||||
getNpmMinimumPackageAgeExclusions: () =>
|
||||
minimumPackageAgeExclusionsSetting,
|
||||
},
|
||||
});
|
||||
|
||||
mock.module("../../../../scanning/audit/index.js", {
|
||||
namedExports: {
|
||||
isMalwarePackage: async () => {
|
||||
return false;
|
||||
},
|
||||
},
|
||||
});
|
||||
mock.module("../../../../environment/userInteraction.js", {
|
||||
namedExports: {
|
||||
ui: {
|
||||
startProcess: () => {},
|
||||
writeError: () => {},
|
||||
writeInformation: () => {},
|
||||
writeWarning: () => {},
|
||||
writeVerbose: () => {},
|
||||
writeExitWithoutInstallingMaliciousPackages: () => {},
|
||||
emptyLine: () => {},
|
||||
},
|
||||
},
|
||||
});
|
||||
const { npmInterceptorForUrl } = await import("./npmInterceptor.js");
|
||||
|
||||
for (const packageInfoUrl of [
|
||||
// Basic package metadata
|
||||
"https://registry.npmjs.org/lodash",
|
||||
"https://registry.npmjs.org/express",
|
||||
// Scoped packages
|
||||
"https://registry.npmjs.org/@vercel/functions",
|
||||
"https://registry.npmjs.org/@babel/core",
|
||||
"https://registry.npmjs.org/@types/node",
|
||||
// With query parameters
|
||||
"https://registry.npmjs.org/lodash?write=true",
|
||||
"https://registry.npmjs.org/@babel/core?param=value&other=test",
|
||||
// With fragments
|
||||
"https://registry.npmjs.org/lodash#readme",
|
||||
"https://registry.npmjs.org/@babel/core#installation",
|
||||
// Version-specific metadata
|
||||
"https://registry.npmjs.org/lodash/4.17.21",
|
||||
"https://registry.npmjs.org/lodash/latest",
|
||||
"https://registry.npmjs.org/@babel/core/7.21.4",
|
||||
// URL-encoded scoped packages
|
||||
"https://registry.npmjs.org/@types%2Fnode",
|
||||
"https://registry.npmjs.org/@babel%2Fcore",
|
||||
// With trailing slashes
|
||||
"https://registry.npmjs.org/lodash/",
|
||||
"https://registry.npmjs.org/@babel/core/",
|
||||
]) {
|
||||
it(`modifyResponse should be true for package info requests: ${packageInfoUrl}`, async () => {
|
||||
const interceptor = npmInterceptorForUrl(packageInfoUrl);
|
||||
const requestInterceptor =
|
||||
await interceptor.handleRequest(packageInfoUrl);
|
||||
|
||||
assert.equal(requestInterceptor.modifiesResponse(), true);
|
||||
});
|
||||
}
|
||||
|
||||
for (const packageUrl of [
|
||||
// Regular package tarballs
|
||||
"https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
||||
"https://registry.npmjs.org/express/-/express-4.18.2.tgz",
|
||||
// Scoped package tarballs
|
||||
"https://registry.npmjs.org/@babel/core/-/core-8.0.0-alpha.1.tgz",
|
||||
"https://registry.npmjs.org/@types/node/-/node-20.10.5.tgz",
|
||||
// Tarballs with query parameters (integrity checks)
|
||||
"https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz?integrity=sha512-abc123",
|
||||
"https://registry.npmjs.org/@babel/core/-/core-7.21.4.tgz?integrity=sha512-def456&cache=false",
|
||||
// Tarballs with fragments
|
||||
"https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz#sha512-abc123",
|
||||
"https://registry.npmjs.org/@babel/core/-/core-7.21.4.tgz#hash",
|
||||
// Prerelease versions
|
||||
"https://registry.npmjs.org/react/-/react-18.3.0-canary-abc123.tgz",
|
||||
"https://registry.npmjs.org/lodash/-/lodash-5.0.0-beta.1.tgz",
|
||||
]) {
|
||||
it(`modifyResponse should be false for package downloads: ${packageUrl}`, async () => {
|
||||
const interceptor = npmInterceptorForUrl(packageUrl);
|
||||
const requestInterceptor = await interceptor.handleRequest(packageUrl);
|
||||
|
||||
assert.equal(requestInterceptor.modifiesResponse(), false);
|
||||
});
|
||||
}
|
||||
|
||||
for (const specialEndpoint of [
|
||||
// Security advisory endpoints
|
||||
"https://registry.npmjs.org/-/npm/v1/security/advisories/bulk",
|
||||
"https://registry.npmjs.org/-/npm/v1/security/audits",
|
||||
"https://registry.npmjs.org/-/npm/v1/security/audits/quick",
|
||||
// Search endpoints
|
||||
"https://registry.npmjs.org/-/v1/search?text=lodash&size=20",
|
||||
"https://registry.npmjs.org/-/v1/search?text=react&from=0",
|
||||
// Package access/collaboration endpoints
|
||||
"https://registry.npmjs.org/-/package/lodash/access",
|
||||
"https://registry.npmjs.org/-/package/@babel/core/collaborators",
|
||||
"https://registry.npmjs.org/-/package/lodash/dist-tags",
|
||||
"https://registry.npmjs.org/-/package/@babel/core/dist-tags/latest",
|
||||
// User/organization endpoints
|
||||
"https://registry.npmjs.org/-/user/org.couchdb.user:username",
|
||||
"https://registry.npmjs.org/-/org/myorg/package",
|
||||
// Anonymous metrics
|
||||
"https://registry.npmjs.org/-/npm/anon-metrics/v1/",
|
||||
// Ping/health check
|
||||
"https://registry.npmjs.org/-/ping",
|
||||
]) {
|
||||
it(`modifyResponse should be false for special endpoints: ${specialEndpoint}`, async () => {
|
||||
const interceptor = npmInterceptorForUrl(specialEndpoint);
|
||||
const requestInterceptor =
|
||||
await interceptor.handleRequest(specialEndpoint);
|
||||
|
||||
assert.equal(requestInterceptor.modifiesResponse(), false);
|
||||
});
|
||||
}
|
||||
|
||||
it("Should remove packages older than the treshold", async () => {
|
||||
minimumPackageAgeSettings = 5;
|
||||
const packageUrl = "https://registry.npmjs.org/lodash";
|
||||
|
||||
const modifiedBody = await runModifyNpmInfoRequest(
|
||||
packageUrl,
|
||||
JSON.stringify({
|
||||
name: "lodash",
|
||||
["dist-tags"]: {
|
||||
latest: "3.0.0",
|
||||
},
|
||||
versions: {
|
||||
["1.0.0"]: {},
|
||||
["2.0.0"]: {},
|
||||
["3.0.0"]: {},
|
||||
},
|
||||
time: {
|
||||
created: getDate(-365 * 24),
|
||||
modified: getDate(-3),
|
||||
["1.0.0"]: getDate(-7),
|
||||
// cutoff-date here
|
||||
["2.0.0"]: getDate(-4),
|
||||
["3.0.0"]: getDate(-3),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const modifiedJson = JSON.parse(modifiedBody);
|
||||
|
||||
assert.equal(Object.keys(modifiedJson.time).length, 3);
|
||||
assert.equal(Object.keys(modifiedJson.versions).length, 1);
|
||||
assert.ok(Object.keys(modifiedJson.time).includes("1.0.0"));
|
||||
assert.ok(Object.keys(modifiedJson.versions).includes("1.0.0"));
|
||||
assert.ok(!Object.keys(modifiedJson.time).includes("2.0.0"));
|
||||
assert.ok(!Object.keys(modifiedJson.versions).includes("2.0.0"));
|
||||
assert.ok(!Object.keys(modifiedJson.time).includes("3.0.0"));
|
||||
assert.ok(!Object.keys(modifiedJson.versions).includes("3.0.0"));
|
||||
});
|
||||
|
||||
it("Should set the package to the new latest non-preview release", async () => {
|
||||
minimumPackageAgeSettings = 5;
|
||||
const packageUrl = "https://registry.npmjs.org/lodash";
|
||||
|
||||
const modifiedBody = await runModifyNpmInfoRequest(
|
||||
packageUrl,
|
||||
JSON.stringify({
|
||||
name: "lodash",
|
||||
["dist-tags"]: {
|
||||
latest: "3.0.0",
|
||||
},
|
||||
versions: {
|
||||
["1.0.0"]: {},
|
||||
["2.0.0"]: {},
|
||||
["3.0.0"]: {},
|
||||
},
|
||||
time: {
|
||||
created: getDate(-365 * 24),
|
||||
modified: getDate(-3),
|
||||
["1.0.0"]: getDate(-7),
|
||||
["0.0.1"]: getDate(-8), // package order: this package is older than 1.0.0, it should not be considered latest
|
||||
["2.0.0-alpha"]: getDate(-6), //package is a pre-release, it should not be latest
|
||||
// cutoff-date here
|
||||
["2.0.0"]: getDate(-4),
|
||||
["3.0.0"]: getDate(-3),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const modifiedJson = JSON.parse(modifiedBody);
|
||||
|
||||
assert.equal(modifiedJson["dist-tags"]["latest"], "1.0.0");
|
||||
});
|
||||
|
||||
it("Should remove dist-tags if version was removed", async () => {
|
||||
minimumPackageAgeSettings = 5;
|
||||
const packageUrl = "https://registry.npmjs.org/lodash";
|
||||
|
||||
const modifiedBody = await runModifyNpmInfoRequest(
|
||||
packageUrl,
|
||||
JSON.stringify({
|
||||
name: "lodash",
|
||||
["dist-tags"]: {
|
||||
latest: "3.0.0",
|
||||
alpha: "2.0.0-alpha",
|
||||
},
|
||||
versions: {
|
||||
["1.0.0"]: {},
|
||||
["2.0.0"]: {},
|
||||
["3.0.0"]: {},
|
||||
},
|
||||
time: {
|
||||
created: getDate(-365 * 24),
|
||||
modified: getDate(-4),
|
||||
["1.0.0"]: getDate(-7),
|
||||
// cutoff-date here
|
||||
["2.0.0-alpha"]: getDate(-4),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const modifiedJson = JSON.parse(modifiedBody);
|
||||
console.log(modifiedJson);
|
||||
|
||||
assert.equal(modifiedJson["dist-tags"]["alpha"], undefined);
|
||||
});
|
||||
|
||||
it("Should not filter packages when skipMinimumPackageAge is enabled", async () => {
|
||||
minimumPackageAgeSettings = 5;
|
||||
skipMinimumPackageAgeSetting = true;
|
||||
const packageUrl = "https://registry.npmjs.org/lodash";
|
||||
|
||||
const originalBody = JSON.stringify({
|
||||
name: "lodash",
|
||||
["dist-tags"]: {
|
||||
latest: "3.0.0",
|
||||
},
|
||||
versions: {
|
||||
["1.0.0"]: {},
|
||||
["2.0.0"]: {},
|
||||
["3.0.0"]: {},
|
||||
},
|
||||
time: {
|
||||
created: getDate(-365 * 24),
|
||||
modified: getDate(-3),
|
||||
["1.0.0"]: getDate(-7),
|
||||
// cutoff-date here
|
||||
["2.0.0"]: getDate(-4),
|
||||
["3.0.0"]: getDate(-3),
|
||||
},
|
||||
});
|
||||
|
||||
const modifiedBody = await runModifyNpmInfoRequest(
|
||||
packageUrl,
|
||||
originalBody,
|
||||
);
|
||||
|
||||
const modifiedJson = JSON.parse(modifiedBody);
|
||||
|
||||
// All versions should remain unchanged
|
||||
assert.equal(Object.keys(modifiedJson.versions).length, 3);
|
||||
assert.ok(Object.keys(modifiedJson.versions).includes("1.0.0"));
|
||||
assert.ok(Object.keys(modifiedJson.versions).includes("2.0.0"));
|
||||
assert.ok(Object.keys(modifiedJson.versions).includes("3.0.0"));
|
||||
|
||||
// Latest should remain unchanged
|
||||
assert.equal(modifiedJson["dist-tags"]["latest"], "3.0.0");
|
||||
});
|
||||
|
||||
it("Should use custom minimum package age of 48 hours", async () => {
|
||||
minimumPackageAgeSettings = 48;
|
||||
skipMinimumPackageAgeSetting = false;
|
||||
const packageUrl = "https://registry.npmjs.org/lodash";
|
||||
|
||||
const modifiedBody = await runModifyNpmInfoRequest(
|
||||
packageUrl,
|
||||
JSON.stringify({
|
||||
name: "lodash",
|
||||
["dist-tags"]: {
|
||||
latest: "4.0.0",
|
||||
},
|
||||
versions: {
|
||||
["1.0.0"]: {},
|
||||
["2.0.0"]: {},
|
||||
["3.0.0"]: {},
|
||||
["4.0.0"]: {},
|
||||
},
|
||||
time: {
|
||||
created: getDate(-365 * 24),
|
||||
modified: getDate(-24),
|
||||
["1.0.0"]: getDate(-72), // 3 days old - should remain
|
||||
["2.0.0"]: getDate(-50), // ~2 days old - should remain
|
||||
// 48-hour cutoff here
|
||||
["3.0.0"]: getDate(-40), // ~1.7 days old - should be removed
|
||||
["4.0.0"]: getDate(-24), // 1 day old - should be removed
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const modifiedJson = JSON.parse(modifiedBody);
|
||||
|
||||
// Versions older than 48 hours should remain
|
||||
assert.ok(Object.keys(modifiedJson.versions).includes("1.0.0"));
|
||||
assert.ok(Object.keys(modifiedJson.versions).includes("2.0.0"));
|
||||
|
||||
// Versions newer than 48 hours should be removed
|
||||
assert.ok(!Object.keys(modifiedJson.versions).includes("3.0.0"));
|
||||
assert.ok(!Object.keys(modifiedJson.versions).includes("4.0.0"));
|
||||
|
||||
// Latest should be recalculated to 2.0.0
|
||||
assert.equal(modifiedJson["dist-tags"]["latest"], "2.0.0");
|
||||
|
||||
assert.equal(Object.keys(modifiedJson.versions).length, 2);
|
||||
});
|
||||
|
||||
it("Should use very small minimum package age of 1 hour", async () => {
|
||||
minimumPackageAgeSettings = 1;
|
||||
skipMinimumPackageAgeSetting = false;
|
||||
const packageUrl = "https://registry.npmjs.org/lodash";
|
||||
|
||||
const modifiedBody = await runModifyNpmInfoRequest(
|
||||
packageUrl,
|
||||
JSON.stringify({
|
||||
name: "lodash",
|
||||
["dist-tags"]: {
|
||||
latest: "3.0.0",
|
||||
},
|
||||
versions: {
|
||||
["1.0.0"]: {},
|
||||
["2.0.0"]: {},
|
||||
["3.0.0"]: {},
|
||||
},
|
||||
time: {
|
||||
created: getDate(-48),
|
||||
modified: getDate(0),
|
||||
["1.0.0"]: getDate(-3), // 3 hours old - should remain
|
||||
["2.0.0"]: getDate(-2), // 2 hours old - should remain
|
||||
// 1-hour cutoff here
|
||||
["3.0.0"]: getDate(0), // just published - should be removed
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const modifiedJson = JSON.parse(modifiedBody);
|
||||
|
||||
assert.equal(Object.keys(modifiedJson.versions).length, 2);
|
||||
assert.ok(Object.keys(modifiedJson.versions).includes("1.0.0"));
|
||||
assert.ok(Object.keys(modifiedJson.versions).includes("2.0.0"));
|
||||
assert.ok(!Object.keys(modifiedJson.versions).includes("3.0.0"));
|
||||
assert.equal(modifiedJson["dist-tags"]["latest"], "2.0.0");
|
||||
});
|
||||
|
||||
it("Should not filter packages when package is in exclusion list", async () => {
|
||||
minimumPackageAgeSettings = 5;
|
||||
skipMinimumPackageAgeSetting = false;
|
||||
minimumPackageAgeExclusionsSetting = ["lodash"];
|
||||
|
||||
const packageUrl = "https://registry.npmjs.org/lodash";
|
||||
|
||||
const originalBody = JSON.stringify({
|
||||
name: "lodash",
|
||||
["dist-tags"]: {
|
||||
latest: "3.0.0",
|
||||
},
|
||||
versions: {
|
||||
["1.0.0"]: {},
|
||||
["2.0.0"]: {},
|
||||
["3.0.0"]: {},
|
||||
},
|
||||
time: {
|
||||
created: getDate(-365 * 24),
|
||||
modified: getDate(-3),
|
||||
["1.0.0"]: getDate(-7),
|
||||
// cutoff-date here
|
||||
["2.0.0"]: getDate(-4),
|
||||
["3.0.0"]: getDate(-3), // Would normally be filtered
|
||||
},
|
||||
});
|
||||
|
||||
const modifiedBody = await runModifyNpmInfoRequest(
|
||||
packageUrl,
|
||||
originalBody,
|
||||
);
|
||||
const modifiedJson = JSON.parse(modifiedBody);
|
||||
|
||||
// All versions should remain unchanged since lodash is excluded
|
||||
assert.equal(Object.keys(modifiedJson.versions).length, 3);
|
||||
assert.ok(Object.keys(modifiedJson.versions).includes("1.0.0"));
|
||||
assert.ok(Object.keys(modifiedJson.versions).includes("2.0.0"));
|
||||
assert.ok(Object.keys(modifiedJson.versions).includes("3.0.0"));
|
||||
assert.equal(modifiedJson["dist-tags"]["latest"], "3.0.0");
|
||||
});
|
||||
|
||||
it("Should filter packages when package is NOT in exclusion list", async () => {
|
||||
minimumPackageAgeSettings = 5;
|
||||
skipMinimumPackageAgeSetting = false;
|
||||
minimumPackageAgeExclusionsSetting = ["react"]; // Different package
|
||||
|
||||
const packageUrl = "https://registry.npmjs.org/lodash";
|
||||
|
||||
const modifiedBody = await runModifyNpmInfoRequest(
|
||||
packageUrl,
|
||||
JSON.stringify({
|
||||
name: "lodash",
|
||||
["dist-tags"]: { latest: "3.0.0" },
|
||||
versions: { ["1.0.0"]: {}, ["3.0.0"]: {} },
|
||||
time: {
|
||||
created: getDate(-365 * 24),
|
||||
modified: getDate(-3),
|
||||
["1.0.0"]: getDate(-7),
|
||||
["3.0.0"]: getDate(-3),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const modifiedJson = JSON.parse(modifiedBody);
|
||||
|
||||
// lodash should still be filtered since it's not in exclusions
|
||||
assert.equal(Object.keys(modifiedJson.versions).length, 1);
|
||||
assert.ok(Object.keys(modifiedJson.versions).includes("1.0.0"));
|
||||
assert.ok(!Object.keys(modifiedJson.versions).includes("3.0.0"));
|
||||
});
|
||||
|
||||
it("Should handle scoped packages in exclusion list", async () => {
|
||||
minimumPackageAgeSettings = 5;
|
||||
skipMinimumPackageAgeSetting = false;
|
||||
minimumPackageAgeExclusionsSetting = ["@babel/core"];
|
||||
|
||||
const packageUrl = "https://registry.npmjs.org/@babel/core";
|
||||
|
||||
const originalBody = JSON.stringify({
|
||||
name: "@babel/core",
|
||||
["dist-tags"]: { latest: "7.0.0" },
|
||||
versions: { ["6.0.0"]: {}, ["7.0.0"]: {} },
|
||||
time: {
|
||||
created: getDate(-365 * 24),
|
||||
modified: getDate(-1),
|
||||
["6.0.0"]: getDate(-100),
|
||||
["7.0.0"]: getDate(-1), // Would normally be filtered
|
||||
},
|
||||
});
|
||||
|
||||
const modifiedBody = await runModifyNpmInfoRequest(
|
||||
packageUrl,
|
||||
originalBody,
|
||||
);
|
||||
const modifiedJson = JSON.parse(modifiedBody);
|
||||
|
||||
// All versions should remain for excluded scoped package
|
||||
assert.equal(Object.keys(modifiedJson.versions).length, 2);
|
||||
assert.ok(Object.keys(modifiedJson.versions).includes("6.0.0"));
|
||||
assert.ok(Object.keys(modifiedJson.versions).includes("7.0.0"));
|
||||
});
|
||||
|
||||
it("Should handle multiple packages in exclusion list", async () => {
|
||||
minimumPackageAgeSettings = 5;
|
||||
skipMinimumPackageAgeSetting = false;
|
||||
minimumPackageAgeExclusionsSetting = ["react", "lodash", "@types/node"];
|
||||
|
||||
const packageUrl = "https://registry.npmjs.org/lodash";
|
||||
|
||||
const originalBody = JSON.stringify({
|
||||
name: "lodash",
|
||||
["dist-tags"]: { latest: "2.0.0" },
|
||||
versions: { ["1.0.0"]: {}, ["2.0.0"]: {} },
|
||||
time: {
|
||||
created: getDate(-365 * 24),
|
||||
modified: getDate(-1),
|
||||
["1.0.0"]: getDate(-100),
|
||||
["2.0.0"]: getDate(-1),
|
||||
},
|
||||
});
|
||||
|
||||
const modifiedBody = await runModifyNpmInfoRequest(
|
||||
packageUrl,
|
||||
originalBody,
|
||||
);
|
||||
const modifiedJson = JSON.parse(modifiedBody);
|
||||
|
||||
// All versions should remain since lodash is in the exclusion list
|
||||
assert.equal(Object.keys(modifiedJson.versions).length, 2);
|
||||
});
|
||||
|
||||
it("Should exclude packages matching wildcard pattern @scope/*", async () => {
|
||||
minimumPackageAgeSettings = 5;
|
||||
skipMinimumPackageAgeSetting = false;
|
||||
minimumPackageAgeExclusionsSetting = ["@aikidosec/*"];
|
||||
|
||||
const packageUrl = "https://registry.npmjs.org/@aikidosec/safe-chain";
|
||||
|
||||
const originalBody = JSON.stringify({
|
||||
name: "@aikidosec/safe-chain",
|
||||
["dist-tags"]: { latest: "2.0.0" },
|
||||
versions: { ["1.0.0"]: {}, ["2.0.0"]: {} },
|
||||
time: {
|
||||
created: getDate(-365 * 24),
|
||||
modified: getDate(-1),
|
||||
["1.0.0"]: getDate(-100),
|
||||
["2.0.0"]: getDate(-1), // Would normally be filtered
|
||||
},
|
||||
});
|
||||
|
||||
const modifiedBody = await runModifyNpmInfoRequest(
|
||||
packageUrl,
|
||||
originalBody,
|
||||
);
|
||||
const modifiedJson = JSON.parse(modifiedBody);
|
||||
|
||||
// All versions should remain since @aikidosec/* matches @aikidosec/safe-chain
|
||||
assert.equal(Object.keys(modifiedJson.versions).length, 2);
|
||||
assert.ok(Object.keys(modifiedJson.versions).includes("1.0.0"));
|
||||
assert.ok(Object.keys(modifiedJson.versions).includes("2.0.0"));
|
||||
});
|
||||
|
||||
it("Should NOT exclude packages that don't match wildcard pattern", async () => {
|
||||
minimumPackageAgeSettings = 5;
|
||||
skipMinimumPackageAgeSetting = false;
|
||||
minimumPackageAgeExclusionsSetting = ["@aikidosec/*"];
|
||||
|
||||
const packageUrl = "https://registry.npmjs.org/@other/package";
|
||||
|
||||
const originalBody = JSON.stringify({
|
||||
name: "@other/package",
|
||||
["dist-tags"]: { latest: "2.0.0" },
|
||||
versions: { ["1.0.0"]: {}, ["2.0.0"]: {} },
|
||||
time: {
|
||||
created: getDate(-365 * 24),
|
||||
modified: getDate(-1),
|
||||
["1.0.0"]: getDate(-100),
|
||||
["2.0.0"]: getDate(-1),
|
||||
},
|
||||
});
|
||||
|
||||
const modifiedBody = await runModifyNpmInfoRequest(
|
||||
packageUrl,
|
||||
originalBody,
|
||||
);
|
||||
const modifiedJson = JSON.parse(modifiedBody);
|
||||
|
||||
// Version 2.0.0 should be filtered since @other/package doesn't match @aikidosec/*
|
||||
assert.equal(Object.keys(modifiedJson.versions).length, 1);
|
||||
assert.ok(Object.keys(modifiedJson.versions).includes("1.0.0"));
|
||||
});
|
||||
|
||||
it("Should reset exclusions between tests", async () => {
|
||||
minimumPackageAgeSettings = 5;
|
||||
skipMinimumPackageAgeSetting = false;
|
||||
minimumPackageAgeExclusionsSetting = []; // Reset to empty
|
||||
|
||||
const packageUrl = "https://registry.npmjs.org/lodash";
|
||||
|
||||
const modifiedBody = await runModifyNpmInfoRequest(
|
||||
packageUrl,
|
||||
JSON.stringify({
|
||||
name: "lodash",
|
||||
["dist-tags"]: { latest: "2.0.0" },
|
||||
versions: { ["1.0.0"]: {}, ["2.0.0"]: {} },
|
||||
time: {
|
||||
created: getDate(-365 * 24),
|
||||
modified: getDate(-1),
|
||||
["1.0.0"]: getDate(-100),
|
||||
["2.0.0"]: getDate(-1),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const modifiedJson = JSON.parse(modifiedBody);
|
||||
|
||||
// Version 2.0.0 should be filtered since exclusions are empty
|
||||
assert.equal(Object.keys(modifiedJson.versions).length, 1);
|
||||
assert.ok(Object.keys(modifiedJson.versions).includes("1.0.0"));
|
||||
});
|
||||
|
||||
function getDate(plusHours) {
|
||||
const date = new Date();
|
||||
date.setHours(date.getHours() + plusHours);
|
||||
|
||||
return date;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("../interceptorBuilder.js").Interceptor} interceptor
|
||||
* @param {string} body
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
async function runModifyNpmInfoRequest(url, body) {
|
||||
const interceptor = npmInterceptorForUrl(url);
|
||||
const requestHandler = await interceptor.handleRequest(url);
|
||||
|
||||
if (requestHandler.modifiesResponse()) {
|
||||
const modifiedBuffer = requestHandler.modifyBody(Buffer.from(body), {
|
||||
["content-type"]: "application/json",
|
||||
});
|
||||
return modifiedBuffer.toString("utf8");
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
});
|
||||
|
|
@ -0,0 +1,268 @@
|
|||
import { describe, it, mock } from "node:test";
|
||||
import assert from "node:assert";
|
||||
|
||||
let lastPackage;
|
||||
let malwareResponse = false;
|
||||
let customRegistries = [];
|
||||
|
||||
mock.module("../../../../scanning/audit/index.js", {
|
||||
namedExports: {
|
||||
isMalwarePackage: async (packageName, version) => {
|
||||
lastPackage = { packageName, version };
|
||||
return malwareResponse;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
mock.module("../../../../config/settings.js", {
|
||||
namedExports: {
|
||||
LOGGING_SILENT: "silent",
|
||||
LOGGING_NORMAL: "normal",
|
||||
LOGGING_VERBOSE: "verbose",
|
||||
ECOSYSTEM_JS: "js",
|
||||
ECOSYSTEM_PY: "py",
|
||||
getLoggingLevel: () => "normal",
|
||||
getEcoSystem: () => "js",
|
||||
setEcoSystem: () => {},
|
||||
getMinimumPackageAgeHours: () => 24,
|
||||
getNpmCustomRegistries: () => customRegistries,
|
||||
getNpmMinimumPackageAgeExclusions: () => [],
|
||||
skipMinimumPackageAge: () => false,
|
||||
},
|
||||
});
|
||||
|
||||
describe("npmInterceptor", async () => {
|
||||
const { npmInterceptorForUrl } = await import("./npmInterceptor.js");
|
||||
|
||||
const parserCases = [
|
||||
// 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" },
|
||||
},
|
||||
// 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",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
parserCases.forEach(({ url, expected }, index) => {
|
||||
it(`should parse URL ${index + 1}: ${url}`, async () => {
|
||||
const interceptor = npmInterceptorForUrl(url);
|
||||
assert.ok(
|
||||
interceptor,
|
||||
"Interceptor should be created for known npm registry",
|
||||
);
|
||||
|
||||
await interceptor.handleRequest(url);
|
||||
|
||||
assert.deepEqual(lastPackage, expected);
|
||||
});
|
||||
});
|
||||
|
||||
it("should not create interceptor for unknown registry", () => {
|
||||
const url = "https://example.com/some-package/-/some-package-1.0.0.tgz";
|
||||
|
||||
const interceptor = npmInterceptorForUrl(url);
|
||||
|
||||
assert.equal(
|
||||
interceptor,
|
||||
undefined,
|
||||
"Interceptor should be undefined for unknown registry",
|
||||
);
|
||||
});
|
||||
|
||||
it("should block malicious package", async () => {
|
||||
const url =
|
||||
"https://registry.npmjs.org/malicious-package/-/malicious-package-1.0.0.tgz";
|
||||
malwareResponse = true;
|
||||
|
||||
const interceptor = npmInterceptorForUrl(url);
|
||||
|
||||
const result = await interceptor.handleRequest(url);
|
||||
|
||||
assert.ok(result.blockResponse, "Should contain a blockResponse");
|
||||
assert.equal(
|
||||
result.blockResponse.statusCode,
|
||||
403,
|
||||
"Block response should have status code 403",
|
||||
);
|
||||
assert.equal(
|
||||
result.blockResponse.message,
|
||||
"Forbidden - blocked by safe-chain",
|
||||
"Block response should have correct status message",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("npmInterceptor with custom registries", async () => {
|
||||
const { npmInterceptorForUrl } = await import("./npmInterceptor.js");
|
||||
|
||||
it("should create interceptor for custom registry", async () => {
|
||||
// Set custom registries for this test
|
||||
customRegistries = ["npm.company.com", "registry.internal.net"];
|
||||
const url = "https://npm.company.com/lodash/-/lodash-4.17.21.tgz";
|
||||
|
||||
const interceptor = npmInterceptorForUrl(url);
|
||||
|
||||
assert.ok(interceptor, "Interceptor should be created for custom registry");
|
||||
|
||||
await interceptor.handleRequest(url);
|
||||
|
||||
assert.deepEqual(lastPackage, {
|
||||
packageName: "lodash",
|
||||
version: "4.17.21",
|
||||
});
|
||||
});
|
||||
|
||||
it("should create interceptor for custom registry with scoped packages", async () => {
|
||||
// Set custom registries for this test
|
||||
customRegistries = ["npm.company.com", "registry.internal.net"];
|
||||
malwareResponse = false;
|
||||
|
||||
const url =
|
||||
"https://registry.internal.net/@company/package/-/package-1.0.0.tgz";
|
||||
|
||||
const interceptor = npmInterceptorForUrl(url);
|
||||
|
||||
assert.ok(
|
||||
interceptor,
|
||||
"Interceptor should be created for custom registry with scoped package",
|
||||
);
|
||||
|
||||
await interceptor.handleRequest(url);
|
||||
|
||||
assert.deepEqual(lastPackage, {
|
||||
packageName: "@company/package",
|
||||
version: "1.0.0",
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle multiple custom registries", async () => {
|
||||
// Set custom registries for this test
|
||||
customRegistries = ["npm.company.com", "registry.internal.net"];
|
||||
malwareResponse = false;
|
||||
|
||||
const url1 = "https://npm.company.com/lodash/-/lodash-4.17.21.tgz";
|
||||
const url2 = "https://registry.internal.net/express/-/express-4.18.2.tgz";
|
||||
|
||||
const interceptor1 = npmInterceptorForUrl(url1);
|
||||
const interceptor2 = npmInterceptorForUrl(url2);
|
||||
|
||||
assert.ok(interceptor1, "Should create interceptor for first registry");
|
||||
assert.ok(interceptor2, "Should create interceptor for second registry");
|
||||
|
||||
await interceptor1.handleRequest(url1);
|
||||
assert.deepEqual(lastPackage, {
|
||||
packageName: "lodash",
|
||||
version: "4.17.21",
|
||||
});
|
||||
|
||||
await interceptor2.handleRequest(url2);
|
||||
assert.deepEqual(lastPackage, {
|
||||
packageName: "express",
|
||||
version: "4.18.2",
|
||||
});
|
||||
});
|
||||
|
||||
it("should not create interceptor for non-custom registry", () => {
|
||||
// Set custom registries for this test
|
||||
customRegistries = ["npm.company.com", "registry.internal.net"];
|
||||
malwareResponse = false;
|
||||
|
||||
const url = "https://unknown.registry.com/package/-/package-1.0.0.tgz";
|
||||
|
||||
const interceptor = npmInterceptorForUrl(url);
|
||||
|
||||
assert.equal(
|
||||
interceptor,
|
||||
undefined,
|
||||
"Should not create interceptor for unknown registry",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
/**
|
||||
* @param {string} url
|
||||
* @param {string} registry
|
||||
* @returns {{packageName: string | undefined, version: string | undefined}}
|
||||
*/
|
||||
export function parseNpmPackageUrl(url, registry) {
|
||||
let packageName, version;
|
||||
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,135 @@
|
|||
import { getPipCustomRegistries } from "../../../config/settings.js";
|
||||
import { isMalwarePackage } from "../../../scanning/audit/index.js";
|
||||
import { interceptRequests } from "./interceptorBuilder.js";
|
||||
|
||||
const knownPipRegistries = [
|
||||
"files.pythonhosted.org",
|
||||
"pypi.org",
|
||||
"pypi.python.org",
|
||||
"pythonhosted.org",
|
||||
];
|
||||
|
||||
/**
|
||||
* @param {string} url
|
||||
* @returns {import("./interceptorBuilder.js").Interceptor | undefined}
|
||||
*/
|
||||
export function pipInterceptorForUrl(url) {
|
||||
const customRegistries = getPipCustomRegistries();
|
||||
const registries = [...knownPipRegistries, ...customRegistries];
|
||||
const registry = registries.find((reg) => url.includes(reg));
|
||||
|
||||
if (registry) {
|
||||
return buildPipInterceptor(registry);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} registry
|
||||
* @returns {import("./interceptorBuilder.js").Interceptor | undefined}
|
||||
*/
|
||||
function buildPipInterceptor(registry) {
|
||||
return interceptRequests(async (reqContext) => {
|
||||
const { packageName, version } = parsePipPackageFromUrl(
|
||||
reqContext.targetUrl,
|
||||
registry,
|
||||
);
|
||||
|
||||
// Normalize underscores to hyphens for DB matching, as PyPI allows underscores in distribution names.
|
||||
// Per python, packages that differ only by hyphen vs underscore are considered the same.
|
||||
const hyphenName = packageName?.includes("_")
|
||||
? packageName.replace(/_/g, "-")
|
||||
: packageName;
|
||||
|
||||
const isMalicious =
|
||||
(await isMalwarePackage(packageName, version)) ||
|
||||
(await isMalwarePackage(hyphenName, version));
|
||||
|
||||
if (isMalicious) {
|
||||
reqContext.blockMalware(packageName, version);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} url
|
||||
* @param {string} registry
|
||||
* @returns {{packageName: string | undefined, version: string | undefined}}
|
||||
*/
|
||||
function parsePipPackageFromUrl(url, registry) {
|
||||
let packageName, version;
|
||||
|
||||
// Basic validation
|
||||
if (!registry || typeof url !== "string") {
|
||||
return { packageName, version };
|
||||
}
|
||||
|
||||
// Quick sanity check on the URL + parse
|
||||
let urlObj;
|
||||
try {
|
||||
urlObj = new URL(url);
|
||||
} catch {
|
||||
return { packageName, version };
|
||||
}
|
||||
|
||||
// Get the last path segment (filename) and decode it (strip query & fragment automatically)
|
||||
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) and Poetry's preflight metadata (.whl.metadata)
|
||||
// Examples:
|
||||
// foo_bar-2.0.0-py3-none-any.whl
|
||||
// foo_bar-2.0.0-py3-none-any.whl.metadata
|
||||
const wheelExtRe = /\.whl(?:\.metadata)?$/;
|
||||
const wheelExtMatch = filename.match(wheelExtRe);
|
||||
if (wheelExtMatch) {
|
||||
const base = filename.replace(wheelExtRe, "");
|
||||
const firstDash = base.indexOf("-");
|
||||
if (firstDash > 0) {
|
||||
const dist = base.slice(0, firstDash); // may contain underscores
|
||||
const rest = base.slice(firstDash + 1); // version + the rest of tags
|
||||
const secondDash = rest.indexOf("-");
|
||||
const rawVersion = secondDash >= 0 ? rest.slice(0, secondDash) : rest;
|
||||
packageName = dist;
|
||||
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 };
|
||||
}
|
||||
return { packageName, version };
|
||||
}
|
||||
}
|
||||
|
||||
// Source dist (sdist) and potential metadata sidecars (e.g., .tar.gz.metadata)
|
||||
const sdistExtWithMetadataRe =
|
||||
/\.(tar\.gz|zip|tar\.bz2|tar\.xz)(\.metadata)?$/i;
|
||||
const sdistExtMatch = filename.match(sdistExtWithMetadataRe);
|
||||
if (sdistExtMatch) {
|
||||
const base = filename.replace(sdistExtWithMetadataRe, "");
|
||||
const lastDash = base.lastIndexOf("-");
|
||||
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 };
|
||||
}
|
||||
return { packageName, version };
|
||||
}
|
||||
}
|
||||
// Unknown file type or invalid
|
||||
return { packageName: undefined, version: undefined };
|
||||
}
|
||||
|
|
@ -0,0 +1,193 @@
|
|||
import { describe, it, mock } from "node:test";
|
||||
import assert from "node:assert";
|
||||
|
||||
describe("pipInterceptor custom registries", async () => {
|
||||
let lastPackage;
|
||||
let malwareResponse = false;
|
||||
let customRegistries = [];
|
||||
|
||||
mock.module("../../../config/settings.js", {
|
||||
namedExports: {
|
||||
getPipCustomRegistries: () => customRegistries,
|
||||
},
|
||||
});
|
||||
|
||||
mock.module("../../../scanning/audit/index.js", {
|
||||
namedExports: {
|
||||
isMalwarePackage: async (packageName, version) => {
|
||||
lastPackage = { packageName, version };
|
||||
return malwareResponse;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { pipInterceptorForUrl } = await import("./pipInterceptor.js");
|
||||
|
||||
it("should create interceptor for custom registry", () => {
|
||||
customRegistries = ["my-custom-registry.example.com"];
|
||||
const url =
|
||||
"https://my-custom-registry.example.com/packages/xx/yy/foobar-1.2.3.tar.gz";
|
||||
|
||||
const interceptor = pipInterceptorForUrl(url);
|
||||
|
||||
assert.ok(interceptor, "Interceptor should be created for custom registry");
|
||||
});
|
||||
|
||||
it("should parse package from custom registry URL", async () => {
|
||||
customRegistries = ["my-custom-registry.example.com"];
|
||||
const url =
|
||||
"https://my-custom-registry.example.com/packages/xx/yy/foobar-1.2.3.tar.gz";
|
||||
|
||||
const interceptor = pipInterceptorForUrl(url);
|
||||
assert.ok(interceptor, "Interceptor should be created");
|
||||
|
||||
await interceptor.handleRequest(url);
|
||||
|
||||
assert.deepEqual(lastPackage, {
|
||||
packageName: "foobar",
|
||||
version: "1.2.3",
|
||||
});
|
||||
});
|
||||
|
||||
it("should parse wheel package from custom registry URL", async () => {
|
||||
customRegistries = ["private-pypi.internal.com"];
|
||||
const url =
|
||||
"https://private-pypi.internal.com/packages/foo_bar-2.0.0-py3-none-any.whl";
|
||||
|
||||
const interceptor = pipInterceptorForUrl(url);
|
||||
assert.ok(interceptor, "Interceptor should be created");
|
||||
|
||||
await interceptor.handleRequest(url);
|
||||
|
||||
assert.deepEqual(lastPackage, {
|
||||
packageName: "foo-bar",
|
||||
version: "2.0.0",
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle multiple custom registries", async () => {
|
||||
customRegistries = ["registry-one.example.com", "registry-two.example.com"];
|
||||
|
||||
const url1 =
|
||||
"https://registry-one.example.com/packages/package1-1.0.0.tar.gz";
|
||||
const url2 =
|
||||
"https://registry-two.example.com/packages/package2-2.0.0.tar.gz";
|
||||
|
||||
const interceptor1 = pipInterceptorForUrl(url1);
|
||||
const interceptor2 = pipInterceptorForUrl(url2);
|
||||
|
||||
assert.ok(interceptor1, "Interceptor should be created for first registry");
|
||||
assert.ok(
|
||||
interceptor2,
|
||||
"Interceptor should be created for second registry",
|
||||
);
|
||||
});
|
||||
|
||||
it("should block malicious package from custom registry", async () => {
|
||||
customRegistries = ["my-custom-registry.example.com"];
|
||||
malwareResponse = true;
|
||||
|
||||
const url =
|
||||
"https://my-custom-registry.example.com/packages/malicious_package-1.0.0.tar.gz";
|
||||
|
||||
const interceptor = pipInterceptorForUrl(url);
|
||||
assert.ok(interceptor, "Interceptor should be created");
|
||||
|
||||
const result = await interceptor.handleRequest(url);
|
||||
|
||||
assert.ok(result.blockResponse, "Should contain a blockResponse");
|
||||
assert.equal(
|
||||
result.blockResponse.statusCode,
|
||||
403,
|
||||
"Block response should have status code 403",
|
||||
);
|
||||
assert.equal(
|
||||
result.blockResponse.message,
|
||||
"Forbidden - blocked by safe-chain",
|
||||
"Block response should have correct status message",
|
||||
);
|
||||
|
||||
malwareResponse = false;
|
||||
});
|
||||
|
||||
it("should still work with known registries when custom registries are set", async () => {
|
||||
customRegistries = ["my-custom-registry.example.com"];
|
||||
|
||||
const url =
|
||||
"https://files.pythonhosted.org/packages/xx/yy/foobar-1.2.3.tar.gz";
|
||||
|
||||
const interceptor = pipInterceptorForUrl(url);
|
||||
|
||||
assert.ok(
|
||||
interceptor,
|
||||
"Interceptor should be created for known registry even with custom registries set",
|
||||
);
|
||||
|
||||
await interceptor.handleRequest(url);
|
||||
|
||||
assert.deepEqual(lastPackage, {
|
||||
packageName: "foobar",
|
||||
version: "1.2.3",
|
||||
});
|
||||
});
|
||||
|
||||
it("should not create interceptor for unknown registry when custom registries are set", () => {
|
||||
customRegistries = ["my-custom-registry.example.com"];
|
||||
const url =
|
||||
"https://unknown-registry.example.com/packages/foobar-1.0.0.tar.gz";
|
||||
|
||||
const interceptor = pipInterceptorForUrl(url);
|
||||
|
||||
assert.equal(
|
||||
interceptor,
|
||||
undefined,
|
||||
"Interceptor should be undefined for unknown registry",
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle empty custom registries array", () => {
|
||||
customRegistries = [];
|
||||
const url =
|
||||
"https://my-custom-registry.example.com/packages/foobar-1.0.0.tar.gz";
|
||||
|
||||
const interceptor = pipInterceptorForUrl(url);
|
||||
|
||||
assert.equal(
|
||||
interceptor,
|
||||
undefined,
|
||||
"Interceptor should be undefined when no custom registries are configured",
|
||||
);
|
||||
});
|
||||
|
||||
it("should parse .whl.metadata from custom registry", async () => {
|
||||
customRegistries = ["private-pypi.internal.com"];
|
||||
const url =
|
||||
"https://private-pypi.internal.com/packages/foo_bar-2.0.0-py3-none-any.whl.metadata";
|
||||
|
||||
const interceptor = pipInterceptorForUrl(url);
|
||||
assert.ok(interceptor, "Interceptor should be created");
|
||||
|
||||
await interceptor.handleRequest(url);
|
||||
|
||||
assert.deepEqual(lastPackage, {
|
||||
packageName: "foo-bar",
|
||||
version: "2.0.0",
|
||||
});
|
||||
});
|
||||
|
||||
it("should parse .tar.gz.metadata from custom registry", async () => {
|
||||
customRegistries = ["private-pypi.internal.com"];
|
||||
const url =
|
||||
"https://private-pypi.internal.com/packages/foo_bar-2.0.0.tar.gz.metadata";
|
||||
|
||||
const interceptor = pipInterceptorForUrl(url);
|
||||
assert.ok(interceptor, "Interceptor should be created");
|
||||
|
||||
await interceptor.handleRequest(url);
|
||||
|
||||
assert.deepEqual(lastPackage, {
|
||||
packageName: "foo-bar",
|
||||
version: "2.0.0",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,145 @@
|
|||
import { describe, it, mock } from "node:test";
|
||||
import assert from "node:assert";
|
||||
|
||||
describe("pipInterceptor", async () => {
|
||||
let lastPackage;
|
||||
let malwareResponse = false;
|
||||
|
||||
mock.module("../../../scanning/audit/index.js", {
|
||||
namedExports: {
|
||||
isMalwarePackage: async (packageName, version) => {
|
||||
lastPackage = { packageName, version };
|
||||
return malwareResponse;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { pipInterceptorForUrl } = await import("./pipInterceptor.js");
|
||||
|
||||
const parserCases = [
|
||||
// Valid pip URLs
|
||||
{
|
||||
url: "https://files.pythonhosted.org/packages/xx/yy/foobar-1.2.3.tar.gz",
|
||||
expected: { packageName: "foobar", version: "1.2.3" },
|
||||
},
|
||||
{
|
||||
url: "https://pypi.org/packages/source/f/foobar/foobar-1.2.3.tar.gz",
|
||||
expected: { packageName: "foobar", version: "1.2.3" },
|
||||
},
|
||||
{
|
||||
url: "https://pypi.org/packages/source/f/foo-bar/foo-bar-0.9.0.tar.gz",
|
||||
expected: { packageName: "foo-bar", version: "0.9.0" },
|
||||
},
|
||||
{
|
||||
url: "https://pypi.org/packages/source/f/foo_bar/foo_bar-2.0.0-py3-none-any.whl",
|
||||
expected: { packageName: "foo-bar", version: "2.0.0" },
|
||||
},
|
||||
{
|
||||
// Poetry preflight metadata alongside wheel (.whl.metadata)
|
||||
url: "https://files.pythonhosted.org/packages/xx/yy/foo_bar-2.0.0-py3-none-any.whl.metadata",
|
||||
expected: { packageName: "foo-bar", version: "2.0.0" },
|
||||
},
|
||||
{
|
||||
url: "https://files.pythonhosted.org/packages/xx/yy/foo_bar-2.0.0-py3-none-any.whl",
|
||||
expected: { packageName: "foo-bar", version: "2.0.0" },
|
||||
},
|
||||
{
|
||||
url: "https://pypi.org/packages/source/f/foo.bar/foo.bar-1.0.0.tar.gz",
|
||||
expected: { packageName: "foo.bar", version: "1.0.0" },
|
||||
},
|
||||
{
|
||||
url: "https://pypi.org/packages/source/f/foo_bar/foo_bar-2.0.0b1.tar.gz",
|
||||
expected: { packageName: "foo-bar", version: "2.0.0b1" },
|
||||
},
|
||||
{
|
||||
// sdist with metadata sidecar (.tar.gz.metadata)
|
||||
url: "https://files.pythonhosted.org/packages/xx/yy/foo_bar-2.0.0.tar.gz.metadata",
|
||||
expected: { packageName: "foo-bar", version: "2.0.0" },
|
||||
},
|
||||
{
|
||||
url: "https://pypi.org/packages/source/f/foo_bar/foo_bar-2.0.0rc1.tar.gz",
|
||||
expected: { packageName: "foo-bar", version: "2.0.0rc1" },
|
||||
},
|
||||
{
|
||||
url: "https://pypi.org/packages/source/f/foo_bar/foo_bar-2.0.0.post1.tar.gz",
|
||||
expected: { packageName: "foo-bar", version: "2.0.0.post1" },
|
||||
},
|
||||
{
|
||||
url: "https://pypi.org/packages/source/f/foo_bar/foo_bar-2.0.0.dev1.tar.gz",
|
||||
expected: { packageName: "foo-bar", version: "2.0.0.dev1" },
|
||||
},
|
||||
{
|
||||
url: "https://pypi.org/packages/source/f/foo_bar/foo_bar-2.0.0a1.tar.gz",
|
||||
expected: { packageName: "foo-bar", version: "2.0.0a1" },
|
||||
},
|
||||
{
|
||||
url: "https://pypi.org/packages/source/f/foo_bar/foo_bar-2.0.0-cp38-cp38-manylinux1_x86_64.whl",
|
||||
expected: { packageName: "foo-bar", version: "2.0.0" },
|
||||
},
|
||||
// Invalid pip URLs
|
||||
{
|
||||
url: "https://pypi.org/simple/",
|
||||
expected: { packageName: undefined, version: undefined },
|
||||
},
|
||||
{
|
||||
url: "https://pypi.org/project/foobar/",
|
||||
expected: { packageName: undefined, version: undefined },
|
||||
},
|
||||
{
|
||||
url: "https://files.pythonhosted.org/packages/xx/yy/foobar-latest.tar.gz",
|
||||
expected: { packageName: undefined, version: undefined },
|
||||
},
|
||||
{
|
||||
url: "https://pypi.org/packages/source/f/foo_bar/foo_bar-latest.tar.gz",
|
||||
expected: { packageName: undefined, version: undefined },
|
||||
},
|
||||
];
|
||||
|
||||
parserCases.forEach(({ url, expected }, index) => {
|
||||
it(`should parse URL ${index + 1}: ${url}`, async () => {
|
||||
const interceptor = pipInterceptorForUrl(url);
|
||||
assert.ok(
|
||||
interceptor,
|
||||
"Interceptor should be created for known npm registry",
|
||||
);
|
||||
|
||||
await interceptor.handleRequest(url);
|
||||
|
||||
assert.deepEqual(lastPackage, expected);
|
||||
});
|
||||
});
|
||||
|
||||
it("should not create interceptor for unknown registry", () => {
|
||||
const url = "https://example.com/packages/xx/yy/foobar-1.2.3.tar.gz";
|
||||
|
||||
const interceptor = pipInterceptorForUrl(url);
|
||||
|
||||
assert.equal(
|
||||
interceptor,
|
||||
undefined,
|
||||
"Interceptor should be undefined for unknown registry",
|
||||
);
|
||||
});
|
||||
|
||||
it("should block malicious package", async () => {
|
||||
const url =
|
||||
"https://files.pythonhosted.org/packages/xx/yy/malicious_package-1.0.0.tar.gz";
|
||||
malwareResponse = true;
|
||||
|
||||
const interceptor = pipInterceptorForUrl(url);
|
||||
|
||||
const result = await interceptor.handleRequest(url);
|
||||
|
||||
assert.ok(result.blockResponse, "Should contain a blockResponse");
|
||||
assert.equal(
|
||||
result.blockResponse.statusCode,
|
||||
403,
|
||||
"Block response should have status code 403",
|
||||
);
|
||||
assert.equal(
|
||||
result.blockResponse.message,
|
||||
"Forbidden - blocked by safe-chain",
|
||||
"Block response should have correct status message",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
// Instance Metadata Service (IMDS) endpoints used by cloud providers.
|
||||
// Cloud SDK tools probe these to detect environment and retrieve credentials.
|
||||
// When outside cloud environments, connections timeout - we reduce timeout (3s vs 30s)
|
||||
// and suppress error logging since this is expected behavior.
|
||||
const imdsEndpoints = [
|
||||
"metadata.google.internal",
|
||||
"metadata.goog",
|
||||
"169.254.169.254", // AWS, Azure, Oracle Cloud, GCP
|
||||
];
|
||||
|
||||
export function isImdsEndpoint(/** @type {string} */ host) {
|
||||
return imdsEndpoints.includes(host);
|
||||
}
|
||||
|
|
@ -0,0 +1,234 @@
|
|||
import https from "https";
|
||||
import { generateCertForHost } from "./certUtils.js";
|
||||
import { HttpsProxyAgent } from "https-proxy-agent";
|
||||
import { ui } from "../../environment/userInteraction.js";
|
||||
import { gunzipSync, gzipSync } from "zlib";
|
||||
|
||||
/**
|
||||
* @typedef {import("./interceptors/interceptorBuilder.js").Interceptor} Interceptor
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {import("http").IncomingMessage} req
|
||||
* @param {import("http").ServerResponse} clientSocket
|
||||
* @param {Interceptor} interceptor
|
||||
*/
|
||||
export function mitmConnect(req, clientSocket, interceptor) {
|
||||
ui.writeVerbose(`Safe-chain: Set up MITM tunnel for ${req.url}`);
|
||||
const { hostname, port } = new URL(`http://${req.url}`);
|
||||
|
||||
clientSocket.on("error", (err) => {
|
||||
ui.writeVerbose(
|
||||
`Safe-chain: Client socket error for ${req.url}: ${err.message}`,
|
||||
);
|
||||
// NO-OP
|
||||
// This can happen if the client TCP socket sends RST instead of FIN.
|
||||
// Not subscribing to 'close' event will cause node to throw and crash.
|
||||
});
|
||||
|
||||
const server = createHttpsServer(hostname, port, interceptor);
|
||||
|
||||
server.on("error", (err) => {
|
||||
ui.writeError(`Safe-chain: HTTPS server error: ${err.message}`);
|
||||
if (!clientSocket.headersSent) {
|
||||
clientSocket.end("HTTP/1.1 502 Bad Gateway\r\n\r\n");
|
||||
} else if (clientSocket.writable) {
|
||||
clientSocket.end();
|
||||
}
|
||||
});
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} hostname
|
||||
* @param {string} port
|
||||
* @param {Interceptor} interceptor
|
||||
* @returns {import("https").Server}
|
||||
*/
|
||||
function createHttpsServer(hostname, port, interceptor) {
|
||||
const cert = generateCertForHost(hostname);
|
||||
|
||||
/**
|
||||
* @param {import("http").IncomingMessage} req
|
||||
* @param {import("http").ServerResponse} res
|
||||
*
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function handleRequest(req, res) {
|
||||
if (!req.url) {
|
||||
ui.writeError("Safe-chain: Request missing URL");
|
||||
res.writeHead(400, "Bad Request");
|
||||
res.end("Bad Request: Missing URL");
|
||||
return;
|
||||
}
|
||||
|
||||
const pathAndQuery = getRequestPathAndQuery(req.url);
|
||||
const targetUrl = `https://${hostname}${pathAndQuery}`;
|
||||
|
||||
const requestInterceptor = await interceptor.handleRequest(targetUrl);
|
||||
const blockResponse = requestInterceptor.blockResponse;
|
||||
|
||||
if (blockResponse) {
|
||||
ui.writeVerbose(`Safe-chain: Blocking request to ${targetUrl}`);
|
||||
res.writeHead(blockResponse.statusCode, blockResponse.message);
|
||||
res.end(blockResponse.message);
|
||||
return;
|
||||
}
|
||||
|
||||
// Collect request body
|
||||
forwardRequest(req, hostname, port, res, requestInterceptor);
|
||||
}
|
||||
|
||||
const server = https.createServer(
|
||||
{
|
||||
key: cert.privateKey,
|
||||
cert: cert.certificate,
|
||||
},
|
||||
handleRequest,
|
||||
);
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} url
|
||||
* @returns {string}
|
||||
*/
|
||||
function getRequestPathAndQuery(url) {
|
||||
if (url.startsWith("http://") || url.startsWith("https://")) {
|
||||
const parsedUrl = new URL(url);
|
||||
return parsedUrl.pathname + parsedUrl.search + parsedUrl.hash;
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("http").IncomingMessage} req
|
||||
* @param {string} hostname
|
||||
* @param {string} port
|
||||
* @param {import("http").ServerResponse} res
|
||||
* @param {import("./interceptors/interceptorBuilder.js").RequestInterceptionHandler} requestHandler
|
||||
*/
|
||||
function forwardRequest(req, hostname, port, res, requestHandler) {
|
||||
const proxyReq = createProxyRequest(hostname, port, req, res, requestHandler);
|
||||
|
||||
proxyReq.on("error", (err) => {
|
||||
ui.writeVerbose(
|
||||
`Safe-chain: Error occurred while proxying request to ${req.url} for ${hostname}: ${err.message}`,
|
||||
);
|
||||
res.writeHead(502);
|
||||
res.end("Bad Gateway");
|
||||
});
|
||||
|
||||
req.on("error", (err) => {
|
||||
ui.writeError(
|
||||
`Safe-chain: Error reading client request to ${req.url} for ${hostname}: ${err.message}`,
|
||||
);
|
||||
proxyReq.destroy();
|
||||
});
|
||||
|
||||
req.on("data", (chunk) => {
|
||||
proxyReq.write(chunk);
|
||||
});
|
||||
|
||||
req.on("end", () => {
|
||||
ui.writeVerbose(
|
||||
`Safe-chain: Finished proxying request to ${req.url} for ${hostname}`,
|
||||
);
|
||||
proxyReq.end();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} hostname
|
||||
* @param {string} port
|
||||
* @param {import("http").IncomingMessage} req
|
||||
* @param {import("http").ServerResponse} res
|
||||
* @param {import("./interceptors/interceptorBuilder.js").RequestInterceptionHandler} requestHandler
|
||||
*
|
||||
* @returns {import("http").ClientRequest}
|
||||
*/
|
||||
function createProxyRequest(hostname, port, req, res, requestHandler) {
|
||||
/** @type {NodeJS.Dict<string | string[]> | undefined} */
|
||||
let headers = { ...req.headers };
|
||||
// Remove the host header from the incoming request before forwarding.
|
||||
// Node's http module sets the correct host header for the target hostname automatically.
|
||||
if (headers.host) {
|
||||
delete headers.host;
|
||||
}
|
||||
headers = requestHandler.modifyRequestHeaders(headers);
|
||||
|
||||
/** @type {import("http").RequestOptions} */
|
||||
const options = {
|
||||
hostname: hostname,
|
||||
port: port || 443,
|
||||
path: req.url,
|
||||
method: req.method,
|
||||
headers: { ...headers },
|
||||
};
|
||||
|
||||
const httpsProxy = process.env.HTTPS_PROXY || process.env.https_proxy;
|
||||
if (httpsProxy) {
|
||||
options.agent = new HttpsProxyAgent(httpsProxy);
|
||||
}
|
||||
|
||||
const proxyReq = https.request(options, (proxyRes) => {
|
||||
proxyRes.on("error", (err) => {
|
||||
ui.writeError(
|
||||
`Safe-chain: Error reading upstream response to ${req.url} for ${hostname}: ${err.message}`,
|
||||
);
|
||||
if (!res.headersSent) {
|
||||
res.writeHead(502);
|
||||
res.end("Bad Gateway");
|
||||
}
|
||||
});
|
||||
|
||||
if (!proxyRes.statusCode) {
|
||||
ui.writeError(
|
||||
`Safe-chain: Proxy response missing status code to ${req.url} for ${hostname}`,
|
||||
);
|
||||
res.writeHead(500);
|
||||
res.end("Internal Server Error");
|
||||
return;
|
||||
}
|
||||
|
||||
const { statusCode, headers } = proxyRes;
|
||||
|
||||
if (requestHandler.modifiesResponse()) {
|
||||
/** @type {Array<any>} */
|
||||
let chunks = [];
|
||||
|
||||
proxyRes.on("data", (chunk) => chunks.push(chunk));
|
||||
|
||||
proxyRes.on("end", () => {
|
||||
/** @type {Buffer} */
|
||||
let buffer = Buffer.concat(chunks);
|
||||
|
||||
if (proxyRes.headers["content-encoding"] === "gzip") {
|
||||
buffer = gunzipSync(buffer);
|
||||
}
|
||||
|
||||
buffer = requestHandler.modifyBody(buffer, headers);
|
||||
|
||||
if (proxyRes.headers["content-encoding"] === "gzip") {
|
||||
buffer = gzipSync(buffer);
|
||||
}
|
||||
|
||||
res.writeHead(statusCode, headers);
|
||||
res.end(buffer);
|
||||
});
|
||||
} else {
|
||||
// If the response is not being modified, we can
|
||||
// just pipe without the need for buffering the output
|
||||
res.writeHead(statusCode, headers);
|
||||
proxyRes.pipe(res);
|
||||
}
|
||||
});
|
||||
|
||||
return proxyReq;
|
||||
}
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
import * as http from "http";
|
||||
import * as https from "https";
|
||||
import { ui } from "../../environment/userInteraction.js";
|
||||
|
||||
/**
|
||||
* @param {import("http").IncomingMessage} req
|
||||
* @param {import("http").ServerResponse} res
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
export function handleHttpProxyRequest(req, res) {
|
||||
if (!req.url) {
|
||||
ui.writeError("Safe-chain: Request missing URL");
|
||||
res.writeHead(400, "Bad Request");
|
||||
res.end("Bad Request: Missing URL");
|
||||
return;
|
||||
}
|
||||
|
||||
const url = new URL(req.url);
|
||||
|
||||
// The protocol for the plainHttpProxy should usually only be http:
|
||||
// but when the client for some reason sends an https: request directly
|
||||
// instead of using the CONNECT method, we should handle it gracefully.
|
||||
let protocol;
|
||||
if (url.protocol === "http:") {
|
||||
protocol = http;
|
||||
} else if (url.protocol === "https:") {
|
||||
protocol = https;
|
||||
} else {
|
||||
res.writeHead(502);
|
||||
res.end(`Bad Gateway: Unsupported protocol ${url.protocol}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const proxyRequest = protocol
|
||||
.request(
|
||||
req.url,
|
||||
{ method: req.method, headers: req.headers },
|
||||
(proxyRes) => {
|
||||
if (!proxyRes.statusCode) {
|
||||
ui.writeError("Safe-chain: Proxy response missing status code");
|
||||
res.writeHead(500);
|
||||
res.end("Internal Server Error");
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(proxyRes.statusCode, proxyRes.headers);
|
||||
proxyRes.pipe(res);
|
||||
|
||||
proxyRes.on("error", () => {
|
||||
// Proxy response stream error
|
||||
// Clean up client response stream
|
||||
if (res.writable) {
|
||||
res.end();
|
||||
}
|
||||
});
|
||||
|
||||
proxyRes.on("close", () => {
|
||||
// Clean up if the proxy response stream closes
|
||||
if (res.writable) {
|
||||
res.end();
|
||||
}
|
||||
});
|
||||
},
|
||||
)
|
||||
.on("error", (err) => {
|
||||
if (!res.headersSent) {
|
||||
res.writeHead(502);
|
||||
res.end(`Bad Gateway: ${err.message}`);
|
||||
} else {
|
||||
// Headers already sent, just destroy the response
|
||||
res.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
req.on("error", () => {
|
||||
// Client request stream error
|
||||
// Abort the proxy request
|
||||
proxyRequest.destroy();
|
||||
});
|
||||
|
||||
res.on("error", () => {
|
||||
// Client response stream error (client disconnected)
|
||||
// Clean up proxy streams
|
||||
proxyRequest.destroy();
|
||||
});
|
||||
|
||||
res.on("close", () => {
|
||||
// Client disconnected
|
||||
// Abort the proxy request to avoid unnecessary work
|
||||
proxyRequest.destroy();
|
||||
});
|
||||
|
||||
req.pipe(proxyRequest);
|
||||
}
|
||||
|
|
@ -0,0 +1,212 @@
|
|||
import * as net from "net";
|
||||
import { ui } from "../../environment/userInteraction.js";
|
||||
import { isImdsEndpoint } from "./isImdsEndpoint.js";
|
||||
import { getConnectTimeout } from "./getConnectTimeout.js";
|
||||
|
||||
/** @type {string[]} */
|
||||
let timedoutImdsEndpoints = [];
|
||||
|
||||
/**
|
||||
* @param {import("http").IncomingMessage} req
|
||||
* @param {import("http").ServerResponse} clientSocket
|
||||
* @param {Buffer} head
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("http").IncomingMessage} req
|
||||
* @param {import("http").ServerResponse} clientSocket
|
||||
* @param {Buffer} head
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
function tunnelRequestToDestination(req, clientSocket, head) {
|
||||
const { port, hostname } = new URL(`http://${req.url}`);
|
||||
const isImds = isImdsEndpoint(hostname);
|
||||
const targetPort = Number.parseInt(port) || 443;
|
||||
|
||||
if (timedoutImdsEndpoints.includes(hostname)) {
|
||||
clientSocket.end("HTTP/1.1 502 Bad Gateway\r\n\r\n");
|
||||
if (isImds) {
|
||||
ui.writeVerbose(
|
||||
`Safe-chain: Closing connection because previously timedout connect to ${hostname}`,
|
||||
);
|
||||
} else {
|
||||
ui.writeError(
|
||||
`Safe-chain: Closing connection because previously timedout connect to ${hostname}`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const connectTimeout = getConnectTimeout(hostname);
|
||||
|
||||
// Use JS setTimeout for true connection timeout (not idle timeout).
|
||||
// socket.setTimeout() measures inactivity, not time since connection attempt.
|
||||
const connectTimer = setTimeout(() => {
|
||||
if (isImds) {
|
||||
timedoutImdsEndpoints.push(hostname);
|
||||
ui.writeVerbose(
|
||||
`Safe-chain: connect to ${hostname}:${targetPort} timed out after ${connectTimeout}ms`,
|
||||
);
|
||||
} else {
|
||||
ui.writeError(
|
||||
`Safe-chain: connect to ${hostname}:${targetPort} timed out after ${connectTimeout}ms`,
|
||||
);
|
||||
}
|
||||
serverSocket.destroy();
|
||||
if (clientSocket.writable) {
|
||||
clientSocket.end("HTTP/1.1 504 Gateway Timeout\r\n\r\n");
|
||||
}
|
||||
}, connectTimeout);
|
||||
|
||||
const serverSocket = net.connect(targetPort, hostname, () => {
|
||||
// Clear timer to prevent false timeout errors after successful connection
|
||||
clearTimeout(connectTimer);
|
||||
|
||||
clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
|
||||
serverSocket.write(head);
|
||||
serverSocket.pipe(clientSocket);
|
||||
clientSocket.pipe(serverSocket);
|
||||
});
|
||||
|
||||
clientSocket.on("error", () => {
|
||||
// This can happen if the client TCP socket sends RST instead of FIN.
|
||||
// Not subscribing to 'error' event will cause node to throw and crash.
|
||||
clearTimeout(connectTimer);
|
||||
if (serverSocket.writable) {
|
||||
serverSocket.end();
|
||||
}
|
||||
});
|
||||
|
||||
clientSocket.on("close", () => {
|
||||
// Client closed connection - clean up server socket
|
||||
clearTimeout(connectTimer);
|
||||
if (serverSocket.writable) {
|
||||
serverSocket.end();
|
||||
}
|
||||
});
|
||||
|
||||
serverSocket.on("error", (err) => {
|
||||
clearTimeout(connectTimer);
|
||||
if (isImds) {
|
||||
ui.writeVerbose(
|
||||
`Safe-chain: error connecting to ${hostname}:${targetPort} - ${err.message}`,
|
||||
);
|
||||
} else {
|
||||
ui.writeError(
|
||||
`Safe-chain: error connecting to ${hostname}:${targetPort} - ${err.message}`,
|
||||
);
|
||||
}
|
||||
if (clientSocket.writable) {
|
||||
clientSocket.end("HTTP/1.1 502 Bad Gateway\r\n\r\n");
|
||||
}
|
||||
});
|
||||
|
||||
serverSocket.on("close", () => {
|
||||
// Server closed connection - clean up client socket
|
||||
clearTimeout(connectTimer);
|
||||
if (clientSocket.writable) {
|
||||
clientSocket.end();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("http").IncomingMessage} req
|
||||
* @param {import("http").ServerResponse} clientSocket
|
||||
* @param {Buffer} head
|
||||
* @param {string} proxyUrl
|
||||
*/
|
||||
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: Number.parseInt(proxy.port) || 80,
|
||||
});
|
||||
|
||||
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]}`,
|
||||
);
|
||||
if (clientSocket.writable) {
|
||||
clientSocket.end("HTTP/1.1 502 Bad Gateway\r\n\r\n");
|
||||
}
|
||||
if (proxySocket.writable) {
|
||||
proxySocket.end();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
proxySocket.on("error", (err) => {
|
||||
if (!isConnected) {
|
||||
ui.writeError(
|
||||
`Safe-chain: error connecting to proxy ${proxy.hostname}:${
|
||||
proxy.port || 8080
|
||||
} - ${err.message}`,
|
||||
);
|
||||
if (clientSocket.writable) {
|
||||
clientSocket.end("HTTP/1.1 502 Bad Gateway\r\n\r\n");
|
||||
}
|
||||
} else {
|
||||
ui.writeError(
|
||||
`Safe-chain: proxy socket error after connection - ${err.message}`,
|
||||
);
|
||||
if (clientSocket.writable) {
|
||||
clientSocket.end();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
clientSocket.on("error", () => {
|
||||
if (proxySocket.writable) {
|
||||
proxySocket.end();
|
||||
}
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue