Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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