@aikidosec/safe-chain 1.4.4 → 1.4.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +65 -8
- package/package.json +1 -1
- package/src/api/aikido.js +93 -18
- package/src/config/cliArguments.js +24 -1
- package/src/config/configFile.js +64 -6
- package/src/config/environmentVariables.js +13 -2
- package/src/config/settings.js +53 -4
- package/src/main.js +6 -2
- package/src/packagemanager/_shared/commandErrors.js +17 -0
- package/src/packagemanager/bun/createBunPackageManager.js +2 -7
- package/src/packagemanager/npm/runNpmCommand.js +2 -7
- package/src/packagemanager/npx/runNpxCommand.js +2 -7
- package/src/packagemanager/pip/runPipCommand.js +2 -7
- package/src/packagemanager/pipx/runPipXCommand.js +2 -7
- package/src/packagemanager/pnpm/runPnpmCommand.js +3 -7
- package/src/packagemanager/poetry/createPoetryPackageManager.js +2 -7
- package/src/packagemanager/uv/runUvCommand.js +2 -7
- package/src/packagemanager/yarn/runYarnCommand.js +2 -7
- package/src/registryProxy/certBundle.js +25 -3
- package/src/registryProxy/http-utils.js +63 -0
- package/src/registryProxy/interceptors/createInterceptorForEcoSystem.js +1 -1
- package/src/registryProxy/interceptors/interceptorBuilder.js +37 -4
- package/src/registryProxy/interceptors/minimumPackageAgeExclusions.js +33 -0
- package/src/registryProxy/interceptors/npm/modifyNpmInfo.js +18 -41
- package/src/registryProxy/interceptors/npm/npmInterceptor.js +47 -2
- package/src/registryProxy/interceptors/npm/parseNpmPackageUrl.js +20 -3
- package/src/registryProxy/interceptors/pip/modifyPipInfo.js +167 -0
- package/src/registryProxy/interceptors/pip/modifyPipJsonResponse.js +176 -0
- package/src/registryProxy/interceptors/pip/parsePipPackageUrl.js +162 -0
- package/src/registryProxy/interceptors/pip/pipInterceptor.js +122 -0
- package/src/registryProxy/interceptors/pip/pipMetadataResponseUtils.js +27 -0
- package/src/registryProxy/interceptors/pip/pipMetadataVersionUtils.js +131 -0
- package/src/registryProxy/interceptors/suppressedVersionsState.js +21 -0
- package/src/registryProxy/mitmRequestHandler.js +12 -6
- package/src/registryProxy/registryProxy.js +72 -9
- package/src/scanning/newPackagesDatabaseBuilder.js +71 -0
- package/src/scanning/newPackagesDatabaseWarnings.js +17 -0
- package/src/scanning/newPackagesListCache.js +126 -0
- package/src/scanning/packageNameVariants.js +29 -0
- package/src/shell-integration/setup.js +7 -3
- package/src/shell-integration/shellDetection.js +2 -0
- package/src/shell-integration/supported-shells/bash.js +19 -1
- package/src/shell-integration/supported-shells/fish.js +18 -0
- package/src/shell-integration/supported-shells/powershell.js +18 -0
- package/src/shell-integration/supported-shells/windowsPowershell.js +18 -0
- package/src/shell-integration/supported-shells/zsh.js +19 -1
- package/src/shell-integration/teardown.js +7 -1
- package/src/ultimate/ultimateTroubleshooting.js +1 -1
- package/src/installation/downloadAgent.js +0 -125
- package/src/installation/installOnMacOS.js +0 -155
- package/src/installation/installOnWindows.js +0 -203
- package/src/installation/installUltimate.js +0 -35
- package/src/registryProxy/interceptors/pipInterceptor.js +0 -132
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import {
|
|
2
|
+
calculateLatestVersion,
|
|
3
|
+
getAvailableVersionsFromJson,
|
|
4
|
+
getPackageVersionFromMetadataFile,
|
|
5
|
+
} from "./pipMetadataVersionUtils.js";
|
|
6
|
+
import { logSuppressedVersion } from "./pipMetadataResponseUtils.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @param {any} json
|
|
10
|
+
* @param {string} metadataUrl
|
|
11
|
+
* @param {(packageName: string | undefined, version: string | undefined) => boolean} isNewlyReleasedPackage
|
|
12
|
+
* @param {string} packageName
|
|
13
|
+
* @returns {boolean}
|
|
14
|
+
*/
|
|
15
|
+
export function modifyPipJsonResponse(
|
|
16
|
+
json,
|
|
17
|
+
metadataUrl,
|
|
18
|
+
isNewlyReleasedPackage,
|
|
19
|
+
packageName
|
|
20
|
+
) {
|
|
21
|
+
const filesModified = filterJsonMetadataFiles(
|
|
22
|
+
json,
|
|
23
|
+
metadataUrl,
|
|
24
|
+
isNewlyReleasedPackage,
|
|
25
|
+
packageName
|
|
26
|
+
);
|
|
27
|
+
const releasesModified = removeJsonMetadataReleases(
|
|
28
|
+
json,
|
|
29
|
+
isNewlyReleasedPackage,
|
|
30
|
+
packageName
|
|
31
|
+
);
|
|
32
|
+
const urlsModified = filterJsonMetadataUrls(
|
|
33
|
+
json,
|
|
34
|
+
metadataUrl,
|
|
35
|
+
isNewlyReleasedPackage,
|
|
36
|
+
packageName
|
|
37
|
+
);
|
|
38
|
+
const versionModified = updateJsonInfoVersion(json, metadataUrl);
|
|
39
|
+
|
|
40
|
+
return filesModified || releasesModified || urlsModified || versionModified;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* @param {any} json
|
|
45
|
+
* @param {string} metadataUrl
|
|
46
|
+
* @param {(packageName: string | undefined, version: string | undefined) => boolean} isNewlyReleasedPackage
|
|
47
|
+
* @param {string} packageName
|
|
48
|
+
* @returns {boolean}
|
|
49
|
+
*/
|
|
50
|
+
function filterJsonMetadataFiles(
|
|
51
|
+
json,
|
|
52
|
+
metadataUrl,
|
|
53
|
+
isNewlyReleasedPackage,
|
|
54
|
+
packageName
|
|
55
|
+
) {
|
|
56
|
+
if (!Array.isArray(json.files)) {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
let modified = false;
|
|
61
|
+
const loggedVersions = new Set();
|
|
62
|
+
json.files = json.files.filter((/** @type {any} */ file) => {
|
|
63
|
+
const version = getPackageVersionFromMetadataFile(file, metadataUrl);
|
|
64
|
+
|
|
65
|
+
if (version && isNewlyReleasedPackage(packageName, version)) {
|
|
66
|
+
modified = true;
|
|
67
|
+
if (!loggedVersions.has(version)) {
|
|
68
|
+
logSuppressedVersion(packageName, version);
|
|
69
|
+
loggedVersions.add(version);
|
|
70
|
+
}
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return true;
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
return modified;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* @param {any} json
|
|
82
|
+
* @param {(packageName: string | undefined, version: string | undefined) => boolean} isNewlyReleasedPackage
|
|
83
|
+
* @param {string} packageName
|
|
84
|
+
* @returns {boolean}
|
|
85
|
+
*/
|
|
86
|
+
function removeJsonMetadataReleases(json, isNewlyReleasedPackage, packageName) {
|
|
87
|
+
if (!json.releases || typeof json.releases !== "object") {
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
let modified = false;
|
|
92
|
+
|
|
93
|
+
for (const [version, files] of Object.entries(json.releases)) {
|
|
94
|
+
if (
|
|
95
|
+
Array.isArray(/** @type {unknown[]} */ (files)) &&
|
|
96
|
+
isNewlyReleasedPackage(packageName, version)
|
|
97
|
+
) {
|
|
98
|
+
delete json.releases[version];
|
|
99
|
+
modified = true;
|
|
100
|
+
logSuppressedVersion(packageName, version);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return modified;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* @param {any} json
|
|
109
|
+
* @param {string} metadataUrl
|
|
110
|
+
* @param {(packageName: string | undefined, version: string | undefined) => boolean} isNewlyReleasedPackage
|
|
111
|
+
* @param {string} packageName
|
|
112
|
+
* @returns {boolean}
|
|
113
|
+
*/
|
|
114
|
+
function filterJsonMetadataUrls(
|
|
115
|
+
json,
|
|
116
|
+
metadataUrl,
|
|
117
|
+
isNewlyReleasedPackage,
|
|
118
|
+
packageName
|
|
119
|
+
) {
|
|
120
|
+
if (!Array.isArray(json.urls)) {
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
let modified = false;
|
|
125
|
+
const loggedVersions = new Set();
|
|
126
|
+
json.urls = json.urls.filter((/** @type {any} */ file) => {
|
|
127
|
+
const version = getPackageVersionFromMetadataFile(file, metadataUrl);
|
|
128
|
+
|
|
129
|
+
if (version && isNewlyReleasedPackage(packageName, version)) {
|
|
130
|
+
modified = true;
|
|
131
|
+
if (!loggedVersions.has(version)) {
|
|
132
|
+
logSuppressedVersion(packageName, version);
|
|
133
|
+
loggedVersions.add(version);
|
|
134
|
+
}
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return true;
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
return modified;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* @param {any} json
|
|
146
|
+
* @param {string} metadataUrl
|
|
147
|
+
* @returns {boolean}
|
|
148
|
+
*/
|
|
149
|
+
function updateJsonInfoVersion(json, metadataUrl) {
|
|
150
|
+
if (!json.info || typeof json.info !== "object") {
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const replacementVersion = computeReplacementVersion(json, metadataUrl);
|
|
155
|
+
|
|
156
|
+
if (
|
|
157
|
+
typeof json.info.version !== "string" ||
|
|
158
|
+
!replacementVersion ||
|
|
159
|
+
json.info.version === replacementVersion
|
|
160
|
+
) {
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
json.info.version = replacementVersion;
|
|
165
|
+
return true;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* @param {any} json
|
|
170
|
+
* @param {string} metadataUrl
|
|
171
|
+
* @returns {string | undefined}
|
|
172
|
+
*/
|
|
173
|
+
function computeReplacementVersion(json, metadataUrl) {
|
|
174
|
+
const candidateVersions = getAvailableVersionsFromJson(json, metadataUrl);
|
|
175
|
+
return calculateLatestVersion(candidateVersions);
|
|
176
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parses a PyPI metadata URL and returns the package name and API type.
|
|
3
|
+
*
|
|
4
|
+
* @example
|
|
5
|
+
* parsePipMetadataUrl("https://pypi.org/simple/requests/")
|
|
6
|
+
* // => { packageName: "requests", type: "simple" }
|
|
7
|
+
*
|
|
8
|
+
* parsePipMetadataUrl("https://pypi.org/pypi/requests/json")
|
|
9
|
+
* // => { packageName: "requests", type: "json" }
|
|
10
|
+
*
|
|
11
|
+
* parsePipMetadataUrl("https://pypi.org/pypi/requests/2.28.1/json")
|
|
12
|
+
* // => { packageName: "requests", type: "json" }
|
|
13
|
+
*
|
|
14
|
+
* parsePipMetadataUrl("https://files.pythonhosted.org/packages/requests-2.28.1.tar.gz")
|
|
15
|
+
* // => { packageName: undefined, type: undefined }
|
|
16
|
+
*
|
|
17
|
+
* @param {string} url
|
|
18
|
+
* @returns {{ packageName: string | undefined, type: "simple" | "json" | undefined }}
|
|
19
|
+
*/
|
|
20
|
+
export function parsePipMetadataUrl(url) {
|
|
21
|
+
if (typeof url !== "string") {
|
|
22
|
+
return { packageName: undefined, type: undefined };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
let urlObj;
|
|
26
|
+
try {
|
|
27
|
+
urlObj = new URL(url);
|
|
28
|
+
} catch {
|
|
29
|
+
return { packageName: undefined, type: undefined };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const pathSegments = urlObj.pathname.split("/").filter(Boolean);
|
|
33
|
+
if (pathSegments[0] === "simple" && pathSegments[1]) {
|
|
34
|
+
return {
|
|
35
|
+
packageName: decodeURIComponent(pathSegments[1]),
|
|
36
|
+
type: "simple",
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (
|
|
41
|
+
pathSegments[0] === "pypi" &&
|
|
42
|
+
pathSegments[pathSegments.length - 1] === "json" &&
|
|
43
|
+
pathSegments[1]
|
|
44
|
+
) {
|
|
45
|
+
return {
|
|
46
|
+
packageName: decodeURIComponent(pathSegments[1]),
|
|
47
|
+
type: "json",
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return { packageName: undefined, type: undefined };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* @param {string} url
|
|
56
|
+
* @returns {boolean}
|
|
57
|
+
*/
|
|
58
|
+
export function isPipPackageInfoUrl(url) {
|
|
59
|
+
return !!parsePipMetadataUrl(url).packageName;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Parse Python package artifact URLs from PyPI-style registries.
|
|
64
|
+
* Examples:
|
|
65
|
+
* - Wheel: https://files.pythonhosted.org/packages/.../requests-2.28.1-py3-none-any.whl
|
|
66
|
+
* - Wheel metadata: https://files.pythonhosted.org/packages/.../requests-2.28.1-py3-none-any.whl.metadata
|
|
67
|
+
* - Sdist: https://files.pythonhosted.org/packages/.../requests-2.28.1.tar.gz
|
|
68
|
+
*
|
|
69
|
+
* @param {string} url
|
|
70
|
+
* @param {string} registry
|
|
71
|
+
* @returns {{packageName: string | undefined, version: string | undefined}}
|
|
72
|
+
*/
|
|
73
|
+
export function parsePipPackageFromUrl(url, registry) {
|
|
74
|
+
if (!registry || typeof url !== "string") {
|
|
75
|
+
return { packageName: undefined, version: undefined };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
let urlObj;
|
|
79
|
+
try {
|
|
80
|
+
urlObj = new URL(url);
|
|
81
|
+
} catch {
|
|
82
|
+
return { packageName: undefined, version: undefined };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const lastSegment = urlObj.pathname.split("/").filter(Boolean).pop();
|
|
86
|
+
if (!lastSegment) {
|
|
87
|
+
return { packageName: undefined, version: undefined };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const filename = decodeURIComponent(lastSegment);
|
|
91
|
+
|
|
92
|
+
const wheelExtRe = /\.whl(?:\.metadata)?$/;
|
|
93
|
+
if (wheelExtRe.test(filename)) {
|
|
94
|
+
return parseWheelFilename(filename, wheelExtRe);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const sdistExtWithMetadataRe = /\.(tar\.gz|zip|tar\.bz2|tar\.xz)(\.metadata)?$/i;
|
|
98
|
+
if (!sdistExtWithMetadataRe.test(filename)) {
|
|
99
|
+
return { packageName: undefined, version: undefined };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return parseSdistFilename(filename, sdistExtWithMetadataRe);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Parse wheel filenames and Poetry preflight metadata.
|
|
107
|
+
* Examples:
|
|
108
|
+
* - foo_bar-2.0.0-py3-none-any.whl
|
|
109
|
+
* - foo_bar-2.0.0-py3-none-any.whl.metadata
|
|
110
|
+
*
|
|
111
|
+
* @param {string} filename
|
|
112
|
+
* @param {RegExp} wheelExtRe
|
|
113
|
+
* @returns {{packageName: string | undefined, version: string | undefined}}
|
|
114
|
+
*/
|
|
115
|
+
function parseWheelFilename(filename, wheelExtRe) {
|
|
116
|
+
const base = filename.replace(wheelExtRe, "");
|
|
117
|
+
const firstDash = base.indexOf("-");
|
|
118
|
+
if (firstDash <= 0) {
|
|
119
|
+
return { packageName: undefined, version: undefined };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const packageName = base.slice(0, firstDash);
|
|
123
|
+
const rest = base.slice(firstDash + 1);
|
|
124
|
+
const secondDash = rest.indexOf("-");
|
|
125
|
+
const version = secondDash >= 0 ? rest.slice(0, secondDash) : rest;
|
|
126
|
+
|
|
127
|
+
// "latest" is a resolver-style token, not an actual published artifact version.
|
|
128
|
+
if (version === "latest" || !packageName || !version) {
|
|
129
|
+
return { packageName: undefined, version: undefined };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return { packageName, version };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Parse source distribution filenames, with optional metadata suffix.
|
|
137
|
+
* Examples:
|
|
138
|
+
* - requests-2.28.1.tar.gz
|
|
139
|
+
* - requests-2.28.1.zip
|
|
140
|
+
* - requests-2.28.1.tar.gz.metadata
|
|
141
|
+
*
|
|
142
|
+
* @param {string} filename
|
|
143
|
+
* @param {RegExp} sdistExtWithMetadataRe
|
|
144
|
+
* @returns {{packageName: string | undefined, version: string | undefined}}
|
|
145
|
+
*/
|
|
146
|
+
function parseSdistFilename(filename, sdistExtWithMetadataRe) {
|
|
147
|
+
const base = filename.replace(sdistExtWithMetadataRe, "");
|
|
148
|
+
const lastDash = base.lastIndexOf("-");
|
|
149
|
+
if (lastDash <= 0 || lastDash >= base.length - 1) {
|
|
150
|
+
return { packageName: undefined, version: undefined };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const packageName = base.slice(0, lastDash);
|
|
154
|
+
const version = base.slice(lastDash + 1);
|
|
155
|
+
|
|
156
|
+
// "latest" is a resolver-style token, not an actual published artifact version.
|
|
157
|
+
if (version === "latest" || !packageName || !version) {
|
|
158
|
+
return { packageName: undefined, version: undefined };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return { packageName, version };
|
|
162
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ECOSYSTEM_PY,
|
|
3
|
+
getPipCustomRegistries,
|
|
4
|
+
skipMinimumPackageAge,
|
|
5
|
+
} from "../../../config/settings.js";
|
|
6
|
+
import { isMalwarePackage } from "../../../scanning/audit/index.js";
|
|
7
|
+
import { getEquivalentPackageNames } from "../../../scanning/packageNameVariants.js";
|
|
8
|
+
import { openNewPackagesDatabase } from "../../../scanning/newPackagesListCache.js";
|
|
9
|
+
import { interceptRequests } from "../interceptorBuilder.js";
|
|
10
|
+
import { isExcludedFromMinimumPackageAge } from "../minimumPackageAgeExclusions.js";
|
|
11
|
+
import {
|
|
12
|
+
modifyPipInfoResponse,
|
|
13
|
+
parsePipMetadataUrl,
|
|
14
|
+
} from "./modifyPipInfo.js";
|
|
15
|
+
import { parsePipPackageFromUrl } from "./parsePipPackageUrl.js";
|
|
16
|
+
|
|
17
|
+
const knownPipRegistries = [
|
|
18
|
+
"files.pythonhosted.org",
|
|
19
|
+
"pypi.org",
|
|
20
|
+
"pypi.python.org",
|
|
21
|
+
"pythonhosted.org",
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @param {string} url
|
|
26
|
+
* @returns {import("../interceptorBuilder.js").Interceptor | undefined}
|
|
27
|
+
*/
|
|
28
|
+
export function pipInterceptorForUrl(url) {
|
|
29
|
+
const customRegistries = getPipCustomRegistries();
|
|
30
|
+
const registries = [...knownPipRegistries, ...customRegistries];
|
|
31
|
+
const registry = registries.find((reg) => url.includes(reg));
|
|
32
|
+
|
|
33
|
+
if (registry) {
|
|
34
|
+
return buildPipInterceptor(registry);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* @param {string} registry
|
|
42
|
+
* @returns {import("../interceptorBuilder.js").Interceptor | undefined}
|
|
43
|
+
*/
|
|
44
|
+
function buildPipInterceptor(registry) {
|
|
45
|
+
return interceptRequests(createPipRequestHandler(registry));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* @param {string} registry
|
|
50
|
+
* @returns {(reqContext: import("../interceptorBuilder.js").RequestInterceptionContext) => Promise<void>}
|
|
51
|
+
*/
|
|
52
|
+
function createPipRequestHandler(registry) {
|
|
53
|
+
return async (reqContext) => {
|
|
54
|
+
const minimumAgeChecksEnabled = !skipMinimumPackageAge();
|
|
55
|
+
const metadataInfo = parsePipMetadataUrl(reqContext.targetUrl);
|
|
56
|
+
const metadataPackageName = metadataInfo.packageName;
|
|
57
|
+
|
|
58
|
+
if (
|
|
59
|
+
minimumAgeChecksEnabled &&
|
|
60
|
+
metadataPackageName &&
|
|
61
|
+
!isExcludedFromMinimumPackageAge(metadataPackageName)
|
|
62
|
+
) {
|
|
63
|
+
const newPackagesDatabase = await openNewPackagesDatabase();
|
|
64
|
+
reqContext.modifyBody((body, headers) =>
|
|
65
|
+
modifyPipInfoResponse(
|
|
66
|
+
body,
|
|
67
|
+
headers,
|
|
68
|
+
reqContext.targetUrl,
|
|
69
|
+
newPackagesDatabase.isNewlyReleasedPackage,
|
|
70
|
+
metadataPackageName
|
|
71
|
+
)
|
|
72
|
+
);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const { packageName, version } = parsePipPackageFromUrl(
|
|
77
|
+
reqContext.targetUrl,
|
|
78
|
+
registry
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
if (!packageName) {
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const equivalentPackageNames = getEquivalentPackageNames(
|
|
86
|
+
packageName,
|
|
87
|
+
ECOSYSTEM_PY
|
|
88
|
+
);
|
|
89
|
+
let isMalicious = false;
|
|
90
|
+
for (const equivalentPackageName of equivalentPackageNames) {
|
|
91
|
+
if (await isMalwarePackage(equivalentPackageName, version)) {
|
|
92
|
+
isMalicious = true;
|
|
93
|
+
break;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (isMalicious) {
|
|
98
|
+
reqContext.blockMalware(packageName, version);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (
|
|
103
|
+
version &&
|
|
104
|
+
minimumAgeChecksEnabled &&
|
|
105
|
+
!isExcludedFromMinimumPackageAge(packageName)
|
|
106
|
+
) {
|
|
107
|
+
const newPackagesDatabase = await openNewPackagesDatabase();
|
|
108
|
+
const isNewlyReleased = newPackagesDatabase.isNewlyReleasedPackage(
|
|
109
|
+
packageName,
|
|
110
|
+
version
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
if (isNewlyReleased) {
|
|
114
|
+
reqContext.blockMinimumAgeRequest(
|
|
115
|
+
packageName,
|
|
116
|
+
version,
|
|
117
|
+
`Forbidden - blocked by safe-chain direct download minimum package age (${packageName}@${version})`
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { getMinimumPackageAgeHours } from "../../../config/settings.js";
|
|
2
|
+
import { ui } from "../../../environment/userInteraction.js";
|
|
3
|
+
import { getHeaderValueAsString } from "../../http-utils.js";
|
|
4
|
+
import { recordSuppressedVersion } from "../suppressedVersionsState.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @param {NodeJS.Dict<string | string[]> | undefined} headers
|
|
8
|
+
* @returns {string | undefined}
|
|
9
|
+
*/
|
|
10
|
+
export function getPipMetadataContentType(headers) {
|
|
11
|
+
return getHeaderValueAsString(headers, "content-type")
|
|
12
|
+
?.toLowerCase()
|
|
13
|
+
.split(";")[0]
|
|
14
|
+
.trim();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @param {string} packageName
|
|
19
|
+
* @param {string} version
|
|
20
|
+
* @returns {void}
|
|
21
|
+
*/
|
|
22
|
+
export function logSuppressedVersion(packageName, version) {
|
|
23
|
+
recordSuppressedVersion();
|
|
24
|
+
ui.writeVerbose(
|
|
25
|
+
`Safe-chain: ${packageName}@${version} is newer than ${getMinimumPackageAgeHours()} hours and was removed (minimumPackageAgeInHours setting).`
|
|
26
|
+
);
|
|
27
|
+
}
|