Skip to content

Commit ef47ca4

Browse files
authored
feat(security): add content scanning for SKILL.md files (#6)
Implement security scanning layer for SKILL.md content with risk labels, sanitization, and UI warnings. Includes database schema updates, API integration, CLI warnings, and comprehensive documentation.
1 parent eae3f53 commit ef47ca4

21 files changed

+2467
-15
lines changed

CLAUDE.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ pnpm db:migrate:remote # Apply migrations to remote D1
9090
| `/api/skills/:slug/install` | api.skill-install.ts | Optional (API key or X-Device-Id) |
9191
| `/api/report` | api.usage-report.ts | Session/Key |
9292
| `/api/user/api-keys` | api.user-api-keys.ts | Session |
93-
| `/api/skills/register` | api.skill-register.ts | None |
93+
| `/api/skills/register` | api.skill-register.ts | Session/Key |
9494
| `/api/admin/seed` | api.admin.seed.ts | Admin secret |
9595

9696
## Database Tables (Drizzle schema)
@@ -125,6 +125,7 @@ Search algorithm: `./docs/search-algorithm.md`
125125
- **Env vars** in `apps/web/.dev.vars` (local) or Cloudflare Secrets (production). Never commit `.dev.vars`.
126126
- **Max 200 LOC per file** — split into focused modules if exceeded.
127127
- **Seed data** in `scripts/seed-data.json` (30 real skills from skills.sh). Seed via `ADMIN_SECRET=... pnpm seed`.
128+
- **Content security scanning** — all SKILL.md content scanned for prompt injection, invisible chars, ANSI escapes, shell injection. `risk_label` column stores result ("safe"/"caution"/"danger"/"unknown"). Sanitization strips zero-width Unicode + ANSI escapes before storage.
128129

129130
## Deployment
130131

apps/web/app/components/skill-content-renderer.tsx

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,36 @@ import Markdown from "react-markdown";
22

33
interface SkillContentRendererProps {
44
content: string;
5+
riskLabel?: string;
56
}
67

7-
/** Renders skill markdown content with styled prose */
8-
export function SkillContentRenderer({ content }: SkillContentRendererProps) {
8+
/** Renders skill markdown content with styled prose and optional risk badge */
9+
export function SkillContentRenderer({ content, riskLabel }: SkillContentRendererProps) {
910
return (
10-
<div className="sx-prose">
11-
<Markdown>{content}</Markdown>
11+
<div className="relative">
12+
{riskLabel && riskLabel !== "unknown" && (
13+
<div className="mb-3 flex items-center gap-2 text-xs">
14+
<span
15+
className={[
16+
"rounded-full px-2 py-0.5 font-medium",
17+
riskLabel === "safe" && "bg-green-500/20 text-green-400",
18+
riskLabel === "caution" && "bg-yellow-500/20 text-yellow-400",
19+
riskLabel === "danger" && "bg-red-500/20 text-red-400",
20+
]
21+
.filter(Boolean)
22+
.join(" ")}
23+
>
24+
{riskLabel === "safe"
25+
? "No Issues Detected"
26+
: riskLabel === "caution"
27+
? "Review Recommended"
28+
: "Suspicious"}
29+
</span>
30+
</div>
31+
)}
32+
<div className="sx-prose">
33+
<Markdown>{content}</Markdown>
34+
</div>
1235
</div>
1336
);
1437
}

apps/web/app/lib/db/schema.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ export const skills = sqliteTable(
2626
bayesian_rating: real("bayesian_rating").default(0),
2727
trending_score: real("trending_score").default(0),
2828
favorite_count: integer("favorite_count").default(0),
29+
risk_label: text("risk_label").default("unknown"),
2930
created_at: integer("created_at", { mode: "timestamp_ms" }).notNull(),
3031
updated_at: integer("updated_at", { mode: "timestamp_ms" }).notNull(),
3132
},
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
/**
2+
* Content scanner for SKILL.md files.
3+
* Detects prompt injection, invisible chars, and suspicious patterns.
4+
* Pure function, no async, no dependencies — fast and testable.
5+
*/
6+
7+
export type RiskLabel = "safe" | "caution" | "danger" | "unknown";
8+
9+
export interface ScanResult {
10+
label: RiskLabel;
11+
findings: string[];
12+
}
13+
14+
// DANGER patterns — any single match = "danger"
15+
const INVISIBLE_UNICODE = /[\u200B-\u200D\uFEFF\u2060-\u2064\u2066-\u206F]/g;
16+
const ANSI_ESCAPE = /\x1B\[[0-9;]*[A-Za-z]/g;
17+
const PROMPT_INJECTION_PATTERNS = [
18+
/ignore\s+(all\s+)?(previous|prior|above)\s+(instructions|prompts|rules)/i,
19+
/you\s+are\s+now\s+(?:a|an|the|my)\s+/i,
20+
/(?:reveal|show|print|output|leak)\s+(?:the\s+)?system\s+prompt/i,
21+
/(?:override|replace|rewrite)\s+(?:the\s+)?system\s+prompt/i,
22+
];
23+
const JS_PROTOCOL = /javascript\s*:/i;
24+
const DATA_HTML = /data\s*:\s*text\/html/i;
25+
const SHELL_INJECTION = /(?:\$\([^)]+\)|eval\s*\(|exec\s*\()/;
26+
27+
// CAUTION patterns — 2+ matches = "caution"
28+
const CAUTION_PATTERNS: Array<{ regex: RegExp; label: string }> = [
29+
{ regex: /<script/i, label: "html-script-tag" },
30+
{ regex: /<iframe/i, label: "html-iframe-tag" },
31+
{ regex: /<object/i, label: "html-object-tag" },
32+
{ regex: /<embed/i, label: "html-embed-tag" },
33+
{ regex: /<form/i, label: "html-form-tag" },
34+
{ regex: /(?:bit\.ly|tinyurl\.com|t\.co|goo\.gl)\//i, label: "url-shortener" },
35+
{ regex: /[A-Za-z0-9+/]{200,}={0,2}/, label: "base64-block" },
36+
{ regex: /<!--[\s\S]{500,}?-->/, label: "hidden-html-comment" },
37+
{ regex: /process\.env/i, label: "env-access" },
38+
{ regex: /fs\.readFile/i, label: "fs-read" },
39+
{ regex: /child_process/i, label: "child-process" },
40+
// XML-style tags commonly used in prompt injection (moved from DANGER)
41+
{ regex: /^\s*<system/im, label: "xml-system-tag" },
42+
{ regex: /^\s*<assistant/im, label: "xml-assistant-tag" },
43+
{ regex: /^\s*<human/im, label: "xml-human-tag" },
44+
];
45+
46+
export function scanContent(content: string): ScanResult {
47+
const findings: string[] = [];
48+
49+
// DANGER checks
50+
const zwChars = content.match(INVISIBLE_UNICODE);
51+
if (zwChars) {
52+
findings.push(`danger:invisible-chars:${zwChars.length} zero-width characters`);
53+
}
54+
55+
const ansi = content.match(ANSI_ESCAPE);
56+
if (ansi) {
57+
findings.push(`danger:ansi-escape:${ansi.length} terminal escape codes`);
58+
}
59+
60+
for (const pattern of PROMPT_INJECTION_PATTERNS) {
61+
if (pattern.test(content)) {
62+
findings.push(`danger:prompt-injection:${pattern.source}`);
63+
}
64+
}
65+
66+
if (JS_PROTOCOL.test(content)) {
67+
findings.push("danger:js-protocol:javascript: URL detected");
68+
}
69+
70+
if (DATA_HTML.test(content)) {
71+
findings.push("danger:data-html:data:text/html URL detected");
72+
}
73+
74+
if (SHELL_INJECTION.test(content)) {
75+
findings.push("danger:shell-injection:shell command pattern detected");
76+
}
77+
78+
// CAUTION checks
79+
let cautionCount = 0;
80+
for (const { regex, label } of CAUTION_PATTERNS) {
81+
if (regex.test(content)) {
82+
findings.push(`caution:${label}`);
83+
cautionCount++;
84+
}
85+
}
86+
87+
// Derive label
88+
const hasDanger = findings.some((f) => f.startsWith("danger:"));
89+
let label: RiskLabel;
90+
if (hasDanger) {
91+
label = "danger";
92+
} else if (cautionCount >= 2) {
93+
label = "caution";
94+
} else {
95+
label = "safe";
96+
}
97+
98+
return { label, findings };
99+
}
100+
101+
/** Strip zero-width Unicode chars and ANSI escape codes from content */
102+
export function sanitizeContent(content: string): string {
103+
return content
104+
.replace(INVISIBLE_UNICODE, "")
105+
.replace(ANSI_ESCAPE, "");
106+
}

apps/web/app/routes/api.skill-detail.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { getDb } from "~/lib/db";
33
import { skills, ratings, reviews, favorites } from "~/lib/db/schema";
44
import { eq, desc, count, avg } from "drizzle-orm";
55
import { getSession } from "~/lib/auth/session-helpers";
6+
import { scanContent, sanitizeContent } from "~/lib/security/content-scanner";
67

78
/** Detect stub content: short + ends with "## Author\n{author}" */
89
function isStubContent(content: string, author: string): boolean {
@@ -59,11 +60,15 @@ export async function loader({ params, request, context }: LoaderFunctionArgs) {
5960
if (skill.source_url && isStubContent(skill.content, skill.author)) {
6061
const realContent = await fetchRealContent(skill.source_url);
6162
if (realContent) {
62-
skill.content = realContent;
63+
const cleanContent = sanitizeContent(realContent);
64+
const scanResult = scanContent(cleanContent);
65+
skill.content = cleanContent;
66+
skill.risk_label = scanResult.label;
6367
// Persist to DB so future requests are fast (fire-and-forget)
6468
db.update(skills)
65-
.set({ content: realContent, updated_at: new Date() })
69+
.set({ content: cleanContent, risk_label: scanResult.label, updated_at: new Date() })
6670
.where(eq(skills.id, skill.id))
71+
.execute()
6772
.catch(() => {});
6873
}
6974
}

apps/web/app/routes/api.skill-register.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { scanGitHubRepo } from "~/lib/github/scan-github-repo";
1616
import { indexSkill } from "~/lib/vectorize/index-skill";
1717
import { authenticateRequest } from "~/lib/auth/authenticate-request";
1818
import { validateRepoOwnership } from "~/lib/github/validate-repo-ownership";
19+
import { scanContent, sanitizeContent } from "~/lib/security/content-scanner";
1920

2021
const GITHUB_REPO_PATTERN = /^[a-zA-Z0-9._-]+\/[a-zA-Z0-9._-]+$/;
2122
const SAFE_PATH_PATTERN = /^[a-zA-Z0-9._\-/]+$/;
@@ -201,12 +202,16 @@ async function insertAndIndexSkill(
201202
const skillId = crypto.randomUUID();
202203
const now = new Date();
203204

205+
// Sanitize first, then scan the clean version so label reflects stored content
206+
const cleanContent = sanitizeContent(ghSkill.content);
207+
const scanResult = scanContent(cleanContent);
208+
204209
await db.insert(skills).values({
205210
id: skillId,
206211
name: ghSkill.name,
207212
slug: ghSkill.slug,
208213
description: ghSkill.description,
209-
content: ghSkill.content,
214+
content: cleanContent,
210215
author: ghSkill.author,
211216
source_url: ghSkill.source_url,
212217
category: ghSkill.category,
@@ -218,6 +223,7 @@ async function insertAndIndexSkill(
218223
rating_count: 0,
219224
github_stars: ghSkill.github_stars,
220225
install_count: 0,
226+
risk_label: scanResult.label,
221227
created_at: now,
222228
updated_at: now,
223229
});
@@ -228,7 +234,7 @@ async function insertAndIndexSkill(
228234
id: skillId,
229235
name: ghSkill.name,
230236
description: ghSkill.description,
231-
content: ghSkill.content,
237+
content: cleanContent,
232238
category: ghSkill.category,
233239
is_paid: false,
234240
avg_rating: 0,

apps/web/app/routes/skill-detail.tsx

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import {
2121
} from "~/lib/db/skill-detail-queries";
2222
import { useState } from "react";
2323
import { useFetcher } from "react-router";
24-
import { FileText } from "lucide-react";
24+
import { FileText, ShieldAlert } from "lucide-react";
2525

2626
export async function loader({ params, request, context }: LoaderFunctionArgs) {
2727
const slug = params.slug;
@@ -118,6 +118,20 @@ export default function SkillDetail() {
118118
)}
119119
</div>
120120

121+
{/* Risk warning banner */}
122+
{data.skill.risk_label === "danger" && (
123+
<div className="mb-6 rounded-lg border border-red-500/30 bg-red-500/10 px-4 py-3 text-sm text-red-400">
124+
<ShieldAlert className="mr-2 inline h-4 w-4" />
125+
Suspicious content patterns detected. Review carefully before use.
126+
</div>
127+
)}
128+
{data.skill.risk_label === "caution" && (
129+
<div className="mb-6 rounded-lg border border-yellow-500/30 bg-yellow-500/10 px-4 py-3 text-sm text-yellow-400">
130+
<ShieldAlert className="mr-2 inline h-4 w-4" />
131+
Some content patterns flagged for review.
132+
</div>
133+
)}
134+
121135
{/* Use this skill */}
122136
<div className="mb-8 space-y-3">
123137
<p className="text-xs font-medium uppercase tracking-wider text-sx-fg-subtle">Use this skill</p>
@@ -149,7 +163,7 @@ export default function SkillDetail() {
149163
{/* Description / Content (rendered as markdown) */}
150164
<div className="mb-10">
151165
{data.skill.content && data.skill.content !== data.skill.description ? (
152-
<SkillContentRenderer content={data.skill.content} />
166+
<SkillContentRenderer content={data.skill.content} riskLabel={data.skill.risk_label ?? undefined} />
153167
) : (
154168
<p className="text-sx-fg-muted leading-relaxed">{data.skill.description}</p>
155169
)}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
ALTER TABLE `skills` ADD `risk_label` text DEFAULT 'unknown';

0 commit comments

Comments
 (0)