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,111 @@
|
|
|
1
|
+
import type { PackageMetadata, VersionHistoryEntry, SourceFile } from './types';
|
|
2
|
+
|
|
3
|
+
export const SYSTEM_PROMPT = `You are a security auditor for npm packages. Your job is to analyze package source code and version history to identify security risks, malicious patterns, and supply chain attack indicators.
|
|
4
|
+
|
|
5
|
+
You MUST respond with a valid JSON object matching this exact schema -- no markdown, no explanation outside the JSON:
|
|
6
|
+
|
|
7
|
+
{
|
|
8
|
+
"risk_score": <number 0-100>,
|
|
9
|
+
"risk_level": "<LOW | MEDIUM | HIGH | CRITICAL>",
|
|
10
|
+
"reasoning": "<string: 2-4 sentence summary of your overall security assessment>",
|
|
11
|
+
"vulnerabilities": [
|
|
12
|
+
{
|
|
13
|
+
"severity": "<LOW | MEDIUM | HIGH | CRITICAL>",
|
|
14
|
+
"category": "<string>",
|
|
15
|
+
"description": "<string: what the vulnerability is>",
|
|
16
|
+
"file": "<string: file path where found>",
|
|
17
|
+
"evidence": "<string: relevant code snippet or pattern>"
|
|
18
|
+
}
|
|
19
|
+
],
|
|
20
|
+
"supply_chain_indicators": {
|
|
21
|
+
"has_install_scripts": <boolean>,
|
|
22
|
+
"has_native_bindings": <boolean>,
|
|
23
|
+
"has_obfuscated_code": <boolean>,
|
|
24
|
+
"has_network_calls": <boolean>,
|
|
25
|
+
"has_filesystem_access": <boolean>,
|
|
26
|
+
"has_process_spawn": <boolean>,
|
|
27
|
+
"has_eval_usage": <boolean>,
|
|
28
|
+
"accesses_env_variables": <boolean>
|
|
29
|
+
},
|
|
30
|
+
"version_analysis": {
|
|
31
|
+
"version_reviewed": "<string: current version>",
|
|
32
|
+
"previous_versions_reviewed": ["<string>"],
|
|
33
|
+
"changelog_risk": "<NONE | LOW | MEDIUM | HIGH>",
|
|
34
|
+
"changelog_reasoning": "<string>"
|
|
35
|
+
},
|
|
36
|
+
"recommendation": "<SAFE | CAUTION | WARN | BLOCK>"
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
Risk score guidelines:
|
|
40
|
+
- 0-20: Clean, well-structured code with no concerning patterns
|
|
41
|
+
- 21-40: Minor concerns but likely benign
|
|
42
|
+
- 41-70: Moderate risk, suspicious patterns that warrant manual review
|
|
43
|
+
- 71-100: High/critical risk, likely malicious or extremely dangerous
|
|
44
|
+
|
|
45
|
+
Focus on these attack vectors:
|
|
46
|
+
1. Postinstall/preinstall scripts that execute arbitrary code
|
|
47
|
+
2. Code that exfiltrates environment variables, credentials, or filesystem data
|
|
48
|
+
3. Obfuscated or encoded payloads (base64, hex-encoded strings executed at runtime)
|
|
49
|
+
4. Network requests to suspicious or hardcoded external endpoints
|
|
50
|
+
5. Prototype pollution or injection vulnerabilities
|
|
51
|
+
6. Typosquatting indicators (name similarity to popular packages)
|
|
52
|
+
7. Sudden large changes between versions (new maintainer, scope change)
|
|
53
|
+
8. Dependency confusion patterns (scoped vs unscoped name conflicts)`;
|
|
54
|
+
|
|
55
|
+
export interface KnownCVE {
|
|
56
|
+
id: string;
|
|
57
|
+
summary: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function buildUserPrompt(
|
|
61
|
+
meta: PackageMetadata,
|
|
62
|
+
versionHistory: VersionHistoryEntry[],
|
|
63
|
+
sourceFiles: SourceFile[],
|
|
64
|
+
knownCVEs?: KnownCVE[],
|
|
65
|
+
): string {
|
|
66
|
+
const depsStr = Object.entries(meta.dependencies || {})
|
|
67
|
+
.map(([k, v]) => `${k}@${v}`)
|
|
68
|
+
.join(', ') || 'none';
|
|
69
|
+
|
|
70
|
+
const scriptsStr = ['preinstall', 'postinstall', 'prepare']
|
|
71
|
+
.map((s) => `${s}: ${meta.scripts?.[s] || 'none'}`)
|
|
72
|
+
.join(', ');
|
|
73
|
+
|
|
74
|
+
const historyStr = versionHistory
|
|
75
|
+
.map(
|
|
76
|
+
(v) =>
|
|
77
|
+
`- ${v.version} (published: ${v.published}):\n` +
|
|
78
|
+
` Dependencies changed: ${v.depsChanged}\n` +
|
|
79
|
+
` Files changed: ${v.filesChanged}\n` +
|
|
80
|
+
` Size delta: ${v.sizeDelta}\n` +
|
|
81
|
+
` New maintainer: ${v.newMaintainer}`,
|
|
82
|
+
)
|
|
83
|
+
.join('\n');
|
|
84
|
+
|
|
85
|
+
const codeStr = sourceFiles
|
|
86
|
+
.map((f) => `### File: ${f.path} (${f.size} bytes)\n\`\`\`\n${f.content}\n\`\`\``)
|
|
87
|
+
.join('\n\n');
|
|
88
|
+
|
|
89
|
+
const cveStr = knownCVEs && knownCVEs.length > 0
|
|
90
|
+
? `## Known Vulnerabilities (from OSV/GitHub Advisory Database)\n${knownCVEs.map((c) => `- ${c.id}: ${c.summary}`).join('\n')}\n`
|
|
91
|
+
: '';
|
|
92
|
+
|
|
93
|
+
return `Analyze this npm package for security risks.
|
|
94
|
+
|
|
95
|
+
## Package Metadata
|
|
96
|
+
- Name: ${meta.name}
|
|
97
|
+
- Version: ${meta.version}
|
|
98
|
+
- Description: ${meta.description || 'none'}
|
|
99
|
+
- Author: ${meta.author || 'unknown'}
|
|
100
|
+
- License: ${meta.license || 'none'}
|
|
101
|
+
- Dependencies: ${depsStr}
|
|
102
|
+
- Install scripts: ${scriptsStr}
|
|
103
|
+
|
|
104
|
+
## Version History (last 3 versions)
|
|
105
|
+
${historyStr || 'No previous versions available.'}
|
|
106
|
+
|
|
107
|
+
${cveStr}## Source Code
|
|
108
|
+
${codeStr || 'No source files found.'}
|
|
109
|
+
|
|
110
|
+
Analyze this package thoroughly and respond with the JSON schema specified in your system instructions.`;
|
|
111
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
export type RiskLevel = 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL';
|
|
2
|
+
export type Recommendation = 'SAFE' | 'CAUTION' | 'WARN' | 'BLOCK';
|
|
3
|
+
export type ChangelogRisk = 'NONE' | 'LOW' | 'MEDIUM' | 'HIGH';
|
|
4
|
+
|
|
5
|
+
export interface Vulnerability {
|
|
6
|
+
severity: RiskLevel;
|
|
7
|
+
category: string;
|
|
8
|
+
description: string;
|
|
9
|
+
file: string;
|
|
10
|
+
evidence: string;
|
|
11
|
+
cve_id?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface SupplyChainIndicators {
|
|
15
|
+
has_install_scripts: boolean;
|
|
16
|
+
has_native_bindings: boolean;
|
|
17
|
+
has_obfuscated_code: boolean;
|
|
18
|
+
has_network_calls: boolean;
|
|
19
|
+
has_filesystem_access: boolean;
|
|
20
|
+
has_process_spawn: boolean;
|
|
21
|
+
has_eval_usage: boolean;
|
|
22
|
+
accesses_env_variables: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface VersionAnalysis {
|
|
26
|
+
version_reviewed: string;
|
|
27
|
+
previous_versions_reviewed: string[];
|
|
28
|
+
changelog_risk: ChangelogRisk;
|
|
29
|
+
changelog_reasoning: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface AgentScanResult {
|
|
33
|
+
risk_score: number;
|
|
34
|
+
risk_level: RiskLevel;
|
|
35
|
+
reasoning: string;
|
|
36
|
+
vulnerabilities: Vulnerability[];
|
|
37
|
+
supply_chain_indicators: SupplyChainIndicators;
|
|
38
|
+
version_analysis: VersionAnalysis;
|
|
39
|
+
recommendation: Recommendation;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface AgentEntry {
|
|
43
|
+
agent_id: string;
|
|
44
|
+
model: string;
|
|
45
|
+
result: AgentScanResult;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface ScanReport {
|
|
49
|
+
package: string;
|
|
50
|
+
version: string;
|
|
51
|
+
scan_timestamp: string;
|
|
52
|
+
agents: AgentEntry[];
|
|
53
|
+
aggregate_risk_score: number;
|
|
54
|
+
consensus: RiskLevel;
|
|
55
|
+
versions_analyzed: string[];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface PackageMetadata {
|
|
59
|
+
name: string;
|
|
60
|
+
version: string;
|
|
61
|
+
description: string;
|
|
62
|
+
author: string;
|
|
63
|
+
license: string;
|
|
64
|
+
dependencies: Record<string, string>;
|
|
65
|
+
scripts: Record<string, string>;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface VersionHistoryEntry {
|
|
69
|
+
version: string;
|
|
70
|
+
published: string;
|
|
71
|
+
depsChanged: string;
|
|
72
|
+
filesChanged: string;
|
|
73
|
+
sizeDelta: string;
|
|
74
|
+
newMaintainer: boolean;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface SourceFile {
|
|
78
|
+
path: string;
|
|
79
|
+
size: number;
|
|
80
|
+
content: string;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface AuthorProfile {
|
|
84
|
+
addr: string;
|
|
85
|
+
ensName: string;
|
|
86
|
+
reputationScore: number;
|
|
87
|
+
packagesPublished: number;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface OnChainPackageInfo {
|
|
91
|
+
author: string;
|
|
92
|
+
checksum: string;
|
|
93
|
+
signature: string;
|
|
94
|
+
ensName: string;
|
|
95
|
+
reportURI: string;
|
|
96
|
+
scores: Array<{ agent: string; riskScore: number; reasoning: string }>;
|
|
97
|
+
aggregateScore: number;
|
|
98
|
+
exists: boolean;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export interface ChainPatrolResult {
|
|
102
|
+
status: 'UNKNOWN' | 'ALLOWED' | 'BLOCKED';
|
|
103
|
+
source: string;
|
|
104
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { HIGH_RISK_THRESHOLD, MEDIUM_RISK_THRESHOLD } from './constants';
|
|
2
|
+
import type { RiskLevel, AgentScanResult } from './types';
|
|
3
|
+
|
|
4
|
+
export function classifyRisk(score: number): RiskLevel {
|
|
5
|
+
if (score >= HIGH_RISK_THRESHOLD) return 'HIGH';
|
|
6
|
+
if (score >= MEDIUM_RISK_THRESHOLD) return 'MEDIUM';
|
|
7
|
+
return 'LOW';
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function averageScores(scores: number[]): number {
|
|
11
|
+
if (scores.length === 0) return 0;
|
|
12
|
+
return Math.round(scores.reduce((a, b) => a + b, 0) / scores.length);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function truncateAddress(addr: string): string {
|
|
16
|
+
if (addr.length <= 10) return addr;
|
|
17
|
+
return `${addr.slice(0, 6)}...${addr.slice(-4)}`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function getEnvOrThrow(key: string): string {
|
|
21
|
+
const val = process.env[key];
|
|
22
|
+
if (!val) throw new Error(`Missing required env var: ${key}`);
|
|
23
|
+
return val;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function getEnvOrDefault(key: string, fallback: string): string {
|
|
27
|
+
return process.env[key] || fallback;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function validateScanResult(obj: unknown): obj is AgentScanResult {
|
|
31
|
+
if (!obj || typeof obj !== 'object') return false;
|
|
32
|
+
const o = obj as Record<string, unknown>;
|
|
33
|
+
return (
|
|
34
|
+
typeof o.risk_score === 'number' &&
|
|
35
|
+
typeof o.risk_level === 'string' &&
|
|
36
|
+
typeof o.reasoning === 'string' &&
|
|
37
|
+
Array.isArray(o.vulnerabilities) &&
|
|
38
|
+
typeof o.supply_chain_indicators === 'object' &&
|
|
39
|
+
typeof o.version_analysis === 'object' &&
|
|
40
|
+
typeof o.recommendation === 'string'
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function safeJsonParse<T>(raw: string): T | null {
|
|
45
|
+
try {
|
|
46
|
+
return JSON.parse(raw) as T;
|
|
47
|
+
} catch {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { OPENROUTER_MODELS, OPENAI_MODELS } from '@opm/core';
|
|
2
|
+
import { getEnvOrDefault } from '@opm/core';
|
|
3
|
+
import { getLLMProvider } from '../services/openrouter';
|
|
4
|
+
import type { AgentConfig } from './base-agent';
|
|
5
|
+
|
|
6
|
+
export function getAgentConfigs(): AgentConfig[] {
|
|
7
|
+
const provider = getLLMProvider();
|
|
8
|
+
const defaults = provider === 'openai' ? OPENAI_MODELS : OPENROUTER_MODELS;
|
|
9
|
+
|
|
10
|
+
return [
|
|
11
|
+
{
|
|
12
|
+
agentId: `agent-1`,
|
|
13
|
+
model: getEnvOrDefault('AGENT1_MODEL', defaults.agent1),
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
agentId: `agent-2`,
|
|
17
|
+
model: getEnvOrDefault('AGENT2_MODEL', defaults.agent2),
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
agentId: `agent-3`,
|
|
21
|
+
model: getEnvOrDefault('AGENT3_MODEL', defaults.agent3),
|
|
22
|
+
},
|
|
23
|
+
];
|
|
24
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { SYSTEM_PROMPT, buildUserPrompt } from '@opm/core';
|
|
2
|
+
import type { AgentEntry, KnownCVE } from '@opm/core';
|
|
3
|
+
import {
|
|
4
|
+
fetchPackageData, buildLocalPackageData, extractMetadata,
|
|
5
|
+
buildVersionHistory, fetchSourceFiles, extractLocalSourceFiles,
|
|
6
|
+
type NpmPackageData,
|
|
7
|
+
} from '../services/npm-registry';
|
|
8
|
+
import { callLLM } from '../services/openrouter';
|
|
9
|
+
import { submitScoreOnChain } from '../services/contract-writer';
|
|
10
|
+
import { queryOSV } from '../services/osv';
|
|
11
|
+
|
|
12
|
+
export interface AgentConfig {
|
|
13
|
+
agentId: string;
|
|
14
|
+
model: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface LocalScanContext {
|
|
18
|
+
tarballPath: string;
|
|
19
|
+
pkgJsonPath: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function runAgent(
|
|
23
|
+
config: AgentConfig,
|
|
24
|
+
packageName: string,
|
|
25
|
+
version: string,
|
|
26
|
+
onStatus?: (msg: string) => void,
|
|
27
|
+
local?: LocalScanContext,
|
|
28
|
+
): Promise<AgentEntry> {
|
|
29
|
+
const log = onStatus || console.log;
|
|
30
|
+
|
|
31
|
+
log(`[${config.agentId}] Fetching package data...`);
|
|
32
|
+
let data: NpmPackageData;
|
|
33
|
+
let sourceFiles;
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
data = await fetchPackageData(packageName);
|
|
37
|
+
const tarballUrl = data.versions[version]?.dist?.tarball;
|
|
38
|
+
if (!tarballUrl) throw new Error(`Version ${version} not on npm`);
|
|
39
|
+
log(`[${config.agentId}] Downloading source from npm...`);
|
|
40
|
+
sourceFiles = await fetchSourceFiles(packageName, version, tarballUrl);
|
|
41
|
+
} catch {
|
|
42
|
+
if (!local) throw new Error(`${packageName}@${version} not found on npm and no local tarball provided`);
|
|
43
|
+
log(`[${config.agentId}] Using local tarball...`);
|
|
44
|
+
data = buildLocalPackageData(local.pkgJsonPath);
|
|
45
|
+
sourceFiles = await extractLocalSourceFiles(local.tarballPath);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const meta = extractMetadata(data, version);
|
|
49
|
+
const history = buildVersionHistory(data, version);
|
|
50
|
+
|
|
51
|
+
log(`[${config.agentId}] Querying CVE database (OSV)...`);
|
|
52
|
+
const osvVulns = await queryOSV(packageName, version);
|
|
53
|
+
const knownCVEs: KnownCVE[] = osvVulns.map((v) => ({ id: v.id, summary: v.summary }));
|
|
54
|
+
if (knownCVEs.length > 0) {
|
|
55
|
+
log(`[${config.agentId}] Found ${knownCVEs.length} known CVE(s)`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
log(`[${config.agentId}] Analyzing with ${config.model} (${sourceFiles.length} files)...`);
|
|
59
|
+
const userPrompt = buildUserPrompt(meta, history, sourceFiles, knownCVEs);
|
|
60
|
+
const result = await callLLM(config.model, SYSTEM_PROMPT, userPrompt);
|
|
61
|
+
|
|
62
|
+
log(`[${config.agentId}] Submitting score (${result.risk_score}) to contract...`);
|
|
63
|
+
try {
|
|
64
|
+
await submitScoreOnChain(packageName, version, result.risk_score, result.reasoning);
|
|
65
|
+
log(`[${config.agentId}] Score submitted on-chain`);
|
|
66
|
+
} catch (err: any) {
|
|
67
|
+
log(`[${config.agentId}] On-chain: ${err?.shortMessage || err?.message || 'failed'}`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
agent_id: config.agentId,
|
|
72
|
+
model: config.model,
|
|
73
|
+
result,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { enqueueScan } from './queue/memory-queue';
|
|
2
|
+
|
|
3
|
+
export { enqueueScan } from './queue/memory-queue';
|
|
4
|
+
export type { LocalScanContext } from './agents/base-agent';
|
|
5
|
+
export { runAgent } from './agents/base-agent';
|
|
6
|
+
export { getAgentConfigs } from './agents/agent-configs';
|
|
7
|
+
export { callLLM, getLLMProvider } from './services/openrouter';
|
|
8
|
+
export { fetchPackageData, extractMetadata, buildVersionHistory, fetchSourceFiles, extractLocalSourceFiles, buildLocalPackageData } from './services/npm-registry';
|
|
9
|
+
export { submitScoreOnChain, setReportURIOnChain } from './services/contract-writer';
|
|
10
|
+
export { uploadReportToFileverse, fetchReportFromFileverse } from './services/fileverse';
|
|
11
|
+
|
|
12
|
+
if (import.meta.main) {
|
|
13
|
+
const [pkg, ver] = process.argv.slice(2);
|
|
14
|
+
if (!pkg || !ver) {
|
|
15
|
+
console.error('Usage: bun run packages/scanner/src/index.ts <package> <version>');
|
|
16
|
+
process.exit(1);
|
|
17
|
+
}
|
|
18
|
+
enqueueScan(pkg, ver).then((r) => {
|
|
19
|
+
console.log(`Scan complete. Risk: ${r.report.aggregate_risk_score} (${r.report.consensus})`);
|
|
20
|
+
console.log(`Report: ${r.reportURI}`);
|
|
21
|
+
}).catch((err) => {
|
|
22
|
+
console.error('Scan failed:', err);
|
|
23
|
+
process.exit(1);
|
|
24
|
+
});
|
|
25
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type { ScanReport, AgentEntry } from '@opm/core';
|
|
2
|
+
import { averageScores, classifyRisk } from '@opm/core';
|
|
3
|
+
import { runAgent, type LocalScanContext } from '../agents/base-agent';
|
|
4
|
+
import { getAgentConfigs } from '../agents/agent-configs';
|
|
5
|
+
import { setReportURIOnChain } from '../services/contract-writer';
|
|
6
|
+
import { uploadReportToFileverse } from '../services/fileverse';
|
|
7
|
+
|
|
8
|
+
export interface ScanJobResult {
|
|
9
|
+
report: ScanReport;
|
|
10
|
+
reportURI: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const activeJobs = new Map<string, Promise<ScanJobResult>>();
|
|
14
|
+
|
|
15
|
+
export async function enqueueScan(
|
|
16
|
+
packageName: string,
|
|
17
|
+
version: string,
|
|
18
|
+
onStatus?: (msg: string) => void,
|
|
19
|
+
local?: LocalScanContext,
|
|
20
|
+
): Promise<ScanJobResult> {
|
|
21
|
+
const key = `${packageName}@${version}`;
|
|
22
|
+
const existing = activeJobs.get(key);
|
|
23
|
+
if (existing) return existing;
|
|
24
|
+
|
|
25
|
+
const promise = executeScan(packageName, version, onStatus, local);
|
|
26
|
+
activeJobs.set(key, promise);
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
return await promise;
|
|
30
|
+
} finally {
|
|
31
|
+
activeJobs.delete(key);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function executeScan(
|
|
36
|
+
packageName: string,
|
|
37
|
+
version: string,
|
|
38
|
+
onStatus?: (msg: string) => void,
|
|
39
|
+
local?: LocalScanContext,
|
|
40
|
+
): Promise<ScanJobResult> {
|
|
41
|
+
const log = onStatus || console.log;
|
|
42
|
+
const configs = getAgentConfigs();
|
|
43
|
+
|
|
44
|
+
log('Starting parallel agent scans...');
|
|
45
|
+
const results = await Promise.allSettled(
|
|
46
|
+
configs.map((cfg) => runAgent(cfg, packageName, version, onStatus, local)),
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
const agents: AgentEntry[] = [];
|
|
50
|
+
for (const r of results) {
|
|
51
|
+
if (r.status === 'fulfilled') {
|
|
52
|
+
agents.push(r.value);
|
|
53
|
+
} else {
|
|
54
|
+
log(`Agent failed: ${r.reason}`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (agents.length === 0) throw new Error('All agents failed');
|
|
59
|
+
|
|
60
|
+
const scores = agents.map((a) => a.result.risk_score);
|
|
61
|
+
const aggScore = averageScores(scores);
|
|
62
|
+
|
|
63
|
+
const report: ScanReport = {
|
|
64
|
+
package: packageName,
|
|
65
|
+
version,
|
|
66
|
+
scan_timestamp: new Date().toISOString(),
|
|
67
|
+
agents,
|
|
68
|
+
aggregate_risk_score: aggScore,
|
|
69
|
+
consensus: classifyRisk(aggScore),
|
|
70
|
+
versions_analyzed: [version],
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
log('Uploading report to Fileverse...');
|
|
74
|
+
let reportURI: string;
|
|
75
|
+
try {
|
|
76
|
+
reportURI = await uploadReportToFileverse(report);
|
|
77
|
+
log(`Report uploaded: ${reportURI}`);
|
|
78
|
+
} catch (err) {
|
|
79
|
+
log(`Fileverse upload failed: ${err}`);
|
|
80
|
+
reportURI = `local://report-${packageName}-${version}`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
await setReportURIOnChain(packageName, version, reportURI);
|
|
85
|
+
log('Report URI stored on-chain');
|
|
86
|
+
} catch (err: any) {
|
|
87
|
+
log(`On-chain report URI: ${err?.shortMessage || err?.message || 'failed'}`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return { report, reportURI };
|
|
91
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { ethers } from 'ethers';
|
|
2
|
+
import { OPM_REGISTRY_ABI, getEnvOrThrow, getEnvOrDefault, BASE_SEPOLIA_RPC } from '@opm/core';
|
|
3
|
+
|
|
4
|
+
function getContract() {
|
|
5
|
+
const rpc = getEnvOrDefault('BASE_SEPOLIA_RPC_URL', BASE_SEPOLIA_RPC);
|
|
6
|
+
const provider = new ethers.JsonRpcProvider(rpc);
|
|
7
|
+
const wallet = new ethers.Wallet(getEnvOrThrow('AGENT_PRIVATE_KEY'), provider);
|
|
8
|
+
const address = getEnvOrThrow('CONTRACT_ADDRESS');
|
|
9
|
+
return new ethers.Contract(address, OPM_REGISTRY_ABI, wallet);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function submitScoreOnChain(
|
|
13
|
+
packageName: string,
|
|
14
|
+
version: string,
|
|
15
|
+
riskScore: number,
|
|
16
|
+
reasoning: string,
|
|
17
|
+
): Promise<string> {
|
|
18
|
+
const contract = getContract();
|
|
19
|
+
const truncated = reasoning.slice(0, 256);
|
|
20
|
+
const tx = await contract.submitScore(packageName, version, riskScore, truncated);
|
|
21
|
+
const receipt = await tx.wait();
|
|
22
|
+
return receipt.hash;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function setReportURIOnChain(
|
|
26
|
+
packageName: string,
|
|
27
|
+
version: string,
|
|
28
|
+
uri: string,
|
|
29
|
+
): Promise<string> {
|
|
30
|
+
const contract = getContract();
|
|
31
|
+
const tx = await contract.setReportURI(packageName, version, uri);
|
|
32
|
+
const receipt = await tx.wait();
|
|
33
|
+
return receipt.hash;
|
|
34
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type { ScanReport } from '@opm/core';
|
|
2
|
+
import { getEnvOrDefault } from '@opm/core';
|
|
3
|
+
import { formatReportAsMarkdown } from './report-formatter';
|
|
4
|
+
|
|
5
|
+
const DEFAULT_API_URL = 'http://localhost:8001';
|
|
6
|
+
const POLL_INTERVAL_MS = 3000;
|
|
7
|
+
const POLL_TIMEOUT_MS = 60_000;
|
|
8
|
+
|
|
9
|
+
function getApiConfig() {
|
|
10
|
+
const apiUrl = getEnvOrDefault('FILEVERSE_API_URL', DEFAULT_API_URL);
|
|
11
|
+
const apiKey = process.env.FILEVERSE_API_KEY;
|
|
12
|
+
if (!apiKey) throw new Error('FILEVERSE_API_KEY is required (generate at ddocs.new → Settings → Developer Mode)');
|
|
13
|
+
return { apiUrl, apiKey };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function uploadReportToFileverse(report: ScanReport): Promise<string> {
|
|
17
|
+
const { apiUrl, apiKey } = getApiConfig();
|
|
18
|
+
|
|
19
|
+
const title = `OPM Security Report: ${report.package}@${report.version}`;
|
|
20
|
+
const content = formatReportAsMarkdown(report);
|
|
21
|
+
|
|
22
|
+
const res = await fetch(`${apiUrl}/api/ddocs?apiKey=${encodeURIComponent(apiKey)}`, {
|
|
23
|
+
method: 'POST',
|
|
24
|
+
headers: { 'Content-Type': 'application/json' },
|
|
25
|
+
body: JSON.stringify({ title, content }),
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
if (!res.ok) {
|
|
29
|
+
const body = await res.text();
|
|
30
|
+
throw new Error(`Fileverse create failed (${res.status}): ${body}`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const { data } = await res.json() as { data: { ddocId: string; syncStatus: string; link?: string } };
|
|
34
|
+
const ddocId = data.ddocId;
|
|
35
|
+
|
|
36
|
+
if (data.syncStatus === 'synced' && data.link) return data.link;
|
|
37
|
+
|
|
38
|
+
const link = await pollForSync(apiUrl, apiKey, ddocId);
|
|
39
|
+
return link;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function pollForSync(apiUrl: string, apiKey: string, ddocId: string): Promise<string> {
|
|
43
|
+
const start = Date.now();
|
|
44
|
+
|
|
45
|
+
while (Date.now() - start < POLL_TIMEOUT_MS) {
|
|
46
|
+
await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
|
|
47
|
+
|
|
48
|
+
const res = await fetch(`${apiUrl}/api/ddocs/${ddocId}?apiKey=${encodeURIComponent(apiKey)}`);
|
|
49
|
+
if (!res.ok) continue;
|
|
50
|
+
|
|
51
|
+
const doc = await res.json() as { syncStatus: string; link?: string };
|
|
52
|
+
if (doc.syncStatus === 'synced' && doc.link) return doc.link;
|
|
53
|
+
if (doc.syncStatus === 'failed') throw new Error('Fileverse blockchain sync failed');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return `https://ddocs.new/pending/${ddocId}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function fetchReportFromFileverse(reportURI: string): Promise<ScanReport | null> {
|
|
60
|
+
if (!reportURI || reportURI.startsWith('local://')) return null;
|
|
61
|
+
|
|
62
|
+
const apiKey = process.env.FILEVERSE_API_KEY;
|
|
63
|
+
const apiUrl = getEnvOrDefault('FILEVERSE_API_URL', DEFAULT_API_URL);
|
|
64
|
+
|
|
65
|
+
const ddocId = extractDdocId(reportURI);
|
|
66
|
+
if (ddocId && apiKey) {
|
|
67
|
+
try {
|
|
68
|
+
const res = await fetch(`${apiUrl}/api/ddocs/${ddocId}?apiKey=${encodeURIComponent(apiKey)}`);
|
|
69
|
+
if (res.ok) {
|
|
70
|
+
const doc = await res.json() as { content: string };
|
|
71
|
+
return JSON.parse(doc.content) as ScanReport;
|
|
72
|
+
}
|
|
73
|
+
} catch { /* fall through */ }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function extractDdocId(link: string): string | null {
|
|
80
|
+
try {
|
|
81
|
+
const url = new URL(link);
|
|
82
|
+
const parts = url.pathname.split('/');
|
|
83
|
+
const dIdx = parts.indexOf('d');
|
|
84
|
+
if (dIdx >= 0 && parts[dIdx + 1]) return parts[dIdx + 1];
|
|
85
|
+
const pendingIdx = parts.indexOf('pending');
|
|
86
|
+
if (pendingIdx >= 0 && parts[pendingIdx + 1]) return parts[pendingIdx + 1];
|
|
87
|
+
} catch { /* not a URL */ }
|
|
88
|
+
return null;
|
|
89
|
+
}
|