@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.
Files changed (116) hide show
  1. package/LICENSE +674 -0
  2. package/README.md +537 -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 +17 -0
  8. package/bin/aikido-pip3.js +17 -0
  9. package/bin/aikido-pipx.js +16 -0
  10. package/bin/aikido-pnpm.js +14 -0
  11. package/bin/aikido-pnpx.js +14 -0
  12. package/bin/aikido-poetry.js +13 -0
  13. package/bin/aikido-python.js +19 -0
  14. package/bin/aikido-python3.js +19 -0
  15. package/bin/aikido-uv.js +16 -0
  16. package/bin/aikido-uvx.js +16 -0
  17. package/bin/aikido-yarn.js +14 -0
  18. package/bin/safe-chain.js +147 -0
  19. package/docs/Release.md +25 -0
  20. package/docs/banner.svg +151 -0
  21. package/docs/safe-package-manager-demo.gif +0 -0
  22. package/docs/safe-package-manager-demo.png +0 -0
  23. package/docs/shell-integration.md +149 -0
  24. package/docs/troubleshooting.md +321 -0
  25. package/npm-shrinkwrap.json +3180 -0
  26. package/package.json +71 -0
  27. package/src/api/aikido.js +187 -0
  28. package/src/api/npmApi.js +71 -0
  29. package/src/config/cliArguments.js +161 -0
  30. package/src/config/configFile.js +327 -0
  31. package/src/config/environmentVariables.js +57 -0
  32. package/src/config/safeChainDir.js +71 -0
  33. package/src/config/settings.js +247 -0
  34. package/src/environment/environment.js +14 -0
  35. package/src/environment/userInteraction.js +122 -0
  36. package/src/installLocation.js +42 -0
  37. package/src/main.js +123 -0
  38. package/src/packagemanager/_shared/commandErrors.js +17 -0
  39. package/src/packagemanager/_shared/matchesCommand.js +18 -0
  40. package/src/packagemanager/bun/createBunPackageManager.js +48 -0
  41. package/src/packagemanager/currentPackageManager.js +82 -0
  42. package/src/packagemanager/npm/createPackageManager.js +72 -0
  43. package/src/packagemanager/npm/dependencyScanner/commandArgumentScanner.js +74 -0
  44. package/src/packagemanager/npm/dependencyScanner/nullScanner.js +9 -0
  45. package/src/packagemanager/npm/parsing/parsePackagesFromInstallArgs.js +144 -0
  46. package/src/packagemanager/npm/runNpmCommand.js +20 -0
  47. package/src/packagemanager/npm/utils/abbrevs-generated.js +359 -0
  48. package/src/packagemanager/npm/utils/cmd-list.js +174 -0
  49. package/src/packagemanager/npm/utils/npmCommands.js +34 -0
  50. package/src/packagemanager/npx/createPackageManager.js +15 -0
  51. package/src/packagemanager/npx/dependencyScanner/commandArgumentScanner.js +43 -0
  52. package/src/packagemanager/npx/parsing/parsePackagesFromArguments.js +130 -0
  53. package/src/packagemanager/npx/runNpxCommand.js +20 -0
  54. package/src/packagemanager/pip/createPackageManager.js +25 -0
  55. package/src/packagemanager/pip/pipSettings.js +6 -0
  56. package/src/packagemanager/pip/runPipCommand.js +209 -0
  57. package/src/packagemanager/pipx/createPipXPackageManager.js +18 -0
  58. package/src/packagemanager/pipx/runPipXCommand.js +60 -0
  59. package/src/packagemanager/pnpm/createPackageManager.js +57 -0
  60. package/src/packagemanager/pnpm/dependencyScanner/commandArgumentScanner.js +35 -0
  61. package/src/packagemanager/pnpm/parsing/parsePackagesFromArguments.js +109 -0
  62. package/src/packagemanager/pnpm/runPnpmCommand.js +32 -0
  63. package/src/packagemanager/poetry/createPoetryPackageManager.js +72 -0
  64. package/src/packagemanager/uv/createUvPackageManager.js +18 -0
  65. package/src/packagemanager/uv/runUvCommand.js +66 -0
  66. package/src/packagemanager/uvx/createUvxPackageManager.js +18 -0
  67. package/src/packagemanager/yarn/createPackageManager.js +41 -0
  68. package/src/packagemanager/yarn/dependencyScanner/commandArgumentScanner.js +35 -0
  69. package/src/packagemanager/yarn/parsing/parsePackagesFromArguments.js +128 -0
  70. package/src/packagemanager/yarn/runYarnCommand.js +36 -0
  71. package/src/registryProxy/certBundle.js +203 -0
  72. package/src/registryProxy/certUtils.js +178 -0
  73. package/src/registryProxy/getConnectTimeout.js +13 -0
  74. package/src/registryProxy/http-utils.js +80 -0
  75. package/src/registryProxy/interceptors/createInterceptorForEcoSystem.js +25 -0
  76. package/src/registryProxy/interceptors/interceptorBuilder.js +179 -0
  77. package/src/registryProxy/interceptors/minimumPackageAgeExclusions.js +33 -0
  78. package/src/registryProxy/interceptors/npm/modifyNpmInfo.js +180 -0
  79. package/src/registryProxy/interceptors/npm/npmInterceptor.js +101 -0
  80. package/src/registryProxy/interceptors/npm/parseNpmPackageUrl.js +60 -0
  81. package/src/registryProxy/interceptors/pip/modifyPipInfo.js +167 -0
  82. package/src/registryProxy/interceptors/pip/modifyPipJsonResponse.js +176 -0
  83. package/src/registryProxy/interceptors/pip/parsePipPackageUrl.js +162 -0
  84. package/src/registryProxy/interceptors/pip/pipInterceptor.js +122 -0
  85. package/src/registryProxy/interceptors/pip/pipMetadataResponseUtils.js +27 -0
  86. package/src/registryProxy/interceptors/pip/pipMetadataVersionUtils.js +131 -0
  87. package/src/registryProxy/interceptors/suppressedVersionsState.js +21 -0
  88. package/src/registryProxy/isImdsEndpoint.js +13 -0
  89. package/src/registryProxy/mitmRequestHandler.js +240 -0
  90. package/src/registryProxy/plainHttpProxy.js +95 -0
  91. package/src/registryProxy/registryProxy.js +255 -0
  92. package/src/registryProxy/tunnelRequestHandler.js +213 -0
  93. package/src/scanning/audit/index.js +129 -0
  94. package/src/scanning/index.js +82 -0
  95. package/src/scanning/malwareDatabase.js +131 -0
  96. package/src/scanning/newPackagesDatabaseBuilder.js +71 -0
  97. package/src/scanning/newPackagesDatabaseWarnings.js +17 -0
  98. package/src/scanning/newPackagesListCache.js +126 -0
  99. package/src/scanning/packageNameVariants.js +29 -0
  100. package/src/shell-integration/helpers.js +296 -0
  101. package/src/shell-integration/path-wrappers/templates/unix-wrapper.template.sh +37 -0
  102. package/src/shell-integration/path-wrappers/templates/windows-wrapper.template.cmd +25 -0
  103. package/src/shell-integration/setup-ci.js +152 -0
  104. package/src/shell-integration/setup.js +110 -0
  105. package/src/shell-integration/shellDetection.js +39 -0
  106. package/src/shell-integration/startup-scripts/init-fish.fish +122 -0
  107. package/src/shell-integration/startup-scripts/init-posix.sh +112 -0
  108. package/src/shell-integration/startup-scripts/init-pwsh.ps1 +176 -0
  109. package/src/shell-integration/supported-shells/bash.js +222 -0
  110. package/src/shell-integration/supported-shells/fish.js +97 -0
  111. package/src/shell-integration/supported-shells/powershell.js +102 -0
  112. package/src/shell-integration/supported-shells/windowsPowershell.js +102 -0
  113. package/src/shell-integration/supported-shells/zsh.js +94 -0
  114. package/src/shell-integration/teardown.js +114 -0
  115. package/src/utils/safeSpawn.js +153 -0
  116. package/tsconfig.json +21 -0
@@ -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
+ }
@@ -0,0 +1,180 @@
1
+ import { getMinimumPackageAgeHours } from "../../../config/settings.js";
2
+ import { ui } from "../../../environment/userInteraction.js";
3
+ import { clearCachingHeaders, getHeaderValueAsString } from "../../http-utils.js";
4
+ import { recordSuppressedVersion } from "../suppressedVersionsState.js";
5
+
6
+ /**
7
+ * @param {NodeJS.Dict<string | string[]>} headers
8
+ * @returns {NodeJS.Dict<string | string[]>}
9
+ */
10
+ export function modifyNpmInfoRequestHeaders(headers) {
11
+ const accept = getHeaderValueAsString(headers, "accept");
12
+ if (accept?.includes("application/vnd.npm.install-v1+json")) {
13
+ // The npm registry sometimes serves a more compact format that lacks
14
+ // the time metadata we need to filter out too new packages.
15
+ // Force the registry to return the full metadata by changing the Accept header.
16
+ headers["accept"] = "application/json";
17
+ }
18
+ return headers;
19
+ }
20
+
21
+ /**
22
+ * @param {string} url
23
+ * @returns {boolean}
24
+ */
25
+ export function isPackageInfoUrl(url) {
26
+ // Remove query string and fragment to get the actual path
27
+ const urlWithoutParams = url.split("?")[0].split("#")[0];
28
+
29
+ // Tarball downloads end with .tgz
30
+ if (urlWithoutParams.endsWith(".tgz")) return false;
31
+
32
+ // Special endpoints start with /-/ and should not be modified
33
+ // Examples: /-/npm/v1/security/advisories/bulk, /-/v1/search, /-/package/foo/access
34
+ if (urlWithoutParams.includes("/-/")) return false;
35
+
36
+ // Everything else is package metadata that can be modified
37
+ return true;
38
+ }
39
+ /**
40
+ *
41
+ * @param {Buffer} body
42
+ * @param {NodeJS.Dict<string | string[]> | undefined} headers
43
+ * @returns Buffer
44
+ */
45
+ export function modifyNpmInfoResponse(body, headers) {
46
+ try {
47
+ const contentType = getHeaderValueAsString(headers, "content-type");
48
+ if (!contentType?.toLowerCase().includes("application/json")) {
49
+ return body;
50
+ }
51
+
52
+ if (body.byteLength === 0) {
53
+ return body;
54
+ }
55
+
56
+ // utf-8 is default encoding for JSON, so we don't check if charset is defined in content-type header
57
+ const bodyContent = body.toString("utf8");
58
+ const bodyJson = JSON.parse(bodyContent);
59
+
60
+ if (!bodyJson.time || !bodyJson["dist-tags"] || !bodyJson.versions) {
61
+ // Just return the current body if the format is not
62
+ return body;
63
+ }
64
+
65
+ const cutOff = new Date(
66
+ new Date().getTime() - getMinimumPackageAgeHours() * 3600 * 1000
67
+ );
68
+
69
+ const hasLatestTag = !!bodyJson["dist-tags"]["latest"];
70
+
71
+ const versions = Object.entries(bodyJson.time)
72
+ .map(([version, timestamp]) => ({
73
+ version,
74
+ timestamp,
75
+ }))
76
+ .filter((x) => x.version !== "created" && x.version !== "modified");
77
+
78
+ for (const { version, timestamp } of versions) {
79
+ const timestampValue = new Date(timestamp);
80
+ if (timestampValue > cutOff) {
81
+ deleteVersionFromJson(bodyJson, version);
82
+ clearCachingHeaders(headers);
83
+ }
84
+ }
85
+
86
+ if (hasLatestTag && !bodyJson["dist-tags"]["latest"]) {
87
+ // The latest tag was removed because it contained a package younger than the treshold.
88
+ // A new latest tag needs to be calculated
89
+ bodyJson["dist-tags"]["latest"] = calculateLatestTag(bodyJson.time);
90
+ }
91
+
92
+ return Buffer.from(JSON.stringify(bodyJson));
93
+ } catch (/** @type {any} */ err) {
94
+ ui.writeVerbose(
95
+ `Safe-chain: Package metadata not in expected format - bypassing modification. Error: ${err.message}`
96
+ );
97
+ return body;
98
+ }
99
+ }
100
+
101
+ /**
102
+ * @param {any} json
103
+ * @param {string} version
104
+ */
105
+ function deleteVersionFromJson(json, version) {
106
+ recordSuppressedVersion();
107
+
108
+ const packageName = typeof json?.name === "string" ? json.name : "(unknown)";
109
+
110
+ ui.writeVerbose(
111
+ `Safe-chain: ${packageName}@${version} is newer than ${getMinimumPackageAgeHours()} hours and was removed (minimumPackageAgeInHours setting).`
112
+ );
113
+
114
+ delete json.time[version];
115
+ delete json.versions[version];
116
+
117
+ for (const [tag, distVersion] of Object.entries(json["dist-tags"])) {
118
+ if (version == distVersion) {
119
+ delete json["dist-tags"][tag];
120
+ }
121
+ }
122
+ }
123
+
124
+ /**
125
+ * @param {Record<string, string>} tagList
126
+ * @returns {string | undefined}
127
+ */
128
+ function calculateLatestTag(tagList) {
129
+ const entries = Object.entries(tagList).filter(
130
+ ([version, _]) => version !== "created" && version !== "modified"
131
+ );
132
+
133
+ const latestFullRelease = getMostRecentTag(
134
+ Object.fromEntries(entries.filter(([version, _]) => !version.includes("-")))
135
+ );
136
+ if (latestFullRelease) {
137
+ return latestFullRelease;
138
+ }
139
+
140
+ const latestPrerelease = getMostRecentTag(
141
+ Object.fromEntries(entries.filter(([version, _]) => version.includes("-")))
142
+ );
143
+ return latestPrerelease;
144
+ }
145
+
146
+ /**
147
+ * @param {Record<string, string>} tagList
148
+ * @returns {string | undefined}
149
+ */
150
+ function getMostRecentTag(tagList) {
151
+ let current, currentDate;
152
+
153
+ for (const [version, timestamp] of Object.entries(tagList)) {
154
+ if (!currentDate || currentDate < timestamp) {
155
+ current = version;
156
+ currentDate = timestamp;
157
+ }
158
+ }
159
+
160
+ return current;
161
+ }
162
+
163
+ /**
164
+ * @param {Buffer} body
165
+ * @param {NodeJS.Dict<string | string[]> | undefined} headers
166
+ * @returns {string | undefined}
167
+ */
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
+ }
174
+
175
+ const bodyJson = JSON.parse(body.toString("utf8"));
176
+ return typeof bodyJson.name === "string" ? bodyJson.name : undefined;
177
+ } catch {
178
+ return undefined;
179
+ }
180
+ }
@@ -0,0 +1,101 @@
1
+ import {
2
+ getNpmCustomRegistries,
3
+ skipMinimumPackageAge,
4
+ } from "../../../config/settings.js";
5
+ import { isMalwarePackage } from "../../../scanning/audit/index.js";
6
+ import { interceptRequests } from "../interceptorBuilder.js";
7
+ import {
8
+ getPackageNameFromMetadataResponse,
9
+ isPackageInfoUrl,
10
+ modifyNpmInfoRequestHeaders,
11
+ modifyNpmInfoResponse,
12
+ } from "./modifyNpmInfo.js";
13
+ import { parseNpmPackageUrl } from "./parseNpmPackageUrl.js";
14
+ import { openNewPackagesDatabase } from "../../../scanning/newPackagesListCache.js";
15
+ import {
16
+ isExcludedFromMinimumPackageAge,
17
+ } from "../minimumPackageAgeExclusions.js";
18
+
19
+ const knownJsRegistries = [
20
+ "registry.npmjs.org",
21
+ "registry.yarnpkg.com",
22
+ "registry.npmjs.com",
23
+ ];
24
+
25
+ /**
26
+ * @param {string} url
27
+ * @returns {import("../interceptorBuilder.js").Interceptor | undefined}
28
+ */
29
+ export function npmInterceptorForUrl(url) {
30
+ const registry = [...knownJsRegistries, ...getNpmCustomRegistries()].find(
31
+ (reg) => url.includes(reg)
32
+ );
33
+
34
+ if (registry) {
35
+ return buildNpmInterceptor(registry);
36
+ }
37
+
38
+ return undefined;
39
+ }
40
+
41
+ /**
42
+ * @param {string} registry
43
+ * @returns {import("../interceptorBuilder.js").Interceptor}
44
+ */
45
+ function buildNpmInterceptor(registry) {
46
+ return interceptRequests(async (reqContext) => {
47
+ const { packageName, version } = parseNpmPackageUrl(
48
+ reqContext.targetUrl,
49
+ registry
50
+ );
51
+ const minimumAgeChecksEnabled = !skipMinimumPackageAge();
52
+
53
+ if (await isMalwarePackage(packageName, version)) {
54
+ reqContext.blockMalware(packageName, version);
55
+ return;
56
+ }
57
+
58
+ if (minimumAgeChecksEnabled && isPackageInfoUrl(reqContext.targetUrl)) {
59
+ reqContext.modifyRequestHeaders(modifyNpmInfoRequestHeaders);
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
+ }
81
+ }
82
+ });
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
+ }
@@ -0,0 +1,60 @@
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
+ 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)) {
25
+ return { packageName, version };
26
+ }
27
+
28
+ const afterRegistry = decodeURIComponent(
29
+ urlAfterProtocol.substring(registryPrefix.length)
30
+ );
31
+
32
+ const separatorIndex = afterRegistry.indexOf("/-/");
33
+ if (separatorIndex === -1) {
34
+ return { packageName, version };
35
+ }
36
+
37
+ packageName = afterRegistry.substring(0, separatorIndex);
38
+ const filename = afterRegistry.substring(
39
+ separatorIndex + 3,
40
+ afterRegistry.length - 4
41
+ ); // Remove /-/ and .tgz
42
+
43
+ // Extract version from filename
44
+ // For scoped packages like @babel/core, the filename is core-7.21.4.tgz
45
+ // For regular packages like lodash, the filename is lodash-4.17.21.tgz
46
+ if (packageName.startsWith("@")) {
47
+ const scopedPackageName = packageName.substring(
48
+ packageName.lastIndexOf("/") + 1
49
+ );
50
+ if (filename.startsWith(scopedPackageName + "-")) {
51
+ version = filename.substring(scopedPackageName.length + 1);
52
+ }
53
+ } else {
54
+ if (filename.startsWith(packageName + "-")) {
55
+ version = filename.substring(packageName.length + 1);
56
+ }
57
+ }
58
+
59
+ return { packageName, version };
60
+ }
@@ -0,0 +1,167 @@
1
+ import { ui } from "../../../environment/userInteraction.js";
2
+ import { clearCachingHeaders } from "../../http-utils.js";
3
+ import { normalizePipPackageName } from "../../../scanning/packageNameVariants.js";
4
+ import { parsePipPackageFromUrl } from "./parsePipPackageUrl.js";
5
+ export { parsePipMetadataUrl, isPipPackageInfoUrl } from "./parsePipPackageUrl.js";
6
+ import { getPipMetadataContentType, logSuppressedVersion } from "./pipMetadataResponseUtils.js";
7
+ import { modifyPipJsonResponse } from "./modifyPipJsonResponse.js";
8
+
9
+ // Match simple-index anchor tags and capture their href so we can suppress
10
+ // individual distribution links from PyPI HTML metadata responses.
11
+ const HTML_ANCHOR_HREF_RE =
12
+ /<a\b[^>]*href\s*=\s*(["'])([^"']+)\1[^>]*>[\s\S]*?<\/a>/gi;
13
+
14
+ /**
15
+ * @param {Buffer} body
16
+ * @param {NodeJS.Dict<string | string[]> | undefined} headers
17
+ * @param {string} metadataUrl
18
+ * @param {(packageName: string | undefined, version: string | undefined) => boolean} isNewlyReleasedPackage
19
+ * @param {string} packageName
20
+ * @returns {Buffer}
21
+ */
22
+ export function modifyPipInfoResponse(
23
+ body,
24
+ headers,
25
+ metadataUrl,
26
+ isNewlyReleasedPackage,
27
+ packageName
28
+ ) {
29
+ try {
30
+ const contentType = getPipMetadataContentType(headers);
31
+
32
+ if (!contentType || body.byteLength === 0) {
33
+ return body;
34
+ }
35
+
36
+ if (
37
+ contentType.includes("html") ||
38
+ contentType.includes("application/vnd.pypi.simple.v1+html")
39
+ ) {
40
+ return modifyHtmlSimpleResponse(
41
+ body,
42
+ headers,
43
+ metadataUrl,
44
+ isNewlyReleasedPackage,
45
+ packageName
46
+ );
47
+ }
48
+
49
+ if (
50
+ contentType.includes("json") ||
51
+ contentType.includes("application/vnd.pypi.simple.v1+json")
52
+ ) {
53
+ return modifyJsonResponse(
54
+ body,
55
+ headers,
56
+ metadataUrl,
57
+ isNewlyReleasedPackage,
58
+ packageName
59
+ );
60
+ }
61
+
62
+ return body;
63
+ } catch (/** @type {any} */ err) {
64
+ ui.writeVerbose(
65
+ `Safe-chain: PyPI package metadata not in expected format - bypassing modification. Error: ${err.message}`
66
+ );
67
+ return body;
68
+ }
69
+ }
70
+
71
+ /**
72
+ * @param {Buffer} body
73
+ * @param {NodeJS.Dict<string | string[]> | undefined} headers
74
+ * @param {string} metadataUrl
75
+ * @param {(packageName: string | undefined, version: string | undefined) => boolean} isNewlyReleasedPackage
76
+ * @param {string} packageName
77
+ * @returns {Buffer}
78
+ */
79
+ function modifyHtmlSimpleResponse(
80
+ body,
81
+ headers,
82
+ metadataUrl,
83
+ isNewlyReleasedPackage,
84
+ packageName
85
+ ) {
86
+ const html = body.toString("utf8");
87
+ let modified = false;
88
+ const rewriteHtmlAnchor = createHtmlAnchorRewriter(
89
+ metadataUrl,
90
+ isNewlyReleasedPackage,
91
+ packageName,
92
+ () => {
93
+ modified = true;
94
+ }
95
+ );
96
+ const updatedHtml = html.replace(HTML_ANCHOR_HREF_RE, rewriteHtmlAnchor);
97
+
98
+ if (!modified) return body;
99
+ const modifiedBuffer = Buffer.from(updatedHtml);
100
+ clearCachingHeaders(headers);
101
+ return modifiedBuffer;
102
+ }
103
+
104
+ /**
105
+ * @param {string} metadataUrl
106
+ * @param {(packageName: string | undefined, version: string | undefined) => boolean} isNewlyReleasedPackage
107
+ * @param {string} packageName
108
+ * @param {() => void} onModified
109
+ * @returns {(anchor: string, quote: string, href: string) => string}
110
+ */
111
+ function createHtmlAnchorRewriter(
112
+ metadataUrl,
113
+ isNewlyReleasedPackage,
114
+ packageName,
115
+ onModified
116
+ ) {
117
+ return (anchor, _quote, href) => {
118
+ const resolvedHref = new URL(href, metadataUrl).toString();
119
+ const { packageName: hrefPackageName, version } = parsePipPackageFromUrl(
120
+ resolvedHref,
121
+ new URL(resolvedHref).host
122
+ );
123
+
124
+ if (
125
+ hrefPackageName &&
126
+ normalizePipPackageName(hrefPackageName) ===
127
+ normalizePipPackageName(packageName) &&
128
+ version &&
129
+ isNewlyReleasedPackage(packageName, version)
130
+ ) {
131
+ onModified();
132
+ logSuppressedVersion(packageName, version);
133
+ return "";
134
+ }
135
+
136
+ return anchor;
137
+ };
138
+ }
139
+
140
+ /**
141
+ * @param {Buffer} body
142
+ * @param {NodeJS.Dict<string | string[]> | undefined} headers
143
+ * @param {string} metadataUrl
144
+ * @param {(packageName: string | undefined, version: string | undefined) => boolean} isNewlyReleasedPackage
145
+ * @param {string} packageName
146
+ * @returns {Buffer}
147
+ */
148
+ function modifyJsonResponse(
149
+ body,
150
+ headers,
151
+ metadataUrl,
152
+ isNewlyReleasedPackage,
153
+ packageName
154
+ ) {
155
+ const json = JSON.parse(body.toString("utf8"));
156
+ const modified = modifyPipJsonResponse(
157
+ json,
158
+ metadataUrl,
159
+ isNewlyReleasedPackage,
160
+ packageName
161
+ );
162
+
163
+ if (!modified) return body;
164
+ const modifiedBuffer = Buffer.from(JSON.stringify(json));
165
+ clearCachingHeaders(headers);
166
+ return modifiedBuffer;
167
+ }