@denial-web/clawguard 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/.clawguard.example.json +16 -0
- package/LICENSE +21 -0
- package/README.md +241 -0
- package/SECURITY.md +33 -0
- package/action.yml +72 -0
- package/docs/ARCHITECTURE.md +312 -0
- package/docs/ARCHITECTURE_ROADMAP.md +267 -0
- package/docs/CLAWHUB_METADATA.md +57 -0
- package/docs/DEMO_CAPTURE.md +25 -0
- package/docs/DEMO_SCRIPT.md +87 -0
- package/docs/DEPENDENCY_SCANNING.md +61 -0
- package/docs/GITHUB_ACTION.md +56 -0
- package/docs/GITHUB_REPO_SETUP.md +76 -0
- package/docs/HTML_REPORTS.md +27 -0
- package/docs/INTEGRATION_SPEC.md +253 -0
- package/docs/LAUNCH_CHECKLIST.md +64 -0
- package/docs/LAUNCH_PLAN.md +40 -0
- package/docs/LOCAL_PROJECT_ASSETS.md +250 -0
- package/docs/MCP_PLUGIN_SCANNING.md +53 -0
- package/docs/NEXT_SESSION.md +110 -0
- package/docs/NPM_PUBLISHING.md +66 -0
- package/docs/OPENCLAW_CLAWHUB_RESEARCH.md +128 -0
- package/docs/POLICY_MODEL.md +198 -0
- package/docs/PROJECT_REVIEW.md +108 -0
- package/docs/REAL_WORLD_VALIDATION.md +57 -0
- package/docs/RELEASE_NOTES_v0.1.0.md +52 -0
- package/docs/REPORT_SCHEMA.md +81 -0
- package/docs/RULES.md +92 -0
- package/docs/THREAT_MODEL.md +50 -0
- package/docs/WEB_DEMO.md +39 -0
- package/docs/WORKSPACE_SCANNING.md +41 -0
- package/examples/clawhub-origin-without-lock/skills/orphan-helper/.clawhub/origin.json +6 -0
- package/examples/clawhub-origin-without-lock/skills/orphan-helper/SKILL.md +11 -0
- package/examples/clawhub-workspace/.clawhub/lock.json +22 -0
- package/examples/clawhub-workspace/skills/drift-helper/.clawhub/origin.json +6 -0
- package/examples/clawhub-workspace/skills/drift-helper/SKILL.md +11 -0
- package/examples/clawhub-workspace/skills/missing-origin/SKILL.md +11 -0
- package/examples/clawhub-workspace/skills/weather-helper/.clawhub/origin.json +6 -0
- package/examples/clawhub-workspace/skills/weather-helper/SKILL.md +15 -0
- package/examples/declared-api-skill/SKILL.md +27 -0
- package/examples/dependency-python-skill/SKILL.md +16 -0
- package/examples/dependency-python-skill/pyproject.toml +5 -0
- package/examples/dependency-python-skill/requirements.txt +3 -0
- package/examples/dependency-risky-skill/SKILL.md +16 -0
- package/examples/dependency-risky-skill/package.json +12 -0
- package/examples/dependency-safe-skill/SKILL.md +16 -0
- package/examples/dependency-safe-skill/package-lock.json +19 -0
- package/examples/dependency-safe-skill/package.json +7 -0
- package/examples/metadata-mismatch-skill/SKILL.md +22 -0
- package/examples/openclaw-plugin-config/.openclaw/plugins.json +18 -0
- package/examples/openclaw-workspace/.agents/skills/research-helper/SKILL.md +11 -0
- package/examples/openclaw-workspace/skills/notes/SKILL.md +3 -0
- package/examples/openclaw-workspace/skills/research-helper/SKILL.md +17 -0
- package/examples/risky-mcp-config/.cursor/mcp.json +29 -0
- package/examples/risky-openclaw-plugin/openclaw.plugin.json +6 -0
- package/examples/risky-openclaw-plugin/package.json +7 -0
- package/examples/risky-openclaw-plugin/src/index.ts +1 -0
- package/examples/risky-skill/SKILL.md +17 -0
- package/examples/safe-mcp-config/.cursor/mcp.json +15 -0
- package/examples/safe-openclaw-plugin/dist/index.js +1 -0
- package/examples/safe-openclaw-plugin/openclaw.plugin.json +5 -0
- package/examples/safe-openclaw-plugin/package.json +14 -0
- package/examples/safe-skill/SKILL.md +12 -0
- package/package.json +49 -0
- package/schemas/clawguard-report.schema.json +266 -0
- package/scripts/capture-demo.js +206 -0
- package/src/clawhub.js +383 -0
- package/src/cli.js +296 -0
- package/src/config.js +205 -0
- package/src/dependencies.js +417 -0
- package/src/mcp-config.js +592 -0
- package/src/policy.js +165 -0
- package/src/reporters/html.js +482 -0
- package/src/reporters/sarif.js +121 -0
- package/src/rule-catalog.js +400 -0
- package/src/rules.js +121 -0
- package/src/scanner.js +387 -0
- package/src/skill-metadata.js +516 -0
- package/src/web-server.js +395 -0
- package/src/workspace.js +233 -0
- package/web/app.js +374 -0
- package/web/index.html +119 -0
- package/web/styles.css +453 -0
package/src/scanner.js
ADDED
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { analyzeClawHubMetadata, isClawHubMetadataFile } from "./clawhub.js";
|
|
4
|
+
import { analyzeDependencyManifests, isDependencyFile } from "./dependencies.js";
|
|
5
|
+
import { analyzeMcpConfigs } from "./mcp-config.js";
|
|
6
|
+
import { evaluatePolicy } from "./policy.js";
|
|
7
|
+
import { rules, severityWeights } from "./rules.js";
|
|
8
|
+
import { analyzeSkillMetadata } from "./skill-metadata.js";
|
|
9
|
+
import { analyzeWorkspaceSkills } from "./workspace.js";
|
|
10
|
+
|
|
11
|
+
export const reportSchemaVersion = "1.0.0";
|
|
12
|
+
|
|
13
|
+
export const defaultScanOptions = {
|
|
14
|
+
maxFileSizeBytes: 1024 * 1024,
|
|
15
|
+
maxFindingsPerRulePerFile: 5,
|
|
16
|
+
policy: "personal",
|
|
17
|
+
suppressions: []
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const defaultIncludeFiles = new Set([
|
|
21
|
+
"SKILL.md",
|
|
22
|
+
"skill.md",
|
|
23
|
+
"README.md",
|
|
24
|
+
"readme.md",
|
|
25
|
+
"package.json",
|
|
26
|
+
"package-lock.json",
|
|
27
|
+
"requirements.txt",
|
|
28
|
+
"yarn.lock",
|
|
29
|
+
"manifest.json",
|
|
30
|
+
"mcp.json",
|
|
31
|
+
"server.json",
|
|
32
|
+
"config.json"
|
|
33
|
+
]);
|
|
34
|
+
|
|
35
|
+
const sourceExtensions = new Set([
|
|
36
|
+
".js",
|
|
37
|
+
".jsx",
|
|
38
|
+
".ts",
|
|
39
|
+
".tsx",
|
|
40
|
+
".mjs",
|
|
41
|
+
".cjs",
|
|
42
|
+
".py",
|
|
43
|
+
".sh",
|
|
44
|
+
".bash",
|
|
45
|
+
".zsh",
|
|
46
|
+
".ps1",
|
|
47
|
+
".json",
|
|
48
|
+
".md",
|
|
49
|
+
".yaml",
|
|
50
|
+
".yml",
|
|
51
|
+
".toml"
|
|
52
|
+
]);
|
|
53
|
+
|
|
54
|
+
const ignoredDirs = new Set([
|
|
55
|
+
".git",
|
|
56
|
+
"node_modules",
|
|
57
|
+
"dist",
|
|
58
|
+
"build",
|
|
59
|
+
"coverage",
|
|
60
|
+
".next",
|
|
61
|
+
".turbo",
|
|
62
|
+
".venv",
|
|
63
|
+
"__pycache__"
|
|
64
|
+
]);
|
|
65
|
+
|
|
66
|
+
export async function scanTarget(targetPath, options = {}) {
|
|
67
|
+
const scanOptions = normalizeOptions(options);
|
|
68
|
+
const resolvedPath = path.resolve(targetPath);
|
|
69
|
+
const { files, skippedFiles } = await collectFiles(resolvedPath, scanOptions);
|
|
70
|
+
const fileRecords = [];
|
|
71
|
+
const findings = [];
|
|
72
|
+
|
|
73
|
+
for (const file of files) {
|
|
74
|
+
let text;
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
text = await fs.readFile(file, "utf8");
|
|
78
|
+
} catch (error) {
|
|
79
|
+
skippedFiles.push(skippedFile(file, resolvedPath, "unreadable-file", error.message));
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
fileRecords.push({ file, text });
|
|
84
|
+
if (!isClawHubMetadataFile(file, resolvedPath) && !isDependencyFile(file)) {
|
|
85
|
+
findings.push(...scanText(text, file, resolvedPath, scanOptions));
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
findings.push(...analyzeSkillMetadata(fileRecords, resolvedPath));
|
|
90
|
+
findings.push(...analyzeMcpConfigs(fileRecords, resolvedPath));
|
|
91
|
+
const clawhubAnalysis = analyzeClawHubMetadata(fileRecords, resolvedPath);
|
|
92
|
+
findings.push(...clawhubAnalysis.findings);
|
|
93
|
+
const dependencyAnalysis = analyzeDependencyManifests(fileRecords, resolvedPath);
|
|
94
|
+
findings.push(...dependencyAnalysis.findings);
|
|
95
|
+
const workspaceAnalysis = analyzeWorkspaceSkills(fileRecords, findings, resolvedPath);
|
|
96
|
+
findings.push(...workspaceAnalysis.findings);
|
|
97
|
+
|
|
98
|
+
const { activeFindings, suppressedFindings } = applySuppressions(dedupeFindings(findings), scanOptions.suppressions);
|
|
99
|
+
const score = calculateScore(activeFindings);
|
|
100
|
+
const result = {
|
|
101
|
+
schemaVersion: reportSchemaVersion,
|
|
102
|
+
target: resolvedPath,
|
|
103
|
+
score,
|
|
104
|
+
level: scoreToLevel(score),
|
|
105
|
+
filesScanned: files.length,
|
|
106
|
+
filesSkipped: skippedFiles.length,
|
|
107
|
+
skippedFiles,
|
|
108
|
+
findings: groupFindings(activeFindings),
|
|
109
|
+
suppressedFindings: groupFindings(suppressedFindings),
|
|
110
|
+
summary: summarizeFindings(activeFindings),
|
|
111
|
+
clawhub: clawhubAnalysis.clawhub,
|
|
112
|
+
dependencies: dependencyAnalysis.dependencies,
|
|
113
|
+
workspace: workspaceAnalysis.workspace,
|
|
114
|
+
options: scanOptions
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
result.policy = evaluatePolicy(result, scanOptions.policy);
|
|
118
|
+
return result;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function scanText(text, filePath = "input", basePath = process.cwd(), options = {}) {
|
|
122
|
+
const scanOptions = normalizeOptions(options);
|
|
123
|
+
const findings = [];
|
|
124
|
+
|
|
125
|
+
for (const rule of rules) {
|
|
126
|
+
const seen = new Set();
|
|
127
|
+
const seenSpans = [];
|
|
128
|
+
let ruleFindings = 0;
|
|
129
|
+
|
|
130
|
+
for (const pattern of rule.patterns) {
|
|
131
|
+
const matcher = toGlobalRegex(pattern);
|
|
132
|
+
let match;
|
|
133
|
+
|
|
134
|
+
while ((match = matcher.exec(text)) && ruleFindings < scanOptions.maxFindingsPerRulePerFile) {
|
|
135
|
+
if (match[0] === "") {
|
|
136
|
+
matcher.lastIndex += 1;
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const index = match.index ?? 0;
|
|
141
|
+
const end = index + match[0].length;
|
|
142
|
+
const evidence = cleanEvidence(match[0]);
|
|
143
|
+
const dedupeKey = `${index}:${evidence}`;
|
|
144
|
+
|
|
145
|
+
if (seen.has(dedupeKey) || overlapsSeenSpan(index, end, seenSpans)) {
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
seen.add(dedupeKey);
|
|
150
|
+
seenSpans.push([index, end]);
|
|
151
|
+
ruleFindings += 1;
|
|
152
|
+
|
|
153
|
+
findings.push({
|
|
154
|
+
ruleId: rule.id,
|
|
155
|
+
title: rule.title,
|
|
156
|
+
severity: rule.severity,
|
|
157
|
+
recommendation: rule.recommendation,
|
|
158
|
+
file: relativePath(basePath, filePath),
|
|
159
|
+
line: lineNumberForIndex(text, index),
|
|
160
|
+
evidence
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (ruleFindings >= scanOptions.maxFindingsPerRulePerFile) {
|
|
165
|
+
break;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return findings;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function collectFiles(targetPath, options) {
|
|
174
|
+
const stats = await fs.lstat(targetPath);
|
|
175
|
+
const basePath = stats.isDirectory() ? targetPath : path.dirname(targetPath);
|
|
176
|
+
const files = [];
|
|
177
|
+
const skippedFiles = [];
|
|
178
|
+
|
|
179
|
+
if (stats.isSymbolicLink()) {
|
|
180
|
+
skippedFiles.push(skippedFile(targetPath, basePath, "symbolic-link"));
|
|
181
|
+
return { files, skippedFiles };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (stats.isFile()) {
|
|
185
|
+
await addFileIfSafe(targetPath, basePath, files, skippedFiles, options);
|
|
186
|
+
return { files, skippedFiles };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (!stats.isDirectory()) {
|
|
190
|
+
return { files, skippedFiles };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
await walk(targetPath, basePath, files, skippedFiles, options);
|
|
194
|
+
return {
|
|
195
|
+
files: files.sort(),
|
|
196
|
+
skippedFiles: skippedFiles.sort((a, b) => a.file.localeCompare(b.file))
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async function walk(dir, basePath, files, skippedFiles, options) {
|
|
201
|
+
let entries;
|
|
202
|
+
|
|
203
|
+
try {
|
|
204
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
205
|
+
} catch (error) {
|
|
206
|
+
skippedFiles.push(skippedFile(dir, basePath, "unreadable-directory", error.message));
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
for (const entry of entries) {
|
|
211
|
+
const fullPath = path.join(dir, entry.name);
|
|
212
|
+
|
|
213
|
+
if (entry.isSymbolicLink()) {
|
|
214
|
+
skippedFiles.push(skippedFile(fullPath, basePath, "symbolic-link"));
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (entry.isDirectory()) {
|
|
219
|
+
if (!ignoredDirs.has(entry.name)) {
|
|
220
|
+
await walk(fullPath, basePath, files, skippedFiles, options);
|
|
221
|
+
}
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (entry.isFile()) {
|
|
226
|
+
await addFileIfSafe(fullPath, basePath, files, skippedFiles, options);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async function addFileIfSafe(filePath, basePath, files, skippedFiles, options) {
|
|
232
|
+
if (!shouldScanFile(filePath)) {
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const stats = await fs.lstat(filePath);
|
|
237
|
+
|
|
238
|
+
if (stats.size > options.maxFileSizeBytes) {
|
|
239
|
+
skippedFiles.push(skippedFile(filePath, basePath, "file-too-large", `${stats.size} bytes`));
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
files.push(filePath);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function shouldScanFile(filePath) {
|
|
247
|
+
const name = path.basename(filePath);
|
|
248
|
+
const ext = path.extname(filePath);
|
|
249
|
+
return defaultIncludeFiles.has(name) || sourceExtensions.has(ext);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function calculateScore(findings) {
|
|
253
|
+
const rawScore = findings.reduce((sum, finding) => {
|
|
254
|
+
return sum + severityWeights[finding.severity];
|
|
255
|
+
}, 0);
|
|
256
|
+
return Math.min(100, rawScore);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function scoreToLevel(score) {
|
|
260
|
+
if (score >= 75) return "critical";
|
|
261
|
+
if (score >= 50) return "high";
|
|
262
|
+
if (score >= 25) return "medium";
|
|
263
|
+
if (score > 0) return "low";
|
|
264
|
+
return "info";
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function groupFindings(findings) {
|
|
268
|
+
return findings.sort((a, b) => {
|
|
269
|
+
return (
|
|
270
|
+
severityWeights[b.severity] - severityWeights[a.severity] ||
|
|
271
|
+
a.file.localeCompare(b.file) ||
|
|
272
|
+
a.line - b.line ||
|
|
273
|
+
a.ruleId.localeCompare(b.ruleId)
|
|
274
|
+
);
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function summarizeFindings(findings) {
|
|
279
|
+
const counts = {
|
|
280
|
+
critical: 0,
|
|
281
|
+
high: 0,
|
|
282
|
+
medium: 0,
|
|
283
|
+
low: 0
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
for (const finding of findings) {
|
|
287
|
+
counts[finding.severity] += 1;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return counts;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function applySuppressions(findings, suppressions) {
|
|
294
|
+
const activeFindings = [];
|
|
295
|
+
const suppressedFindings = [];
|
|
296
|
+
|
|
297
|
+
for (const finding of findings) {
|
|
298
|
+
const suppression = suppressions.find((candidate) => matchesSuppression(candidate, finding));
|
|
299
|
+
|
|
300
|
+
if (!suppression) {
|
|
301
|
+
activeFindings.push(finding);
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
suppressedFindings.push({
|
|
306
|
+
...finding,
|
|
307
|
+
suppressed: true,
|
|
308
|
+
suppressionReason: suppression.reason
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
return { activeFindings, suppressedFindings };
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function dedupeFindings(findings) {
|
|
316
|
+
const seen = new Set();
|
|
317
|
+
const unique = [];
|
|
318
|
+
|
|
319
|
+
for (const finding of findings) {
|
|
320
|
+
const key = `${finding.ruleId}:${finding.file}:${finding.line}:${finding.evidence}`;
|
|
321
|
+
|
|
322
|
+
if (seen.has(key)) {
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
seen.add(key);
|
|
327
|
+
unique.push(finding);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
return unique;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function matchesSuppression(suppression, finding) {
|
|
334
|
+
if (suppression.ruleId !== finding.ruleId) {
|
|
335
|
+
return false;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
if (finding.severity === "critical" && !suppression.allowCritical) {
|
|
339
|
+
return false;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
if (suppression.expires && Date.parse(suppression.expires) < Date.now()) {
|
|
343
|
+
return false;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
if (!suppression.path) {
|
|
347
|
+
return true;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
return finding.file === suppression.path || finding.file.endsWith(`/${suppression.path}`);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function lineNumberForIndex(text, index) {
|
|
354
|
+
return text.slice(0, index).split("\n").length;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function cleanEvidence(value) {
|
|
358
|
+
return value.replace(/\s+/g, " ").trim().slice(0, 160);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function normalizeOptions(options) {
|
|
362
|
+
return {
|
|
363
|
+
...defaultScanOptions,
|
|
364
|
+
...options
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function toGlobalRegex(pattern) {
|
|
369
|
+
const flags = pattern.flags.includes("g") ? pattern.flags : `${pattern.flags}g`;
|
|
370
|
+
return new RegExp(pattern.source, flags);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function skippedFile(filePath, basePath, reason, detail = "") {
|
|
374
|
+
return {
|
|
375
|
+
file: relativePath(basePath, filePath),
|
|
376
|
+
reason,
|
|
377
|
+
detail
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function relativePath(basePath, filePath) {
|
|
382
|
+
return path.relative(basePath, filePath) || path.basename(filePath);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function overlapsSeenSpan(start, end, spans) {
|
|
386
|
+
return spans.some(([seenStart, seenEnd]) => start < seenEnd && end > seenStart);
|
|
387
|
+
}
|