@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.
Files changed (83) hide show
  1. package/.clawguard.example.json +16 -0
  2. package/LICENSE +21 -0
  3. package/README.md +241 -0
  4. package/SECURITY.md +33 -0
  5. package/action.yml +72 -0
  6. package/docs/ARCHITECTURE.md +312 -0
  7. package/docs/ARCHITECTURE_ROADMAP.md +267 -0
  8. package/docs/CLAWHUB_METADATA.md +57 -0
  9. package/docs/DEMO_CAPTURE.md +25 -0
  10. package/docs/DEMO_SCRIPT.md +87 -0
  11. package/docs/DEPENDENCY_SCANNING.md +61 -0
  12. package/docs/GITHUB_ACTION.md +56 -0
  13. package/docs/GITHUB_REPO_SETUP.md +76 -0
  14. package/docs/HTML_REPORTS.md +27 -0
  15. package/docs/INTEGRATION_SPEC.md +253 -0
  16. package/docs/LAUNCH_CHECKLIST.md +64 -0
  17. package/docs/LAUNCH_PLAN.md +40 -0
  18. package/docs/LOCAL_PROJECT_ASSETS.md +250 -0
  19. package/docs/MCP_PLUGIN_SCANNING.md +53 -0
  20. package/docs/NEXT_SESSION.md +110 -0
  21. package/docs/NPM_PUBLISHING.md +66 -0
  22. package/docs/OPENCLAW_CLAWHUB_RESEARCH.md +128 -0
  23. package/docs/POLICY_MODEL.md +198 -0
  24. package/docs/PROJECT_REVIEW.md +108 -0
  25. package/docs/REAL_WORLD_VALIDATION.md +57 -0
  26. package/docs/RELEASE_NOTES_v0.1.0.md +52 -0
  27. package/docs/REPORT_SCHEMA.md +81 -0
  28. package/docs/RULES.md +92 -0
  29. package/docs/THREAT_MODEL.md +50 -0
  30. package/docs/WEB_DEMO.md +39 -0
  31. package/docs/WORKSPACE_SCANNING.md +41 -0
  32. package/examples/clawhub-origin-without-lock/skills/orphan-helper/.clawhub/origin.json +6 -0
  33. package/examples/clawhub-origin-without-lock/skills/orphan-helper/SKILL.md +11 -0
  34. package/examples/clawhub-workspace/.clawhub/lock.json +22 -0
  35. package/examples/clawhub-workspace/skills/drift-helper/.clawhub/origin.json +6 -0
  36. package/examples/clawhub-workspace/skills/drift-helper/SKILL.md +11 -0
  37. package/examples/clawhub-workspace/skills/missing-origin/SKILL.md +11 -0
  38. package/examples/clawhub-workspace/skills/weather-helper/.clawhub/origin.json +6 -0
  39. package/examples/clawhub-workspace/skills/weather-helper/SKILL.md +15 -0
  40. package/examples/declared-api-skill/SKILL.md +27 -0
  41. package/examples/dependency-python-skill/SKILL.md +16 -0
  42. package/examples/dependency-python-skill/pyproject.toml +5 -0
  43. package/examples/dependency-python-skill/requirements.txt +3 -0
  44. package/examples/dependency-risky-skill/SKILL.md +16 -0
  45. package/examples/dependency-risky-skill/package.json +12 -0
  46. package/examples/dependency-safe-skill/SKILL.md +16 -0
  47. package/examples/dependency-safe-skill/package-lock.json +19 -0
  48. package/examples/dependency-safe-skill/package.json +7 -0
  49. package/examples/metadata-mismatch-skill/SKILL.md +22 -0
  50. package/examples/openclaw-plugin-config/.openclaw/plugins.json +18 -0
  51. package/examples/openclaw-workspace/.agents/skills/research-helper/SKILL.md +11 -0
  52. package/examples/openclaw-workspace/skills/notes/SKILL.md +3 -0
  53. package/examples/openclaw-workspace/skills/research-helper/SKILL.md +17 -0
  54. package/examples/risky-mcp-config/.cursor/mcp.json +29 -0
  55. package/examples/risky-openclaw-plugin/openclaw.plugin.json +6 -0
  56. package/examples/risky-openclaw-plugin/package.json +7 -0
  57. package/examples/risky-openclaw-plugin/src/index.ts +1 -0
  58. package/examples/risky-skill/SKILL.md +17 -0
  59. package/examples/safe-mcp-config/.cursor/mcp.json +15 -0
  60. package/examples/safe-openclaw-plugin/dist/index.js +1 -0
  61. package/examples/safe-openclaw-plugin/openclaw.plugin.json +5 -0
  62. package/examples/safe-openclaw-plugin/package.json +14 -0
  63. package/examples/safe-skill/SKILL.md +12 -0
  64. package/package.json +49 -0
  65. package/schemas/clawguard-report.schema.json +266 -0
  66. package/scripts/capture-demo.js +206 -0
  67. package/src/clawhub.js +383 -0
  68. package/src/cli.js +296 -0
  69. package/src/config.js +205 -0
  70. package/src/dependencies.js +417 -0
  71. package/src/mcp-config.js +592 -0
  72. package/src/policy.js +165 -0
  73. package/src/reporters/html.js +482 -0
  74. package/src/reporters/sarif.js +121 -0
  75. package/src/rule-catalog.js +400 -0
  76. package/src/rules.js +121 -0
  77. package/src/scanner.js +387 -0
  78. package/src/skill-metadata.js +516 -0
  79. package/src/web-server.js +395 -0
  80. package/src/workspace.js +233 -0
  81. package/web/app.js +374 -0
  82. package/web/index.html +119 -0
  83. 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
+ }