@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.
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Resolved plugin_vet configuration: plain values with defaults applied.
3
+ *
4
+ * The Schemastery schema lives in `../index.ts` next to the skills-provider
5
+ * config; this module only consumes the already-validated plain object so the
6
+ * engine never depends on Schemastery at runtime.
7
+ *
8
+ * @module dsh-skill-pack-security/vet/config
9
+ */
10
+ export const VET_DEFAULTS = {
11
+ enable: true,
12
+ timeoutMs: 15000,
13
+ maxFiles: 800,
14
+ maxFileBytes: 256 * 1024,
15
+ maxExtractBytes: 64 * 1024 * 1024,
16
+ maxDepNodes: 600,
17
+ maxFindingsPerCheck: 12,
18
+ userAgent: 'dsh-skill-pack-security/2.0.0 (+https://github.com/PerryLink/dsh-skill-pack-security)',
19
+ gate: { policy: 'warn' },
20
+ };
21
+ /** Merge raw config over the defaults (the schema already validated shape/ranges). */
22
+ export function resolveVetConfig(raw) {
23
+ if (raw === undefined)
24
+ return VET_DEFAULTS;
25
+ return {
26
+ enable: raw.enable ?? VET_DEFAULTS.enable,
27
+ timeoutMs: raw.timeoutMs ?? VET_DEFAULTS.timeoutMs,
28
+ maxFiles: raw.maxFiles ?? VET_DEFAULTS.maxFiles,
29
+ maxFileBytes: raw.maxFileBytes ?? VET_DEFAULTS.maxFileBytes,
30
+ maxExtractBytes: raw.maxExtractBytes ?? VET_DEFAULTS.maxExtractBytes,
31
+ maxDepNodes: raw.maxDepNodes ?? VET_DEFAULTS.maxDepNodes,
32
+ maxFindingsPerCheck: raw.maxFindingsPerCheck ?? VET_DEFAULTS.maxFindingsPerCheck,
33
+ userAgent: raw.userAgent ?? VET_DEFAULTS.userAgent,
34
+ gate: { policy: raw.gate?.policy ?? VET_DEFAULTS.gate.policy },
35
+ };
36
+ }
@@ -0,0 +1,236 @@
1
+ /**
2
+ * plugin_vet engine: resolves a target, runs the eight checks, scores the five
3
+ * risk dimensions, applies the installation gate, and returns the canonical
4
+ * report. Pure orchestration — all side effects live in `source.ts`/`fetch.ts`
5
+ * and every value produced here is JSON-safe and redacted.
6
+ *
7
+ * @module dsh-skill-pack-security/vet/engine
8
+ */
9
+ import { readFile } from 'node:fs/promises';
10
+ import { join } from 'node:path';
11
+ import { ALL_CHECK_IDS } from './vocabulary.js';
12
+ import { runChecks } from './checks.js';
13
+ import { parseLockfile, parseManifest } from './manifest.js';
14
+ import { resolveTarget } from './source.js';
15
+ import { VetFetchError } from './fetch.js';
16
+ import { CHECK_NAME, SKILL_REF, T } from './skills.js';
17
+ /** Engine-level failure: target unusable (not found, offline, budget). */
18
+ export class VetTargetError extends Error {
19
+ constructor(message) {
20
+ super(message);
21
+ this.name = 'VetTargetError';
22
+ }
23
+ }
24
+ /** Parse and validate the requested check subset. */
25
+ function requestedChecks(args) {
26
+ if (args.checks === undefined || args.checks.length === 0)
27
+ return [...ALL_CHECK_IDS];
28
+ const selected = [];
29
+ for (const raw of args.checks) {
30
+ const id = raw.trim();
31
+ if (!ALL_CHECK_IDS.includes(id)) {
32
+ throw new VetTargetError(`unknown check "${raw}"; available: ${ALL_CHECK_IDS.join(', ')}`);
33
+ }
34
+ if (!selected.includes(id))
35
+ selected.push(id);
36
+ }
37
+ return selected;
38
+ }
39
+ /** Read a local git HEAD commit without spawning git (best effort). */
40
+ async function readLocalHead(root) {
41
+ try {
42
+ const head = (await readFile(join(root, '.git', 'HEAD'), 'utf8')).trim();
43
+ const direct = /^[0-9a-f]{40}$/i.exec(head);
44
+ if (direct !== null)
45
+ return direct[0];
46
+ const refMatch = /^ref:\s*(.+)$/.exec(head);
47
+ if (refMatch === null)
48
+ return '';
49
+ const ref = (await readFile(join(root, '.git', refMatch[1]), 'utf8')).trim();
50
+ return /^[0-9a-f]{40}$/i.test(ref) ? ref : '';
51
+ }
52
+ catch {
53
+ return '';
54
+ }
55
+ }
56
+ const WEIGHTS = { license: 0.25, source: 0.2, dependencies: 0.15, 'build-scripts': 0.25, maintenance: 0.15 };
57
+ /**
58
+ * Recursively drop `undefined` properties from plain objects/arrays so the
59
+ * canonical value is always lossless JSON (the tool runtime rejects anything
60
+ * less). Engine internals may carry optional undefined fields; only this
61
+ * projection is surfaced.
62
+ */
63
+ function jsonClean(value) {
64
+ if (Array.isArray(value))
65
+ return value.map(jsonClean);
66
+ if (typeof value === 'object' && value !== null && !(value instanceof Date)) {
67
+ const out = {};
68
+ for (const [key, child] of Object.entries(value)) {
69
+ if (child === undefined)
70
+ continue;
71
+ out[key] = jsonClean(child);
72
+ }
73
+ return out;
74
+ }
75
+ return value;
76
+ }
77
+ /** Dimension → checks contributing to it. */
78
+ const DIMENSION_CHECKS = {
79
+ license: ['license'],
80
+ source: ['source', 'commit-lock'],
81
+ dependencies: ['sbom'],
82
+ 'build-scripts': ['install-scripts', 'network-exfil', 'obfuscation'],
83
+ maintenance: ['maintenance'],
84
+ };
85
+ /** Build the five-dimension scorecard from executed checks. */
86
+ function scoreDimensions(checks) {
87
+ const byId = new Map(checks.map(check => [check.id, check]));
88
+ const dims = {};
89
+ for (const dim of Object.keys(DIMENSION_CHECKS)) {
90
+ const contributors = DIMENSION_CHECKS[dim].map(id => byId.get(id)).filter((check) => check !== undefined);
91
+ const run = contributors.filter(check => check.verdict !== 'skip');
92
+ if (run.length === 0) {
93
+ dims[dim] = 60; // unknown: neither penalize nor credit
94
+ continue;
95
+ }
96
+ const weighted = run.map(check => {
97
+ if (dim === 'build-scripts') {
98
+ const factor = check.id === 'install-scripts' ? 0.4 : check.id === 'network-exfil' ? 0.35 : 0.25;
99
+ return check.score * factor;
100
+ }
101
+ if (dim === 'source') {
102
+ return check.score * (check.id === 'source' ? 0.5 : 0.5);
103
+ }
104
+ return check.score;
105
+ });
106
+ // build-scripts and source already sum their weight factors to 1; other
107
+ // dimensions average their contributors.
108
+ const divisor = dim === 'build-scripts' || dim === 'source' ? 1 : weighted.length;
109
+ dims[dim] = Math.round(weighted.reduce((a, b) => a + b, 0) / divisor);
110
+ }
111
+ const overall = Math.round(Object.keys(dims).reduce((sum, dim) => sum + dims[dim] * WEIGHTS[dim], 0));
112
+ return {
113
+ license: dims.license,
114
+ source: dims.source,
115
+ dependencies: dims.dependencies,
116
+ 'build-scripts': dims['build-scripts'],
117
+ maintenance: dims.maintenance,
118
+ overall,
119
+ };
120
+ }
121
+ /** Run the whole pipeline for one tool call. */
122
+ export async function runVet(args, config, lang, signal) {
123
+ const ids = requestedChecks(args);
124
+ const policy = args.policy === undefined || args.policy === 'inherit' ? config.gate.policy : args.policy;
125
+ const now = Date.now();
126
+ const fetchedAt = new Date(now).toISOString();
127
+ let resolved;
128
+ try {
129
+ resolved = await resolveTarget(args.target, config, signal);
130
+ }
131
+ catch (error) {
132
+ if (error instanceof VetFetchError) {
133
+ // Offline/timeout: every check skips with the concrete reason.
134
+ const skipReason = `${T[lang].offline}: ${error.message}`;
135
+ const checks = ALL_CHECK_IDS.map(id => ({
136
+ id,
137
+ name: CHECK_NAME[lang][id],
138
+ verdict: 'skip',
139
+ skipReason,
140
+ score: 60,
141
+ findings: [],
142
+ truncatedFindings: false,
143
+ skill: SKILL_REF[id],
144
+ }));
145
+ return jsonClean({
146
+ kind: 'vet-report',
147
+ target: { raw: args.target, kind: 'github-repo', resolved: args.target, ref: '' },
148
+ fetchedAt,
149
+ checks,
150
+ scores: scoreDimensions(checks),
151
+ verdict: 'skip',
152
+ gate: { policy, applied: false, blocked: false },
153
+ sbom: { lockfile: null, directDependencies: 0, directDevDependencies: 0, packages: [], truncated: false, totalPackages: 0, unpinned: [] },
154
+ budget: { filesScanned: 0, filesSkipped: 0, bytesScanned: 0, truncated: false, truncatedReason: skipReason },
155
+ followupSkills: ['security-audit'],
156
+ });
157
+ }
158
+ throw error;
159
+ }
160
+ if (resolved.files.length === 0 && resolved.budget.truncatedReason !== undefined) {
161
+ throw new VetTargetError(resolved.budget.truncatedReason);
162
+ }
163
+ const manifest = parseManifest(resolved.files);
164
+ const lock = parseLockfile(resolved.files);
165
+ const localHead = resolved.kind === 'local-path' ? await readLocalHead(resolved.resolved) : '';
166
+ const results = runChecks({
167
+ files: resolved.files,
168
+ manifest,
169
+ lock,
170
+ github: resolved.github,
171
+ npm: resolved.npm,
172
+ target: resolved,
173
+ config,
174
+ lang,
175
+ localHead,
176
+ now,
177
+ }, ids);
178
+ const checks = results.map(result => result.check);
179
+ const sbom = results.find(result => result.sbom !== undefined)?.sbom;
180
+ // Unrequested checks are absent; the scorecard treats them as neutral.
181
+ const scores = scoreDimensions(checks);
182
+ let verdict = 'pass';
183
+ if (checks.some(check => check.verdict === 'fail'))
184
+ verdict = 'fail';
185
+ else if (scores.overall < 60)
186
+ verdict = 'warn';
187
+ const gateApplied = verdict === 'fail';
188
+ const blocked = gateApplied && policy === 'deny';
189
+ const followupSkills = new Set(['security-audit']);
190
+ for (const check of checks) {
191
+ for (const finding of check.findings) {
192
+ if (finding.level !== 'fail' && finding.level !== 'warn')
193
+ continue;
194
+ const skillName = finding.skill.split(' ')[0];
195
+ if (skillName !== '')
196
+ followupSkills.add(skillName);
197
+ }
198
+ }
199
+ const budget = { ...resolved.budget };
200
+ if (resolved.files.length === 0) {
201
+ budget.truncated = true;
202
+ budget.truncatedReason = resolved.budget.truncatedReason ?? 'target contained no scannable files';
203
+ }
204
+ return jsonClean({
205
+ kind: 'vet-report',
206
+ target: {
207
+ raw: args.target,
208
+ kind: resolved.kind,
209
+ resolved: resolved.resolved,
210
+ ref: resolved.ref,
211
+ },
212
+ fetchedAt,
213
+ checks,
214
+ scores,
215
+ verdict,
216
+ gate: {
217
+ policy,
218
+ applied: gateApplied,
219
+ blocked,
220
+ reason: blocked
221
+ ? lang === 'zh' ? 'verdict=fail 且门禁策略为 deny' : 'verdict=fail with deny gate policy'
222
+ : undefined,
223
+ },
224
+ sbom: sbom ?? {
225
+ lockfile: null,
226
+ directDependencies: 0,
227
+ directDevDependencies: 0,
228
+ packages: [],
229
+ truncated: false,
230
+ totalPackages: 0,
231
+ unpinned: [],
232
+ },
233
+ budget,
234
+ followupSkills: [...followupSkills],
235
+ });
236
+ }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Zero-dependency network access for the scan engine.
3
+ *
4
+ * Only `globalThis.fetch` (Node 18+ built-in undici) is used. Every request:
5
+ * - honors the caller's AbortSignal AND a cooperative timeout
6
+ * (`AbortSignal.any` + `AbortSignal.timeout`), so a hung upstream can never
7
+ * stall a session;
8
+ * - enforces a hard byte cap while reading the body (stream-counted, no
9
+ * unbounded buffering);
10
+ * - sends a fixed User-Agent and never attaches credentials.
11
+ *
12
+ * @module dsh-skill-pack-security/vet/fetch
13
+ */
14
+ /** A network failure — always surfaced as a check `skip`, never as a finding. */
15
+ export class VetFetchError extends Error {
16
+ kind;
17
+ constructor(kind, message) {
18
+ super(message);
19
+ this.name = 'VetFetchError';
20
+ this.kind = kind;
21
+ }
22
+ }
23
+ /** Compose the caller signal with the cooperative timeout. */
24
+ function combinedSignal(options) {
25
+ const timeout = AbortSignal.timeout(options.timeoutMs);
26
+ return options.signal === undefined ? timeout : AbortSignal.any([options.signal, timeout]);
27
+ }
28
+ /** Map a fetch rejection into a classified VetFetchError. */
29
+ function classify(error) {
30
+ if (error instanceof VetFetchError)
31
+ return error;
32
+ if (error instanceof Error && error.name === 'TimeoutError') {
33
+ return new VetFetchError('timeout', `network request timed out: ${error.message}`);
34
+ }
35
+ if (error instanceof Error && error.name === 'AbortError') {
36
+ return new VetFetchError('aborted', 'network request was aborted');
37
+ }
38
+ const message = error instanceof Error ? error.message : String(error);
39
+ return new VetFetchError('network', `network request failed: ${message}`);
40
+ }
41
+ /** Fetch a text body with size cap. */
42
+ export async function fetchText(url, options) {
43
+ let response;
44
+ try {
45
+ response = await fetch(url, {
46
+ signal: combinedSignal(options),
47
+ headers: { 'user-agent': options.userAgent, accept: 'application/json, text/plain, */*' },
48
+ redirect: 'follow',
49
+ });
50
+ }
51
+ catch (error) {
52
+ throw classify(error);
53
+ }
54
+ if (!response.ok) {
55
+ throw new VetFetchError('http', `HTTP ${response.status} for ${url}`);
56
+ }
57
+ const reader = response.body?.getReader();
58
+ if (reader === undefined) {
59
+ return { status: response.status, text: await response.text(), truncated: false };
60
+ }
61
+ const chunks = [];
62
+ let bytes = 0;
63
+ let truncated = false;
64
+ try {
65
+ for (;;) {
66
+ const { done, value } = await reader.read();
67
+ if (done)
68
+ break;
69
+ bytes += value.byteLength;
70
+ if (bytes > options.maxBytes) {
71
+ truncated = true;
72
+ await reader.cancel();
73
+ break;
74
+ }
75
+ chunks.push(value);
76
+ }
77
+ }
78
+ catch (error) {
79
+ throw classify(error);
80
+ }
81
+ const buffer = Buffer.concat(chunks);
82
+ return { status: response.status, text: buffer.toString('utf8'), truncated };
83
+ }
84
+ /** Fetch a binary body with size cap (tarballs). */
85
+ export async function fetchBuffer(url, options) {
86
+ let response;
87
+ try {
88
+ response = await fetch(url, {
89
+ signal: combinedSignal(options),
90
+ headers: { 'user-agent': options.userAgent },
91
+ redirect: 'follow',
92
+ });
93
+ }
94
+ catch (error) {
95
+ throw classify(error);
96
+ }
97
+ if (!response.ok) {
98
+ throw new VetFetchError('http', `HTTP ${response.status} for ${url}`);
99
+ }
100
+ const reader = response.body?.getReader();
101
+ if (reader === undefined) {
102
+ const buffer = new Uint8Array(await response.arrayBuffer());
103
+ return { status: response.status, buffer, truncated: buffer.byteLength > options.maxBytes };
104
+ }
105
+ const chunks = [];
106
+ let bytes = 0;
107
+ try {
108
+ for (;;) {
109
+ const { done, value } = await reader.read();
110
+ if (done)
111
+ break;
112
+ bytes += value.byteLength;
113
+ if (bytes > options.maxBytes) {
114
+ await reader.cancel();
115
+ throw new VetFetchError('too-large', `response exceeds the ${options.maxBytes} byte cap`);
116
+ }
117
+ chunks.push(value);
118
+ }
119
+ }
120
+ catch (error) {
121
+ throw classify(error);
122
+ }
123
+ return { status: response.status, buffer: Buffer.concat(chunks), truncated: false };
124
+ }