@aikidosec/safe-chain 1.4.0 → 1.4.2
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 +56 -11
- package/docs/troubleshooting.md +60 -3
- package/package.json +1 -1
- package/src/api/aikido.js +85 -27
- package/src/config/configFile.js +22 -0
- package/src/config/environmentVariables.js +19 -0
- package/src/config/settings.js +43 -6
- package/src/registryProxy/interceptors/npm/modifyNpmInfo.js +28 -2
- package/src/shell-integration/startup-scripts/init-pwsh.ps1 +63 -16
package/README.md
CHANGED
|
@@ -152,23 +152,36 @@ iex (iwr "https://github.com/AikidoSec/safe-chain/releases/latest/download/unins
|
|
|
152
152
|
|
|
153
153
|
## Logging
|
|
154
154
|
|
|
155
|
-
You can control the output from Aikido Safe Chain using the `--safe-chain-logging` flag
|
|
155
|
+
You can control the output from Aikido Safe Chain using the `--safe-chain-logging` flag or the `SAFE_CHAIN_LOGGING` environment variable.
|
|
156
156
|
|
|
157
|
-
|
|
157
|
+
### Configuration Options
|
|
158
|
+
|
|
159
|
+
You can set the logging level through multiple sources (in order of priority):
|
|
160
|
+
|
|
161
|
+
1. **CLI Argument** (highest priority):
|
|
162
|
+
|
|
163
|
+
- `--safe-chain-logging=silent` - Suppresses all Aikido Safe Chain output except when malware is blocked. The package manager output is written to stdout as normal, and Safe Chain only writes a short message if it has blocked malware and causes the process to exit.
|
|
164
|
+
|
|
165
|
+
```shell
|
|
166
|
+
npm install express --safe-chain-logging=silent
|
|
167
|
+
```
|
|
158
168
|
|
|
159
|
-
|
|
169
|
+
- `--safe-chain-logging=verbose` - Enables detailed diagnostic output from Aikido Safe Chain. Useful for troubleshooting issues or understanding what Safe Chain is doing behind the scenes.
|
|
160
170
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
171
|
+
```shell
|
|
172
|
+
npm install express --safe-chain-logging=verbose
|
|
173
|
+
```
|
|
164
174
|
|
|
165
|
-
|
|
175
|
+
2. **Environment Variable**:
|
|
176
|
+
|
|
177
|
+
```shell
|
|
178
|
+
export SAFE_CHAIN_LOGGING=verbose
|
|
179
|
+
npm install express
|
|
180
|
+
```
|
|
166
181
|
|
|
167
|
-
|
|
182
|
+
Valid values: `silent`, `normal`, `verbose`
|
|
168
183
|
|
|
169
|
-
|
|
170
|
-
npm install express --safe-chain-logging=verbose
|
|
171
|
-
```
|
|
184
|
+
This is useful for setting a default logging level for all package manager commands in your terminal session or CI/CD environment.
|
|
172
185
|
|
|
173
186
|
## Minimum Package Age
|
|
174
187
|
|
|
@@ -199,6 +212,22 @@ You can set the minimum package age through multiple sources (in order of priori
|
|
|
199
212
|
}
|
|
200
213
|
```
|
|
201
214
|
|
|
215
|
+
### Excluding Packages
|
|
216
|
+
|
|
217
|
+
Exclude trusted packages from minimum age filtering via environment variable or config file (both are merged). Use `@scope/*` to trust all packages from an organization:
|
|
218
|
+
|
|
219
|
+
```shell
|
|
220
|
+
export SAFE_CHAIN_NPM_MINIMUM_PACKAGE_AGE_EXCLUSIONS="@aikidosec/*"
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
```json
|
|
224
|
+
{
|
|
225
|
+
"npm": {
|
|
226
|
+
"minimumPackageAgeExclusions": ["@aikidosec/*"]
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
```
|
|
230
|
+
|
|
202
231
|
## Custom Registries
|
|
203
232
|
|
|
204
233
|
Configure Safe Chain to scan packages from custom or private registries.
|
|
@@ -258,6 +287,7 @@ iex "& { $(iwr 'https://github.com/AikidoSec/safe-chain/releases/latest/download
|
|
|
258
287
|
- ✅ **Azure Pipelines**
|
|
259
288
|
- ✅ **CircleCI**
|
|
260
289
|
- ✅ **Jenkins**
|
|
290
|
+
- ✅ **Bitbucket Pipelines**
|
|
261
291
|
|
|
262
292
|
## GitHub Actions Example
|
|
263
293
|
|
|
@@ -347,6 +377,21 @@ pipeline {
|
|
|
347
377
|
}
|
|
348
378
|
```
|
|
349
379
|
|
|
380
|
+
## Bitbucket Pipelines Example
|
|
381
|
+
|
|
382
|
+
```yaml
|
|
383
|
+
image: node:22
|
|
384
|
+
|
|
385
|
+
steps:
|
|
386
|
+
- step:
|
|
387
|
+
name: Install
|
|
388
|
+
script:
|
|
389
|
+
- npm install -g @aikidosec/safe-chain
|
|
390
|
+
- safe-chain setup-ci
|
|
391
|
+
- export PATH=~/.safe-chain/shims:$PATH
|
|
392
|
+
- npm ci
|
|
393
|
+
```
|
|
394
|
+
|
|
350
395
|
After setup, all subsequent package manager commands in your CI pipeline will automatically be protected by Aikido Safe Chain's malware detection.
|
|
351
396
|
|
|
352
397
|
# Troubleshooting
|
package/docs/troubleshooting.md
CHANGED
|
@@ -44,20 +44,72 @@ pip3 install safe-chain-pi-test
|
|
|
44
44
|
|
|
45
45
|
These test packages are flagged as malware and should be blocked by Safe Chain.
|
|
46
46
|
|
|
47
|
+
**If the test package installs successfully instead of being blocked**, see [Malware Not Being Blocked](#malware-not-being-blocked) below.
|
|
48
|
+
|
|
47
49
|
### Logging Options
|
|
48
50
|
|
|
49
|
-
Use logging flags to get more information:
|
|
51
|
+
Use logging flags or environment variables to get more information:
|
|
50
52
|
|
|
51
53
|
```bash
|
|
52
54
|
# Verbose mode - detailed diagnostic output for troubleshooting
|
|
53
55
|
npm install express --safe-chain-logging=verbose
|
|
54
56
|
|
|
57
|
+
# Or set it globally for all commands in your session
|
|
58
|
+
export SAFE_CHAIN_LOGGING=verbose
|
|
59
|
+
npm install express
|
|
60
|
+
|
|
55
61
|
# Silent mode - suppress all output except malware blocking
|
|
56
62
|
npm install express --safe-chain-logging=silent
|
|
57
63
|
```
|
|
58
64
|
|
|
59
65
|
## Common Issues
|
|
60
66
|
|
|
67
|
+
### Malware Not Being Blocked
|
|
68
|
+
|
|
69
|
+
**Symptom:** Test malware packages (like `safe-chain-test`) install successfully when they should be blocked
|
|
70
|
+
|
|
71
|
+
**Most Common Cause:** The package is cached in your package manager's local store
|
|
72
|
+
|
|
73
|
+
Safe-chain blocks malicious packages by intercepting network requests to package registries using its proxy.
|
|
74
|
+
|
|
75
|
+
When a package is already cached locally, the package manager skips downloading it from the registry, which bypasses the proxy.
|
|
76
|
+
|
|
77
|
+
**Resolution Steps:**
|
|
78
|
+
|
|
79
|
+
1. **Clear your package manager's cache:**
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
# For npm
|
|
83
|
+
npm cache clean --force
|
|
84
|
+
|
|
85
|
+
# For pnpm
|
|
86
|
+
pnpm store prune
|
|
87
|
+
|
|
88
|
+
# For yarn (classic)
|
|
89
|
+
yarn cache clean
|
|
90
|
+
|
|
91
|
+
# For yarn (berry/v2+)
|
|
92
|
+
yarn cache clean --all
|
|
93
|
+
|
|
94
|
+
# For bun
|
|
95
|
+
bun pm cache rm
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
> **⚠️ Warning:** Cache clearing is safe but will remove all cached packages. Subsequent installations will need to re-download packages. In CI/CD environments or monorepos, this may affect build times.
|
|
99
|
+
|
|
100
|
+
2. **Clean local installation artifacts:**
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
# Remove node_modules if you want a completely fresh install
|
|
104
|
+
rm -rf node_modules
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
3. **Re-test malware blocking:**
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
npm install safe-chain-test # Should be blocked
|
|
111
|
+
```
|
|
112
|
+
|
|
61
113
|
### Shell Aliases Not Working After Installation
|
|
62
114
|
|
|
63
115
|
**Symptom:** Running `npm` shows regular npm instead of safe-chain wrapped version
|
|
@@ -229,11 +281,16 @@ rm -rf ~/.safe-chain
|
|
|
229
281
|
|
|
230
282
|
### Enable Verbose Logging
|
|
231
283
|
|
|
232
|
-
Get detailed diagnostic output:
|
|
284
|
+
Get detailed diagnostic output using a CLI flag or environment variable:
|
|
233
285
|
|
|
234
286
|
```bash
|
|
287
|
+
# Using CLI flag
|
|
235
288
|
npm install express --safe-chain-logging=verbose
|
|
236
289
|
pip install requests --safe-chain-logging=verbose
|
|
290
|
+
|
|
291
|
+
# Using environment variable (applies to all commands)
|
|
292
|
+
export SAFE_CHAIN_LOGGING=verbose
|
|
293
|
+
npm install express
|
|
237
294
|
```
|
|
238
295
|
|
|
239
296
|
### Report Issues
|
|
@@ -246,4 +303,4 @@ If you encounter problems:
|
|
|
246
303
|
- Shell type and version
|
|
247
304
|
- `safe-chain --version` output
|
|
248
305
|
- Output from verification commands
|
|
249
|
-
- Verbose logs of the failing command
|
|
306
|
+
- Verbose logs of the failing command (add the `--safe-chain-logging=verbose` argument)
|
package/package.json
CHANGED
package/src/api/aikido.js
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import fetch from "make-fetch-happen";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
getEcoSystem,
|
|
4
|
+
ECOSYSTEM_JS,
|
|
5
|
+
ECOSYSTEM_PY,
|
|
6
|
+
} from "../config/settings.js";
|
|
7
|
+
import { ui } from "../environment/userInteraction.js";
|
|
3
8
|
|
|
4
9
|
const malwareDatabaseUrls = {
|
|
5
10
|
[ECOSYSTEM_JS]: "https://malware-list.aikido.dev/malware_predictions.json",
|
|
@@ -17,38 +22,91 @@ const malwareDatabaseUrls = {
|
|
|
17
22
|
* @returns {Promise<{malwareDatabase: MalwarePackage[], version: string | undefined}>}
|
|
18
23
|
*/
|
|
19
24
|
export async function fetchMalwareDatabase() {
|
|
20
|
-
const
|
|
21
|
-
const malwareDatabaseUrl = malwareDatabaseUrls[/** @type {keyof typeof malwareDatabaseUrls} */ (ecosystem)];
|
|
22
|
-
const response = await fetch(malwareDatabaseUrl);
|
|
23
|
-
if (!response.ok) {
|
|
24
|
-
throw new Error(`Error fetching ${ecosystem} malware database: ${response.statusText}`);
|
|
25
|
-
}
|
|
25
|
+
const numberOfAttempts = 4;
|
|
26
26
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
27
|
+
return retry(async () => {
|
|
28
|
+
const ecosystem = getEcoSystem();
|
|
29
|
+
const malwareDatabaseUrl =
|
|
30
|
+
malwareDatabaseUrls[
|
|
31
|
+
/** @type {keyof typeof malwareDatabaseUrls} */ (ecosystem)
|
|
32
|
+
];
|
|
33
|
+
const response = await fetch(malwareDatabaseUrl);
|
|
34
|
+
if (!response.ok) {
|
|
35
|
+
throw new Error(
|
|
36
|
+
`Error fetching ${ecosystem} malware database: ${response.statusText}`
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
let malwareDatabase = await response.json();
|
|
42
|
+
return {
|
|
43
|
+
malwareDatabase: malwareDatabase,
|
|
44
|
+
version: response.headers.get("etag") || undefined,
|
|
45
|
+
};
|
|
46
|
+
} catch (/** @type {any} */ error) {
|
|
47
|
+
throw new Error(`Error parsing malware database: ${error.message}`);
|
|
48
|
+
}
|
|
49
|
+
}, numberOfAttempts);
|
|
36
50
|
}
|
|
37
51
|
|
|
38
52
|
/**
|
|
39
53
|
* @returns {Promise<string | undefined>}
|
|
40
54
|
*/
|
|
41
55
|
export async function fetchMalwareDatabaseVersion() {
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
56
|
+
const numberOfAttempts = 4;
|
|
57
|
+
|
|
58
|
+
return retry(async () => {
|
|
59
|
+
const ecosystem = getEcoSystem();
|
|
60
|
+
const malwareDatabaseUrl =
|
|
61
|
+
malwareDatabaseUrls[
|
|
62
|
+
/** @type {keyof typeof malwareDatabaseUrls} */ (ecosystem)
|
|
63
|
+
];
|
|
64
|
+
const response = await fetch(malwareDatabaseUrl, {
|
|
65
|
+
method: "HEAD",
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
if (!response.ok) {
|
|
69
|
+
throw new Error(
|
|
70
|
+
`Error fetching ${ecosystem} malware database version: ${response.statusText}`
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
return response.headers.get("etag") || undefined;
|
|
74
|
+
}, numberOfAttempts);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Retries an asynchronous function multiple times until it succeeds or exhausts all attempts.
|
|
79
|
+
*
|
|
80
|
+
* @template T
|
|
81
|
+
* @param {() => Promise<T>} func - The asynchronous function to retry
|
|
82
|
+
* @param {number} attempts - The number of attempts
|
|
83
|
+
* @returns {Promise<T>} The return value of the function if successful
|
|
84
|
+
* @throws {Error} The last error encountered if all retry attempts fail
|
|
85
|
+
*/
|
|
86
|
+
async function retry(func, attempts) {
|
|
87
|
+
let lastError;
|
|
88
|
+
|
|
89
|
+
for (let i = 0; i < attempts; i++) {
|
|
90
|
+
try {
|
|
91
|
+
return await func();
|
|
92
|
+
} catch (error) {
|
|
93
|
+
ui.writeVerbose(
|
|
94
|
+
"An error occurred while trying to download the Aikido Malware database",
|
|
95
|
+
error
|
|
96
|
+
);
|
|
97
|
+
lastError = error;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (i < attempts - 1) {
|
|
101
|
+
// When this is not the last try, back-off exponentially:
|
|
102
|
+
// 1st attempt - 500ms delay
|
|
103
|
+
// 2nd attempt - 1000ms delay
|
|
104
|
+
// 3rd attempt - 2000ms delay
|
|
105
|
+
// 4th attempt - 4000ms delay
|
|
106
|
+
// ...
|
|
107
|
+
await new Promise((resolve) => setTimeout(resolve, Math.pow(2, i) * 500));
|
|
108
|
+
}
|
|
52
109
|
}
|
|
53
|
-
|
|
110
|
+
|
|
111
|
+
throw lastError;
|
|
54
112
|
}
|
package/src/config/configFile.js
CHANGED
|
@@ -16,6 +16,7 @@ import { getEcoSystem } from "./settings.js";
|
|
|
16
16
|
* @typedef {Object} SafeChainRegistryConfiguration
|
|
17
17
|
* We cannot trust the input and should add the necessary validations.
|
|
18
18
|
* @property {unknown | string[]} customRegistries
|
|
19
|
+
* @property {unknown | string[]} minimumPackageAgeExclusions
|
|
19
20
|
*/
|
|
20
21
|
|
|
21
22
|
/**
|
|
@@ -127,6 +128,27 @@ export function getPipCustomRegistries() {
|
|
|
127
128
|
return customRegistries.filter((item) => typeof item === "string");
|
|
128
129
|
}
|
|
129
130
|
|
|
131
|
+
/**
|
|
132
|
+
* Gets the minimum package age exclusions from the config file
|
|
133
|
+
* @returns {string[]}
|
|
134
|
+
*/
|
|
135
|
+
export function getNpmMinimumPackageAgeExclusions() {
|
|
136
|
+
const config = readConfigFile();
|
|
137
|
+
|
|
138
|
+
if (!config || !config.npm) {
|
|
139
|
+
return [];
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const npmConfig = /** @type {SafeChainRegistryConfiguration} */ (config.npm);
|
|
143
|
+
const exclusions = npmConfig.minimumPackageAgeExclusions;
|
|
144
|
+
|
|
145
|
+
if (!Array.isArray(exclusions)) {
|
|
146
|
+
return [];
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return exclusions.filter((item) => typeof item === "string");
|
|
150
|
+
}
|
|
151
|
+
|
|
130
152
|
/**
|
|
131
153
|
* @param {import("../api/aikido.js").MalwarePackage[]} data
|
|
132
154
|
* @param {string | number} version
|
|
@@ -25,3 +25,22 @@ export function getNpmCustomRegistries() {
|
|
|
25
25
|
export function getPipCustomRegistries() {
|
|
26
26
|
return process.env.SAFE_CHAIN_PIP_CUSTOM_REGISTRIES;
|
|
27
27
|
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Gets the logging level from environment variable
|
|
31
|
+
* Valid values: "silent", "normal", "verbose"
|
|
32
|
+
* @returns {string | undefined}
|
|
33
|
+
*/
|
|
34
|
+
export function getLoggingLevel() {
|
|
35
|
+
return process.env.SAFE_CHAIN_LOGGING;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Gets the minimum package age exclusions from environment variable
|
|
40
|
+
* Expected format: comma-separated list of package names
|
|
41
|
+
* Example: "react,@aikidosec/safe-chain,lodash"
|
|
42
|
+
* @returns {string | undefined}
|
|
43
|
+
*/
|
|
44
|
+
export function getNpmMinimumPackageAgeExclusions() {
|
|
45
|
+
return process.env.SAFE_CHAIN_NPM_MINIMUM_PACKAGE_AGE_EXCLUSIONS;
|
|
46
|
+
}
|
package/src/config/settings.js
CHANGED
|
@@ -7,14 +7,20 @@ export const LOGGING_NORMAL = "normal";
|
|
|
7
7
|
export const LOGGING_VERBOSE = "verbose";
|
|
8
8
|
|
|
9
9
|
export function getLoggingLevel() {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
if (
|
|
13
|
-
return
|
|
10
|
+
// Priority 1: CLI argument
|
|
11
|
+
const cliLevel = cliArguments.getLoggingLevel();
|
|
12
|
+
if (cliLevel === LOGGING_SILENT || cliLevel === LOGGING_VERBOSE) {
|
|
13
|
+
return cliLevel;
|
|
14
|
+
}
|
|
15
|
+
if (cliLevel) {
|
|
16
|
+
// CLI arg was set but invalid, default to normal for backwards compatibility.
|
|
17
|
+
return LOGGING_NORMAL;
|
|
14
18
|
}
|
|
15
19
|
|
|
16
|
-
|
|
17
|
-
|
|
20
|
+
// Priority 2: Environment variable
|
|
21
|
+
const envLevel = environmentVariables.getLoggingLevel()?.toLowerCase();
|
|
22
|
+
if (envLevel === LOGGING_SILENT || envLevel === LOGGING_VERBOSE) {
|
|
23
|
+
return envLevel;
|
|
18
24
|
}
|
|
19
25
|
|
|
20
26
|
return LOGGING_NORMAL;
|
|
@@ -161,3 +167,34 @@ export function getPipCustomRegistries() {
|
|
|
161
167
|
// Normalize each registry (remove protocol if any)
|
|
162
168
|
return uniqueRegistries.map(normalizeRegistry);
|
|
163
169
|
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Parses comma-separated exclusions from environment variable
|
|
173
|
+
* @param {string | undefined} envValue
|
|
174
|
+
* @returns {string[]}
|
|
175
|
+
*/
|
|
176
|
+
function parseExclusionsFromEnv(envValue) {
|
|
177
|
+
if (!envValue || typeof envValue !== "string") {
|
|
178
|
+
return [];
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return envValue
|
|
182
|
+
.split(",")
|
|
183
|
+
.map((exclusion) => exclusion.trim())
|
|
184
|
+
.filter((exclusion) => exclusion.length > 0);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Gets the minimum package age exclusions from both environment variable and config file (merged)
|
|
189
|
+
* @returns {string[]}
|
|
190
|
+
*/
|
|
191
|
+
export function getNpmMinimumPackageAgeExclusions() {
|
|
192
|
+
const envExclusions = parseExclusionsFromEnv(
|
|
193
|
+
environmentVariables.getNpmMinimumPackageAgeExclusions()
|
|
194
|
+
);
|
|
195
|
+
const configExclusions = configFile.getNpmMinimumPackageAgeExclusions();
|
|
196
|
+
|
|
197
|
+
// Merge both sources and remove duplicates
|
|
198
|
+
const allExclusions = [...envExclusions, ...configExclusions];
|
|
199
|
+
return [...new Set(allExclusions)];
|
|
200
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { getMinimumPackageAgeHours } from "../../../config/settings.js";
|
|
1
|
+
import { getMinimumPackageAgeHours, getNpmMinimumPackageAgeExclusions } from "../../../config/settings.js";
|
|
2
2
|
import { ui } from "../../../environment/userInteraction.js";
|
|
3
3
|
import { getHeaderValueAsString } from "../../http-utils.js";
|
|
4
4
|
|
|
@@ -65,6 +65,16 @@ export function modifyNpmInfoResponse(body, headers) {
|
|
|
65
65
|
return body;
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
// Check if this package is excluded from minimum age filtering
|
|
69
|
+
const packageName = bodyJson.name;
|
|
70
|
+
const exclusions = getNpmMinimumPackageAgeExclusions();
|
|
71
|
+
if (packageName && exclusions.some((pattern) => matchesExclusionPattern(packageName, pattern))) {
|
|
72
|
+
ui.writeVerbose(
|
|
73
|
+
`Safe-chain: ${packageName} is excluded from minimum package age filtering (minimumPackageAgeExclusions setting).`
|
|
74
|
+
);
|
|
75
|
+
return body;
|
|
76
|
+
}
|
|
77
|
+
|
|
68
78
|
const cutOff = new Date(
|
|
69
79
|
new Date().getTime() - getMinimumPackageAgeHours() * 3600 * 1000
|
|
70
80
|
);
|
|
@@ -116,8 +126,10 @@ export function modifyNpmInfoResponse(body, headers) {
|
|
|
116
126
|
function deleteVersionFromJson(json, version) {
|
|
117
127
|
state.hasSuppressedVersions = true;
|
|
118
128
|
|
|
129
|
+
const packageName = typeof json?.name === "string" ? json.name : "(unknown)";
|
|
130
|
+
|
|
119
131
|
ui.writeVerbose(
|
|
120
|
-
`Safe-chain: ${version} is newer than ${getMinimumPackageAgeHours()} hours and was removed (minimumPackageAgeInHours setting).`
|
|
132
|
+
`Safe-chain: ${packageName}@${version} is newer than ${getMinimumPackageAgeHours()} hours and was removed (minimumPackageAgeInHours setting).`
|
|
121
133
|
);
|
|
122
134
|
|
|
123
135
|
delete json.time[version];
|
|
@@ -175,3 +187,17 @@ function getMostRecentTag(tagList) {
|
|
|
175
187
|
export function getHasSuppressedVersions() {
|
|
176
188
|
return state.hasSuppressedVersions;
|
|
177
189
|
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Checks if a package name matches an exclusion pattern.
|
|
193
|
+
* Supports trailing wildcard (*) for prefix matching.
|
|
194
|
+
* @param {string} packageName
|
|
195
|
+
* @param {string} pattern
|
|
196
|
+
* @returns {boolean}
|
|
197
|
+
*/
|
|
198
|
+
function matchesExclusionPattern(packageName, pattern) {
|
|
199
|
+
if (pattern.endsWith("/*")) {
|
|
200
|
+
return packageName.startsWith(pattern.slice(0, -1));
|
|
201
|
+
}
|
|
202
|
+
return packageName === pattern;
|
|
203
|
+
}
|
|
@@ -6,27 +6,27 @@ $safeChainBin = Join-Path (Join-Path $HOME '.safe-chain') 'bin'
|
|
|
6
6
|
$env:PATH = "$env:PATH$pathSeparator$safeChainBin"
|
|
7
7
|
|
|
8
8
|
function npx {
|
|
9
|
-
Invoke-WrappedCommand "npx" $args
|
|
9
|
+
Invoke-WrappedCommand "npx" $args $MyInvocation.Line $MyInvocation.OffsetInLine
|
|
10
10
|
}
|
|
11
11
|
|
|
12
12
|
function yarn {
|
|
13
|
-
Invoke-WrappedCommand "yarn" $args
|
|
13
|
+
Invoke-WrappedCommand "yarn" $args $MyInvocation.Line $MyInvocation.OffsetInLine
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
function pnpm {
|
|
17
|
-
Invoke-WrappedCommand "pnpm" $args
|
|
17
|
+
Invoke-WrappedCommand "pnpm" $args $MyInvocation.Line $MyInvocation.OffsetInLine
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
function pnpx {
|
|
21
|
-
Invoke-WrappedCommand "pnpx" $args
|
|
21
|
+
Invoke-WrappedCommand "pnpx" $args $MyInvocation.Line $MyInvocation.OffsetInLine
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
function bun {
|
|
25
|
-
Invoke-WrappedCommand "bun" $args
|
|
25
|
+
Invoke-WrappedCommand "bun" $args $MyInvocation.Line $MyInvocation.OffsetInLine
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
function bunx {
|
|
29
|
-
Invoke-WrappedCommand "bunx" $args
|
|
29
|
+
Invoke-WrappedCommand "bunx" $args $MyInvocation.Line $MyInvocation.OffsetInLine
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
function npm {
|
|
@@ -37,37 +37,37 @@ function npm {
|
|
|
37
37
|
return
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
Invoke-WrappedCommand "npm" $args
|
|
40
|
+
Invoke-WrappedCommand "npm" $args $MyInvocation.Line $MyInvocation.OffsetInLine
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
function pip {
|
|
44
|
-
Invoke-WrappedCommand "pip" $args
|
|
44
|
+
Invoke-WrappedCommand "pip" $args $MyInvocation.Line $MyInvocation.OffsetInLine
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
function pip3 {
|
|
48
|
-
Invoke-WrappedCommand "pip3" $args
|
|
48
|
+
Invoke-WrappedCommand "pip3" $args $MyInvocation.Line $MyInvocation.OffsetInLine
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
function uv {
|
|
52
|
-
Invoke-WrappedCommand "uv" $args
|
|
52
|
+
Invoke-WrappedCommand "uv" $args $MyInvocation.Line $MyInvocation.OffsetInLine
|
|
53
53
|
}
|
|
54
54
|
|
|
55
55
|
function poetry {
|
|
56
|
-
Invoke-WrappedCommand "poetry" $args
|
|
56
|
+
Invoke-WrappedCommand "poetry" $args $MyInvocation.Line $MyInvocation.OffsetInLine
|
|
57
57
|
}
|
|
58
58
|
|
|
59
59
|
# `python -m pip`, `python -m pip3`.
|
|
60
60
|
function python {
|
|
61
|
-
Invoke-WrappedCommand 'python' $args
|
|
61
|
+
Invoke-WrappedCommand 'python' $args $MyInvocation.Line $MyInvocation.OffsetInLine
|
|
62
62
|
}
|
|
63
63
|
|
|
64
64
|
# `python3 -m pip`, `python3 -m pip3'.
|
|
65
65
|
function python3 {
|
|
66
|
-
Invoke-WrappedCommand 'python3' $args
|
|
66
|
+
Invoke-WrappedCommand 'python3' $args $MyInvocation.Line $MyInvocation.OffsetInLine
|
|
67
67
|
}
|
|
68
68
|
|
|
69
69
|
function pipx {
|
|
70
|
-
Invoke-WrappedCommand "pipx" $args
|
|
70
|
+
Invoke-WrappedCommand "pipx" $args $MyInvocation.Line $MyInvocation.OffsetInLine
|
|
71
71
|
}
|
|
72
72
|
|
|
73
73
|
function Write-SafeChainWarning {
|
|
@@ -108,13 +108,60 @@ function Invoke-RealCommand {
|
|
|
108
108
|
}
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
+
function Get-ReconstructedArguments {
|
|
112
|
+
param(
|
|
113
|
+
[string]$RawLine,
|
|
114
|
+
[int]$RawOffset
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
if (-not $RawLine) { return $null }
|
|
118
|
+
|
|
119
|
+
$tokens = [System.Management.Automation.PSParser]::Tokenize($RawLine, [ref]$null)
|
|
120
|
+
$newArgs = @()
|
|
121
|
+
$foundCommand = $false
|
|
122
|
+
|
|
123
|
+
foreach ($t in $tokens) {
|
|
124
|
+
if (-not $foundCommand) {
|
|
125
|
+
if ($t.Start -eq ($RawOffset - 1)) { $foundCommand = $true }
|
|
126
|
+
continue
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if ($t.Type -eq 'Operator' -and $t.Content -match '[|;&]') { break }
|
|
130
|
+
|
|
131
|
+
# Stop if complex variable expansion is used
|
|
132
|
+
if ($t.Type -eq 'Variable' -or $t.Type -eq 'Group' -or $t.Type -eq 'SubExpression') {
|
|
133
|
+
return $null
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
$newArgs += $t.Content
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if ($foundCommand) {
|
|
140
|
+
return ,$newArgs
|
|
141
|
+
}
|
|
142
|
+
return $null
|
|
143
|
+
}
|
|
144
|
+
|
|
111
145
|
function Invoke-WrappedCommand {
|
|
112
146
|
param(
|
|
113
147
|
[string]$OriginalCmd,
|
|
114
|
-
[string[]]$Arguments
|
|
148
|
+
[string[]]$Arguments,
|
|
149
|
+
[string]$RawLine = $null,
|
|
150
|
+
[int]$RawOffset = 0
|
|
115
151
|
)
|
|
116
152
|
|
|
117
|
-
|
|
153
|
+
# Use raw line parsing to recover arguments like '--' that PowerShell consumes
|
|
154
|
+
if ($RawLine) {
|
|
155
|
+
$reconstructedArgs = Get-ReconstructedArguments $RawLine $RawOffset
|
|
156
|
+
if ($null -ne $reconstructedArgs) {
|
|
157
|
+
$Arguments = $reconstructedArgs
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if ($isWindowsPlatform -and (Test-CommandAvailable "safe-chain.cmd")) {
|
|
162
|
+
& safe-chain.cmd $OriginalCmd @Arguments
|
|
163
|
+
}
|
|
164
|
+
elseif (Test-CommandAvailable "safe-chain") {
|
|
118
165
|
& safe-chain $OriginalCmd @Arguments
|
|
119
166
|
}
|
|
120
167
|
else {
|