@perrylink/dsh-skill-pack-security-provider 2.0.0 → 2.0.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/lib/vet/checks.js +755 -0
- package/lib/vet/config.js +36 -0
- package/lib/vet/engine.js +236 -0
- package/lib/vet/fetch.js +124 -0
- package/lib/vet/manifest.js +345 -0
- package/lib/vet/redact.js +46 -0
- package/lib/vet/report.js +95 -0
- package/lib/vet/skills.js +99 -0
- package/lib/vet/source.js +218 -0
- package/lib/vet/tar.js +137 -0
- package/lib/vet/tool.js +164 -0
- package/lib/vet/vocabulary.js +19 -0
- package/lib/vet/walk.js +147 -0
- package/pack/skills/dependency-audit/SKILL.md +1 -1
- package/pack/skills/incident-response/SKILL.md +1 -1
- package/pack/skills/prompt-injection-review/SKILL.md +1 -1
- package/pack/skills/secret-scan/SKILL.md +1 -1
- package/pack/skills/security-audit/SKILL.md +1 -1
- package/pack/skills/supply-chain-review/SKILL.md +1 -1
- package/pack/skills/threat-model/SKILL.md +1 -1
- package/pack/skills/vuln-intel/SKILL.md +1 -1
- package/pack/skills-en/dependency-audit/SKILL.md +1 -1
- package/pack/skills-en/incident-response/SKILL.md +1 -1
- package/pack/skills-en/prompt-injection-review/SKILL.md +1 -1
- package/pack/skills-en/secret-scan/SKILL.md +1 -1
- package/pack/skills-en/security-audit/SKILL.md +1 -1
- package/pack/skills-en/supply-chain-review/SKILL.md +1 -1
- package/pack/skills-en/threat-model/SKILL.md +1 -1
- package/pack/skills-en/vuln-intel/SKILL.md +1 -1
- package/package.json +8 -6
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Target resolution: one `plugin_vet` target string becomes either
|
|
3
|
+
* - a local directory walk,
|
|
4
|
+
* - a GitHub repo (API metadata + codeload tarball, ref-aware), or
|
|
5
|
+
* - an npm package (registry metadata + published tarball).
|
|
6
|
+
*
|
|
7
|
+
* All remote work flows through the zero-dependency fetch helpers; every
|
|
8
|
+
* failure is typed so the caller can mark affected checks `skip` instead of
|
|
9
|
+
* inventing findings.
|
|
10
|
+
*
|
|
11
|
+
* @module dsh-skill-pack-security/vet/source
|
|
12
|
+
*/
|
|
13
|
+
import { resolve } from 'node:path';
|
|
14
|
+
import { VetFetchError, fetchBuffer, fetchText } from './fetch.js';
|
|
15
|
+
import { extractTarGz } from './tar.js';
|
|
16
|
+
import { filesFromMap, stripRoot, walkLocal } from './walk.js';
|
|
17
|
+
const GITHUB_RE = /^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)(?:@(.+))?$/;
|
|
18
|
+
const NPM_RE = /^npm:((?:@[^/]+\/)?[^@/]+)@?(.+)$/;
|
|
19
|
+
const HEX40 = /^[0-9a-f]{40}$/i;
|
|
20
|
+
function record(value) {
|
|
21
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value))
|
|
22
|
+
return {};
|
|
23
|
+
const out = {};
|
|
24
|
+
for (const [key, spec] of Object.entries(value)) {
|
|
25
|
+
if (typeof spec === 'string')
|
|
26
|
+
out[key] = spec;
|
|
27
|
+
}
|
|
28
|
+
return out;
|
|
29
|
+
}
|
|
30
|
+
async function githubMeta(owner, repo, config, signal) {
|
|
31
|
+
const url = `https://api.github.com/repos/${owner}/${repo}`;
|
|
32
|
+
const empty = {
|
|
33
|
+
exists: false, defaultBranch: '', licenseSpdx: null, licenseName: null,
|
|
34
|
+
pushedAt: '', createdAt: '', archived: false, stars: 0, description: '', rateLimited: false,
|
|
35
|
+
};
|
|
36
|
+
try {
|
|
37
|
+
const fetched = await fetchText(url, { signal, timeoutMs: config.timeoutMs, userAgent: config.userAgent, maxBytes: 512 * 1024 });
|
|
38
|
+
const json = JSON.parse(fetched.text);
|
|
39
|
+
const license = json['license'];
|
|
40
|
+
return {
|
|
41
|
+
exists: true,
|
|
42
|
+
defaultBranch: typeof json['default_branch'] === 'string' ? json['default_branch'] : 'main',
|
|
43
|
+
licenseSpdx: typeof license?.['spdx_id'] === 'string' ? license['spdx_id'] : null,
|
|
44
|
+
licenseName: typeof license?.['name'] === 'string' ? license['name'] : null,
|
|
45
|
+
pushedAt: typeof json['pushed_at'] === 'string' ? json['pushed_at'] : '',
|
|
46
|
+
createdAt: typeof json['created_at'] === 'string' ? json['created_at'] : '',
|
|
47
|
+
archived: json['archived'] === true,
|
|
48
|
+
stars: typeof json['stargazers_count'] === 'number' ? json['stargazers_count'] : 0,
|
|
49
|
+
description: typeof json['description'] === 'string' ? json['description'] : '',
|
|
50
|
+
rateLimited: false,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
if (error instanceof VetFetchError && error.kind === 'http' && error.message.includes('404')) {
|
|
55
|
+
return empty;
|
|
56
|
+
}
|
|
57
|
+
if (error instanceof VetFetchError && error.kind === 'http' && /(403|429)/.test(error.message)) {
|
|
58
|
+
// Rate-limited: fall back to rate-limit-free endpoints for the tarball
|
|
59
|
+
// and default branch; timestamps/stars stay unknown.
|
|
60
|
+
return { ...empty, exists: true, rateLimited: true, defaultBranch: await defaultBranchViaRefs(owner, repo, config, signal) };
|
|
61
|
+
}
|
|
62
|
+
throw error;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Default-branch discovery without the REST API: the git smart-HTTP refs
|
|
67
|
+
* advertisement carries `symref=HEAD:refs/heads/<branch>`. No rate limit.
|
|
68
|
+
*/
|
|
69
|
+
async function defaultBranchViaRefs(owner, repo, config, signal) {
|
|
70
|
+
try {
|
|
71
|
+
const fetched = await fetchText(`https://github.com/${owner}/${repo}.git/info/refs?service=git-upload-pack`, { signal, timeoutMs: config.timeoutMs, userAgent: 'git/dsh-skill-pack-security', maxBytes: 1024 * 1024 });
|
|
72
|
+
const symref = /symref=HEAD:refs\/heads\/([^\s]+)/.exec(fetched.text);
|
|
73
|
+
if (symref !== null)
|
|
74
|
+
return symref[1];
|
|
75
|
+
const main = /refs\/heads\/(main|master)\s*$/.exec(fetched.text);
|
|
76
|
+
if (main !== null)
|
|
77
|
+
return main[1];
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
// best effort; caller falls back to 'main'
|
|
81
|
+
}
|
|
82
|
+
return 'main';
|
|
83
|
+
}
|
|
84
|
+
function stripPrefix(files, prefix) {
|
|
85
|
+
if (prefix === '')
|
|
86
|
+
return files;
|
|
87
|
+
const out = new Map();
|
|
88
|
+
for (const [path, content] of files) {
|
|
89
|
+
if (path.startsWith(`${prefix}/`))
|
|
90
|
+
out.set(path.slice(prefix.length + 1), content);
|
|
91
|
+
else
|
|
92
|
+
out.set(path, content);
|
|
93
|
+
}
|
|
94
|
+
return out;
|
|
95
|
+
}
|
|
96
|
+
async function resolveRemote(kind, resolved, ref, tarballUrl, config, signal, github, npm) {
|
|
97
|
+
let fetched;
|
|
98
|
+
try {
|
|
99
|
+
// Tarballs are the slow part of a scan: give them triple the cooperative
|
|
100
|
+
// timeout while keeping the caller's AbortSignal authoritative.
|
|
101
|
+
fetched = await fetchBuffer(tarballUrl, {
|
|
102
|
+
signal, timeoutMs: config.timeoutMs * 3, userAgent: config.userAgent, maxBytes: config.maxExtractBytes,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
catch (error) {
|
|
106
|
+
if (error instanceof VetFetchError && error.kind === 'http' && error.message.includes('404')) {
|
|
107
|
+
return { kind, resolved, ref, files: [], budget: { filesScanned: 0, filesSkipped: 0, bytesScanned: 0, truncated: false, truncatedReason: `tarball not found for ${resolved}@${ref}` }, github, npm };
|
|
108
|
+
}
|
|
109
|
+
throw error;
|
|
110
|
+
}
|
|
111
|
+
const extracted = extractTarGz(fetched.buffer, {
|
|
112
|
+
maxTotalBytes: config.maxExtractBytes,
|
|
113
|
+
maxFileBytes: config.maxFileBytes,
|
|
114
|
+
maxFiles: config.maxFiles,
|
|
115
|
+
});
|
|
116
|
+
const root = stripRoot(extracted.files);
|
|
117
|
+
const stripped = stripPrefix(extracted.files, root);
|
|
118
|
+
const { files, budget } = filesFromMap(stripped, config.maxFileBytes);
|
|
119
|
+
if (extracted.truncated && budget.truncatedReason === undefined) {
|
|
120
|
+
budget.truncated = true;
|
|
121
|
+
budget.truncatedReason = 'tarball extraction hit the byte/file budget';
|
|
122
|
+
}
|
|
123
|
+
return { kind, resolved, ref, files, budget, github, npm };
|
|
124
|
+
}
|
|
125
|
+
async function npmMeta(name, version, config, signal) {
|
|
126
|
+
const escaped = name.startsWith('@') ? name.replace('/', '%2F') : name;
|
|
127
|
+
const url = `https://registry.npmjs.org/${escaped}/${version}`;
|
|
128
|
+
const empty = {
|
|
129
|
+
exists: false, name, version, license: '', gitHead: '', repository: '',
|
|
130
|
+
scripts: {}, dependencies: {}, devDependencies: {}, distIntegrity: '', timeModified: '', deprecated: '',
|
|
131
|
+
};
|
|
132
|
+
try {
|
|
133
|
+
const fetched = await fetchText(url, { signal, timeoutMs: config.timeoutMs, userAgent: config.userAgent, maxBytes: 8 * 1024 * 1024 });
|
|
134
|
+
const json = JSON.parse(fetched.text);
|
|
135
|
+
const repository = json['repository'];
|
|
136
|
+
let repositoryString = '';
|
|
137
|
+
if (typeof repository === 'string')
|
|
138
|
+
repositoryString = repository;
|
|
139
|
+
else if (typeof repository === 'object' && repository !== null && !Array.isArray(repository)) {
|
|
140
|
+
const repoUrl = repository['url'];
|
|
141
|
+
if (typeof repoUrl === 'string')
|
|
142
|
+
repositoryString = repoUrl;
|
|
143
|
+
}
|
|
144
|
+
const dist = json['dist'];
|
|
145
|
+
const license = json['license'];
|
|
146
|
+
let licenseString = '';
|
|
147
|
+
if (typeof license === 'string')
|
|
148
|
+
licenseString = license;
|
|
149
|
+
else if (typeof license === 'object' && license !== null && !Array.isArray(license)) {
|
|
150
|
+
const type = license['type'];
|
|
151
|
+
if (typeof type === 'string')
|
|
152
|
+
licenseString = type;
|
|
153
|
+
}
|
|
154
|
+
return {
|
|
155
|
+
exists: true,
|
|
156
|
+
name: typeof json['name'] === 'string' ? json['name'] : name,
|
|
157
|
+
version: typeof json['version'] === 'string' ? json['version'] : version,
|
|
158
|
+
license: licenseString,
|
|
159
|
+
gitHead: typeof json['gitHead'] === 'string' ? json['gitHead'] : '',
|
|
160
|
+
repository: repositoryString,
|
|
161
|
+
scripts: record(json['scripts']),
|
|
162
|
+
dependencies: record(json['dependencies']),
|
|
163
|
+
devDependencies: record(json['devDependencies']),
|
|
164
|
+
distIntegrity: typeof dist?.['integrity'] === 'string' ? dist['integrity'] : '',
|
|
165
|
+
timeModified: '',
|
|
166
|
+
deprecated: '',
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
catch (error) {
|
|
170
|
+
if (error instanceof VetFetchError && error.kind === 'http' && error.message.includes('404'))
|
|
171
|
+
return empty;
|
|
172
|
+
throw error;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
/** Resolve one target string into scan inputs. Never throws for network skips; hard budget violations propagate. */
|
|
176
|
+
export async function resolveTarget(raw, config, signal) {
|
|
177
|
+
const trimmed = raw.trim();
|
|
178
|
+
const npmMatch = NPM_RE.exec(trimmed);
|
|
179
|
+
if (npmMatch !== null) {
|
|
180
|
+
const name = npmMatch[1];
|
|
181
|
+
const version = npmMatch[2];
|
|
182
|
+
const meta = await npmMeta(name, version, config, signal);
|
|
183
|
+
if (!meta.exists) {
|
|
184
|
+
return {
|
|
185
|
+
kind: 'npm-package', resolved: `${name}@${version}`, ref: version, files: [],
|
|
186
|
+
budget: { filesScanned: 0, filesSkipped: 0, bytesScanned: 0, truncated: false, truncatedReason: 'package not found on the npm registry' },
|
|
187
|
+
github: null, npm: meta,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
const escaped = name.startsWith('@') ? name.replace('/', '%2F') : name;
|
|
191
|
+
const tarball = `https://registry.npmjs.org/${escaped}/-/${name.split('/').pop()}-${version}.tgz`;
|
|
192
|
+
return resolveRemote('npm-package', `${name}@${version}`, version, tarball, config, signal, null, meta);
|
|
193
|
+
}
|
|
194
|
+
const githubMatch = GITHUB_RE.exec(trimmed);
|
|
195
|
+
if (githubMatch !== null) {
|
|
196
|
+
const owner = githubMatch[1];
|
|
197
|
+
const repo = githubMatch[2];
|
|
198
|
+
const meta = await githubMeta(owner, repo, config, signal);
|
|
199
|
+
if (!meta.exists) {
|
|
200
|
+
return {
|
|
201
|
+
kind: 'github-repo', resolved: `${owner}/${repo}`, ref: githubMatch[3] ?? '', files: [],
|
|
202
|
+
budget: { filesScanned: 0, filesSkipped: 0, bytesScanned: 0, truncated: false, truncatedReason: 'repository not found on GitHub' },
|
|
203
|
+
github: meta, npm: null,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
const ref = githubMatch[3] ?? meta.defaultBranch;
|
|
207
|
+
const tarball = `https://codeload.github.com/${owner}/${repo}/tar.gz/${ref}`;
|
|
208
|
+
return resolveRemote('github-repo', `${owner}/${repo}`, ref, tarball, config, signal, meta, null);
|
|
209
|
+
}
|
|
210
|
+
// Local path fallback: relative paths resolve against the process cwd.
|
|
211
|
+
const abs = resolve(trimmed);
|
|
212
|
+
const { files, budget } = await walkLocal(abs, config.maxFiles, config.maxFileBytes);
|
|
213
|
+
return { kind: 'local-path', resolved: abs, ref: '', files, budget, github: null, npm: null };
|
|
214
|
+
}
|
|
215
|
+
/** Whether a ref string is a 40-hex immutable commit. */
|
|
216
|
+
export function isCommitRef(ref) {
|
|
217
|
+
return HEX40.test(ref.trim());
|
|
218
|
+
}
|
package/lib/vet/tar.js
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal gzip + ustar (POSIX tar) extractor — zero dependencies.
|
|
3
|
+
*
|
|
4
|
+
* Only the pieces needed to unpack registry/codeload tarballs: regular files,
|
|
5
|
+
* directories, symlinks (recorded, never followed), GNU long-name (`L`) and
|
|
6
|
+
* pax (`x`/`g`) extended headers. Hard constraints:
|
|
7
|
+
* - path traversal is rejected (absolute paths, `..`, drive letters);
|
|
8
|
+
* - total bytes, per-file bytes, and file count are capped;
|
|
9
|
+
* - symlink targets are stored as strings only (never materialized).
|
|
10
|
+
*
|
|
11
|
+
* @module dsh-skill-pack-security/vet/tar
|
|
12
|
+
*/
|
|
13
|
+
import { gunzipSync } from 'node:zlib';
|
|
14
|
+
class TarError extends Error {
|
|
15
|
+
constructor(message) {
|
|
16
|
+
super(message);
|
|
17
|
+
this.name = 'TarError';
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
const BLOCK = 512;
|
|
21
|
+
/** Decode an ASCII/UTF-8 field, trimming NULs and spaces. */
|
|
22
|
+
function field(buffer, start, length) {
|
|
23
|
+
let end = start + length;
|
|
24
|
+
while (end > start && (buffer[end - 1] === 0x00 || buffer[end - 1] === 0x20))
|
|
25
|
+
end -= 1;
|
|
26
|
+
return new TextDecoder().decode(buffer.subarray(start, end));
|
|
27
|
+
}
|
|
28
|
+
/** Parse a zero-padded octal size field. */
|
|
29
|
+
function octalSize(buffer, start, length) {
|
|
30
|
+
const raw = field(buffer, start, length);
|
|
31
|
+
const clean = raw.replace(/\0/g, '').trim();
|
|
32
|
+
const value = /^[0-7]+$/.test(clean) ? Number.parseInt(clean, 8) : NaN;
|
|
33
|
+
return Number.isFinite(value) && value >= 0 ? value : 0;
|
|
34
|
+
}
|
|
35
|
+
/** Reject traversal/absolute/Windows-drive paths before they enter the map. */
|
|
36
|
+
function safePath(path) {
|
|
37
|
+
if (path.includes('\0'))
|
|
38
|
+
throw new TarError('tar entry path contains NUL');
|
|
39
|
+
const normalized = path.replaceAll('\\', '/');
|
|
40
|
+
const segments = normalized.split('/');
|
|
41
|
+
for (const segment of segments) {
|
|
42
|
+
if (segment === '..' || segment === '')
|
|
43
|
+
continue;
|
|
44
|
+
if (/^[A-Za-z]:$/.test(segment))
|
|
45
|
+
throw new TarError(`tar entry escapes with a drive letter: ${path}`);
|
|
46
|
+
}
|
|
47
|
+
if (segments.includes('..'))
|
|
48
|
+
throw new TarError(`tar entry path escapes the root: ${path}`);
|
|
49
|
+
if (normalized.startsWith('/'))
|
|
50
|
+
throw new TarError(`tar entry uses an absolute path: ${path}`);
|
|
51
|
+
return normalized;
|
|
52
|
+
}
|
|
53
|
+
/** Extract one gzipped ustar archive into an in-memory file map. */
|
|
54
|
+
export function extractTarGz(gzipped, budget) {
|
|
55
|
+
let raw;
|
|
56
|
+
try {
|
|
57
|
+
raw = gunzipSync(gzipped);
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
throw new TarError(`not a gzip stream: ${error instanceof Error ? error.message : String(error)}`);
|
|
61
|
+
}
|
|
62
|
+
const files = new Map();
|
|
63
|
+
const symlinks = new Map();
|
|
64
|
+
let truncated = false;
|
|
65
|
+
let totalBytes = 0;
|
|
66
|
+
let offset = 0;
|
|
67
|
+
let pendingName;
|
|
68
|
+
let seenEnd = false;
|
|
69
|
+
while (offset + BLOCK <= raw.length) {
|
|
70
|
+
const header = raw.subarray(offset, offset + BLOCK);
|
|
71
|
+
if (header.every(byte => byte === 0)) {
|
|
72
|
+
// Two consecutive zero blocks end the archive.
|
|
73
|
+
const next = raw.subarray(offset + BLOCK, offset + 2 * BLOCK);
|
|
74
|
+
if (next.length === 0 || next.every(byte => byte === 0)) {
|
|
75
|
+
seenEnd = true;
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
const nameField = field(header, 0, 100);
|
|
80
|
+
const size = octalSize(header, 124, 12);
|
|
81
|
+
const typeflag = String.fromCharCode(header[156] ?? 0);
|
|
82
|
+
const linkname = field(header, 157, 100);
|
|
83
|
+
offset += BLOCK;
|
|
84
|
+
if (typeflag === 'L') {
|
|
85
|
+
// GNU long name: the payload of this entry is the real path.
|
|
86
|
+
if (size > 1024 * 1024)
|
|
87
|
+
throw new TarError('oversized long-name entry');
|
|
88
|
+
const body = raw.subarray(offset, offset + size);
|
|
89
|
+
pendingName = new TextDecoder().decode(body).replaceAll('\0', '').trim();
|
|
90
|
+
offset += Math.ceil(size / BLOCK) * BLOCK;
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (typeflag === 'x' || typeflag === 'g') {
|
|
94
|
+
// pax extended header: only "path=" matters to us.
|
|
95
|
+
const body = new TextDecoder().decode(raw.subarray(offset, offset + size));
|
|
96
|
+
const pathMatch = /(?:^|\n)\d+ path=([^\n]+)/.exec(body);
|
|
97
|
+
if (pathMatch !== null)
|
|
98
|
+
pendingName = pathMatch[1];
|
|
99
|
+
offset += Math.ceil(size / BLOCK) * BLOCK;
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
if (typeflag !== '0' && typeflag !== '\0' && typeflag !== '5' && typeflag !== '2') {
|
|
103
|
+
// Device nodes, fifos, hard links, sparse files, etc. — skip payload.
|
|
104
|
+
offset += Math.ceil(size / BLOCK) * BLOCK;
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
const path = safePath(pendingName ?? nameField);
|
|
108
|
+
pendingName = undefined;
|
|
109
|
+
if (typeflag === '2') {
|
|
110
|
+
symlinks.set(path, linkname);
|
|
111
|
+
offset += Math.ceil(size / BLOCK) * BLOCK;
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
if (typeflag === '5') {
|
|
115
|
+
// Directory entry: no payload to store.
|
|
116
|
+
offset += Math.ceil(size / BLOCK) * BLOCK;
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
if (size > budget.maxFileBytes) {
|
|
120
|
+
truncated = true;
|
|
121
|
+
offset += Math.ceil(size / BLOCK) * BLOCK;
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
if (totalBytes + size > budget.maxTotalBytes || files.size >= budget.maxFiles) {
|
|
125
|
+
truncated = true;
|
|
126
|
+
offset += Math.ceil(size / BLOCK) * BLOCK;
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
const content = raw.subarray(offset, offset + size);
|
|
130
|
+
files.set(path, content);
|
|
131
|
+
totalBytes += size;
|
|
132
|
+
offset += Math.ceil(size / BLOCK) * BLOCK;
|
|
133
|
+
}
|
|
134
|
+
if (!seenEnd)
|
|
135
|
+
throw new TarError('archive ended prematurely (truncated tar stream)');
|
|
136
|
+
return { files, symlinks, truncated, totalBytes };
|
|
137
|
+
}
|
package/lib/vet/tool.js
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `plugin_vet` tool definition: schema-validated arguments, a strictly
|
|
3
|
+
* validated canonical report, a model-facing markdown render, and the
|
|
4
|
+
* pending/completed UI cards. This is the only module in the vet engine that
|
|
5
|
+
* imports harness packages; everything below it is plain zero-dependency code.
|
|
6
|
+
*
|
|
7
|
+
* @module dsh-skill-pack-security/vet/tool
|
|
8
|
+
*/
|
|
9
|
+
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
10
|
+
import { runVet } from './engine.js';
|
|
11
|
+
import { renderReport } from './report.js';
|
|
12
|
+
/** Output-side JSON Schema dialect entry for one finding. */
|
|
13
|
+
const FINDING_SCHEMA = {
|
|
14
|
+
type: 'object',
|
|
15
|
+
additionalProperties: false,
|
|
16
|
+
properties: {
|
|
17
|
+
level: { type: 'string', required: true, enum: ['fail', 'warn', 'info'] },
|
|
18
|
+
message: { type: 'string', required: true },
|
|
19
|
+
location: { type: 'string' },
|
|
20
|
+
skill: { type: 'string', required: true },
|
|
21
|
+
evidence: { type: 'string' },
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
const CHECK_SCHEMA = {
|
|
25
|
+
type: 'object',
|
|
26
|
+
additionalProperties: false,
|
|
27
|
+
properties: {
|
|
28
|
+
id: { type: 'string', required: true },
|
|
29
|
+
name: { type: 'string', required: true },
|
|
30
|
+
verdict: { type: 'string', required: true, enum: ['pass', 'warn', 'fail', 'skip'] },
|
|
31
|
+
skipReason: { type: 'string' },
|
|
32
|
+
score: { type: 'integer', required: true },
|
|
33
|
+
findings: { type: 'array', required: true, items: FINDING_SCHEMA },
|
|
34
|
+
truncatedFindings: { type: 'boolean', required: true },
|
|
35
|
+
skill: { type: 'string', required: true },
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
const REPORT_SCHEMA = {
|
|
39
|
+
type: 'object',
|
|
40
|
+
additionalProperties: false,
|
|
41
|
+
properties: {
|
|
42
|
+
kind: { type: 'string', required: true, const: 'vet-report' },
|
|
43
|
+
target: {
|
|
44
|
+
type: 'object', required: true, additionalProperties: false,
|
|
45
|
+
properties: {
|
|
46
|
+
raw: { type: 'string', required: true },
|
|
47
|
+
kind: { type: 'string', required: true, enum: ['github-repo', 'local-path', 'npm-package'] },
|
|
48
|
+
resolved: { type: 'string', required: true },
|
|
49
|
+
ref: { type: 'string', required: true },
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
fetchedAt: { type: 'string', required: true },
|
|
53
|
+
checks: { type: 'array', required: true, items: CHECK_SCHEMA },
|
|
54
|
+
scores: {
|
|
55
|
+
type: 'object', required: true, additionalProperties: false,
|
|
56
|
+
properties: {
|
|
57
|
+
license: { type: 'integer', required: true },
|
|
58
|
+
source: { type: 'integer', required: true },
|
|
59
|
+
dependencies: { type: 'integer', required: true },
|
|
60
|
+
'build-scripts': { type: 'integer', required: true },
|
|
61
|
+
maintenance: { type: 'integer', required: true },
|
|
62
|
+
overall: { type: 'integer', required: true },
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
verdict: { type: 'string', required: true, enum: ['pass', 'warn', 'fail', 'skip'] },
|
|
66
|
+
gate: {
|
|
67
|
+
type: 'object', required: true, additionalProperties: false,
|
|
68
|
+
properties: {
|
|
69
|
+
policy: { type: 'string', required: true, enum: ['warn', 'deny'] },
|
|
70
|
+
applied: { type: 'boolean', required: true },
|
|
71
|
+
blocked: { type: 'boolean', required: true },
|
|
72
|
+
reason: { type: 'string' },
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
sbom: {
|
|
76
|
+
type: 'object', required: true, additionalProperties: false,
|
|
77
|
+
properties: {
|
|
78
|
+
lockfile: { oneOf: [{ type: 'string' }, { type: 'null' }], required: true },
|
|
79
|
+
lockfileVersion: { type: 'string' },
|
|
80
|
+
directDependencies: { type: 'integer', required: true },
|
|
81
|
+
directDevDependencies: { type: 'integer', required: true },
|
|
82
|
+
packages: {
|
|
83
|
+
type: 'array', required: true,
|
|
84
|
+
items: {
|
|
85
|
+
type: 'object', additionalProperties: false,
|
|
86
|
+
properties: {
|
|
87
|
+
name: { type: 'string', required: true },
|
|
88
|
+
version: { type: 'string', required: true },
|
|
89
|
+
depth: { type: 'integer', required: true },
|
|
90
|
+
license: { type: 'string' },
|
|
91
|
+
dev: { type: 'boolean', required: true },
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
truncated: { type: 'boolean', required: true },
|
|
96
|
+
totalPackages: { type: 'integer', required: true },
|
|
97
|
+
unpinned: { type: 'array', required: true, items: { type: 'string' } },
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
budget: {
|
|
101
|
+
type: 'object', required: true, additionalProperties: false,
|
|
102
|
+
properties: {
|
|
103
|
+
filesScanned: { type: 'integer', required: true },
|
|
104
|
+
filesSkipped: { type: 'integer', required: true },
|
|
105
|
+
bytesScanned: { type: 'integer', required: true },
|
|
106
|
+
truncated: { type: 'boolean', required: true },
|
|
107
|
+
truncatedReason: { type: 'string' },
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
followupSkills: { type: 'array', required: true, items: { type: 'string' } },
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
function textBlock(text) {
|
|
114
|
+
return { type: 'text', text };
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Build the plugin_vet tool bound to the resolved plugin configuration.
|
|
118
|
+
* @param config - resolved vet configuration (defaults already applied).
|
|
119
|
+
* @param lang - report language, driven by the plugin's `language` config.
|
|
120
|
+
*/
|
|
121
|
+
export function buildVetTool(config, lang) {
|
|
122
|
+
return defineTool({
|
|
123
|
+
name: 'plugin_vet',
|
|
124
|
+
description: 'Supply-chain security gate for DSH plugin repositories/packages. Scans a target (GitHub `owner/repo[@ref]`, an `npm:name@version` package, or a local path) and returns a five-dimension risk report: license, source, dependencies, build scripts, maintenance. Checks: LICENSE/SPDX detection (missing/unknown/NOASSERTION flagged), SBOM dependency tree, 40-hex commit pinning of install references, dangerous postinstall/preinstall scripts, network-exfiltration domains, obfuscated code, source trust signals, maintenance status. Each finding cites the matching dsh-skill-pack-security skill section for a manual deep-dive. Use it BEFORE installing any plugin (`dsh plugin add`); a FAIL verdict warns or blocks depending on the configured gate policy (default warn). Read-only: never modifies the target. Respects timeouts; bounded network use.',
|
|
125
|
+
parameters: {
|
|
126
|
+
target: {
|
|
127
|
+
type: 'string',
|
|
128
|
+
required: true,
|
|
129
|
+
description: 'Target to vet: GitHub `owner/repo` or `owner/repo@ref`, npm package `npm:name@version`, or a local absolute/relative path.',
|
|
130
|
+
},
|
|
131
|
+
ref: {
|
|
132
|
+
type: 'string',
|
|
133
|
+
description: 'Optional git ref override for GitHub targets (prefer a 40-hex commit). Defaults to the repository default branch.',
|
|
134
|
+
},
|
|
135
|
+
checks: {
|
|
136
|
+
type: 'array',
|
|
137
|
+
items: { type: 'string' },
|
|
138
|
+
description: 'Optional subset of check ids: license, sbom, commit-lock, install-scripts, network-exfil, obfuscation, source, maintenance. Default: all.',
|
|
139
|
+
},
|
|
140
|
+
policy: {
|
|
141
|
+
type: 'string',
|
|
142
|
+
enum: ['inherit', 'warn', 'deny'],
|
|
143
|
+
description: 'Per-call gate policy override. inherit (default) uses the configured gate.policy; deny blocks installation on FAIL.',
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
output: {
|
|
147
|
+
schema: REPORT_SCHEMA,
|
|
148
|
+
render: (_args, value) => [textBlock(renderReport(value, lang))],
|
|
149
|
+
},
|
|
150
|
+
timeoutMs: Math.max(config.timeoutMs * 4 + 5000, 30000),
|
|
151
|
+
async execute(args, exec) {
|
|
152
|
+
return await runVet({ target: args.target, ref: args.ref, checks: args.checks, policy: args.policy }, config, lang, exec.signal);
|
|
153
|
+
},
|
|
154
|
+
presentCall(args) {
|
|
155
|
+
return { card: 'generic', kind: 'search', title: `plugin_vet ${args.target}`, rawInput: { target: args.target } };
|
|
156
|
+
},
|
|
157
|
+
presentResult(args, result) {
|
|
158
|
+
const content = result.content;
|
|
159
|
+
const firstText = content.find(block => block.type === 'text');
|
|
160
|
+
const verdict = /Verdict:\s*(\w+)/.exec(firstText?.text ?? '')?.[1]?.toUpperCase() ?? 'done';
|
|
161
|
+
return { card: 'generic', title: `plugin_vet ${args.target}: ${verdict}`, content };
|
|
162
|
+
},
|
|
163
|
+
});
|
|
164
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared vocabulary for the plugin_vet supply-chain gate.
|
|
3
|
+
*
|
|
4
|
+
* Everything here is plain data (JSON-safe, no live runtime objects) so the
|
|
5
|
+
* scan engine stays a pure, harness-free module: the tool definition in
|
|
6
|
+
* `tool.ts` is the only part that touches ctx/defineTool.
|
|
7
|
+
*
|
|
8
|
+
* @module dsh-skill-pack-security/vet/vocabulary
|
|
9
|
+
*/
|
|
10
|
+
export const ALL_CHECK_IDS = [
|
|
11
|
+
'license',
|
|
12
|
+
'sbom',
|
|
13
|
+
'commit-lock',
|
|
14
|
+
'install-scripts',
|
|
15
|
+
'network-exfil',
|
|
16
|
+
'obfuscation',
|
|
17
|
+
'source',
|
|
18
|
+
'maintenance',
|
|
19
|
+
];
|