@aikidosec/safe-chain 0.0.4-connect-timeout-beta

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 (94) hide show
  1. package/LICENSE +674 -0
  2. package/README.md +257 -0
  3. package/bin/aikido-bun.js +14 -0
  4. package/bin/aikido-bunx.js +14 -0
  5. package/bin/aikido-npm.js +14 -0
  6. package/bin/aikido-npx.js +14 -0
  7. package/bin/aikido-pip.js +20 -0
  8. package/bin/aikido-pip3.js +21 -0
  9. package/bin/aikido-pnpm.js +14 -0
  10. package/bin/aikido-pnpx.js +14 -0
  11. package/bin/aikido-python.js +30 -0
  12. package/bin/aikido-python3.js +30 -0
  13. package/bin/aikido-uv.js +16 -0
  14. package/bin/aikido-yarn.js +14 -0
  15. package/bin/safe-chain.js +190 -0
  16. package/docs/banner.svg +151 -0
  17. package/docs/npm-to-binary-migration.md +89 -0
  18. package/docs/safe-package-manager-demo.gif +0 -0
  19. package/docs/safe-package-manager-demo.png +0 -0
  20. package/docs/shell-integration.md +149 -0
  21. package/package.json +68 -0
  22. package/src/api/aikido.js +54 -0
  23. package/src/api/npmApi.js +71 -0
  24. package/src/config/cliArguments.js +138 -0
  25. package/src/config/configFile.js +192 -0
  26. package/src/config/environmentVariables.js +7 -0
  27. package/src/config/settings.js +100 -0
  28. package/src/environment/environment.js +14 -0
  29. package/src/environment/userInteraction.js +122 -0
  30. package/src/main.js +104 -0
  31. package/src/packagemanager/_shared/matchesCommand.js +18 -0
  32. package/src/packagemanager/bun/createBunPackageManager.js +53 -0
  33. package/src/packagemanager/currentPackageManager.js +72 -0
  34. package/src/packagemanager/npm/createPackageManager.js +72 -0
  35. package/src/packagemanager/npm/dependencyScanner/commandArgumentScanner.js +74 -0
  36. package/src/packagemanager/npm/dependencyScanner/nullScanner.js +9 -0
  37. package/src/packagemanager/npm/parsing/parsePackagesFromInstallArgs.js +144 -0
  38. package/src/packagemanager/npm/runNpmCommand.js +25 -0
  39. package/src/packagemanager/npm/utils/abbrevs-generated.js +359 -0
  40. package/src/packagemanager/npm/utils/cmd-list.js +174 -0
  41. package/src/packagemanager/npm/utils/npmCommands.js +34 -0
  42. package/src/packagemanager/npx/createPackageManager.js +15 -0
  43. package/src/packagemanager/npx/dependencyScanner/commandArgumentScanner.js +43 -0
  44. package/src/packagemanager/npx/parsing/parsePackagesFromArguments.js +130 -0
  45. package/src/packagemanager/npx/runNpxCommand.js +25 -0
  46. package/src/packagemanager/pip/createPackageManager.js +21 -0
  47. package/src/packagemanager/pip/pipSettings.js +30 -0
  48. package/src/packagemanager/pip/runPipCommand.js +175 -0
  49. package/src/packagemanager/pnpm/createPackageManager.js +57 -0
  50. package/src/packagemanager/pnpm/dependencyScanner/commandArgumentScanner.js +35 -0
  51. package/src/packagemanager/pnpm/parsing/parsePackagesFromArguments.js +109 -0
  52. package/src/packagemanager/pnpm/runPnpmCommand.js +36 -0
  53. package/src/packagemanager/uv/createUvPackageManager.js +18 -0
  54. package/src/packagemanager/uv/runUvCommand.js +71 -0
  55. package/src/packagemanager/yarn/createPackageManager.js +41 -0
  56. package/src/packagemanager/yarn/dependencyScanner/commandArgumentScanner.js +35 -0
  57. package/src/packagemanager/yarn/parsing/parsePackagesFromArguments.js +128 -0
  58. package/src/packagemanager/yarn/runYarnCommand.js +41 -0
  59. package/src/registryProxy/certBundle.js +95 -0
  60. package/src/registryProxy/certUtils.js +128 -0
  61. package/src/registryProxy/http-utils.js +17 -0
  62. package/src/registryProxy/interceptors/createInterceptorForEcoSystem.js +25 -0
  63. package/src/registryProxy/interceptors/interceptorBuilder.js +140 -0
  64. package/src/registryProxy/interceptors/npm/modifyNpmInfo.js +177 -0
  65. package/src/registryProxy/interceptors/npm/npmInterceptor.js +47 -0
  66. package/src/registryProxy/interceptors/npm/parseNpmPackageUrl.js +43 -0
  67. package/src/registryProxy/interceptors/pipInterceptor.js +115 -0
  68. package/src/registryProxy/mitmRequestHandler.js +231 -0
  69. package/src/registryProxy/plainHttpProxy.js +95 -0
  70. package/src/registryProxy/registryProxy.js +184 -0
  71. package/src/registryProxy/tunnelRequestHandler.js +180 -0
  72. package/src/scanning/audit/index.js +129 -0
  73. package/src/scanning/index.js +82 -0
  74. package/src/scanning/malwareDatabase.js +131 -0
  75. package/src/shell-integration/helpers.js +213 -0
  76. package/src/shell-integration/path-wrappers/templates/unix-wrapper.template.sh +22 -0
  77. package/src/shell-integration/path-wrappers/templates/windows-wrapper.template.cmd +24 -0
  78. package/src/shell-integration/setup-ci.js +170 -0
  79. package/src/shell-integration/setup.js +127 -0
  80. package/src/shell-integration/shellDetection.js +37 -0
  81. package/src/shell-integration/startup-scripts/include-python/init-fish.fish +94 -0
  82. package/src/shell-integration/startup-scripts/include-python/init-posix.sh +81 -0
  83. package/src/shell-integration/startup-scripts/include-python/init-pwsh.ps1 +115 -0
  84. package/src/shell-integration/startup-scripts/init-fish.fish +71 -0
  85. package/src/shell-integration/startup-scripts/init-posix.sh +58 -0
  86. package/src/shell-integration/startup-scripts/init-pwsh.ps1 +92 -0
  87. package/src/shell-integration/supported-shells/bash.js +134 -0
  88. package/src/shell-integration/supported-shells/fish.js +77 -0
  89. package/src/shell-integration/supported-shells/powershell.js +73 -0
  90. package/src/shell-integration/supported-shells/windowsPowershell.js +73 -0
  91. package/src/shell-integration/supported-shells/zsh.js +74 -0
  92. package/src/shell-integration/teardown.js +64 -0
  93. package/src/utils/safeSpawn.js +137 -0
  94. package/tsconfig.json +21 -0
@@ -0,0 +1,140 @@
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 {(modificationFunc: (headers: NodeJS.Dict<string | string[]>) => NodeJS.Dict<string | string[]>) => void} modifyRequestHeaders
14
+ * @property {(modificationFunc: (body: Buffer, headers: NodeJS.Dict<string | string[]> | undefined) => Buffer) => void} modifyBody
15
+ * @property {() => RequestInterceptionHandler} build
16
+ *
17
+ *
18
+ * @typedef {Object} RequestInterceptionHandler
19
+ * @property {{statusCode: number, message: string} | undefined} blockResponse
20
+ * @property {(headers: NodeJS.Dict<string | string[]> | undefined) => NodeJS.Dict<string | string[]> | undefined} modifyRequestHeaders
21
+ * @property {() => boolean} modifiesResponse
22
+ * @property {(body: Buffer, headers: NodeJS.Dict<string | string[]> | undefined) => Buffer} modifyBody
23
+ */
24
+
25
+ /**
26
+ * @param {(requestHandlerBuilder: RequestInterceptionContext) => Promise<void>} requestInterceptionFunc
27
+ * @returns {Interceptor}
28
+ */
29
+ export function interceptRequests(requestInterceptionFunc) {
30
+ return buildInterceptor([requestInterceptionFunc]);
31
+ }
32
+
33
+ /**
34
+ * @param {Array<(requestHandlerBuilder: RequestInterceptionContext) => Promise<void>>} requestHandlers
35
+ * @returns {Interceptor}
36
+ */
37
+ function buildInterceptor(requestHandlers) {
38
+ const eventEmitter = new EventEmitter();
39
+
40
+ return {
41
+ async handleRequest(targetUrl) {
42
+ const requestContext = createRequestContext(targetUrl, eventEmitter);
43
+
44
+ for (const handler of requestHandlers) {
45
+ await handler(requestContext);
46
+ }
47
+
48
+ return requestContext.build();
49
+ },
50
+ on(event, listener) {
51
+ eventEmitter.on(event, listener);
52
+ return this;
53
+ },
54
+ emit(event, ...args) {
55
+ return eventEmitter.emit(event, ...args);
56
+ },
57
+ };
58
+ }
59
+
60
+ /**
61
+ * @param {string} targetUrl
62
+ * @param {import('events').EventEmitter} eventEmitter
63
+ * @returns {RequestInterceptionContext}
64
+ */
65
+ function createRequestContext(targetUrl, eventEmitter) {
66
+ /** @type {{statusCode: number, message: string} | undefined} */
67
+ let blockResponse = undefined;
68
+ /** @type {Array<(headers: NodeJS.Dict<string | string[]>) => NodeJS.Dict<string | string[]>>} */
69
+ let reqheaderModificationFuncs = [];
70
+ /** @type {Array<(body: Buffer, headers: NodeJS.Dict<string | string[]> | undefined) => Buffer>} */
71
+ let modifyBodyFuncs = [];
72
+
73
+ /**
74
+ * @param {string | undefined} packageName
75
+ * @param {string | undefined} version
76
+ */
77
+ function blockMalwareSetup(packageName, version) {
78
+ blockResponse = {
79
+ statusCode: 403,
80
+ message: "Forbidden - blocked by safe-chain",
81
+ };
82
+
83
+ // Emit the malwareBlocked event
84
+ eventEmitter.emit("malwareBlocked", {
85
+ packageName,
86
+ version,
87
+ targetUrl,
88
+ timestamp: Date.now(),
89
+ });
90
+ }
91
+
92
+ /** @returns {RequestInterceptionHandler} */
93
+ function build() {
94
+ /**
95
+ * @param {NodeJS.Dict<string | string[]> | undefined} headers
96
+ * @returns {NodeJS.Dict<string | string[]> | undefined}
97
+ */
98
+ function modifyRequestHeaders(headers) {
99
+ if (headers) {
100
+ for (const func of reqheaderModificationFuncs) {
101
+ func(headers);
102
+ }
103
+ }
104
+
105
+ return headers;
106
+ }
107
+
108
+ /**
109
+ * @param {Buffer} body
110
+ * @param {NodeJS.Dict<string | string[]> | undefined} headers
111
+ * @returns {Buffer}
112
+ */
113
+ function modifyBody(body, headers) {
114
+ let modifiedBody = body;
115
+
116
+ for (var func of modifyBodyFuncs) {
117
+ modifiedBody = func(body, headers);
118
+ }
119
+
120
+ return modifiedBody;
121
+ }
122
+
123
+ // These functions are invoked in the proxy, allowing to apply the configured modifications
124
+ return {
125
+ blockResponse,
126
+ modifyRequestHeaders: modifyRequestHeaders,
127
+ modifiesResponse: () => modifyBodyFuncs.length > 0,
128
+ modifyBody,
129
+ };
130
+ }
131
+
132
+ // These functions are used to setup the modifications
133
+ return {
134
+ targetUrl,
135
+ blockMalware: blockMalwareSetup,
136
+ modifyRequestHeaders: (func) => reqheaderModificationFuncs.push(func),
137
+ modifyBody: (func) => modifyBodyFuncs.push(func),
138
+ build,
139
+ };
140
+ }
@@ -0,0 +1,177 @@
1
+ import { getMinimumPackageAgeHours } from "../../../config/settings.js";
2
+ import { ui } from "../../../environment/userInteraction.js";
3
+ import { getHeaderValueAsString } from "../../http-utils.js";
4
+
5
+ const state = {
6
+ hasSuppressedVersions: false,
7
+ };
8
+
9
+ /**
10
+ * @param {NodeJS.Dict<string | string[]>} headers
11
+ * @returns {NodeJS.Dict<string | string[]>}
12
+ */
13
+ export function modifyNpmInfoRequestHeaders(headers) {
14
+ const accept = getHeaderValueAsString(headers, "accept");
15
+ if (accept?.includes("application/vnd.npm.install-v1+json")) {
16
+ // The npm registry sometimes serves a more compact format that lacks
17
+ // the time metadata we need to filter out too new packages.
18
+ // Force the registry to return the full metadata by changing the Accept header.
19
+ headers["accept"] = "application/json";
20
+ }
21
+ return headers;
22
+ }
23
+
24
+ /**
25
+ * @param {string} url
26
+ * @returns {boolean}
27
+ */
28
+ export function isPackageInfoUrl(url) {
29
+ // Remove query string and fragment to get the actual path
30
+ const urlWithoutParams = url.split("?")[0].split("#")[0];
31
+
32
+ // Tarball downloads end with .tgz
33
+ if (urlWithoutParams.endsWith(".tgz")) return false;
34
+
35
+ // Special endpoints start with /-/ and should not be modified
36
+ // Examples: /-/npm/v1/security/advisories/bulk, /-/v1/search, /-/package/foo/access
37
+ if (urlWithoutParams.includes("/-/")) return false;
38
+
39
+ // Everything else is package metadata that can be modified
40
+ return true;
41
+ }
42
+ /**
43
+ *
44
+ * @param {Buffer} body
45
+ * @param {NodeJS.Dict<string | string[]> | undefined} headers
46
+ * @returns Buffer
47
+ */
48
+ export function modifyNpmInfoResponse(body, headers) {
49
+ try {
50
+ const contentType = getHeaderValueAsString(headers, "content-type");
51
+ if (!contentType?.toLowerCase().includes("application/json")) {
52
+ return body;
53
+ }
54
+
55
+ if (body.byteLength === 0) {
56
+ return body;
57
+ }
58
+
59
+ // utf-8 is default encoding for JSON, so we don't check if charset is defined in content-type header
60
+ const bodyContent = body.toString("utf8");
61
+ const bodyJson = JSON.parse(bodyContent);
62
+
63
+ if (!bodyJson.time || !bodyJson["dist-tags"] || !bodyJson.versions) {
64
+ // Just return the current body if the format is not
65
+ return body;
66
+ }
67
+
68
+ const cutOff = new Date(
69
+ new Date().getTime() - getMinimumPackageAgeHours() * 3600 * 1000
70
+ );
71
+
72
+ const hasLatestTag = !!bodyJson["dist-tags"]["latest"];
73
+
74
+ const versions = Object.entries(bodyJson.time)
75
+ .map(([version, timestamp]) => ({
76
+ version,
77
+ timestamp,
78
+ }))
79
+ .filter((x) => x.version !== "created" && x.version !== "modified");
80
+
81
+ for (const { version, timestamp } of versions) {
82
+ const timestampValue = new Date(timestamp);
83
+ if (timestampValue > cutOff) {
84
+ deleteVersionFromJson(bodyJson, version);
85
+ if (headers) {
86
+ // When modifying the response, the etag and last-modified headers
87
+ // no longer match the content so they needs to be removed before sending the response.
88
+ delete headers["etag"];
89
+ delete headers["last-modified"];
90
+ // Removing the cache-control header will prevent the package manager from caching
91
+ // the modified response.
92
+ delete headers["cache-control"];
93
+ }
94
+ }
95
+ }
96
+
97
+ if (hasLatestTag && !bodyJson["dist-tags"]["latest"]) {
98
+ // The latest tag was removed because it contained a package younger than the treshold.
99
+ // A new latest tag needs to be calculated
100
+ bodyJson["dist-tags"]["latest"] = calculateLatestTag(bodyJson.time);
101
+ }
102
+
103
+ return Buffer.from(JSON.stringify(bodyJson));
104
+ } catch (/** @type {any} */ err) {
105
+ ui.writeVerbose(
106
+ `Safe-chain: Package metadata not in expected format - bypassing modification. Error: ${err.message}`
107
+ );
108
+ return body;
109
+ }
110
+ }
111
+
112
+ /**
113
+ * @param {any} json
114
+ * @param {string} version
115
+ */
116
+ function deleteVersionFromJson(json, version) {
117
+ state.hasSuppressedVersions = true;
118
+
119
+ ui.writeVerbose(
120
+ `Safe-chain: ${version} is newer than ${getMinimumPackageAgeHours()} hours and was removed (minimumPackageAgeInHours setting).`
121
+ );
122
+
123
+ delete json.time[version];
124
+ delete json.versions[version];
125
+
126
+ for (const [tag, distVersion] of Object.entries(json["dist-tags"])) {
127
+ if (version == distVersion) {
128
+ delete json["dist-tags"][tag];
129
+ }
130
+ }
131
+ }
132
+
133
+ /**
134
+ * @param {Record<string, string>} tagList
135
+ * @returns {string | undefined}
136
+ */
137
+ function calculateLatestTag(tagList) {
138
+ const entries = Object.entries(tagList).filter(
139
+ ([version, _]) => version !== "created" && version !== "modified"
140
+ );
141
+
142
+ const latestFullRelease = getMostRecentTag(
143
+ Object.fromEntries(entries.filter(([version, _]) => !version.includes("-")))
144
+ );
145
+ if (latestFullRelease) {
146
+ return latestFullRelease;
147
+ }
148
+
149
+ const latestPrerelease = getMostRecentTag(
150
+ Object.fromEntries(entries.filter(([version, _]) => version.includes("-")))
151
+ );
152
+ return latestPrerelease;
153
+ }
154
+
155
+ /**
156
+ * @param {Record<string, string>} tagList
157
+ * @returns {string | undefined}
158
+ */
159
+ function getMostRecentTag(tagList) {
160
+ let current, currentDate;
161
+
162
+ for (const [version, timestamp] of Object.entries(tagList)) {
163
+ if (!currentDate || currentDate < timestamp) {
164
+ current = version;
165
+ currentDate = timestamp;
166
+ }
167
+ }
168
+
169
+ return current;
170
+ }
171
+
172
+ /**
173
+ * @returns {boolean}
174
+ */
175
+ export function getHasSuppressedVersions() {
176
+ return state.hasSuppressedVersions;
177
+ }
@@ -0,0 +1,47 @@
1
+ import { skipMinimumPackageAge } from "../../../config/settings.js";
2
+ import { isMalwarePackage } from "../../../scanning/audit/index.js";
3
+ import { interceptRequests } from "../interceptorBuilder.js";
4
+ import {
5
+ isPackageInfoUrl,
6
+ modifyNpmInfoRequestHeaders,
7
+ modifyNpmInfoResponse,
8
+ } from "./modifyNpmInfo.js";
9
+ import { parseNpmPackageUrl } from "./parseNpmPackageUrl.js";
10
+
11
+ const knownJsRegistries = ["registry.npmjs.org", "registry.yarnpkg.com"];
12
+
13
+ /**
14
+ * @param {string} url
15
+ * @returns {import("../interceptorBuilder.js").Interceptor | undefined}
16
+ */
17
+ export function npmInterceptorForUrl(url) {
18
+ const registry = knownJsRegistries.find((reg) => url.includes(reg));
19
+
20
+ if (registry) {
21
+ return buildNpmInterceptor(registry);
22
+ }
23
+
24
+ return undefined;
25
+ }
26
+
27
+ /**
28
+ * @param {string} registry
29
+ * @returns {import("../interceptorBuilder.js").Interceptor}
30
+ */
31
+ function buildNpmInterceptor(registry) {
32
+ return interceptRequests(async (reqContext) => {
33
+ const { packageName, version } = parseNpmPackageUrl(
34
+ reqContext.targetUrl,
35
+ registry
36
+ );
37
+
38
+ if (await isMalwarePackage(packageName, version)) {
39
+ reqContext.blockMalware(packageName, version);
40
+ }
41
+
42
+ if (!skipMinimumPackageAge() && isPackageInfoUrl(reqContext.targetUrl)) {
43
+ reqContext.modifyRequestHeaders(modifyNpmInfoRequestHeaders);
44
+ reqContext.modifyBody(modifyNpmInfoResponse);
45
+ }
46
+ });
47
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * @param {string} url
3
+ * @param {string} registry
4
+ * @returns {{packageName: string | undefined, version: string | undefined}}
5
+ */
6
+ export function parseNpmPackageUrl(url, registry) {
7
+ let packageName, version;
8
+ if (!registry || !url.endsWith(".tgz")) {
9
+ return { packageName, version };
10
+ }
11
+
12
+ const registryIndex = url.indexOf(registry);
13
+ const afterRegistry = url.substring(registryIndex + registry.length + 1); // +1 to skip the slash
14
+
15
+ const separatorIndex = afterRegistry.indexOf("/-/");
16
+ if (separatorIndex === -1) {
17
+ return { packageName, version };
18
+ }
19
+
20
+ packageName = afterRegistry.substring(0, separatorIndex);
21
+ const filename = afterRegistry.substring(
22
+ separatorIndex + 3,
23
+ afterRegistry.length - 4
24
+ ); // Remove /-/ and .tgz
25
+
26
+ // Extract version from filename
27
+ // For scoped packages like @babel/core, the filename is core-7.21.4.tgz
28
+ // For regular packages like lodash, the filename is lodash-4.17.21.tgz
29
+ if (packageName.startsWith("@")) {
30
+ const scopedPackageName = packageName.substring(
31
+ packageName.lastIndexOf("/") + 1
32
+ );
33
+ if (filename.startsWith(scopedPackageName + "-")) {
34
+ version = filename.substring(scopedPackageName.length + 1);
35
+ }
36
+ } else {
37
+ if (filename.startsWith(packageName + "-")) {
38
+ version = filename.substring(packageName.length + 1);
39
+ }
40
+ }
41
+
42
+ return { packageName, version };
43
+ }
@@ -0,0 +1,115 @@
1
+ import { isMalwarePackage } from "../../scanning/audit/index.js";
2
+ import { interceptRequests } from "./interceptorBuilder.js";
3
+
4
+ const knownPipRegistries = [
5
+ "files.pythonhosted.org",
6
+ "pypi.org",
7
+ "pypi.python.org",
8
+ "pythonhosted.org",
9
+ ];
10
+
11
+ /**
12
+ * @param {string} url
13
+ * @returns {import("./interceptorBuilder.js").Interceptor | undefined}
14
+ */
15
+ export function pipInterceptorForUrl(url) {
16
+ const registry = knownPipRegistries.find((reg) => url.includes(reg));
17
+
18
+ if (registry) {
19
+ return buildPipInterceptor(registry);
20
+ }
21
+
22
+ return undefined;
23
+ }
24
+
25
+ /**
26
+ * @param {string} registry
27
+ * @returns {import("./interceptorBuilder.js").Interceptor | undefined}
28
+ */
29
+ function buildPipInterceptor(registry) {
30
+ return interceptRequests(async (reqContext) => {
31
+ const { packageName, version } = parsePipPackageFromUrl(
32
+ reqContext.targetUrl,
33
+ registry
34
+ );
35
+ if (await isMalwarePackage(packageName, version)) {
36
+ reqContext.blockMalware(packageName, version);
37
+ }
38
+ });
39
+ }
40
+
41
+ /**
42
+ * @param {string} url
43
+ * @param {string} registry
44
+ * @returns {{packageName: string | undefined, version: string | undefined}}
45
+ */
46
+ function parsePipPackageFromUrl(url, registry) {
47
+ let packageName, version;
48
+
49
+ // Basic validation
50
+ if (!registry || typeof url !== "string") {
51
+ return { packageName, version };
52
+ }
53
+
54
+ // Quick sanity check on the URL + parse
55
+ let urlObj;
56
+ try {
57
+ urlObj = new URL(url);
58
+ } catch {
59
+ return { packageName, version };
60
+ }
61
+
62
+ // Get the last path segment (filename) and decode it (strip query & fragment automatically)
63
+ const lastSegment = urlObj.pathname.split("/").filter(Boolean).pop();
64
+ if (!lastSegment) {
65
+ return { packageName, version };
66
+ }
67
+
68
+ const filename = decodeURIComponent(lastSegment);
69
+
70
+ // Parse Python package downloads from PyPI/pythonhosted.org
71
+ // Example wheel: https://files.pythonhosted.org/packages/xx/yy/requests-2.28.1-py3-none-any.whl
72
+ // Example sdist: https://files.pythonhosted.org/packages/xx/yy/requests-2.28.1.tar.gz
73
+
74
+ // Wheel (.whl)
75
+ if (filename.endsWith(".whl")) {
76
+ const base = filename.slice(0, -4); // remove ".whl"
77
+ const firstDash = base.indexOf("-");
78
+ if (firstDash > 0) {
79
+ const dist = base.slice(0, firstDash); // may contain underscores
80
+ const rest = base.slice(firstDash + 1); // version + the rest of tags
81
+ const secondDash = rest.indexOf("-");
82
+ const rawVersion = secondDash >= 0 ? rest.slice(0, secondDash) : rest;
83
+ packageName = dist; // preserve underscores
84
+ version = rawVersion;
85
+ // Reject "latest" as it's a placeholder, not a real version
86
+ // When version is "latest", this signals the URL doesn't contain actual version info
87
+ // Returning undefined allows the request (see registryProxy.js isAllowedUrl)
88
+ if (version === "latest" || !packageName || !version) {
89
+ return { packageName: undefined, version: undefined };
90
+ }
91
+ return { packageName, version };
92
+ }
93
+ }
94
+
95
+ // Source dist (sdist)
96
+ const sdistExtMatch = filename.match(/\.(tar\.gz|zip|tar\.bz2|tar\.xz)$/i);
97
+ if (sdistExtMatch) {
98
+ const base = filename.slice(0, -sdistExtMatch[0].length);
99
+ const lastDash = base.lastIndexOf("-");
100
+ if (lastDash > 0 && lastDash < base.length - 1) {
101
+ packageName = base.slice(0, lastDash);
102
+ version = base.slice(lastDash + 1);
103
+ // Reject "latest" as it's a placeholder, not a real version
104
+ // When version is "latest", this signals the URL doesn't contain actual version info
105
+ // Returning undefined allows the request (see registryProxy.js isAllowedUrl)
106
+ if (version === "latest" || !packageName || !version) {
107
+ return { packageName: undefined, version: undefined };
108
+ }
109
+ return { packageName, version };
110
+ }
111
+ }
112
+
113
+ // Unknown file type or invalid
114
+ return { packageName: undefined, version: undefined };
115
+ }