@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.
Files changed (3) hide show
  1. package/README.md +63 -0
  2. package/package.json +30 -0
  3. 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
+ });