@aikidosec/safe-chain 0.0.1-immutable-releases-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.
- package/LICENSE +674 -0
- package/README.md +517 -0
- package/bin/aikido-bun.js +14 -0
- package/bin/aikido-bunx.js +14 -0
- package/bin/aikido-npm.js +14 -0
- package/bin/aikido-npx.js +14 -0
- package/bin/aikido-pip.js +17 -0
- package/bin/aikido-pip3.js +17 -0
- package/bin/aikido-pipx.js +16 -0
- package/bin/aikido-pnpm.js +14 -0
- package/bin/aikido-pnpx.js +14 -0
- package/bin/aikido-poetry.js +13 -0
- package/bin/aikido-python.js +19 -0
- package/bin/aikido-python3.js +19 -0
- package/bin/aikido-uv.js +16 -0
- package/bin/aikido-yarn.js +14 -0
- package/bin/safe-chain.js +130 -0
- package/docs/banner.svg +151 -0
- package/docs/safe-package-manager-demo.gif +0 -0
- package/docs/safe-package-manager-demo.png +0 -0
- package/docs/shell-integration.md +149 -0
- package/docs/troubleshooting.md +321 -0
- package/npm-shrinkwrap.json +4069 -0
- package/package.json +72 -0
- package/src/api/aikido.js +187 -0
- package/src/api/npmApi.js +71 -0
- package/src/config/cliArguments.js +161 -0
- package/src/config/configFile.js +327 -0
- package/src/config/environmentVariables.js +57 -0
- package/src/config/settings.js +247 -0
- package/src/environment/environment.js +14 -0
- package/src/environment/userInteraction.js +122 -0
- package/src/main.js +123 -0
- package/src/packagemanager/_shared/commandErrors.js +17 -0
- package/src/packagemanager/_shared/matchesCommand.js +18 -0
- package/src/packagemanager/bun/createBunPackageManager.js +48 -0
- package/src/packagemanager/currentPackageManager.js +79 -0
- package/src/packagemanager/npm/createPackageManager.js +72 -0
- package/src/packagemanager/npm/dependencyScanner/commandArgumentScanner.js +74 -0
- package/src/packagemanager/npm/dependencyScanner/nullScanner.js +9 -0
- package/src/packagemanager/npm/parsing/parsePackagesFromInstallArgs.js +144 -0
- package/src/packagemanager/npm/runNpmCommand.js +20 -0
- package/src/packagemanager/npm/utils/abbrevs-generated.js +359 -0
- package/src/packagemanager/npm/utils/cmd-list.js +174 -0
- package/src/packagemanager/npm/utils/npmCommands.js +34 -0
- package/src/packagemanager/npx/createPackageManager.js +15 -0
- package/src/packagemanager/npx/dependencyScanner/commandArgumentScanner.js +43 -0
- package/src/packagemanager/npx/parsing/parsePackagesFromArguments.js +130 -0
- package/src/packagemanager/npx/runNpxCommand.js +20 -0
- package/src/packagemanager/pip/createPackageManager.js +25 -0
- package/src/packagemanager/pip/pipSettings.js +6 -0
- package/src/packagemanager/pip/runPipCommand.js +209 -0
- package/src/packagemanager/pipx/createPipXPackageManager.js +18 -0
- package/src/packagemanager/pipx/runPipXCommand.js +60 -0
- package/src/packagemanager/pnpm/createPackageManager.js +57 -0
- package/src/packagemanager/pnpm/dependencyScanner/commandArgumentScanner.js +35 -0
- package/src/packagemanager/pnpm/parsing/parsePackagesFromArguments.js +109 -0
- package/src/packagemanager/pnpm/runPnpmCommand.js +32 -0
- package/src/packagemanager/poetry/createPoetryPackageManager.js +72 -0
- package/src/packagemanager/uv/createUvPackageManager.js +18 -0
- package/src/packagemanager/uv/runUvCommand.js +66 -0
- package/src/packagemanager/yarn/createPackageManager.js +41 -0
- package/src/packagemanager/yarn/dependencyScanner/commandArgumentScanner.js +35 -0
- package/src/packagemanager/yarn/parsing/parsePackagesFromArguments.js +128 -0
- package/src/packagemanager/yarn/runYarnCommand.js +36 -0
- package/src/registryProxy/certBundle.js +203 -0
- package/src/registryProxy/certUtils.js +178 -0
- package/src/registryProxy/getConnectTimeout.js +13 -0
- package/src/registryProxy/http-utils.js +80 -0
- package/src/registryProxy/interceptors/createInterceptorForEcoSystem.js +25 -0
- package/src/registryProxy/interceptors/interceptorBuilder.js +179 -0
- package/src/registryProxy/interceptors/minimumPackageAgeExclusions.js +33 -0
- package/src/registryProxy/interceptors/npm/modifyNpmInfo.js +180 -0
- package/src/registryProxy/interceptors/npm/npmInterceptor.js +101 -0
- package/src/registryProxy/interceptors/npm/parseNpmPackageUrl.js +60 -0
- package/src/registryProxy/interceptors/pip/modifyPipInfo.js +167 -0
- package/src/registryProxy/interceptors/pip/modifyPipJsonResponse.js +176 -0
- package/src/registryProxy/interceptors/pip/parsePipPackageUrl.js +162 -0
- package/src/registryProxy/interceptors/pip/pipInterceptor.js +122 -0
- package/src/registryProxy/interceptors/pip/pipMetadataResponseUtils.js +27 -0
- package/src/registryProxy/interceptors/pip/pipMetadataVersionUtils.js +131 -0
- package/src/registryProxy/interceptors/suppressedVersionsState.js +21 -0
- package/src/registryProxy/isImdsEndpoint.js +13 -0
- package/src/registryProxy/mitmRequestHandler.js +240 -0
- package/src/registryProxy/plainHttpProxy.js +95 -0
- package/src/registryProxy/registryProxy.js +255 -0
- package/src/registryProxy/tunnelRequestHandler.js +213 -0
- package/src/scanning/audit/index.js +129 -0
- package/src/scanning/index.js +82 -0
- package/src/scanning/malwareDatabase.js +131 -0
- package/src/scanning/newPackagesDatabaseBuilder.js +71 -0
- package/src/scanning/newPackagesDatabaseWarnings.js +17 -0
- package/src/scanning/newPackagesListCache.js +126 -0
- package/src/scanning/packageNameVariants.js +29 -0
- package/src/shell-integration/helpers.js +304 -0
- package/src/shell-integration/path-wrappers/templates/unix-wrapper.template.sh +22 -0
- package/src/shell-integration/path-wrappers/templates/windows-wrapper.template.cmd +24 -0
- package/src/shell-integration/setup-ci.js +172 -0
- package/src/shell-integration/setup.js +129 -0
- package/src/shell-integration/shellDetection.js +39 -0
- package/src/shell-integration/startup-scripts/init-fish.fish +115 -0
- package/src/shell-integration/startup-scripts/init-posix.sh +96 -0
- package/src/shell-integration/startup-scripts/init-pwsh.ps1 +171 -0
- package/src/shell-integration/supported-shells/bash.js +152 -0
- package/src/shell-integration/supported-shells/fish.js +95 -0
- package/src/shell-integration/supported-shells/powershell.js +100 -0
- package/src/shell-integration/supported-shells/windowsPowershell.js +100 -0
- package/src/shell-integration/supported-shells/zsh.js +92 -0
- package/src/shell-integration/teardown.js +112 -0
- package/src/ultimate/ultimateTroubleshooting.js +111 -0
- package/src/utils/safeSpawn.js +153 -0
- 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
|
+
}
|