@lionad/safe-npx 0.1.0
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 +63 -0
- package/package.json +30 -0
- package/snpx.js +253 -0
package/README.md
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# safe-npx (snpx)
|
|
2
|
+
|
|
3
|
+
Safe npx wrapper - lock to latest-1 version with 24h cache.
|
|
4
|
+
|
|
5
|
+
## Why
|
|
6
|
+
|
|
7
|
+
`npx -y pkg@latest` installs the bleeding edge. If that version was just compromised in a supply chain attack, you get owned immediately. **snpx** installs the version *before* latest, and only if it's at least 24 hours old. This gives the security community time to catch malicious releases.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npm install -g @lionad/safe-npx
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Usage
|
|
16
|
+
|
|
17
|
+
Drop-in replacement for `npx`:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
# Instead of npx -y create-react-app@latest my-app
|
|
21
|
+
snpx -y create-react-app@latest my-app
|
|
22
|
+
|
|
23
|
+
# Works with scoped packages too
|
|
24
|
+
snpx -y @vue/cli@latest create my-project
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
### Self-update check
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
# Check for snpx updates (safe mode - respects 24h window)
|
|
31
|
+
snpx --self-update
|
|
32
|
+
|
|
33
|
+
# Bypass safety window (not recommended)
|
|
34
|
+
snpx --unsafe-self-update
|
|
35
|
+
|
|
36
|
+
# Show help
|
|
37
|
+
snpx --help
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## How it works
|
|
41
|
+
|
|
42
|
+
1. Intercepts calls containing `@latest`
|
|
43
|
+
2. Queries npm registry for the package
|
|
44
|
+
3. Finds the version published immediately before `latest`
|
|
45
|
+
4. Verifies that version is at least 24 hours old
|
|
46
|
+
5. Caches the resolved version for 24 hours
|
|
47
|
+
6. Executes `npx pkg@resolved_version ...`
|
|
48
|
+
|
|
49
|
+
Calls without `@latest` are passed through directly to npx.
|
|
50
|
+
|
|
51
|
+
## Cache
|
|
52
|
+
|
|
53
|
+
Resolved versions are cached in `~/.cache/snpx/` for 24 hours. This means:
|
|
54
|
+
- Fast subsequent runs (no registry requests)
|
|
55
|
+
- At most one registry query per package per day
|
|
56
|
+
|
|
57
|
+
## Acknowledgments
|
|
58
|
+
|
|
59
|
+
Inspired by [safe-npm](https://github.com/kevinslin/safe-npm) by Kevin Lin. The core idea of using package age as a supply chain security signal comes from that project.
|
|
60
|
+
|
|
61
|
+
## License
|
|
62
|
+
|
|
63
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@lionad/safe-npx",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Safe npx wrapper - lock to latest-1 version with 24h cache",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"snpx": "snpx.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"snpx.js"
|
|
11
|
+
],
|
|
12
|
+
"scripts": {
|
|
13
|
+
"test": "vitest run"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"npx",
|
|
17
|
+
"npm",
|
|
18
|
+
"security",
|
|
19
|
+
"supply-chain",
|
|
20
|
+
"cli"
|
|
21
|
+
],
|
|
22
|
+
"author": "仿生狮子 <tangnad@qq.com> (https://github.com/Lionad-Morotar)",
|
|
23
|
+
"license": "MIT",
|
|
24
|
+
"engines": {
|
|
25
|
+
"node": ">=18.0.0"
|
|
26
|
+
},
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"vitest": "^1.6.0"
|
|
29
|
+
}
|
|
30
|
+
}
|
package/snpx.js
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* safe-npx (snpx) - Lock npx to latest-1 version with 24h cache
|
|
4
|
+
* Inspired by safe-npm: https://github.com/kevinslin/safe-npm
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { spawn } from 'child_process';
|
|
8
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync, statSync } from 'fs';
|
|
9
|
+
import { homedir } from 'os';
|
|
10
|
+
import { join } from 'path';
|
|
11
|
+
|
|
12
|
+
const CACHE_DIR = join(homedir(), '.cache', 'snpx');
|
|
13
|
+
const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
|
14
|
+
const MIN_AGE_MS = 24 * 60 * 60 * 1000; // 1 day
|
|
15
|
+
const REGISTRY = 'https://registry.npmjs.org';
|
|
16
|
+
const PKG_NAME = '@lionad/safe-npx';
|
|
17
|
+
|
|
18
|
+
const HELP_TEXT = `
|
|
19
|
+
safe-npx (snpx) - Lock npx to latest-1 version with 24h cache
|
|
20
|
+
|
|
21
|
+
Usage:
|
|
22
|
+
snpx [options] <package>@latest [args...]
|
|
23
|
+
snpx [options] <command>
|
|
24
|
+
|
|
25
|
+
Options:
|
|
26
|
+
-h, --help Show this help message
|
|
27
|
+
--self-update Check for snpx updates (safe mode, default)
|
|
28
|
+
--unsafe-self-update Allow immediate snpx updates without 24h delay
|
|
29
|
+
|
|
30
|
+
Examples:
|
|
31
|
+
snpx -y cowsay@latest "Hello World"
|
|
32
|
+
snpx --self-update
|
|
33
|
+
|
|
34
|
+
Note: Only calls containing @latest are intercepted. Other commands pass through to npx directly.
|
|
35
|
+
`.trim();
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Parse package specifier from argv
|
|
39
|
+
* Returns { pkgSpec: string|null, pkgName: string|null, restArgs: string[] }
|
|
40
|
+
* Only intercepts specs containing '@latest'
|
|
41
|
+
*/
|
|
42
|
+
export function parseArgs(argv) {
|
|
43
|
+
const args = argv.slice(2);
|
|
44
|
+
let pkgSpec = null;
|
|
45
|
+
let pkgName = null;
|
|
46
|
+
let restArgs = [];
|
|
47
|
+
|
|
48
|
+
for (let i = 0; i < args.length; i++) {
|
|
49
|
+
const arg = args[i];
|
|
50
|
+
// Match package@latest pattern (including scoped @scope/pkg@latest)
|
|
51
|
+
const match = arg.match(/^(@[^/]+\/[^@]+|[^@]+)@latest$/);
|
|
52
|
+
if (match && !pkgSpec) {
|
|
53
|
+
pkgSpec = arg;
|
|
54
|
+
pkgName = match[1];
|
|
55
|
+
} else {
|
|
56
|
+
restArgs.push(arg);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return { pkgSpec, pkgName, restArgs };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Read cached version if valid
|
|
65
|
+
*/
|
|
66
|
+
export function getCachedVersion(pkgName) {
|
|
67
|
+
const cacheFile = join(CACHE_DIR, `${pkgName.replace('/', '--')}.json`);
|
|
68
|
+
if (!existsSync(cacheFile)) return null;
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
const stat = statSync(cacheFile);
|
|
72
|
+
const age = Date.now() - stat.mtimeMs;
|
|
73
|
+
if (age > CACHE_TTL_MS) return null;
|
|
74
|
+
|
|
75
|
+
const data = JSON.parse(readFileSync(cacheFile, 'utf8'));
|
|
76
|
+
return data.version;
|
|
77
|
+
} catch {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Write version to cache
|
|
84
|
+
*/
|
|
85
|
+
export function setCachedVersion(pkgName, version) {
|
|
86
|
+
mkdirSync(CACHE_DIR, { recursive: true });
|
|
87
|
+
const cacheFile = join(CACHE_DIR, `${pkgName.replace('/', '--')}.json`);
|
|
88
|
+
writeFileSync(cacheFile, JSON.stringify({ version, resolvedAt: Date.now() }));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Fetch package metadata from registry
|
|
93
|
+
*/
|
|
94
|
+
export async function fetchPackageMetadata(pkgName) {
|
|
95
|
+
const url = `${REGISTRY}/${encodeURIComponent(pkgName)}`;
|
|
96
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(10000) });
|
|
97
|
+
if (!res.ok) throw new Error(`Failed to fetch ${pkgName}: ${res.status}`);
|
|
98
|
+
return res.json();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Find latest-1 version that is at least 1 day old
|
|
103
|
+
*/
|
|
104
|
+
export async function resolveSafeVersion(pkgName) {
|
|
105
|
+
const data = await fetchPackageMetadata(pkgName);
|
|
106
|
+
const latest = data['dist-tags']?.latest;
|
|
107
|
+
if (!latest) throw new Error('No latest tag found');
|
|
108
|
+
|
|
109
|
+
const times = data.time || {};
|
|
110
|
+
const versions = Object.entries(times)
|
|
111
|
+
.filter(([v]) => v !== 'created' && v !== 'modified')
|
|
112
|
+
.map(([v, t]) => ({ version: v, time: new Date(t).getTime() }))
|
|
113
|
+
.sort((a, b) => b.time - a.time); // Descending by time
|
|
114
|
+
|
|
115
|
+
// Find index of latest
|
|
116
|
+
const latestIdx = versions.findIndex(v => v.version === latest);
|
|
117
|
+
if (latestIdx === -1) throw new Error('Latest version not found in time data');
|
|
118
|
+
|
|
119
|
+
// Get previous version (latest-1)
|
|
120
|
+
const prev = versions[latestIdx + 1];
|
|
121
|
+
if (!prev) throw new Error('No previous version available');
|
|
122
|
+
|
|
123
|
+
// Check age
|
|
124
|
+
const age = Date.now() - prev.time;
|
|
125
|
+
if (age < MIN_AGE_MS) {
|
|
126
|
+
throw new Error(`Previous version ${prev.version} is only ${Math.floor(age / 3600000)}h old, need 24h`);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return prev.version;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Check if snpx itself has an update available
|
|
134
|
+
* Returns { hasUpdate: boolean, currentVersion: string, latestVersion: string|null }
|
|
135
|
+
*/
|
|
136
|
+
export async function checkSelfUpdate() {
|
|
137
|
+
try {
|
|
138
|
+
const data = await fetchPackageMetadata(PKG_NAME);
|
|
139
|
+
const latest = data['dist-tags']?.latest;
|
|
140
|
+
if (!latest) return { hasUpdate: false, currentVersion: '0.1.0', latestVersion: null };
|
|
141
|
+
|
|
142
|
+
const currentVersion = '0.1.0'; // Should match package.json
|
|
143
|
+
const hasUpdate = latest !== currentVersion;
|
|
144
|
+
|
|
145
|
+
return { hasUpdate, currentVersion, latestVersion: latest };
|
|
146
|
+
} catch {
|
|
147
|
+
return { hasUpdate: false, currentVersion: '0.1.0', latestVersion: null };
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Run npx with proper exit code handling
|
|
153
|
+
*/
|
|
154
|
+
function runNpx(args) {
|
|
155
|
+
return new Promise((resolve, reject) => {
|
|
156
|
+
const child = spawn('npx', args, { stdio: 'inherit' });
|
|
157
|
+
child.on('close', (code) => {
|
|
158
|
+
process.exitCode = code ?? 0;
|
|
159
|
+
resolve();
|
|
160
|
+
});
|
|
161
|
+
child.on('error', reject);
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Main entry
|
|
166
|
+
*/
|
|
167
|
+
async function main() {
|
|
168
|
+
const args = process.argv.slice(2);
|
|
169
|
+
|
|
170
|
+
// Handle help
|
|
171
|
+
if (args.includes('-h') || args.includes('--help')) {
|
|
172
|
+
console.log(HELP_TEXT);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Handle self-update check
|
|
177
|
+
const selfUpdateIndex = args.findIndex(a => a === '--self-update');
|
|
178
|
+
const unsafeSelfUpdateIndex = args.findIndex(a => a === '--unsafe-self-update');
|
|
179
|
+
|
|
180
|
+
if (selfUpdateIndex !== -1 || unsafeSelfUpdateIndex !== -1) {
|
|
181
|
+
const unsafe = unsafeSelfUpdateIndex !== -1;
|
|
182
|
+
console.error(`[snpx] Checking for updates${unsafe ? ' (unsafe mode)' : ''}...`);
|
|
183
|
+
|
|
184
|
+
try {
|
|
185
|
+
const { hasUpdate, currentVersion, latestVersion } = await checkSelfUpdate();
|
|
186
|
+
|
|
187
|
+
if (!latestVersion) {
|
|
188
|
+
console.error('[snpx] Could not check for updates. Try again later.');
|
|
189
|
+
process.exit(1);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (hasUpdate) {
|
|
193
|
+
console.error(`[snpx] Update available: ${currentVersion} → ${latestVersion}`);
|
|
194
|
+
console.error('[snpx] Run: npm update -g @lionad/safe-npx');
|
|
195
|
+
|
|
196
|
+
if (!unsafe) {
|
|
197
|
+
// Safe mode: check if latest is 24h old
|
|
198
|
+
const data = await fetchPackageMetadata(PKG_NAME);
|
|
199
|
+
const times = data.time || {};
|
|
200
|
+
const latestTime = times[latestVersion];
|
|
201
|
+
if (latestTime) {
|
|
202
|
+
const age = Date.now() - new Date(latestTime).getTime();
|
|
203
|
+
if (age < MIN_AGE_MS) {
|
|
204
|
+
console.error(`[snpx] Warning: Latest version is only ${Math.floor(age / 3600000)}h old. Waiting for 24h safety window.`);
|
|
205
|
+
console.error('[snpx] Use --unsafe-self-update to bypass (not recommended)');
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
} else {
|
|
210
|
+
console.error(`[snpx] Already up to date (${currentVersion})`);
|
|
211
|
+
}
|
|
212
|
+
} catch (err) {
|
|
213
|
+
console.error(`[snpx] Error checking for updates: ${err.message}`);
|
|
214
|
+
process.exit(1);
|
|
215
|
+
}
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Normal package execution flow
|
|
220
|
+
const { pkgSpec, pkgName, restArgs } = parseArgs(process.argv);
|
|
221
|
+
|
|
222
|
+
// No @latest found, pass through directly
|
|
223
|
+
if (!pkgSpec || !pkgName) {
|
|
224
|
+
await runNpx(args);
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Check cache first
|
|
229
|
+
let version = getCachedVersion(pkgName);
|
|
230
|
+
|
|
231
|
+
if (!version) {
|
|
232
|
+
console.error(`[snpx] Resolving safe version for ${pkgName}...`);
|
|
233
|
+
try {
|
|
234
|
+
version = await resolveSafeVersion(pkgName);
|
|
235
|
+
setCachedVersion(pkgName, version);
|
|
236
|
+
console.error(`[snpx] Using ${pkgName}@${version} (latest-1, cached for 24h)`);
|
|
237
|
+
} catch (err) {
|
|
238
|
+
console.error(`[snpx] Error: ${err.message}`);
|
|
239
|
+
process.exit(1);
|
|
240
|
+
}
|
|
241
|
+
} else {
|
|
242
|
+
console.error(`[snpx] Using cached ${pkgName}@${version}`);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Replace @latest with @version and spawn npx
|
|
246
|
+
const npxArgs = [`${pkgName}@${version}`, ...restArgs];
|
|
247
|
+
await runNpx(npxArgs);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
main().catch(err => {
|
|
251
|
+
console.error(`[snpx] Fatal: ${err.message}`);
|
|
252
|
+
process.exit(1);
|
|
253
|
+
});
|