opmsec 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +14 -0
- package/.pnp.cjs +9953 -0
- package/.pnp.loader.mjs +2126 -0
- package/README.md +266 -0
- package/bun.lock +620 -0
- package/bunfig.toml +6 -0
- package/docker-compose.yml +10 -0
- package/package.json +39 -0
- package/packages/cli/package.json +7 -0
- package/packages/cli/src/commands/audit.tsx +142 -0
- package/packages/cli/src/commands/author-view.tsx +247 -0
- package/packages/cli/src/commands/info.tsx +109 -0
- package/packages/cli/src/commands/install.tsx +362 -0
- package/packages/cli/src/commands/passthrough.tsx +36 -0
- package/packages/cli/src/commands/push.tsx +321 -0
- package/packages/cli/src/components/AgentScores.tsx +32 -0
- package/packages/cli/src/components/AuthorInfo.tsx +45 -0
- package/packages/cli/src/components/Header.tsx +24 -0
- package/packages/cli/src/components/PackageCard.tsx +48 -0
- package/packages/cli/src/components/RiskBadge.tsx +32 -0
- package/packages/cli/src/components/ScanReport.tsx +50 -0
- package/packages/cli/src/components/StatusLine.tsx +30 -0
- package/packages/cli/src/index.tsx +111 -0
- package/packages/cli/src/services/avatar.ts +10 -0
- package/packages/cli/src/services/chainpatrol.ts +25 -0
- package/packages/cli/src/services/contract.ts +182 -0
- package/packages/cli/src/services/ens.ts +143 -0
- package/packages/cli/src/services/fileverse.ts +36 -0
- package/packages/cli/src/services/osv.ts +141 -0
- package/packages/cli/src/services/signature.ts +22 -0
- package/packages/cli/src/services/version.ts +10 -0
- package/packages/contracts/contracts/OPMRegistry.sol +253 -0
- package/packages/contracts/hardhat.config.ts +32 -0
- package/packages/contracts/package-lock.json +7772 -0
- package/packages/contracts/package.json +10 -0
- package/packages/contracts/scripts/deploy.ts +28 -0
- package/packages/contracts/test/OPMRegistry.test.ts +101 -0
- package/packages/contracts/tsconfig.json +11 -0
- package/packages/core/package.json +7 -0
- package/packages/core/src/abi.ts +629 -0
- package/packages/core/src/constants.ts +30 -0
- package/packages/core/src/index.ts +5 -0
- package/packages/core/src/prompt.ts +111 -0
- package/packages/core/src/types.ts +104 -0
- package/packages/core/src/utils.ts +50 -0
- package/packages/scanner/package.json +6 -0
- package/packages/scanner/src/agents/agent-configs.ts +24 -0
- package/packages/scanner/src/agents/base-agent.ts +75 -0
- package/packages/scanner/src/index.ts +25 -0
- package/packages/scanner/src/queue/memory-queue.ts +91 -0
- package/packages/scanner/src/services/contract-writer.ts +34 -0
- package/packages/scanner/src/services/fileverse.ts +89 -0
- package/packages/scanner/src/services/npm-registry.ts +159 -0
- package/packages/scanner/src/services/openrouter.ts +86 -0
- package/packages/scanner/src/services/osv.ts +87 -0
- package/packages/scanner/src/services/report-formatter.ts +134 -0
- package/tsconfig.json +23 -0
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
import React, { useState, useEffect } from 'react';
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import { getEnvOrThrow, truncateAddress, classifyRisk } from '@opm/core';
|
|
4
|
+
import type { AgentEntry } from '@opm/core';
|
|
5
|
+
import { Header } from '../components/Header';
|
|
6
|
+
import { StatusLine, type Status } from '../components/StatusLine';
|
|
7
|
+
import { RiskBadge } from '../components/RiskBadge';
|
|
8
|
+
import { computeChecksum, signChecksumAsync } from '../services/signature';
|
|
9
|
+
import { resolveENSName } from '../services/ens';
|
|
10
|
+
import { registerPackageOnChain } from '../services/contract';
|
|
11
|
+
import { enqueueScan } from '@opm/scanner';
|
|
12
|
+
import * as fs from 'fs';
|
|
13
|
+
import * as path from 'path';
|
|
14
|
+
import { execSync } from 'child_process';
|
|
15
|
+
|
|
16
|
+
type StepStatus = Status;
|
|
17
|
+
|
|
18
|
+
interface Steps {
|
|
19
|
+
pack: StepStatus;
|
|
20
|
+
sign: StepStatus;
|
|
21
|
+
ens: StepStatus;
|
|
22
|
+
scan: StepStatus;
|
|
23
|
+
publish: StepStatus;
|
|
24
|
+
register: StepStatus;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface PushResult {
|
|
28
|
+
checksum?: string;
|
|
29
|
+
signature?: string;
|
|
30
|
+
address?: string;
|
|
31
|
+
ensName?: string;
|
|
32
|
+
npmUrl?: string;
|
|
33
|
+
npmError?: string;
|
|
34
|
+
txHash?: string;
|
|
35
|
+
riskScore?: number;
|
|
36
|
+
riskLevel?: string;
|
|
37
|
+
reportURI?: string;
|
|
38
|
+
agents?: AgentEntry[];
|
|
39
|
+
blocked?: boolean;
|
|
40
|
+
blockReason?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface PushCommandProps {
|
|
44
|
+
npmToken?: string;
|
|
45
|
+
otp?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function PushCommand({ npmToken, otp }: PushCommandProps) {
|
|
49
|
+
const [steps, setSteps] = useState<Steps>({
|
|
50
|
+
pack: 'pending', sign: 'pending', ens: 'pending',
|
|
51
|
+
scan: 'pending', publish: 'pending', register: 'pending',
|
|
52
|
+
});
|
|
53
|
+
const [result, setResult] = useState<PushResult>({});
|
|
54
|
+
const [error, setError] = useState<string | null>(null);
|
|
55
|
+
const [scanLogs, setScanLogs] = useState<string[]>([]);
|
|
56
|
+
const [pkgLabel, setPkgLabel] = useState('');
|
|
57
|
+
|
|
58
|
+
const updateStep = (key: keyof Steps, status: StepStatus) =>
|
|
59
|
+
setSteps((prev) => ({ ...prev, [key]: status }));
|
|
60
|
+
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
runPush().catch((err) => setError(String(err)));
|
|
63
|
+
}, []);
|
|
64
|
+
|
|
65
|
+
async function runPush() {
|
|
66
|
+
const pkgJsonPath = path.resolve('package.json');
|
|
67
|
+
if (!fs.existsSync(pkgJsonPath)) throw new Error('No package.json found in current directory');
|
|
68
|
+
|
|
69
|
+
const pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf-8'));
|
|
70
|
+
const { name, version } = pkgJson;
|
|
71
|
+
if (!name || !version) throw new Error('package.json missing name or version');
|
|
72
|
+
setPkgLabel(`${name}@${version}`);
|
|
73
|
+
|
|
74
|
+
const privateKey = getEnvOrThrow('OPM_PRIVATE_KEY');
|
|
75
|
+
|
|
76
|
+
updateStep('pack', 'running');
|
|
77
|
+
const tarball = execSync('npm pack --json 2>/dev/null', { encoding: 'utf-8' });
|
|
78
|
+
const parsed = JSON.parse(tarball);
|
|
79
|
+
const tarballFile = Array.isArray(parsed) ? parsed[0].filename : parsed.filename;
|
|
80
|
+
updateStep('pack', 'done');
|
|
81
|
+
|
|
82
|
+
updateStep('sign', 'running');
|
|
83
|
+
const checksum = computeChecksum(tarballFile);
|
|
84
|
+
const { signature, address } = await signChecksumAsync(checksum, privateKey);
|
|
85
|
+
setResult((r) => ({ ...r, checksum, signature, address }));
|
|
86
|
+
updateStep('sign', 'done');
|
|
87
|
+
|
|
88
|
+
updateStep('ens', 'running');
|
|
89
|
+
const ensName = await resolveENSName(address) || '';
|
|
90
|
+
setResult((r) => ({ ...r, ensName, address }));
|
|
91
|
+
updateStep('ens', 'done');
|
|
92
|
+
|
|
93
|
+
updateStep('scan', 'running');
|
|
94
|
+
let scanPassed = false;
|
|
95
|
+
try {
|
|
96
|
+
const scanResult = await enqueueScan(name, version, (msg) =>
|
|
97
|
+
setScanLogs((prev) => [...prev.slice(-8), msg]),
|
|
98
|
+
{ tarballPath: tarballFile, pkgJsonPath },
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
const riskScore = scanResult.report.aggregate_risk_score;
|
|
102
|
+
const riskLevel = classifyRisk(riskScore);
|
|
103
|
+
|
|
104
|
+
setResult((r) => ({
|
|
105
|
+
...r,
|
|
106
|
+
riskScore,
|
|
107
|
+
riskLevel,
|
|
108
|
+
reportURI: scanResult.reportURI,
|
|
109
|
+
agents: scanResult.report.agents,
|
|
110
|
+
}));
|
|
111
|
+
|
|
112
|
+
if (riskLevel === 'CRITICAL' || riskScore >= 80) {
|
|
113
|
+
setResult((r) => ({
|
|
114
|
+
...r,
|
|
115
|
+
blocked: true,
|
|
116
|
+
blockReason: `Risk score ${riskScore}/100 (${riskLevel}) — too dangerous to publish`,
|
|
117
|
+
}));
|
|
118
|
+
updateStep('scan', 'error');
|
|
119
|
+
updateStep('publish', 'blocked');
|
|
120
|
+
updateStep('register', 'blocked');
|
|
121
|
+
if (fs.existsSync(tarballFile)) fs.unlinkSync(tarballFile);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
scanPassed = true;
|
|
126
|
+
updateStep('scan', 'done');
|
|
127
|
+
} catch (err: any) {
|
|
128
|
+
setScanLogs((prev) => [...prev, `Scan: ${err?.message || 'failed'}`]);
|
|
129
|
+
scanPassed = true;
|
|
130
|
+
updateStep('scan', 'error');
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (!scanPassed) return;
|
|
134
|
+
|
|
135
|
+
const originalPkgJsonContent = fs.readFileSync(pkgJsonPath, 'utf-8');
|
|
136
|
+
pkgJson.opm = { signature, author: address, ensName, checksum };
|
|
137
|
+
fs.writeFileSync(pkgJsonPath, JSON.stringify(pkgJson, null, 2));
|
|
138
|
+
|
|
139
|
+
updateStep('publish', 'running');
|
|
140
|
+
const token = npmToken || process.env.NPM_TOKEN;
|
|
141
|
+
const npmrcPath = path.resolve('.npmrc');
|
|
142
|
+
const existingNpmrc = fs.existsSync(npmrcPath) ? fs.readFileSync(npmrcPath, 'utf-8') : null;
|
|
143
|
+
|
|
144
|
+
let canPublish = false;
|
|
145
|
+
if (token) {
|
|
146
|
+
fs.writeFileSync(npmrcPath, `//registry.npmjs.org/:_authToken=${token}\n`);
|
|
147
|
+
canPublish = true;
|
|
148
|
+
} else {
|
|
149
|
+
canPublish = (() => {
|
|
150
|
+
try { execSync('npm whoami', { encoding: 'utf-8', stdio: 'pipe' }); return true; } catch { return false; }
|
|
151
|
+
})();
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (canPublish) {
|
|
155
|
+
try {
|
|
156
|
+
let publishCmd = 'npm publish --access public';
|
|
157
|
+
if (otp) publishCmd += ` --otp=${otp}`;
|
|
158
|
+
execSync(publishCmd, { encoding: 'utf-8', stdio: 'pipe' });
|
|
159
|
+
setResult((r) => ({
|
|
160
|
+
...r,
|
|
161
|
+
npmUrl: `https://www.npmjs.com/package/${name}/v/${version}`,
|
|
162
|
+
}));
|
|
163
|
+
} catch (err: any) {
|
|
164
|
+
const msg = err?.stderr || err?.stdout || err?.message || '';
|
|
165
|
+
let reason = 'version may already exist';
|
|
166
|
+
if (msg.includes('Two-factor') || msg.includes('2fa') || msg.includes('EOTP')) {
|
|
167
|
+
reason = token
|
|
168
|
+
? '2FA enforced — your token needs "bypass 2FA" enabled, or use --otp <code>'
|
|
169
|
+
: '2FA required — use: opm push --otp <code> or --token <automation-token>';
|
|
170
|
+
} else if (msg.includes('E403')) {
|
|
171
|
+
reason = 'forbidden — check npm token permissions';
|
|
172
|
+
} else if (msg.includes('E402')) {
|
|
173
|
+
reason = 'payment required — scoped packages need npm Pro';
|
|
174
|
+
} else {
|
|
175
|
+
const lines = msg.split('\n').filter((l: string) => l.trim().length > 0);
|
|
176
|
+
const errorLine = lines.find((l: string) =>
|
|
177
|
+
(l.includes('npm error') || l.includes('ERR!')) && !l.includes('npm notice'),
|
|
178
|
+
);
|
|
179
|
+
if (errorLine) reason = errorLine.replace(/^npm\s+(error|ERR!)\s*/i, '').trim();
|
|
180
|
+
}
|
|
181
|
+
setResult((r) => ({ ...r, npmError: reason.slice(0, 150) }));
|
|
182
|
+
}
|
|
183
|
+
} else {
|
|
184
|
+
setResult((r) => ({ ...r, npmError: 'not authenticated — use: opm push --token <npm-token>' }));
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (token) {
|
|
188
|
+
if (existingNpmrc !== null) {
|
|
189
|
+
fs.writeFileSync(npmrcPath, existingNpmrc);
|
|
190
|
+
} else if (fs.existsSync(npmrcPath)) {
|
|
191
|
+
fs.unlinkSync(npmrcPath);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
updateStep('publish', 'done');
|
|
195
|
+
|
|
196
|
+
fs.writeFileSync(pkgJsonPath, originalPkgJsonContent);
|
|
197
|
+
|
|
198
|
+
updateStep('register', 'running');
|
|
199
|
+
try {
|
|
200
|
+
const sigBytes = new Uint8Array(Buffer.from(signature.slice(2), 'hex'));
|
|
201
|
+
const txHash = await registerPackageOnChain(name, version, checksum, sigBytes, ensName);
|
|
202
|
+
setResult((r) => ({ ...r, txHash }));
|
|
203
|
+
} catch (err: any) {
|
|
204
|
+
setScanLogs((prev) => [...prev, `Registration: ${err?.shortMessage || err?.message || 'failed'}`]);
|
|
205
|
+
}
|
|
206
|
+
updateStep('register', 'done');
|
|
207
|
+
|
|
208
|
+
if (fs.existsSync(tarballFile)) fs.unlinkSync(tarballFile);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const riskColor = (score: number) => score >= 70 ? 'red' : score >= 40 ? 'yellow' : 'green';
|
|
212
|
+
|
|
213
|
+
return (
|
|
214
|
+
<Box flexDirection="column">
|
|
215
|
+
<Header subtitle="push" />
|
|
216
|
+
{pkgLabel && <Text color="white" bold> {pkgLabel}</Text>}
|
|
217
|
+
<Text> </Text>
|
|
218
|
+
<StatusLine label="Pack tarball" status={steps.pack} detail={result.checksum?.slice(0, 16)} />
|
|
219
|
+
<StatusLine label="Sign checksum" status={steps.sign} detail={result.signature?.slice(0, 16)} />
|
|
220
|
+
<StatusLine label="Resolve ENS" status={steps.ens} />
|
|
221
|
+
{steps.ens === 'done' && result.address && (
|
|
222
|
+
<Box marginLeft={4}>
|
|
223
|
+
<Text color="cyan">{truncateAddress(result.address)}</Text>
|
|
224
|
+
{result.ensName ? (
|
|
225
|
+
<Text> → <Text color="green" bold>{result.ensName}</Text></Text>
|
|
226
|
+
) : (
|
|
227
|
+
<Text color="gray"> (no ENS name)</Text>
|
|
228
|
+
)}
|
|
229
|
+
</Box>
|
|
230
|
+
)}
|
|
231
|
+
<StatusLine label="Security scan (3 agents)" status={steps.scan} />
|
|
232
|
+
|
|
233
|
+
{scanLogs.length > 0 && (
|
|
234
|
+
<Box flexDirection="column" marginLeft={4}>
|
|
235
|
+
{scanLogs.map((log, i) => (
|
|
236
|
+
<Text key={i} color="gray">{log}</Text>
|
|
237
|
+
))}
|
|
238
|
+
</Box>
|
|
239
|
+
)}
|
|
240
|
+
|
|
241
|
+
{result.agents && result.agents.length > 0 && (
|
|
242
|
+
<Box flexDirection="column" marginTop={1}>
|
|
243
|
+
<Text color="gray">────────────────────────────────────────</Text>
|
|
244
|
+
<Text color="white" bold> Agent Results</Text>
|
|
245
|
+
{result.agents.map((agent) => (
|
|
246
|
+
<Box key={agent.agent_id} flexDirection="column" marginLeft={2} marginTop={1}>
|
|
247
|
+
<Box>
|
|
248
|
+
<Text color="white" bold>{agent.agent_id}</Text>
|
|
249
|
+
<Text color="gray"> ({agent.model}) </Text>
|
|
250
|
+
<Text color={riskColor(agent.result.risk_score)} bold>
|
|
251
|
+
{agent.result.risk_score}/100
|
|
252
|
+
</Text>
|
|
253
|
+
<Text color="gray"> {agent.result.risk_level}</Text>
|
|
254
|
+
</Box>
|
|
255
|
+
<Box marginLeft={2}>
|
|
256
|
+
<Text color="gray" wrap="wrap">{agent.result.reasoning.slice(0, 200)}</Text>
|
|
257
|
+
</Box>
|
|
258
|
+
{agent.result.vulnerabilities.length > 0 && (
|
|
259
|
+
<Box marginLeft={2}>
|
|
260
|
+
<Text color="yellow">{agent.result.vulnerabilities.length} vulnerabilities found</Text>
|
|
261
|
+
</Box>
|
|
262
|
+
)}
|
|
263
|
+
</Box>
|
|
264
|
+
))}
|
|
265
|
+
</Box>
|
|
266
|
+
)}
|
|
267
|
+
|
|
268
|
+
{result.riskScore !== undefined && (
|
|
269
|
+
<Box flexDirection="column" marginTop={1}>
|
|
270
|
+
<Text color="gray">────────────────────────────────────────</Text>
|
|
271
|
+
<Box>
|
|
272
|
+
<Text color="white" bold> Aggregate Risk: </Text>
|
|
273
|
+
<RiskBadge level={classifyRisk(result.riskScore)} score={result.riskScore} />
|
|
274
|
+
</Box>
|
|
275
|
+
</Box>
|
|
276
|
+
)}
|
|
277
|
+
|
|
278
|
+
{result.blocked && (
|
|
279
|
+
<Box marginLeft={2} marginTop={1}>
|
|
280
|
+
<Text color="red" bold>✗ BLOCKED: {result.blockReason}</Text>
|
|
281
|
+
</Box>
|
|
282
|
+
)}
|
|
283
|
+
|
|
284
|
+
{!result.blocked && (
|
|
285
|
+
<>
|
|
286
|
+
<StatusLine label="Publish to npm" status={steps.publish} />
|
|
287
|
+
{steps.publish === 'done' && (
|
|
288
|
+
<Box marginLeft={4}>
|
|
289
|
+
{result.npmUrl ? (
|
|
290
|
+
<Text color="green">✓ <Text color="blue">{result.npmUrl}</Text></Text>
|
|
291
|
+
) : result.npmError ? (
|
|
292
|
+
<Text color="yellow">⚠ {result.npmError}</Text>
|
|
293
|
+
) : null}
|
|
294
|
+
</Box>
|
|
295
|
+
)}
|
|
296
|
+
<StatusLine label="Register on-chain" status={steps.register} detail={result.txHash?.slice(0, 16)} />
|
|
297
|
+
</>
|
|
298
|
+
)}
|
|
299
|
+
|
|
300
|
+
{result.reportURI && (
|
|
301
|
+
<Box flexDirection="column" marginLeft={1} marginTop={1}>
|
|
302
|
+
<Box>
|
|
303
|
+
<Text color="white" bold> Report: </Text>
|
|
304
|
+
{result.reportURI.startsWith('local://') ? (
|
|
305
|
+
<Text color="gray">(stored locally)</Text>
|
|
306
|
+
) : (
|
|
307
|
+
<Text color="green">✓ Uploaded to Fileverse</Text>
|
|
308
|
+
)}
|
|
309
|
+
</Box>
|
|
310
|
+
{!result.reportURI.startsWith('local://') && (
|
|
311
|
+
<Box marginLeft={2}>
|
|
312
|
+
<Text color="blue">{result.reportURI}</Text>
|
|
313
|
+
</Box>
|
|
314
|
+
)}
|
|
315
|
+
</Box>
|
|
316
|
+
)}
|
|
317
|
+
|
|
318
|
+
{error && <Text color="red">Error: {error}</Text>}
|
|
319
|
+
</Box>
|
|
320
|
+
);
|
|
321
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import { classifyRisk } from '@opm/core';
|
|
4
|
+
import type { AgentEntry } from '@opm/core';
|
|
5
|
+
|
|
6
|
+
const RISK_COLORS = { LOW: 'green', MEDIUM: 'yellow', HIGH: 'red', CRITICAL: 'redBright' } as const;
|
|
7
|
+
|
|
8
|
+
interface AgentScoresProps {
|
|
9
|
+
agents: AgentEntry[];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function AgentScores({ agents }: AgentScoresProps) {
|
|
13
|
+
return (
|
|
14
|
+
<Box flexDirection="column" marginLeft={2}>
|
|
15
|
+
<Text bold color="white"> Agent Scan Results</Text>
|
|
16
|
+
{agents.map((agent, i) => {
|
|
17
|
+
const level = classifyRisk(agent.result.risk_score);
|
|
18
|
+
const color = RISK_COLORS[level];
|
|
19
|
+
const connector = i === agents.length - 1 ? '└──' : '├──';
|
|
20
|
+
return (
|
|
21
|
+
<Box key={agent.agent_id}>
|
|
22
|
+
<Text color="gray">{connector} </Text>
|
|
23
|
+
<Text color="cyan">{agent.agent_id}</Text>
|
|
24
|
+
<Text color="gray"> ({agent.model}): </Text>
|
|
25
|
+
<Text color={color} bold>{agent.result.risk_score}/100</Text>
|
|
26
|
+
<Text color="gray"> - {agent.result.recommendation}</Text>
|
|
27
|
+
</Box>
|
|
28
|
+
);
|
|
29
|
+
})}
|
|
30
|
+
</Box>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import { truncateAddress } from '@opm/core';
|
|
4
|
+
import type { ENSProfile } from '../services/ens';
|
|
5
|
+
|
|
6
|
+
interface AuthorInfoProps {
|
|
7
|
+
address: string;
|
|
8
|
+
ensName?: string;
|
|
9
|
+
profile?: ENSProfile;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function AuthorInfo({ address, ensName, profile }: AuthorInfoProps) {
|
|
13
|
+
const displayName = ensName || profile?.name;
|
|
14
|
+
return (
|
|
15
|
+
<Box flexDirection="column" marginLeft={2}>
|
|
16
|
+
<Box>
|
|
17
|
+
<Text color="gray">Author: </Text>
|
|
18
|
+
{displayName ? (
|
|
19
|
+
<Text color="magenta" bold>{displayName}</Text>
|
|
20
|
+
) : (
|
|
21
|
+
<Text color="white">{truncateAddress(address)}</Text>
|
|
22
|
+
)}
|
|
23
|
+
{displayName && <Text color="gray"> ({truncateAddress(address)})</Text>}
|
|
24
|
+
</Box>
|
|
25
|
+
{profile?.github && (
|
|
26
|
+
<Box>
|
|
27
|
+
<Text color="gray"> GitHub: </Text>
|
|
28
|
+
<Text color="blue">{profile.github}</Text>
|
|
29
|
+
</Box>
|
|
30
|
+
)}
|
|
31
|
+
{profile?.twitter && (
|
|
32
|
+
<Box>
|
|
33
|
+
<Text color="gray"> Twitter: </Text>
|
|
34
|
+
<Text color="blue">@{profile.twitter}</Text>
|
|
35
|
+
</Box>
|
|
36
|
+
)}
|
|
37
|
+
{profile?.url && (
|
|
38
|
+
<Box>
|
|
39
|
+
<Text color="gray"> Web: </Text>
|
|
40
|
+
<Text color="blue">{profile.url}</Text>
|
|
41
|
+
</Box>
|
|
42
|
+
)}
|
|
43
|
+
</Box>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
|
|
4
|
+
const LOGO = `
|
|
5
|
+
██████ ██████ ███ ███
|
|
6
|
+
██ ██ ██ ██ ████ ████
|
|
7
|
+
██ ██ ██████ ██ ████ ██
|
|
8
|
+
██ ██ ██ ██ ██ ██
|
|
9
|
+
██████ ██ ██ ██`;
|
|
10
|
+
|
|
11
|
+
interface HeaderProps {
|
|
12
|
+
subtitle?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function Header({ subtitle }: HeaderProps) {
|
|
16
|
+
return (
|
|
17
|
+
<Box flexDirection="column" marginBottom={1}>
|
|
18
|
+
<Text color="cyan" bold>{LOGO}</Text>
|
|
19
|
+
<Text color="gray"> On-chain Package Manager</Text>
|
|
20
|
+
{subtitle && <Text color="yellow"> {subtitle}</Text>}
|
|
21
|
+
<Text color="gray">{'─'.repeat(40)}</Text>
|
|
22
|
+
</Box>
|
|
23
|
+
);
|
|
24
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import { classifyRisk, truncateAddress } from '@opm/core';
|
|
4
|
+
import type { OnChainPackageInfo, ScanReport as ScanReportType } from '@opm/core';
|
|
5
|
+
import type { ENSProfile } from '../services/ens';
|
|
6
|
+
import { RiskBadge } from './RiskBadge';
|
|
7
|
+
import { AuthorInfo } from './AuthorInfo';
|
|
8
|
+
import { ScanReport } from './ScanReport';
|
|
9
|
+
|
|
10
|
+
interface PackageCardProps {
|
|
11
|
+
name: string;
|
|
12
|
+
version: string;
|
|
13
|
+
info: OnChainPackageInfo;
|
|
14
|
+
ensProfile?: ENSProfile;
|
|
15
|
+
report?: ScanReportType | null;
|
|
16
|
+
signatureValid?: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function PackageCard({ name, version, info, ensProfile, report, signatureValid }: PackageCardProps) {
|
|
20
|
+
const level = classifyRisk(info.aggregateScore);
|
|
21
|
+
return (
|
|
22
|
+
<Box flexDirection="column" borderStyle="round" borderColor="gray" paddingX={1} marginBottom={1}>
|
|
23
|
+
<Box>
|
|
24
|
+
<Text bold color="white">{name}</Text>
|
|
25
|
+
<Text color="gray">@{version}</Text>
|
|
26
|
+
</Box>
|
|
27
|
+
<Box marginTop={1}>
|
|
28
|
+
<Text color="gray">Risk: </Text>
|
|
29
|
+
<RiskBadge level={level} score={info.aggregateScore} />
|
|
30
|
+
</Box>
|
|
31
|
+
<Box flexDirection="column">
|
|
32
|
+
<Box>
|
|
33
|
+
<Text color="gray">Checksum: </Text>
|
|
34
|
+
<Text color="cyan">{truncateAddress(info.checksum)}</Text>
|
|
35
|
+
</Box>
|
|
36
|
+
<Box>
|
|
37
|
+
<Text color="gray">Signature: </Text>
|
|
38
|
+
<Text color="cyan">{truncateAddress(info.signature)}</Text>
|
|
39
|
+
<Text color={signatureValid ? 'green' : 'red'}>
|
|
40
|
+
{' '}{signatureValid ? '✓ verified' : '✗ unverified'}
|
|
41
|
+
</Text>
|
|
42
|
+
</Box>
|
|
43
|
+
</Box>
|
|
44
|
+
<AuthorInfo address={info.author} ensName={info.ensName} profile={ensProfile} />
|
|
45
|
+
<ScanReport report={report} reportURI={info.reportURI} />
|
|
46
|
+
</Box>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Text } from 'ink';
|
|
3
|
+
import type { RiskLevel } from '@opm/core';
|
|
4
|
+
|
|
5
|
+
const RISK_COLORS: Record<RiskLevel, string> = {
|
|
6
|
+
LOW: 'green',
|
|
7
|
+
MEDIUM: 'yellow',
|
|
8
|
+
HIGH: 'red',
|
|
9
|
+
CRITICAL: 'redBright',
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const RISK_ICONS: Record<RiskLevel, string> = {
|
|
13
|
+
LOW: '●',
|
|
14
|
+
MEDIUM: '▲',
|
|
15
|
+
HIGH: '✖',
|
|
16
|
+
CRITICAL: '⬤',
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
interface RiskBadgeProps {
|
|
20
|
+
level: RiskLevel;
|
|
21
|
+
score: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function RiskBadge({ level, score }: RiskBadgeProps) {
|
|
25
|
+
const color = RISK_COLORS[level];
|
|
26
|
+
return (
|
|
27
|
+
<Text>
|
|
28
|
+
<Text color={color} bold>{RISK_ICONS[level]} {level}</Text>
|
|
29
|
+
<Text color="gray"> ({score}/100)</Text>
|
|
30
|
+
</Text>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import type { ScanReport as ScanReportType } from '@opm/core';
|
|
4
|
+
|
|
5
|
+
interface ScanReportProps {
|
|
6
|
+
report?: ScanReportType | null;
|
|
7
|
+
reportURI?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function ScanReport({ report, reportURI }: ScanReportProps) {
|
|
11
|
+
if (!report && !reportURI) return null;
|
|
12
|
+
|
|
13
|
+
const totalVulns = report
|
|
14
|
+
? report.agents.reduce((sum, a) => sum + a.result.vulnerabilities.length, 0)
|
|
15
|
+
: 0;
|
|
16
|
+
|
|
17
|
+
const hasInstallScripts = report?.agents.some(
|
|
18
|
+
(a) => a.result.supply_chain_indicators.has_install_scripts,
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<Box flexDirection="column" marginLeft={2}>
|
|
23
|
+
<Text bold color="white"> Scan Report</Text>
|
|
24
|
+
{reportURI && (
|
|
25
|
+
<Box>
|
|
26
|
+
<Text color="gray"> Link: </Text>
|
|
27
|
+
<Text color="blue" underline>{reportURI}</Text>
|
|
28
|
+
</Box>
|
|
29
|
+
)}
|
|
30
|
+
{report && (
|
|
31
|
+
<>
|
|
32
|
+
<Box>
|
|
33
|
+
<Text color="gray"> Vulnerabilities found: </Text>
|
|
34
|
+
<Text color={totalVulns > 0 ? 'yellow' : 'green'}>{totalVulns}</Text>
|
|
35
|
+
</Box>
|
|
36
|
+
<Box>
|
|
37
|
+
<Text color="gray"> Install scripts: </Text>
|
|
38
|
+
<Text color={hasInstallScripts ? 'red' : 'green'}>
|
|
39
|
+
{hasInstallScripts ? 'YES' : 'none'}
|
|
40
|
+
</Text>
|
|
41
|
+
</Box>
|
|
42
|
+
<Box>
|
|
43
|
+
<Text color="gray"> Versions analyzed: </Text>
|
|
44
|
+
<Text>{report.versions_analyzed.join(', ')}</Text>
|
|
45
|
+
</Box>
|
|
46
|
+
</>
|
|
47
|
+
)}
|
|
48
|
+
</Box>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
|
|
4
|
+
export type Status = 'pending' | 'running' | 'done' | 'error' | 'skip' | 'blocked';
|
|
5
|
+
|
|
6
|
+
const STATUS_MAP: Record<Status, { icon: string; color: string }> = {
|
|
7
|
+
pending: { icon: '○', color: 'gray' },
|
|
8
|
+
running: { icon: '◌', color: 'yellow' },
|
|
9
|
+
done: { icon: '●', color: 'green' },
|
|
10
|
+
error: { icon: '✖', color: 'red' },
|
|
11
|
+
skip: { icon: '─', color: 'gray' },
|
|
12
|
+
blocked: { icon: '⊘', color: 'red' },
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
interface StatusLineProps {
|
|
16
|
+
label: string;
|
|
17
|
+
status: Status;
|
|
18
|
+
detail?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function StatusLine({ label, status, detail }: StatusLineProps) {
|
|
22
|
+
const { icon, color } = STATUS_MAP[status];
|
|
23
|
+
return (
|
|
24
|
+
<Box>
|
|
25
|
+
<Text color={color}>{icon} </Text>
|
|
26
|
+
<Text>{label}</Text>
|
|
27
|
+
{detail && <Text color="gray"> - {detail}</Text>}
|
|
28
|
+
</Box>
|
|
29
|
+
);
|
|
30
|
+
}
|