@skillguard/cli 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/CHANGELOG.md +8 -0
- package/README.md +94 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +148 -0
- package/dist/commands/init.d.ts +5 -0
- package/dist/commands/init.js +31 -0
- package/dist/commands/scan.d.ts +18 -0
- package/dist/commands/scan.js +193 -0
- package/dist/commands/verify.d.ts +6 -0
- package/dist/commands/verify.js +85 -0
- package/dist/lib/api.d.ts +36 -0
- package/dist/lib/api.js +155 -0
- package/dist/lib/config.d.ts +19 -0
- package/dist/lib/config.js +73 -0
- package/dist/lib/format.d.ts +18 -0
- package/dist/lib/format.js +92 -0
- package/dist/lib/verify.d.ts +21 -0
- package/dist/lib/verify.js +193 -0
- package/package.json +35 -0
package/CHANGELOG.md
ADDED
package/README.md
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# @skillguard/cli
|
|
2
|
+
|
|
3
|
+
Security scanner CLI for AI agent `SKILL.md` files.
|
|
4
|
+
|
|
5
|
+
## Quick start
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npx @skillguard/cli scan SKILL.md
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Setup
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npx @skillguard/cli init
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
You can also provide the key via env:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
export SKILLGUARD_API_KEY="sgk_..."
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Usage
|
|
24
|
+
|
|
25
|
+
### Scan one file
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
npx @skillguard/cli scan ./SKILL.md
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
### Scan a directory
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
npx @skillguard/cli scan ./skills --fail-on warning
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### JSON output for CI
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
npx @skillguard/cli scan ./skills --json > skillguard-report.json
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
### Verify signature
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
npx @skillguard/cli verify ./SKILL.md
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Options
|
|
50
|
+
|
|
51
|
+
### scan
|
|
52
|
+
|
|
53
|
+
- `--json`
|
|
54
|
+
- `--fail-on <safe|warning|dangerous>` (default: `warning`)
|
|
55
|
+
- `--timeout <ms>` (default: `30000`)
|
|
56
|
+
- `--base-url <url>` (default: `https://skillguard.ai`)
|
|
57
|
+
- `--api-key <key>`
|
|
58
|
+
- `--dry-run`
|
|
59
|
+
- `--quiet`
|
|
60
|
+
- `--no-color`
|
|
61
|
+
|
|
62
|
+
### verify
|
|
63
|
+
|
|
64
|
+
- `--json`
|
|
65
|
+
- `--timeout <ms>`
|
|
66
|
+
- `--base-url <url>`
|
|
67
|
+
|
|
68
|
+
## Exit codes
|
|
69
|
+
|
|
70
|
+
- `0` success / below threshold
|
|
71
|
+
- `1` threshold exceeded (scan) or invalid/tampered signature (verify)
|
|
72
|
+
- `2` usage/input error
|
|
73
|
+
- `3` network/API/runtime external failure
|
|
74
|
+
|
|
75
|
+
## GitHub Actions example
|
|
76
|
+
|
|
77
|
+
```yaml
|
|
78
|
+
name: Skill Security Scan
|
|
79
|
+
on: [push, pull_request]
|
|
80
|
+
|
|
81
|
+
jobs:
|
|
82
|
+
scan:
|
|
83
|
+
runs-on: ubuntu-latest
|
|
84
|
+
steps:
|
|
85
|
+
- uses: actions/checkout@v4
|
|
86
|
+
- uses: actions/setup-node@v4
|
|
87
|
+
with:
|
|
88
|
+
node-version: '20'
|
|
89
|
+
- run: npx @skillguard/cli scan ./skills
|
|
90
|
+
env:
|
|
91
|
+
SKILLGUARD_API_KEY: ${{ secrets.SKILLGUARD_API_KEY }}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
More info: [skillguard.ai](https://skillguard.ai)
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { parseArgs } from 'node:util';
|
|
3
|
+
import { runInit } from './commands/init.js';
|
|
4
|
+
import { runScan } from './commands/scan.js';
|
|
5
|
+
import { runVerify } from './commands/verify.js';
|
|
6
|
+
const VERSION = '0.1.0';
|
|
7
|
+
function printUsage() {
|
|
8
|
+
console.log(`SkillGuard CLI ${VERSION}
|
|
9
|
+
|
|
10
|
+
Usage:
|
|
11
|
+
skillguard init [--api-key <key>] [--base-url <url>]
|
|
12
|
+
skillguard scan <path> [--json] [--fail-on <safe|warning|dangerous>] [--timeout <ms>] [--base-url <url>] [--api-key <key>] [--dry-run] [--quiet] [--no-color]
|
|
13
|
+
skillguard verify <path> [--json] [--timeout <ms>] [--base-url <url>]
|
|
14
|
+
|
|
15
|
+
Options:
|
|
16
|
+
--help Show help
|
|
17
|
+
--version Show version
|
|
18
|
+
`);
|
|
19
|
+
}
|
|
20
|
+
function toPositiveInteger(raw, fallback) {
|
|
21
|
+
if (!raw) {
|
|
22
|
+
return fallback;
|
|
23
|
+
}
|
|
24
|
+
const parsed = Number.parseInt(raw, 10);
|
|
25
|
+
if (!Number.isInteger(parsed) || parsed <= 0) {
|
|
26
|
+
throw new Error('Expected a positive integer value.');
|
|
27
|
+
}
|
|
28
|
+
return parsed;
|
|
29
|
+
}
|
|
30
|
+
function toFailOn(raw) {
|
|
31
|
+
if (!raw) {
|
|
32
|
+
return 'warning';
|
|
33
|
+
}
|
|
34
|
+
if (raw === 'safe' || raw === 'warning' || raw === 'dangerous') {
|
|
35
|
+
return raw;
|
|
36
|
+
}
|
|
37
|
+
throw new Error("Invalid --fail-on value. Use 'safe', 'warning', or 'dangerous'.");
|
|
38
|
+
}
|
|
39
|
+
function parseShared(args) {
|
|
40
|
+
const parsed = parseArgs({
|
|
41
|
+
args,
|
|
42
|
+
allowPositionals: true,
|
|
43
|
+
options: {
|
|
44
|
+
help: { type: 'boolean' },
|
|
45
|
+
version: { type: 'boolean' },
|
|
46
|
+
json: { type: 'boolean' },
|
|
47
|
+
'dry-run': { type: 'boolean' },
|
|
48
|
+
quiet: { type: 'boolean' },
|
|
49
|
+
'no-color': { type: 'boolean' },
|
|
50
|
+
'fail-on': { type: 'string' },
|
|
51
|
+
timeout: { type: 'string' },
|
|
52
|
+
'base-url': { type: 'string' },
|
|
53
|
+
'api-key': { type: 'string' },
|
|
54
|
+
},
|
|
55
|
+
strict: false,
|
|
56
|
+
});
|
|
57
|
+
return {
|
|
58
|
+
options: parsed.values,
|
|
59
|
+
positionals: parsed.positionals,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
async function run() {
|
|
63
|
+
const argv = process.argv.slice(2);
|
|
64
|
+
const command = argv[0];
|
|
65
|
+
if (!command || command === '--help' || command === '-h') {
|
|
66
|
+
printUsage();
|
|
67
|
+
return 0;
|
|
68
|
+
}
|
|
69
|
+
if (command === '--version' || command === '-v') {
|
|
70
|
+
console.log(VERSION);
|
|
71
|
+
return 0;
|
|
72
|
+
}
|
|
73
|
+
const { options, positionals } = parseShared(argv.slice(1));
|
|
74
|
+
if (options.version) {
|
|
75
|
+
console.log(VERSION);
|
|
76
|
+
return 0;
|
|
77
|
+
}
|
|
78
|
+
if (options.help) {
|
|
79
|
+
printUsage();
|
|
80
|
+
return 0;
|
|
81
|
+
}
|
|
82
|
+
if (command === 'init') {
|
|
83
|
+
return await runInit({
|
|
84
|
+
apiKey: typeof options['api-key'] === 'string' ? options['api-key'] : undefined,
|
|
85
|
+
baseUrl: typeof options['base-url'] === 'string' ? options['base-url'] : undefined,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
if (command === 'scan') {
|
|
89
|
+
const pathArg = positionals[0];
|
|
90
|
+
if (!pathArg) {
|
|
91
|
+
console.error('Missing required <path> argument.');
|
|
92
|
+
return 2;
|
|
93
|
+
}
|
|
94
|
+
let parsedTimeout;
|
|
95
|
+
let failOn;
|
|
96
|
+
try {
|
|
97
|
+
parsedTimeout = toPositiveInteger(typeof options.timeout === 'string' ? options.timeout : undefined, 30000);
|
|
98
|
+
failOn = toFailOn(typeof options['fail-on'] === 'string' ? options['fail-on'] : undefined);
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
console.error(error.message);
|
|
102
|
+
return 2;
|
|
103
|
+
}
|
|
104
|
+
const scanOptions = {
|
|
105
|
+
json: options.json === true,
|
|
106
|
+
failOn,
|
|
107
|
+
timeoutMs: parsedTimeout,
|
|
108
|
+
baseUrl: typeof options['base-url'] === 'string' ? options['base-url'] : undefined,
|
|
109
|
+
apiKey: typeof options['api-key'] === 'string' ? options['api-key'] : undefined,
|
|
110
|
+
dryRun: options['dry-run'] === true,
|
|
111
|
+
quiet: options.quiet === true,
|
|
112
|
+
noColor: options['no-color'] === true,
|
|
113
|
+
};
|
|
114
|
+
return await runScan(pathArg, scanOptions);
|
|
115
|
+
}
|
|
116
|
+
if (command === 'verify') {
|
|
117
|
+
const pathArg = positionals[0];
|
|
118
|
+
if (!pathArg) {
|
|
119
|
+
console.error('Missing required <path> argument.');
|
|
120
|
+
return 2;
|
|
121
|
+
}
|
|
122
|
+
let parsedTimeout;
|
|
123
|
+
try {
|
|
124
|
+
parsedTimeout = toPositiveInteger(typeof options.timeout === 'string' ? options.timeout : undefined, 30000);
|
|
125
|
+
}
|
|
126
|
+
catch (error) {
|
|
127
|
+
console.error(error.message);
|
|
128
|
+
return 2;
|
|
129
|
+
}
|
|
130
|
+
const verifyOptions = {
|
|
131
|
+
baseUrl: typeof options['base-url'] === 'string' ? options['base-url'] : undefined,
|
|
132
|
+
timeoutMs: parsedTimeout,
|
|
133
|
+
json: options.json === true,
|
|
134
|
+
};
|
|
135
|
+
return await runVerify(pathArg, verifyOptions);
|
|
136
|
+
}
|
|
137
|
+
console.error(`Unknown command: ${command}`);
|
|
138
|
+
printUsage();
|
|
139
|
+
return 2;
|
|
140
|
+
}
|
|
141
|
+
run()
|
|
142
|
+
.then((code) => {
|
|
143
|
+
process.exitCode = code;
|
|
144
|
+
})
|
|
145
|
+
.catch(() => {
|
|
146
|
+
console.error('Unexpected CLI error.');
|
|
147
|
+
process.exitCode = 3;
|
|
148
|
+
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { createInterface } from 'node:readline/promises';
|
|
2
|
+
import { stdin as input, stdout as output } from 'node:process';
|
|
3
|
+
import { DEFAULT_BASE_URL, getConfigPath, writeConfig } from '../lib/config.js';
|
|
4
|
+
function sanitizeApiKey(value) {
|
|
5
|
+
const key = value.trim();
|
|
6
|
+
if (!key) {
|
|
7
|
+
throw new Error('API key cannot be empty.');
|
|
8
|
+
}
|
|
9
|
+
return key;
|
|
10
|
+
}
|
|
11
|
+
async function promptApiKey() {
|
|
12
|
+
const rl = createInterface({ input, output });
|
|
13
|
+
try {
|
|
14
|
+
const answer = await rl.question('Enter your SkillGuard API key: ');
|
|
15
|
+
return sanitizeApiKey(answer);
|
|
16
|
+
}
|
|
17
|
+
finally {
|
|
18
|
+
rl.close();
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
export async function runInit(options) {
|
|
22
|
+
const apiKey = options.apiKey ? sanitizeApiKey(options.apiKey) : await promptApiKey();
|
|
23
|
+
const apiUrl = (options.baseUrl || DEFAULT_BASE_URL).trim() || DEFAULT_BASE_URL;
|
|
24
|
+
const configPath = getConfigPath();
|
|
25
|
+
await writeConfig({
|
|
26
|
+
apiKey,
|
|
27
|
+
apiUrl,
|
|
28
|
+
}, configPath);
|
|
29
|
+
console.log(`API key saved to ${configPath}`);
|
|
30
|
+
return 0;
|
|
31
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type ScanFinding, type ScanScore } from '../lib/api.js';
|
|
2
|
+
export interface ScanOptions {
|
|
3
|
+
json: boolean;
|
|
4
|
+
failOn: ScanScore;
|
|
5
|
+
timeoutMs: number;
|
|
6
|
+
baseUrl?: string;
|
|
7
|
+
apiKey?: string;
|
|
8
|
+
dryRun: boolean;
|
|
9
|
+
quiet: boolean;
|
|
10
|
+
noColor: boolean;
|
|
11
|
+
}
|
|
12
|
+
export interface ScanResult {
|
|
13
|
+
file: string;
|
|
14
|
+
score: ScanScore;
|
|
15
|
+
findings: ScanFinding[];
|
|
16
|
+
signatureStatus: string;
|
|
17
|
+
}
|
|
18
|
+
export declare function runScan(inputPath: string, options: ScanOptions): Promise<number>;
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { readFile, readdir, realpath } from 'node:fs/promises';
|
|
2
|
+
import { basename, resolve, sep } from 'node:path';
|
|
3
|
+
import { normalizeFindings, scanContent, toScanScore } from '../lib/api.js';
|
|
4
|
+
import { normalizeBaseUrl, readConfig, resolveApiKey } from '../lib/config.js';
|
|
5
|
+
import { renderSingleScan, renderSummary, shouldUseColor, summarizeScores } from '../lib/format.js';
|
|
6
|
+
const FAIL_LEVELS = {
|
|
7
|
+
safe: 0,
|
|
8
|
+
warning: 1,
|
|
9
|
+
dangerous: 2,
|
|
10
|
+
};
|
|
11
|
+
function shouldFail(score, failOn) {
|
|
12
|
+
if (failOn === 'safe') {
|
|
13
|
+
return score !== 'safe';
|
|
14
|
+
}
|
|
15
|
+
return FAIL_LEVELS[score] >= FAIL_LEVELS[failOn];
|
|
16
|
+
}
|
|
17
|
+
function isForbiddenFilePath(filePath) {
|
|
18
|
+
const name = basename(filePath).toLowerCase();
|
|
19
|
+
return (name === '.env' ||
|
|
20
|
+
name.endsWith('.pem') ||
|
|
21
|
+
name.endsWith('.key') ||
|
|
22
|
+
name.includes('id_rsa') ||
|
|
23
|
+
name.includes('id_ed25519'));
|
|
24
|
+
}
|
|
25
|
+
function isWithinRoot(rootPath, candidatePath) {
|
|
26
|
+
if (candidatePath === rootPath) {
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
return candidatePath.startsWith(`${rootPath}${sep}`);
|
|
30
|
+
}
|
|
31
|
+
async function findSkillFiles(rootPath) {
|
|
32
|
+
const rootRealPath = await realpath(rootPath);
|
|
33
|
+
const queue = [rootPath];
|
|
34
|
+
const found = new Set();
|
|
35
|
+
while (queue.length > 0) {
|
|
36
|
+
const current = queue.shift();
|
|
37
|
+
if (!current) {
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
const entries = await readdir(current, { withFileTypes: true });
|
|
41
|
+
for (const entry of entries) {
|
|
42
|
+
const fullPath = resolve(current, entry.name);
|
|
43
|
+
if (entry.isSymbolicLink()) {
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
if (entry.isDirectory()) {
|
|
47
|
+
const resolvedDir = await realpath(fullPath);
|
|
48
|
+
if (!isWithinRoot(rootRealPath, resolvedDir)) {
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
queue.push(fullPath);
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (!entry.isFile()) {
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
const entryName = entry.name.toLowerCase();
|
|
58
|
+
if (entryName !== 'skill.md') {
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (isForbiddenFilePath(fullPath)) {
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
const resolvedFile = await realpath(fullPath);
|
|
65
|
+
if (!isWithinRoot(rootRealPath, resolvedFile)) {
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
found.add(fullPath);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return Array.from(found).sort();
|
|
72
|
+
}
|
|
73
|
+
async function resolveTargets(inputPath) {
|
|
74
|
+
const path = resolve(inputPath);
|
|
75
|
+
const stat = await import('node:fs/promises').then((mod) => mod.lstat(path));
|
|
76
|
+
if (stat.isSymbolicLink()) {
|
|
77
|
+
throw new Error('Symlink paths are not supported.');
|
|
78
|
+
}
|
|
79
|
+
if (stat.isDirectory()) {
|
|
80
|
+
const files = await findSkillFiles(path);
|
|
81
|
+
if (files.length === 0) {
|
|
82
|
+
throw new Error('No SKILL.md files found in directory.');
|
|
83
|
+
}
|
|
84
|
+
return files;
|
|
85
|
+
}
|
|
86
|
+
if (!stat.isFile()) {
|
|
87
|
+
throw new Error('Path must be a file or directory.');
|
|
88
|
+
}
|
|
89
|
+
if (isForbiddenFilePath(path)) {
|
|
90
|
+
throw new Error('Refusing to scan secret-like files.');
|
|
91
|
+
}
|
|
92
|
+
return [path];
|
|
93
|
+
}
|
|
94
|
+
export async function runScan(inputPath, options) {
|
|
95
|
+
let baseUrl;
|
|
96
|
+
try {
|
|
97
|
+
baseUrl = normalizeBaseUrl(options.baseUrl);
|
|
98
|
+
}
|
|
99
|
+
catch (error) {
|
|
100
|
+
console.error(error.message);
|
|
101
|
+
return 2;
|
|
102
|
+
}
|
|
103
|
+
let targets;
|
|
104
|
+
try {
|
|
105
|
+
targets = await resolveTargets(inputPath);
|
|
106
|
+
}
|
|
107
|
+
catch (error) {
|
|
108
|
+
console.error(error.message);
|
|
109
|
+
return 2;
|
|
110
|
+
}
|
|
111
|
+
if (options.dryRun) {
|
|
112
|
+
if (!options.quiet) {
|
|
113
|
+
console.log(`Dry run: ${targets.length} file(s) would be scanned.`);
|
|
114
|
+
for (const file of targets) {
|
|
115
|
+
console.log(`- ${file}`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return 0;
|
|
119
|
+
}
|
|
120
|
+
const hasApiKeyFlag = typeof options.apiKey === 'string' && options.apiKey.trim().length > 0;
|
|
121
|
+
const hasApiKeyEnv = typeof process.env.SKILLGUARD_API_KEY === 'string' && process.env.SKILLGUARD_API_KEY.trim().length > 0;
|
|
122
|
+
let config = null;
|
|
123
|
+
if (!hasApiKeyFlag && !hasApiKeyEnv) {
|
|
124
|
+
try {
|
|
125
|
+
config = await readConfig();
|
|
126
|
+
}
|
|
127
|
+
catch (error) {
|
|
128
|
+
console.error(error.message);
|
|
129
|
+
return 2;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
const resolvedApiKey = resolveApiKey({
|
|
133
|
+
apiKeyFlag: options.apiKey,
|
|
134
|
+
env: process.env,
|
|
135
|
+
config,
|
|
136
|
+
});
|
|
137
|
+
if (!resolvedApiKey) {
|
|
138
|
+
console.error("No API key found. Run 'skillguard init' or set SKILLGUARD_API_KEY.");
|
|
139
|
+
return 2;
|
|
140
|
+
}
|
|
141
|
+
const color = shouldUseColor(options.noColor);
|
|
142
|
+
const results = [];
|
|
143
|
+
for (const filePath of targets) {
|
|
144
|
+
const content = await readFile(filePath, 'utf8');
|
|
145
|
+
let apiResponse;
|
|
146
|
+
try {
|
|
147
|
+
apiResponse = await scanContent({
|
|
148
|
+
baseUrl,
|
|
149
|
+
apiKey: resolvedApiKey.apiKey,
|
|
150
|
+
content,
|
|
151
|
+
timeoutMs: options.timeoutMs,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
catch (error) {
|
|
155
|
+
console.error(error.message);
|
|
156
|
+
return 3;
|
|
157
|
+
}
|
|
158
|
+
const findings = normalizeFindings(apiResponse.findings);
|
|
159
|
+
const score = toScanScore(apiResponse.score || apiResponse.overallRisk);
|
|
160
|
+
const signatureStatus = typeof apiResponse.signatureStatus === 'string'
|
|
161
|
+
? apiResponse.signatureStatus
|
|
162
|
+
: (apiResponse.security ? 'issued' : 'disabled');
|
|
163
|
+
results.push({
|
|
164
|
+
file: filePath,
|
|
165
|
+
score,
|
|
166
|
+
findings,
|
|
167
|
+
signatureStatus,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
const failedFiles = results.filter((result) => shouldFail(result.score, options.failOn)).map((result) => result.file);
|
|
171
|
+
if (!options.quiet) {
|
|
172
|
+
if (options.json) {
|
|
173
|
+
const summary = summarizeScores(results);
|
|
174
|
+
console.log(JSON.stringify({
|
|
175
|
+
cli_version: '0.1.0',
|
|
176
|
+
scanned_at: new Date().toISOString(),
|
|
177
|
+
fail_on: options.failOn,
|
|
178
|
+
summary,
|
|
179
|
+
failed_files: failedFiles,
|
|
180
|
+
results,
|
|
181
|
+
}, null, 2));
|
|
182
|
+
}
|
|
183
|
+
else {
|
|
184
|
+
for (const result of results) {
|
|
185
|
+
console.log(renderSingleScan(result, color));
|
|
186
|
+
}
|
|
187
|
+
const summary = summarizeScores(results);
|
|
188
|
+
const rootLabel = targets.length === 1 ? targets[0] : resolve(inputPath);
|
|
189
|
+
console.log(renderSummary(summary, rootLabel, color, failedFiles));
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return failedFiles.length > 0 ? 1 : 0;
|
|
193
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { fetchJwks } from '../lib/api.js';
|
|
4
|
+
import { normalizeBaseUrl } from '../lib/config.js';
|
|
5
|
+
import { parseSkillContent, verifySignature } from '../lib/verify.js';
|
|
6
|
+
function renderHuman(result) {
|
|
7
|
+
if (!result.security) {
|
|
8
|
+
return 'Invalid signature: missing security block.';
|
|
9
|
+
}
|
|
10
|
+
const signedBy = `${String(result.security.verified_by || 'unknown')} (${String(result.security.kid || 'unknown')})`;
|
|
11
|
+
const score = String(result.security.score || 'unknown');
|
|
12
|
+
const scanned = String(result.security.scanned_at || 'unknown');
|
|
13
|
+
const validUntil = String(result.security.valid_until || 'unknown');
|
|
14
|
+
return [
|
|
15
|
+
result.signatureValid && result.hashMatches && !result.expired ? 'Signature valid' : 'Signature invalid',
|
|
16
|
+
`Signed by: ${signedBy}`,
|
|
17
|
+
`Score: ${score}`,
|
|
18
|
+
`Scanned: ${scanned}`,
|
|
19
|
+
`Valid until: ${validUntil}${result.expired ? ' (expired)' : ''}`,
|
|
20
|
+
`Content hash matches: ${result.hashMatches ? 'yes' : 'no'}`,
|
|
21
|
+
!result.hashMatches ? `Computed hash: ${result.computedHash}` : '',
|
|
22
|
+
].filter(Boolean).join('\n');
|
|
23
|
+
}
|
|
24
|
+
export async function runVerify(inputPath, options) {
|
|
25
|
+
const filePath = resolve(inputPath);
|
|
26
|
+
let baseUrl;
|
|
27
|
+
try {
|
|
28
|
+
baseUrl = normalizeBaseUrl(options.baseUrl);
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
console.error(error.message);
|
|
32
|
+
return 2;
|
|
33
|
+
}
|
|
34
|
+
let rawContent;
|
|
35
|
+
try {
|
|
36
|
+
rawContent = await readFile(filePath, 'utf8');
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
console.error('Could not read file.');
|
|
40
|
+
return 2;
|
|
41
|
+
}
|
|
42
|
+
const parsedSkill = parseSkillContent(rawContent);
|
|
43
|
+
if (!parsedSkill.security || typeof parsedSkill.security.signature !== 'string') {
|
|
44
|
+
const message = 'Invalid signature: missing security block.';
|
|
45
|
+
if (options.json) {
|
|
46
|
+
console.log(JSON.stringify({ valid: false, reason: 'missing_security_block' }, null, 2));
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
console.error(message);
|
|
50
|
+
}
|
|
51
|
+
return 1;
|
|
52
|
+
}
|
|
53
|
+
let jwks;
|
|
54
|
+
try {
|
|
55
|
+
jwks = await fetchJwks({
|
|
56
|
+
baseUrl,
|
|
57
|
+
timeoutMs: options.timeoutMs,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
console.error(error.message);
|
|
62
|
+
return 3;
|
|
63
|
+
}
|
|
64
|
+
const verification = verifySignature({ parsedSkill, jwks });
|
|
65
|
+
const valid = verification.signatureValid && verification.hashMatches && !verification.expired;
|
|
66
|
+
if (options.json) {
|
|
67
|
+
console.log(JSON.stringify({
|
|
68
|
+
valid,
|
|
69
|
+
signatureValid: verification.signatureValid,
|
|
70
|
+
hashMatches: verification.hashMatches,
|
|
71
|
+
expired: verification.expired,
|
|
72
|
+
security: verification.security,
|
|
73
|
+
}, null, 2));
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
const output = renderHuman(verification);
|
|
77
|
+
if (valid) {
|
|
78
|
+
console.log(output);
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
console.error(output);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return valid ? 0 : 1;
|
|
85
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export type ScanScore = 'safe' | 'warning' | 'dangerous';
|
|
2
|
+
export interface ScanFinding {
|
|
3
|
+
severity?: string;
|
|
4
|
+
title?: string;
|
|
5
|
+
description?: string;
|
|
6
|
+
recommendation?: string;
|
|
7
|
+
}
|
|
8
|
+
export interface ScanApiResponse {
|
|
9
|
+
score?: string;
|
|
10
|
+
overallRisk?: string;
|
|
11
|
+
findings?: unknown;
|
|
12
|
+
signatureStatus?: string;
|
|
13
|
+
security?: Record<string, unknown>;
|
|
14
|
+
signedSkillContent?: string;
|
|
15
|
+
[key: string]: unknown;
|
|
16
|
+
}
|
|
17
|
+
export interface SanitizedApiError {
|
|
18
|
+
message: string;
|
|
19
|
+
exitCode: 2 | 3;
|
|
20
|
+
}
|
|
21
|
+
export declare function toScanScore(value: unknown): ScanScore;
|
|
22
|
+
export declare function scanContent(input: {
|
|
23
|
+
baseUrl: string;
|
|
24
|
+
apiKey: string;
|
|
25
|
+
content: string;
|
|
26
|
+
timeoutMs: number;
|
|
27
|
+
fetchImpl?: typeof fetch;
|
|
28
|
+
}): Promise<ScanApiResponse>;
|
|
29
|
+
export declare function fetchJwks(input: {
|
|
30
|
+
baseUrl: string;
|
|
31
|
+
timeoutMs: number;
|
|
32
|
+
fetchImpl?: typeof fetch;
|
|
33
|
+
}): Promise<{
|
|
34
|
+
keys: Array<Record<string, unknown>>;
|
|
35
|
+
}>;
|
|
36
|
+
export declare function normalizeFindings(rawFindings: unknown): ScanFinding[];
|
package/dist/lib/api.js
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
export function toScanScore(value) {
|
|
2
|
+
if (value === 'safe' || value === 'warning' || value === 'dangerous') {
|
|
3
|
+
return value;
|
|
4
|
+
}
|
|
5
|
+
return 'safe';
|
|
6
|
+
}
|
|
7
|
+
function withTimeout(ms) {
|
|
8
|
+
const controller = new AbortController();
|
|
9
|
+
const timeout = setTimeout(() => controller.abort(), ms);
|
|
10
|
+
timeout.unref?.();
|
|
11
|
+
return {
|
|
12
|
+
signal: controller.signal,
|
|
13
|
+
cancel: () => clearTimeout(timeout),
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
function shouldRetryStatus(status) {
|
|
17
|
+
return status === 408 || status === 429 || status >= 500;
|
|
18
|
+
}
|
|
19
|
+
async function fetchWithRetry(fetchFn, input, init, retries) {
|
|
20
|
+
let attempt = 0;
|
|
21
|
+
let lastError = null;
|
|
22
|
+
while (attempt <= retries) {
|
|
23
|
+
try {
|
|
24
|
+
const response = await fetchFn(input, init);
|
|
25
|
+
if (attempt < retries && shouldRetryStatus(response.status)) {
|
|
26
|
+
attempt += 1;
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
return response;
|
|
30
|
+
}
|
|
31
|
+
catch (error) {
|
|
32
|
+
lastError = error;
|
|
33
|
+
if (attempt >= retries) {
|
|
34
|
+
throw error;
|
|
35
|
+
}
|
|
36
|
+
attempt += 1;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
throw lastError || new Error('Network request failed.');
|
|
40
|
+
}
|
|
41
|
+
async function parseJsonSafe(response) {
|
|
42
|
+
try {
|
|
43
|
+
return await response.json();
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
throw new Error('Unexpected API response.');
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
function mapStatusMessage(status) {
|
|
50
|
+
if (status === 401) {
|
|
51
|
+
return "Invalid API key. Run 'skillguard init' or set SKILLGUARD_API_KEY.";
|
|
52
|
+
}
|
|
53
|
+
if (status === 429) {
|
|
54
|
+
return 'Rate limit exceeded. Wait and retry.';
|
|
55
|
+
}
|
|
56
|
+
if (status >= 500) {
|
|
57
|
+
return 'SkillGuard API error. Try again later.';
|
|
58
|
+
}
|
|
59
|
+
return 'SkillGuard API request failed.';
|
|
60
|
+
}
|
|
61
|
+
export async function scanContent(input) {
|
|
62
|
+
const fetchFn = input.fetchImpl || fetch;
|
|
63
|
+
const endpoint = `${input.baseUrl}/api/v1/scan`;
|
|
64
|
+
const timeout = withTimeout(input.timeoutMs);
|
|
65
|
+
try {
|
|
66
|
+
const response = await fetchWithRetry(fetchFn, endpoint, {
|
|
67
|
+
method: 'POST',
|
|
68
|
+
headers: {
|
|
69
|
+
'X-API-Key': input.apiKey,
|
|
70
|
+
'Content-Type': 'application/json',
|
|
71
|
+
},
|
|
72
|
+
body: JSON.stringify({ content: input.content }),
|
|
73
|
+
signal: timeout.signal,
|
|
74
|
+
}, 1);
|
|
75
|
+
if (!response.ok) {
|
|
76
|
+
throw new Error(mapStatusMessage(response.status));
|
|
77
|
+
}
|
|
78
|
+
const parsed = await parseJsonSafe(response);
|
|
79
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
80
|
+
throw new Error('Unexpected API response.');
|
|
81
|
+
}
|
|
82
|
+
return parsed;
|
|
83
|
+
}
|
|
84
|
+
catch (error) {
|
|
85
|
+
const typed = error;
|
|
86
|
+
if (typed.name === 'AbortError') {
|
|
87
|
+
throw new Error('Cannot reach skillguard.ai. Check your connection.');
|
|
88
|
+
}
|
|
89
|
+
if (typed.message === 'Unexpected API response.') {
|
|
90
|
+
throw typed;
|
|
91
|
+
}
|
|
92
|
+
if (typed.message.includes('Invalid API key.') ||
|
|
93
|
+
typed.message.includes('Rate limit exceeded.') ||
|
|
94
|
+
typed.message.includes('SkillGuard API error.') ||
|
|
95
|
+
typed.message.includes('SkillGuard API request failed.')) {
|
|
96
|
+
throw typed;
|
|
97
|
+
}
|
|
98
|
+
throw new Error('Cannot reach skillguard.ai. Check your connection.');
|
|
99
|
+
}
|
|
100
|
+
finally {
|
|
101
|
+
timeout.cancel();
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
export async function fetchJwks(input) {
|
|
105
|
+
const fetchFn = input.fetchImpl || fetch;
|
|
106
|
+
const endpoint = `${input.baseUrl}/.well-known/skillguard-jwks.json`;
|
|
107
|
+
const timeout = withTimeout(input.timeoutMs);
|
|
108
|
+
try {
|
|
109
|
+
const response = await fetchWithRetry(fetchFn, endpoint, {
|
|
110
|
+
method: 'GET',
|
|
111
|
+
signal: timeout.signal,
|
|
112
|
+
}, 1);
|
|
113
|
+
if (!response.ok) {
|
|
114
|
+
throw new Error('Could not fetch SkillGuard JWKS.');
|
|
115
|
+
}
|
|
116
|
+
const parsed = await parseJsonSafe(response);
|
|
117
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
118
|
+
throw new Error('Could not fetch SkillGuard JWKS.');
|
|
119
|
+
}
|
|
120
|
+
const keys = parsed.keys;
|
|
121
|
+
if (!Array.isArray(keys)) {
|
|
122
|
+
throw new Error('Could not fetch SkillGuard JWKS.');
|
|
123
|
+
}
|
|
124
|
+
return { keys: keys };
|
|
125
|
+
}
|
|
126
|
+
catch (error) {
|
|
127
|
+
const typed = error;
|
|
128
|
+
if (typed.name === 'AbortError') {
|
|
129
|
+
throw new Error('Could not fetch SkillGuard JWKS.');
|
|
130
|
+
}
|
|
131
|
+
if (typed.message === 'Could not fetch SkillGuard JWKS.') {
|
|
132
|
+
throw typed;
|
|
133
|
+
}
|
|
134
|
+
throw new Error('Could not fetch SkillGuard JWKS.');
|
|
135
|
+
}
|
|
136
|
+
finally {
|
|
137
|
+
timeout.cancel();
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
export function normalizeFindings(rawFindings) {
|
|
141
|
+
if (!Array.isArray(rawFindings)) {
|
|
142
|
+
return [];
|
|
143
|
+
}
|
|
144
|
+
return rawFindings
|
|
145
|
+
.filter((item) => item && typeof item === 'object' && !Array.isArray(item))
|
|
146
|
+
.map((item) => {
|
|
147
|
+
const finding = item;
|
|
148
|
+
return {
|
|
149
|
+
severity: typeof finding.severity === 'string' ? finding.severity : undefined,
|
|
150
|
+
title: typeof finding.title === 'string' ? finding.title : undefined,
|
|
151
|
+
description: typeof finding.description === 'string' ? finding.description : undefined,
|
|
152
|
+
recommendation: typeof finding.recommendation === 'string' ? finding.recommendation : undefined,
|
|
153
|
+
};
|
|
154
|
+
});
|
|
155
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export declare const DEFAULT_BASE_URL = "https://skillguard.ai";
|
|
2
|
+
export interface SkillguardCliConfig {
|
|
3
|
+
apiKey?: string;
|
|
4
|
+
apiUrl?: string;
|
|
5
|
+
}
|
|
6
|
+
export interface ResolvedApiKey {
|
|
7
|
+
apiKey: string;
|
|
8
|
+
source: 'flag' | 'env' | 'config';
|
|
9
|
+
}
|
|
10
|
+
export interface ApiKeyResolutionInput {
|
|
11
|
+
apiKeyFlag?: string;
|
|
12
|
+
config?: SkillguardCliConfig | null;
|
|
13
|
+
env?: NodeJS.ProcessEnv;
|
|
14
|
+
}
|
|
15
|
+
export declare function getConfigPath(home?: string): string;
|
|
16
|
+
export declare function readConfig(configPath?: string): Promise<SkillguardCliConfig | null>;
|
|
17
|
+
export declare function writeConfig(config: SkillguardCliConfig, configPath?: string): Promise<void>;
|
|
18
|
+
export declare function normalizeBaseUrl(value?: string): string;
|
|
19
|
+
export declare function resolveApiKey(input: ApiKeyResolutionInput): ResolvedApiKey | null;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { mkdir, readFile, chmod, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
export const DEFAULT_BASE_URL = 'https://skillguard.ai';
|
|
5
|
+
export function getConfigPath(home = homedir()) {
|
|
6
|
+
return join(home, '.config', 'skillguard', 'config.json');
|
|
7
|
+
}
|
|
8
|
+
function isNonEmptyString(value) {
|
|
9
|
+
return typeof value === 'string' && value.trim().length > 0;
|
|
10
|
+
}
|
|
11
|
+
export async function readConfig(configPath = getConfigPath()) {
|
|
12
|
+
try {
|
|
13
|
+
const raw = await readFile(configPath, 'utf8');
|
|
14
|
+
const parsed = JSON.parse(raw);
|
|
15
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
return {
|
|
19
|
+
apiKey: isNonEmptyString(parsed.apiKey) ? parsed.apiKey.trim() : undefined,
|
|
20
|
+
apiUrl: isNonEmptyString(parsed.apiUrl) ? parsed.apiUrl.trim() : undefined,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
catch (error) {
|
|
24
|
+
const typed = error;
|
|
25
|
+
if (typed?.code === 'ENOENT') {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
throw new Error('Failed to read CLI config.');
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
export async function writeConfig(config, configPath = getConfigPath()) {
|
|
32
|
+
const dirPath = dirname(configPath);
|
|
33
|
+
await mkdir(dirPath, { recursive: true, mode: 0o700 });
|
|
34
|
+
await chmod(dirPath, 0o700);
|
|
35
|
+
const body = JSON.stringify({
|
|
36
|
+
apiKey: config.apiKey,
|
|
37
|
+
apiUrl: config.apiUrl || DEFAULT_BASE_URL,
|
|
38
|
+
}, null, 2);
|
|
39
|
+
await writeFile(configPath, `${body}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
40
|
+
await chmod(configPath, 0o600);
|
|
41
|
+
}
|
|
42
|
+
export function normalizeBaseUrl(value) {
|
|
43
|
+
const raw = (value || DEFAULT_BASE_URL).trim();
|
|
44
|
+
const withoutTrailingSlash = raw.replace(/\/+$/, '');
|
|
45
|
+
if (!withoutTrailingSlash) {
|
|
46
|
+
return DEFAULT_BASE_URL;
|
|
47
|
+
}
|
|
48
|
+
let parsed;
|
|
49
|
+
try {
|
|
50
|
+
parsed = new URL(withoutTrailingSlash);
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
throw new Error('Invalid --base-url value.');
|
|
54
|
+
}
|
|
55
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
56
|
+
throw new Error('Invalid --base-url protocol. Use http or https.');
|
|
57
|
+
}
|
|
58
|
+
return parsed.toString().replace(/\/+$/, '');
|
|
59
|
+
}
|
|
60
|
+
export function resolveApiKey(input) {
|
|
61
|
+
if (isNonEmptyString(input.apiKeyFlag)) {
|
|
62
|
+
return { apiKey: input.apiKeyFlag.trim(), source: 'flag' };
|
|
63
|
+
}
|
|
64
|
+
const envValue = input.env?.SKILLGUARD_API_KEY;
|
|
65
|
+
if (isNonEmptyString(envValue)) {
|
|
66
|
+
return { apiKey: envValue.trim(), source: 'env' };
|
|
67
|
+
}
|
|
68
|
+
const configValue = input.config?.apiKey;
|
|
69
|
+
if (isNonEmptyString(configValue)) {
|
|
70
|
+
return { apiKey: configValue.trim(), source: 'config' };
|
|
71
|
+
}
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { ScanFinding, ScanScore } from './api.js';
|
|
2
|
+
export interface ScanRenderedResult {
|
|
3
|
+
file: string;
|
|
4
|
+
score: ScanScore;
|
|
5
|
+
findings: ScanFinding[];
|
|
6
|
+
signatureStatus: string;
|
|
7
|
+
}
|
|
8
|
+
export interface ScanSummary {
|
|
9
|
+
total: number;
|
|
10
|
+
safe: number;
|
|
11
|
+
warning: number;
|
|
12
|
+
dangerous: number;
|
|
13
|
+
}
|
|
14
|
+
export declare function shouldUseColor(noColorFlag: boolean): boolean;
|
|
15
|
+
export declare function scoreLabel(score: ScanScore, color: boolean): string;
|
|
16
|
+
export declare function renderSingleScan(result: ScanRenderedResult, color: boolean): string;
|
|
17
|
+
export declare function summarizeScores(results: ScanRenderedResult[]): ScanSummary;
|
|
18
|
+
export declare function renderSummary(summary: ScanSummary, rootPath: string, color: boolean, failedFiles: string[]): string;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
const RESET = '\u001B[0m';
|
|
2
|
+
const GREEN = '\u001B[32m';
|
|
3
|
+
const YELLOW = '\u001B[33m';
|
|
4
|
+
const RED = '\u001B[31m';
|
|
5
|
+
const CYAN = '\u001B[36m';
|
|
6
|
+
const DIM = '\u001B[2m';
|
|
7
|
+
function maybeColor(enabled, color, text) {
|
|
8
|
+
return enabled ? `${color}${text}${RESET}` : text;
|
|
9
|
+
}
|
|
10
|
+
export function shouldUseColor(noColorFlag) {
|
|
11
|
+
if (noColorFlag) {
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
if (process.env.NO_COLOR && process.env.NO_COLOR.length > 0) {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
return process.stdout.isTTY !== false;
|
|
18
|
+
}
|
|
19
|
+
export function scoreLabel(score, color) {
|
|
20
|
+
if (score === 'safe') {
|
|
21
|
+
return maybeColor(color, GREEN, 'safe');
|
|
22
|
+
}
|
|
23
|
+
if (score === 'warning') {
|
|
24
|
+
return maybeColor(color, YELLOW, 'warning');
|
|
25
|
+
}
|
|
26
|
+
return maybeColor(color, RED, 'dangerous');
|
|
27
|
+
}
|
|
28
|
+
function statusLabel(status, color) {
|
|
29
|
+
return status === 'issued'
|
|
30
|
+
? maybeColor(color, CYAN, 'issued')
|
|
31
|
+
: maybeColor(color, DIM, 'disabled');
|
|
32
|
+
}
|
|
33
|
+
export function renderSingleScan(result, color) {
|
|
34
|
+
const lines = [
|
|
35
|
+
'┌─────────────────────────────────────────┐',
|
|
36
|
+
'│ SkillGuard Scan Report │',
|
|
37
|
+
'├─────────────────────────────────────────┤',
|
|
38
|
+
`│ File: ${truncate(result.file, 30).padEnd(30, ' ')} │`,
|
|
39
|
+
`│ Score: ${padInline(scoreLabel(result.score, color), 28)} │`,
|
|
40
|
+
`│ Findings: ${String(result.findings.length).padEnd(28, ' ')} │`,
|
|
41
|
+
`│ Signature:${padInline(statusLabel(result.signatureStatus, color), 28)} │`,
|
|
42
|
+
];
|
|
43
|
+
if (result.findings.length > 0) {
|
|
44
|
+
lines.push('├─────────────────────────────────────────┤');
|
|
45
|
+
for (const finding of result.findings.slice(0, 3)) {
|
|
46
|
+
const sev = (finding.severity || 'info').toUpperCase();
|
|
47
|
+
const title = finding.title || finding.description || 'Finding';
|
|
48
|
+
lines.push(`│ [${sev}] ${truncate(title, 30).padEnd(30, ' ')} │`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
lines.push('└─────────────────────────────────────────┘');
|
|
52
|
+
return lines.join('\n');
|
|
53
|
+
}
|
|
54
|
+
function truncate(value, limit) {
|
|
55
|
+
if (value.length <= limit) {
|
|
56
|
+
return value;
|
|
57
|
+
}
|
|
58
|
+
return `${value.slice(0, limit - 1)}…`;
|
|
59
|
+
}
|
|
60
|
+
function padInline(value, target) {
|
|
61
|
+
const strippedLength = value.replace(/\u001B\[[0-9;]*m/g, '').length;
|
|
62
|
+
if (strippedLength >= target) {
|
|
63
|
+
return value;
|
|
64
|
+
}
|
|
65
|
+
return value + ' '.repeat(target - strippedLength);
|
|
66
|
+
}
|
|
67
|
+
export function summarizeScores(results) {
|
|
68
|
+
const summary = {
|
|
69
|
+
total: results.length,
|
|
70
|
+
safe: 0,
|
|
71
|
+
warning: 0,
|
|
72
|
+
dangerous: 0,
|
|
73
|
+
};
|
|
74
|
+
for (const result of results) {
|
|
75
|
+
summary[result.score] += 1;
|
|
76
|
+
}
|
|
77
|
+
return summary;
|
|
78
|
+
}
|
|
79
|
+
export function renderSummary(summary, rootPath, color, failedFiles) {
|
|
80
|
+
const lines = [
|
|
81
|
+
`Scanned ${summary.total} skills in ${rootPath}`,
|
|
82
|
+
'',
|
|
83
|
+
` ${maybeColor(color, GREEN, 'safe')}: ${String(summary.safe).padStart(4, ' ')}`,
|
|
84
|
+
` ${maybeColor(color, YELLOW, 'warning')}: ${String(summary.warning).padStart(4, ' ')}`,
|
|
85
|
+
` ${maybeColor(color, RED, 'dangerous')}: ${String(summary.dangerous).padStart(4, ' ')}`,
|
|
86
|
+
];
|
|
87
|
+
if (failedFiles.length > 0) {
|
|
88
|
+
lines.push('');
|
|
89
|
+
lines.push(`Failed: ${failedFiles.join(', ')}`);
|
|
90
|
+
}
|
|
91
|
+
return lines.join('\n');
|
|
92
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export interface ParsedSkillFile {
|
|
2
|
+
normalizedContent: string;
|
|
3
|
+
body: string;
|
|
4
|
+
unsignedContent: string;
|
|
5
|
+
security: Record<string, unknown> | null;
|
|
6
|
+
}
|
|
7
|
+
export interface VerificationResult {
|
|
8
|
+
signatureValid: boolean;
|
|
9
|
+
hashMatches: boolean;
|
|
10
|
+
expired: boolean;
|
|
11
|
+
security: Record<string, unknown> | null;
|
|
12
|
+
computedHash: string;
|
|
13
|
+
}
|
|
14
|
+
export declare function parseSkillContent(content: string): ParsedSkillFile;
|
|
15
|
+
export declare function buildSignaturePayload(security: Record<string, unknown>): Record<string, unknown>;
|
|
16
|
+
export declare function verifySignature(input: {
|
|
17
|
+
parsedSkill: ParsedSkillFile;
|
|
18
|
+
jwks: {
|
|
19
|
+
keys: Array<Record<string, unknown>>;
|
|
20
|
+
};
|
|
21
|
+
}): VerificationResult;
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { createHash, createPublicKey, verify as cryptoVerify } from 'node:crypto';
|
|
2
|
+
function normalizeLineEndings(value) {
|
|
3
|
+
return value.replace(/\r\n?/g, '\n');
|
|
4
|
+
}
|
|
5
|
+
function parseScalar(raw) {
|
|
6
|
+
const value = raw.trim();
|
|
7
|
+
if (value === 'null' || value === '~') {
|
|
8
|
+
return null;
|
|
9
|
+
}
|
|
10
|
+
if (value === 'true') {
|
|
11
|
+
return true;
|
|
12
|
+
}
|
|
13
|
+
if (value === 'false') {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
if (/^-?\d+$/.test(value)) {
|
|
17
|
+
return Number.parseInt(value, 10);
|
|
18
|
+
}
|
|
19
|
+
if (/^-?\d+\.\d+$/.test(value)) {
|
|
20
|
+
return Number.parseFloat(value);
|
|
21
|
+
}
|
|
22
|
+
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
|
23
|
+
return value.slice(1, -1);
|
|
24
|
+
}
|
|
25
|
+
return value;
|
|
26
|
+
}
|
|
27
|
+
function extractFrontMatter(content) {
|
|
28
|
+
const normalized = normalizeLineEndings(content);
|
|
29
|
+
if (!normalized.startsWith('---\n')) {
|
|
30
|
+
return {
|
|
31
|
+
frontMatterLines: [],
|
|
32
|
+
body: normalized,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
const closeIdx = normalized.indexOf('\n---\n', 4);
|
|
36
|
+
if (closeIdx === -1) {
|
|
37
|
+
return {
|
|
38
|
+
frontMatterLines: [],
|
|
39
|
+
body: normalized,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
const frontMatterRaw = normalized.slice(4, closeIdx);
|
|
43
|
+
const body = normalized.slice(closeIdx + 5);
|
|
44
|
+
return {
|
|
45
|
+
frontMatterLines: frontMatterRaw.length > 0 ? frontMatterRaw.split('\n') : [],
|
|
46
|
+
body,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
function splitSecurityBlock(frontMatterLines) {
|
|
50
|
+
const withoutSecurity = [];
|
|
51
|
+
const security = {};
|
|
52
|
+
let index = 0;
|
|
53
|
+
let foundSecurity = false;
|
|
54
|
+
while (index < frontMatterLines.length) {
|
|
55
|
+
const line = frontMatterLines[index];
|
|
56
|
+
if (!foundSecurity && /^security:\s*$/.test(line)) {
|
|
57
|
+
foundSecurity = true;
|
|
58
|
+
index += 1;
|
|
59
|
+
while (index < frontMatterLines.length) {
|
|
60
|
+
const nested = frontMatterLines[index];
|
|
61
|
+
if (!/^\s+/.test(nested)) {
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
64
|
+
const match = nested.match(/^\s{2}([A-Za-z0-9_]+):\s*(.*)$/);
|
|
65
|
+
if (match) {
|
|
66
|
+
security[match[1]] = parseScalar(match[2]);
|
|
67
|
+
}
|
|
68
|
+
index += 1;
|
|
69
|
+
}
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
withoutSecurity.push(line);
|
|
73
|
+
index += 1;
|
|
74
|
+
}
|
|
75
|
+
if (!foundSecurity) {
|
|
76
|
+
return {
|
|
77
|
+
security: null,
|
|
78
|
+
withoutSecurity: frontMatterLines,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
return {
|
|
82
|
+
security,
|
|
83
|
+
withoutSecurity,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
function buildUnsignedContent(frontMatterLines, body) {
|
|
87
|
+
if (frontMatterLines.length === 0) {
|
|
88
|
+
return body;
|
|
89
|
+
}
|
|
90
|
+
return `---\n${frontMatterLines.join('\n')}\n---\n${body}`;
|
|
91
|
+
}
|
|
92
|
+
function fromBase64Url(value) {
|
|
93
|
+
const normalized = value.replace(/-/g, '+').replace(/_/g, '/');
|
|
94
|
+
const padding = normalized.length % 4;
|
|
95
|
+
const padded = padding ? `${normalized}${'='.repeat(4 - padding)}` : normalized;
|
|
96
|
+
return Buffer.from(padded, 'base64');
|
|
97
|
+
}
|
|
98
|
+
function canonicalJSONStringify(value) {
|
|
99
|
+
if (value === null || typeof value !== 'object') {
|
|
100
|
+
return JSON.stringify(value);
|
|
101
|
+
}
|
|
102
|
+
if (Array.isArray(value)) {
|
|
103
|
+
return `[${value.map((item) => canonicalJSONStringify(item)).join(',')}]`;
|
|
104
|
+
}
|
|
105
|
+
const entries = Object.entries(value).sort(([a], [b]) => a.localeCompare(b));
|
|
106
|
+
const serialized = entries.map(([key, item]) => `${JSON.stringify(key)}:${canonicalJSONStringify(item)}`);
|
|
107
|
+
return `{${serialized.join(',')}}`;
|
|
108
|
+
}
|
|
109
|
+
function sha256Hex(value) {
|
|
110
|
+
return createHash('sha256').update(value, 'utf8').digest('hex');
|
|
111
|
+
}
|
|
112
|
+
export function parseSkillContent(content) {
|
|
113
|
+
const normalizedContent = normalizeLineEndings(content);
|
|
114
|
+
const parsed = extractFrontMatter(normalizedContent);
|
|
115
|
+
const split = splitSecurityBlock(parsed.frontMatterLines);
|
|
116
|
+
return {
|
|
117
|
+
normalizedContent,
|
|
118
|
+
body: parsed.body,
|
|
119
|
+
unsignedContent: buildUnsignedContent(split.withoutSecurity, parsed.body),
|
|
120
|
+
security: split.security,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
export function buildSignaturePayload(security) {
|
|
124
|
+
return {
|
|
125
|
+
verified_by: security.verified_by,
|
|
126
|
+
version: security.version,
|
|
127
|
+
issuer: security.issuer,
|
|
128
|
+
kid: security.kid,
|
|
129
|
+
alg: security.alg,
|
|
130
|
+
scan_id: security.scan_id,
|
|
131
|
+
score: security.score,
|
|
132
|
+
scanned_at: security.scanned_at,
|
|
133
|
+
valid_until: security.valid_until,
|
|
134
|
+
content_sha256: security.content_sha256,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
function findJwkByKid(jwks, kid) {
|
|
138
|
+
if (typeof kid !== 'string' || kid.trim().length === 0) {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
for (const key of jwks.keys) {
|
|
142
|
+
if (typeof key.kid === 'string' && key.kid === kid) {
|
|
143
|
+
return key;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
export function verifySignature(input) {
|
|
149
|
+
const security = input.parsedSkill.security;
|
|
150
|
+
const computedHash = sha256Hex(input.parsedSkill.unsignedContent);
|
|
151
|
+
if (!security || typeof security.signature !== 'string') {
|
|
152
|
+
return {
|
|
153
|
+
signatureValid: false,
|
|
154
|
+
hashMatches: false,
|
|
155
|
+
expired: false,
|
|
156
|
+
security,
|
|
157
|
+
computedHash,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
const jwk = findJwkByKid(input.jwks, security.kid);
|
|
161
|
+
if (!jwk) {
|
|
162
|
+
return {
|
|
163
|
+
signatureValid: false,
|
|
164
|
+
hashMatches: false,
|
|
165
|
+
expired: false,
|
|
166
|
+
security,
|
|
167
|
+
computedHash,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
let signatureValid = false;
|
|
171
|
+
try {
|
|
172
|
+
const publicKey = createPublicKey({
|
|
173
|
+
key: jwk,
|
|
174
|
+
format: 'jwk',
|
|
175
|
+
});
|
|
176
|
+
const payload = buildSignaturePayload(security);
|
|
177
|
+
signatureValid = cryptoVerify(null, Buffer.from(canonicalJSONStringify(payload), 'utf8'), publicKey, fromBase64Url(String(security.signature)));
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
signatureValid = false;
|
|
181
|
+
}
|
|
182
|
+
const expectedHash = typeof security.content_sha256 === 'string' ? security.content_sha256 : '';
|
|
183
|
+
const hashMatches = expectedHash.length > 0 && expectedHash === computedHash;
|
|
184
|
+
const validUntil = typeof security.valid_until === 'string' ? Date.parse(security.valid_until) : NaN;
|
|
185
|
+
const expired = Number.isFinite(validUntil) ? Date.now() > validUntil : true;
|
|
186
|
+
return {
|
|
187
|
+
signatureValid,
|
|
188
|
+
hashMatches,
|
|
189
|
+
expired,
|
|
190
|
+
security,
|
|
191
|
+
computedHash,
|
|
192
|
+
};
|
|
193
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@skillguard/cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Security scanner for AI agent skill files",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"skillguard": "./dist/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"engines": {
|
|
10
|
+
"node": ">=18"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"dist/",
|
|
14
|
+
"README.md",
|
|
15
|
+
"CHANGELOG.md"
|
|
16
|
+
],
|
|
17
|
+
"scripts": {
|
|
18
|
+
"build": "tsc -p tsconfig.json",
|
|
19
|
+
"test": "npm run build && node --test test/*.node.mjs"
|
|
20
|
+
},
|
|
21
|
+
"keywords": [
|
|
22
|
+
"security",
|
|
23
|
+
"ai",
|
|
24
|
+
"agent",
|
|
25
|
+
"skill",
|
|
26
|
+
"scanner",
|
|
27
|
+
"openclaw"
|
|
28
|
+
],
|
|
29
|
+
"author": "",
|
|
30
|
+
"license": "ISC",
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"@types/node": "^24.3.0",
|
|
33
|
+
"typescript": "^5.7.3"
|
|
34
|
+
}
|
|
35
|
+
}
|