npmguard-cli 1.1.2 → 1.1.4
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 +72 -12
- package/dist/api.js +16 -0
- package/dist/commands/install.js +142 -32
- package/dist/index.js +15 -3
- package/dist/install-source.js +131 -0
- package/dist/utils.js +25 -0
- package/dist/wallet/walletconnect.js +8 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@ engine before it touches your `node_modules`.
|
|
|
7
7
|
npx npmguard-cli install express
|
|
8
8
|
```
|
|
9
9
|
|
|
10
|
-
- **SAFE** → installs immediately
|
|
10
|
+
- **SAFE** → installs immediately from the configured source
|
|
11
11
|
- **DANGEROUS** → warns, shows findings, asks before installing
|
|
12
12
|
- **No audit yet** → offers to pay for one (Stripe or crypto), then streams
|
|
13
13
|
the results in real time
|
|
@@ -55,12 +55,31 @@ lockfiles and runs the correct add command (`npm install`, `pnpm add`,
|
|
|
55
55
|
(bypass with `--force`)
|
|
56
56
|
5. **Not found** → asks how you want to pay for the audit:
|
|
57
57
|
- Stripe (credit card) — browser checkout via QR
|
|
58
|
-
-
|
|
58
|
+
- Browser wallet — MetaMask/Rabby signs in Brave or Chrome
|
|
59
|
+
- WalletConnect — mobile wallet signs a tx on Base Sepolia
|
|
59
60
|
- Install without audit (yolo)
|
|
60
61
|
- Cancel
|
|
61
|
-
6. Streams audit events live
|
|
62
|
+
6. Streams audit events live for Stripe/WalletConnect, or waits for the
|
|
63
|
+
report when the browser-wallet page owns the live view
|
|
62
64
|
7. Runs the install if the verdict is SAFE, or prompts otherwise
|
|
63
65
|
|
|
66
|
+
The install source is configurable:
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
# default: audited verdict, then normal registry install
|
|
70
|
+
npmguard install express --install-source npm
|
|
71
|
+
|
|
72
|
+
# audited verdict, then install the tarball published by NpmGuard on Pinata
|
|
73
|
+
npmguard install express --install-source pinata
|
|
74
|
+
|
|
75
|
+
# audited verdict, then resolve ENS text records to find the Pinata tarball
|
|
76
|
+
npmguard install express --install-source ens
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
The source choice never replaces server-side verification. NpmGuard always
|
|
80
|
+
checks the report store first; `pinata` and `ens` only decide where the SAFE
|
|
81
|
+
package bytes come from.
|
|
82
|
+
|
|
64
83
|
### `npmguard audit <package>[@version]`
|
|
65
84
|
|
|
66
85
|
Run a standalone audit without installing. Returns the verdict and exits.
|
|
@@ -70,7 +89,9 @@ npmguard audit is-number
|
|
|
70
89
|
npmguard audit express@5.2.1
|
|
71
90
|
```
|
|
72
91
|
|
|
73
|
-
|
|
92
|
+
If the package hasn't been audited yet, the standalone audit command starts
|
|
93
|
+
the Stripe checkout flow. Use `install` for the interactive browser-wallet
|
|
94
|
+
and WalletConnect choices.
|
|
74
95
|
|
|
75
96
|
### `npmguard check [--path <dir>]`
|
|
76
97
|
|
|
@@ -87,7 +108,7 @@ npmguard check --path /path/to/other-project
|
|
|
87
108
|
## Payment options
|
|
88
109
|
|
|
89
110
|
When a package hasn't been audited yet, an audit run costs real compute
|
|
90
|
-
(LLM calls, sandbox execution).
|
|
111
|
+
(LLM calls, sandbox execution). Three ways to pay:
|
|
91
112
|
|
|
92
113
|
### Stripe (fiat)
|
|
93
114
|
|
|
@@ -95,11 +116,26 @@ Opens a Stripe checkout page in the browser. After payment, the engine
|
|
|
95
116
|
triggers the audit automatically. Works from any machine, no wallet
|
|
96
117
|
required.
|
|
97
118
|
|
|
119
|
+
### Browser wallet (crypto)
|
|
120
|
+
|
|
121
|
+
The CLI prints a `https://npmguard.com/pay?...` URL and can open it in your
|
|
122
|
+
default browser. Open that page in Brave or Chrome with MetaMask/Rabby
|
|
123
|
+
enabled, connect the wallet, and confirm the Base Sepolia transaction. The
|
|
124
|
+
browser starts the server-side audit with the tx hash, while the CLI waits
|
|
125
|
+
for the persisted report before deciding whether to install.
|
|
126
|
+
|
|
127
|
+
This is the desktop-friendly flow for extension wallets.
|
|
128
|
+
|
|
98
129
|
### WalletConnect (crypto)
|
|
99
130
|
|
|
100
131
|
The CLI generates a WalletConnect v2 QR code in the terminal. Scan it with
|
|
101
132
|
any mobile wallet (MetaMask, Rainbow, Coinbase Wallet, etc.) and confirm
|
|
102
|
-
the transaction.
|
|
133
|
+
the transaction. It also prints the raw WalletConnect URI for desktop
|
|
134
|
+
wallets that support a "connect by URI" flow.
|
|
135
|
+
|
|
136
|
+
Browser extension wallets do not automatically connect to a terminal
|
|
137
|
+
process. If your wallet only works as a Brave/Chrome extension, use the
|
|
138
|
+
browser wallet option instead.
|
|
103
139
|
|
|
104
140
|
- **Chain**: Base Sepolia (testnet — free ETH from
|
|
105
141
|
[Alchemy faucet](https://www.alchemy.com/faucets/base-sepolia))
|
|
@@ -108,7 +144,7 @@ the transaction.
|
|
|
108
144
|
|
|
109
145
|
Flow:
|
|
110
146
|
|
|
111
|
-
1. CLI
|
|
147
|
+
1. CLI asks the engine for public crypto config (contract + fee), with a direct contract-read fallback
|
|
112
148
|
2. You approve the tx in your wallet
|
|
113
149
|
3. Engine verifies the receipt on Base Sepolia via Alchemy
|
|
114
150
|
4. Audit starts, CLI streams events
|
|
@@ -120,20 +156,44 @@ against your request before launching the audit.
|
|
|
120
156
|
## Configuration
|
|
121
157
|
|
|
122
158
|
The CLI talks to `https://npmguard.com` by default. You can override the
|
|
123
|
-
API URL for local development:
|
|
159
|
+
API URL and the web app URL for local development:
|
|
124
160
|
|
|
125
161
|
```bash
|
|
126
162
|
# via flag
|
|
127
|
-
npmguard --api http://localhost:8000 install lodash
|
|
163
|
+
npmguard --api http://localhost:8000 --web http://localhost:3000 install lodash
|
|
128
164
|
|
|
129
165
|
# via env
|
|
130
166
|
export NPMGUARD_API_URL=http://localhost:8000
|
|
167
|
+
export NPMGUARD_WEB_URL=http://localhost:3000
|
|
131
168
|
npmguard install lodash
|
|
132
169
|
```
|
|
133
170
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
171
|
+
Install source:
|
|
172
|
+
|
|
173
|
+
```bash
|
|
174
|
+
# via flag
|
|
175
|
+
npmguard install lodash --install-source ens
|
|
176
|
+
|
|
177
|
+
# via env
|
|
178
|
+
export NPMGUARD_INSTALL_SOURCE=pinata
|
|
179
|
+
export NPMGUARD_ENS_ROOT_DOMAIN=npmguard-demo.eth
|
|
180
|
+
export NPMGUARD_ENS_RPC_URL=https://ethereum-sepolia-rpc.publicnode.com
|
|
181
|
+
npmguard install lodash
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
Available values:
|
|
185
|
+
|
|
186
|
+
| Source | Behavior |
|
|
187
|
+
|---|---|
|
|
188
|
+
| `npm` | Installs `<package>@<version>` from the normal npm registry |
|
|
189
|
+
| `pinata` | Reads `/package/<name>/storage?version=<version>` from NpmGuard and installs the pinned tarball URL |
|
|
190
|
+
| `ens` | Resolves Sepolia ENS `npmguard.*` text records and installs the announced Pinata tarball |
|
|
191
|
+
|
|
192
|
+
No private key or paid RPC config is required from the user. For the
|
|
193
|
+
WalletConnect path, the CLI uses `viem` with a public Base Sepolia RPC to
|
|
194
|
+
read/confirm the transaction for local UX; your wallet broadcasts the tx,
|
|
195
|
+
and the engine re-verifies the receipt with its own RPC before starting the
|
|
196
|
+
audit.
|
|
137
197
|
|
|
138
198
|
## Exit codes
|
|
139
199
|
|
package/dist/api.js
CHANGED
|
@@ -10,6 +10,9 @@ async function request(url, options) {
|
|
|
10
10
|
}
|
|
11
11
|
return res.json();
|
|
12
12
|
}
|
|
13
|
+
export async function getPublicConfig(apiUrl) {
|
|
14
|
+
return request(`${apiUrl}/config/public`);
|
|
15
|
+
}
|
|
13
16
|
export async function checkout(apiUrl, packageName, version) {
|
|
14
17
|
const body = { packageName };
|
|
15
18
|
if (version)
|
|
@@ -75,3 +78,16 @@ export async function getPackageReport(apiUrl, packageName, version) {
|
|
|
75
78
|
throw err;
|
|
76
79
|
}
|
|
77
80
|
}
|
|
81
|
+
export async function getPackageStorage(apiUrl, packageName, version) {
|
|
82
|
+
const query = `?version=${encodeURIComponent(version)}`;
|
|
83
|
+
const url = `${apiUrl}/package/${encodeURIComponent(packageName)}/storage${query}`;
|
|
84
|
+
try {
|
|
85
|
+
return await request(url);
|
|
86
|
+
}
|
|
87
|
+
catch (err) {
|
|
88
|
+
if (err instanceof Error && err.message.startsWith("HTTP 404")) {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
throw err;
|
|
92
|
+
}
|
|
93
|
+
}
|
package/dist/commands/install.js
CHANGED
|
@@ -3,22 +3,51 @@ import ora from "ora";
|
|
|
3
3
|
import { spawnSync } from "node:child_process";
|
|
4
4
|
import { formatEther } from "viem";
|
|
5
5
|
import * as api from "../api.js";
|
|
6
|
-
import { parsePackageArg, prompt, resolveLatestVersion, detectPackageManager, } from "../utils.js";
|
|
6
|
+
import { parsePackageArg, prompt, resolveLatestVersion, detectPackageManager, openExternalUrl, } from "../utils.js";
|
|
7
7
|
import { auditCommand } from "./audit.js";
|
|
8
8
|
import { payViaWalletConnect, readAuditFee } from "../wallet/walletconnect.js";
|
|
9
9
|
import { streamAuditEvents } from "../stream.js";
|
|
10
|
+
import { defaultEnsRootDomain, defaultEnsRpcUrl, normalizeInstallSource, resolveEnsInstallSpec, resolvePinataInstallSpec, } from "../install-source.js";
|
|
10
11
|
/**
|
|
11
12
|
* Run the correct "add this package" command for the detected package manager.
|
|
12
13
|
* `npm install <pkg>` adds the package. `pnpm add <pkg>` / `yarn add <pkg>` do
|
|
13
14
|
* the same. Crucially, `yarn install <pkg>` would ignore <pkg> in yarn classic.
|
|
14
15
|
*/
|
|
15
|
-
function runInstall(packageSpec) {
|
|
16
|
+
function runInstall(packageSpec, label) {
|
|
16
17
|
const pm = detectPackageManager();
|
|
17
18
|
const verb = pm === "npm" ? "install" : "add";
|
|
18
|
-
console.log(chalk.gray(`\n Running: ${pm} ${verb} ${packageSpec}\n`));
|
|
19
|
+
console.log(chalk.gray(`\n Running: ${pm} ${verb} ${packageSpec}${label ? `\n Source: ${label}` : ""}\n`));
|
|
19
20
|
const res = spawnSync(pm, [verb, packageSpec], { stdio: "inherit" });
|
|
20
21
|
return res.status ?? 1;
|
|
21
22
|
}
|
|
23
|
+
async function resolveInstallTarget(fullSpec, packageName, version, opts) {
|
|
24
|
+
const source = normalizeInstallSource(opts.installSource);
|
|
25
|
+
if (source === "npm") {
|
|
26
|
+
return { spec: fullSpec, detail: "npm registry" };
|
|
27
|
+
}
|
|
28
|
+
if (source === "pinata") {
|
|
29
|
+
return resolvePinataInstallSpec(opts.api, packageName, version);
|
|
30
|
+
}
|
|
31
|
+
return resolveEnsInstallSpec({
|
|
32
|
+
packageName,
|
|
33
|
+
version,
|
|
34
|
+
rootDomain: opts.ensRoot ?? defaultEnsRootDomain(),
|
|
35
|
+
rpcUrl: opts.ensRpc ?? defaultEnsRpcUrl(),
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
async function installSafePackage(fullSpec, packageName, version, opts) {
|
|
39
|
+
let target;
|
|
40
|
+
try {
|
|
41
|
+
target = await resolveInstallTarget(fullSpec, packageName, version, opts);
|
|
42
|
+
}
|
|
43
|
+
catch (err) {
|
|
44
|
+
console.error(chalk.red("Could not resolve install source: " +
|
|
45
|
+
(err instanceof Error ? err.message : String(err))));
|
|
46
|
+
console.log(chalk.gray(" Retry with --install-source npm to install from the npm registry."));
|
|
47
|
+
process.exit(1);
|
|
48
|
+
}
|
|
49
|
+
process.exit(runInstall(target.spec, target.detail));
|
|
50
|
+
}
|
|
22
51
|
function extractVerdict(report) {
|
|
23
52
|
const nested = report.report?.verdict;
|
|
24
53
|
return (nested ?? report.verdict ?? "UNKNOWN").toUpperCase();
|
|
@@ -30,6 +59,7 @@ function extractCapabilities(report) {
|
|
|
30
59
|
}
|
|
31
60
|
export async function installCommand(packageSpec, opts) {
|
|
32
61
|
const apiUrl = opts.api;
|
|
62
|
+
const webUrl = opts.web;
|
|
33
63
|
let parsed;
|
|
34
64
|
try {
|
|
35
65
|
parsed = parsePackageArg(packageSpec);
|
|
@@ -65,7 +95,7 @@ export async function installCommand(packageSpec, opts) {
|
|
|
65
95
|
}
|
|
66
96
|
spinner.stop();
|
|
67
97
|
if (report) {
|
|
68
|
-
handleExistingReport(report, name, fullSpec, apiUrl, opts);
|
|
98
|
+
await handleExistingReport(report, name, version, fullSpec, apiUrl, opts);
|
|
69
99
|
return;
|
|
70
100
|
}
|
|
71
101
|
// No audit found — ask how to pay
|
|
@@ -73,27 +103,32 @@ export async function installCommand(packageSpec, opts) {
|
|
|
73
103
|
console.log();
|
|
74
104
|
console.log(chalk.bold(" How do you want to pay for the audit?"));
|
|
75
105
|
console.log(" 1) Stripe (credit card)");
|
|
76
|
-
console.log(" 2)
|
|
77
|
-
console.log(" 3)
|
|
78
|
-
console.log(" 4)
|
|
106
|
+
console.log(" 2) Browser wallet — MetaMask/Rabby in Brave or Chrome");
|
|
107
|
+
console.log(" 3) WalletConnect — QR/mobile wallet");
|
|
108
|
+
console.log(" 4) Install without audit (at your own risk)");
|
|
109
|
+
console.log(" 5) Cancel");
|
|
79
110
|
console.log();
|
|
80
|
-
const choice = await prompt(" Choice [1/2/3/4]: ");
|
|
111
|
+
const choice = await prompt(" Choice [1/2/3/4/5]: ");
|
|
81
112
|
if (choice === "1") {
|
|
82
|
-
await runStripeAuditAndInstall(fullSpec, name, version, apiUrl);
|
|
113
|
+
await runStripeAuditAndInstall(fullSpec, name, version, apiUrl, opts);
|
|
83
114
|
return;
|
|
84
115
|
}
|
|
85
116
|
if (choice === "2") {
|
|
86
|
-
await
|
|
117
|
+
await runBrowserWalletAuditAndInstall(fullSpec, name, version, apiUrl, webUrl, opts);
|
|
87
118
|
return;
|
|
88
119
|
}
|
|
89
120
|
if (choice === "3") {
|
|
121
|
+
await runCryptoAuditAndInstall(fullSpec, name, version, apiUrl, opts);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
if (choice === "4") {
|
|
90
125
|
console.log(chalk.yellow(" Installing without audit. Proceed at your own risk."));
|
|
91
|
-
process.exit(runInstall(fullSpec));
|
|
126
|
+
process.exit(runInstall(fullSpec, "npm registry (audit skipped)"));
|
|
92
127
|
}
|
|
93
128
|
console.log(chalk.gray(" Cancelled."));
|
|
94
129
|
process.exit(0);
|
|
95
130
|
}
|
|
96
|
-
function handleExistingReport(report, name, fullSpec, apiUrl, opts) {
|
|
131
|
+
async function handleExistingReport(report, name, version, fullSpec, apiUrl, opts) {
|
|
97
132
|
const verdict = extractVerdict(report);
|
|
98
133
|
const capabilities = extractCapabilities(report);
|
|
99
134
|
if (verdict === "SAFE") {
|
|
@@ -101,7 +136,8 @@ function handleExistingReport(report, name, fullSpec, apiUrl, opts) {
|
|
|
101
136
|
if (capabilities.length > 0) {
|
|
102
137
|
console.log(chalk.gray(` Capabilities: ${capabilities.join(", ")}`));
|
|
103
138
|
}
|
|
104
|
-
|
|
139
|
+
await installSafePackage(fullSpec, name, version, opts);
|
|
140
|
+
return;
|
|
105
141
|
}
|
|
106
142
|
if (verdict === "DANGEROUS" || verdict === "CRITICAL" || verdict === "WARNING") {
|
|
107
143
|
console.log(chalk.bgRed.white.bold(` ${verdict} `));
|
|
@@ -113,23 +149,24 @@ function handleExistingReport(report, name, fullSpec, apiUrl, opts) {
|
|
|
113
149
|
console.log();
|
|
114
150
|
if (opts.force) {
|
|
115
151
|
console.log(chalk.yellow(" --force passed, installing anyway..."));
|
|
116
|
-
|
|
152
|
+
await installSafePackage(fullSpec, name, version, opts);
|
|
153
|
+
return;
|
|
117
154
|
}
|
|
118
|
-
promptAndInstallIfAccepted(fullSpec, " Install anyway? This package is flagged. (y/N) ");
|
|
155
|
+
await promptAndInstallIfAccepted(fullSpec, name, version, opts, " Install anyway? This package is flagged. (y/N) ");
|
|
119
156
|
return;
|
|
120
157
|
}
|
|
121
158
|
console.log(chalk.yellow(` Verdict: ${verdict}`));
|
|
122
|
-
promptAndInstallIfAccepted(fullSpec, " Proceed with install? (y/N) ");
|
|
159
|
+
await promptAndInstallIfAccepted(fullSpec, name, version, opts, " Proceed with install? (y/N) ");
|
|
123
160
|
}
|
|
124
|
-
async function promptAndInstallIfAccepted(fullSpec, question) {
|
|
161
|
+
async function promptAndInstallIfAccepted(fullSpec, name, version, opts, question) {
|
|
125
162
|
const answer = await prompt(chalk.red.bold(question));
|
|
126
163
|
if (answer === "y" || answer === "yes") {
|
|
127
|
-
|
|
164
|
+
await installSafePackage(fullSpec, name, version, opts);
|
|
128
165
|
}
|
|
129
166
|
console.log(chalk.gray(" Aborted."));
|
|
130
167
|
process.exit(1);
|
|
131
168
|
}
|
|
132
|
-
async function runStripeAuditAndInstall(fullSpec, name, version, apiUrl) {
|
|
169
|
+
async function runStripeAuditAndInstall(fullSpec, name, version, apiUrl, opts) {
|
|
133
170
|
try {
|
|
134
171
|
await auditCommand(fullSpec, { api: apiUrl, exit: false });
|
|
135
172
|
}
|
|
@@ -137,19 +174,89 @@ async function runStripeAuditAndInstall(fullSpec, name, version, apiUrl) {
|
|
|
137
174
|
console.error(chalk.red("Audit failed: " + (err instanceof Error ? err.message : String(err))));
|
|
138
175
|
process.exit(1);
|
|
139
176
|
}
|
|
140
|
-
await finalizeAfterAudit(fullSpec, name, version, apiUrl);
|
|
177
|
+
await finalizeAfterAudit(fullSpec, name, version, apiUrl, opts);
|
|
141
178
|
}
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
179
|
+
function buildBrowserWalletUrl(webUrl, packageName, version) {
|
|
180
|
+
const url = new URL("/pay", webUrl.replace(/\/+$/, ""));
|
|
181
|
+
url.searchParams.set("packageName", packageName);
|
|
182
|
+
url.searchParams.set("version", version);
|
|
183
|
+
url.searchParams.set("source", "cli");
|
|
184
|
+
return url.toString();
|
|
185
|
+
}
|
|
186
|
+
function sleep(ms) {
|
|
187
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
188
|
+
}
|
|
189
|
+
async function waitForPackageReport(apiUrl, packageName, version, timeoutMs) {
|
|
190
|
+
const deadline = Date.now() + timeoutMs;
|
|
191
|
+
while (Date.now() < deadline) {
|
|
192
|
+
const report = await api.getPackageReport(apiUrl, packageName, version);
|
|
193
|
+
if (report)
|
|
194
|
+
return report;
|
|
195
|
+
await sleep(3000);
|
|
196
|
+
}
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
async function runBrowserWalletAuditAndInstall(fullSpec, name, version, apiUrl, webUrl, opts) {
|
|
200
|
+
const paymentUrl = buildBrowserWalletUrl(webUrl, name, version);
|
|
201
|
+
console.log();
|
|
202
|
+
console.log(chalk.bold(" Open this URL in Brave or Chrome with MetaMask/Rabby:"));
|
|
203
|
+
console.log(chalk.blue.underline(` ${paymentUrl}`));
|
|
204
|
+
console.log();
|
|
205
|
+
console.log(chalk.gray(" The browser page will connect your wallet, sign the Base Sepolia tx, and start the audit."));
|
|
206
|
+
console.log(chalk.gray(" Keep this terminal open; it will continue when the report is ready."));
|
|
207
|
+
console.log();
|
|
208
|
+
const openAnswer = await prompt(" Open it now in your default browser? (Y/n) ");
|
|
209
|
+
if (openAnswer !== "n" && openAnswer !== "no") {
|
|
210
|
+
const opened = openExternalUrl(paymentUrl);
|
|
211
|
+
if (!opened) {
|
|
212
|
+
console.log(chalk.yellow(" Could not open automatically. Copy the URL above."));
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
const spinner = ora(" Waiting for browser payment and audit report...").start();
|
|
216
|
+
let report = null;
|
|
145
217
|
try {
|
|
146
|
-
|
|
218
|
+
report = await waitForPackageReport(apiUrl, name, version, 30 * 60 * 1000);
|
|
147
219
|
}
|
|
148
220
|
catch (err) {
|
|
149
|
-
|
|
150
|
-
(err instanceof Error ? err.message : String(err)))
|
|
221
|
+
spinner.fail("Could not read report: " +
|
|
222
|
+
(err instanceof Error ? err.message : String(err)));
|
|
151
223
|
process.exit(1);
|
|
152
224
|
}
|
|
225
|
+
if (!report) {
|
|
226
|
+
spinner.fail("Timed out waiting for the browser audit report.");
|
|
227
|
+
console.log(chalk.gray(` Check ${webUrl.replace(/\/+$/, "")}/package/${encodeURIComponent(name)}`));
|
|
228
|
+
process.exit(1);
|
|
229
|
+
}
|
|
230
|
+
spinner.succeed("Audit report received");
|
|
231
|
+
await finalizeWithReport(fullSpec, name, version, report, opts);
|
|
232
|
+
}
|
|
233
|
+
async function runCryptoAuditAndInstall(fullSpec, name, version, apiUrl, opts) {
|
|
234
|
+
// 1. Read current fee from the engine's public config, then fall back to
|
|
235
|
+
// direct contract read if the engine is older or config is unavailable.
|
|
236
|
+
let feeWei;
|
|
237
|
+
let contractAddress;
|
|
238
|
+
try {
|
|
239
|
+
const publicConfig = await api.getPublicConfig(apiUrl);
|
|
240
|
+
const contract = publicConfig.crypto?.contract;
|
|
241
|
+
const auditFeeWei = publicConfig.crypto?.auditFeeWei;
|
|
242
|
+
if (contract && /^0x[0-9a-fA-F]{40}$/.test(contract) && auditFeeWei) {
|
|
243
|
+
contractAddress = contract;
|
|
244
|
+
feeWei = BigInt(auditFeeWei);
|
|
245
|
+
}
|
|
246
|
+
else {
|
|
247
|
+
feeWei = await readAuditFee();
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
catch (err) {
|
|
251
|
+
try {
|
|
252
|
+
feeWei = await readAuditFee();
|
|
253
|
+
}
|
|
254
|
+
catch {
|
|
255
|
+
console.error(chalk.red("Could not read fee from engine or contract: " +
|
|
256
|
+
(err instanceof Error ? err.message : String(err))));
|
|
257
|
+
process.exit(1);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
153
260
|
const feeDisplay = `${formatEther(feeWei)} ETH`;
|
|
154
261
|
const confirm = await prompt(chalk.yellow(` Pay ${feeDisplay} on Base Sepolia? (y/N) `));
|
|
155
262
|
if (confirm !== "y" && confirm !== "yes") {
|
|
@@ -157,7 +264,7 @@ async function runCryptoAuditAndInstall(fullSpec, name, version, apiUrl) {
|
|
|
157
264
|
process.exit(0);
|
|
158
265
|
}
|
|
159
266
|
// 2. WalletConnect → user signs → we get txHash
|
|
160
|
-
const result = await payViaWalletConnect(name, version, feeWei, feeDisplay);
|
|
267
|
+
const result = await payViaWalletConnect(name, version, feeWei, feeDisplay, contractAddress);
|
|
161
268
|
if (!result.paid || !result.txHash) {
|
|
162
269
|
console.log(chalk.red(" Payment failed, aborting."));
|
|
163
270
|
process.exit(1);
|
|
@@ -181,23 +288,26 @@ async function runCryptoAuditAndInstall(fullSpec, name, version, apiUrl) {
|
|
|
181
288
|
// would trigger a second, unpaid audit via /checkout).
|
|
182
289
|
await streamAuditEvents(apiUrl, auditId);
|
|
183
290
|
// 5. Fetch the persisted report and decide whether to install
|
|
184
|
-
await finalizeAfterAudit(fullSpec, name, version, apiUrl);
|
|
291
|
+
await finalizeAfterAudit(fullSpec, name, version, apiUrl, opts);
|
|
185
292
|
}
|
|
186
|
-
async function finalizeAfterAudit(fullSpec, name, version, apiUrl) {
|
|
293
|
+
async function finalizeAfterAudit(fullSpec, name, version, apiUrl, opts) {
|
|
187
294
|
const freshReport = await api.getPackageReport(apiUrl, name, version);
|
|
188
295
|
if (!freshReport) {
|
|
189
296
|
console.log(chalk.red(" Audit finished but report not found."));
|
|
190
297
|
process.exit(1);
|
|
191
298
|
}
|
|
192
|
-
|
|
299
|
+
await finalizeWithReport(fullSpec, name, version, freshReport, opts);
|
|
300
|
+
}
|
|
301
|
+
async function finalizeWithReport(fullSpec, name, version, report, opts) {
|
|
302
|
+
const verdict = extractVerdict(report);
|
|
193
303
|
if (verdict === "SAFE") {
|
|
194
304
|
console.log(chalk.green("\n ✓ SAFE — proceeding with install"));
|
|
195
|
-
|
|
305
|
+
await installSafePackage(fullSpec, name, version, opts);
|
|
196
306
|
}
|
|
197
307
|
console.log(chalk.red(`\n Audit verdict: ${verdict}`));
|
|
198
308
|
const confirm = await prompt(chalk.red.bold(" Install anyway? (y/N) "));
|
|
199
309
|
if (confirm === "y" || confirm === "yes") {
|
|
200
|
-
|
|
310
|
+
await installSafePackage(fullSpec, name, version, opts);
|
|
201
311
|
}
|
|
202
312
|
process.exit(1);
|
|
203
313
|
}
|
package/dist/index.js
CHANGED
|
@@ -4,6 +4,7 @@ import { readFileSync } from "node:fs";
|
|
|
4
4
|
import { auditCommand } from "./commands/audit.js";
|
|
5
5
|
import { checkCommand } from "./commands/check.js";
|
|
6
6
|
import { installCommand } from "./commands/install.js";
|
|
7
|
+
import { defaultEnsRootDomain, defaultEnsRpcUrl, normalizeInstallSource } from "./install-source.js";
|
|
7
8
|
function readPackageVersion() {
|
|
8
9
|
try {
|
|
9
10
|
const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf-8"));
|
|
@@ -18,7 +19,8 @@ program
|
|
|
18
19
|
.name("npmguard")
|
|
19
20
|
.description("NpmGuard CLI — audit npm packages for security issues")
|
|
20
21
|
.version(readPackageVersion())
|
|
21
|
-
.option("--api <url>", "NpmGuard engine API URL", process.env.NPMGUARD_API_URL ?? "https://npmguard.com")
|
|
22
|
+
.option("--api <url>", "NpmGuard engine API URL", process.env.NPMGUARD_API_URL ?? "https://npmguard.com")
|
|
23
|
+
.option("--web <url>", "NpmGuard web app URL for browser wallet payments", process.env.NPMGUARD_WEB_URL ?? "https://npmguard.com");
|
|
22
24
|
program
|
|
23
25
|
.command("audit")
|
|
24
26
|
.description("Pay for and run a security audit on an npm package")
|
|
@@ -32,9 +34,19 @@ program
|
|
|
32
34
|
.description("Install an npm package with NpmGuard security check")
|
|
33
35
|
.argument("<package>", "Package name, optionally with version (e.g. express@4.18.0)")
|
|
34
36
|
.option("-f, --force", "Install even if the package is flagged as dangerous")
|
|
37
|
+
.option("--install-source <source>", "Install SAFE packages from npm, pinata, or ens", process.env.NPMGUARD_INSTALL_SOURCE ?? "npm")
|
|
38
|
+
.option("--ens-root <name>", "ENS root domain for --install-source ens", defaultEnsRootDomain())
|
|
39
|
+
.option("--ens-rpc <url>", "Sepolia RPC URL for --install-source ens", defaultEnsRpcUrl())
|
|
35
40
|
.action(async (pkg, cmdOpts) => {
|
|
36
|
-
const
|
|
37
|
-
await installCommand(pkg, {
|
|
41
|
+
const opts = program.opts();
|
|
42
|
+
await installCommand(pkg, {
|
|
43
|
+
api: opts.api,
|
|
44
|
+
web: opts.web,
|
|
45
|
+
force: cmdOpts.force,
|
|
46
|
+
installSource: normalizeInstallSource(cmdOpts.installSource),
|
|
47
|
+
ensRoot: cmdOpts.ensRoot,
|
|
48
|
+
ensRpc: cmdOpts.ensRpc,
|
|
49
|
+
});
|
|
38
50
|
});
|
|
39
51
|
program
|
|
40
52
|
.command("check")
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { createPublicClient, http, parseAbi, toHex, zeroAddress } from "viem";
|
|
2
|
+
import { sepolia } from "viem/chains";
|
|
3
|
+
import { namehash, packetToBytes } from "viem/ens";
|
|
4
|
+
import * as api from "./api.js";
|
|
5
|
+
const DEFAULT_ENS_RPC_URL = "https://ethereum-sepolia-rpc.publicnode.com";
|
|
6
|
+
const DEFAULT_ENS_ROOT_DOMAIN = "npmguard-demo.eth";
|
|
7
|
+
const UNIVERSAL_RESOLVER = "0xeEeEEEeE14D718C2B47D9923Deab1335E144EeEe";
|
|
8
|
+
const universalResolverAbi = parseAbi([
|
|
9
|
+
"function findResolver(bytes name) view returns (address resolver, bytes32 node, uint256 offset)",
|
|
10
|
+
]);
|
|
11
|
+
const resolverAbi = parseAbi([
|
|
12
|
+
"function text(bytes32 node, string key) view returns (string)",
|
|
13
|
+
]);
|
|
14
|
+
export function normalizeInstallSource(value) {
|
|
15
|
+
const source = (value ?? "npm").toLowerCase();
|
|
16
|
+
if (source === "npm" || source === "pinata" || source === "ens")
|
|
17
|
+
return source;
|
|
18
|
+
throw new Error(`Invalid install source "${value}". Expected npm, pinata, or ens.`);
|
|
19
|
+
}
|
|
20
|
+
export function defaultEnsRootDomain() {
|
|
21
|
+
return process.env.NPMGUARD_ENS_ROOT_DOMAIN ?? DEFAULT_ENS_ROOT_DOMAIN;
|
|
22
|
+
}
|
|
23
|
+
export function defaultEnsRpcUrl() {
|
|
24
|
+
return process.env.NPMGUARD_ENS_RPC_URL ?? process.env.SEPOLIA_RPC_URL ?? DEFAULT_ENS_RPC_URL;
|
|
25
|
+
}
|
|
26
|
+
function ensSafeLabel(value) {
|
|
27
|
+
const label = value
|
|
28
|
+
.replace(/^@/, "")
|
|
29
|
+
.replace(/[^a-z0-9-]+/gi, "-")
|
|
30
|
+
.replace(/^-+|-+$/g, "")
|
|
31
|
+
.toLowerCase();
|
|
32
|
+
if (!label)
|
|
33
|
+
throw new Error(`Cannot derive an ENS label from "${value}"`);
|
|
34
|
+
return label.slice(0, 63);
|
|
35
|
+
}
|
|
36
|
+
function tarballFromStorage(storage) {
|
|
37
|
+
return (storage.storage.tarball?.gatewayUrl ??
|
|
38
|
+
storage.storage.manifest?.value?.tarball?.gatewayUrl ??
|
|
39
|
+
null);
|
|
40
|
+
}
|
|
41
|
+
export async function resolvePinataInstallSpec(apiUrl, packageName, version) {
|
|
42
|
+
const publication = await api.getPackageStorage(apiUrl, packageName, version);
|
|
43
|
+
if (!publication) {
|
|
44
|
+
throw new Error(`No Pinata publication found for ${packageName}@${version}`);
|
|
45
|
+
}
|
|
46
|
+
const tarballUrl = tarballFromStorage(publication);
|
|
47
|
+
if (!tarballUrl) {
|
|
48
|
+
throw new Error(`Pinata publication for ${packageName}@${version} has no installable tarball URL`);
|
|
49
|
+
}
|
|
50
|
+
return {
|
|
51
|
+
spec: tarballUrl,
|
|
52
|
+
detail: publication.storage.ens?.recordName
|
|
53
|
+
? `Pinata tarball announced by ${publication.storage.ens.recordName}`
|
|
54
|
+
: "Pinata tarball from NpmGuard storage API",
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
async function readTextRecords(name, keys, rpcUrl) {
|
|
58
|
+
const client = createPublicClient({ chain: sepolia, transport: http(rpcUrl) });
|
|
59
|
+
const found = await client.readContract({
|
|
60
|
+
address: UNIVERSAL_RESOLVER,
|
|
61
|
+
abi: universalResolverAbi,
|
|
62
|
+
functionName: "findResolver",
|
|
63
|
+
args: [toHex(packetToBytes(name))],
|
|
64
|
+
});
|
|
65
|
+
const resolver = found.resolver ?? found[0] ?? zeroAddress;
|
|
66
|
+
if (!resolver || resolver === zeroAddress)
|
|
67
|
+
return {};
|
|
68
|
+
const node = namehash(name);
|
|
69
|
+
const records = {};
|
|
70
|
+
for (const key of keys) {
|
|
71
|
+
try {
|
|
72
|
+
const value = await client.readContract({
|
|
73
|
+
address: resolver,
|
|
74
|
+
abi: resolverAbi,
|
|
75
|
+
functionName: "text",
|
|
76
|
+
args: [node, key],
|
|
77
|
+
});
|
|
78
|
+
if (value)
|
|
79
|
+
records[key] = value;
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
// Some resolvers throw for missing keys; treat that as an empty record.
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return records;
|
|
86
|
+
}
|
|
87
|
+
async function tarballFromManifest(manifestUrl) {
|
|
88
|
+
const res = await fetch(manifestUrl);
|
|
89
|
+
if (!res.ok)
|
|
90
|
+
return null;
|
|
91
|
+
const manifest = await res.json();
|
|
92
|
+
return manifest.tarball?.gatewayUrl ?? null;
|
|
93
|
+
}
|
|
94
|
+
export async function resolveEnsInstallSpec(options) {
|
|
95
|
+
const packageLabel = ensSafeLabel(options.packageName);
|
|
96
|
+
const versionLabel = ensSafeLabel(options.version || "latest");
|
|
97
|
+
const root = options.rootDomain.replace(/\.+$/, "");
|
|
98
|
+
const candidates = [
|
|
99
|
+
`${versionLabel}.${packageLabel}.${root}`,
|
|
100
|
+
`${packageLabel}.${root}`,
|
|
101
|
+
root,
|
|
102
|
+
];
|
|
103
|
+
const keys = [
|
|
104
|
+
"npmguard.tarball_uri",
|
|
105
|
+
"npmguard.latest_tarball_uri",
|
|
106
|
+
"npmguard.manifest_uri",
|
|
107
|
+
"npmguard.latest_manifest_uri",
|
|
108
|
+
"npmguard.package",
|
|
109
|
+
"npmguard.version",
|
|
110
|
+
"npmguard.latest_version",
|
|
111
|
+
];
|
|
112
|
+
for (const name of candidates) {
|
|
113
|
+
const records = await readTextRecords(name, keys, options.rpcUrl);
|
|
114
|
+
const recordPackage = records["npmguard.package"];
|
|
115
|
+
const recordVersion = records["npmguard.version"] ?? records["npmguard.latest_version"];
|
|
116
|
+
const packageMatches = !recordPackage || recordPackage === options.packageName;
|
|
117
|
+
const versionMatches = !recordVersion || recordVersion === options.version;
|
|
118
|
+
if (!packageMatches || !versionMatches)
|
|
119
|
+
continue;
|
|
120
|
+
const directTarball = records["npmguard.tarball_uri"] ?? records["npmguard.latest_tarball_uri"];
|
|
121
|
+
if (directTarball)
|
|
122
|
+
return { spec: directTarball, detail: `ENS ${name} → Pinata tarball` };
|
|
123
|
+
const manifestUrl = records["npmguard.manifest_uri"] ?? records["npmguard.latest_manifest_uri"];
|
|
124
|
+
if (manifestUrl) {
|
|
125
|
+
const tarballUrl = await tarballFromManifest(manifestUrl);
|
|
126
|
+
if (tarballUrl)
|
|
127
|
+
return { spec: tarballUrl, detail: `ENS ${name} → manifest → Pinata tarball` };
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
throw new Error(`No installable ENS tarball record found for ${options.packageName}@${options.version}`);
|
|
131
|
+
}
|
package/dist/utils.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createInterface } from "node:readline";
|
|
2
2
|
import { existsSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
|
+
import { spawn } from "node:child_process";
|
|
4
5
|
export function parsePackageArg(pkg) {
|
|
5
6
|
if (pkg.startsWith("@")) {
|
|
6
7
|
const slashIndex = pkg.indexOf("/");
|
|
@@ -54,3 +55,27 @@ export function detectPackageManager(cwd = process.cwd()) {
|
|
|
54
55
|
return "yarn";
|
|
55
56
|
return "npm";
|
|
56
57
|
}
|
|
58
|
+
export function openExternalUrl(url) {
|
|
59
|
+
try {
|
|
60
|
+
const platform = process.platform;
|
|
61
|
+
const command = platform === "darwin"
|
|
62
|
+
? "open"
|
|
63
|
+
: platform === "win32"
|
|
64
|
+
? "cmd"
|
|
65
|
+
: "xdg-open";
|
|
66
|
+
const args = platform === "win32"
|
|
67
|
+
? ["/c", "start", "", url]
|
|
68
|
+
: [url];
|
|
69
|
+
const child = spawn(command, args, {
|
|
70
|
+
detached: true,
|
|
71
|
+
stdio: "ignore",
|
|
72
|
+
windowsHide: true,
|
|
73
|
+
});
|
|
74
|
+
child.on("error", () => { });
|
|
75
|
+
child.unref();
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -14,7 +14,7 @@ function generateQrCode(text) {
|
|
|
14
14
|
});
|
|
15
15
|
});
|
|
16
16
|
}
|
|
17
|
-
export async function payViaWalletConnect(packageName, version, feeWei, feeDisplay) {
|
|
17
|
+
export async function payViaWalletConnect(packageName, version, feeWei, feeDisplay, contractAddress = AUDIT_REQUEST_ADDRESS_BASE_SEPOLIA) {
|
|
18
18
|
const calldata = encodeFunctionData({
|
|
19
19
|
abi: AUDIT_REQUEST_ABI,
|
|
20
20
|
functionName: "requestAudit",
|
|
@@ -63,6 +63,10 @@ export async function payViaWalletConnect(packageName, version, feeWei, feeDispl
|
|
|
63
63
|
console.log();
|
|
64
64
|
await generateQrCode(uri);
|
|
65
65
|
console.log();
|
|
66
|
+
console.log(chalk.cyan(" Desktop wallet? Paste this WalletConnect URI into your wallet:"));
|
|
67
|
+
console.log(chalk.gray(` ${uri}`));
|
|
68
|
+
console.log(chalk.gray(" Keep it private; it is only for this pairing session."));
|
|
69
|
+
console.log();
|
|
66
70
|
const pairSpinner = ora(" Waiting for wallet connection...").start();
|
|
67
71
|
const session = await approval();
|
|
68
72
|
const accounts = session.namespaces.eip155?.accounts ?? [];
|
|
@@ -84,7 +88,7 @@ export async function payViaWalletConnect(packageName, version, feeWei, feeDispl
|
|
|
84
88
|
params: [
|
|
85
89
|
{
|
|
86
90
|
from: sender,
|
|
87
|
-
to:
|
|
91
|
+
to: contractAddress,
|
|
88
92
|
data: calldata,
|
|
89
93
|
value: "0x" + feeWei.toString(16),
|
|
90
94
|
},
|
|
@@ -120,13 +124,13 @@ export async function payViaWalletConnect(packageName, version, feeWei, feeDispl
|
|
|
120
124
|
setTimeout(() => process.off("uncaughtException", wcErrorHandler), 5000);
|
|
121
125
|
}
|
|
122
126
|
}
|
|
123
|
-
export async function readAuditFee() {
|
|
127
|
+
export async function readAuditFee(contractAddress = AUDIT_REQUEST_ADDRESS_BASE_SEPOLIA) {
|
|
124
128
|
const publicClient = createPublicClient({
|
|
125
129
|
chain: baseSepolia,
|
|
126
130
|
transport: http(),
|
|
127
131
|
});
|
|
128
132
|
return (await publicClient.readContract({
|
|
129
|
-
address:
|
|
133
|
+
address: contractAddress,
|
|
130
134
|
abi: AUDIT_REQUEST_ABI,
|
|
131
135
|
functionName: "auditFee",
|
|
132
136
|
}));
|