@perrylink/dsh-skill-pack-security-provider 1.3.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/index.js +36 -8
- package/lib/types/index.d.ts +20 -8
- package/lib/types/vet/checks.d.ts +43 -0
- package/lib/types/vet/config.d.ts +41 -0
- package/lib/types/vet/engine.d.ts +24 -0
- package/lib/types/vet/fetch.d.ts +42 -0
- package/lib/types/vet/manifest.d.ts +53 -0
- package/lib/types/vet/redact.d.ts +14 -0
- package/lib/types/vet/report.d.ts +17 -0
- package/lib/types/vet/skills.d.ts +56 -0
- package/lib/types/vet/source.d.ts +58 -0
- package/lib/types/vet/tar.d.ts +35 -0
- package/lib/types/vet/tool.d.ts +16 -0
- package/lib/types/vet/vocabulary.d.ts +123 -0
- package/lib/types/vet/walk.d.ts +37 -0
- 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 +5 -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 +5 -1
- package/pack/skills/supply-chain-review/SKILL.md +5 -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 +5 -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 +5 -1
- package/pack/skills-en/supply-chain-review/SKILL.md +5 -1
- package/pack/skills-en/threat-model/SKILL.md +1 -1
- package/pack/skills-en/vuln-intel/SKILL.md +1 -1
- package/package.json +47 -4
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Manifest and lockfile parsing for the SBOM check — pure text/JSON parsing,
|
|
3
|
+
* zero dependencies. Supports package.json plus pnpm-lock.yaml,
|
|
4
|
+
* package-lock.json (v1–v3) and yarn.lock (v1); unknown formats are reported
|
|
5
|
+
* as `unsupported`, never guessed.
|
|
6
|
+
*
|
|
7
|
+
* @module dsh-skill-pack-security/vet/manifest
|
|
8
|
+
*/
|
|
9
|
+
const LOCKFILE_NAMES = ['pnpm-lock.yaml', 'package-lock.json', 'yarn.lock', 'npm-shrinkwrap.json', 'bun.lockb'];
|
|
10
|
+
/** Find and parse the first supported lockfile in the scan set. */
|
|
11
|
+
export function parseLockfile(files) {
|
|
12
|
+
for (const name of LOCKFILE_NAMES) {
|
|
13
|
+
const hit = files.find(file => file.path === name || file.path.endsWith(`/${name}`));
|
|
14
|
+
if (hit === undefined || hit.text === null)
|
|
15
|
+
continue;
|
|
16
|
+
if (name === 'pnpm-lock.yaml')
|
|
17
|
+
return parsePnpmLock(hit.text);
|
|
18
|
+
if (name === 'package-lock.json' || name === 'npm-shrinkwrap.json')
|
|
19
|
+
return parseNpmLock(hit.text, name);
|
|
20
|
+
if (name === 'yarn.lock')
|
|
21
|
+
return parseYarnLock(hit.text);
|
|
22
|
+
if (name === 'bun.lockb') {
|
|
23
|
+
return { kind: 'unsupported', lockfile: name, lockfileVersion: '', entries: new Map(), hasIntegrity: false };
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return { kind: null, lockfile: null, lockfileVersion: '', entries: new Map(), hasIntegrity: false };
|
|
27
|
+
}
|
|
28
|
+
/** Parse package.json when present. */
|
|
29
|
+
export function parseManifest(files) {
|
|
30
|
+
const hit = files.find(file => file.path === 'package.json' || file.path.endsWith('/package.json'));
|
|
31
|
+
const empty = {
|
|
32
|
+
name: '', version: '', license: '', scripts: {},
|
|
33
|
+
dependencies: {}, devDependencies: {}, optionalDependencies: {}, peerDependencies: {},
|
|
34
|
+
repository: '', present: false,
|
|
35
|
+
};
|
|
36
|
+
if (hit === undefined || hit.text === null)
|
|
37
|
+
return empty;
|
|
38
|
+
let json;
|
|
39
|
+
try {
|
|
40
|
+
json = JSON.parse(hit.text);
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return empty;
|
|
44
|
+
}
|
|
45
|
+
if (typeof json !== 'object' || json === null || Array.isArray(json))
|
|
46
|
+
return empty;
|
|
47
|
+
const pkg = json;
|
|
48
|
+
const record = (value) => {
|
|
49
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value))
|
|
50
|
+
return {};
|
|
51
|
+
const out = {};
|
|
52
|
+
for (const [key, spec] of Object.entries(value)) {
|
|
53
|
+
if (typeof spec === 'string')
|
|
54
|
+
out[key] = spec;
|
|
55
|
+
}
|
|
56
|
+
return out;
|
|
57
|
+
};
|
|
58
|
+
const licenseField = pkg['license'];
|
|
59
|
+
let license = '';
|
|
60
|
+
if (typeof licenseField === 'string')
|
|
61
|
+
license = licenseField;
|
|
62
|
+
else if (typeof licenseField === 'object' && licenseField !== null && !Array.isArray(licenseField)) {
|
|
63
|
+
const type = licenseField['type'];
|
|
64
|
+
if (typeof type === 'string')
|
|
65
|
+
license = type;
|
|
66
|
+
}
|
|
67
|
+
const repository = pkg['repository'];
|
|
68
|
+
let repositoryString = '';
|
|
69
|
+
if (typeof repository === 'string')
|
|
70
|
+
repositoryString = repository;
|
|
71
|
+
else if (typeof repository === 'object' && repository !== null && !Array.isArray(repository)) {
|
|
72
|
+
const url = repository['url'];
|
|
73
|
+
if (typeof url === 'string')
|
|
74
|
+
repositoryString = url;
|
|
75
|
+
}
|
|
76
|
+
return {
|
|
77
|
+
name: typeof pkg['name'] === 'string' ? pkg['name'] : '',
|
|
78
|
+
version: typeof pkg['version'] === 'string' ? pkg['version'] : '',
|
|
79
|
+
license,
|
|
80
|
+
scripts: record(pkg['scripts']),
|
|
81
|
+
dependencies: record(pkg['dependencies']),
|
|
82
|
+
devDependencies: record(pkg['devDependencies']),
|
|
83
|
+
optionalDependencies: record(pkg['optionalDependencies']),
|
|
84
|
+
peerDependencies: record(pkg['peerDependencies']),
|
|
85
|
+
repository: repositoryString,
|
|
86
|
+
present: true,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
// --- pnpm-lock.yaml ----------------------------------------------------------
|
|
90
|
+
/** Normalize a pnpm packages-section key to `name@version` (strip `/` and `(suffix)`). */
|
|
91
|
+
function normalizePnpmKey(key) {
|
|
92
|
+
const trimmed = key.replace(/^['"]|['"]$/g, '');
|
|
93
|
+
const noSuffix = trimmed.replace(/\(.*\)$/, '');
|
|
94
|
+
return noSuffix.startsWith('/') ? noSuffix.slice(1) : noSuffix;
|
|
95
|
+
}
|
|
96
|
+
function parsePnpmLock(text) {
|
|
97
|
+
const entries = new Map();
|
|
98
|
+
const hasIntegrity = text.includes('integrity');
|
|
99
|
+
const versionMatch = /lockfileVersion:\s*['"]?([0-9.]+)/.exec(text);
|
|
100
|
+
const version = versionMatch?.[1] ?? '';
|
|
101
|
+
// Only the `packages:` section is needed: `<key>` entries whose children list dependencies.
|
|
102
|
+
const packagesAt = text.indexOf('\npackages:');
|
|
103
|
+
const body = packagesAt === -1 ? text : text.slice(packagesAt + 1);
|
|
104
|
+
const lines = body.split('\n');
|
|
105
|
+
let current = null;
|
|
106
|
+
let inDependencies = false;
|
|
107
|
+
for (const rawLine of lines) {
|
|
108
|
+
const line = rawLine.replace(/\r$/, '');
|
|
109
|
+
if (line.trim() === '')
|
|
110
|
+
continue;
|
|
111
|
+
const indent = line.length - line.trimStart().length;
|
|
112
|
+
const trimmedLine = line.trim();
|
|
113
|
+
if (indent === 0) {
|
|
114
|
+
current = null;
|
|
115
|
+
inDependencies = false;
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if (indent === 2 && trimmedLine.endsWith(':')) {
|
|
119
|
+
// New package entry: `name@version:`, `'name@version':` or `/name@version:`.
|
|
120
|
+
const keyMatch = /^(?:'([^']+)'|"([^"]+)"|(\S+)):$/.exec(trimmedLine);
|
|
121
|
+
if (keyMatch !== null) {
|
|
122
|
+
const key = keyMatch[1] ?? keyMatch[2] ?? keyMatch[3];
|
|
123
|
+
current = {};
|
|
124
|
+
entries.set(normalizePnpmKey(key), current);
|
|
125
|
+
}
|
|
126
|
+
else {
|
|
127
|
+
current = null;
|
|
128
|
+
}
|
|
129
|
+
inDependencies = false;
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
if (indent === 4) {
|
|
133
|
+
inDependencies = trimmedLine === 'dependencies:' || trimmedLine === 'optionalDependencies:';
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
if (indent === 6 && current !== null && inDependencies) {
|
|
137
|
+
const depMatch = /^(?:'([^']+)'|"([^"]+)"|(@?[^:\s][^:]*)):\s*(.+)$/.exec(trimmedLine);
|
|
138
|
+
if (depMatch !== null) {
|
|
139
|
+
const name = depMatch[1] ?? depMatch[2] ?? depMatch[3];
|
|
140
|
+
current[name] = depMatch[4].trim();
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return { kind: 'pnpm', lockfile: 'pnpm-lock.yaml', lockfileVersion: version, entries, hasIntegrity };
|
|
145
|
+
}
|
|
146
|
+
// --- package-lock.json -------------------------------------------------------
|
|
147
|
+
function parseNpmLock(text, lockfile) {
|
|
148
|
+
let json;
|
|
149
|
+
try {
|
|
150
|
+
json = JSON.parse(text);
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
return { kind: 'unsupported', lockfile, lockfileVersion: '', entries: new Map(), hasIntegrity: false };
|
|
154
|
+
}
|
|
155
|
+
if (typeof json !== 'object' || json === null || Array.isArray(json)) {
|
|
156
|
+
return { kind: 'unsupported', lockfile, lockfileVersion: '', entries: new Map(), hasIntegrity: false };
|
|
157
|
+
}
|
|
158
|
+
const root = json;
|
|
159
|
+
const entries = new Map();
|
|
160
|
+
let hasIntegrity = false;
|
|
161
|
+
const version = typeof root['lockfileVersion'] === 'number' ? String(root['lockfileVersion']) : '';
|
|
162
|
+
const packages = root['packages'];
|
|
163
|
+
if (typeof packages === 'object' && packages !== null && !Array.isArray(packages)) {
|
|
164
|
+
for (const [location, raw] of Object.entries(packages)) {
|
|
165
|
+
if (typeof raw !== 'object' || raw === null || Array.isArray(raw))
|
|
166
|
+
continue;
|
|
167
|
+
const entry = raw;
|
|
168
|
+
const name = typeof entry['name'] === 'string' ? entry['name'] : location.split('node_modules/').pop() ?? '';
|
|
169
|
+
const ver = typeof entry['version'] === 'string' ? entry['version'] : '';
|
|
170
|
+
if (location !== '' && name !== '') {
|
|
171
|
+
const deps = {};
|
|
172
|
+
if (typeof entry['dependencies'] === 'object' && entry['dependencies'] !== null) {
|
|
173
|
+
for (const [dep, spec] of Object.entries(entry['dependencies'])) {
|
|
174
|
+
if (typeof spec === 'string')
|
|
175
|
+
deps[dep] = spec;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
entries.set(`${name}@${ver}`, deps);
|
|
179
|
+
if (typeof entry['integrity'] === 'string' && entry['integrity'] !== '')
|
|
180
|
+
hasIntegrity = true;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
else {
|
|
185
|
+
// v1 shape: nested `dependencies` objects.
|
|
186
|
+
const walk = (node) => {
|
|
187
|
+
if (typeof node !== 'object' || node === null || Array.isArray(node))
|
|
188
|
+
return;
|
|
189
|
+
for (const [name, raw] of Object.entries(node)) {
|
|
190
|
+
if (typeof raw !== 'object' || raw === null || Array.isArray(raw))
|
|
191
|
+
continue;
|
|
192
|
+
const entry = raw;
|
|
193
|
+
const ver = typeof entry['version'] === 'string' ? entry['version'] : '';
|
|
194
|
+
const deps = {};
|
|
195
|
+
if (typeof entry['requires'] === 'object' && entry['requires'] !== null) {
|
|
196
|
+
for (const [dep, spec] of Object.entries(entry['requires'])) {
|
|
197
|
+
if (typeof spec === 'string')
|
|
198
|
+
deps[dep] = spec;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
if (name !== '' && ver !== '')
|
|
202
|
+
entries.set(`${name}@${ver}`, deps);
|
|
203
|
+
if (typeof entry['integrity'] === 'string' && entry['integrity'] !== '')
|
|
204
|
+
hasIntegrity = true;
|
|
205
|
+
walk(entry['dependencies']);
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
walk(root['dependencies']);
|
|
209
|
+
}
|
|
210
|
+
return { kind: 'npm', lockfile, lockfileVersion: version, entries, hasIntegrity };
|
|
211
|
+
}
|
|
212
|
+
// --- yarn.lock (v1) ----------------------------------------------------------
|
|
213
|
+
/** Extract the package name from a yarn key line (`name@^range` / `@scope/name@^range`). */
|
|
214
|
+
function yarnKeyName(key) {
|
|
215
|
+
const unquoted = key.replace(/^["']|["']$/g, '').trim();
|
|
216
|
+
const at = unquoted.lastIndexOf('@');
|
|
217
|
+
return at <= 0 ? unquoted : unquoted.slice(0, at);
|
|
218
|
+
}
|
|
219
|
+
function parseYarnLock(text) {
|
|
220
|
+
const entries = new Map();
|
|
221
|
+
let hasIntegrity = text.includes('integrity') || text.includes('resolved "');
|
|
222
|
+
const lines = text.split('\n');
|
|
223
|
+
let current = null;
|
|
224
|
+
let key = '';
|
|
225
|
+
for (const rawLine of lines) {
|
|
226
|
+
const line = rawLine.replace(/\r$/, '');
|
|
227
|
+
if (line.trim() === '' || line.trimStart().startsWith('#'))
|
|
228
|
+
continue;
|
|
229
|
+
if (!/^\s/.test(line)) {
|
|
230
|
+
key = line.replace(/:$/, '').trim();
|
|
231
|
+
if (current !== null && current.version !== '') {
|
|
232
|
+
entries.set(`${current.key}@${current.version}`, current.deps);
|
|
233
|
+
}
|
|
234
|
+
current = null;
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
const match = /^\s+(.+?)\s+(.+)$/.exec(line);
|
|
238
|
+
if (match === null)
|
|
239
|
+
continue;
|
|
240
|
+
const fieldName = match[1];
|
|
241
|
+
const value = match[2].trim();
|
|
242
|
+
if (fieldName === 'version') {
|
|
243
|
+
if (current !== null && current.version !== '') {
|
|
244
|
+
entries.set(`${current.key}@${current.version}`, current.deps);
|
|
245
|
+
}
|
|
246
|
+
current = { key: yarnKeyName(key), deps: {}, version: value.replace(/^"|"$/g, '') };
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
if (fieldName === 'integrity') {
|
|
250
|
+
hasIntegrity = true;
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
if (fieldName === 'dependencies' || fieldName === 'optionalDependencies')
|
|
254
|
+
continue;
|
|
255
|
+
if (current !== null && !['resolved', 'uid'].includes(fieldName)) {
|
|
256
|
+
current.deps[fieldName.replace(/^["']|["']$/g, '')] = value;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
if (current !== null && current.version !== '') {
|
|
260
|
+
entries.set(`${current.key}@${current.version}`, current.deps);
|
|
261
|
+
}
|
|
262
|
+
return { kind: 'yarn', lockfile: 'yarn.lock', lockfileVersion: '1', entries, hasIntegrity };
|
|
263
|
+
}
|
|
264
|
+
// --- tree building ------------------------------------------------------------
|
|
265
|
+
/** Normalize a dependency spec into a bare version when possible. */
|
|
266
|
+
export function specVersion(spec) {
|
|
267
|
+
const trimmed = spec.trim();
|
|
268
|
+
const match = /^(?:[\^~<>=*| ]+|npm:|workspace:|file:|link:)*(.+)$/.exec(trimmed);
|
|
269
|
+
return match?.[1] ?? trimmed;
|
|
270
|
+
}
|
|
271
|
+
/** Resolve a name+spec against lockfile entries (exact match, then prefix scan). */
|
|
272
|
+
function resolveLockedVersion(name, spec, entries) {
|
|
273
|
+
const bare = specVersion(spec);
|
|
274
|
+
if (entries.has(`${name}@${bare}`))
|
|
275
|
+
return bare;
|
|
276
|
+
for (const key of entries.keys()) {
|
|
277
|
+
if (key.startsWith(`${name}@`))
|
|
278
|
+
return key.slice(name.length + 1);
|
|
279
|
+
}
|
|
280
|
+
return undefined;
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* Build the dependency tree: BFS from the manifest's direct dependencies
|
|
284
|
+
* through lockfile edges, deduped by name@version, depth- and node-capped.
|
|
285
|
+
*/
|
|
286
|
+
export function buildDependencyTree(manifest, lock, maxNodes) {
|
|
287
|
+
const packages = [];
|
|
288
|
+
const seen = new Set();
|
|
289
|
+
const queue = [];
|
|
290
|
+
const direct = [
|
|
291
|
+
...Object.entries(manifest.dependencies).map(([name, spec]) => ({ name, spec, depth: 0, dev: false })),
|
|
292
|
+
...Object.entries(manifest.devDependencies).map(([name, spec]) => ({ name, spec, depth: 0, dev: true })),
|
|
293
|
+
];
|
|
294
|
+
let total = 0;
|
|
295
|
+
let truncated = false;
|
|
296
|
+
for (const root of direct) {
|
|
297
|
+
total += 1;
|
|
298
|
+
const version = lock.entries.size > 0 ? resolveLockedVersion(root.name, root.spec, lock.entries) ?? specVersion(root.spec) : specVersion(root.spec);
|
|
299
|
+
const key = `${root.name}@${version}`;
|
|
300
|
+
if (!seen.has(key) && packages.length < maxNodes) {
|
|
301
|
+
seen.add(key);
|
|
302
|
+
packages.push({ name: root.name, version, depth: 0, dev: root.dev });
|
|
303
|
+
}
|
|
304
|
+
else if (!seen.has(key)) {
|
|
305
|
+
truncated = true;
|
|
306
|
+
}
|
|
307
|
+
queue.push(root);
|
|
308
|
+
}
|
|
309
|
+
while (queue.length > 0) {
|
|
310
|
+
const item = queue.shift();
|
|
311
|
+
if (item.depth >= 20)
|
|
312
|
+
continue;
|
|
313
|
+
const version = lock.entries.size > 0 ? resolveLockedVersion(item.name, item.spec, lock.entries) : undefined;
|
|
314
|
+
const locked = version === undefined ? `${item.name}@${specVersion(item.spec)}` : `${item.name}@${version}`;
|
|
315
|
+
const edges = lock.entries.get(locked);
|
|
316
|
+
if (edges === undefined)
|
|
317
|
+
continue;
|
|
318
|
+
for (const [depName, depSpec] of Object.entries(edges)) {
|
|
319
|
+
if (depName === '' || depSpec === '')
|
|
320
|
+
continue;
|
|
321
|
+
const depVersion = resolveLockedVersion(depName, depSpec, lock.entries) ?? specVersion(depSpec);
|
|
322
|
+
const depKey = `${depName}@${depVersion}`;
|
|
323
|
+
total += 1;
|
|
324
|
+
if (!seen.has(depKey)) {
|
|
325
|
+
if (packages.length >= maxNodes) {
|
|
326
|
+
truncated = true;
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
seen.add(depKey);
|
|
330
|
+
packages.push({ name: depName, version: depVersion, depth: item.depth + 1, dev: item.dev });
|
|
331
|
+
queue.push({ name: depName, spec: depSpec, depth: item.depth + 1, dev: item.dev });
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
return { packages, truncated, total };
|
|
336
|
+
}
|
|
337
|
+
/** Direct specs that are not exact-version pinned (a supply-chain signal). */
|
|
338
|
+
export function unpinnedSpecs(manifest) {
|
|
339
|
+
const out = [];
|
|
340
|
+
for (const [name, spec] of Object.entries(manifest.dependencies)) {
|
|
341
|
+
if (!/^\d/.test(spec.trim()))
|
|
342
|
+
out.push(`${name}@${spec}`);
|
|
343
|
+
}
|
|
344
|
+
return out;
|
|
345
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Report redaction: no secret-shaped text may ever leave the scan engine.
|
|
3
|
+
*
|
|
4
|
+
* Applied to every evidence snippet and finding message before a value enters
|
|
5
|
+
* the canonical report, mirroring the pack's `secret-scan` redaction rule
|
|
6
|
+
* (type marker only, never the value). Patterns cover the token families the
|
|
7
|
+
* `secret-scan` skill documents plus webhook/bot URLs.
|
|
8
|
+
*
|
|
9
|
+
* @module dsh-skill-pack-security/vet/redact
|
|
10
|
+
*/
|
|
11
|
+
const REDACTIONS = [
|
|
12
|
+
// Private key blocks (PEM / OpenSSH), multiline.
|
|
13
|
+
{ pattern: /-----BEGIN (?:RSA |EC |OPENSSH |DSA )?PRIVATE KEY-----[\s\S]*?-----END (?:RSA |EC |OPENSSH |DSA )?PRIVATE KEY-----/g, replace: '[REDACTED private key]' },
|
|
14
|
+
// GitHub tokens.
|
|
15
|
+
{ pattern: /ghp_[A-Za-z0-9]{30,}/g, replace: 'ghp_***' },
|
|
16
|
+
{ pattern: /github_pat_[A-Za-z0-9_]{20,}/g, replace: 'github_pat_***' },
|
|
17
|
+
// AWS access keys.
|
|
18
|
+
{ pattern: /AKIA[0-9A-Z]{16}/g, replace: 'AKIA***' },
|
|
19
|
+
// Generic sk- / xox tokens (OpenAI, Slack…).
|
|
20
|
+
{ pattern: /sk-[A-Za-z0-9]{16,}/g, replace: 'sk-***' },
|
|
21
|
+
{ pattern: /xox[baprs]-[A-Za-z0-9-]{10,}/g, replace: 'xox*-***' },
|
|
22
|
+
// Azure storage keys.
|
|
23
|
+
{ pattern: /AZURE_STORAGE_[A-Za-z0-9]+=[A-Za-z0-9+/=]+/g, replace: 'AZURE_STORAGE_***=***' },
|
|
24
|
+
// npm registry auth.
|
|
25
|
+
{ pattern: /_authToken\s*=\s*[^\s"']+/g, replace: '_authToken=***' },
|
|
26
|
+
{ pattern: /\/\/registry\.npmjs\.org\/:_authToken=[^\s"']+/g, replace: '//registry.npmjs.org/:_authToken=***' },
|
|
27
|
+
// Discord webhooks and Telegram bot tokens.
|
|
28
|
+
{ pattern: /https:\/\/discord(?:app)?\.com\/api\/webhooks\/[0-9]+\/[A-Za-z0-9_-]+/g, replace: 'https://discord.com/api/webhooks/[REDACTED]' },
|
|
29
|
+
{ pattern: /https:\/\/api\.telegram\.org\/bot[0-9]+:[A-Za-z0-9_-]+/g, replace: 'https://api.telegram.org/bot[REDACTED]' },
|
|
30
|
+
// Assignment-style credentials of any name.
|
|
31
|
+
{ pattern: /(password|passwd|secret|api[_-]?key|token|credential[a-z]*)\s*[:=]\s*["'][^"']{6,}["']/gi, replace: '$1=[REDACTED]' },
|
|
32
|
+
];
|
|
33
|
+
/** Replace every secret-shaped substring with a type marker. */
|
|
34
|
+
export function redact(text) {
|
|
35
|
+
let out = text;
|
|
36
|
+
for (const { pattern, replace } of REDACTIONS) {
|
|
37
|
+
out = out.replace(pattern, replace);
|
|
38
|
+
}
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
/** Cap a raw snippet and redact it for use as finding evidence. */
|
|
42
|
+
export function redactSnippet(text, maxChars = 160) {
|
|
43
|
+
const trimmed = text.replace(/\s+/g, ' ').trim();
|
|
44
|
+
const cut = trimmed.length > maxChars ? `${trimmed.slice(0, maxChars - 1)}…` : trimmed;
|
|
45
|
+
return redact(cut);
|
|
46
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Model-facing renderers for the plugin_vet report: the canonical JSON value
|
|
3
|
+
* becomes a compact markdown report (render), a pending-call card
|
|
4
|
+
* (presentCall), and a completed gate card (presentResult). Everything is
|
|
5
|
+
* pure and capped — no secrets (the engine redacts before this layer), no
|
|
6
|
+
* unbounded trees.
|
|
7
|
+
*
|
|
8
|
+
* @module dsh-skill-pack-security/vet/report
|
|
9
|
+
*/
|
|
10
|
+
import { DIMENSION_LABEL, T } from './skills.js';
|
|
11
|
+
const TREE_CAP = 40;
|
|
12
|
+
const FINDING_CAP = 6;
|
|
13
|
+
function verdictMark(verdict) {
|
|
14
|
+
switch (verdict) {
|
|
15
|
+
case 'fail': return '🔴';
|
|
16
|
+
case 'warn': return '🟡';
|
|
17
|
+
case 'pass': return '🟢';
|
|
18
|
+
default: return '⚪';
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
/** Five-dimension score line. */
|
|
22
|
+
export function scoresLine(scores, lang) {
|
|
23
|
+
const dims = ['license', 'source', 'dependencies', 'build-scripts', 'maintenance'];
|
|
24
|
+
return dims.map(dim => `${DIMENSION_LABEL[lang][dim]} ${scores[dim]}/100`).join(' · ') + ` · ${lang === 'zh' ? '总分' : 'overall'} ${scores.overall}/100`;
|
|
25
|
+
}
|
|
26
|
+
/** One check section. */
|
|
27
|
+
function renderCheck(check, lang) {
|
|
28
|
+
const t = T[lang];
|
|
29
|
+
const head = `- ${verdictMark(check.verdict)} [${check.verdict.toUpperCase()}] ${check.name} — ${lang === 'zh' ? '深审技能' : 'deep-dive skill'}: \`${check.skill}\``;
|
|
30
|
+
const lines = [head];
|
|
31
|
+
if (check.verdict === 'skip' && check.skipReason !== undefined) {
|
|
32
|
+
lines.push(` - ⚪ ${t.skip}: ${check.skipReason}`);
|
|
33
|
+
return lines.join('\n');
|
|
34
|
+
}
|
|
35
|
+
for (const finding of check.findings.slice(0, FINDING_CAP)) {
|
|
36
|
+
const loc = finding.location !== undefined ? ` \`${finding.location}\`` : '';
|
|
37
|
+
const evidence = finding.evidence !== undefined ? ` — ${t.evidence}: \`${finding.evidence}\`` : '';
|
|
38
|
+
lines.push(` - ${verdictMark(finding.level)} ${finding.message}${loc}${evidence}`);
|
|
39
|
+
}
|
|
40
|
+
if (check.truncatedFindings)
|
|
41
|
+
lines.push(` - … (${lang === 'zh' ? '其余发现被截断' : 'further findings truncated'})`);
|
|
42
|
+
return lines.join('\n');
|
|
43
|
+
}
|
|
44
|
+
/** Render the canonical report as model-facing markdown. */
|
|
45
|
+
export function renderReport(report, lang) {
|
|
46
|
+
const t = T[lang];
|
|
47
|
+
const targetLabel = report.target.kind === 'npm-package' ? report.target.resolved : report.target.kind === 'local-path' ? report.target.resolved : `${report.target.resolved}@${report.target.ref}`;
|
|
48
|
+
const parts = [];
|
|
49
|
+
parts.push(`## plugin_vet ${report.target.raw}`);
|
|
50
|
+
parts.push('');
|
|
51
|
+
parts.push(`${verdictMark(report.verdict)} **${lang === 'zh' ? '结论' : 'Verdict'}: ${report.verdict.toUpperCase()}** — ${scoresLine(report.scores, lang)}`);
|
|
52
|
+
if (report.budget.truncated)
|
|
53
|
+
parts.push(`⚠️ ${t.budgetTruncated}: ${report.budget.truncatedReason ?? ''}`);
|
|
54
|
+
parts.push('');
|
|
55
|
+
if (report.gate.applied) {
|
|
56
|
+
if (report.gate.blocked) {
|
|
57
|
+
parts.push(`🛑 **${t.gateDenyTitle}**`);
|
|
58
|
+
parts.push(t.gateDenyBody);
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
parts.push(`⚠️ **${t.gateWarnTitle}**`);
|
|
62
|
+
parts.push(t.gateWarnBody);
|
|
63
|
+
}
|
|
64
|
+
parts.push('');
|
|
65
|
+
}
|
|
66
|
+
parts.push(`${lang === 'zh' ? '扫描对象' : 'Target'}: ${targetLabel} · ${report.fetchedAt}`);
|
|
67
|
+
parts.push('');
|
|
68
|
+
for (const check of report.checks) {
|
|
69
|
+
parts.push(renderCheck(check, lang));
|
|
70
|
+
}
|
|
71
|
+
parts.push('');
|
|
72
|
+
parts.push(`**SBOM** (${report.sbom.lockfile ?? (lang === 'zh' ? '无锁文件' : 'no lockfile')}) — ${lang === 'zh' ? '直接依赖' : 'direct'} ${report.sbom.directDependencies} + dev ${report.sbom.directDevDependencies}, ${lang === 'zh' ? '唯一包' : 'unique packages'} ${report.sbom.packages.length}${report.sbom.totalPackages > report.sbom.packages.length ? ` (${lang === 'zh' ? '总计' : 'total'} ${report.sbom.totalPackages})` : ''}`);
|
|
73
|
+
if (report.sbom.packages.length > 0) {
|
|
74
|
+
const tree = report.sbom.packages.slice(0, TREE_CAP).map(pkg => `${' '.repeat(Math.min(pkg.depth, 8))}${pkg.name}@${pkg.version}`).join('\n');
|
|
75
|
+
parts.push('```text');
|
|
76
|
+
parts.push(tree);
|
|
77
|
+
parts.push('```');
|
|
78
|
+
if (report.sbom.packages.length > TREE_CAP)
|
|
79
|
+
parts.push(`… (${lang === 'zh' ? '树被截断至' : 'tree capped at'} ${TREE_CAP} ${lang === 'zh' ? '行' : 'lines'})`);
|
|
80
|
+
}
|
|
81
|
+
parts.push('');
|
|
82
|
+
parts.push(`${lang === 'zh' ? '扫描预算' : 'Scan budget'}: ${report.budget.filesScanned} ${lang === 'zh' ? '个文件' : 'files'} · ${report.budget.bytesScanned} bytes · ${report.budget.filesSkipped} ${lang === 'zh' ? '跳过' : 'skipped'}${report.budget.truncated ? ` · ⚠️ ${t.budgetTruncated}` : ''}`);
|
|
83
|
+
parts.push('');
|
|
84
|
+
parts.push(`**${t.followup}**: ${report.followupSkills.map(name => `\`${name}\``).join(', ')}`);
|
|
85
|
+
return parts.join('\n');
|
|
86
|
+
}
|
|
87
|
+
/** Short gate summary for the completed card (≤ a few lines). */
|
|
88
|
+
export function gateSummary(report, lang) {
|
|
89
|
+
const t = T[lang];
|
|
90
|
+
if (report.gate.blocked)
|
|
91
|
+
return `🛑 ${t.gateDenyTitle}\n${t.gateDenyBody}`;
|
|
92
|
+
if (report.gate.applied)
|
|
93
|
+
return `⚠️ ${t.gateWarnTitle}\n${t.gateWarnBody}`;
|
|
94
|
+
return '';
|
|
95
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Skill cross-references and report-language strings for plugin_vet.
|
|
3
|
+
*
|
|
4
|
+
* Every check cites the pack skill (and section) that continues its subject
|
|
5
|
+
* as a manual audit — "skill 教流程,插件自动执行". Skill names are identical
|
|
6
|
+
* in the zh/en editions, so the reference is language-neutral.
|
|
7
|
+
*
|
|
8
|
+
* @module dsh-skill-pack-security/vet/skills
|
|
9
|
+
*/
|
|
10
|
+
/** check id → pack skill + section (the manual deep-dive continuation). */
|
|
11
|
+
export const SKILL_REF = {
|
|
12
|
+
license: 'dependency-audit §3',
|
|
13
|
+
sbom: 'dependency-audit §7',
|
|
14
|
+
'commit-lock': 'supply-chain-review §3',
|
|
15
|
+
'install-scripts': 'supply-chain-review §1',
|
|
16
|
+
'network-exfil': 'dependency-audit §4.4',
|
|
17
|
+
obfuscation: 'supply-chain-review §1',
|
|
18
|
+
source: 'dependency-audit §4.3',
|
|
19
|
+
maintenance: 'security-audit §3',
|
|
20
|
+
};
|
|
21
|
+
/** check id → human-readable check name per language. */
|
|
22
|
+
export const CHECK_NAME = {
|
|
23
|
+
zh: {
|
|
24
|
+
license: '许可证扫描',
|
|
25
|
+
sbom: 'SBOM 依赖树',
|
|
26
|
+
'commit-lock': 'commit 锁定校验',
|
|
27
|
+
'install-scripts': 'install 脚本检查',
|
|
28
|
+
'network-exfil': '网络回传检测',
|
|
29
|
+
obfuscation: '混淆代码检测',
|
|
30
|
+
source: '来源可信信号',
|
|
31
|
+
maintenance: '维护状态',
|
|
32
|
+
},
|
|
33
|
+
en: {
|
|
34
|
+
license: 'License scan',
|
|
35
|
+
sbom: 'SBOM dependency tree',
|
|
36
|
+
'commit-lock': 'Commit lock verification',
|
|
37
|
+
'install-scripts': 'Install script checks',
|
|
38
|
+
'network-exfil': 'Network exfiltration scan',
|
|
39
|
+
obfuscation: 'Obfuscation scan',
|
|
40
|
+
source: 'Source trust signals',
|
|
41
|
+
maintenance: 'Maintenance status',
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
/** dimension → label per language. */
|
|
45
|
+
export const DIMENSION_LABEL = {
|
|
46
|
+
zh: {
|
|
47
|
+
license: '许可证',
|
|
48
|
+
source: '来源',
|
|
49
|
+
dependencies: '依赖',
|
|
50
|
+
'build-scripts': '构建脚本',
|
|
51
|
+
maintenance: '维护状态',
|
|
52
|
+
},
|
|
53
|
+
en: {
|
|
54
|
+
license: 'License',
|
|
55
|
+
source: 'Source',
|
|
56
|
+
dependencies: 'Dependencies',
|
|
57
|
+
'build-scripts': 'Build scripts',
|
|
58
|
+
maintenance: 'Maintenance',
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
/** Messages shared by checks and the report renderer. */
|
|
62
|
+
export const T = {
|
|
63
|
+
zh: {
|
|
64
|
+
pass: '通过',
|
|
65
|
+
warn: '警告',
|
|
66
|
+
fail: '失败',
|
|
67
|
+
skip: '跳过',
|
|
68
|
+
verdictPass: 'PASS',
|
|
69
|
+
verdictWarn: 'WARN',
|
|
70
|
+
verdictFail: 'FAIL',
|
|
71
|
+
gateDenyTitle: '门禁 DENY:此插件未通过供应链检查,安装已被策略拒绝',
|
|
72
|
+
gateDenyBody: '请加载 supply-chain-review / dependency-audit 技能人工深审;或由可信维护者修改门禁策略后重试。',
|
|
73
|
+
gateWarnTitle: '门禁警告:plugin_vet 结果为 FAIL,强烈建议停止安装',
|
|
74
|
+
gateWarnBody: '默认策略 warn 不阻断。继续安装前请按下方 skill 引用人工深审,确认风险可接受。',
|
|
75
|
+
followup: '人工深审建议(加载对应技能继续)',
|
|
76
|
+
budget: '扫描预算',
|
|
77
|
+
budgetTruncated: '扫描被预算截断:结果不完整',
|
|
78
|
+
offline: '离线/受限',
|
|
79
|
+
evidence: '证据',
|
|
80
|
+
},
|
|
81
|
+
en: {
|
|
82
|
+
pass: 'pass',
|
|
83
|
+
warn: 'warn',
|
|
84
|
+
fail: 'fail',
|
|
85
|
+
skip: 'skip',
|
|
86
|
+
verdictPass: 'PASS',
|
|
87
|
+
verdictWarn: 'WARN',
|
|
88
|
+
verdictFail: 'FAIL',
|
|
89
|
+
gateDenyTitle: 'Gate DENY: this plugin failed the supply-chain checks; installation is blocked by policy',
|
|
90
|
+
gateDenyBody: 'Load the supply-chain-review / dependency-audit skills for a manual deep-dive, or have a trusted maintainer change the gate policy and retry.',
|
|
91
|
+
gateWarnTitle: 'Gate warning: plugin_vet returned FAIL — installation is strongly discouraged',
|
|
92
|
+
gateWarnBody: 'The default policy is warn (non-blocking). Before continuing the install, follow the skill references below for a manual review and confirm the risk is acceptable.',
|
|
93
|
+
followup: 'Manual deep-dive (load these skills to continue)',
|
|
94
|
+
budget: 'Scan budget',
|
|
95
|
+
budgetTruncated: 'Scan truncated by budget: results are incomplete',
|
|
96
|
+
offline: 'Offline/limited',
|
|
97
|
+
evidence: 'Evidence',
|
|
98
|
+
},
|
|
99
|
+
};
|