@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.
- package/LICENSE +674 -0
- package/README.md +257 -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 +20 -0
- package/bin/aikido-pip3.js +21 -0
- package/bin/aikido-pnpm.js +14 -0
- package/bin/aikido-pnpx.js +14 -0
- package/bin/aikido-python.js +30 -0
- package/bin/aikido-python3.js +30 -0
- package/bin/aikido-uv.js +16 -0
- package/bin/aikido-yarn.js +14 -0
- package/bin/safe-chain.js +190 -0
- package/docs/banner.svg +151 -0
- package/docs/npm-to-binary-migration.md +89 -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/package.json +68 -0
- package/src/api/aikido.js +54 -0
- package/src/api/npmApi.js +71 -0
- package/src/config/cliArguments.js +138 -0
- package/src/config/configFile.js +192 -0
- package/src/config/environmentVariables.js +7 -0
- package/src/config/settings.js +100 -0
- package/src/environment/environment.js +14 -0
- package/src/environment/userInteraction.js +122 -0
- package/src/main.js +104 -0
- package/src/packagemanager/_shared/matchesCommand.js +18 -0
- package/src/packagemanager/bun/createBunPackageManager.js +53 -0
- package/src/packagemanager/currentPackageManager.js +72 -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 +25 -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 +25 -0
- package/src/packagemanager/pip/createPackageManager.js +21 -0
- package/src/packagemanager/pip/pipSettings.js +30 -0
- package/src/packagemanager/pip/runPipCommand.js +175 -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 +36 -0
- package/src/packagemanager/uv/createUvPackageManager.js +18 -0
- package/src/packagemanager/uv/runUvCommand.js +71 -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 +41 -0
- package/src/registryProxy/certBundle.js +95 -0
- package/src/registryProxy/certUtils.js +128 -0
- package/src/registryProxy/http-utils.js +17 -0
- package/src/registryProxy/interceptors/createInterceptorForEcoSystem.js +25 -0
- package/src/registryProxy/interceptors/interceptorBuilder.js +140 -0
- package/src/registryProxy/interceptors/npm/modifyNpmInfo.js +177 -0
- package/src/registryProxy/interceptors/npm/npmInterceptor.js +47 -0
- package/src/registryProxy/interceptors/npm/parseNpmPackageUrl.js +43 -0
- package/src/registryProxy/interceptors/pipInterceptor.js +115 -0
- package/src/registryProxy/mitmRequestHandler.js +231 -0
- package/src/registryProxy/plainHttpProxy.js +95 -0
- package/src/registryProxy/registryProxy.js +184 -0
- package/src/registryProxy/tunnelRequestHandler.js +180 -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/shell-integration/helpers.js +213 -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 +170 -0
- package/src/shell-integration/setup.js +127 -0
- package/src/shell-integration/shellDetection.js +37 -0
- package/src/shell-integration/startup-scripts/include-python/init-fish.fish +94 -0
- package/src/shell-integration/startup-scripts/include-python/init-posix.sh +81 -0
- package/src/shell-integration/startup-scripts/include-python/init-pwsh.ps1 +115 -0
- package/src/shell-integration/startup-scripts/init-fish.fish +71 -0
- package/src/shell-integration/startup-scripts/init-posix.sh +58 -0
- package/src/shell-integration/startup-scripts/init-pwsh.ps1 +92 -0
- package/src/shell-integration/supported-shells/bash.js +134 -0
- package/src/shell-integration/supported-shells/fish.js +77 -0
- package/src/shell-integration/supported-shells/powershell.js +73 -0
- package/src/shell-integration/supported-shells/windowsPowershell.js +73 -0
- package/src/shell-integration/supported-shells/zsh.js +74 -0
- package/src/shell-integration/teardown.js +64 -0
- package/src/utils/safeSpawn.js +137 -0
- 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
|
+
}
|