@skillguard/cli 0.1.0 → 0.2.1
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 +9 -0
- package/README.md +29 -5
- package/dist/cli.js +18 -12
- package/dist/commands/scan.d.ts +5 -2
- package/dist/commands/scan.js +53 -14
- package/dist/commands/verify.js +3 -3
- package/dist/lib/format.js +58 -26
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.0
|
|
4
|
+
|
|
5
|
+
- Added `skillguard scan` without path argument (defaults to recursive scan from current directory)
|
|
6
|
+
- Added recursive skip controls: `--skip-node-modules` and `--scan-all`
|
|
7
|
+
- Added worst-score default exit mode for scan: `safe=0`, `warning=1`, `dangerous=2`
|
|
8
|
+
- Preserved legacy threshold mode when `--fail-on` is explicitly provided
|
|
9
|
+
- Added JSON scan output fields: `worst_score`, `exit_code`, `mode`
|
|
10
|
+
- Updated non-scan failure exits across commands: usage/input/config -> `4`, network/API/runtime -> `5`
|
|
11
|
+
|
|
3
12
|
## 0.1.0
|
|
4
13
|
|
|
5
14
|
- Initial release of `@skillguard/cli`
|
package/README.md
CHANGED
|
@@ -8,6 +8,12 @@ Security scanner CLI for AI agent `SKILL.md` files.
|
|
|
8
8
|
npx @skillguard/cli scan SKILL.md
|
|
9
9
|
```
|
|
10
10
|
|
|
11
|
+
### Scan all skills in current repo
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npx @skillguard/cli scan
|
|
15
|
+
```
|
|
16
|
+
|
|
11
17
|
## Setup
|
|
12
18
|
|
|
13
19
|
```bash
|
|
@@ -40,6 +46,13 @@ npx @skillguard/cli scan ./skills --fail-on warning
|
|
|
40
46
|
npx @skillguard/cli scan ./skills --json > skillguard-report.json
|
|
41
47
|
```
|
|
42
48
|
|
|
49
|
+
### Worst-score CI exit code (recommended)
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
npx @skillguard/cli scan
|
|
53
|
+
# exit: safe=0, warning=1, dangerous=2
|
|
54
|
+
```
|
|
55
|
+
|
|
43
56
|
### Verify signature
|
|
44
57
|
|
|
45
58
|
```bash
|
|
@@ -51,13 +64,15 @@ npx @skillguard/cli verify ./SKILL.md
|
|
|
51
64
|
### scan
|
|
52
65
|
|
|
53
66
|
- `--json`
|
|
54
|
-
- `--fail-on <safe|warning|dangerous>` (
|
|
67
|
+
- `--fail-on <safe|warning|dangerous>` (optional, enables legacy threshold mode)
|
|
55
68
|
- `--timeout <ms>` (default: `30000`)
|
|
56
69
|
- `--base-url <url>` (default: `https://skillguard.ai`)
|
|
57
70
|
- `--api-key <key>`
|
|
58
71
|
- `--dry-run`
|
|
59
72
|
- `--quiet`
|
|
60
73
|
- `--no-color`
|
|
74
|
+
- `--skip-node-modules` (default: enabled)
|
|
75
|
+
- `--scan-all` (disable skip filters, scans everything recursively)
|
|
61
76
|
|
|
62
77
|
### verify
|
|
63
78
|
|
|
@@ -67,10 +82,19 @@ npx @skillguard/cli verify ./SKILL.md
|
|
|
67
82
|
|
|
68
83
|
## Exit codes
|
|
69
84
|
|
|
70
|
-
- `
|
|
71
|
-
- `
|
|
72
|
-
- `
|
|
73
|
-
- `
|
|
85
|
+
- `scan` default mode (no `--fail-on`):
|
|
86
|
+
- `0` worst score is `safe`
|
|
87
|
+
- `1` worst score is `warning`
|
|
88
|
+
- `2` worst score is `dangerous`
|
|
89
|
+
- `scan` threshold mode (`--fail-on` provided):
|
|
90
|
+
- `0` below threshold
|
|
91
|
+
- `1` threshold exceeded
|
|
92
|
+
- `verify`:
|
|
93
|
+
- `0` signature valid
|
|
94
|
+
- `1` invalid/tampered/expired signature
|
|
95
|
+
- all commands:
|
|
96
|
+
- `4` usage/input/config error
|
|
97
|
+
- `5` network/API/runtime external failure
|
|
74
98
|
|
|
75
99
|
## GitHub Actions example
|
|
76
100
|
|
package/dist/cli.js
CHANGED
|
@@ -3,13 +3,13 @@ import { parseArgs } from 'node:util';
|
|
|
3
3
|
import { runInit } from './commands/init.js';
|
|
4
4
|
import { runScan } from './commands/scan.js';
|
|
5
5
|
import { runVerify } from './commands/verify.js';
|
|
6
|
-
const VERSION = '0.
|
|
6
|
+
const VERSION = '0.2.0';
|
|
7
7
|
function printUsage() {
|
|
8
8
|
console.log(`SkillGuard CLI ${VERSION}
|
|
9
9
|
|
|
10
10
|
Usage:
|
|
11
11
|
skillguard init [--api-key <key>] [--base-url <url>]
|
|
12
|
-
skillguard scan
|
|
12
|
+
skillguard scan [path] [--json] [--fail-on <safe|warning|dangerous>] [--timeout <ms>] [--base-url <url>] [--api-key <key>] [--dry-run] [--quiet] [--no-color] [--skip-node-modules] [--scan-all]
|
|
13
13
|
skillguard verify <path> [--json] [--timeout <ms>] [--base-url <url>]
|
|
14
14
|
|
|
15
15
|
Options:
|
|
@@ -48,6 +48,8 @@ function parseShared(args) {
|
|
|
48
48
|
quiet: { type: 'boolean' },
|
|
49
49
|
'no-color': { type: 'boolean' },
|
|
50
50
|
'fail-on': { type: 'string' },
|
|
51
|
+
'scan-all': { type: 'boolean' },
|
|
52
|
+
'skip-node-modules': { type: 'boolean' },
|
|
51
53
|
timeout: { type: 'string' },
|
|
52
54
|
'base-url': { type: 'string' },
|
|
53
55
|
'api-key': { type: 'string' },
|
|
@@ -59,6 +61,9 @@ function parseShared(args) {
|
|
|
59
61
|
positionals: parsed.positionals,
|
|
60
62
|
};
|
|
61
63
|
}
|
|
64
|
+
function isFailOnExplicit(args) {
|
|
65
|
+
return args.some((arg) => arg === '--fail-on' || arg.startsWith('--fail-on='));
|
|
66
|
+
}
|
|
62
67
|
async function run() {
|
|
63
68
|
const argv = process.argv.slice(2);
|
|
64
69
|
const command = argv[0];
|
|
@@ -87,23 +92,24 @@ async function run() {
|
|
|
87
92
|
}
|
|
88
93
|
if (command === 'scan') {
|
|
89
94
|
const pathArg = positionals[0];
|
|
90
|
-
if (!pathArg) {
|
|
91
|
-
console.error('Missing required <path> argument.');
|
|
92
|
-
return 2;
|
|
93
|
-
}
|
|
94
95
|
let parsedTimeout;
|
|
95
96
|
let failOn;
|
|
96
97
|
try {
|
|
97
98
|
parsedTimeout = toPositiveInteger(typeof options.timeout === 'string' ? options.timeout : undefined, 30000);
|
|
98
|
-
|
|
99
|
+
if (isFailOnExplicit(argv.slice(1))) {
|
|
100
|
+
failOn = toFailOn(typeof options['fail-on'] === 'string' ? options['fail-on'] : undefined);
|
|
101
|
+
}
|
|
99
102
|
}
|
|
100
103
|
catch (error) {
|
|
101
104
|
console.error(error.message);
|
|
102
|
-
return
|
|
105
|
+
return 4;
|
|
103
106
|
}
|
|
104
107
|
const scanOptions = {
|
|
105
108
|
json: options.json === true,
|
|
106
109
|
failOn,
|
|
110
|
+
failOnExplicit: isFailOnExplicit(argv.slice(1)),
|
|
111
|
+
scanAll: options['scan-all'] === true,
|
|
112
|
+
skipNodeModules: options['scan-all'] === true ? false : options['skip-node-modules'] !== false,
|
|
107
113
|
timeoutMs: parsedTimeout,
|
|
108
114
|
baseUrl: typeof options['base-url'] === 'string' ? options['base-url'] : undefined,
|
|
109
115
|
apiKey: typeof options['api-key'] === 'string' ? options['api-key'] : undefined,
|
|
@@ -117,7 +123,7 @@ async function run() {
|
|
|
117
123
|
const pathArg = positionals[0];
|
|
118
124
|
if (!pathArg) {
|
|
119
125
|
console.error('Missing required <path> argument.');
|
|
120
|
-
return
|
|
126
|
+
return 4;
|
|
121
127
|
}
|
|
122
128
|
let parsedTimeout;
|
|
123
129
|
try {
|
|
@@ -125,7 +131,7 @@ async function run() {
|
|
|
125
131
|
}
|
|
126
132
|
catch (error) {
|
|
127
133
|
console.error(error.message);
|
|
128
|
-
return
|
|
134
|
+
return 4;
|
|
129
135
|
}
|
|
130
136
|
const verifyOptions = {
|
|
131
137
|
baseUrl: typeof options['base-url'] === 'string' ? options['base-url'] : undefined,
|
|
@@ -136,7 +142,7 @@ async function run() {
|
|
|
136
142
|
}
|
|
137
143
|
console.error(`Unknown command: ${command}`);
|
|
138
144
|
printUsage();
|
|
139
|
-
return
|
|
145
|
+
return 4;
|
|
140
146
|
}
|
|
141
147
|
run()
|
|
142
148
|
.then((code) => {
|
|
@@ -144,5 +150,5 @@ run()
|
|
|
144
150
|
})
|
|
145
151
|
.catch(() => {
|
|
146
152
|
console.error('Unexpected CLI error.');
|
|
147
|
-
process.exitCode =
|
|
153
|
+
process.exitCode = 5;
|
|
148
154
|
});
|
package/dist/commands/scan.d.ts
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { type ScanFinding, type ScanScore } from '../lib/api.js';
|
|
2
2
|
export interface ScanOptions {
|
|
3
3
|
json: boolean;
|
|
4
|
-
failOn
|
|
4
|
+
failOn?: ScanScore;
|
|
5
|
+
failOnExplicit: boolean;
|
|
6
|
+
scanAll: boolean;
|
|
7
|
+
skipNodeModules: boolean;
|
|
5
8
|
timeoutMs: number;
|
|
6
9
|
baseUrl?: string;
|
|
7
10
|
apiKey?: string;
|
|
@@ -15,4 +18,4 @@ export interface ScanResult {
|
|
|
15
18
|
findings: ScanFinding[];
|
|
16
19
|
signatureStatus: string;
|
|
17
20
|
}
|
|
18
|
-
export declare function runScan(inputPath: string, options: ScanOptions): Promise<number>;
|
|
21
|
+
export declare function runScan(inputPath: string | undefined, options: ScanOptions): Promise<number>;
|
package/dist/commands/scan.js
CHANGED
|
@@ -8,12 +8,26 @@ const FAIL_LEVELS = {
|
|
|
8
8
|
warning: 1,
|
|
9
9
|
dangerous: 2,
|
|
10
10
|
};
|
|
11
|
+
const WORST_EXIT_CODES = {
|
|
12
|
+
safe: 0,
|
|
13
|
+
warning: 1,
|
|
14
|
+
dangerous: 2,
|
|
15
|
+
};
|
|
11
16
|
function shouldFail(score, failOn) {
|
|
12
17
|
if (failOn === 'safe') {
|
|
13
18
|
return score !== 'safe';
|
|
14
19
|
}
|
|
15
20
|
return FAIL_LEVELS[score] >= FAIL_LEVELS[failOn];
|
|
16
21
|
}
|
|
22
|
+
function resolveWorstScore(results) {
|
|
23
|
+
if (results.some((result) => result.score === 'dangerous')) {
|
|
24
|
+
return 'dangerous';
|
|
25
|
+
}
|
|
26
|
+
if (results.some((result) => result.score === 'warning')) {
|
|
27
|
+
return 'warning';
|
|
28
|
+
}
|
|
29
|
+
return 'safe';
|
|
30
|
+
}
|
|
17
31
|
function isForbiddenFilePath(filePath) {
|
|
18
32
|
const name = basename(filePath).toLowerCase();
|
|
19
33
|
return (name === '.env' ||
|
|
@@ -28,7 +42,16 @@ function isWithinRoot(rootPath, candidatePath) {
|
|
|
28
42
|
}
|
|
29
43
|
return candidatePath.startsWith(`${rootPath}${sep}`);
|
|
30
44
|
}
|
|
31
|
-
|
|
45
|
+
function shouldSkipDirectory(entryName, options) {
|
|
46
|
+
if (options.scanAll) {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
if (options.skipNodeModules && entryName.toLowerCase() === 'node_modules') {
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
async function findSkillFiles(rootPath, options) {
|
|
32
55
|
const rootRealPath = await realpath(rootPath);
|
|
33
56
|
const queue = [rootPath];
|
|
34
57
|
const found = new Set();
|
|
@@ -44,6 +67,9 @@ async function findSkillFiles(rootPath) {
|
|
|
44
67
|
continue;
|
|
45
68
|
}
|
|
46
69
|
if (entry.isDirectory()) {
|
|
70
|
+
if (shouldSkipDirectory(entry.name, options)) {
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
47
73
|
const resolvedDir = await realpath(fullPath);
|
|
48
74
|
if (!isWithinRoot(rootRealPath, resolvedDir)) {
|
|
49
75
|
continue;
|
|
@@ -70,14 +96,14 @@ async function findSkillFiles(rootPath) {
|
|
|
70
96
|
}
|
|
71
97
|
return Array.from(found).sort();
|
|
72
98
|
}
|
|
73
|
-
async function resolveTargets(inputPath) {
|
|
99
|
+
async function resolveTargets(inputPath, traversalOptions) {
|
|
74
100
|
const path = resolve(inputPath);
|
|
75
101
|
const stat = await import('node:fs/promises').then((mod) => mod.lstat(path));
|
|
76
102
|
if (stat.isSymbolicLink()) {
|
|
77
103
|
throw new Error('Symlink paths are not supported.');
|
|
78
104
|
}
|
|
79
105
|
if (stat.isDirectory()) {
|
|
80
|
-
const files = await findSkillFiles(path);
|
|
106
|
+
const files = await findSkillFiles(path, traversalOptions);
|
|
81
107
|
if (files.length === 0) {
|
|
82
108
|
throw new Error('No SKILL.md files found in directory.');
|
|
83
109
|
}
|
|
@@ -92,21 +118,25 @@ async function resolveTargets(inputPath) {
|
|
|
92
118
|
return [path];
|
|
93
119
|
}
|
|
94
120
|
export async function runScan(inputPath, options) {
|
|
121
|
+
const scanRoot = inputPath ? resolve(inputPath) : process.cwd();
|
|
95
122
|
let baseUrl;
|
|
96
123
|
try {
|
|
97
124
|
baseUrl = normalizeBaseUrl(options.baseUrl);
|
|
98
125
|
}
|
|
99
126
|
catch (error) {
|
|
100
127
|
console.error(error.message);
|
|
101
|
-
return
|
|
128
|
+
return 4;
|
|
102
129
|
}
|
|
103
130
|
let targets;
|
|
104
131
|
try {
|
|
105
|
-
targets = await resolveTargets(
|
|
132
|
+
targets = await resolveTargets(scanRoot, {
|
|
133
|
+
scanAll: options.scanAll,
|
|
134
|
+
skipNodeModules: options.skipNodeModules,
|
|
135
|
+
});
|
|
106
136
|
}
|
|
107
137
|
catch (error) {
|
|
108
138
|
console.error(error.message);
|
|
109
|
-
return
|
|
139
|
+
return 4;
|
|
110
140
|
}
|
|
111
141
|
if (options.dryRun) {
|
|
112
142
|
if (!options.quiet) {
|
|
@@ -126,7 +156,7 @@ export async function runScan(inputPath, options) {
|
|
|
126
156
|
}
|
|
127
157
|
catch (error) {
|
|
128
158
|
console.error(error.message);
|
|
129
|
-
return
|
|
159
|
+
return 4;
|
|
130
160
|
}
|
|
131
161
|
}
|
|
132
162
|
const resolvedApiKey = resolveApiKey({
|
|
@@ -136,7 +166,7 @@ export async function runScan(inputPath, options) {
|
|
|
136
166
|
});
|
|
137
167
|
if (!resolvedApiKey) {
|
|
138
168
|
console.error("No API key found. Run 'skillguard init' or set SKILLGUARD_API_KEY.");
|
|
139
|
-
return
|
|
169
|
+
return 4;
|
|
140
170
|
}
|
|
141
171
|
const color = shouldUseColor(options.noColor);
|
|
142
172
|
const results = [];
|
|
@@ -153,7 +183,7 @@ export async function runScan(inputPath, options) {
|
|
|
153
183
|
}
|
|
154
184
|
catch (error) {
|
|
155
185
|
console.error(error.message);
|
|
156
|
-
return
|
|
186
|
+
return 5;
|
|
157
187
|
}
|
|
158
188
|
const findings = normalizeFindings(apiResponse.findings);
|
|
159
189
|
const score = toScanScore(apiResponse.score || apiResponse.overallRisk);
|
|
@@ -167,14 +197,23 @@ export async function runScan(inputPath, options) {
|
|
|
167
197
|
signatureStatus,
|
|
168
198
|
});
|
|
169
199
|
}
|
|
170
|
-
const
|
|
200
|
+
const failOn = options.failOn || 'warning';
|
|
201
|
+
const mode = options.failOnExplicit ? 'threshold' : 'worst';
|
|
202
|
+
const worstScore = resolveWorstScore(results);
|
|
203
|
+
const failedFiles = results.filter((result) => shouldFail(result.score, failOn)).map((result) => result.file);
|
|
204
|
+
const exitCode = mode === 'threshold'
|
|
205
|
+
? (failedFiles.length > 0 ? 1 : 0)
|
|
206
|
+
: WORST_EXIT_CODES[worstScore];
|
|
171
207
|
if (!options.quiet) {
|
|
172
208
|
if (options.json) {
|
|
173
209
|
const summary = summarizeScores(results);
|
|
174
210
|
console.log(JSON.stringify({
|
|
175
|
-
cli_version: '0.
|
|
211
|
+
cli_version: '0.2.0',
|
|
176
212
|
scanned_at: new Date().toISOString(),
|
|
177
|
-
|
|
213
|
+
mode,
|
|
214
|
+
worst_score: worstScore,
|
|
215
|
+
exit_code: exitCode,
|
|
216
|
+
fail_on: mode === 'threshold' ? failOn : undefined,
|
|
178
217
|
summary,
|
|
179
218
|
failed_files: failedFiles,
|
|
180
219
|
results,
|
|
@@ -185,9 +224,9 @@ export async function runScan(inputPath, options) {
|
|
|
185
224
|
console.log(renderSingleScan(result, color));
|
|
186
225
|
}
|
|
187
226
|
const summary = summarizeScores(results);
|
|
188
|
-
const rootLabel = targets.length === 1 ? targets[0] :
|
|
227
|
+
const rootLabel = targets.length === 1 ? targets[0] : scanRoot;
|
|
189
228
|
console.log(renderSummary(summary, rootLabel, color, failedFiles));
|
|
190
229
|
}
|
|
191
230
|
}
|
|
192
|
-
return
|
|
231
|
+
return exitCode;
|
|
193
232
|
}
|
package/dist/commands/verify.js
CHANGED
|
@@ -29,7 +29,7 @@ export async function runVerify(inputPath, options) {
|
|
|
29
29
|
}
|
|
30
30
|
catch (error) {
|
|
31
31
|
console.error(error.message);
|
|
32
|
-
return
|
|
32
|
+
return 4;
|
|
33
33
|
}
|
|
34
34
|
let rawContent;
|
|
35
35
|
try {
|
|
@@ -37,7 +37,7 @@ export async function runVerify(inputPath, options) {
|
|
|
37
37
|
}
|
|
38
38
|
catch {
|
|
39
39
|
console.error('Could not read file.');
|
|
40
|
-
return
|
|
40
|
+
return 4;
|
|
41
41
|
}
|
|
42
42
|
const parsedSkill = parseSkillContent(rawContent);
|
|
43
43
|
if (!parsedSkill.security || typeof parsedSkill.security.signature !== 'string') {
|
|
@@ -59,7 +59,7 @@ export async function runVerify(inputPath, options) {
|
|
|
59
59
|
}
|
|
60
60
|
catch (error) {
|
|
61
61
|
console.error(error.message);
|
|
62
|
-
return
|
|
62
|
+
return 5;
|
|
63
63
|
}
|
|
64
64
|
const verification = verifySignature({ parsedSkill, jwks });
|
|
65
65
|
const valid = verification.signatureValid && verification.hashMatches && !verification.expired;
|
package/dist/lib/format.js
CHANGED
|
@@ -4,9 +4,13 @@ const YELLOW = '\u001B[33m';
|
|
|
4
4
|
const RED = '\u001B[31m';
|
|
5
5
|
const CYAN = '\u001B[36m';
|
|
6
6
|
const DIM = '\u001B[2m';
|
|
7
|
+
const BOX_INNER_WIDTH = 41;
|
|
7
8
|
function maybeColor(enabled, color, text) {
|
|
8
9
|
return enabled ? `${color}${text}${RESET}` : text;
|
|
9
10
|
}
|
|
11
|
+
function stripAnsi(value) {
|
|
12
|
+
return value.replace(/\u001B\[[0-9;]*m/g, '');
|
|
13
|
+
}
|
|
10
14
|
export function shouldUseColor(noColorFlag) {
|
|
11
15
|
if (noColorFlag) {
|
|
12
16
|
return false;
|
|
@@ -30,40 +34,66 @@ function statusLabel(status, color) {
|
|
|
30
34
|
? maybeColor(color, CYAN, 'issued')
|
|
31
35
|
: maybeColor(color, DIM, 'disabled');
|
|
32
36
|
}
|
|
37
|
+
function truncateAscii(value, limit) {
|
|
38
|
+
if (value.length <= limit) {
|
|
39
|
+
return value;
|
|
40
|
+
}
|
|
41
|
+
if (limit <= 3) {
|
|
42
|
+
return '.'.repeat(Math.max(0, limit));
|
|
43
|
+
}
|
|
44
|
+
return `${value.slice(0, limit - 3)}...`;
|
|
45
|
+
}
|
|
46
|
+
function shortenPath(value, limit) {
|
|
47
|
+
if (value.length <= limit) {
|
|
48
|
+
return value;
|
|
49
|
+
}
|
|
50
|
+
if (limit <= 3) {
|
|
51
|
+
return '.'.repeat(Math.max(0, limit));
|
|
52
|
+
}
|
|
53
|
+
const tail = Math.min(16, Math.max(10, Math.floor(limit * 0.42)));
|
|
54
|
+
const head = Math.max(1, limit - tail - 3);
|
|
55
|
+
return `${value.slice(0, head)}...${value.slice(value.length - tail)}`;
|
|
56
|
+
}
|
|
57
|
+
function formatCell(content, width) {
|
|
58
|
+
const visible = stripAnsi(content);
|
|
59
|
+
if (visible.length >= width) {
|
|
60
|
+
return content;
|
|
61
|
+
}
|
|
62
|
+
return content + ' '.repeat(width - visible.length);
|
|
63
|
+
}
|
|
64
|
+
function boxRow(content) {
|
|
65
|
+
const clipped = truncateAscii(content, BOX_INNER_WIDTH);
|
|
66
|
+
return `│${formatCell(clipped, BOX_INNER_WIDTH)}│`;
|
|
67
|
+
}
|
|
68
|
+
function metricRow(label, value) {
|
|
69
|
+
const labelCell = `${label.padEnd(11, ' ')}`;
|
|
70
|
+
return boxRow(` ${labelCell}${value}`);
|
|
71
|
+
}
|
|
33
72
|
export function renderSingleScan(result, color) {
|
|
73
|
+
const fileDisplay = shortenPath(result.file, 28);
|
|
74
|
+
const scoreDisplay = scoreLabel(result.score, color);
|
|
75
|
+
const findingsDisplay = String(result.findings.length);
|
|
76
|
+
const signatureDisplay = statusLabel(result.signatureStatus, color);
|
|
34
77
|
const lines = [
|
|
35
|
-
'
|
|
36
|
-
'
|
|
37
|
-
'
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
78
|
+
`┌${'─'.repeat(BOX_INNER_WIDTH)}┐`,
|
|
79
|
+
boxRow(' SkillGuard Scan Report'),
|
|
80
|
+
`├${'─'.repeat(BOX_INNER_WIDTH)}┤`,
|
|
81
|
+
metricRow('File:', fileDisplay),
|
|
82
|
+
metricRow('Score:', scoreDisplay),
|
|
83
|
+
metricRow('Findings:', findingsDisplay),
|
|
84
|
+
metricRow('Signature:', signatureDisplay),
|
|
42
85
|
];
|
|
43
86
|
if (result.findings.length > 0) {
|
|
44
|
-
lines.push('
|
|
87
|
+
lines.push(`├${'─'.repeat(BOX_INNER_WIDTH)}┤`);
|
|
45
88
|
for (const finding of result.findings.slice(0, 3)) {
|
|
46
89
|
const sev = (finding.severity || 'info').toUpperCase();
|
|
47
90
|
const title = finding.title || finding.description || 'Finding';
|
|
48
|
-
lines.push(
|
|
91
|
+
lines.push(boxRow(` [${sev}] ${truncateAscii(title, 29)}`));
|
|
49
92
|
}
|
|
50
93
|
}
|
|
51
|
-
lines.push('
|
|
94
|
+
lines.push(`└${'─'.repeat(BOX_INNER_WIDTH)}┘`);
|
|
52
95
|
return lines.join('\n');
|
|
53
96
|
}
|
|
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
97
|
export function summarizeScores(results) {
|
|
68
98
|
const summary = {
|
|
69
99
|
total: results.length,
|
|
@@ -80,9 +110,11 @@ export function renderSummary(summary, rootPath, color, failedFiles) {
|
|
|
80
110
|
const lines = [
|
|
81
111
|
`Scanned ${summary.total} skills in ${rootPath}`,
|
|
82
112
|
'',
|
|
83
|
-
` ${maybeColor(color, GREEN, 'safe')}
|
|
84
|
-
` ${maybeColor(color, YELLOW, 'warning')}
|
|
85
|
-
` ${maybeColor(color, RED, 'dangerous')}
|
|
113
|
+
` ${formatCell(maybeColor(color, GREEN, 'safe:'), 11)}${String(summary.safe)}`,
|
|
114
|
+
` ${formatCell(maybeColor(color, YELLOW, 'warning:'), 11)}${String(summary.warning)}`,
|
|
115
|
+
` ${formatCell(maybeColor(color, RED, 'dangerous:'), 11)}${String(summary.dangerous)}`,
|
|
116
|
+
'',
|
|
117
|
+
`Worst score: ${summary.dangerous > 0 ? 'dangerous' : summary.warning > 0 ? 'warning' : 'safe'}`,
|
|
86
118
|
];
|
|
87
119
|
if (failedFiles.length > 0) {
|
|
88
120
|
lines.push('');
|