@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/clawhub.js
ADDED
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
export function analyzeClawHubMetadata(fileRecords, basePath = process.cwd()) {
|
|
4
|
+
const findings = [];
|
|
5
|
+
const metadataRecords = fileRecords.filter((record) => isClawHubMetadataFile(record.file, basePath));
|
|
6
|
+
const lockRecords = metadataRecords.filter((record) => isLockFile(record.file, basePath));
|
|
7
|
+
const originRecords = metadataRecords.filter((record) => isOriginFile(record.file, basePath));
|
|
8
|
+
const skillRecords = fileRecords.filter((record) => isSkillFile(record.file));
|
|
9
|
+
const skills = skillRecords.map((record) => parseSkill(record, basePath)).filter(Boolean);
|
|
10
|
+
const lock = parseLock(lockRecords[0], basePath, findings);
|
|
11
|
+
const origins = originRecords.map((record) => parseOrigin(record, basePath, findings)).filter(Boolean);
|
|
12
|
+
|
|
13
|
+
if (!lock && origins.length > 0) {
|
|
14
|
+
for (const origin of origins) {
|
|
15
|
+
findings.push(createFinding({
|
|
16
|
+
ruleId: "clawhub-missing-lockfile",
|
|
17
|
+
title: "ClawHub origin metadata exists without a lockfile",
|
|
18
|
+
severity: "medium",
|
|
19
|
+
recommendation: "Commit or regenerate .clawhub/lock.json so installed ClawHub skills have auditable source and version state.",
|
|
20
|
+
file: origin.file,
|
|
21
|
+
line: 1,
|
|
22
|
+
evidence: `${origin.skillDir} has origin metadata but no .clawhub/lock.json`
|
|
23
|
+
}));
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (lock) {
|
|
28
|
+
for (const entry of lock.entries) {
|
|
29
|
+
const origin = findMatchingOrigin(entry, origins);
|
|
30
|
+
const skill = findMatchingSkill(entry, skills);
|
|
31
|
+
|
|
32
|
+
if (!origin) {
|
|
33
|
+
findings.push(createFinding({
|
|
34
|
+
ruleId: "clawhub-missing-origin",
|
|
35
|
+
title: "ClawHub lock entry is missing local origin metadata",
|
|
36
|
+
severity: "medium",
|
|
37
|
+
recommendation: "Add per-skill origin metadata or reinstall from ClawHub so source provenance can be reviewed.",
|
|
38
|
+
file: lock.file,
|
|
39
|
+
line: 1,
|
|
40
|
+
evidence: `${entry.name} (${entry.skillDir})`
|
|
41
|
+
}));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
findings.push(...compareVersions(entry, origin, skill, lock.file));
|
|
45
|
+
findings.push(...compareSources(entry, origin, lock.file));
|
|
46
|
+
findings.push(...checkSourceTrust(entry, origin, lock.file));
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
for (const origin of origins) {
|
|
51
|
+
findings.push(...checkSourceTrust(null, origin, origin.file));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
findings: dedupeFindings(findings),
|
|
56
|
+
clawhub: {
|
|
57
|
+
lockfile: lock?.file ?? null,
|
|
58
|
+
entries: lock?.entries.map(publicMetadata) ?? [],
|
|
59
|
+
origins: origins.map(publicMetadata)
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function isClawHubMetadataFile(filePath, basePath = process.cwd()) {
|
|
65
|
+
const relative = toPosixPath(relativePath(basePath, filePath));
|
|
66
|
+
return relative === ".clawhub/lock.json" || relative.endsWith("/.clawhub/origin.json") || relative === ".clawhub/origin.json";
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function parseLock(record, basePath, findings) {
|
|
70
|
+
if (!record) {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const parsed = parseJsonRecord(record, basePath, findings);
|
|
75
|
+
if (!parsed.ok) {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
file: relativePath(basePath, record.file),
|
|
81
|
+
entries: extractLockEntries(parsed.value)
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function parseOrigin(record, basePath, findings) {
|
|
86
|
+
const parsed = parseJsonRecord(record, basePath, findings);
|
|
87
|
+
if (!parsed.ok) {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const relative = toPosixPath(relativePath(basePath, record.file));
|
|
92
|
+
const skillDir = skillDirForOrigin(relative);
|
|
93
|
+
const metadata = normalizeMetadata(parsed.value, "", skillDir);
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
...metadata,
|
|
97
|
+
file: relative,
|
|
98
|
+
skillDir
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function parseJsonRecord(record, basePath, findings) {
|
|
103
|
+
try {
|
|
104
|
+
return {
|
|
105
|
+
ok: true,
|
|
106
|
+
value: JSON.parse(record.text)
|
|
107
|
+
};
|
|
108
|
+
} catch (error) {
|
|
109
|
+
findings.push(createFinding({
|
|
110
|
+
ruleId: "invalid-clawhub-metadata",
|
|
111
|
+
title: "ClawHub metadata is not valid JSON",
|
|
112
|
+
severity: "medium",
|
|
113
|
+
recommendation: "Fix invalid ClawHub metadata so source and version provenance can be reviewed.",
|
|
114
|
+
file: relativePath(basePath, record.file),
|
|
115
|
+
line: 1,
|
|
116
|
+
evidence: error.message
|
|
117
|
+
}));
|
|
118
|
+
|
|
119
|
+
return { ok: false };
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function extractLockEntries(value) {
|
|
124
|
+
const rawEntries = [];
|
|
125
|
+
|
|
126
|
+
if (Array.isArray(value?.skills)) {
|
|
127
|
+
rawEntries.push(...value.skills.map((entry) => [entry.name ?? entry.slug ?? "", entry]));
|
|
128
|
+
} else if (value?.skills && typeof value.skills === "object") {
|
|
129
|
+
rawEntries.push(...Object.entries(value.skills));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (Array.isArray(value?.packages)) {
|
|
133
|
+
rawEntries.push(...value.packages.map((entry) => [entry.name ?? entry.slug ?? "", entry]));
|
|
134
|
+
} else if (value?.packages && typeof value.packages === "object") {
|
|
135
|
+
rawEntries.push(...Object.entries(value.packages));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (rawEntries.length === 0 && value && typeof value === "object") {
|
|
139
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
140
|
+
if (entry && typeof entry === "object" && hasMetadataShape(entry)) {
|
|
141
|
+
rawEntries.push([key, entry]);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return rawEntries.map(([key, entry]) => normalizeMetadata(entry, key)).filter((entry) => entry.name || entry.skillDir);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function normalizeMetadata(entry, fallbackName = "", fallbackSkillDir = "") {
|
|
150
|
+
const value = entry && typeof entry === "object" ? entry : {};
|
|
151
|
+
const name = clean(value.name ?? value.slug ?? value.id ?? value.package ?? fallbackName);
|
|
152
|
+
const version = clean(value.version ?? value.ref ?? value.tag ?? "");
|
|
153
|
+
const source = clean(value.source ?? value.repository ?? value.repo ?? value.url ?? value.origin ?? value.homepage ?? "");
|
|
154
|
+
const skillDir = toPosixPath(clean(value.path ?? value.dir ?? value.target ?? fallbackSkillDir ?? (name ? `skills/${name}` : "")));
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
name,
|
|
158
|
+
version,
|
|
159
|
+
source,
|
|
160
|
+
skillDir
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function parseSkill(record, basePath) {
|
|
165
|
+
const relative = toPosixPath(relativePath(basePath, record.file));
|
|
166
|
+
if (!relative.startsWith("skills/") && !relative.startsWith(".agents/skills/")) {
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const lines = record.text.replace(/^\uFEFF/, "").split(/\r?\n/);
|
|
171
|
+
let name = path.basename(path.dirname(record.file));
|
|
172
|
+
let version = "";
|
|
173
|
+
|
|
174
|
+
if (lines[0]?.trim() === "---") {
|
|
175
|
+
const endIndex = lines.findIndex((line, index) => index > 0 && ["---", "..."].includes(line.trim()));
|
|
176
|
+
const end = endIndex === -1 ? lines.length : endIndex;
|
|
177
|
+
|
|
178
|
+
for (let index = 1; index < end; index += 1) {
|
|
179
|
+
const nameMatch = /^name\s*:\s*(.+)\s*$/i.exec(lines[index].trim());
|
|
180
|
+
const versionMatch = /^version\s*:\s*(.+)\s*$/i.exec(lines[index].trim());
|
|
181
|
+
|
|
182
|
+
if (nameMatch) {
|
|
183
|
+
name = clean(nameMatch[1]);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (versionMatch) {
|
|
187
|
+
version = clean(versionMatch[1]);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return {
|
|
193
|
+
name,
|
|
194
|
+
version,
|
|
195
|
+
skillDir: toPosixPath(path.dirname(relative)),
|
|
196
|
+
file: relative
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function compareVersions(lockEntry, origin, skill, lockFile) {
|
|
201
|
+
const findings = [];
|
|
202
|
+
const comparisons = [
|
|
203
|
+
["lock", lockEntry?.version, "origin", origin?.version],
|
|
204
|
+
["lock", lockEntry?.version, "skill", skill?.version],
|
|
205
|
+
["origin", origin?.version, "skill", skill?.version]
|
|
206
|
+
];
|
|
207
|
+
|
|
208
|
+
for (const [leftName, leftVersion, rightName, rightVersion] of comparisons) {
|
|
209
|
+
if (leftVersion && rightVersion && leftVersion !== rightVersion) {
|
|
210
|
+
findings.push(createFinding({
|
|
211
|
+
ruleId: "clawhub-version-drift",
|
|
212
|
+
title: "ClawHub metadata version differs from local skill state",
|
|
213
|
+
severity: "medium",
|
|
214
|
+
recommendation: "Review the installed skill version and refresh ClawHub metadata before trusting or publishing.",
|
|
215
|
+
file: skill?.file ?? origin?.file ?? lockFile,
|
|
216
|
+
line: 1,
|
|
217
|
+
evidence: `${lockEntry.name}: ${leftName}=${leftVersion}, ${rightName}=${rightVersion}`
|
|
218
|
+
}));
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return findings;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function compareSources(lockEntry, origin, lockFile) {
|
|
226
|
+
if (!lockEntry?.source || !origin?.source || lockEntry.source === origin.source) {
|
|
227
|
+
return [];
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return [
|
|
231
|
+
createFinding({
|
|
232
|
+
ruleId: "clawhub-source-drift",
|
|
233
|
+
title: "ClawHub lock source differs from origin metadata",
|
|
234
|
+
severity: "high",
|
|
235
|
+
recommendation: "Confirm whether the installed skill was moved, replaced, or modified outside the recorded ClawHub source.",
|
|
236
|
+
file: origin.file ?? lockFile,
|
|
237
|
+
line: 1,
|
|
238
|
+
evidence: `${lockEntry.name}: lock=${lockEntry.source}, origin=${origin.source}`
|
|
239
|
+
})
|
|
240
|
+
];
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function checkSourceTrust(lockEntry, origin, fallbackFile) {
|
|
244
|
+
const source = origin?.source || lockEntry?.source || "";
|
|
245
|
+
|
|
246
|
+
if (!source || isTrustedSource(source)) {
|
|
247
|
+
return [];
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return [
|
|
251
|
+
createFinding({
|
|
252
|
+
ruleId: "clawhub-untrusted-source",
|
|
253
|
+
title: "ClawHub metadata references an untrusted or unusual source",
|
|
254
|
+
severity: "medium",
|
|
255
|
+
recommendation: "Review the source manually and prefer official ClawHub/OpenClaw or trusted organization repositories.",
|
|
256
|
+
file: origin?.file ?? fallbackFile,
|
|
257
|
+
line: 1,
|
|
258
|
+
evidence: source
|
|
259
|
+
})
|
|
260
|
+
];
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function findMatchingOrigin(entry, origins) {
|
|
264
|
+
return origins.find((origin) => metadataMatches(entry, origin));
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function findMatchingSkill(entry, skills) {
|
|
268
|
+
return skills.find((skill) => metadataMatches(entry, skill));
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function metadataMatches(left, right) {
|
|
272
|
+
if (!left || !right) {
|
|
273
|
+
return false;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return (
|
|
277
|
+
(left.skillDir && right.skillDir && left.skillDir === right.skillDir) ||
|
|
278
|
+
(left.name && right.name && left.name.toLowerCase() === right.name.toLowerCase())
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function isTrustedSource(source) {
|
|
283
|
+
if (source.startsWith("clawhub:") || source.startsWith("openclaw:")) {
|
|
284
|
+
return true;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
try {
|
|
288
|
+
const url = new URL(source);
|
|
289
|
+
const host = url.hostname.toLowerCase();
|
|
290
|
+
const pathname = url.pathname.toLowerCase();
|
|
291
|
+
|
|
292
|
+
return (
|
|
293
|
+
url.protocol === "https:" &&
|
|
294
|
+
(
|
|
295
|
+
host === "docs.openclaw.ai" ||
|
|
296
|
+
(host === "github.com" && (pathname.startsWith("/openclaw/") || pathname.startsWith("/denial-web/")))
|
|
297
|
+
)
|
|
298
|
+
);
|
|
299
|
+
} catch {
|
|
300
|
+
return false;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function skillDirForOrigin(relativePathValue) {
|
|
305
|
+
const normalized = toPosixPath(relativePathValue);
|
|
306
|
+
|
|
307
|
+
if (normalized === ".clawhub/origin.json") {
|
|
308
|
+
return "";
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return toPosixPath(path.dirname(path.dirname(normalized)));
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function hasMetadataShape(value) {
|
|
315
|
+
return ["version", "source", "repository", "repo", "url", "origin", "path", "dir", "target"].some((key) => key in value);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function isLockFile(filePath, basePath) {
|
|
319
|
+
return toPosixPath(relativePath(basePath, filePath)) === ".clawhub/lock.json";
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function isOriginFile(filePath, basePath) {
|
|
323
|
+
const relative = toPosixPath(relativePath(basePath, filePath));
|
|
324
|
+
return relative.endsWith("/.clawhub/origin.json") || relative === ".clawhub/origin.json";
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function isSkillFile(file) {
|
|
328
|
+
return ["skill.md", "SKILL.md"].includes(path.basename(file));
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function publicMetadata(metadata) {
|
|
332
|
+
return {
|
|
333
|
+
name: metadata.name,
|
|
334
|
+
version: metadata.version,
|
|
335
|
+
source: metadata.source,
|
|
336
|
+
skillDir: metadata.skillDir
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function dedupeFindings(findings) {
|
|
341
|
+
const seen = new Set();
|
|
342
|
+
const unique = [];
|
|
343
|
+
|
|
344
|
+
for (const finding of findings) {
|
|
345
|
+
const key = `${finding.ruleId}:${finding.file}:${finding.evidence}`;
|
|
346
|
+
if (seen.has(key)) {
|
|
347
|
+
continue;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
seen.add(key);
|
|
351
|
+
unique.push(finding);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
return unique;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function createFinding({ ruleId, title, severity, recommendation, file, line, evidence }) {
|
|
358
|
+
return {
|
|
359
|
+
ruleId,
|
|
360
|
+
title,
|
|
361
|
+
severity,
|
|
362
|
+
recommendation,
|
|
363
|
+
file,
|
|
364
|
+
line,
|
|
365
|
+
evidence
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function clean(value) {
|
|
370
|
+
return String(value ?? "")
|
|
371
|
+
.trim()
|
|
372
|
+
.replace(/^["']+|["']+$/g, "")
|
|
373
|
+
.trim();
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function toPosixPath(value) {
|
|
377
|
+
return String(value ?? "").split(path.sep).join("/");
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function relativePath(basePath, filePath) {
|
|
381
|
+
const relative = path.relative(basePath, filePath);
|
|
382
|
+
return relative || path.basename(filePath);
|
|
383
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { promises as fs } from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { loadConfig, mergeConfig, parseSize } from "./config.js";
|
|
6
|
+
import { policyShouldFail } from "./policy.js";
|
|
7
|
+
import { createHtmlReport } from "./reporters/html.js";
|
|
8
|
+
import { createSarifReport } from "./reporters/sarif.js";
|
|
9
|
+
import { scanTarget } from "./scanner.js";
|
|
10
|
+
|
|
11
|
+
const args = process.argv.slice(2);
|
|
12
|
+
const failLevels = ["none", "low", "medium", "high", "critical"];
|
|
13
|
+
const policyPresets = ["personal", "governed", "enterprise"];
|
|
14
|
+
const policyFailDecisions = ["warn", "manual_review", "sandbox_required", "dual_approval", "block"];
|
|
15
|
+
const riskOrder = {
|
|
16
|
+
info: 0,
|
|
17
|
+
low: 1,
|
|
18
|
+
medium: 2,
|
|
19
|
+
high: 3,
|
|
20
|
+
critical: 4
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
if (args.length === 0 || args.includes("--help") || args.includes("-h")) {
|
|
24
|
+
printHelp();
|
|
25
|
+
process.exit(0);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const command = args[0];
|
|
29
|
+
|
|
30
|
+
if (!["scan", "scan-workspace"].includes(command)) {
|
|
31
|
+
console.error(`Unknown command: ${command}`);
|
|
32
|
+
printHelp();
|
|
33
|
+
process.exit(1);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
const cliOptions = parseOptions(args.slice(1));
|
|
38
|
+
const loadedConfig = await loadConfig(cliOptions.target, cliOptions.configPath);
|
|
39
|
+
const options = mergeConfig(loadedConfig.config, cliOptions);
|
|
40
|
+
const result = await scanTarget(options.target, {
|
|
41
|
+
maxFileSizeBytes: options.maxFileSizeBytes,
|
|
42
|
+
maxFindingsPerRulePerFile: options.maxFindingsPerRulePerFile,
|
|
43
|
+
policy: options.policy,
|
|
44
|
+
suppressions: options.suppressions
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
result.configPath = loadedConfig.path;
|
|
48
|
+
|
|
49
|
+
if (options.sarifPath) {
|
|
50
|
+
await writeReportFile(options.sarifPath, JSON.stringify(createSarifReport(result), null, 2));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (options.htmlPath) {
|
|
54
|
+
await writeReportFile(options.htmlPath, createHtmlReport(result));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (options.json) {
|
|
58
|
+
console.log(JSON.stringify(result, null, 2));
|
|
59
|
+
} else {
|
|
60
|
+
printHumanResult(result, options);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
process.exit(shouldFail(result, options) ? 2 : 0);
|
|
64
|
+
} catch (error) {
|
|
65
|
+
console.error(`Scan failed: ${error.message}`);
|
|
66
|
+
process.exit(1);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function printHelp() {
|
|
70
|
+
console.log(`ClawGuard
|
|
71
|
+
|
|
72
|
+
Usage:
|
|
73
|
+
clawguard scan <path> [--json] [--policy <preset>] [--fail-on <level>]
|
|
74
|
+
clawguard scan-workspace <path> [--json] [--policy <preset>]
|
|
75
|
+
npm run scan -- <path>
|
|
76
|
+
|
|
77
|
+
Options:
|
|
78
|
+
--json Print machine-readable JSON.
|
|
79
|
+
--config <path> Load a specific .clawguard.json config file.
|
|
80
|
+
--html <path> Write a self-contained HTML report.
|
|
81
|
+
--sarif <path> Write SARIF 2.1.0 report for GitHub code scanning.
|
|
82
|
+
--policy <preset> Policy preset: personal, governed, enterprise.
|
|
83
|
+
Default: personal, unless configured.
|
|
84
|
+
--fail-on <level> Exit 2 at this level or higher. Levels: none, low, medium, high, critical.
|
|
85
|
+
Default: critical.
|
|
86
|
+
--fail-on-policy Exit 2 when policy decision reaches policyFailOn.
|
|
87
|
+
--policy-fail-on <name> Decision threshold for --fail-on-policy.
|
|
88
|
+
Values: warn, manual_review, sandbox_required, dual_approval, block.
|
|
89
|
+
Default: manual_review.
|
|
90
|
+
--max-file-size <size> Skip individual files larger than this size. Examples: 512kb, 1mb.
|
|
91
|
+
Default: 1mb.
|
|
92
|
+
|
|
93
|
+
Examples:
|
|
94
|
+
npm run scan -- examples/risky-skill
|
|
95
|
+
npm run scan -- examples/metadata-mismatch-skill --policy governed --fail-on-policy
|
|
96
|
+
npm run scan -- examples/metadata-mismatch-skill --html clawguard.html
|
|
97
|
+
npm run scan -- examples/metadata-mismatch-skill --sarif clawguard.sarif
|
|
98
|
+
node src/cli.js scan-workspace examples/openclaw-workspace
|
|
99
|
+
npm run scan -- examples/risky-skill --fail-on medium
|
|
100
|
+
node src/cli.js scan examples/safe-skill --json
|
|
101
|
+
`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function printHumanResult(result, options) {
|
|
105
|
+
console.log(`ClawGuard scan: ${result.target}`);
|
|
106
|
+
console.log(`Risk: ${result.level.toUpperCase()} (${result.score}/100)`);
|
|
107
|
+
console.log(`Policy: ${result.policy.decision} (${result.policy.preset})`);
|
|
108
|
+
console.log(`Files scanned: ${result.filesScanned}`);
|
|
109
|
+
console.log(`Files skipped: ${result.filesSkipped}`);
|
|
110
|
+
console.log(`Fail threshold: ${options.failOn}`);
|
|
111
|
+
if (options.failOnPolicy) {
|
|
112
|
+
console.log(`Policy fail threshold: ${options.policyFailOn}`);
|
|
113
|
+
}
|
|
114
|
+
if (result.configPath) {
|
|
115
|
+
console.log(`Config: ${result.configPath}`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (result.policy.decision !== "allow") {
|
|
119
|
+
console.log(`Policy reason: ${result.policy.reason}`);
|
|
120
|
+
if (result.policy.requiredActions.length > 0) {
|
|
121
|
+
console.log(`Required actions: ${result.policy.requiredActions.join(", ")}`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (result.workspace?.skills?.length > 0) {
|
|
126
|
+
console.log(`Workspace skills: ${result.workspace.skills.length}`);
|
|
127
|
+
if (result.workspace.duplicates.length > 0) {
|
|
128
|
+
console.log(`Workspace duplicates: ${result.workspace.duplicates.length}`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (result.clawhub?.entries?.length > 0 || result.clawhub?.origins?.length > 0) {
|
|
133
|
+
console.log(`ClawHub lockfile: ${result.clawhub.lockfile ?? "none"}`);
|
|
134
|
+
console.log(`ClawHub entries: ${result.clawhub.entries?.length ?? 0}`);
|
|
135
|
+
console.log(`ClawHub origins: ${result.clawhub.origins?.length ?? 0}`);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (result.dependencies?.manifests?.length > 0 || result.dependencies?.lockfiles?.length > 0) {
|
|
139
|
+
console.log(`Dependency manifests: ${result.dependencies.manifests?.length ?? 0}`);
|
|
140
|
+
console.log(`Dependency lockfiles: ${result.dependencies.lockfiles?.length ?? 0}`);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (result.skippedFiles.length > 0) {
|
|
144
|
+
console.log("\nSkipped files:");
|
|
145
|
+
for (const skipped of result.skippedFiles) {
|
|
146
|
+
const detail = skipped.detail ? ` (${skipped.detail})` : "";
|
|
147
|
+
console.log(`- ${skipped.file}: ${skipped.reason}${detail}`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (result.findings.length === 0) {
|
|
152
|
+
console.log("\nNo risky patterns detected.");
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (result.suppressedFindings.length > 0) {
|
|
157
|
+
console.log("\nSuppressed findings:");
|
|
158
|
+
for (const finding of result.suppressedFindings) {
|
|
159
|
+
console.log(`- [${finding.severity.toUpperCase()}] ${finding.title}`);
|
|
160
|
+
console.log(` ${finding.file}:${finding.line}`);
|
|
161
|
+
console.log(` Reason: ${finding.suppressionReason}`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
console.log("\nFindings:");
|
|
166
|
+
for (const finding of result.findings) {
|
|
167
|
+
console.log(`- [${finding.severity.toUpperCase()}] ${finding.title}`);
|
|
168
|
+
console.log(` ${finding.file}:${finding.line}`);
|
|
169
|
+
console.log(` Evidence: ${finding.evidence}`);
|
|
170
|
+
console.log(` Recommendation: ${finding.recommendation}`);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function parseOptions(values) {
|
|
175
|
+
const options = {
|
|
176
|
+
json: false,
|
|
177
|
+
configPath: undefined,
|
|
178
|
+
htmlPath: undefined,
|
|
179
|
+
sarifPath: undefined,
|
|
180
|
+
failOn: undefined,
|
|
181
|
+
failOnPolicy: undefined,
|
|
182
|
+
policy: undefined,
|
|
183
|
+
policyFailOn: undefined,
|
|
184
|
+
maxFileSizeBytes: undefined,
|
|
185
|
+
target: "."
|
|
186
|
+
};
|
|
187
|
+
const paths = [];
|
|
188
|
+
|
|
189
|
+
for (let index = 0; index < values.length; index += 1) {
|
|
190
|
+
const value = values[index];
|
|
191
|
+
|
|
192
|
+
if (value === "--json") {
|
|
193
|
+
options.json = true;
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (value === "--sarif") {
|
|
198
|
+
options.sarifPath = requireNextValue(values, index, "--sarif");
|
|
199
|
+
index += 1;
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (value === "--html") {
|
|
204
|
+
options.htmlPath = requireNextValue(values, index, "--html");
|
|
205
|
+
index += 1;
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (value === "--") {
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (value === "--config") {
|
|
214
|
+
options.configPath = requireNextValue(values, index, "--config");
|
|
215
|
+
index += 1;
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (value === "--policy") {
|
|
220
|
+
const policy = requireNextValue(values, index, "--policy");
|
|
221
|
+
if (!policyPresets.includes(policy)) {
|
|
222
|
+
throw new Error(`Invalid --policy value. Use one of: ${policyPresets.join(", ")}`);
|
|
223
|
+
}
|
|
224
|
+
options.policy = policy;
|
|
225
|
+
index += 1;
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (value === "--fail-on") {
|
|
230
|
+
const level = requireNextValue(values, index, "--fail-on");
|
|
231
|
+
if (!failLevels.includes(level)) {
|
|
232
|
+
throw new Error(`Invalid --fail-on value. Use one of: ${failLevels.join(", ")}`);
|
|
233
|
+
}
|
|
234
|
+
options.failOn = level;
|
|
235
|
+
index += 1;
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (value === "--fail-on-policy") {
|
|
240
|
+
options.failOnPolicy = true;
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (value === "--policy-fail-on") {
|
|
245
|
+
const decision = requireNextValue(values, index, "--policy-fail-on");
|
|
246
|
+
if (!policyFailDecisions.includes(decision)) {
|
|
247
|
+
throw new Error(`Invalid --policy-fail-on value. Use one of: ${policyFailDecisions.join(", ")}`);
|
|
248
|
+
}
|
|
249
|
+
options.policyFailOn = decision;
|
|
250
|
+
index += 1;
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (value === "--max-file-size") {
|
|
255
|
+
const size = requireNextValue(values, index, "--max-file-size");
|
|
256
|
+
options.maxFileSizeBytes = parseSize(size);
|
|
257
|
+
index += 1;
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (value.startsWith("--")) {
|
|
262
|
+
throw new Error(`Unknown option: ${value}`);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
paths.push(value);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
options.target = paths[0] ?? ".";
|
|
269
|
+
return options;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
async function writeReportFile(outputPath, content) {
|
|
273
|
+
const resolvedPath = path.resolve(outputPath);
|
|
274
|
+
await fs.mkdir(path.dirname(resolvedPath), { recursive: true });
|
|
275
|
+
await fs.writeFile(resolvedPath, `${content}\n`);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function requireNextValue(values, index, optionName) {
|
|
279
|
+
const value = values[index + 1];
|
|
280
|
+
if (!value || value.startsWith("--")) {
|
|
281
|
+
throw new Error(`Missing value for ${optionName}`);
|
|
282
|
+
}
|
|
283
|
+
return value;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function shouldFail(result, options) {
|
|
287
|
+
if (options.failOn !== "none" && riskOrder[result.level] >= riskOrder[options.failOn]) {
|
|
288
|
+
return true;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (!options.failOnPolicy) {
|
|
292
|
+
return false;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
return policyShouldFail(result.policy, options.policyFailOn);
|
|
296
|
+
}
|