@aikidosec/safe-chain 1.4.3 → 1.4.7
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/README.md +65 -8
- package/bin/safe-chain.js +1 -81
- package/package.json +1 -1
- package/src/api/aikido.js +93 -18
- package/src/config/cliArguments.js +24 -1
- package/src/config/configFile.js +64 -6
- package/src/config/environmentVariables.js +13 -2
- package/src/config/settings.js +53 -4
- package/src/main.js +6 -2
- package/src/packagemanager/_shared/commandErrors.js +17 -0
- package/src/packagemanager/bun/createBunPackageManager.js +2 -7
- package/src/packagemanager/npm/runNpmCommand.js +2 -7
- package/src/packagemanager/npx/runNpxCommand.js +2 -7
- package/src/packagemanager/pip/runPipCommand.js +2 -7
- package/src/packagemanager/pipx/runPipXCommand.js +2 -7
- package/src/packagemanager/pnpm/runPnpmCommand.js +3 -7
- package/src/packagemanager/poetry/createPoetryPackageManager.js +2 -7
- package/src/packagemanager/uv/runUvCommand.js +2 -7
- package/src/packagemanager/yarn/runYarnCommand.js +2 -7
- package/src/registryProxy/certBundle.js +25 -3
- package/src/registryProxy/http-utils.js +63 -0
- package/src/registryProxy/interceptors/createInterceptorForEcoSystem.js +1 -1
- package/src/registryProxy/interceptors/interceptorBuilder.js +37 -4
- package/src/registryProxy/interceptors/minimumPackageAgeExclusions.js +33 -0
- package/src/registryProxy/interceptors/npm/modifyNpmInfo.js +18 -41
- package/src/registryProxy/interceptors/npm/npmInterceptor.js +47 -2
- package/src/registryProxy/interceptors/npm/parseNpmPackageUrl.js +20 -3
- 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/mitmRequestHandler.js +12 -6
- package/src/registryProxy/registryProxy.js +72 -9
- 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/setup.js +7 -3
- package/src/shell-integration/shellDetection.js +2 -0
- package/src/shell-integration/supported-shells/bash.js +19 -1
- package/src/shell-integration/supported-shells/fish.js +18 -0
- package/src/shell-integration/supported-shells/powershell.js +18 -0
- package/src/shell-integration/supported-shells/windowsPowershell.js +18 -0
- package/src/shell-integration/supported-shells/zsh.js +19 -1
- package/src/shell-integration/teardown.js +7 -1
- package/src/ultimate/ultimateTroubleshooting.js +1 -1
- package/src/installation/downloadAgent.js +0 -125
- package/src/installation/installOnMacOS.js +0 -155
- package/src/installation/installOnWindows.js +0 -203
- package/src/installation/installUltimate.js +0 -35
- package/src/registryProxy/interceptors/pipInterceptor.js +0 -132
package/src/main.js
CHANGED
|
@@ -64,7 +64,11 @@ export async function main(args) {
|
|
|
64
64
|
// Write all buffered logs
|
|
65
65
|
ui.writeBufferedLogsAndStopBuffering();
|
|
66
66
|
|
|
67
|
-
if (
|
|
67
|
+
if (proxy.hasBlockedMaliciousPackages()) {
|
|
68
|
+
return 1;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (proxy.hasBlockedMinimumAgeRequests()) {
|
|
68
72
|
return 1;
|
|
69
73
|
}
|
|
70
74
|
|
|
@@ -81,7 +85,7 @@ export async function main(args) {
|
|
|
81
85
|
ui.writeInformation(
|
|
82
86
|
`${chalk.yellow(
|
|
83
87
|
"ℹ",
|
|
84
|
-
)} Safe-chain: Some package versions were suppressed due to minimum age
|
|
88
|
+
)} Safe-chain: Some package versions were suppressed during package metadata resolution due to minimum package age.`,
|
|
85
89
|
);
|
|
86
90
|
ui.writeInformation(
|
|
87
91
|
` To disable this check, use: ${chalk.cyan(
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { ui } from "../../environment/userInteraction.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Centralized logging for package-manager command launch failures.
|
|
5
|
+
*
|
|
6
|
+
* @param {any} error - Error thrown by safeSpawn while preparing/running the command.
|
|
7
|
+
* @param {string} command - Command name that failed to execute.
|
|
8
|
+
* @returns {{status: number}}
|
|
9
|
+
*/
|
|
10
|
+
export function reportCommandExecutionFailure(error, command) {
|
|
11
|
+
const message = typeof error?.message === "string" ? error.message : "Unknown error";
|
|
12
|
+
ui.writeError(`Error executing command: ${message}`);
|
|
13
|
+
|
|
14
|
+
ui.writeError(`Is '${command}' installed and available on your system?`);
|
|
15
|
+
|
|
16
|
+
return { status: typeof error?.status === "number" ? error.status : 1 };
|
|
17
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { ui } from "../../environment/userInteraction.js";
|
|
2
1
|
import { safeSpawn } from "../../utils/safeSpawn.js";
|
|
3
2
|
import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js";
|
|
3
|
+
import { reportCommandExecutionFailure } from "../_shared/commandErrors.js";
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* @returns {import("../currentPackageManager.js").PackageManager}
|
|
@@ -43,11 +43,6 @@ async function runBunCommand(command, args) {
|
|
|
43
43
|
});
|
|
44
44
|
return { status: result.status };
|
|
45
45
|
} catch (/** @type any */ error) {
|
|
46
|
-
|
|
47
|
-
return { status: error.status };
|
|
48
|
-
} else {
|
|
49
|
-
ui.writeError("Error executing command:", error.message);
|
|
50
|
-
return { status: 1 };
|
|
51
|
-
}
|
|
46
|
+
return reportCommandExecutionFailure(error, command);
|
|
52
47
|
}
|
|
53
48
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { ui } from "../../environment/userInteraction.js";
|
|
2
1
|
import { safeSpawn } from "../../utils/safeSpawn.js";
|
|
3
2
|
import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js";
|
|
3
|
+
import { reportCommandExecutionFailure } from "../_shared/commandErrors.js";
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* @param {string[]} args
|
|
@@ -15,11 +15,6 @@ export async function runNpm(args) {
|
|
|
15
15
|
});
|
|
16
16
|
return { status: result.status };
|
|
17
17
|
} catch (/** @type any */ error) {
|
|
18
|
-
|
|
19
|
-
return { status: error.status };
|
|
20
|
-
} else {
|
|
21
|
-
ui.writeError("Error executing command:", error.message);
|
|
22
|
-
return { status: 1 };
|
|
23
|
-
}
|
|
18
|
+
return reportCommandExecutionFailure(error, "npm");
|
|
24
19
|
}
|
|
25
20
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { ui } from "../../environment/userInteraction.js";
|
|
2
1
|
import { safeSpawn } from "../../utils/safeSpawn.js";
|
|
3
2
|
import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js";
|
|
3
|
+
import { reportCommandExecutionFailure } from "../_shared/commandErrors.js";
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* @param {string[]} args
|
|
@@ -15,11 +15,6 @@ export async function runNpx(args) {
|
|
|
15
15
|
});
|
|
16
16
|
return { status: result.status };
|
|
17
17
|
} catch (/** @type any */ error) {
|
|
18
|
-
|
|
19
|
-
return { status: error.status };
|
|
20
|
-
} else {
|
|
21
|
-
ui.writeError("Error executing command:", error.message);
|
|
22
|
-
return { status: 1 };
|
|
23
|
-
}
|
|
18
|
+
return reportCommandExecutionFailure(error, "npx");
|
|
24
19
|
}
|
|
25
20
|
}
|
|
@@ -9,6 +9,7 @@ import os from "node:os";
|
|
|
9
9
|
import path from "node:path";
|
|
10
10
|
import ini from "ini";
|
|
11
11
|
import { spawn } from "child_process";
|
|
12
|
+
import { reportCommandExecutionFailure } from "../_shared/commandErrors.js";
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* Checks if this pip invocation should bypass safe-chain and spawn directly.
|
|
@@ -203,12 +204,6 @@ export async function runPip(command, args) {
|
|
|
203
204
|
|
|
204
205
|
return { status: result.status };
|
|
205
206
|
} catch (/** @type any */ error) {
|
|
206
|
-
|
|
207
|
-
return { status: error.status };
|
|
208
|
-
} else {
|
|
209
|
-
ui.writeError(`Error executing command: ${error.message}`);
|
|
210
|
-
ui.writeError(`Is '${command}' installed and available on your system?`);
|
|
211
|
-
return { status: 1 };
|
|
212
|
-
}
|
|
207
|
+
return reportCommandExecutionFailure(error, command);
|
|
213
208
|
}
|
|
214
209
|
}
|
|
@@ -2,6 +2,7 @@ import { ui } from "../../environment/userInteraction.js";
|
|
|
2
2
|
import { safeSpawn } from "../../utils/safeSpawn.js";
|
|
3
3
|
import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js";
|
|
4
4
|
import { getCombinedCaBundlePath } from "../../registryProxy/certBundle.js";
|
|
5
|
+
import { reportCommandExecutionFailure } from "../_shared/commandErrors.js";
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* Sets CA bundle environment variables used by Python libraries and pipx.
|
|
@@ -54,12 +55,6 @@ export async function runPipX(command, args) {
|
|
|
54
55
|
|
|
55
56
|
return { status: result.status };
|
|
56
57
|
} catch (/** @type any */ error) {
|
|
57
|
-
|
|
58
|
-
return { status: error.status };
|
|
59
|
-
} else {
|
|
60
|
-
ui.writeError(`Error executing command: ${error.message}`);
|
|
61
|
-
ui.writeError(`Is '${command}' installed and available on your system?`);
|
|
62
|
-
return { status: 1 };
|
|
63
|
-
}
|
|
58
|
+
return reportCommandExecutionFailure(error, command);
|
|
64
59
|
}
|
|
65
60
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { ui } from "../../environment/userInteraction.js";
|
|
2
1
|
import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js";
|
|
3
2
|
import { safeSpawn } from "../../utils/safeSpawn.js";
|
|
3
|
+
import { reportCommandExecutionFailure } from "../_shared/commandErrors.js";
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* @param {string[]} args
|
|
@@ -26,11 +26,7 @@ export async function runPnpmCommand(args, toolName = "pnpm") {
|
|
|
26
26
|
|
|
27
27
|
return { status: result.status };
|
|
28
28
|
} catch (/** @type any */ error) {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
} else {
|
|
32
|
-
ui.writeError("Error executing command:", error.message);
|
|
33
|
-
return { status: 1 };
|
|
34
|
-
}
|
|
29
|
+
const target = toolName === "pnpm" ? "pnpm" : "pnpx";
|
|
30
|
+
return reportCommandExecutionFailure(error, target);
|
|
35
31
|
}
|
|
36
32
|
}
|
|
@@ -2,6 +2,7 @@ import { ui } from "../../environment/userInteraction.js";
|
|
|
2
2
|
import { safeSpawn } from "../../utils/safeSpawn.js";
|
|
3
3
|
import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js";
|
|
4
4
|
import { getCombinedCaBundlePath } from "../../registryProxy/certBundle.js";
|
|
5
|
+
import { reportCommandExecutionFailure } from "../_shared/commandErrors.js";
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* @returns {import("../currentPackageManager.js").PackageManager}
|
|
@@ -66,12 +67,6 @@ async function runPoetryCommand(args) {
|
|
|
66
67
|
|
|
67
68
|
return { status: result.status };
|
|
68
69
|
} catch (/** @type any */ error) {
|
|
69
|
-
|
|
70
|
-
return { status: error.status };
|
|
71
|
-
} else {
|
|
72
|
-
ui.writeError("Error executing command:", error.message);
|
|
73
|
-
ui.writeError("Is 'poetry' installed and available on your system?");
|
|
74
|
-
return { status: 1 };
|
|
75
|
-
}
|
|
70
|
+
return reportCommandExecutionFailure(error, "poetry");
|
|
76
71
|
}
|
|
77
72
|
}
|
|
@@ -2,6 +2,7 @@ import { ui } from "../../environment/userInteraction.js";
|
|
|
2
2
|
import { safeSpawn } from "../../utils/safeSpawn.js";
|
|
3
3
|
import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js";
|
|
4
4
|
import { getCombinedCaBundlePath } from "../../registryProxy/certBundle.js";
|
|
5
|
+
import { reportCommandExecutionFailure } from "../_shared/commandErrors.js";
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* Sets CA bundle environment variables used by Python libraries and uv.
|
|
@@ -60,12 +61,6 @@ export async function runUv(command, args) {
|
|
|
60
61
|
|
|
61
62
|
return { status: result.status };
|
|
62
63
|
} catch (/** @type any */ error) {
|
|
63
|
-
|
|
64
|
-
return { status: error.status };
|
|
65
|
-
} else {
|
|
66
|
-
ui.writeError(`Error executing command: ${error.message}`);
|
|
67
|
-
ui.writeError(`Is '${command}' installed and available on your system?`);
|
|
68
|
-
return { status: 1 };
|
|
69
|
-
}
|
|
64
|
+
return reportCommandExecutionFailure(error, command);
|
|
70
65
|
}
|
|
71
66
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { ui } from "../../environment/userInteraction.js";
|
|
2
1
|
import { safeSpawn } from "../../utils/safeSpawn.js";
|
|
3
2
|
import { mergeSafeChainProxyEnvironmentVariables } from "../../registryProxy/registryProxy.js";
|
|
3
|
+
import { reportCommandExecutionFailure } from "../_shared/commandErrors.js";
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* @param {string[]} args
|
|
@@ -18,12 +18,7 @@ export async function runYarnCommand(args) {
|
|
|
18
18
|
});
|
|
19
19
|
return { status: result.status };
|
|
20
20
|
} catch (/** @type any */ error) {
|
|
21
|
-
|
|
22
|
-
return { status: error.status };
|
|
23
|
-
} else {
|
|
24
|
-
ui.writeError("Error executing command:", error.message);
|
|
25
|
-
return { status: 1 };
|
|
26
|
-
}
|
|
21
|
+
return reportCommandExecutionFailure(error, "yarn");
|
|
27
22
|
}
|
|
28
23
|
}
|
|
29
24
|
|
|
@@ -8,6 +8,9 @@ import { X509Certificate } from "node:crypto";
|
|
|
8
8
|
import { getCaCertPath } from "./certUtils.js";
|
|
9
9
|
import { ui } from "../environment/userInteraction.js";
|
|
10
10
|
|
|
11
|
+
/** @type {string | null} */
|
|
12
|
+
let bundlePath = null;
|
|
13
|
+
|
|
11
14
|
/**
|
|
12
15
|
* Check if a PEM string contains only parsable cert blocks.
|
|
13
16
|
* @param {string} pem - PEM-encoded certificate string
|
|
@@ -54,6 +57,11 @@ function isParsable(pem) {
|
|
|
54
57
|
* @returns {string} Path to the combined CA bundle PEM file
|
|
55
58
|
*/
|
|
56
59
|
export function getCombinedCaBundlePath() {
|
|
60
|
+
if (bundlePath)
|
|
61
|
+
{
|
|
62
|
+
return bundlePath;
|
|
63
|
+
}
|
|
64
|
+
|
|
57
65
|
const parts = [];
|
|
58
66
|
|
|
59
67
|
// 1) Safe Chain CA (for MITM'd registries)
|
|
@@ -99,9 +107,23 @@ export function getCombinedCaBundlePath() {
|
|
|
99
107
|
}
|
|
100
108
|
|
|
101
109
|
const combined = parts.filter(Boolean).join("\n");
|
|
102
|
-
|
|
103
|
-
fs.writeFileSync(
|
|
104
|
-
return
|
|
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
|
+
}
|
|
105
127
|
}
|
|
106
128
|
|
|
107
129
|
/**
|
|
@@ -15,3 +15,66 @@ export function getHeaderValueAsString(headers, headerName) {
|
|
|
15
15
|
|
|
16
16
|
return header;
|
|
17
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
|
+
}
|
|
@@ -4,7 +4,7 @@ import {
|
|
|
4
4
|
getEcoSystem,
|
|
5
5
|
} from "../../config/settings.js";
|
|
6
6
|
import { npmInterceptorForUrl } from "./npm/npmInterceptor.js";
|
|
7
|
-
import { pipInterceptorForUrl } from "./pipInterceptor.js";
|
|
7
|
+
import { pipInterceptorForUrl } from "./pip/pipInterceptor.js";
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* @param {string} url
|
|
@@ -10,6 +10,7 @@ import { EventEmitter } from "events";
|
|
|
10
10
|
* @typedef {Object} RequestInterceptionContext
|
|
11
11
|
* @property {string} targetUrl
|
|
12
12
|
* @property {(packageName: string | undefined, version: string | undefined) => void} blockMalware
|
|
13
|
+
* @property {(packageName: string, version: string, message: string) => void} blockMinimumAgeRequest
|
|
13
14
|
* @property {(modificationFunc: (headers: NodeJS.Dict<string | string[]>) => NodeJS.Dict<string | string[]>) => void} modifyRequestHeaders
|
|
14
15
|
* @property {(modificationFunc: (body: Buffer, headers: NodeJS.Dict<string | string[]> | undefined) => Buffer) => void} modifyBody
|
|
15
16
|
* @property {() => RequestInterceptionHandler} build
|
|
@@ -26,6 +27,12 @@ import { EventEmitter } from "events";
|
|
|
26
27
|
* @property {string} version
|
|
27
28
|
* @property {string} targetUrl
|
|
28
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
|
|
29
36
|
*/
|
|
30
37
|
|
|
31
38
|
/**
|
|
@@ -81,10 +88,7 @@ function createRequestContext(targetUrl, eventEmitter) {
|
|
|
81
88
|
* @param {string | undefined} version
|
|
82
89
|
*/
|
|
83
90
|
function blockMalwareSetup(packageName, version) {
|
|
84
|
-
blockResponse =
|
|
85
|
-
statusCode: 403,
|
|
86
|
-
message: "Forbidden - blocked by safe-chain",
|
|
87
|
-
};
|
|
91
|
+
blockResponse = createBlockResponse("Forbidden - blocked by safe-chain");
|
|
88
92
|
|
|
89
93
|
// Emit the malwareBlocked event
|
|
90
94
|
eventEmitter.emit("malwareBlocked", {
|
|
@@ -95,6 +99,34 @@ function createRequestContext(targetUrl, eventEmitter) {
|
|
|
95
99
|
});
|
|
96
100
|
}
|
|
97
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
|
+
|
|
98
130
|
/** @returns {RequestInterceptionHandler} */
|
|
99
131
|
function build() {
|
|
100
132
|
/**
|
|
@@ -139,6 +171,7 @@ function createRequestContext(targetUrl, eventEmitter) {
|
|
|
139
171
|
return {
|
|
140
172
|
targetUrl,
|
|
141
173
|
blockMalware: blockMalwareSetup,
|
|
174
|
+
blockMinimumAgeRequest: blockMinimumAgeRequestSetup,
|
|
142
175
|
modifyRequestHeaders: (func) => reqheaderModificationFuncs.push(func),
|
|
143
176
|
modifyBody: (func) => modifyBodyFuncs.push(func),
|
|
144
177
|
build,
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { getMinimumPackageAgeExclusions, getEcoSystem } from "../../config/settings.js";
|
|
2
|
+
import { getEquivalentPackageNames } from "../../scanning/packageNameVariants.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Checks if a package name matches an exclusion pattern.
|
|
6
|
+
* Supports trailing wildcard (*) for prefix matching.
|
|
7
|
+
* @param {string} packageName
|
|
8
|
+
* @param {string} pattern
|
|
9
|
+
* @returns {boolean}
|
|
10
|
+
*/
|
|
11
|
+
export function matchesExclusionPattern(packageName, pattern) {
|
|
12
|
+
if (pattern.endsWith("/*")) {
|
|
13
|
+
return packageName.startsWith(pattern.slice(0, -1));
|
|
14
|
+
}
|
|
15
|
+
return packageName === pattern;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @param {string | undefined} packageName
|
|
20
|
+
* @returns {boolean}
|
|
21
|
+
*/
|
|
22
|
+
export function isExcludedFromMinimumPackageAge(packageName) {
|
|
23
|
+
if (!packageName) {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const exclusions = getMinimumPackageAgeExclusions();
|
|
28
|
+
const candidateNames = getEquivalentPackageNames(packageName, getEcoSystem());
|
|
29
|
+
|
|
30
|
+
return exclusions.some((pattern) =>
|
|
31
|
+
candidateNames.some((name) => matchesExclusionPattern(name, pattern))
|
|
32
|
+
);
|
|
33
|
+
}
|
|
@@ -1,10 +1,7 @@
|
|
|
1
|
-
import { getMinimumPackageAgeHours
|
|
1
|
+
import { getMinimumPackageAgeHours } from "../../../config/settings.js";
|
|
2
2
|
import { ui } from "../../../environment/userInteraction.js";
|
|
3
|
-
import { getHeaderValueAsString } from "../../http-utils.js";
|
|
4
|
-
|
|
5
|
-
const state = {
|
|
6
|
-
hasSuppressedVersions: false,
|
|
7
|
-
};
|
|
3
|
+
import { clearCachingHeaders, getHeaderValueAsString } from "../../http-utils.js";
|
|
4
|
+
import { recordSuppressedVersion } from "../suppressedVersionsState.js";
|
|
8
5
|
|
|
9
6
|
/**
|
|
10
7
|
* @param {NodeJS.Dict<string | string[]>} headers
|
|
@@ -65,16 +62,6 @@ export function modifyNpmInfoResponse(body, headers) {
|
|
|
65
62
|
return body;
|
|
66
63
|
}
|
|
67
64
|
|
|
68
|
-
// Check if this package is excluded from minimum age filtering
|
|
69
|
-
const packageName = bodyJson.name;
|
|
70
|
-
const exclusions = getNpmMinimumPackageAgeExclusions();
|
|
71
|
-
if (packageName && exclusions.some((pattern) => matchesExclusionPattern(packageName, pattern))) {
|
|
72
|
-
ui.writeVerbose(
|
|
73
|
-
`Safe-chain: ${packageName} is excluded from minimum package age filtering (minimumPackageAgeExclusions setting).`
|
|
74
|
-
);
|
|
75
|
-
return body;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
65
|
const cutOff = new Date(
|
|
79
66
|
new Date().getTime() - getMinimumPackageAgeHours() * 3600 * 1000
|
|
80
67
|
);
|
|
@@ -92,15 +79,7 @@ export function modifyNpmInfoResponse(body, headers) {
|
|
|
92
79
|
const timestampValue = new Date(timestamp);
|
|
93
80
|
if (timestampValue > cutOff) {
|
|
94
81
|
deleteVersionFromJson(bodyJson, version);
|
|
95
|
-
|
|
96
|
-
// When modifying the response, the etag and last-modified headers
|
|
97
|
-
// no longer match the content so they needs to be removed before sending the response.
|
|
98
|
-
delete headers["etag"];
|
|
99
|
-
delete headers["last-modified"];
|
|
100
|
-
// Removing the cache-control header will prevent the package manager from caching
|
|
101
|
-
// the modified response.
|
|
102
|
-
delete headers["cache-control"];
|
|
103
|
-
}
|
|
82
|
+
clearCachingHeaders(headers);
|
|
104
83
|
}
|
|
105
84
|
}
|
|
106
85
|
|
|
@@ -124,7 +103,7 @@ export function modifyNpmInfoResponse(body, headers) {
|
|
|
124
103
|
* @param {string} version
|
|
125
104
|
*/
|
|
126
105
|
function deleteVersionFromJson(json, version) {
|
|
127
|
-
|
|
106
|
+
recordSuppressedVersion();
|
|
128
107
|
|
|
129
108
|
const packageName = typeof json?.name === "string" ? json.name : "(unknown)";
|
|
130
109
|
|
|
@@ -182,22 +161,20 @@ function getMostRecentTag(tagList) {
|
|
|
182
161
|
}
|
|
183
162
|
|
|
184
163
|
/**
|
|
185
|
-
* @
|
|
164
|
+
* @param {Buffer} body
|
|
165
|
+
* @param {NodeJS.Dict<string | string[]> | undefined} headers
|
|
166
|
+
* @returns {string | undefined}
|
|
186
167
|
*/
|
|
187
|
-
export function
|
|
188
|
-
|
|
189
|
-
|
|
168
|
+
export function getPackageNameFromMetadataResponse(body, headers) {
|
|
169
|
+
try {
|
|
170
|
+
const contentType = getHeaderValueAsString(headers, "content-type");
|
|
171
|
+
if (!contentType?.toLowerCase().includes("application/json")) {
|
|
172
|
+
return undefined;
|
|
173
|
+
}
|
|
190
174
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
* @param {string} pattern
|
|
196
|
-
* @returns {boolean}
|
|
197
|
-
*/
|
|
198
|
-
function matchesExclusionPattern(packageName, pattern) {
|
|
199
|
-
if (pattern.endsWith("/*")) {
|
|
200
|
-
return packageName.startsWith(pattern.slice(0, -1));
|
|
175
|
+
const bodyJson = JSON.parse(body.toString("utf8"));
|
|
176
|
+
return typeof bodyJson.name === "string" ? bodyJson.name : undefined;
|
|
177
|
+
} catch {
|
|
178
|
+
return undefined;
|
|
201
179
|
}
|
|
202
|
-
return packageName === pattern;
|
|
203
180
|
}
|
|
@@ -5,11 +5,16 @@ import {
|
|
|
5
5
|
import { isMalwarePackage } from "../../../scanning/audit/index.js";
|
|
6
6
|
import { interceptRequests } from "../interceptorBuilder.js";
|
|
7
7
|
import {
|
|
8
|
+
getPackageNameFromMetadataResponse,
|
|
8
9
|
isPackageInfoUrl,
|
|
9
10
|
modifyNpmInfoRequestHeaders,
|
|
10
11
|
modifyNpmInfoResponse,
|
|
11
12
|
} from "./modifyNpmInfo.js";
|
|
12
13
|
import { parseNpmPackageUrl } from "./parseNpmPackageUrl.js";
|
|
14
|
+
import { openNewPackagesDatabase } from "../../../scanning/newPackagesListCache.js";
|
|
15
|
+
import {
|
|
16
|
+
isExcludedFromMinimumPackageAge,
|
|
17
|
+
} from "../minimumPackageAgeExclusions.js";
|
|
13
18
|
|
|
14
19
|
const knownJsRegistries = [
|
|
15
20
|
"registry.npmjs.org",
|
|
@@ -43,14 +48,54 @@ function buildNpmInterceptor(registry) {
|
|
|
43
48
|
reqContext.targetUrl,
|
|
44
49
|
registry
|
|
45
50
|
);
|
|
51
|
+
const minimumAgeChecksEnabled = !skipMinimumPackageAge();
|
|
46
52
|
|
|
47
53
|
if (await isMalwarePackage(packageName, version)) {
|
|
48
54
|
reqContext.blockMalware(packageName, version);
|
|
55
|
+
return;
|
|
49
56
|
}
|
|
50
57
|
|
|
51
|
-
if (
|
|
58
|
+
if (minimumAgeChecksEnabled && isPackageInfoUrl(reqContext.targetUrl)) {
|
|
52
59
|
reqContext.modifyRequestHeaders(modifyNpmInfoRequestHeaders);
|
|
53
|
-
reqContext.modifyBody(
|
|
60
|
+
reqContext.modifyBody(modifyNpmInfoResponseUnlessExcluded);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// For tarball requests the metadata check above is skipped, so we check the
|
|
65
|
+
// new packages list as a fallback (covers e.g. frozen-lockfile installs).
|
|
66
|
+
if (
|
|
67
|
+
minimumAgeChecksEnabled &&
|
|
68
|
+
packageName &&
|
|
69
|
+
version &&
|
|
70
|
+
!isExcludedFromMinimumPackageAge(packageName)
|
|
71
|
+
) {
|
|
72
|
+
const newPackagesDatabase = await openNewPackagesDatabase();
|
|
73
|
+
|
|
74
|
+
if (newPackagesDatabase.isNewlyReleasedPackage(packageName, version)) {
|
|
75
|
+
reqContext.blockMinimumAgeRequest(
|
|
76
|
+
packageName,
|
|
77
|
+
version,
|
|
78
|
+
`Forbidden - blocked by safe-chain direct download minimum package age (${packageName}@${version})`
|
|
79
|
+
);
|
|
80
|
+
}
|
|
54
81
|
}
|
|
55
82
|
});
|
|
56
83
|
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* @param {Buffer} body
|
|
87
|
+
* @param {NodeJS.Dict<string | string[]> | undefined} headers
|
|
88
|
+
* @returns {Buffer}
|
|
89
|
+
*/
|
|
90
|
+
function modifyNpmInfoResponseUnlessExcluded(body, headers) {
|
|
91
|
+
const metadataPackageName = getPackageNameFromMetadataResponse(body, headers);
|
|
92
|
+
|
|
93
|
+
if (
|
|
94
|
+
metadataPackageName &&
|
|
95
|
+
isExcludedFromMinimumPackageAge(metadataPackageName)
|
|
96
|
+
) {
|
|
97
|
+
return body;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return modifyNpmInfoResponse(body, headers);
|
|
101
|
+
}
|
|
@@ -5,12 +5,29 @@
|
|
|
5
5
|
*/
|
|
6
6
|
export function parseNpmPackageUrl(url, registry) {
|
|
7
7
|
let packageName, version;
|
|
8
|
-
|
|
8
|
+
let parsedUrl;
|
|
9
|
+
|
|
10
|
+
try {
|
|
11
|
+
parsedUrl = new URL(url);
|
|
12
|
+
} catch {
|
|
13
|
+
return { packageName, version };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const pathname = parsedUrl.pathname;
|
|
17
|
+
|
|
18
|
+
if (!registry || !pathname.endsWith(".tgz")) {
|
|
19
|
+
return { packageName, version };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const registryPrefix = `${registry}/`;
|
|
23
|
+
const urlAfterProtocol = `${parsedUrl.host}${pathname}`;
|
|
24
|
+
if (!urlAfterProtocol.startsWith(registryPrefix)) {
|
|
9
25
|
return { packageName, version };
|
|
10
26
|
}
|
|
11
27
|
|
|
12
|
-
const
|
|
13
|
-
|
|
28
|
+
const afterRegistry = decodeURIComponent(
|
|
29
|
+
urlAfterProtocol.substring(registryPrefix.length)
|
|
30
|
+
);
|
|
14
31
|
|
|
15
32
|
const separatorIndex = afterRegistry.indexOf("/-/");
|
|
16
33
|
if (separatorIndex === -1) {
|