npmguard-cli 1.1.3 → 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 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
@@ -63,6 +63,23 @@ lockfiles and runs the correct add command (`npm install`, `pnpm add`,
63
63
  report when the browser-wallet page owns the live view
64
64
  7. Runs the install if the verdict is SAFE, or prompts otherwise
65
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
+
66
83
  ### `npmguard audit <package>[@version]`
67
84
 
68
85
  Run a standalone audit without installing. Returns the verdict and exits.
@@ -151,6 +168,27 @@ export NPMGUARD_WEB_URL=http://localhost:3000
151
168
  npmguard install lodash
152
169
  ```
153
170
 
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
+
154
192
  No private key or paid RPC config is required from the user. For the
155
193
  WalletConnect path, the CLI uses `viem` with a public Base Sepolia RPC to
156
194
  read/confirm the transaction for local UX; your wallet broadcasts the tx,
package/dist/api.js CHANGED
@@ -78,3 +78,16 @@ export async function getPackageReport(apiUrl, packageName, version) {
78
78
  throw err;
79
79
  }
80
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
+ }
@@ -7,18 +7,47 @@ import { parsePackageArg, prompt, resolveLatestVersion, detectPackageManager, op
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();
@@ -66,7 +95,7 @@ export async function installCommand(packageSpec, opts) {
66
95
  }
67
96
  spinner.stop();
68
97
  if (report) {
69
- handleExistingReport(report, name, fullSpec, apiUrl, opts);
98
+ await handleExistingReport(report, name, version, fullSpec, apiUrl, opts);
70
99
  return;
71
100
  }
72
101
  // No audit found — ask how to pay
@@ -81,25 +110,25 @@ export async function installCommand(packageSpec, opts) {
81
110
  console.log();
82
111
  const choice = await prompt(" Choice [1/2/3/4/5]: ");
83
112
  if (choice === "1") {
84
- await runStripeAuditAndInstall(fullSpec, name, version, apiUrl);
113
+ await runStripeAuditAndInstall(fullSpec, name, version, apiUrl, opts);
85
114
  return;
86
115
  }
87
116
  if (choice === "2") {
88
- await runBrowserWalletAuditAndInstall(fullSpec, name, version, apiUrl, webUrl);
117
+ await runBrowserWalletAuditAndInstall(fullSpec, name, version, apiUrl, webUrl, opts);
89
118
  return;
90
119
  }
91
120
  if (choice === "3") {
92
- await runCryptoAuditAndInstall(fullSpec, name, version, apiUrl);
121
+ await runCryptoAuditAndInstall(fullSpec, name, version, apiUrl, opts);
93
122
  return;
94
123
  }
95
124
  if (choice === "4") {
96
125
  console.log(chalk.yellow(" Installing without audit. Proceed at your own risk."));
97
- process.exit(runInstall(fullSpec));
126
+ process.exit(runInstall(fullSpec, "npm registry (audit skipped)"));
98
127
  }
99
128
  console.log(chalk.gray(" Cancelled."));
100
129
  process.exit(0);
101
130
  }
102
- function handleExistingReport(report, name, fullSpec, apiUrl, opts) {
131
+ async function handleExistingReport(report, name, version, fullSpec, apiUrl, opts) {
103
132
  const verdict = extractVerdict(report);
104
133
  const capabilities = extractCapabilities(report);
105
134
  if (verdict === "SAFE") {
@@ -107,7 +136,8 @@ function handleExistingReport(report, name, fullSpec, apiUrl, opts) {
107
136
  if (capabilities.length > 0) {
108
137
  console.log(chalk.gray(` Capabilities: ${capabilities.join(", ")}`));
109
138
  }
110
- process.exit(runInstall(fullSpec));
139
+ await installSafePackage(fullSpec, name, version, opts);
140
+ return;
111
141
  }
112
142
  if (verdict === "DANGEROUS" || verdict === "CRITICAL" || verdict === "WARNING") {
113
143
  console.log(chalk.bgRed.white.bold(` ${verdict} `));
@@ -119,23 +149,24 @@ function handleExistingReport(report, name, fullSpec, apiUrl, opts) {
119
149
  console.log();
120
150
  if (opts.force) {
121
151
  console.log(chalk.yellow(" --force passed, installing anyway..."));
122
- process.exit(runInstall(fullSpec));
152
+ await installSafePackage(fullSpec, name, version, opts);
153
+ return;
123
154
  }
124
- 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) ");
125
156
  return;
126
157
  }
127
158
  console.log(chalk.yellow(` Verdict: ${verdict}`));
128
- promptAndInstallIfAccepted(fullSpec, " Proceed with install? (y/N) ");
159
+ await promptAndInstallIfAccepted(fullSpec, name, version, opts, " Proceed with install? (y/N) ");
129
160
  }
130
- async function promptAndInstallIfAccepted(fullSpec, question) {
161
+ async function promptAndInstallIfAccepted(fullSpec, name, version, opts, question) {
131
162
  const answer = await prompt(chalk.red.bold(question));
132
163
  if (answer === "y" || answer === "yes") {
133
- process.exit(runInstall(fullSpec));
164
+ await installSafePackage(fullSpec, name, version, opts);
134
165
  }
135
166
  console.log(chalk.gray(" Aborted."));
136
167
  process.exit(1);
137
168
  }
138
- async function runStripeAuditAndInstall(fullSpec, name, version, apiUrl) {
169
+ async function runStripeAuditAndInstall(fullSpec, name, version, apiUrl, opts) {
139
170
  try {
140
171
  await auditCommand(fullSpec, { api: apiUrl, exit: false });
141
172
  }
@@ -143,7 +174,7 @@ async function runStripeAuditAndInstall(fullSpec, name, version, apiUrl) {
143
174
  console.error(chalk.red("Audit failed: " + (err instanceof Error ? err.message : String(err))));
144
175
  process.exit(1);
145
176
  }
146
- await finalizeAfterAudit(fullSpec, name, version, apiUrl);
177
+ await finalizeAfterAudit(fullSpec, name, version, apiUrl, opts);
147
178
  }
148
179
  function buildBrowserWalletUrl(webUrl, packageName, version) {
149
180
  const url = new URL("/pay", webUrl.replace(/\/+$/, ""));
@@ -165,7 +196,7 @@ async function waitForPackageReport(apiUrl, packageName, version, timeoutMs) {
165
196
  }
166
197
  return null;
167
198
  }
168
- async function runBrowserWalletAuditAndInstall(fullSpec, name, version, apiUrl, webUrl) {
199
+ async function runBrowserWalletAuditAndInstall(fullSpec, name, version, apiUrl, webUrl, opts) {
169
200
  const paymentUrl = buildBrowserWalletUrl(webUrl, name, version);
170
201
  console.log();
171
202
  console.log(chalk.bold(" Open this URL in Brave or Chrome with MetaMask/Rabby:"));
@@ -197,9 +228,9 @@ async function runBrowserWalletAuditAndInstall(fullSpec, name, version, apiUrl,
197
228
  process.exit(1);
198
229
  }
199
230
  spinner.succeed("Audit report received");
200
- await finalizeWithReport(fullSpec, report);
231
+ await finalizeWithReport(fullSpec, name, version, report, opts);
201
232
  }
202
- async function runCryptoAuditAndInstall(fullSpec, name, version, apiUrl) {
233
+ async function runCryptoAuditAndInstall(fullSpec, name, version, apiUrl, opts) {
203
234
  // 1. Read current fee from the engine's public config, then fall back to
204
235
  // direct contract read if the engine is older or config is unavailable.
205
236
  let feeWei;
@@ -257,26 +288,26 @@ async function runCryptoAuditAndInstall(fullSpec, name, version, apiUrl) {
257
288
  // would trigger a second, unpaid audit via /checkout).
258
289
  await streamAuditEvents(apiUrl, auditId);
259
290
  // 5. Fetch the persisted report and decide whether to install
260
- await finalizeAfterAudit(fullSpec, name, version, apiUrl);
291
+ await finalizeAfterAudit(fullSpec, name, version, apiUrl, opts);
261
292
  }
262
- async function finalizeAfterAudit(fullSpec, name, version, apiUrl) {
293
+ async function finalizeAfterAudit(fullSpec, name, version, apiUrl, opts) {
263
294
  const freshReport = await api.getPackageReport(apiUrl, name, version);
264
295
  if (!freshReport) {
265
296
  console.log(chalk.red(" Audit finished but report not found."));
266
297
  process.exit(1);
267
298
  }
268
- await finalizeWithReport(fullSpec, freshReport);
299
+ await finalizeWithReport(fullSpec, name, version, freshReport, opts);
269
300
  }
270
- async function finalizeWithReport(fullSpec, report) {
301
+ async function finalizeWithReport(fullSpec, name, version, report, opts) {
271
302
  const verdict = extractVerdict(report);
272
303
  if (verdict === "SAFE") {
273
304
  console.log(chalk.green("\n ✓ SAFE — proceeding with install"));
274
- process.exit(runInstall(fullSpec));
305
+ await installSafePackage(fullSpec, name, version, opts);
275
306
  }
276
307
  console.log(chalk.red(`\n Audit verdict: ${verdict}`));
277
308
  const confirm = await prompt(chalk.red.bold(" Install anyway? (y/N) "));
278
309
  if (confirm === "y" || confirm === "yes") {
279
- process.exit(runInstall(fullSpec));
310
+ await installSafePackage(fullSpec, name, version, opts);
280
311
  }
281
312
  process.exit(1);
282
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"));
@@ -33,9 +34,19 @@ program
33
34
  .description("Install an npm package with NpmGuard security check")
34
35
  .argument("<package>", "Package name, optionally with version (e.g. express@4.18.0)")
35
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())
36
40
  .action(async (pkg, cmdOpts) => {
37
41
  const opts = program.opts();
38
- await installCommand(pkg, { api: opts.api, web: opts.web, force: cmdOpts.force });
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
+ });
39
50
  });
40
51
  program
41
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "npmguard-cli",
3
- "version": "1.1.3",
3
+ "version": "1.1.4",
4
4
  "type": "module",
5
5
  "description": "NpmGuard CLI — check npm packages against NpmGuard security audits",
6
6
  "main": "dist/index.js",