npmguard-cli 0.5.6 → 1.0.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 +6 -152
- package/dist/api.js +66 -0
- package/dist/commands/audit.js +195 -0
- package/dist/commands/check.js +69 -118
- package/dist/index.js +16 -26
- package/dist/render.js +47 -0
- package/package.json +14 -21
- package/dist/audit-source.d.ts +0 -12
- package/dist/audit-source.js +0 -1
- package/dist/commands/check.d.ts +0 -2
- package/dist/commands/install.d.ts +0 -2
- package/dist/commands/install.js +0 -424
- package/dist/contract.d.ts +0 -121
- package/dist/contract.js +0 -92
- package/dist/ens-source.d.ts +0 -4
- package/dist/ens-source.js +0 -57
- package/dist/index.d.ts +0 -2
- package/dist/mock-source.d.ts +0 -4
- package/dist/mock-source.js +0 -50
- package/dist/scanner.d.ts +0 -7
- package/dist/scanner.js +0 -35
package/README.md
CHANGED
|
@@ -1,155 +1,9 @@
|
|
|
1
|
-
#
|
|
1
|
+
# NpmGuard CLI
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
> **TODO** — CLI will talk to the NpmGuard API server (not ENS/IPFS). Not yet implemented.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Planned features:
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
npx npmguard-cli check --path /your/project
|
|
11
|
-
```
|
|
12
|
-
|
|
13
|
-
```mermaid
|
|
14
|
-
flowchart LR
|
|
15
|
-
A[package.json] -->|read deps| B[npm registry]
|
|
16
|
-
B -->|latest version| C{ENS Sepolia}
|
|
17
|
-
C -->|resolve 1-7-9.axios.npmguard.eth| D[Text Records]
|
|
18
|
-
D -->|verdict, score, capabilities, CIDs| E[Terminal output]
|
|
19
|
-
```
|
|
20
|
-
|
|
21
|
-
What happens:
|
|
22
|
-
|
|
23
|
-
1. Reads `package.json` and extracts all dependencies
|
|
24
|
-
2. For each dependency, fetches `https://registry.npmjs.org/{package}/latest` to get the latest version
|
|
25
|
-
3. Constructs the ENS name: `{version}.{package}.npmguard.eth` (e.g. `1-7-9.axios.npmguard.eth`)
|
|
26
|
-
4. Resolves these ENS text records on Sepolia via a public RPC:
|
|
27
|
-
- `npmguard.verdict` — SAFE / CRITICAL
|
|
28
|
-
- `npmguard.score` — 0 to 100
|
|
29
|
-
- `npmguard.capabilities` — network, filesystem, process_spawn, etc.
|
|
30
|
-
- `npmguard.report_cid` — IPFS CID of the full audit report
|
|
31
|
-
- `npmguard.source_cid` — IPFS CID of the audited source code
|
|
32
|
-
5. Displays a table with verdict and capabilities per package
|
|
33
|
-
6. Lists clickable IPFS links to the full audit reports
|
|
34
|
-
|
|
35
|
-
Example output:
|
|
36
|
-
|
|
37
|
-
```
|
|
38
|
-
NpmGuard Dependency Audit
|
|
39
|
-
|
|
40
|
-
┌─────────┬───────────┬────────┬──────────────┬──────────────┐
|
|
41
|
-
│ Package │ Installed │ Latest │ Verdict │ Capabilities │
|
|
42
|
-
├─────────┼───────────┼────────┼──────────────┼──────────────┤
|
|
43
|
-
│ axios │ 1.6.0 │ 1.7.9 │ SAFE (96) │ network │
|
|
44
|
-
├─────────┼───────────┼────────┼──────────────┼──────────────┤
|
|
45
|
-
│ lodash │ 4.17.21 │ 4.18.1 │ CRITICAL (12)│ network, ... │
|
|
46
|
-
├─────────┼───────────┼────────┼──────────────┼──────────────┤
|
|
47
|
-
│ chalk │ 5.6.2 │ 5.6.2 │ NOT AUDITED │ - │
|
|
48
|
-
└─────────┴───────────┴────────┴──────────────┴──────────────┘
|
|
49
|
-
|
|
50
|
-
1 safe | 1 critical | 1 not audited
|
|
51
|
-
|
|
52
|
-
axios@1.7.9 report: https://gateway.pinata.cloud/ipfs/bafkrei...
|
|
53
|
-
lodash@4.18.1 report: https://gateway.pinata.cloud/ipfs/bafkrei...
|
|
54
|
-
```
|
|
55
|
-
|
|
56
|
-
## `npmguard-cli install`
|
|
57
|
-
|
|
58
|
-
Installs a package from IPFS instead of the npm registry when an audit record exists on ENS.
|
|
59
|
-
|
|
60
|
-
```bash
|
|
61
|
-
npx npmguard-cli install axios
|
|
62
|
-
npx npmguard-cli install axios@1.7.9
|
|
63
|
-
```
|
|
64
|
-
|
|
65
|
-
```mermaid
|
|
66
|
-
flowchart TD
|
|
67
|
-
A[npmguard-cli install axios] --> B{ENS Sepolia}
|
|
68
|
-
B -->|resolve 1-7-9.axios.npmguard.eth| C[Get verdict + sourceCid]
|
|
69
|
-
C --> D{Verdict?}
|
|
70
|
-
D -->|SAFE / WARNING| E[npm install from IPFS gateway]
|
|
71
|
-
D -->|CRITICAL| F[Installation blocked]
|
|
72
|
-
F -->|--force| E
|
|
73
|
-
E -->|tarball from gateway.pinata.cloud/ipfs/sourceCid| G[node_modules/]
|
|
74
|
-
```
|
|
75
|
-
|
|
76
|
-
What happens:
|
|
77
|
-
|
|
78
|
-
1. If no version specified, fetches the latest version from npm registry
|
|
79
|
-
2. Resolves `{version}.{package}.npmguard.eth` on Sepolia ENS
|
|
80
|
-
3. Reads the `npmguard.source_cid` text record — the IPFS CID of the audited source tarball
|
|
81
|
-
4. Runs `npm install https://gateway.pinata.cloud/ipfs/{sourceCid}`
|
|
82
|
-
5. npm downloads the tarball directly from IPFS and installs it
|
|
83
|
-
|
|
84
|
-
The result in `package-lock.json`:
|
|
85
|
-
|
|
86
|
-
```json
|
|
87
|
-
"node_modules/axios": {
|
|
88
|
-
"version": "1.7.9",
|
|
89
|
-
"resolved": "https://gateway.pinata.cloud/ipfs/bafkrei...",
|
|
90
|
-
"integrity": "sha512-..."
|
|
91
|
-
}
|
|
92
|
-
```
|
|
93
|
-
|
|
94
|
-
The `resolved` field points to IPFS, not npm. Anyone running `npm install` on this project gets the exact same IPFS-verified code.
|
|
95
|
-
|
|
96
|
-
If the package is flagged **CRITICAL**, installation is blocked:
|
|
97
|
-
|
|
98
|
-
```
|
|
99
|
-
lodash@4.18.1
|
|
100
|
-
|
|
101
|
-
CRITICAL (score: 12)
|
|
102
|
-
Capabilities: network, filesystem, process_spawn
|
|
103
|
-
Report: https://gateway.pinata.cloud/ipfs/bafkrei...
|
|
104
|
-
|
|
105
|
-
Installation blocked. This package has critical security issues.
|
|
106
|
-
Use --force to install anyway.
|
|
107
|
-
```
|
|
108
|
-
|
|
109
|
-
If no audit record exists on ENS, falls back to standard `npm install`.
|
|
110
|
-
|
|
111
|
-
## ENS structure
|
|
112
|
-
|
|
113
|
-
```mermaid
|
|
114
|
-
graph TD
|
|
115
|
-
A[npmguard.eth] --> B[axios.npmguard.eth]
|
|
116
|
-
A --> C[lodash.npmguard.eth]
|
|
117
|
-
B --> D[1-7-9.axios.npmguard.eth]
|
|
118
|
-
B --> E[1-8-0.axios.npmguard.eth]
|
|
119
|
-
D --> F["npmguard.verdict = safe<br/>npmguard.score = 96<br/>npmguard.capabilities = network<br/>npmguard.report_cid = bafkrei...<br/>npmguard.source_cid = bafkrei..."]
|
|
120
|
-
E --> G["npmguard.verdict = critical<br/>npmguard.score = 66"]
|
|
121
|
-
```
|
|
122
|
-
|
|
123
|
-
## Project structure
|
|
124
|
-
|
|
125
|
-
```
|
|
126
|
-
cli/
|
|
127
|
-
├── src/
|
|
128
|
-
│ ├── index.ts # Entry point — commander setup
|
|
129
|
-
│ ├── audit-source.ts # AuditSource interface
|
|
130
|
-
│ ├── ens-source.ts # Reads ENS text records on Sepolia via viem
|
|
131
|
-
│ ├── mock-source.ts # Mock data for testing without ENS
|
|
132
|
-
│ ├── scanner.ts # Reads package.json + fetches npm for updates
|
|
133
|
-
│ └── commands/
|
|
134
|
-
│ ├── check.ts # npmguard-cli check
|
|
135
|
-
│ └── install.ts # npmguard-cli install
|
|
136
|
-
├── reports/ # Sample audit reports (uploaded to IPFS)
|
|
137
|
-
├── package.json
|
|
138
|
-
└── tsconfig.json
|
|
139
|
-
```
|
|
140
|
-
|
|
141
|
-
## Development
|
|
142
|
-
|
|
143
|
-
```bash
|
|
144
|
-
cd cli && npm install
|
|
145
|
-
|
|
146
|
-
# Run locally
|
|
147
|
-
npx tsx src/index.ts check --path /tmp/test-project
|
|
148
|
-
npx tsx src/index.ts install axios@1.7.9
|
|
149
|
-
|
|
150
|
-
# Build
|
|
151
|
-
npm run build
|
|
152
|
-
|
|
153
|
-
# Publish
|
|
154
|
-
npm publish
|
|
155
|
-
```
|
|
7
|
+
- `npmguard check <package>` — run a security audit via the API
|
|
8
|
+
- `npmguard install <package>` — audit-then-install workflow
|
|
9
|
+
- Machine-readable JSON output for CI/CD integration
|
package/dist/api.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
async function request(url, options) {
|
|
2
|
+
const res = await fetch(url, options);
|
|
3
|
+
if (!res.ok) {
|
|
4
|
+
const body = await res.text().catch(() => "");
|
|
5
|
+
throw new Error(`HTTP ${res.status}: ${body || res.statusText}`);
|
|
6
|
+
}
|
|
7
|
+
return res.json();
|
|
8
|
+
}
|
|
9
|
+
export async function checkout(apiUrl, packageName, version) {
|
|
10
|
+
const body = { packageName };
|
|
11
|
+
if (version)
|
|
12
|
+
body.version = version;
|
|
13
|
+
return request(`${apiUrl}/checkout`, {
|
|
14
|
+
method: "POST",
|
|
15
|
+
headers: { "Content-Type": "application/json" },
|
|
16
|
+
body: JSON.stringify(body),
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
export async function pollCheckoutStatus(apiUrl, sessionId) {
|
|
20
|
+
return request(`${apiUrl}/checkout/${encodeURIComponent(sessionId)}/status`);
|
|
21
|
+
}
|
|
22
|
+
export async function startAudit(apiUrl, stripeSessionId) {
|
|
23
|
+
return request(`${apiUrl}/audit/stream`, {
|
|
24
|
+
method: "POST",
|
|
25
|
+
headers: { "Content-Type": "application/json" },
|
|
26
|
+
body: JSON.stringify({ stripeSessionId }),
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
export async function startAuditFree(apiUrl, packageName, version) {
|
|
30
|
+
return request(`${apiUrl}/audit/stream`, {
|
|
31
|
+
method: "POST",
|
|
32
|
+
headers: { "Content-Type": "application/json" },
|
|
33
|
+
body: JSON.stringify({ packageName, ...(version && { version }) }),
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
export async function checkoutRaw(apiUrl, packageName, version) {
|
|
37
|
+
const body = { packageName };
|
|
38
|
+
if (version)
|
|
39
|
+
body.version = version;
|
|
40
|
+
const res = await fetch(`${apiUrl}/checkout`, {
|
|
41
|
+
method: "POST",
|
|
42
|
+
headers: { "Content-Type": "application/json" },
|
|
43
|
+
body: JSON.stringify(body),
|
|
44
|
+
});
|
|
45
|
+
if (res.status === 501) {
|
|
46
|
+
return { status: 501, data: null };
|
|
47
|
+
}
|
|
48
|
+
if (!res.ok) {
|
|
49
|
+
const text = await res.text().catch(() => "");
|
|
50
|
+
throw new Error(`HTTP ${res.status}: ${text || res.statusText}`);
|
|
51
|
+
}
|
|
52
|
+
return { status: res.status, data: (await res.json()) };
|
|
53
|
+
}
|
|
54
|
+
export async function getPackageReport(apiUrl, packageName, version) {
|
|
55
|
+
const query = version ? `?version=${encodeURIComponent(version)}` : "";
|
|
56
|
+
const url = `${apiUrl}/package/${encodeURIComponent(packageName)}/report${query}`;
|
|
57
|
+
try {
|
|
58
|
+
return await request(url);
|
|
59
|
+
}
|
|
60
|
+
catch (err) {
|
|
61
|
+
if (err instanceof Error && err.message.startsWith("HTTP 404")) {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
throw err;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import ora from "ora";
|
|
3
|
+
import qrcode from "qrcode-terminal";
|
|
4
|
+
import EventSource from "eventsource";
|
|
5
|
+
import * as api from "../api.js";
|
|
6
|
+
import { renderVerdict, renderFinding, renderPhase } from "../render.js";
|
|
7
|
+
function parsePackageArg(pkg) {
|
|
8
|
+
// Handle scoped packages: @scope/name@version
|
|
9
|
+
if (pkg.startsWith("@")) {
|
|
10
|
+
const slashIndex = pkg.indexOf("/");
|
|
11
|
+
if (slashIndex === -1) {
|
|
12
|
+
throw new Error(`Invalid scoped package name: ${pkg}`);
|
|
13
|
+
}
|
|
14
|
+
const rest = pkg.slice(slashIndex + 1);
|
|
15
|
+
const atIndex = rest.lastIndexOf("@");
|
|
16
|
+
if (atIndex > 0) {
|
|
17
|
+
return {
|
|
18
|
+
name: pkg.slice(0, slashIndex + 1 + atIndex),
|
|
19
|
+
version: rest.slice(atIndex + 1),
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
return { name: pkg };
|
|
23
|
+
}
|
|
24
|
+
// Handle unscoped packages: name@version
|
|
25
|
+
const atIndex = pkg.lastIndexOf("@");
|
|
26
|
+
if (atIndex > 0) {
|
|
27
|
+
return {
|
|
28
|
+
name: pkg.slice(0, atIndex),
|
|
29
|
+
version: pkg.slice(atIndex + 1),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
return { name: pkg };
|
|
33
|
+
}
|
|
34
|
+
export async function auditCommand(pkg, opts) {
|
|
35
|
+
const apiUrl = opts.api;
|
|
36
|
+
// 1. Parse package name and version
|
|
37
|
+
let parsed;
|
|
38
|
+
try {
|
|
39
|
+
parsed = parsePackageArg(pkg);
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
console.error(chalk.red(`Invalid package: ${pkg}`));
|
|
43
|
+
process.exit(1);
|
|
44
|
+
}
|
|
45
|
+
console.log(chalk.bold(`Auditing ${parsed.name}`) +
|
|
46
|
+
(parsed.version ? chalk.gray(`@${parsed.version}`) : ""));
|
|
47
|
+
console.log();
|
|
48
|
+
// 1b. Check if already audited
|
|
49
|
+
const existing = await api.getPackageReport(apiUrl, parsed.name, parsed.version);
|
|
50
|
+
if (existing) {
|
|
51
|
+
console.log(chalk.yellow("This package has already been audited."));
|
|
52
|
+
console.log();
|
|
53
|
+
const verdict = existing.report?.verdict ?? existing.verdict ?? "UNKNOWN";
|
|
54
|
+
renderVerdict(verdict, existing.report?.capabilities ?? [], existing.report?.proofs?.length ?? 0);
|
|
55
|
+
console.log();
|
|
56
|
+
console.log(chalk.dim(`View full report: ${apiUrl}/package/${encodeURIComponent(parsed.name)}/report`));
|
|
57
|
+
process.exit(verdict === "SAFE" ? 0 : 1);
|
|
58
|
+
}
|
|
59
|
+
// 2. Try checkout — if payments not configured (501), go straight to free audit
|
|
60
|
+
const spinner = ora("Connecting...").start();
|
|
61
|
+
let auditId;
|
|
62
|
+
let checkoutResult;
|
|
63
|
+
try {
|
|
64
|
+
checkoutResult = await api.checkoutRaw(apiUrl, parsed.name, parsed.version);
|
|
65
|
+
}
|
|
66
|
+
catch (err) {
|
|
67
|
+
spinner.fail("Failed to create checkout session: " +
|
|
68
|
+
(err instanceof Error ? err.message : String(err)));
|
|
69
|
+
process.exit(1);
|
|
70
|
+
}
|
|
71
|
+
if (checkoutResult.status === 501 || !checkoutResult.data) {
|
|
72
|
+
// Payments not configured — start audit directly (dev/free mode)
|
|
73
|
+
spinner.text = "Starting audit (no payment required)...";
|
|
74
|
+
try {
|
|
75
|
+
const auditRes = await api.startAuditFree(apiUrl, parsed.name, parsed.version);
|
|
76
|
+
auditId = auditRes.auditId;
|
|
77
|
+
}
|
|
78
|
+
catch (err) {
|
|
79
|
+
spinner.fail("Failed to start audit: " +
|
|
80
|
+
(err instanceof Error ? err.message : String(err)));
|
|
81
|
+
process.exit(1);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
// Payment required — show URL + QR + poll
|
|
86
|
+
spinner.stop();
|
|
87
|
+
const link = chalk.blue.underline(checkoutResult.data.url);
|
|
88
|
+
console.log(chalk.bold("Pay to start the audit:"));
|
|
89
|
+
console.log(link);
|
|
90
|
+
console.log();
|
|
91
|
+
qrcode.generate(checkoutResult.data.url, { small: true });
|
|
92
|
+
console.log();
|
|
93
|
+
spinner.start("Waiting for payment...");
|
|
94
|
+
let status;
|
|
95
|
+
try {
|
|
96
|
+
status = await new Promise((resolve, reject) => {
|
|
97
|
+
const interval = setInterval(async () => {
|
|
98
|
+
try {
|
|
99
|
+
const s = await api.pollCheckoutStatus(apiUrl, checkoutResult.data.sessionId);
|
|
100
|
+
if (s.paid) {
|
|
101
|
+
clearInterval(interval);
|
|
102
|
+
resolve(s);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
catch (err) {
|
|
106
|
+
clearInterval(interval);
|
|
107
|
+
reject(err);
|
|
108
|
+
}
|
|
109
|
+
}, 3000);
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
catch (err) {
|
|
113
|
+
spinner.fail("Payment polling failed: " +
|
|
114
|
+
(err instanceof Error ? err.message : String(err)));
|
|
115
|
+
process.exit(1);
|
|
116
|
+
}
|
|
117
|
+
spinner.text = "Payment confirmed. Starting audit...";
|
|
118
|
+
if (status.auditId) {
|
|
119
|
+
auditId = status.auditId;
|
|
120
|
+
}
|
|
121
|
+
else {
|
|
122
|
+
try {
|
|
123
|
+
const auditRes = await api.startAudit(apiUrl, checkoutResult.data.sessionId);
|
|
124
|
+
auditId = auditRes.auditId;
|
|
125
|
+
}
|
|
126
|
+
catch (err) {
|
|
127
|
+
spinner.fail("Failed to start audit: " +
|
|
128
|
+
(err instanceof Error ? err.message : String(err)));
|
|
129
|
+
process.exit(1);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
spinner.text = "Audit in progress...";
|
|
134
|
+
// 9. Connect to SSE
|
|
135
|
+
const eventsUrl = `${apiUrl}/audit/${encodeURIComponent(auditId)}/events`;
|
|
136
|
+
const es = new EventSource(eventsUrl);
|
|
137
|
+
let exitCode = 0;
|
|
138
|
+
await new Promise((resolve) => {
|
|
139
|
+
es.addEventListener("phase_started", (event) => {
|
|
140
|
+
try {
|
|
141
|
+
const data = JSON.parse(event.data);
|
|
142
|
+
spinner.text = renderPhase(data.phase ?? data.name ?? "");
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
// ignore parse errors
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
es.addEventListener("finding_discovered", (event) => {
|
|
149
|
+
try {
|
|
150
|
+
const data = JSON.parse(event.data);
|
|
151
|
+
const finding = data.finding ?? data;
|
|
152
|
+
spinner.stop();
|
|
153
|
+
renderFinding(finding);
|
|
154
|
+
spinner.start();
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
// ignore parse errors
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
es.addEventListener("verdict_reached", (event) => {
|
|
161
|
+
try {
|
|
162
|
+
const data = JSON.parse(event.data);
|
|
163
|
+
spinner.stop();
|
|
164
|
+
renderVerdict(data.verdict ?? "UNKNOWN", data.capabilities ?? [], data.proofCount ?? data.findings?.length ?? 0);
|
|
165
|
+
exitCode = data.verdict?.toUpperCase() === "SAFE" ? 0 : 1;
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
spinner.stop();
|
|
169
|
+
}
|
|
170
|
+
es.close();
|
|
171
|
+
resolve();
|
|
172
|
+
});
|
|
173
|
+
es.addEventListener("audit_error", (event) => {
|
|
174
|
+
try {
|
|
175
|
+
const data = JSON.parse(event.data);
|
|
176
|
+
spinner.fail(chalk.red("Audit error: " + (data.error ?? event.data)));
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
179
|
+
spinner.fail(chalk.red("Audit error: " + event.data));
|
|
180
|
+
}
|
|
181
|
+
exitCode = 1;
|
|
182
|
+
es.close();
|
|
183
|
+
resolve();
|
|
184
|
+
});
|
|
185
|
+
es.onerror = () => {
|
|
186
|
+
// EventSource will auto-reconnect; if it closes, we resolve
|
|
187
|
+
if (es.readyState === EventSource.CLOSED) {
|
|
188
|
+
spinner.stop();
|
|
189
|
+
es.close();
|
|
190
|
+
resolve();
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
});
|
|
194
|
+
process.exit(exitCode);
|
|
195
|
+
}
|
package/dist/commands/check.js
CHANGED
|
@@ -1,130 +1,81 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { resolve } from "node:path";
|
|
1
3
|
import chalk from "chalk";
|
|
2
|
-
import
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
const
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
return chalk.yellow(`WARNING (${score})`);
|
|
12
|
-
case "CRITICAL":
|
|
13
|
-
return chalk.red(`CRITICAL (${score})`);
|
|
14
|
-
case "DANGEROUS":
|
|
15
|
-
return chalk.red.bold(`DANGEROUS (${score})`);
|
|
16
|
-
default:
|
|
17
|
-
return chalk.gray("UNKNOWN");
|
|
4
|
+
import * as api from "../api.js";
|
|
5
|
+
export async function checkCommand(opts) {
|
|
6
|
+
const apiUrl = opts.api;
|
|
7
|
+
const pkgPath = resolve(opts.path, "package.json");
|
|
8
|
+
// 1. Read package.json
|
|
9
|
+
let pkgJson;
|
|
10
|
+
try {
|
|
11
|
+
const raw = await readFile(pkgPath, "utf-8");
|
|
12
|
+
pkgJson = JSON.parse(raw);
|
|
18
13
|
}
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
|
|
14
|
+
catch (err) {
|
|
15
|
+
console.error(chalk.red(`Failed to read ${pkgPath}: `) +
|
|
16
|
+
(err instanceof Error ? err.message : String(err)));
|
|
17
|
+
process.exit(1);
|
|
18
|
+
}
|
|
19
|
+
// 2. Collect dependency names
|
|
20
|
+
const deps = new Set([
|
|
21
|
+
...Object.keys(pkgJson.dependencies ?? {}),
|
|
22
|
+
...Object.keys(pkgJson.devDependencies ?? {}),
|
|
23
|
+
]);
|
|
24
|
+
if (deps.size === 0) {
|
|
25
|
+
console.log(chalk.yellow("No dependencies found in package.json."));
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
console.log(chalk.bold(`Checking ${deps.size} dependencies...\n`));
|
|
29
|
+
// 3. Check each dependency
|
|
30
|
+
const results = [];
|
|
31
|
+
for (const name of deps) {
|
|
32
|
+
const versionSpec = pkgJson.dependencies?.[name] ?? pkgJson.devDependencies?.[name] ?? "";
|
|
33
|
+
try {
|
|
34
|
+
const report = await api.getPackageReport(apiUrl, name);
|
|
35
|
+
results.push({
|
|
36
|
+
name,
|
|
37
|
+
version: report?.version ?? versionSpec,
|
|
38
|
+
verdict: report?.verdict ?? null,
|
|
39
|
+
});
|
|
30
40
|
}
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
.join(", ");
|
|
34
|
-
}
|
|
35
|
-
function shortCid(cid) {
|
|
36
|
-
return cid.slice(0, 8) + "..." + cid.slice(-4);
|
|
37
|
-
}
|
|
38
|
-
export async function checkCommand(projectPath, auditSource) {
|
|
39
|
-
const spinner = ora("Scanning project dependencies...").start();
|
|
40
|
-
const deps = await scanProject(projectPath);
|
|
41
|
-
spinner.text = `Found ${deps.length} dependencies. Checking audits...`;
|
|
42
|
-
const table = new Table({
|
|
43
|
-
head: [
|
|
44
|
-
chalk.bold("Package"),
|
|
45
|
-
chalk.bold("Installed"),
|
|
46
|
-
chalk.bold("Latest"),
|
|
47
|
-
chalk.bold("Verdict"),
|
|
48
|
-
chalk.bold("Capabilities"),
|
|
49
|
-
],
|
|
50
|
-
style: { head: [], border: [] },
|
|
51
|
-
});
|
|
52
|
-
let safeCount = 0;
|
|
53
|
-
let warningCount = 0;
|
|
54
|
-
let criticalCount = 0;
|
|
55
|
-
let notAuditedCount = 0;
|
|
56
|
-
const links = [];
|
|
57
|
-
for (const dep of deps) {
|
|
58
|
-
const versionToCheck = dep.latest ?? dep.installed;
|
|
59
|
-
let audit;
|
|
60
|
-
if (dep.installed.startsWith("http")) {
|
|
61
|
-
// IPFS/URL installs: audit against the latest registry version
|
|
62
|
-
audit = await auditSource.getAudit(dep.name, versionToCheck);
|
|
41
|
+
catch {
|
|
42
|
+
results.push({ name, version: versionSpec, verdict: null });
|
|
63
43
|
}
|
|
64
|
-
|
|
65
|
-
|
|
44
|
+
}
|
|
45
|
+
// 4. Print summary table
|
|
46
|
+
const nameWidth = Math.max(12, ...results.map((r) => r.name.length)) + 2;
|
|
47
|
+
const versionWidth = Math.max(10, ...results.map((r) => r.version.length)) + 2;
|
|
48
|
+
const header = chalk.bold("Package".padEnd(nameWidth)) +
|
|
49
|
+
chalk.bold("Version".padEnd(versionWidth)) +
|
|
50
|
+
chalk.bold("Verdict");
|
|
51
|
+
console.log(header);
|
|
52
|
+
console.log("─".repeat(nameWidth + versionWidth + 12));
|
|
53
|
+
for (const r of results) {
|
|
54
|
+
const nameCol = r.name.padEnd(nameWidth);
|
|
55
|
+
const versionCol = r.version.padEnd(versionWidth);
|
|
56
|
+
let verdictCol;
|
|
57
|
+
if (r.verdict === null) {
|
|
58
|
+
verdictCol = chalk.gray("not audited");
|
|
66
59
|
}
|
|
67
|
-
else {
|
|
68
|
-
|
|
60
|
+
else if (r.verdict.toUpperCase() === "SAFE") {
|
|
61
|
+
verdictCol = chalk.green("SAFE");
|
|
69
62
|
}
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
if (audit) {
|
|
73
|
-
verdictCol = verdictLabel(audit.verdict, audit.score);
|
|
74
|
-
capsCol = capsLabel(audit.capabilities);
|
|
75
|
-
if (audit.verdict === "SAFE")
|
|
76
|
-
safeCount++;
|
|
77
|
-
else if (audit.verdict === "WARNING")
|
|
78
|
-
warningCount++;
|
|
79
|
-
else if (audit.verdict === "CRITICAL")
|
|
80
|
-
criticalCount++;
|
|
81
|
-
if (audit.reportCid) {
|
|
82
|
-
links.push(` ${dep.name}@${versionToCheck} report: ${IPFS_GATEWAY}/${audit.reportCid}`);
|
|
83
|
-
}
|
|
63
|
+
else if (r.verdict.toUpperCase() === "DANGEROUS") {
|
|
64
|
+
verdictCol = chalk.red("DANGEROUS");
|
|
84
65
|
}
|
|
85
66
|
else {
|
|
86
|
-
verdictCol = chalk.
|
|
87
|
-
capsCol = chalk.gray("-");
|
|
88
|
-
notAuditedCount++;
|
|
89
|
-
}
|
|
90
|
-
// Truncate long IPFS URLs so the table stays readable
|
|
91
|
-
const installedCol = dep.installed.startsWith("http")
|
|
92
|
-
? chalk.yellow("IPFS " + shortCid(dep.installed.split("/").pop()))
|
|
93
|
-
: dep.installed;
|
|
94
|
-
table.push([
|
|
95
|
-
dep.name,
|
|
96
|
-
installedCol,
|
|
97
|
-
dep.latest ?? dep.installed,
|
|
98
|
-
verdictCol,
|
|
99
|
-
capsCol,
|
|
100
|
-
]);
|
|
101
|
-
}
|
|
102
|
-
spinner.stop();
|
|
103
|
-
console.log();
|
|
104
|
-
console.log(chalk.bold("NpmGuard Dependency Audit"));
|
|
105
|
-
console.log();
|
|
106
|
-
console.log(table.toString());
|
|
107
|
-
console.log();
|
|
108
|
-
// Summary
|
|
109
|
-
const parts = [];
|
|
110
|
-
if (safeCount > 0)
|
|
111
|
-
parts.push(chalk.green(`${safeCount} safe`));
|
|
112
|
-
if (warningCount > 0)
|
|
113
|
-
parts.push(chalk.yellow(`${warningCount} warnings`));
|
|
114
|
-
if (criticalCount > 0)
|
|
115
|
-
parts.push(chalk.red(`${criticalCount} critical`));
|
|
116
|
-
if (notAuditedCount > 0)
|
|
117
|
-
parts.push(chalk.gray(`${notAuditedCount} not audited`));
|
|
118
|
-
console.log(` ${parts.join(" | ")}`);
|
|
119
|
-
if (links.length > 0) {
|
|
120
|
-
console.log();
|
|
121
|
-
for (const link of links) {
|
|
122
|
-
console.log(chalk.gray(link));
|
|
67
|
+
verdictCol = chalk.yellow(r.verdict);
|
|
123
68
|
}
|
|
124
|
-
|
|
125
|
-
if (criticalCount > 0) {
|
|
126
|
-
console.log();
|
|
127
|
-
console.log(chalk.red.bold(` ${criticalCount} package(s) flagged as CRITICAL — do not update without reviewing the report.`));
|
|
69
|
+
console.log(nameCol + versionCol + verdictCol);
|
|
128
70
|
}
|
|
129
71
|
console.log();
|
|
72
|
+
// Count stats
|
|
73
|
+
const safe = results.filter((r) => r.verdict?.toUpperCase() === "SAFE").length;
|
|
74
|
+
const dangerous = results.filter((r) => r.verdict?.toUpperCase() === "DANGEROUS").length;
|
|
75
|
+
const notAudited = results.filter((r) => r.verdict === null).length;
|
|
76
|
+
console.log(chalk.green(`${safe} safe`) +
|
|
77
|
+
" | " +
|
|
78
|
+
chalk.red(`${dangerous} dangerous`) +
|
|
79
|
+
" | " +
|
|
80
|
+
chalk.gray(`${notAudited} not audited`));
|
|
130
81
|
}
|
package/dist/index.js
CHANGED
|
@@ -1,37 +1,27 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import "dotenv/config";
|
|
3
2
|
import { Command } from "commander";
|
|
4
|
-
import {
|
|
5
|
-
import { ENSAuditSource } from "./ens-source.js";
|
|
6
|
-
import { MockAuditSource } from "./mock-source.js";
|
|
3
|
+
import { auditCommand } from "./commands/audit.js";
|
|
7
4
|
import { checkCommand } from "./commands/check.js";
|
|
8
|
-
import { installCommand } from "./commands/install.js";
|
|
9
5
|
const program = new Command();
|
|
10
6
|
program
|
|
11
7
|
.name("npmguard")
|
|
12
|
-
.description("
|
|
13
|
-
.version("
|
|
8
|
+
.description("NpmGuard CLI — audit npm packages for security issues")
|
|
9
|
+
.version("1.0.0")
|
|
10
|
+
.option("--api <url>", "NpmGuard engine API URL", process.env.NPMGUARD_API_URL ?? "https://npmguard.com");
|
|
14
11
|
program
|
|
15
|
-
.command("
|
|
16
|
-
.description("
|
|
17
|
-
.
|
|
18
|
-
.
|
|
19
|
-
.
|
|
20
|
-
|
|
21
|
-
? new MockAuditSource()
|
|
22
|
-
: new ENSAuditSource();
|
|
23
|
-
const projectPath = resolve(opts.path);
|
|
24
|
-
await checkCommand(projectPath, auditSource);
|
|
12
|
+
.command("audit")
|
|
13
|
+
.description("Pay for and run a security audit on an npm package")
|
|
14
|
+
.argument("<package>", "Package name, optionally with version (e.g. express@4.18.0)")
|
|
15
|
+
.action(async (pkg) => {
|
|
16
|
+
const apiUrl = program.opts().api;
|
|
17
|
+
await auditCommand(pkg, { api: apiUrl });
|
|
25
18
|
});
|
|
26
19
|
program
|
|
27
|
-
.command("
|
|
28
|
-
.description("
|
|
29
|
-
.option("--
|
|
30
|
-
.
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
? new MockAuditSource()
|
|
34
|
-
: new ENSAuditSource();
|
|
35
|
-
await installCommand(pkg, auditSource, opts.force ?? false);
|
|
20
|
+
.command("check")
|
|
21
|
+
.description("Check all dependencies of a project against existing audits")
|
|
22
|
+
.option("--path <dir>", "Path to project directory", ".")
|
|
23
|
+
.action(async (cmdOpts) => {
|
|
24
|
+
const apiUrl = program.opts().api;
|
|
25
|
+
await checkCommand({ path: cmdOpts.path, api: apiUrl });
|
|
36
26
|
});
|
|
37
27
|
program.parse();
|