@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/policy.js
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
export const policyPresets = new Set(["personal", "governed", "enterprise"]);
|
|
2
|
+
|
|
3
|
+
const decisionRank = {
|
|
4
|
+
allow: 0,
|
|
5
|
+
warn: 1,
|
|
6
|
+
manual_review: 2,
|
|
7
|
+
sandbox_required: 3,
|
|
8
|
+
dual_approval: 4,
|
|
9
|
+
block: 5
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const sensitiveEnterpriseRules = new Set([
|
|
13
|
+
"remote-code-execution",
|
|
14
|
+
"credential-access",
|
|
15
|
+
"data-exfiltration",
|
|
16
|
+
"destructive-shell",
|
|
17
|
+
"undeclared-env-access",
|
|
18
|
+
"mcp-secret-env",
|
|
19
|
+
"mcp-shell-execution",
|
|
20
|
+
"mcp-broad-filesystem-access",
|
|
21
|
+
"dependency-install-script",
|
|
22
|
+
"dependency-direct-source"
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
export function evaluatePolicy(scanResult, preset = "personal") {
|
|
26
|
+
const normalizedPreset = normalizePolicyPreset(preset);
|
|
27
|
+
const findings = scanResult.findings ?? [];
|
|
28
|
+
const ruleIds = new Set(findings.map((finding) => finding.ruleId));
|
|
29
|
+
const level = scanResult.level ?? "info";
|
|
30
|
+
const decision = decisionFor(normalizedPreset, level, ruleIds);
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
preset: normalizedPreset,
|
|
34
|
+
decision,
|
|
35
|
+
rank: decisionRank[decision],
|
|
36
|
+
reason: reasonFor(decision, level, ruleIds),
|
|
37
|
+
requiredActions: requiredActionsFor(decision, ruleIds)
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function normalizePolicyPreset(preset) {
|
|
42
|
+
if (!policyPresets.has(preset)) {
|
|
43
|
+
throw new Error(`Invalid policy preset. Use one of: ${[...policyPresets].join(", ")}`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return preset;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function policyShouldFail(policy, minimumDecision = "manual_review") {
|
|
50
|
+
const decision = policy?.decision ?? "allow";
|
|
51
|
+
|
|
52
|
+
if (!(minimumDecision in decisionRank)) {
|
|
53
|
+
throw new Error(`Invalid policy fail decision: ${minimumDecision}`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return decisionRank[decision] >= decisionRank[minimumDecision];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function decisionFor(preset, level, ruleIds) {
|
|
60
|
+
if (preset === "enterprise" && hasSensitiveEnterpriseFinding(ruleIds)) {
|
|
61
|
+
return "block";
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (level === "critical") {
|
|
65
|
+
return "block";
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (preset === "personal") {
|
|
69
|
+
if (level === "high") return "manual_review";
|
|
70
|
+
if (level === "medium") return "warn";
|
|
71
|
+
return "allow";
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (preset === "governed") {
|
|
75
|
+
if (level === "high") return "sandbox_required";
|
|
76
|
+
if (level === "medium") return "manual_review";
|
|
77
|
+
if (level === "low") return "warn";
|
|
78
|
+
return "allow";
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (level === "high") return "dual_approval";
|
|
82
|
+
if (level === "medium") return "manual_review";
|
|
83
|
+
if (level === "low") return "warn";
|
|
84
|
+
return "allow";
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function hasSensitiveEnterpriseFinding(ruleIds) {
|
|
88
|
+
for (const ruleId of ruleIds) {
|
|
89
|
+
if (sensitiveEnterpriseRules.has(ruleId)) {
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function reasonFor(decision, level, ruleIds) {
|
|
98
|
+
if (decision === "allow") {
|
|
99
|
+
return "No policy action required.";
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (ruleIds.has("remote-code-execution")) {
|
|
103
|
+
return "Remote code execution behavior requires blocking or explicit review.";
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (ruleIds.has("undeclared-env-access")) {
|
|
107
|
+
return "The skill uses environment secrets that are not declared in metadata.";
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (level === "critical") {
|
|
111
|
+
return "Aggregate risk score exceeds the critical policy threshold.";
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (decision === "sandbox_required") {
|
|
115
|
+
return "High-risk behavior should run only with sandboxing or constrained tools.";
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (decision === "dual_approval") {
|
|
119
|
+
return "High-risk enterprise behavior requires stronger approval.";
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (decision === "manual_review") {
|
|
123
|
+
return "Risky behavior requires human review before trust is granted.";
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (level === "medium") {
|
|
127
|
+
return "Medium-risk findings should be visible before install or merge.";
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return "Low-risk findings should be visible before install or merge.";
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function requiredActionsFor(decision, ruleIds) {
|
|
134
|
+
const actions = [];
|
|
135
|
+
|
|
136
|
+
if (decision === "allow") {
|
|
137
|
+
return actions;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (["manual_review", "sandbox_required", "dual_approval", "block"].includes(decision)) {
|
|
141
|
+
actions.push("manual-review");
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (["sandbox_required", "dual_approval"].includes(decision)) {
|
|
145
|
+
actions.push("sandbox");
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (decision === "dual_approval") {
|
|
149
|
+
actions.push("second-approval");
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (decision === "block") {
|
|
153
|
+
actions.push("do-not-install");
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (ruleIds.has("undeclared-env-access") || ruleIds.has("undeclared-network-access")) {
|
|
157
|
+
actions.push("declare-requirements");
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (ruleIds.has("dependency-unpinned-spec") || ruleIds.has("dependency-lockfile-missing")) {
|
|
161
|
+
actions.push("pin-dependencies");
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return [...new Set(actions)];
|
|
165
|
+
}
|
|
@@ -0,0 +1,482 @@
|
|
|
1
|
+
const severityOrder = ["critical", "high", "medium", "low"];
|
|
2
|
+
|
|
3
|
+
export function createHtmlReport(scanResult) {
|
|
4
|
+
const generatedAt = new Date().toISOString();
|
|
5
|
+
const findingsBySeverity = groupFindingsBySeverity(scanResult.findings ?? []);
|
|
6
|
+
const suppressedFindings = scanResult.suppressedFindings ?? [];
|
|
7
|
+
const skippedFiles = scanResult.skippedFiles ?? [];
|
|
8
|
+
|
|
9
|
+
return `<!doctype html>
|
|
10
|
+
<html lang="en">
|
|
11
|
+
<head>
|
|
12
|
+
<meta charset="utf-8">
|
|
13
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
14
|
+
<title>ClawGuard Report</title>
|
|
15
|
+
<style>
|
|
16
|
+
:root {
|
|
17
|
+
color-scheme: light;
|
|
18
|
+
--bg: #f7f7f4;
|
|
19
|
+
--panel: #ffffff;
|
|
20
|
+
--text: #1c1f24;
|
|
21
|
+
--muted: #626a73;
|
|
22
|
+
--border: #d9ded8;
|
|
23
|
+
--critical: #8f1d1d;
|
|
24
|
+
--high: #b34512;
|
|
25
|
+
--medium: #876200;
|
|
26
|
+
--low: #316a49;
|
|
27
|
+
--info: #365b82;
|
|
28
|
+
}
|
|
29
|
+
* { box-sizing: border-box; }
|
|
30
|
+
body {
|
|
31
|
+
margin: 0;
|
|
32
|
+
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
33
|
+
background: var(--bg);
|
|
34
|
+
color: var(--text);
|
|
35
|
+
line-height: 1.5;
|
|
36
|
+
}
|
|
37
|
+
main {
|
|
38
|
+
width: min(1120px, calc(100vw - 32px));
|
|
39
|
+
margin: 0 auto;
|
|
40
|
+
padding: 32px 0 48px;
|
|
41
|
+
}
|
|
42
|
+
header {
|
|
43
|
+
display: grid;
|
|
44
|
+
grid-template-columns: minmax(0, 1fr) auto;
|
|
45
|
+
gap: 24px;
|
|
46
|
+
align-items: end;
|
|
47
|
+
padding-bottom: 24px;
|
|
48
|
+
border-bottom: 1px solid var(--border);
|
|
49
|
+
}
|
|
50
|
+
h1, h2, h3, p { margin: 0; }
|
|
51
|
+
h1 { font-size: 32px; letter-spacing: 0; }
|
|
52
|
+
h2 { font-size: 18px; margin: 28px 0 12px; }
|
|
53
|
+
h3 { font-size: 15px; margin-bottom: 6px; }
|
|
54
|
+
.target {
|
|
55
|
+
margin-top: 8px;
|
|
56
|
+
color: var(--muted);
|
|
57
|
+
word-break: break-word;
|
|
58
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
59
|
+
font-size: 13px;
|
|
60
|
+
}
|
|
61
|
+
.score {
|
|
62
|
+
min-width: 148px;
|
|
63
|
+
border: 1px solid var(--border);
|
|
64
|
+
background: var(--panel);
|
|
65
|
+
border-radius: 8px;
|
|
66
|
+
padding: 16px;
|
|
67
|
+
text-align: center;
|
|
68
|
+
}
|
|
69
|
+
.score strong {
|
|
70
|
+
display: block;
|
|
71
|
+
font-size: 40px;
|
|
72
|
+
line-height: 1;
|
|
73
|
+
}
|
|
74
|
+
.score span {
|
|
75
|
+
color: var(--muted);
|
|
76
|
+
text-transform: uppercase;
|
|
77
|
+
font-size: 12px;
|
|
78
|
+
font-weight: 700;
|
|
79
|
+
letter-spacing: 0;
|
|
80
|
+
}
|
|
81
|
+
.grid {
|
|
82
|
+
display: grid;
|
|
83
|
+
grid-template-columns: repeat(4, minmax(0, 1fr));
|
|
84
|
+
gap: 12px;
|
|
85
|
+
margin-top: 20px;
|
|
86
|
+
}
|
|
87
|
+
.metric, .policy, .finding, .empty, .details {
|
|
88
|
+
background: var(--panel);
|
|
89
|
+
border: 1px solid var(--border);
|
|
90
|
+
border-radius: 8px;
|
|
91
|
+
}
|
|
92
|
+
.metric { padding: 14px; }
|
|
93
|
+
.metric span {
|
|
94
|
+
display: block;
|
|
95
|
+
color: var(--muted);
|
|
96
|
+
font-size: 12px;
|
|
97
|
+
text-transform: uppercase;
|
|
98
|
+
font-weight: 700;
|
|
99
|
+
}
|
|
100
|
+
.metric strong { font-size: 24px; }
|
|
101
|
+
.policy {
|
|
102
|
+
margin-top: 16px;
|
|
103
|
+
padding: 16px;
|
|
104
|
+
}
|
|
105
|
+
.policy-title {
|
|
106
|
+
display: flex;
|
|
107
|
+
align-items: center;
|
|
108
|
+
gap: 8px;
|
|
109
|
+
flex-wrap: wrap;
|
|
110
|
+
}
|
|
111
|
+
.badge {
|
|
112
|
+
display: inline-flex;
|
|
113
|
+
align-items: center;
|
|
114
|
+
min-height: 24px;
|
|
115
|
+
padding: 3px 8px;
|
|
116
|
+
border-radius: 6px;
|
|
117
|
+
font-size: 12px;
|
|
118
|
+
font-weight: 700;
|
|
119
|
+
text-transform: uppercase;
|
|
120
|
+
color: #fff;
|
|
121
|
+
background: var(--info);
|
|
122
|
+
}
|
|
123
|
+
.badge.critical, .badge.block { background: var(--critical); }
|
|
124
|
+
.badge.high, .badge.sandbox_required, .badge.dual_approval { background: var(--high); }
|
|
125
|
+
.badge.medium, .badge.manual_review { background: var(--medium); }
|
|
126
|
+
.badge.low, .badge.warn { background: var(--low); }
|
|
127
|
+
.badge.info, .badge.allow { background: var(--info); }
|
|
128
|
+
.finding {
|
|
129
|
+
padding: 14px;
|
|
130
|
+
margin-bottom: 10px;
|
|
131
|
+
}
|
|
132
|
+
.finding-head {
|
|
133
|
+
display: flex;
|
|
134
|
+
justify-content: space-between;
|
|
135
|
+
gap: 12px;
|
|
136
|
+
align-items: flex-start;
|
|
137
|
+
}
|
|
138
|
+
.location, .evidence, .recommendation, .muted {
|
|
139
|
+
color: var(--muted);
|
|
140
|
+
font-size: 13px;
|
|
141
|
+
}
|
|
142
|
+
.location {
|
|
143
|
+
margin-top: 4px;
|
|
144
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
145
|
+
word-break: break-word;
|
|
146
|
+
}
|
|
147
|
+
.evidence {
|
|
148
|
+
margin-top: 10px;
|
|
149
|
+
padding: 10px;
|
|
150
|
+
background: #f1f3f0;
|
|
151
|
+
border: 1px solid var(--border);
|
|
152
|
+
border-radius: 6px;
|
|
153
|
+
color: var(--text);
|
|
154
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
155
|
+
white-space: pre-wrap;
|
|
156
|
+
word-break: break-word;
|
|
157
|
+
}
|
|
158
|
+
.recommendation { margin-top: 8px; }
|
|
159
|
+
.empty, .details {
|
|
160
|
+
padding: 16px;
|
|
161
|
+
}
|
|
162
|
+
table {
|
|
163
|
+
width: 100%;
|
|
164
|
+
border-collapse: collapse;
|
|
165
|
+
font-size: 13px;
|
|
166
|
+
}
|
|
167
|
+
th, td {
|
|
168
|
+
text-align: left;
|
|
169
|
+
padding: 8px;
|
|
170
|
+
border-bottom: 1px solid var(--border);
|
|
171
|
+
vertical-align: top;
|
|
172
|
+
}
|
|
173
|
+
th { color: var(--muted); }
|
|
174
|
+
footer {
|
|
175
|
+
margin-top: 32px;
|
|
176
|
+
color: var(--muted);
|
|
177
|
+
font-size: 12px;
|
|
178
|
+
}
|
|
179
|
+
@media (max-width: 720px) {
|
|
180
|
+
header { grid-template-columns: 1fr; }
|
|
181
|
+
.grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
|
182
|
+
.finding-head { display: block; }
|
|
183
|
+
}
|
|
184
|
+
</style>
|
|
185
|
+
</head>
|
|
186
|
+
<body>
|
|
187
|
+
<main>
|
|
188
|
+
<header>
|
|
189
|
+
<div>
|
|
190
|
+
<h1>ClawGuard Report</h1>
|
|
191
|
+
<p class="target">${escapeHtml(scanResult.target)}</p>
|
|
192
|
+
<p class="muted">Schema ${escapeHtml(scanResult.schemaVersion ?? "unknown")} generated ${escapeHtml(generatedAt)}</p>
|
|
193
|
+
</div>
|
|
194
|
+
<div class="score">
|
|
195
|
+
<strong>${escapeHtml(scanResult.score)}</strong>
|
|
196
|
+
<span>${escapeHtml(scanResult.level)}</span>
|
|
197
|
+
</div>
|
|
198
|
+
</header>
|
|
199
|
+
|
|
200
|
+
<section class="grid" aria-label="Finding summary">
|
|
201
|
+
${metric("Critical", scanResult.summary?.critical ?? 0)}
|
|
202
|
+
${metric("High", scanResult.summary?.high ?? 0)}
|
|
203
|
+
${metric("Medium", scanResult.summary?.medium ?? 0)}
|
|
204
|
+
${metric("Low", scanResult.summary?.low ?? 0)}
|
|
205
|
+
</section>
|
|
206
|
+
|
|
207
|
+
<section class="policy">
|
|
208
|
+
<div class="policy-title">
|
|
209
|
+
<h2>Policy Decision</h2>
|
|
210
|
+
<span class="badge ${className(scanResult.policy?.decision)}">${escapeHtml(scanResult.policy?.decision ?? "allow")}</span>
|
|
211
|
+
<span class="badge ${className(scanResult.policy?.preset)}">${escapeHtml(scanResult.policy?.preset ?? "personal")}</span>
|
|
212
|
+
</div>
|
|
213
|
+
<p>${escapeHtml(scanResult.policy?.reason ?? "No policy reason provided.")}</p>
|
|
214
|
+
${requiredActions(scanResult.policy?.requiredActions ?? [])}
|
|
215
|
+
</section>
|
|
216
|
+
|
|
217
|
+
<section>
|
|
218
|
+
<h2>Findings</h2>
|
|
219
|
+
${findingsHtml(findingsBySeverity)}
|
|
220
|
+
</section>
|
|
221
|
+
|
|
222
|
+
${suppressedHtml(suppressedFindings)}
|
|
223
|
+
${skippedHtml(skippedFiles)}
|
|
224
|
+
${clawhubHtml(scanResult.clawhub)}
|
|
225
|
+
${dependenciesHtml(scanResult.dependencies)}
|
|
226
|
+
${workspaceHtml(scanResult.workspace)}
|
|
227
|
+
${optionsHtml(scanResult)}
|
|
228
|
+
|
|
229
|
+
<footer>
|
|
230
|
+
ClawGuard is a static scanner. Findings are risk signals, not proof of malicious intent or safety.
|
|
231
|
+
</footer>
|
|
232
|
+
</main>
|
|
233
|
+
</body>
|
|
234
|
+
</html>
|
|
235
|
+
`;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function metric(label, value) {
|
|
239
|
+
return `<div class="metric"><span>${escapeHtml(label)}</span><strong>${escapeHtml(value)}</strong></div>`;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function findingsHtml(grouped) {
|
|
243
|
+
const sections = severityOrder
|
|
244
|
+
.filter((severity) => grouped[severity]?.length)
|
|
245
|
+
.map((severity) => `
|
|
246
|
+
<h3>${escapeHtml(titleCase(severity))}</h3>
|
|
247
|
+
${grouped[severity].map(findingHtml).join("")}
|
|
248
|
+
`)
|
|
249
|
+
.join("");
|
|
250
|
+
|
|
251
|
+
return sections || `<div class="empty">No risky patterns detected.</div>`;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function findingHtml(finding) {
|
|
255
|
+
return `<article class="finding">
|
|
256
|
+
<div class="finding-head">
|
|
257
|
+
<div>
|
|
258
|
+
<h3>${escapeHtml(finding.title)}</h3>
|
|
259
|
+
<div class="location">${escapeHtml(finding.file)}:${escapeHtml(finding.line)}</div>
|
|
260
|
+
</div>
|
|
261
|
+
<span class="badge ${className(finding.severity)}">${escapeHtml(finding.severity)}</span>
|
|
262
|
+
</div>
|
|
263
|
+
<div class="evidence">${escapeHtml(finding.evidence)}</div>
|
|
264
|
+
<p class="recommendation">${escapeHtml(finding.recommendation)}</p>
|
|
265
|
+
</article>`;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function suppressedHtml(findings) {
|
|
269
|
+
if (findings.length === 0) {
|
|
270
|
+
return "";
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
return `<section>
|
|
274
|
+
<h2>Suppressed Findings</h2>
|
|
275
|
+
${findings.map((finding) => `<article class="finding">
|
|
276
|
+
<div class="finding-head">
|
|
277
|
+
<div>
|
|
278
|
+
<h3>${escapeHtml(finding.title)}</h3>
|
|
279
|
+
<div class="location">${escapeHtml(finding.file)}:${escapeHtml(finding.line)}</div>
|
|
280
|
+
</div>
|
|
281
|
+
<span class="badge ${className(finding.severity)}">${escapeHtml(finding.severity)}</span>
|
|
282
|
+
</div>
|
|
283
|
+
<p class="recommendation">Suppression reason: ${escapeHtml(finding.suppressionReason)}</p>
|
|
284
|
+
</article>`).join("")}
|
|
285
|
+
</section>`;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function skippedHtml(skippedFiles) {
|
|
289
|
+
if (skippedFiles.length === 0) {
|
|
290
|
+
return "";
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
return `<section>
|
|
294
|
+
<h2>Skipped Files</h2>
|
|
295
|
+
<div class="details">
|
|
296
|
+
<table>
|
|
297
|
+
<thead><tr><th>File</th><th>Reason</th><th>Detail</th></tr></thead>
|
|
298
|
+
<tbody>
|
|
299
|
+
${skippedFiles.map((file) => `<tr><td>${escapeHtml(file.file)}</td><td>${escapeHtml(file.reason)}</td><td>${escapeHtml(file.detail)}</td></tr>`).join("")}
|
|
300
|
+
</tbody>
|
|
301
|
+
</table>
|
|
302
|
+
</div>
|
|
303
|
+
</section>`;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function clawhubHtml(clawhub) {
|
|
307
|
+
if (!clawhub?.entries?.length && !clawhub?.origins?.length) {
|
|
308
|
+
return "";
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return `<section>
|
|
312
|
+
<h2>ClawHub Metadata</h2>
|
|
313
|
+
<div class="details">
|
|
314
|
+
<table>
|
|
315
|
+
<tbody>
|
|
316
|
+
<tr><th>Lockfile</th><td>${escapeHtml(clawhub.lockfile ?? "none")}</td></tr>
|
|
317
|
+
<tr><th>Lock entries</th><td>${escapeHtml(clawhub.entries?.length ?? 0)}</td></tr>
|
|
318
|
+
<tr><th>Origin records</th><td>${escapeHtml(clawhub.origins?.length ?? 0)}</td></tr>
|
|
319
|
+
</tbody>
|
|
320
|
+
</table>
|
|
321
|
+
</div>
|
|
322
|
+
${clawhubTable("Lock Entries", clawhub.entries ?? [])}
|
|
323
|
+
${clawhubTable("Origin Records", clawhub.origins ?? [])}
|
|
324
|
+
</section>`;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function clawhubTable(title, entries) {
|
|
328
|
+
if (entries.length === 0) {
|
|
329
|
+
return "";
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
return `<div class="details" style="margin-top: 10px;">
|
|
333
|
+
<h3>${escapeHtml(title)}</h3>
|
|
334
|
+
<table>
|
|
335
|
+
<thead><tr><th>Name</th><th>Version</th><th>Source</th><th>Skill directory</th></tr></thead>
|
|
336
|
+
<tbody>
|
|
337
|
+
${entries.map((entry) => `<tr><td>${escapeHtml(entry.name)}</td><td>${escapeHtml(entry.version)}</td><td>${escapeHtml(entry.source)}</td><td>${escapeHtml(entry.skillDir)}</td></tr>`).join("")}
|
|
338
|
+
</tbody>
|
|
339
|
+
</table>
|
|
340
|
+
</div>`;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function dependenciesHtml(dependencies) {
|
|
344
|
+
if (!dependencies?.manifests?.length && !dependencies?.lockfiles?.length) {
|
|
345
|
+
return "";
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
return `<section>
|
|
349
|
+
<h2>Dependencies</h2>
|
|
350
|
+
<div class="details">
|
|
351
|
+
<table>
|
|
352
|
+
<tbody>
|
|
353
|
+
<tr><th>Manifests</th><td>${escapeHtml(dependencies.manifests?.length ?? 0)}</td></tr>
|
|
354
|
+
<tr><th>Lockfiles</th><td>${escapeHtml(dependencies.lockfiles?.length ?? 0)}</td></tr>
|
|
355
|
+
</tbody>
|
|
356
|
+
</table>
|
|
357
|
+
</div>
|
|
358
|
+
${dependencyManifestTable(dependencies.manifests ?? [])}
|
|
359
|
+
${dependencyLockfileTable(dependencies.lockfiles ?? [])}
|
|
360
|
+
</section>`;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function dependencyManifestTable(manifests) {
|
|
364
|
+
if (manifests.length === 0) {
|
|
365
|
+
return "";
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
return `<div class="details" style="margin-top: 10px;">
|
|
369
|
+
<h3>Manifests</h3>
|
|
370
|
+
<table>
|
|
371
|
+
<thead><tr><th>File</th><th>Ecosystem</th><th>Dependencies</th><th>Scripts</th></tr></thead>
|
|
372
|
+
<tbody>
|
|
373
|
+
${manifests.map((manifest) => `<tr><td>${escapeHtml(manifest.file)}</td><td>${escapeHtml(manifest.ecosystem)}</td><td>${escapeHtml(manifest.dependencyCount)}</td><td>${escapeHtml(manifest.scriptCount)}</td></tr>`).join("")}
|
|
374
|
+
</tbody>
|
|
375
|
+
</table>
|
|
376
|
+
</div>`;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function dependencyLockfileTable(lockfiles) {
|
|
380
|
+
if (lockfiles.length === 0) {
|
|
381
|
+
return "";
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
return `<div class="details" style="margin-top: 10px;">
|
|
385
|
+
<h3>Lockfiles</h3>
|
|
386
|
+
<table>
|
|
387
|
+
<thead><tr><th>File</th><th>Ecosystem</th><th>Directory</th></tr></thead>
|
|
388
|
+
<tbody>
|
|
389
|
+
${lockfiles.map((lockfile) => `<tr><td>${escapeHtml(lockfile.file)}</td><td>${escapeHtml(lockfile.ecosystem)}</td><td>${escapeHtml(lockfile.directory)}</td></tr>`).join("")}
|
|
390
|
+
</tbody>
|
|
391
|
+
</table>
|
|
392
|
+
</div>`;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function workspaceHtml(workspace) {
|
|
396
|
+
if (!workspace?.skills?.length) {
|
|
397
|
+
return "";
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
return `<section>
|
|
401
|
+
<h2>Workspace Skills</h2>
|
|
402
|
+
<div class="details">
|
|
403
|
+
<table>
|
|
404
|
+
<thead><tr><th>Name</th><th>Location</th><th>Precedence</th><th>Score</th><th>File</th></tr></thead>
|
|
405
|
+
<tbody>
|
|
406
|
+
${workspace.skills.map((skill) => `<tr><td>${escapeHtml(skill.name)}</td><td>${escapeHtml(skill.locationKind)}</td><td>${escapeHtml(skill.precedence)}</td><td>${escapeHtml(skill.score)}</td><td>${escapeHtml(skill.skillFile)}</td></tr>`).join("")}
|
|
407
|
+
</tbody>
|
|
408
|
+
</table>
|
|
409
|
+
</div>
|
|
410
|
+
${workspaceDuplicatesHtml(workspace.duplicates ?? [])}
|
|
411
|
+
</section>`;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function workspaceDuplicatesHtml(duplicates) {
|
|
415
|
+
if (duplicates.length === 0) {
|
|
416
|
+
return "";
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
return `<div class="details" style="margin-top: 10px;">
|
|
420
|
+
<table>
|
|
421
|
+
<thead><tr><th>Duplicate name</th><th>Winner</th><th>Overridden</th></tr></thead>
|
|
422
|
+
<tbody>
|
|
423
|
+
${duplicates.map((entry) => `<tr><td>${escapeHtml(entry.name)}</td><td>${escapeHtml(entry.winner)}</td><td>${escapeHtml(entry.overridden.join(", "))}</td></tr>`).join("")}
|
|
424
|
+
</tbody>
|
|
425
|
+
</table>
|
|
426
|
+
</div>`;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function optionsHtml(scanResult) {
|
|
430
|
+
return `<section>
|
|
431
|
+
<h2>Scan Details</h2>
|
|
432
|
+
<div class="details">
|
|
433
|
+
<table>
|
|
434
|
+
<tbody>
|
|
435
|
+
<tr><th>Files scanned</th><td>${escapeHtml(scanResult.filesScanned)}</td></tr>
|
|
436
|
+
<tr><th>Files skipped</th><td>${escapeHtml(scanResult.filesSkipped)}</td></tr>
|
|
437
|
+
<tr><th>Max file size</th><td>${escapeHtml(scanResult.options?.maxFileSizeBytes ?? "")} bytes</td></tr>
|
|
438
|
+
<tr><th>Max findings per rule per file</th><td>${escapeHtml(scanResult.options?.maxFindingsPerRulePerFile ?? "")}</td></tr>
|
|
439
|
+
<tr><th>Config path</th><td>${escapeHtml(scanResult.configPath ?? "none")}</td></tr>
|
|
440
|
+
</tbody>
|
|
441
|
+
</table>
|
|
442
|
+
</div>
|
|
443
|
+
</section>`;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function requiredActions(actions) {
|
|
447
|
+
if (actions.length === 0) {
|
|
448
|
+
return "";
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
return `<p class="recommendation">Required actions: ${escapeHtml(actions.join(", "))}</p>`;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function groupFindingsBySeverity(findings) {
|
|
455
|
+
const grouped = Object.fromEntries(severityOrder.map((severity) => [severity, []]));
|
|
456
|
+
|
|
457
|
+
for (const finding of findings) {
|
|
458
|
+
if (!grouped[finding.severity]) {
|
|
459
|
+
grouped[finding.severity] = [];
|
|
460
|
+
}
|
|
461
|
+
grouped[finding.severity].push(finding);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
return grouped;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function className(value) {
|
|
468
|
+
return String(value ?? "info").replace(/[^a-z0-9_-]/gi, "_");
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function titleCase(value) {
|
|
472
|
+
return String(value).slice(0, 1).toUpperCase() + String(value).slice(1);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function escapeHtml(value) {
|
|
476
|
+
return String(value ?? "")
|
|
477
|
+
.replaceAll("&", "&")
|
|
478
|
+
.replaceAll("<", "<")
|
|
479
|
+
.replaceAll(">", ">")
|
|
480
|
+
.replaceAll('"', """)
|
|
481
|
+
.replaceAll("'", "'");
|
|
482
|
+
}
|