@aikidosec/safe-chain 0.0.1-custom-install-dir
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +674 -0
- package/README.md +537 -0
- package/bin/aikido-bun.js +14 -0
- package/bin/aikido-bunx.js +14 -0
- package/bin/aikido-npm.js +14 -0
- package/bin/aikido-npx.js +14 -0
- package/bin/aikido-pip.js +17 -0
- package/bin/aikido-pip3.js +17 -0
- package/bin/aikido-pipx.js +16 -0
- package/bin/aikido-pnpm.js +14 -0
- package/bin/aikido-pnpx.js +14 -0
- package/bin/aikido-poetry.js +13 -0
- package/bin/aikido-python.js +19 -0
- package/bin/aikido-python3.js +19 -0
- package/bin/aikido-uv.js +16 -0
- package/bin/aikido-uvx.js +16 -0
- package/bin/aikido-yarn.js +14 -0
- package/bin/safe-chain.js +147 -0
- package/docs/Release.md +25 -0
- package/docs/banner.svg +151 -0
- package/docs/safe-package-manager-demo.gif +0 -0
- package/docs/safe-package-manager-demo.png +0 -0
- package/docs/shell-integration.md +149 -0
- package/docs/troubleshooting.md +321 -0
- package/npm-shrinkwrap.json +3180 -0
- package/package.json +71 -0
- package/src/api/aikido.js +187 -0
- package/src/api/npmApi.js +71 -0
- package/src/config/cliArguments.js +161 -0
- package/src/config/configFile.js +327 -0
- package/src/config/environmentVariables.js +57 -0
- package/src/config/safeChainDir.js +71 -0
- package/src/config/settings.js +247 -0
- package/src/environment/environment.js +14 -0
- package/src/environment/userInteraction.js +122 -0
- package/src/installLocation.js +42 -0
- package/src/main.js +123 -0
- package/src/packagemanager/_shared/commandErrors.js +17 -0
- package/src/packagemanager/_shared/matchesCommand.js +18 -0
- package/src/packagemanager/bun/createBunPackageManager.js +48 -0
- package/src/packagemanager/currentPackageManager.js +82 -0
- package/src/packagemanager/npm/createPackageManager.js +72 -0
- package/src/packagemanager/npm/dependencyScanner/commandArgumentScanner.js +74 -0
- package/src/packagemanager/npm/dependencyScanner/nullScanner.js +9 -0
- package/src/packagemanager/npm/parsing/parsePackagesFromInstallArgs.js +144 -0
- package/src/packagemanager/npm/runNpmCommand.js +20 -0
- package/src/packagemanager/npm/utils/abbrevs-generated.js +359 -0
- package/src/packagemanager/npm/utils/cmd-list.js +174 -0
- package/src/packagemanager/npm/utils/npmCommands.js +34 -0
- package/src/packagemanager/npx/createPackageManager.js +15 -0
- package/src/packagemanager/npx/dependencyScanner/commandArgumentScanner.js +43 -0
- package/src/packagemanager/npx/parsing/parsePackagesFromArguments.js +130 -0
- package/src/packagemanager/npx/runNpxCommand.js +20 -0
- package/src/packagemanager/pip/createPackageManager.js +25 -0
- package/src/packagemanager/pip/pipSettings.js +6 -0
- package/src/packagemanager/pip/runPipCommand.js +209 -0
- package/src/packagemanager/pipx/createPipXPackageManager.js +18 -0
- package/src/packagemanager/pipx/runPipXCommand.js +60 -0
- package/src/packagemanager/pnpm/createPackageManager.js +57 -0
- package/src/packagemanager/pnpm/dependencyScanner/commandArgumentScanner.js +35 -0
- package/src/packagemanager/pnpm/parsing/parsePackagesFromArguments.js +109 -0
- package/src/packagemanager/pnpm/runPnpmCommand.js +32 -0
- package/src/packagemanager/poetry/createPoetryPackageManager.js +72 -0
- package/src/packagemanager/uv/createUvPackageManager.js +18 -0
- package/src/packagemanager/uv/runUvCommand.js +66 -0
- package/src/packagemanager/uvx/createUvxPackageManager.js +18 -0
- package/src/packagemanager/yarn/createPackageManager.js +41 -0
- package/src/packagemanager/yarn/dependencyScanner/commandArgumentScanner.js +35 -0
- package/src/packagemanager/yarn/parsing/parsePackagesFromArguments.js +128 -0
- package/src/packagemanager/yarn/runYarnCommand.js +36 -0
- package/src/registryProxy/certBundle.js +203 -0
- package/src/registryProxy/certUtils.js +178 -0
- package/src/registryProxy/getConnectTimeout.js +13 -0
- package/src/registryProxy/http-utils.js +80 -0
- package/src/registryProxy/interceptors/createInterceptorForEcoSystem.js +25 -0
- package/src/registryProxy/interceptors/interceptorBuilder.js +179 -0
- package/src/registryProxy/interceptors/minimumPackageAgeExclusions.js +33 -0
- package/src/registryProxy/interceptors/npm/modifyNpmInfo.js +180 -0
- package/src/registryProxy/interceptors/npm/npmInterceptor.js +101 -0
- package/src/registryProxy/interceptors/npm/parseNpmPackageUrl.js +60 -0
- package/src/registryProxy/interceptors/pip/modifyPipInfo.js +167 -0
- package/src/registryProxy/interceptors/pip/modifyPipJsonResponse.js +176 -0
- package/src/registryProxy/interceptors/pip/parsePipPackageUrl.js +162 -0
- package/src/registryProxy/interceptors/pip/pipInterceptor.js +122 -0
- package/src/registryProxy/interceptors/pip/pipMetadataResponseUtils.js +27 -0
- package/src/registryProxy/interceptors/pip/pipMetadataVersionUtils.js +131 -0
- package/src/registryProxy/interceptors/suppressedVersionsState.js +21 -0
- package/src/registryProxy/isImdsEndpoint.js +13 -0
- package/src/registryProxy/mitmRequestHandler.js +240 -0
- package/src/registryProxy/plainHttpProxy.js +95 -0
- package/src/registryProxy/registryProxy.js +255 -0
- package/src/registryProxy/tunnelRequestHandler.js +213 -0
- package/src/scanning/audit/index.js +129 -0
- package/src/scanning/index.js +82 -0
- package/src/scanning/malwareDatabase.js +131 -0
- package/src/scanning/newPackagesDatabaseBuilder.js +71 -0
- package/src/scanning/newPackagesDatabaseWarnings.js +17 -0
- package/src/scanning/newPackagesListCache.js +126 -0
- package/src/scanning/packageNameVariants.js +29 -0
- package/src/shell-integration/helpers.js +296 -0
- package/src/shell-integration/path-wrappers/templates/unix-wrapper.template.sh +37 -0
- package/src/shell-integration/path-wrappers/templates/windows-wrapper.template.cmd +25 -0
- package/src/shell-integration/setup-ci.js +152 -0
- package/src/shell-integration/setup.js +110 -0
- package/src/shell-integration/shellDetection.js +39 -0
- package/src/shell-integration/startup-scripts/init-fish.fish +122 -0
- package/src/shell-integration/startup-scripts/init-posix.sh +112 -0
- package/src/shell-integration/startup-scripts/init-pwsh.ps1 +176 -0
- package/src/shell-integration/supported-shells/bash.js +222 -0
- package/src/shell-integration/supported-shells/fish.js +97 -0
- package/src/shell-integration/supported-shells/powershell.js +102 -0
- package/src/shell-integration/supported-shells/windowsPowershell.js +102 -0
- package/src/shell-integration/supported-shells/zsh.js +94 -0
- package/src/shell-integration/teardown.js +114 -0
- package/src/utils/safeSpawn.js +153 -0
- package/tsconfig.json +21 -0
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
// @ts-ignore - certifi has no type definitions
|
|
5
|
+
import certifi from "certifi";
|
|
6
|
+
import tls from "node:tls";
|
|
7
|
+
import { X509Certificate } from "node:crypto";
|
|
8
|
+
import { getCaCertPath } from "./certUtils.js";
|
|
9
|
+
import { ui } from "../environment/userInteraction.js";
|
|
10
|
+
|
|
11
|
+
/** @type {string | null} */
|
|
12
|
+
let bundlePath = null;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Check if a PEM string contains only parsable cert blocks.
|
|
16
|
+
* @param {string} pem - PEM-encoded certificate string
|
|
17
|
+
* @returns {boolean}
|
|
18
|
+
*/
|
|
19
|
+
function isParsable(pem) {
|
|
20
|
+
if (!pem || typeof pem !== "string") return false;
|
|
21
|
+
pem = normalizeLineEndings(pem);
|
|
22
|
+
const begin = "-----BEGIN CERTIFICATE-----";
|
|
23
|
+
const end = "-----END CERTIFICATE-----";
|
|
24
|
+
const blocks = [];
|
|
25
|
+
|
|
26
|
+
let idx = 0;
|
|
27
|
+
while (idx < pem.length) {
|
|
28
|
+
const start = pem.indexOf(begin, idx);
|
|
29
|
+
if (start === -1) break;
|
|
30
|
+
const stop = pem.indexOf(end, start + begin.length);
|
|
31
|
+
if (stop === -1) break;
|
|
32
|
+
const blockEnd = stop + end.length;
|
|
33
|
+
blocks.push(pem.slice(start, blockEnd));
|
|
34
|
+
idx = blockEnd;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (blocks.length === 0) return false;
|
|
38
|
+
try {
|
|
39
|
+
for (const b of blocks) {
|
|
40
|
+
// throw if invalid
|
|
41
|
+
new X509Certificate(b);
|
|
42
|
+
}
|
|
43
|
+
return true;
|
|
44
|
+
} catch {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Build a combined CA bundle.
|
|
51
|
+
* Automatically includes:
|
|
52
|
+
* - Safe Chain CA (for MITM of known registries)
|
|
53
|
+
* - Mozilla roots via certifi (for public HTTPS)
|
|
54
|
+
* - Node's built-in root certificates (fallback)
|
|
55
|
+
* - User's custom certificates (if NODE_EXTRA_CA_CERTS environment variable is set)
|
|
56
|
+
*
|
|
57
|
+
* @returns {string} Path to the combined CA bundle PEM file
|
|
58
|
+
*/
|
|
59
|
+
export function getCombinedCaBundlePath() {
|
|
60
|
+
if (bundlePath)
|
|
61
|
+
{
|
|
62
|
+
return bundlePath;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const parts = [];
|
|
66
|
+
|
|
67
|
+
// 1) Safe Chain CA (for MITM'd registries)
|
|
68
|
+
const safeChainPath = getCaCertPath();
|
|
69
|
+
try {
|
|
70
|
+
const safeChainPem = fs.readFileSync(safeChainPath, "utf8");
|
|
71
|
+
if (isParsable(safeChainPem)) parts.push(safeChainPem.trim());
|
|
72
|
+
} catch {
|
|
73
|
+
// Ignore if Safe Chain CA is not available
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// 2) certifi (Mozilla CA bundle for all public HTTPS)
|
|
77
|
+
try {
|
|
78
|
+
const certifiPem = fs.readFileSync(certifi, "utf8");
|
|
79
|
+
if (isParsable(certifiPem)) parts.push(certifiPem.trim());
|
|
80
|
+
} catch {
|
|
81
|
+
// Ignore if certifi bundle is not available
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// 3) Node's built-in root certificates
|
|
85
|
+
try {
|
|
86
|
+
const nodeRoots = tls.rootCertificates;
|
|
87
|
+
if (Array.isArray(nodeRoots) && nodeRoots.length) {
|
|
88
|
+
for (const rootPem of nodeRoots) {
|
|
89
|
+
if (typeof rootPem !== "string") continue;
|
|
90
|
+
if (isParsable(rootPem)) parts.push(rootPem.trim());
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
} catch {
|
|
94
|
+
// Ignore if unavailable
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// 4) User's NODE_EXTRA_CA_CERTS (if set)
|
|
98
|
+
const userCertPath = process.env.NODE_EXTRA_CA_CERTS;
|
|
99
|
+
if (userCertPath) {
|
|
100
|
+
const userPem = readUserCertificateFile(userCertPath);
|
|
101
|
+
if (userPem) {
|
|
102
|
+
parts.push(userPem.trim());
|
|
103
|
+
ui.writeVerbose(`Safe-chain: Merging user's NODE_EXTRA_CA_CERTS from ${userCertPath}`);
|
|
104
|
+
} else {
|
|
105
|
+
ui.writeWarning(`Safe-chain: Could not read or parse user's NODE_EXTRA_CA_CERTS from ${userCertPath}`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const combined = parts.filter(Boolean).join("\n");
|
|
110
|
+
bundlePath = path.join(os.tmpdir(), `safe-chain-ca-bundle-${Date.now()}.pem`);
|
|
111
|
+
fs.writeFileSync(bundlePath, combined, { encoding: "utf8" });
|
|
112
|
+
return bundlePath;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Remove the generated CA bundle file from disk.
|
|
117
|
+
*/
|
|
118
|
+
export function cleanupCertBundle() {
|
|
119
|
+
if (bundlePath) {
|
|
120
|
+
try {
|
|
121
|
+
fs.unlinkSync(bundlePath);
|
|
122
|
+
} catch (err) {
|
|
123
|
+
ui.writeVerbose(`Failed to cleanup the create bundle at ${bundlePath}`, err)
|
|
124
|
+
}
|
|
125
|
+
bundlePath = null;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Normalize path
|
|
131
|
+
* @param {string} p - Path to normalize
|
|
132
|
+
* @returns {string}
|
|
133
|
+
*/
|
|
134
|
+
function normalizePathF(p) {
|
|
135
|
+
return p.replace(/\\/g, "/");
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Normalize line endings to LF
|
|
140
|
+
* @param {string} text - Text with mixed line endings
|
|
141
|
+
* @returns {string}
|
|
142
|
+
*/
|
|
143
|
+
function normalizeLineEndings(text) {
|
|
144
|
+
return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Read and validate user certificate file
|
|
149
|
+
* @param {string} certPath - Path to certificate file
|
|
150
|
+
* @returns {string | null} Certificate PEM content or null if invalid/unreadable
|
|
151
|
+
*/
|
|
152
|
+
function readUserCertificateFile(certPath) {
|
|
153
|
+
try {
|
|
154
|
+
// 1) Basic validation
|
|
155
|
+
if (typeof certPath !== "string" || certPath.trim().length === 0) {
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// 2) Reject path traversal attempts (normalize backslashes first for Windows paths)
|
|
160
|
+
const normalizedPath = normalizePathF(certPath);
|
|
161
|
+
if (normalizedPath.includes("..")) {
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// 3) Check if file exists and is not a directory or symlink
|
|
166
|
+
let stats;
|
|
167
|
+
try {
|
|
168
|
+
stats = fs.lstatSync(certPath);
|
|
169
|
+
} catch {
|
|
170
|
+
// File doesn't exist or can't be accessed
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (!stats.isFile()) {
|
|
175
|
+
// Reject directories and symlinks
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// 4) Read file content
|
|
180
|
+
let content;
|
|
181
|
+
try {
|
|
182
|
+
content = fs.readFileSync(certPath, "utf8");
|
|
183
|
+
} catch {
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (!content || typeof content !== "string") {
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// 5) Validate PEM format
|
|
192
|
+
if (!isParsable(content)) {
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return content;
|
|
197
|
+
} catch {
|
|
198
|
+
// Silently fail on any errors
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import forge from "node-forge";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import fs from "fs";
|
|
4
|
+
import { getCertsDir } from "../config/safeChainDir.js";
|
|
5
|
+
|
|
6
|
+
const ca = loadCa();
|
|
7
|
+
|
|
8
|
+
const certCache = new Map();
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @param {forge.pki.PublicKey} publicKey
|
|
12
|
+
* @returns {string}
|
|
13
|
+
*/
|
|
14
|
+
function createKeyIdentifier(publicKey) {
|
|
15
|
+
return forge.pki.getPublicKeyFingerprint(publicKey, {
|
|
16
|
+
encoding: "binary",
|
|
17
|
+
md: forge.md.sha1.create(),
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function getCaCertPath() {
|
|
22
|
+
return path.join(getCertsDir(), "ca-cert.pem");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @param {string} hostname
|
|
27
|
+
* @returns {{privateKey: string, certificate: string}}
|
|
28
|
+
*/
|
|
29
|
+
export function generateCertForHost(hostname) {
|
|
30
|
+
let existingCert = certCache.get(hostname);
|
|
31
|
+
if (existingCert) {
|
|
32
|
+
return existingCert;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const keys = forge.pki.rsa.generateKeyPair(2048);
|
|
36
|
+
const cert = forge.pki.createCertificate();
|
|
37
|
+
cert.publicKey = keys.publicKey;
|
|
38
|
+
cert.serialNumber = "01";
|
|
39
|
+
cert.validity.notBefore = new Date();
|
|
40
|
+
cert.validity.notAfter = new Date();
|
|
41
|
+
cert.validity.notAfter.setHours(cert.validity.notBefore.getHours() + 1);
|
|
42
|
+
|
|
43
|
+
const attrs = [{ name: "commonName", value: hostname }];
|
|
44
|
+
cert.setSubject(attrs);
|
|
45
|
+
cert.setIssuer(ca.certificate.subject.attributes);
|
|
46
|
+
const authorityKeyIdentifier = createKeyIdentifier(ca.certificate.publicKey);
|
|
47
|
+
cert.setExtensions([
|
|
48
|
+
{
|
|
49
|
+
name: "subjectAltName",
|
|
50
|
+
altNames: [
|
|
51
|
+
{
|
|
52
|
+
type: 2, // DNS
|
|
53
|
+
value: hostname,
|
|
54
|
+
},
|
|
55
|
+
],
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
name: "keyUsage",
|
|
59
|
+
digitalSignature: true,
|
|
60
|
+
keyEncipherment: true,
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
/*
|
|
64
|
+
Extended Key Usage (EKU) serverAuth extension
|
|
65
|
+
|
|
66
|
+
Needed for TLS server authentication. This extension indicates the certificate's
|
|
67
|
+
public key may be used for TLS WWW server authentication.
|
|
68
|
+
Python virtualenv environments (like pipx-installed Poetry) enforce this strictly
|
|
69
|
+
https://www.rfc-editor.org/rfc/rfc5280#section-4.2.1.12
|
|
70
|
+
*/
|
|
71
|
+
name: "extKeyUsage",
|
|
72
|
+
serverAuth: true,
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
/*
|
|
76
|
+
Subject Key Identifier (SKI)
|
|
77
|
+
|
|
78
|
+
Needed for Python virtualenv SSL validation and certificate chain building.
|
|
79
|
+
This extension provides a means of identifying certificates containing a particular public key.
|
|
80
|
+
Python virtualenv environments require this for proper certificate chain validation.
|
|
81
|
+
System Python installations may be more lenient.
|
|
82
|
+
https://www.rfc-editor.org/rfc/rfc5280#section-4.2.1.2
|
|
83
|
+
*/
|
|
84
|
+
name: "subjectKeyIdentifier",
|
|
85
|
+
subjectKeyIdentifier: createKeyIdentifier(cert.publicKey),
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
/*
|
|
89
|
+
Authority Key Identifier (AKI)
|
|
90
|
+
|
|
91
|
+
Needed for Python virtualenv SSL validation and certificate path validation.
|
|
92
|
+
This extension identifies the public key corresponding to the private key used to sign
|
|
93
|
+
this certificate. It links this certificate to its issuing CA certificate.
|
|
94
|
+
Without this, Python virtualenv certificate validation might fail (for instance for Poetry)
|
|
95
|
+
https://www.rfc-editor.org/rfc/rfc5280#section-4.2.1.1
|
|
96
|
+
*/
|
|
97
|
+
name: "authorityKeyIdentifier",
|
|
98
|
+
keyIdentifier: authorityKeyIdentifier,
|
|
99
|
+
},
|
|
100
|
+
]);
|
|
101
|
+
cert.sign(ca.privateKey, forge.md.sha256.create());
|
|
102
|
+
|
|
103
|
+
const result = {
|
|
104
|
+
privateKey: forge.pki.privateKeyToPem(keys.privateKey),
|
|
105
|
+
certificate: forge.pki.certificateToPem(cert),
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
certCache.set(hostname, result);
|
|
109
|
+
|
|
110
|
+
return result;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function loadCa() {
|
|
114
|
+
const certFolder = getCertsDir();
|
|
115
|
+
const keyPath = path.join(certFolder, "ca-key.pem");
|
|
116
|
+
const certPath = path.join(certFolder, "ca-cert.pem");
|
|
117
|
+
|
|
118
|
+
if (fs.existsSync(keyPath) && fs.existsSync(certPath)) {
|
|
119
|
+
const privateKeyPem = fs.readFileSync(keyPath, "utf8");
|
|
120
|
+
const certPem = fs.readFileSync(certPath, "utf8");
|
|
121
|
+
const privateKey = forge.pki.privateKeyFromPem(privateKeyPem);
|
|
122
|
+
const certificate = forge.pki.certificateFromPem(certPem);
|
|
123
|
+
|
|
124
|
+
// Don't return a cert that is valid for less than 1 hour
|
|
125
|
+
const oneHourFromNow = new Date(Date.now() + 60 * 60 * 1000);
|
|
126
|
+
if (certificate.validity.notAfter > oneHourFromNow) {
|
|
127
|
+
return { privateKey, certificate };
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const { privateKey, certificate } = generateCa();
|
|
132
|
+
fs.mkdirSync(certFolder, { recursive: true });
|
|
133
|
+
fs.writeFileSync(keyPath, forge.pki.privateKeyToPem(privateKey));
|
|
134
|
+
fs.writeFileSync(certPath, forge.pki.certificateToPem(certificate));
|
|
135
|
+
return { privateKey, certificate };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function generateCa() {
|
|
139
|
+
const keys = forge.pki.rsa.generateKeyPair(2048);
|
|
140
|
+
const cert = forge.pki.createCertificate();
|
|
141
|
+
cert.publicKey = keys.publicKey;
|
|
142
|
+
cert.serialNumber = "01";
|
|
143
|
+
cert.validity.notBefore = new Date();
|
|
144
|
+
cert.validity.notAfter = new Date();
|
|
145
|
+
cert.validity.notAfter.setDate(cert.validity.notBefore.getDate() + 1);
|
|
146
|
+
|
|
147
|
+
const attrs = [{ name: "commonName", value: "safe-chain proxy" }];
|
|
148
|
+
cert.setSubject(attrs);
|
|
149
|
+
cert.setIssuer(attrs); // Self-signed: issuer === subject
|
|
150
|
+
const keyIdentifier = createKeyIdentifier(cert.publicKey);
|
|
151
|
+
cert.setExtensions([
|
|
152
|
+
{
|
|
153
|
+
name: "basicConstraints",
|
|
154
|
+
cA: true,
|
|
155
|
+
critical: true, // Marking basicConstraints as critical is required for CA certificates so clients must process it to trust the cert as a CA
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
name: "keyUsage",
|
|
159
|
+
keyCertSign: true,
|
|
160
|
+
digitalSignature: true,
|
|
161
|
+
keyEncipherment: true,
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
name: "subjectKeyIdentifier",
|
|
165
|
+
subjectKeyIdentifier: keyIdentifier,
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
name: "authorityKeyIdentifier",
|
|
169
|
+
keyIdentifier,
|
|
170
|
+
},
|
|
171
|
+
]);
|
|
172
|
+
cert.sign(keys.privateKey, forge.md.sha256.create());
|
|
173
|
+
|
|
174
|
+
return {
|
|
175
|
+
privateKey: keys.privateKey,
|
|
176
|
+
certificate: cert,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { isImdsEndpoint } from "./isImdsEndpoint.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Returns appropriate connection timeout for a host.
|
|
5
|
+
* - IMDS endpoints: 3s (fail fast when outside cloud, reduce 5min delay to ~20s)
|
|
6
|
+
* - Other endpoints: 30s (allow for slow networks while preventing indefinite hangs)
|
|
7
|
+
*/
|
|
8
|
+
export function getConnectTimeout(/** @type {string} */ host) {
|
|
9
|
+
if (isImdsEndpoint(host)) {
|
|
10
|
+
return 3000;
|
|
11
|
+
}
|
|
12
|
+
return 30000;
|
|
13
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @param {NodeJS.Dict<string | string[]> | undefined} headers
|
|
3
|
+
* @param {string} headerName
|
|
4
|
+
*/
|
|
5
|
+
export function getHeaderValueAsString(headers, headerName) {
|
|
6
|
+
if (!headers) {
|
|
7
|
+
return undefined;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
let header = headers[headerName];
|
|
11
|
+
|
|
12
|
+
if (Array.isArray(header)) {
|
|
13
|
+
return header.join(", ");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
return header;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Returns a copy of headers without the provided header names, matched
|
|
21
|
+
* either exactly or case-insensitively.
|
|
22
|
+
*
|
|
23
|
+
* @param {NodeJS.Dict<string | string[]> | undefined} headers
|
|
24
|
+
* @param {string[]} headerNames
|
|
25
|
+
* @param {{ caseInsensitive?: boolean }} [options]
|
|
26
|
+
* @returns {NodeJS.Dict<string | string[]> | undefined}
|
|
27
|
+
*/
|
|
28
|
+
export function omitHeaders(headers, headerNames, options = {}) {
|
|
29
|
+
if (!headers) {
|
|
30
|
+
return headers;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const omittedHeaderNames = new Set(
|
|
34
|
+
options.caseInsensitive
|
|
35
|
+
? headerNames.map((name) => name.toLowerCase())
|
|
36
|
+
: headerNames
|
|
37
|
+
);
|
|
38
|
+
/** @type {NodeJS.Dict<string | string[]>} */
|
|
39
|
+
const filteredHeaders = {};
|
|
40
|
+
|
|
41
|
+
for (const [headerName, value] of Object.entries(headers)) {
|
|
42
|
+
const comparableHeaderName = options.caseInsensitive
|
|
43
|
+
? headerName.toLowerCase()
|
|
44
|
+
: headerName;
|
|
45
|
+
if (!omittedHeaderNames.has(comparableHeaderName)) {
|
|
46
|
+
filteredHeaders[headerName] = value;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return filteredHeaders;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Remove headers that become stale when the response body is modified.
|
|
55
|
+
*
|
|
56
|
+
* @param {NodeJS.Dict<string | string[]> | undefined} headers
|
|
57
|
+
* @returns {void}
|
|
58
|
+
*/
|
|
59
|
+
export function clearCachingHeaders(headers) {
|
|
60
|
+
if (!headers) {
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const filteredHeaders = omitHeaders(headers, [
|
|
65
|
+
"etag",
|
|
66
|
+
"last-modified",
|
|
67
|
+
"cache-control",
|
|
68
|
+
"content-length",
|
|
69
|
+
]);
|
|
70
|
+
|
|
71
|
+
if (!filteredHeaders) {
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
for (const key of Object.keys(headers)) {
|
|
76
|
+
delete headers[key];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
Object.assign(headers, filteredHeaders);
|
|
80
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ECOSYSTEM_JS,
|
|
3
|
+
ECOSYSTEM_PY,
|
|
4
|
+
getEcoSystem,
|
|
5
|
+
} from "../../config/settings.js";
|
|
6
|
+
import { npmInterceptorForUrl } from "./npm/npmInterceptor.js";
|
|
7
|
+
import { pipInterceptorForUrl } from "./pip/pipInterceptor.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @param {string} url
|
|
11
|
+
* @returns {import("./interceptorBuilder.js").Interceptor | undefined}
|
|
12
|
+
*/
|
|
13
|
+
export function createInterceptorForUrl(url) {
|
|
14
|
+
const ecosystem = getEcoSystem();
|
|
15
|
+
|
|
16
|
+
if (ecosystem === ECOSYSTEM_JS) {
|
|
17
|
+
return npmInterceptorForUrl(url);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (ecosystem === ECOSYSTEM_PY) {
|
|
21
|
+
return pipInterceptorForUrl(url);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { EventEmitter } from "events";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @typedef {Object} Interceptor
|
|
5
|
+
* @property {(targetUrl: string) => Promise<RequestInterceptionHandler>} handleRequest
|
|
6
|
+
* @property {(event: string, listener: (...args: any[]) => void) => Interceptor} on
|
|
7
|
+
* @property {(event: string, ...args: any[]) => boolean} emit
|
|
8
|
+
*
|
|
9
|
+
*
|
|
10
|
+
* @typedef {Object} RequestInterceptionContext
|
|
11
|
+
* @property {string} targetUrl
|
|
12
|
+
* @property {(packageName: string | undefined, version: string | undefined) => void} blockMalware
|
|
13
|
+
* @property {(packageName: string, version: string, message: string) => void} blockMinimumAgeRequest
|
|
14
|
+
* @property {(modificationFunc: (headers: NodeJS.Dict<string | string[]>) => NodeJS.Dict<string | string[]>) => void} modifyRequestHeaders
|
|
15
|
+
* @property {(modificationFunc: (body: Buffer, headers: NodeJS.Dict<string | string[]> | undefined) => Buffer) => void} modifyBody
|
|
16
|
+
* @property {() => RequestInterceptionHandler} build
|
|
17
|
+
*
|
|
18
|
+
*
|
|
19
|
+
* @typedef {Object} RequestInterceptionHandler
|
|
20
|
+
* @property {{statusCode: number, message: string} | undefined} blockResponse
|
|
21
|
+
* @property {(headers: NodeJS.Dict<string | string[]> | undefined) => NodeJS.Dict<string | string[]> | undefined} modifyRequestHeaders
|
|
22
|
+
* @property {() => boolean} modifiesResponse
|
|
23
|
+
* @property {(body: Buffer, headers: NodeJS.Dict<string | string[]> | undefined) => Buffer} modifyBody
|
|
24
|
+
*
|
|
25
|
+
* @typedef {Object} MalwareBlockedEvent
|
|
26
|
+
* @property {string} packageName
|
|
27
|
+
* @property {string} version
|
|
28
|
+
* @property {string} targetUrl
|
|
29
|
+
* @property {number} timestamp
|
|
30
|
+
*
|
|
31
|
+
* @typedef {Object} MinimumAgeRequestBlockedEvent
|
|
32
|
+
* @property {string} packageName
|
|
33
|
+
* @property {string} version
|
|
34
|
+
* @property {string} targetUrl
|
|
35
|
+
* @property {number} timestamp
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @param {(requestHandlerBuilder: RequestInterceptionContext) => Promise<void>} requestInterceptionFunc
|
|
40
|
+
* @returns {Interceptor}
|
|
41
|
+
*/
|
|
42
|
+
export function interceptRequests(requestInterceptionFunc) {
|
|
43
|
+
return buildInterceptor([requestInterceptionFunc]);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* @param {Array<(requestHandlerBuilder: RequestInterceptionContext) => Promise<void>>} requestHandlers
|
|
48
|
+
* @returns {Interceptor}
|
|
49
|
+
*/
|
|
50
|
+
function buildInterceptor(requestHandlers) {
|
|
51
|
+
const eventEmitter = new EventEmitter();
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
async handleRequest(targetUrl) {
|
|
55
|
+
const requestContext = createRequestContext(targetUrl, eventEmitter);
|
|
56
|
+
|
|
57
|
+
for (const handler of requestHandlers) {
|
|
58
|
+
await handler(requestContext);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return requestContext.build();
|
|
62
|
+
},
|
|
63
|
+
on(event, listener) {
|
|
64
|
+
eventEmitter.on(event, listener);
|
|
65
|
+
return this;
|
|
66
|
+
},
|
|
67
|
+
emit(event, ...args) {
|
|
68
|
+
return eventEmitter.emit(event, ...args);
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* @param {string} targetUrl
|
|
75
|
+
* @param {import('events').EventEmitter} eventEmitter
|
|
76
|
+
* @returns {RequestInterceptionContext}
|
|
77
|
+
*/
|
|
78
|
+
function createRequestContext(targetUrl, eventEmitter) {
|
|
79
|
+
/** @type {{statusCode: number, message: string} | undefined} */
|
|
80
|
+
let blockResponse = undefined;
|
|
81
|
+
/** @type {Array<(headers: NodeJS.Dict<string | string[]>) => NodeJS.Dict<string | string[]>>} */
|
|
82
|
+
let reqheaderModificationFuncs = [];
|
|
83
|
+
/** @type {Array<(body: Buffer, headers: NodeJS.Dict<string | string[]> | undefined) => Buffer>} */
|
|
84
|
+
let modifyBodyFuncs = [];
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* @param {string | undefined} packageName
|
|
88
|
+
* @param {string | undefined} version
|
|
89
|
+
*/
|
|
90
|
+
function blockMalwareSetup(packageName, version) {
|
|
91
|
+
blockResponse = createBlockResponse("Forbidden - blocked by safe-chain");
|
|
92
|
+
|
|
93
|
+
// Emit the malwareBlocked event
|
|
94
|
+
eventEmitter.emit("malwareBlocked", {
|
|
95
|
+
packageName,
|
|
96
|
+
version,
|
|
97
|
+
targetUrl,
|
|
98
|
+
timestamp: Date.now(),
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* @param {string} message
|
|
104
|
+
*/
|
|
105
|
+
function blockMinimumAgeRequestSetup(
|
|
106
|
+
/** @type {string} */ packageName,
|
|
107
|
+
/** @type {string} */ version,
|
|
108
|
+
/** @type {string} */ message
|
|
109
|
+
) {
|
|
110
|
+
blockResponse = createBlockResponse(message);
|
|
111
|
+
eventEmitter.emit("minimumAgeRequestBlocked", {
|
|
112
|
+
packageName,
|
|
113
|
+
version,
|
|
114
|
+
targetUrl,
|
|
115
|
+
timestamp: Date.now(),
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* @param {string} message
|
|
121
|
+
* @returns {{statusCode: number, message: string}}
|
|
122
|
+
*/
|
|
123
|
+
function createBlockResponse(message) {
|
|
124
|
+
return {
|
|
125
|
+
statusCode: 403,
|
|
126
|
+
message,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** @returns {RequestInterceptionHandler} */
|
|
131
|
+
function build() {
|
|
132
|
+
/**
|
|
133
|
+
* @param {NodeJS.Dict<string | string[]> | undefined} headers
|
|
134
|
+
* @returns {NodeJS.Dict<string | string[]> | undefined}
|
|
135
|
+
*/
|
|
136
|
+
function modifyRequestHeaders(headers) {
|
|
137
|
+
if (headers) {
|
|
138
|
+
for (const func of reqheaderModificationFuncs) {
|
|
139
|
+
func(headers);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return headers;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* @param {Buffer} body
|
|
148
|
+
* @param {NodeJS.Dict<string | string[]> | undefined} headers
|
|
149
|
+
* @returns {Buffer}
|
|
150
|
+
*/
|
|
151
|
+
function modifyBody(body, headers) {
|
|
152
|
+
let modifiedBody = body;
|
|
153
|
+
|
|
154
|
+
for (var func of modifyBodyFuncs) {
|
|
155
|
+
modifiedBody = func(body, headers);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return modifiedBody;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// These functions are invoked in the proxy, allowing to apply the configured modifications
|
|
162
|
+
return {
|
|
163
|
+
blockResponse,
|
|
164
|
+
modifyRequestHeaders: modifyRequestHeaders,
|
|
165
|
+
modifiesResponse: () => modifyBodyFuncs.length > 0,
|
|
166
|
+
modifyBody,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// These functions are used to setup the modifications
|
|
171
|
+
return {
|
|
172
|
+
targetUrl,
|
|
173
|
+
blockMalware: blockMalwareSetup,
|
|
174
|
+
blockMinimumAgeRequest: blockMinimumAgeRequestSetup,
|
|
175
|
+
modifyRequestHeaders: (func) => reqheaderModificationFuncs.push(func),
|
|
176
|
+
modifyBody: (func) => modifyBodyFuncs.push(func),
|
|
177
|
+
build,
|
|
178
|
+
};
|
|
179
|
+
}
|