@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.
Files changed (54) hide show
  1. package/README.md +65 -8
  2. package/bin/safe-chain.js +1 -81
  3. package/package.json +1 -1
  4. package/src/api/aikido.js +93 -18
  5. package/src/config/cliArguments.js +24 -1
  6. package/src/config/configFile.js +64 -6
  7. package/src/config/environmentVariables.js +13 -2
  8. package/src/config/settings.js +53 -4
  9. package/src/main.js +6 -2
  10. package/src/packagemanager/_shared/commandErrors.js +17 -0
  11. package/src/packagemanager/bun/createBunPackageManager.js +2 -7
  12. package/src/packagemanager/npm/runNpmCommand.js +2 -7
  13. package/src/packagemanager/npx/runNpxCommand.js +2 -7
  14. package/src/packagemanager/pip/runPipCommand.js +2 -7
  15. package/src/packagemanager/pipx/runPipXCommand.js +2 -7
  16. package/src/packagemanager/pnpm/runPnpmCommand.js +3 -7
  17. package/src/packagemanager/poetry/createPoetryPackageManager.js +2 -7
  18. package/src/packagemanager/uv/runUvCommand.js +2 -7
  19. package/src/packagemanager/yarn/runYarnCommand.js +2 -7
  20. package/src/registryProxy/certBundle.js +25 -3
  21. package/src/registryProxy/http-utils.js +63 -0
  22. package/src/registryProxy/interceptors/createInterceptorForEcoSystem.js +1 -1
  23. package/src/registryProxy/interceptors/interceptorBuilder.js +37 -4
  24. package/src/registryProxy/interceptors/minimumPackageAgeExclusions.js +33 -0
  25. package/src/registryProxy/interceptors/npm/modifyNpmInfo.js +18 -41
  26. package/src/registryProxy/interceptors/npm/npmInterceptor.js +47 -2
  27. package/src/registryProxy/interceptors/npm/parseNpmPackageUrl.js +20 -3
  28. package/src/registryProxy/interceptors/pip/modifyPipInfo.js +167 -0
  29. package/src/registryProxy/interceptors/pip/modifyPipJsonResponse.js +176 -0
  30. package/src/registryProxy/interceptors/pip/parsePipPackageUrl.js +162 -0
  31. package/src/registryProxy/interceptors/pip/pipInterceptor.js +122 -0
  32. package/src/registryProxy/interceptors/pip/pipMetadataResponseUtils.js +27 -0
  33. package/src/registryProxy/interceptors/pip/pipMetadataVersionUtils.js +131 -0
  34. package/src/registryProxy/interceptors/suppressedVersionsState.js +21 -0
  35. package/src/registryProxy/mitmRequestHandler.js +12 -6
  36. package/src/registryProxy/registryProxy.js +72 -9
  37. package/src/scanning/newPackagesDatabaseBuilder.js +71 -0
  38. package/src/scanning/newPackagesDatabaseWarnings.js +17 -0
  39. package/src/scanning/newPackagesListCache.js +126 -0
  40. package/src/scanning/packageNameVariants.js +29 -0
  41. package/src/shell-integration/setup.js +7 -3
  42. package/src/shell-integration/shellDetection.js +2 -0
  43. package/src/shell-integration/supported-shells/bash.js +19 -1
  44. package/src/shell-integration/supported-shells/fish.js +18 -0
  45. package/src/shell-integration/supported-shells/powershell.js +18 -0
  46. package/src/shell-integration/supported-shells/windowsPowershell.js +18 -0
  47. package/src/shell-integration/supported-shells/zsh.js +19 -1
  48. package/src/shell-integration/teardown.js +7 -1
  49. package/src/ultimate/ultimateTroubleshooting.js +1 -1
  50. package/src/installation/downloadAgent.js +0 -125
  51. package/src/installation/installOnMacOS.js +0 -155
  52. package/src/installation/installOnWindows.js +0 -203
  53. package/src/installation/installUltimate.js +0 -35
  54. 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 (!proxy.verifyNoMaliciousPackages()) {
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 requirement.`,
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
- if (error.status) {
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
- if (error.status) {
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
- if (error.status) {
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
- if (error.status) {
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
- if (error.status) {
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
- if (error.status) {
30
- return { status: error.status };
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
- if (error.status) {
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
- if (error.status) {
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
- if (error.status) {
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
- const target = path.join(os.tmpdir(), `safe-chain-ca-bundle-${Date.now()}.pem`);
103
- fs.writeFileSync(target, combined, { encoding: "utf8" });
104
- return target;
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, getNpmMinimumPackageAgeExclusions } from "../../../config/settings.js";
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
- if (headers) {
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
- state.hasSuppressedVersions = true;
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
- * @returns {boolean}
164
+ * @param {Buffer} body
165
+ * @param {NodeJS.Dict<string | string[]> | undefined} headers
166
+ * @returns {string | undefined}
186
167
  */
187
- export function getHasSuppressedVersions() {
188
- return state.hasSuppressedVersions;
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
- * Checks if a package name matches an exclusion pattern.
193
- * Supports trailing wildcard (*) for prefix matching.
194
- * @param {string} packageName
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 (!skipMinimumPackageAge() && isPackageInfoUrl(reqContext.targetUrl)) {
58
+ if (minimumAgeChecksEnabled && isPackageInfoUrl(reqContext.targetUrl)) {
52
59
  reqContext.modifyRequestHeaders(modifyNpmInfoRequestHeaders);
53
- reqContext.modifyBody(modifyNpmInfoResponse);
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
- if (!registry || !url.endsWith(".tgz")) {
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 registryIndex = url.indexOf(registry);
13
- const afterRegistry = url.substring(registryIndex + registry.length + 1); // +1 to skip the slash
28
+ const afterRegistry = decodeURIComponent(
29
+ urlAfterProtocol.substring(registryPrefix.length)
30
+ );
14
31
 
15
32
  const separatorIndex = afterRegistry.indexOf("/-/");
16
33
  if (separatorIndex === -1) {