@pwddd/skills-scanner 1.0.0-beta.1

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.
@@ -0,0 +1,245 @@
1
+ /**
2
+ * API client module - direct HTTP calls to skill-scanner-api
3
+ */
4
+
5
+ import { createReadStream, createWriteStream, existsSync, readdirSync, statSync } from "node:fs";
6
+ import { join, basename, relative } from "node:path";
7
+ import { tmpdir } from "node:os";
8
+ import { pipeline } from "node:stream/promises";
9
+ import { createWriteStream as createZipStream } from "node:fs";
10
+ import archiver from "archiver";
11
+
12
+ export interface ScanResult {
13
+ skill_name: string;
14
+ is_safe: boolean;
15
+ max_severity: string;
16
+ findings_count: number;
17
+ findings?: Array<{
18
+ severity: string;
19
+ category: string;
20
+ description: string;
21
+ file?: string;
22
+ line?: number;
23
+ }>;
24
+ scan_time?: number;
25
+ error?: string;
26
+ }
27
+
28
+ export interface HealthResult {
29
+ status: "healthy" | "unhealthy";
30
+ version?: string;
31
+ analyzers_available?: string[];
32
+ error?: string;
33
+ }
34
+
35
+ export interface ScanOptions {
36
+ policy?: "strict" | "balanced" | "permissive";
37
+ useLLM?: boolean;
38
+ useBehavioral?: boolean;
39
+ useZipVirus?: boolean;
40
+ enableMeta?: boolean;
41
+ }
42
+
43
+ export class SkillScannerApiClient {
44
+ private baseUrl: string;
45
+ private timeout: number;
46
+
47
+ constructor(baseUrl: string, timeout = 180000) {
48
+ this.baseUrl = baseUrl.replace(/\/$/, "");
49
+ this.timeout = timeout;
50
+ }
51
+
52
+ /**
53
+ * Health check
54
+ */
55
+ async health(): Promise<HealthResult> {
56
+ try {
57
+ const response = await fetch(`${this.baseUrl}/health`, {
58
+ method: "GET",
59
+ signal: AbortSignal.timeout(5000),
60
+ });
61
+
62
+ if (!response.ok) {
63
+ return {
64
+ status: "unhealthy",
65
+ error: `HTTP ${response.status}: ${response.statusText}`,
66
+ };
67
+ }
68
+
69
+ const data = await response.json();
70
+ return {
71
+ status: "healthy",
72
+ version: data.version,
73
+ analyzers_available: data.analyzers_available,
74
+ };
75
+ } catch (err: any) {
76
+ return {
77
+ status: "unhealthy",
78
+ error: err.message || "Connection failed",
79
+ };
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Scan a local skill by uploading as ZIP
85
+ */
86
+ async scanUpload(skillPath: string, options: ScanOptions = {}): Promise<ScanResult> {
87
+ const zipPath = await this.createZip(skillPath);
88
+
89
+ try {
90
+ const formData = new FormData();
91
+ const zipBlob = await this.readFileAsBlob(zipPath);
92
+ formData.append("file", zipBlob, `${basename(skillPath)}.zip`);
93
+ formData.append("policy", options.policy || "balanced");
94
+ formData.append("use_llm", String(options.useLLM || false));
95
+ formData.append("use_behavioral", String(options.useBehavioral || false));
96
+ formData.append("use_zip_virus", String(options.useZipVirus !== false));
97
+ formData.append("enable_meta", String(options.enableMeta !== false));
98
+
99
+ const response = await fetch(`${this.baseUrl}/scan-upload`, {
100
+ method: "POST",
101
+ body: formData,
102
+ signal: AbortSignal.timeout(this.timeout),
103
+ });
104
+
105
+ if (!response.ok) {
106
+ const errorText = await response.text();
107
+ throw new Error(`HTTP ${response.status}: ${errorText}`);
108
+ }
109
+
110
+ return await response.json();
111
+ } finally {
112
+ // Cleanup temp zip
113
+ try {
114
+ const fs = await import("node:fs/promises");
115
+ await fs.unlink(zipPath);
116
+ } catch {}
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Scan a ClawHub skill by URL
122
+ */
123
+ async scanClawHub(clawhubUrl: string, options: ScanOptions = {}): Promise<ScanResult> {
124
+ const body = {
125
+ clawhub_url: clawhubUrl,
126
+ policy: options.policy || "balanced",
127
+ use_llm: options.useLLM || false,
128
+ use_behavioral: options.useBehavioral !== false, // default true for clawhub
129
+ llm_provider: "anthropic",
130
+ use_virustotal: false,
131
+ use_aidefense: false,
132
+ use_trigger: false,
133
+ use_zip_virus: options.useZipVirus !== false,
134
+ enable_meta: options.enableMeta !== false,
135
+ };
136
+
137
+ const response = await fetch(`${this.baseUrl}/scan-clawhub`, {
138
+ method: "POST",
139
+ headers: { "Content-Type": "application/json" },
140
+ body: JSON.stringify(body),
141
+ signal: AbortSignal.timeout(this.timeout),
142
+ });
143
+
144
+ if (!response.ok) {
145
+ const errorText = await response.text();
146
+ throw new Error(`HTTP ${response.status}: ${errorText}`);
147
+ }
148
+
149
+ return await response.json();
150
+ }
151
+
152
+ /**
153
+ * Create a ZIP file from a directory
154
+ */
155
+ private async createZip(sourceDir: string): Promise<string> {
156
+ const { mkdtemp, rm } = await import("node:fs/promises");
157
+ const tmpDir = await mkdtemp(join(tmpdir(), "skills-scanner-"));
158
+ const zipPath = join(tmpDir, `${basename(sourceDir)}.zip`);
159
+
160
+ return new Promise((resolve, reject) => {
161
+ const output = createZipStream(zipPath);
162
+ const archive = archiver("zip", { zlib: { level: 9 } });
163
+
164
+ output.on("close", () => resolve(zipPath));
165
+ archive.on("error", reject);
166
+
167
+ archive.pipe(output);
168
+
169
+ // Add all files from source directory
170
+ const addFiles = (dir: string, baseDir: string) => {
171
+ const entries = readdirSync(dir);
172
+ for (const entry of entries) {
173
+ const fullPath = join(dir, entry);
174
+ const stat = statSync(fullPath);
175
+
176
+ if (stat.isDirectory()) {
177
+ addFiles(fullPath, baseDir);
178
+ } else if (stat.isFile()) {
179
+ const relativePath = relative(baseDir, fullPath);
180
+ archive.file(fullPath, { name: join(basename(sourceDir), relativePath) });
181
+ }
182
+ }
183
+ };
184
+
185
+ addFiles(sourceDir, sourceDir);
186
+ archive.finalize();
187
+ });
188
+ }
189
+
190
+ /**
191
+ * Read file as Blob for FormData
192
+ */
193
+ private async readFileAsBlob(filePath: string): Promise<Blob> {
194
+ const { readFile } = await import("node:fs/promises");
195
+ const buffer = await readFile(filePath);
196
+ return new Blob([buffer], { type: "application/zip" });
197
+ }
198
+ }
199
+
200
+ /**
201
+ * Format scan result for display
202
+ */
203
+ export function formatScanResult(result: ScanResult, detailed = false): string {
204
+ const lines: string[] = [];
205
+ const statusIcon = result.is_safe ? "✅" : "❌";
206
+
207
+ lines.push(`${statusIcon} ${result.skill_name}`);
208
+ lines.push(` 严重性: ${result.max_severity}`);
209
+ lines.push(` 发现数: ${result.findings_count}`);
210
+
211
+ if (detailed && result.findings && result.findings.length > 0) {
212
+ lines.push("");
213
+ lines.push("发现详情:");
214
+ for (let i = 0; i < Math.min(result.findings.length, 10); i++) {
215
+ const finding = result.findings[i];
216
+ lines.push(` ${i + 1}. [${finding.severity}] ${finding.category}`);
217
+ lines.push(` ${finding.description}`);
218
+ if (finding.file) {
219
+ lines.push(` 文件: ${finding.file}${finding.line ? `:${finding.line}` : ""}`);
220
+ }
221
+ }
222
+
223
+ if (result.findings.length > 10) {
224
+ lines.push(` ... 还有 ${result.findings.length - 10} 条发现`);
225
+ }
226
+ }
227
+
228
+ return lines.join("\n");
229
+ }
230
+
231
+ /**
232
+ * Format health check result
233
+ */
234
+ export function formatHealthResult(result: HealthResult): string {
235
+ if (result.status === "healthy") {
236
+ const lines = ["✅ API 服务正常"];
237
+ if (result.version) lines.push(` 版本: ${result.version}`);
238
+ if (result.analyzers_available) {
239
+ lines.push(` 可用分析器: ${result.analyzers_available.join(", ")}`);
240
+ }
241
+ return lines.join("\n");
242
+ } else {
243
+ return `❌ API 服务不可用\n 错误: ${result.error || "未知错误"}`;
244
+ }
245
+ }
@@ -0,0 +1,241 @@
1
+ /**
2
+ * before_install hook handler
3
+ *
4
+ * 这个模块实现了关键的安全门禁,在 Skill/Plugin 安装前进行拦截。
5
+ */
6
+
7
+ import { runScan } from "./scanner.js";
8
+ import type { PluginLogger } from "./types.js";
9
+
10
+ export interface BeforeInstallEvent {
11
+ targetType: "skill" | "plugin";
12
+ targetName: string;
13
+ sourcePath: string;
14
+ sourcePathKind: "file" | "directory";
15
+ origin?: string;
16
+ request: {
17
+ kind: string;
18
+ mode: string;
19
+ requestedSpecifier?: string;
20
+ };
21
+ builtinScan: {
22
+ status: "ok" | "error";
23
+ scannedFiles: number;
24
+ critical: number;
25
+ warn: number;
26
+ info: number;
27
+ findings: Array<{
28
+ severity: "critical" | "warn" | "info";
29
+ ruleId: string;
30
+ file: string;
31
+ line: number;
32
+ message: string;
33
+ }>;
34
+ error?: string;
35
+ };
36
+ skill?: {
37
+ installId: string;
38
+ installSpec: any;
39
+ };
40
+ plugin?: {
41
+ pluginId: string;
42
+ contentType: "bundle" | "package" | "file";
43
+ packageName?: string;
44
+ manifestId?: string;
45
+ version?: string;
46
+ extensions: string[];
47
+ };
48
+ }
49
+
50
+ export interface BeforeInstallResult {
51
+ findings?: Array<{
52
+ severity: "critical" | "warn" | "info";
53
+ ruleId: string;
54
+ file: string;
55
+ line: number;
56
+ message: string;
57
+ }>;
58
+ block?: boolean;
59
+ blockReason?: string;
60
+ }
61
+
62
+ export interface BeforeInstallHandlerOptions {
63
+ apiUrl: string;
64
+ behavioral: boolean;
65
+ useLLM: boolean;
66
+ policy: "strict" | "balanced" | "permissive";
67
+ logger: PluginLogger;
68
+ enabled: boolean;
69
+ timeoutMs?: number; // Scan timeout in milliseconds (default: 30000)
70
+ }
71
+
72
+ /**
73
+ * 处理 before_install hook 事件(带超时控制)
74
+ *
75
+ * 这是关键的安全门禁,防止不安全的 Skills/Plugins 被安装。
76
+ * 在内置扫描器之后、安装继续之前运行。
77
+ */
78
+ export async function handleBeforeInstall(
79
+ event: BeforeInstallEvent,
80
+ options: BeforeInstallHandlerOptions
81
+ ): Promise<BeforeInstallResult> {
82
+ const timeoutMs = options.timeoutMs || 30000; // Default 30 seconds
83
+
84
+ // Wrap the scan in a timeout promise
85
+ return Promise.race([
86
+ performScan(event, options),
87
+ new Promise<BeforeInstallResult>((_, reject) =>
88
+ setTimeout(() => reject(new Error("Scan timeout")), timeoutMs)
89
+ ),
90
+ ]).catch((err: any) => {
91
+ options.logger.error("[before_install] Scan failed or timed out", {
92
+ error: err.message,
93
+ targetName: event.targetName,
94
+ timeoutMs,
95
+ });
96
+
97
+ // On timeout or error, allow installation but log warning
98
+ return {
99
+ block: false,
100
+ findings: [
101
+ {
102
+ severity: "warn",
103
+ ruleId: "scan-timeout",
104
+ file: event.sourcePath,
105
+ line: 0,
106
+ message: `扫描超时或失败: ${err.message}`,
107
+ },
108
+ ],
109
+ };
110
+ });
111
+ }
112
+
113
+ /**
114
+ * Perform the actual scan (extracted for timeout handling)
115
+ */
116
+ async function performScan(
117
+ event: BeforeInstallEvent,
118
+ options: BeforeInstallHandlerOptions
119
+ ): Promise<BeforeInstallResult> {
120
+ const { targetType, targetName, sourcePath, builtinScan } = event;
121
+ const { apiUrl, behavioral, useLLM, policy, logger, enabled } = options;
122
+
123
+ // 如果 hook 被禁用,跳过
124
+ if (!enabled) {
125
+ logger.debug(`[安装前拦截] Hook 已禁用,允许安装 ${targetName}`);
126
+ return { block: false };
127
+ }
128
+
129
+ // 只扫描 Skills(让内置扫描器处理 Plugins)
130
+ if (targetType !== "skill") {
131
+ logger.debug(`[安装前拦截] 跳过 ${targetType}: ${targetName}`);
132
+ return { block: false };
133
+ }
134
+
135
+ logger.info(`[安装前拦截] 🔍 拦截 Skill 安装: ${targetName}`);
136
+ logger.info(`[安装前拦截] 来源: ${sourcePath}`);
137
+ logger.info(`[安装前拦截] 内置扫描: ${builtinScan.status} (${builtinScan.critical} 严重, ${builtinScan.warn} 警告)`);
138
+
139
+ // 首先检查内置扫描结果
140
+ if (builtinScan.status === "error") {
141
+ logger.warn(`[安装前拦截] ❌ ${targetName} 内置扫描失败`);
142
+ return {
143
+ block: true,
144
+ blockReason: `内置安全扫描失败: ${builtinScan.error || "未知错误"}`
145
+ };
146
+ }
147
+
148
+ // 如果内置扫描发现严重问题,立即阻止
149
+ if (builtinScan.critical > 0) {
150
+ logger.warn(`[安装前拦截] ❌ 内置扫描发现 ${builtinScan.critical} 个严重问题`);
151
+ return {
152
+ block: true,
153
+ blockReason: `内置扫描器检测到 ${builtinScan.critical} 个严重安全问题`,
154
+ findings: builtinScan.findings
155
+ };
156
+ }
157
+
158
+ // 运行增强扫描
159
+ try {
160
+ logger.info(`[安装前拦截] 🔬 正在运行增强安全扫描...`);
161
+
162
+ const scanResult = await runScan("scan", sourcePath, {
163
+ behavioral,
164
+ useLLM,
165
+ policy,
166
+ apiUrl,
167
+ detailed: true
168
+ });
169
+
170
+ if (scanResult.exitCode === 0) {
171
+ logger.info(`[安装前拦截] ✅ ${targetName} 通过安全扫描`);
172
+
173
+ // 如果内置扫描有警告,添加信息性发现
174
+ if (builtinScan.warn > 0) {
175
+ return {
176
+ block: false,
177
+ findings: builtinScan.findings.filter(f => f.severity === "warn")
178
+ };
179
+ }
180
+
181
+ return { block: false };
182
+ }
183
+
184
+ // 扫描失败 - 阻止安装
185
+ const severity = scanResult.data?.max_severity || "未知";
186
+ const findingsCount = scanResult.data?.findings || 0;
187
+
188
+ logger.warn(`[安装前拦截] ❌ ${targetName} 未通过安全扫描`);
189
+ logger.warn(`[安装前拦截] 严重性: ${severity}, 发现数: ${findingsCount}`);
190
+
191
+ return {
192
+ block: true,
193
+ blockReason: [
194
+ `Skills Scanner 在 "${targetName}" 中检测到安全威胁`,
195
+ `严重性: ${severity}`,
196
+ `发现数: ${findingsCount}`,
197
+ ``,
198
+ `运行 '/skills-scanner scan ${sourcePath} --detailed' 查看详细信息。`
199
+ ].join("\n"),
200
+ findings: [{
201
+ severity: "critical",
202
+ ruleId: "skills-scanner-enhanced",
203
+ file: sourcePath,
204
+ line: 0,
205
+ message: `增强扫描检测到 ${findingsCount} 个安全问题,最高严重性: ${severity}`
206
+ }]
207
+ };
208
+
209
+ } catch (error: any) {
210
+ // 扫描因错误失败 - 根据策略决定
211
+ logger.error(`[安装前拦截] ⚠️ ${targetName} 扫描错误: ${error.message}`);
212
+
213
+ // 在严格模式下,扫描错误时阻止
214
+ if (policy === "strict") {
215
+ return {
216
+ block: true,
217
+ blockReason: `安全扫描失败: ${error.message}。由于严格策略,已阻止安装。`,
218
+ findings: [{
219
+ severity: "warn",
220
+ ruleId: "scan-error",
221
+ file: sourcePath,
222
+ line: 0,
223
+ message: `扫描错误: ${error.message}`
224
+ }]
225
+ };
226
+ }
227
+
228
+ // 在平衡/宽松模式下,允许但警告
229
+ logger.warn(`[安装前拦截] ⚠️ 尽管扫描错误,仍允许 ${targetName} (策略: ${policy})`);
230
+ return {
231
+ block: false,
232
+ findings: [{
233
+ severity: "warn",
234
+ ruleId: "scan-error",
235
+ file: sourcePath,
236
+ line: 0,
237
+ message: `安全扫描遇到错误: ${error.message}。请谨慎使用。`
238
+ }]
239
+ };
240
+ }
241
+ }
package/src/cache.ts ADDED
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Scan result cache module
3
+ *
4
+ * Caches scan results to avoid re-scanning unchanged files
5
+ */
6
+
7
+ import { createHash } from "node:crypto";
8
+ import { readFileSync, statSync } from "node:fs";
9
+
10
+ export interface CachedScanResult {
11
+ fileHash: string;
12
+ mtime: number;
13
+ isSafe: boolean;
14
+ severity: string;
15
+ findingsCount: number;
16
+ cachedAt: string;
17
+ }
18
+
19
+ export class ScanResultCache {
20
+ private cache = new Map<string, CachedScanResult>();
21
+ private maxEntries = 1000;
22
+ private maxAgeMs = 24 * 60 * 60 * 1000; // 24 hours
23
+
24
+ /**
25
+ * Get cached result if file hasn't changed
26
+ */
27
+ get(filePath: string): CachedScanResult | null {
28
+ try {
29
+ const cached = this.cache.get(filePath);
30
+ if (!cached) return null;
31
+
32
+ // Check if file has been modified
33
+ const stats = statSync(filePath);
34
+ if (stats.mtimeMs !== cached.mtime) {
35
+ this.cache.delete(filePath);
36
+ return null;
37
+ }
38
+
39
+ // Check if cache is too old
40
+ const age = Date.now() - new Date(cached.cachedAt).getTime();
41
+ if (age > this.maxAgeMs) {
42
+ this.cache.delete(filePath);
43
+ return null;
44
+ }
45
+
46
+ // Verify file hash
47
+ const currentHash = this.computeFileHash(filePath);
48
+ if (currentHash !== cached.fileHash) {
49
+ this.cache.delete(filePath);
50
+ return null;
51
+ }
52
+
53
+ return cached;
54
+ } catch {
55
+ return null;
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Store scan result in cache
61
+ */
62
+ set(
63
+ filePath: string,
64
+ result: {
65
+ isSafe: boolean;
66
+ severity: string;
67
+ findingsCount: number;
68
+ }
69
+ ): void {
70
+ try {
71
+ const stats = statSync(filePath);
72
+ const fileHash = this.computeFileHash(filePath);
73
+
74
+ this.cache.set(filePath, {
75
+ fileHash,
76
+ mtime: stats.mtimeMs,
77
+ isSafe: result.isSafe,
78
+ severity: result.severity,
79
+ findingsCount: result.findingsCount,
80
+ cachedAt: new Date().toISOString(),
81
+ });
82
+
83
+ this.pruneOldEntries();
84
+ } catch {
85
+ // Ignore cache errors
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Compute file hash for verification
91
+ */
92
+ private computeFileHash(filePath: string): string {
93
+ const content = readFileSync(filePath);
94
+ return createHash("sha256").update(content).digest("hex");
95
+ }
96
+
97
+ /**
98
+ * Remove old entries to prevent memory bloat
99
+ */
100
+ private pruneOldEntries(): void {
101
+ if (this.cache.size <= this.maxEntries) return;
102
+
103
+ // Sort by cachedAt and remove oldest entries
104
+ const entries = Array.from(this.cache.entries());
105
+ entries.sort((a, b) => {
106
+ const timeA = new Date(a[1].cachedAt).getTime();
107
+ const timeB = new Date(b[1].cachedAt).getTime();
108
+ return timeA - timeB;
109
+ });
110
+
111
+ const toRemove = entries.slice(0, entries.length - this.maxEntries);
112
+ for (const [path] of toRemove) {
113
+ this.cache.delete(path);
114
+ }
115
+ }
116
+
117
+ /**
118
+ * Clear all cached results
119
+ */
120
+ clear(): void {
121
+ this.cache.clear();
122
+ }
123
+
124
+ /**
125
+ * Get cache statistics
126
+ */
127
+ getStats(): {
128
+ size: number;
129
+ maxEntries: number;
130
+ maxAgeMs: number;
131
+ } {
132
+ return {
133
+ size: this.cache.size,
134
+ maxEntries: this.maxEntries,
135
+ maxAgeMs: this.maxAgeMs,
136
+ };
137
+ }
138
+ }