Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -223,12 +223,13 @@ skillkit generate # AI skill generation wizard
skillkit serve # Start REST API server
```

### Discovery
### Discovery & Security

```bash
skillkit marketplace # Browse skills
skillkit tree # Hierarchical taxonomy
skillkit find <query> # Quick search
skillkit scan <path> # Security scan for skills
```

### Advanced
Expand Down
2 changes: 2 additions & 0 deletions apps/skillkit/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ import {
SkillMdInitCommand,
SkillMdCheckCommand,
ServeCommand,
ScanCommand,
} from '@skillkit/cli';

const __filename = fileURLToPath(import.meta.url);
Expand Down Expand Up @@ -231,5 +232,6 @@ cli.register(SkillMdInitCommand);
cli.register(SkillMdCheckCommand);

cli.register(ServeCommand);
cli.register(ScanCommand);

cli.runExit(process.argv.slice(2));
34 changes: 34 additions & 0 deletions docs/fumadocs/content/docs/commands.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,40 @@ skillkit publish --dry-run # Preview without writing
skillkit publish submit # Submit to SkillKit marketplace
```

## Security Scanning

```bash
skillkit scan <path> # Scan skill for vulnerabilities
skillkit scan <path> --format json # Output as JSON
skillkit scan <path> --format table # Tabular output
skillkit scan <path> --format sarif # SARIF for GitHub Code Scanning
skillkit scan <path> --fail-on high # Exit 1 if HIGH+ findings
skillkit scan <path> --skip-rules UC001 # Skip specific rules
skillkit scan <path> --quiet # Suppress non-essential output
```

**Threat Categories:**

| Category | Description |
|----------|-------------|
| Prompt Injection | Override/bypass agent instructions |
| Command Injection | eval(), exec(), shell commands |
| Data Exfiltration | Webhook URLs, env var reads, credential access |
| Tool Abuse | Tool shadowing, autonomy escalation |
| Hardcoded Secrets | API keys, tokens, private keys |
| Unicode Steganography | Zero-width chars, bidi overrides |

**Install Integration:**

Skills are automatically scanned during `skillkit install`. Use `--no-scan` to skip, or `--force` to install despite findings.

**Output Formats:**

- `summary` (default) - colored terminal output
- `json` - machine-readable JSON
- `table` - tabular format
- `sarif` - SARIF v2.1 for GitHub Code Scanning / IDE integration

## Interactive TUI

```bash
Expand Down
9 changes: 9 additions & 0 deletions docs/fumadocs/src/components/Commands.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,15 @@ const COMMAND_GROUPS: CommandGroup[] = [
{ cmd: 'publish', desc: 'Submit to marketplace' },
],
},
{
name: 'Security',
commands: [
{ cmd: 'scan <path>', desc: 'Security vulnerability scanner' },
{ cmd: 'scan --format sarif', desc: 'SARIF for GitHub Code Scanning' },
{ cmd: 'scan --fail-on high', desc: 'CI security gate' },
{ cmd: 'audit log', desc: 'View audit trail' },
],
},
{
name: 'Advanced',
commands: [
Expand Down
11 changes: 11 additions & 0 deletions docs/skillkit/components/Commands.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,17 @@ const COMMAND_GROUPS: CommandGroup[] = [
{ cmd: 'serve --cache-ttl 3600000', desc: 'Cache TTL' },
],
},
{
name: 'Security',
commands: [
{ cmd: 'scan <path>', desc: 'Security scan' },
{ cmd: 'scan --format sarif', desc: 'SARIF output' },
{ cmd: 'scan --fail-on high', desc: 'CI gate' },
{ cmd: 'install --no-scan', desc: 'Skip scan' },
{ cmd: 'audit log', desc: 'View audit logs' },
{ cmd: 'validate', desc: 'Validate format' },
],
},
{
name: 'Advanced',
commands: [
Expand Down
10 changes: 10 additions & 0 deletions docs/skillkit/components/Features.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,15 @@ const FEATURES: Feature[] = [
</svg>
)
},
{
title: 'Security Scanner',
description: 'Detect prompt injection, secrets, and malicious patterns in skills.',
icon: (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z" />
</svg>
)
},
{
title: 'Testing',
description: 'Built-in test framework with assertions.',
Expand Down Expand Up @@ -106,6 +115,7 @@ const COMPARISONS = [
['Translation', 'Rewrite for each', 'One-click conversion'],
['Team Sharing', 'Copy/paste files', '.skills manifest'],
['Discovery', 'Search forums', 'AI recommendations'],
['Security', 'Manual review', 'Auto-scan on install'],
['API Access', 'None', 'REST + MCP + Python'],
] as const;

Expand Down
15 changes: 15 additions & 0 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,20 @@ skillkit command generate <agent> # Generate agent-native commands
skillkit command list <agent> # List available commands
```

### Security Scanning

```bash
skillkit scan <path> # Scan skill for vulnerabilities
skillkit scan <path> --format json # Output as JSON
skillkit scan <path> --format sarif # SARIF for GitHub Code Scanning
skillkit scan <path> --fail-on high # Exit code 1 if HIGH+ findings
skillkit scan <path> --skip-rules UC001,UC002 # Skip specific rules
```

Detects: prompt injection, command injection, data exfiltration, tool abuse, hardcoded secrets, unicode steganography.

Skills are automatically scanned during `install` (use `--no-scan` to skip) and `publish`.

### Utilities

```bash
Expand All @@ -184,6 +198,7 @@ skillkit install ./local/path # Local directory
--yes # Skip confirmation prompts
--global # Install globally
--force # Overwrite existing
--no-scan # Skip security scan
--agent=cursor,windsurf # Install to specific agents
```

Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/commands/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,3 +118,4 @@ export { SkillMdValidateCommand, SkillMdInitCommand, SkillMdCheckCommand } from

// API server
export { ServeCommand } from './serve.js';
export { ScanCommand } from './scan.js';
29 changes: 28 additions & 1 deletion packages/cli/src/commands/install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { promisify } from 'node:util';

const execFileAsync = promisify(execFile);
import { Command, Option } from 'clipanion';
import { detectProvider, isLocalPath, getProvider, evaluateSkillDirectory } from '@skillkit/core';
import { detectProvider, isLocalPath, getProvider, evaluateSkillDirectory, SkillScanner, formatSummary, Severity } from '@skillkit/core';
import type { SkillMetadata, GitProvider, AgentType } from '@skillkit/core';
import { isPathInside } from '@skillkit/core';
import { getAdapter, detectAgent, getAllAdapters } from '@skillkit/agents';
Expand Down Expand Up @@ -94,6 +94,10 @@ export class InstallCommand extends Command {
description: 'Minimal output (no logo)',
});

scan = Option.Boolean('--scan', true, {
description: 'Run security scan before installing (default: true)',
});

async execute(): Promise<number> {
const isInteractive = process.stdin.isTTY && !this.skills && !this.all && !this.yes;
const s = spinner();
Expand Down Expand Up @@ -259,6 +263,29 @@ export class InstallCommand extends Command {
}
}

if (this.scan) {
const scanner = new SkillScanner({ failOnSeverity: Severity.HIGH });
for (const skill of skillsToInstall) {
const scanResult = await scanner.scan(skill.path);

if (scanResult.verdict === 'fail' && !this.force) {
error(`Security scan FAILED for "${skill.name}"`);
console.log(formatSummary(scanResult));
console.log(colors.muted('Use --force to install anyway, or --no-scan to skip scanning'));

const cleanupPath = result.tempRoot || result.path;
if (!isLocalPath(this.source) && cleanupPath && existsSync(cleanupPath)) {
rmSync(cleanupPath, { recursive: true, force: true });
}
return 1;
}

if (scanResult.verdict === 'warn' && !this.quiet) {
warn(`Security warnings for "${skill.name}" (${scanResult.stats.medium} medium, ${scanResult.stats.low} low)`);
}
Comment on lines +270 to +285
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 No security warning shown when --force bypasses a failing scan during install

When a user runs skillkit install <source> --force and the security scan returns a fail verdict (CRITICAL/HIGH findings), the install proceeds silently with zero feedback about the security issues.

Detailed Explanation

In packages/cli/src/commands/install.ts:270-285, the scan result handling has two branches:

if (scanResult.verdict === 'fail' && !this.force) {
    // Block install and show error
    return 1;
}

if (scanResult.verdict === 'warn' && !this.quiet) {
    warn(`Security warnings for "${skill.name}"...`);
}

When --force is true and the verdict is fail:

  • First condition: true && false → skipped (no error shown)
  • Second condition: 'fail' === 'warn' → false → skipped (no warning shown)

The user force-installs a skill with CRITICAL or HIGH severity findings and receives no indication that security issues were detected. At minimum, a warning should be displayed even when --force overrides the block.

Impact: Users who use --force (perhaps to override an existing install) may unknowingly install skills with serious security vulnerabilities without any feedback.

Suggested change
if (scanResult.verdict === 'fail' && !this.force) {
error(`Security scan FAILED for "${skill.name}"`);
console.log(formatSummary(scanResult));
console.log(colors.muted('Use --force to install anyway, or --no-scan to skip scanning'));
const cleanupPath = result.tempRoot || result.path;
if (!isLocalPath(this.source) && cleanupPath && existsSync(cleanupPath)) {
rmSync(cleanupPath, { recursive: true, force: true });
}
return 1;
}
if (scanResult.verdict === 'warn' && !this.quiet) {
warn(`Security warnings for "${skill.name}" (${scanResult.stats.medium} medium, ${scanResult.stats.low} low)`);
}
if (scanResult.verdict === 'fail' && !this.force) {
error(`Security scan FAILED for "${skill.name}"`);
console.log(formatSummary(scanResult));
console.log(colors.muted('Use --force to install anyway, or --no-scan to skip scanning'));
const cleanupPath = result.tempRoot || result.path;
if (!isLocalPath(this.source) && cleanupPath && existsSync(cleanupPath)) {
rmSync(cleanupPath, { recursive: true, force: true });
}
return 1;
}
if (scanResult.verdict === 'fail' && this.force && !this.quiet) {
warn(`Security scan FAILED for "${skill.name}" (${scanResult.stats.critical} critical, ${scanResult.stats.high} high) — installing anyway due to --force`);
}
if (scanResult.verdict === 'warn' && !this.quiet) {
warn(`Security warnings for "${skill.name}" (${scanResult.stats.medium} medium, ${scanResult.stats.low} low)`);
}
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

}
}

// Confirm installation
if (isInteractive && !this.yes) {
console.log('');
Expand Down
17 changes: 15 additions & 2 deletions packages/cli/src/commands/publish.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { existsSync, readFileSync, mkdirSync, writeFileSync, readdirSync, statSy
import { join, basename, dirname, resolve } from 'node:path';
import chalk from 'chalk';
import { Command, Option } from 'clipanion';
import { generateWellKnownIndex, type WellKnownSkill } from '@skillkit/core';
import { generateWellKnownIndex, type WellKnownSkill, SkillScanner, formatSummary, Severity } from '@skillkit/core';

function sanitizeSkillName(name: string): string | null {
if (!name || typeof name !== 'string') return null;
Expand Down Expand Up @@ -72,7 +72,6 @@ export class PublishCommand extends Command {
console.log(chalk.white(`Found ${discoveredSkills.length} skill(s):\n`));

const wellKnownSkills: WellKnownSkill[] = [];

const validSkills: Array<{ name: string; safeName: string; description?: string; path: string }> = [];

for (const skill of discoveredSkills) {
Expand All @@ -95,6 +94,20 @@ export class PublishCommand extends Command {
});
}

const scanner = new SkillScanner({ failOnSeverity: Severity.HIGH });
for (const skill of validSkills) {
const scanResult = await scanner.scan(skill.path);
if (scanResult.verdict === 'fail') {
console.error(chalk.red(`\nSecurity scan FAILED for "${skill.safeName}"`));
console.error(formatSummary(scanResult));
console.error(chalk.dim('Fix security issues before publishing.'));
return 1;
}
if (scanResult.verdict === 'warn') {
console.log(chalk.yellow(` Security warnings for "${skill.safeName}" (${scanResult.findings.length} findings)`));
}
}

if (validSkills.length === 0) {
console.error(chalk.red('\nNo valid skills to publish'));
return 1;
Expand Down
85 changes: 85 additions & 0 deletions packages/cli/src/commands/scan.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { Command, Option } from 'clipanion';
import { resolve } from 'node:path';
import { existsSync } from 'node:fs';
import { SkillScanner, formatResult, Severity } from '@skillkit/core';

const SEVERITY_MAP: Record<string, Severity> = {
critical: Severity.CRITICAL,
high: Severity.HIGH,
medium: Severity.MEDIUM,
low: Severity.LOW,
info: Severity.INFO,
};

export class ScanCommand extends Command {
static override paths = [['scan']];

static override usage = Command.Usage({
description: 'Scan a skill directory for security vulnerabilities',
details: `
Analyzes skill files for prompt injection, command injection, data exfiltration,
tool abuse, hardcoded secrets, and unicode steganography.

Outputs findings in various formats including SARIF for GitHub Code Scanning.
`,
examples: [
['Scan current directory', '$0 scan .'],
['Scan a specific skill', '$0 scan ./my-skill'],
['Output as JSON', '$0 scan ./my-skill --format json'],
['Output as SARIF', '$0 scan ./my-skill --format sarif'],
['Fail on high severity', '$0 scan ./my-skill --fail-on high'],
['Skip unicode rules', '$0 scan ./my-skill --skip-rules UC001,UC002'],
],
});

skillPath = Option.String({ required: true, name: 'path' });

format = Option.String('--format,-f', 'summary', {
description: 'Output format: summary, json, table, sarif',
});

failOn = Option.String('--fail-on', {
description: 'Exit with code 1 if findings at this severity or above (critical, high, medium, low)',
});

skipRules = Option.String('--skip-rules', {
description: 'Comma-separated rule IDs or categories to skip',
});

async execute(): Promise<number> {
const targetPath = resolve(this.skillPath);

if (!existsSync(targetPath)) {
this.context.stderr.write(`Path not found: ${targetPath}\n`);
return 1;
}

const validFormats = ['summary', 'json', 'table', 'sarif'];
if (!validFormats.includes(this.format)) {
this.context.stderr.write(`Invalid format: "${this.format}". Must be one of: ${validFormats.join(', ')}\n`);
return 1;
}

const skipRules = this.skipRules?.split(',').map((s) => s.trim()) ?? [];

let failOnSeverity: Severity | undefined;
if (this.failOn) {
failOnSeverity = SEVERITY_MAP[this.failOn.toLowerCase()];
if (!failOnSeverity) {
this.context.stderr.write(`Invalid --fail-on value: "${this.failOn}". Must be one of: ${Object.keys(SEVERITY_MAP).join(', ')}\n`);
return 1;
}
}

const scanner = new SkillScanner({
failOnSeverity,
skipRules,
});

const result = await scanner.scan(targetPath);

this.context.stdout.write(formatResult(result, this.format) + '\n');

return result.verdict === 'fail' ? 1 : 0;
}
}
3 changes: 3 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,3 +120,6 @@ export type { SkillReference, ParsedSkillContent } from './parser/index.js';

// Runtime Skill Injection
export * from './runtime/index.js';

// Security Scanner
export * from './scanner/index.js';
6 changes: 6 additions & 0 deletions packages/core/src/scanner/analyzers/base.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import type { Finding } from '../types.js';

export interface Analyzer {
name: string;
analyze(skillPath: string, files: string[]): Promise<Finding[]>;
}
Loading