dsh-code-security 0.3.7 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/engines.js ADDED
@@ -0,0 +1,255 @@
1
+ /** Optional scanners run over bounded, owned snapshots; no project rules or install scripts. */
2
+ import fs from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { createHash } from 'node:crypto';
6
+ import { homedir, tmpdir } from 'node:os';
7
+ import { protectFinding, redactText } from './privacy.js';
8
+ import { sanitizedHtmlLines } from './syntax.js';
9
+ const rulePath = (file) => fileURLToPath(new URL('../rules/' + file, import.meta.url));
10
+ const command = (id, cfg) => cfg.enginePaths[id] ?? (id === 'osv' ? 'osv-scanner' : id);
11
+ const canonical = (file) => process.platform === 'win32' ? path.resolve(file).toLowerCase() : path.resolve(file);
12
+ const missing = (error) => /ENOENT|not found|cannot find|找不到|不存在/i.test(String(error?.code ?? '') + ' ' + String(error?.message ?? error));
13
+ /** File modification dates describe the cache, not the age of every advisory inside it. */
14
+ export async function databaseHealth(cfg) {
15
+ const defaultRoot = process.platform === 'win32' ? process.env.LOCALAPPDATA : process.platform === 'darwin' ? path.join(homedir(), 'Library', 'Caches') : process.env.XDG_CACHE_HOME || path.join(homedir(), '.cache');
16
+ const root = cfg.osvDatabaseDir || process.env.OSV_SCALIBR_LOCAL_DB_CACHE_DIRECTORY || process.env.OSV_SCANNER_LOCAL_DB_CACHE_DIRECTORY || defaultRoot || tmpdir();
17
+ const directory = path.join(root, 'osv-scalibr'), databases = [];
18
+ try {
19
+ for (const entry of (await fs.readdir(directory, { withFileTypes: true })).slice(0, 200)) {
20
+ if (!entry.isDirectory() || entry.isSymbolicLink())
21
+ continue;
22
+ try {
23
+ const info = await fs.lstat(path.join(directory, entry.name, 'all.zip'));
24
+ if (info.isFile() && !info.isSymbolicLink())
25
+ databases.push({ ecosystem: entry.name, fileModifiedAt: info.mtime.toISOString() });
26
+ }
27
+ catch { /* An ecosystem without an archive is unavailable. */ }
28
+ }
29
+ }
30
+ catch { /* A missing/unreadable cache is reported as unavailable, never an empty audit. */ }
31
+ return { directory, available: databases.length > 0, databases };
32
+ }
33
+ export async function probeEngine(engine, cfg, runner, signal) {
34
+ let owned;
35
+ try {
36
+ signal?.throwIfAborted();
37
+ if (engine === 'semgrep')
38
+ owned = await snapshot([], cfg);
39
+ const result = await runner.run([command(engine, cfg), engine === 'betterleaks' ? 'version' : '--version', ...(engine === 'semgrep' ? ['--metrics=off', '--disable-version-check'] : [])], { timeoutMs: Math.min(8000, cfg.engineTimeoutMs), signal,
40
+ ...(owned ? { env: { SEMGREP_APP_TOKEN: '', SEMGREP_SETTINGS_FILE: path.join(owned.dir, '.semgrep-settings.yml'), SEMGREP_SEND_METRICS: 'off', SEMGREP_ENABLE_VERSION_CHECK: '0' } } : {}) });
41
+ signal?.throwIfAborted();
42
+ if (result.signal || result.exitCode !== 0)
43
+ return { engine, status: missing(result.stderr) ? 'not-installed' : 'failed', reason: 'version-check-failed' };
44
+ const version = /\b\d+\.\d+\.\d+\b/.exec(result.stdout)?.[0];
45
+ if (!version || !version.startsWith(engine === 'osv' ? '2.' : '1.'))
46
+ return { engine, status: 'failed', reason: 'unsupported-version' };
47
+ return { engine, status: 'completed', version };
48
+ }
49
+ catch (error) {
50
+ signal?.throwIfAborted();
51
+ return { engine, status: missing(error) ? 'not-installed' : 'failed', reason: missing(error) ? 'executable-missing' : 'version-check-failed' };
52
+ }
53
+ finally {
54
+ await owned?.dispose();
55
+ }
56
+ }
57
+ async function snapshot(documents, cfg) {
58
+ const parent = path.resolve(cfg.stateDir, 'engine-tmp');
59
+ await fs.mkdir(parent, { recursive: true });
60
+ const dir = await fs.mkdtemp(path.join(parent, 'scan-')), files = new Map();
61
+ let bytes = 0, skipped = 0;
62
+ try {
63
+ for (const [i, doc] of documents.entries()) {
64
+ if (Buffer.byteLength(doc.text) > cfg.maxFileBytes || bytes + Buffer.byteLength(doc.text) > 64 * 1024 * 1024 || files.size >= cfg.maxFiles) {
65
+ skipped++;
66
+ continue;
67
+ }
68
+ const normalized = doc.file.replace(/\\/g, '/');
69
+ const relative = !path.isAbsolute(normalized) && !normalized.split('/').some(p => p === '..' || p.includes(':')) ? normalized : 'external/' + i + '/' + path.basename(normalized);
70
+ const file = path.resolve(dir, relative);
71
+ if (!file.startsWith(dir + path.sep))
72
+ throw new Error('Invalid snapshot path');
73
+ await fs.mkdir(path.dirname(file), { recursive: true });
74
+ await fs.writeFile(file, doc.text, { flag: 'wx', mode: 0o600 });
75
+ files.set(canonical(file), doc);
76
+ bytes += Buffer.byteLength(doc.text);
77
+ }
78
+ return { dir, files, skipped, async dispose() { if (path.dirname(dir) !== parent || !path.basename(dir).startsWith('scan-'))
79
+ throw new Error('Invalid owned snapshot'); await fs.rm(dir, { recursive: true, force: true }); } };
80
+ }
81
+ catch (error) {
82
+ if (path.dirname(dir) === parent && path.basename(dir).startsWith('scan-'))
83
+ await fs.rm(dir, { recursive: true, force: true });
84
+ throw error;
85
+ }
86
+ }
87
+ function documentFor(name, dir, files) {
88
+ if (typeof name !== 'string')
89
+ return undefined;
90
+ return files.get(canonical(path.isAbsolute(name) ? name : path.resolve(dir, name)));
91
+ }
92
+ function validLine(value) { return Number.isSafeInteger(value) && value >= 1; }
93
+ function selected(doc, start, end) {
94
+ return !doc.selectedLines || [...doc.selectedLines].some(line => line >= start && line <= end);
95
+ }
96
+ function normalizeFindings(engine, report, dir, files) {
97
+ const rows = engine === 'betterleaks' ? report : report?.results;
98
+ if (!Array.isArray(rows))
99
+ throw new Error('invalid-json-schema');
100
+ const findings = [];
101
+ let incomplete = engine === 'semgrep' && (!Array.isArray(report.errors) || report.errors.length > 0);
102
+ const lines = new Map([...files.values()].map(doc => [doc, doc.text.split(/\r?\n/)]));
103
+ const sanitized = new Map();
104
+ if (rows.length > 5000)
105
+ incomplete = true;
106
+ for (const row of rows.slice(0, 5000)) {
107
+ const doc = documentFor(engine === 'betterleaks' ? row.File : row.path, dir, files);
108
+ const line = engine === 'betterleaks' ? row.StartLine : row.start?.line;
109
+ const endLine = engine === 'betterleaks' ? row.EndLine : row.end?.line;
110
+ const rule = engine === 'betterleaks' ? row.RuleID : row.check_id;
111
+ if (!doc || !validLine(line) || !validLine(endLine) || endLine < line || endLine > lines.get(doc).length || typeof rule !== 'string' || rule.length > 300) {
112
+ incomplete = true;
113
+ continue;
114
+ }
115
+ if (!selected(doc, line, endLine))
116
+ continue;
117
+ if (engine === 'semgrep' && rule === 'dsh-js-html-input') {
118
+ if (!sanitized.has(doc))
119
+ sanitized.set(doc, sanitizedHtmlLines(doc.text));
120
+ if (sanitized.get(doc).has(line))
121
+ continue;
122
+ }
123
+ const snippet = lines.get(doc).slice(line - 1, endLine).join('\n').slice(0, 200);
124
+ const message = engine === 'betterleaks' ? row.Description : row.extra?.message;
125
+ const title = engine === 'betterleaks' ? '检测到平台格式的密钥' : typeof message === 'string' ? message.slice(0, 200) : rule;
126
+ const cwe = engine === 'betterleaks' ? 'CWE-798' : String(row.extra?.metadata?.cwe?.[0] ?? row.extra?.metadata?.cwe ?? 'CWE-20').match(/CWE-\d+/)?.[0] ?? 'CWE-20';
127
+ const severity = engine === 'betterleaks' ? 'high' : row.extra?.severity === 'ERROR' ? 'high' : row.extra?.severity === 'INFO' ? 'low' : 'medium';
128
+ const finding = { id: 'EXT-' + createHash('sha256').update(engine + '|' + rule + '|' + doc.file + '|' + line).digest('hex').slice(0, 12), ruleId: engine + ':' + rule, engine,
129
+ title, cwe, severity, confidence: engine === 'betterleaks' ? row.Attributes?.confidence === 'low' ? 'low' : 'high' : row.extra?.metadata?.confidence === 'HIGH' ? 'high' : 'medium',
130
+ evidence: engine === 'semgrep' ? 'dataflow' : 'pattern', file: doc.file, line, endLine, snippet, message: typeof message === 'string' ? message.slice(0, 2000) : title };
131
+ findings.push(protectFinding(finding));
132
+ }
133
+ return { findings, incomplete };
134
+ }
135
+ async function execute(engine, args, cfg, runner, dir, signal) {
136
+ signal?.throwIfAborted();
137
+ const ctl = new AbortController(), timer = setTimeout(() => ctl.abort(new Error('engine timed out')), cfg.engineTimeoutMs);
138
+ const databaseRoot = cfg.osvDatabaseDir || process.env.OSV_SCALIBR_LOCAL_DB_CACHE_DIRECTORY || process.env.OSV_SCANNER_LOCAL_DB_CACHE_DIRECTORY;
139
+ try {
140
+ const result = await runner.run([command(engine, cfg), ...args], { cwd: dir, timeoutMs: cfg.engineTimeoutMs, signal: signal ? AbortSignal.any([signal, ctl.signal]) : ctl.signal,
141
+ ...(engine === 'osv' && databaseRoot ? { env: { OSV_SCANNER_LOCAL_DB_CACHE_DIRECTORY: databaseRoot, OSV_SCALIBR_LOCAL_DB_CACHE_DIRECTORY: databaseRoot } } : engine === 'semgrep' ? { env: { SEMGREP_APP_TOKEN: '', SEMGREP_SETTINGS_FILE: path.join(dir, '.semgrep-settings.yml'), SEMGREP_SEND_METRICS: 'off', SEMGREP_ENABLE_VERSION_CHECK: '0' } } : {}) });
142
+ signal?.throwIfAborted();
143
+ if (ctl.signal.aborted || result.signal)
144
+ return { coverage: { engine, status: 'timed-out', reason: 'execution-timeout' } };
145
+ if (result.exitCode !== 0 && result.exitCode !== 1)
146
+ return { coverage: { engine, status: 'failed', reason: engine === 'osv' && /database|cache|offline/i.test(result.stderr) ? 'database-unavailable' : 'execution-failed' } };
147
+ try {
148
+ return { report: JSON.parse(result.stdout.replace(/^\uFEFF/, '').trim()), exitCode: result.exitCode };
149
+ }
150
+ catch {
151
+ return { coverage: { engine, status: 'failed', reason: 'invalid-or-truncated-json' } };
152
+ }
153
+ }
154
+ catch (error) {
155
+ signal?.throwIfAborted();
156
+ return { coverage: { engine, status: missing(error) ? 'not-installed' : ctl.signal.aborted ? 'timed-out' : 'failed', reason: missing(error) ? 'executable-missing' : ctl.signal.aborted ? 'execution-timeout' : 'execution-failed' } };
157
+ }
158
+ finally {
159
+ clearTimeout(timer);
160
+ }
161
+ }
162
+ export async function scanExternal(engine, documents, cfg, runner, signal) {
163
+ const started = Date.now(), health = await probeEngine(engine, cfg, runner, signal);
164
+ signal?.throwIfAborted();
165
+ if (health.status !== 'completed')
166
+ return { findings: [], coverage: health };
167
+ if (!documents.length)
168
+ return { findings: [], coverage: { ...health, status: 'not-run', reason: 'no-eligible-files' } };
169
+ const owned = await snapshot(documents, cfg);
170
+ try {
171
+ const args = engine === 'betterleaks' ? ['dir', owned.dir, '--config', rulePath('betterleaks.toml'), '--validation=false', '--redact=100', '--no-banner', '--no-color', '--ignore-gitleaks-allow', '--max-archive-depth=0', '--max-decode-depth=0', '--report-format=json', '--report-path=-', '--timeout', String(Math.ceil(cfg.engineTimeoutMs / 1000))]
172
+ : ['scan', '--config', rulePath('semgrep.yml'), '--metrics=off', '--disable-version-check', '--no-git-ignore', '--no-rewrite-rule-ids', '--json', '--quiet', '--max-target-bytes', String(cfg.maxFileBytes), '--timeout', '15', owned.dir];
173
+ const run = await execute(engine, args, cfg, runner, owned.dir, signal);
174
+ if (run.coverage)
175
+ return { findings: [], coverage: { ...run.coverage, version: health.version } };
176
+ try {
177
+ const normalized = normalizeFindings(engine, run.report, owned.dir, owned.files);
178
+ return { findings: normalized.findings, coverage: { ...health, status: normalized.incomplete || owned.skipped ? 'partial' : 'completed', ...(normalized.incomplete || owned.skipped ? { reason: 'incomplete-coverage' } : {}), findingsCount: normalized.findings.length, durationMs: Date.now() - started } };
179
+ }
180
+ catch {
181
+ return { findings: [], coverage: { ...health, status: 'failed', reason: 'invalid-json-schema' } };
182
+ }
183
+ }
184
+ finally {
185
+ await owned.dispose();
186
+ }
187
+ }
188
+ export function mergeFindings(findings) {
189
+ const grouped = new Map();
190
+ const rank = { critical: 4, high: 3, medium: 2, low: 1 };
191
+ const confidenceRank = { high: 3, medium: 2, low: 1 };
192
+ for (const finding of findings) {
193
+ const key = finding.file + '|' + finding.line + '|' + finding.cwe;
194
+ const previous = grouped.get(key);
195
+ if (!previous)
196
+ grouped.set(key, { ...finding, sources: [finding.engine ?? 'builtin'] });
197
+ else
198
+ grouped.set(key, { ...previous, severity: rank[finding.severity] > rank[previous.severity] ? finding.severity : previous.severity,
199
+ confidence: confidenceRank[finding.confidence] > confidenceRank[previous.confidence] ? finding.confidence : previous.confidence, evidence: finding.evidence === 'dataflow' ? 'dataflow' : previous.evidence,
200
+ sources: [...new Set([...(previous.sources ?? []), finding.engine ?? 'builtin'])] });
201
+ }
202
+ return [...grouped.values()].sort((a, b) => rank[b.severity] - rank[a.severity] || a.file.localeCompare(b.file) || a.line - b.line);
203
+ }
204
+ export async function auditDependencies(documents, mode, cfg, runner, signal) {
205
+ const health = await probeEngine('osv', cfg, runner, signal);
206
+ signal?.throwIfAborted();
207
+ if (health.status !== 'completed')
208
+ return { mode, coverage: health, packagesScanned: 0, vulnerabilities: [] };
209
+ if (!documents.length)
210
+ return { mode, coverage: { ...health, status: 'not-run', reason: 'no-lockfiles' }, packagesScanned: 0, vulnerabilities: [] };
211
+ const owned = await snapshot(documents, cfg);
212
+ try {
213
+ const args = ['scan', 'source', '--recursive', '--no-ignore', '--format=json', '--all-packages', '--no-resolve', '--no-call-analysis=go', '--no-call-analysis=rust', '--config', rulePath('osv.toml'), ...(mode === 'offline' ? ['--offline'] : []), owned.dir];
214
+ const run = await execute('osv', args, cfg, runner, owned.dir, signal);
215
+ if (run.coverage)
216
+ return { mode, coverage: { ...run.coverage, version: health.version }, packagesScanned: 0, vulnerabilities: [] };
217
+ if (!Array.isArray(run.report?.results))
218
+ return { mode, coverage: { ...health, status: 'failed', reason: 'invalid-json-schema' }, packagesScanned: 0, vulnerabilities: [] };
219
+ let packagesScanned = 0, incomplete = owned.skipped > 0;
220
+ const vulnerabilities = [];
221
+ for (const source of run.report.results) {
222
+ const doc = documentFor(source.source?.path, owned.dir, owned.files);
223
+ if (!doc || !Array.isArray(source.packages)) {
224
+ incomplete = true;
225
+ continue;
226
+ }
227
+ for (const item of source.packages) {
228
+ const pkg = item.package;
229
+ if (typeof pkg?.name !== 'string' || typeof pkg.version !== 'string') {
230
+ incomplete = true;
231
+ continue;
232
+ }
233
+ // OSV intentionally does not query public advisories for local packages.
234
+ if (typeof pkg.purl === 'string' && /[?&]source=LOCAL(?:&|$)/i.test(pkg.purl)) {
235
+ incomplete = true;
236
+ continue;
237
+ }
238
+ packagesScanned++;
239
+ for (const vuln of item.vulnerabilities ?? []) {
240
+ if (typeof vuln.id !== 'string') {
241
+ incomplete = true;
242
+ continue;
243
+ }
244
+ const fixedVersions = (vuln.affected ?? []).filter((a) => a.package?.name === pkg.name && a.package?.ecosystem === pkg.ecosystem).flatMap((a) => (a.ranges ?? []).flatMap((r) => (r.events ?? []).flatMap((e) => typeof e.fixed === 'string' ? [e.fixed] : [])));
245
+ vulnerabilities.push({ id: vuln.id, aliases: Array.isArray(vuln.aliases) ? vuln.aliases.filter((x) => typeof x === 'string') : [], package: redactText(pkg.name), version: redactText(pkg.version), ecosystem: pkg.ecosystem,
246
+ file: doc.file, summary: redactText(String(vuln.summary ?? vuln.id).slice(0, 2000)), severity: vuln.database_specific?.severity ?? vuln.ecosystem_specific?.severity ?? 'unknown', fixedVersions: [...new Set(fixedVersions)].slice(0, 50), url: 'https://osv.dev/vulnerability/' + encodeURIComponent(vuln.id) });
247
+ }
248
+ }
249
+ }
250
+ return { mode, coverage: { ...health, status: incomplete ? 'partial' : packagesScanned ? 'completed' : 'not-run', ...(incomplete ? { reason: 'incomplete-coverage' } : !packagesScanned ? { reason: 'no-resolved-packages' } : {}), findingsCount: vulnerabilities.length }, packagesScanned, vulnerabilities };
251
+ }
252
+ finally {
253
+ await owned.dispose();
254
+ }
255
+ }
package/lib/extra.d.ts CHANGED
@@ -5,4 +5,5 @@
5
5
  */
6
6
  import { type ResolvedSecureConfig } from './config.js';
7
7
  import type { SecureToolDefinition } from './tools.js';
8
- export declare function buildExtraTools(cfg: ResolvedSecureConfig, cwd: string, signal?: AbortSignal): SecureToolDefinition[];
8
+ import type { ProcessRunner } from './runner.js';
9
+ export declare function buildExtraTools(cfg: ResolvedSecureConfig, cwd: string, signal?: AbortSignal, runner?: ProcessRunner): SecureToolDefinition[];
package/lib/extra.js CHANGED
@@ -6,7 +6,11 @@
6
6
  import path from 'node:path';
7
7
  import { optionalString, requiredString } from './config.js';
8
8
  import { scanDeps } from './deps.js';
9
- import { loadState, saveBaseline } from './state.js';
9
+ import { findingFingerprint, loadState, saveBaseline } from './state.js';
10
+ import { scanArguments } from './tools.js';
11
+ import { auditDependencies } from './engines.js';
12
+ import { loadPolicy } from './policy.js';
13
+ import fs from 'node:fs/promises';
10
14
  function compileParameters(spec) {
11
15
  const properties = {};
12
16
  const required = [];
@@ -18,11 +22,13 @@ function compileParameters(spec) {
18
22
  node.type = prop.type;
19
23
  if (typeof prop?.description === 'string')
20
24
  node.description = prop.description;
25
+ if (prop?.enum !== undefined)
26
+ node.enum = prop.enum;
21
27
  properties[key] = node;
22
28
  }
23
29
  return { type: 'object', properties, ...(required.length > 0 ? { required } : {}) };
24
30
  }
25
- export function buildExtraTools(cfg, cwd, signal) {
31
+ export function buildExtraTools(cfg, cwd, signal, runner) {
26
32
  const stateDir = cfg.stateDir;
27
33
  const secureBaseline = {
28
34
  name: 'secure_baseline',
@@ -48,7 +54,7 @@ export function buildExtraTools(cfg, cwd, signal) {
48
54
  target: state.last.target,
49
55
  reason,
50
56
  findings: state.last.findings,
51
- fingerprints: state.last.fingerprints,
57
+ fingerprints: state.last.findings.map(findingFingerprint),
52
58
  counts: state.last.counts,
53
59
  };
54
60
  const file = await saveBaseline(stateDir, baseline, signal);
@@ -58,9 +64,10 @@ export function buildExtraTools(cfg, cwd, signal) {
58
64
  };
59
65
  const secureDeps = {
60
66
  name: 'secure_deps',
61
- description: 'SBOM-lite:解析项目依赖清单(package.json / requirements.txt / pyproject.toml / go.mod / Cargo.toml / composer.json / Gemfile),列出依赖名称、版本约束与风险标记(wildcard/git/local/unpinned)。不联网。',
67
+ description: '解析依赖清单与锁文件。默认仅离线清单;audit=offline 使用本地 OSV 数据库,audit=online 明确允许向 OSV 查询包名/版本等依赖信息。不执行包管理器或安装脚本。清单检查不等于已知漏洞审计。',
62
68
  parameters: compileParameters({
63
69
  target: { type: 'string', description: '扫描根目录(可选,默认当前工作区)。' },
70
+ audit: { type: 'string', enum: ['none', 'offline', 'online'], description: '默认 none。offline 需要本地漏洞库;online 会向 OSV 查询依赖信息,不上传源码。' },
64
71
  }),
65
72
  output: {
66
73
  schema: {
@@ -85,10 +92,28 @@ export function buildExtraTools(cfg, cwd, signal) {
85
92
  },
86
93
  },
87
94
  async execute(rawArgs) {
88
- const args = (rawArgs ?? {});
95
+ const args = scanArguments(rawArgs, ['target', 'audit']);
96
+ const mode = args.audit ?? 'none';
97
+ if (!['none', 'offline', 'online'].includes(String(mode)))
98
+ throw new Error('audit 只支持 none、offline、online');
89
99
  const target = optionalString(args, 'target');
90
100
  const root = path.resolve(cwd, target ?? '.');
91
- return scanDeps(root);
101
+ const documents = [];
102
+ let bytes = 0, truncated = false;
103
+ const rootDir = (await fs.lstat(root)).isDirectory() ? root : path.dirname(root);
104
+ const inventory = await scanDeps(root, { maxFiles: cfg.maxFiles, maxFileBytes: cfg.maxFileBytes, signal, policy: await loadPolicy(cwd),
105
+ ...(mode === 'none' ? {} : { onDocument(doc) { if (bytes + Buffer.byteLength(doc.text) > 64 * 1024 * 1024) {
106
+ truncated = true;
107
+ return;
108
+ } ; bytes += Buffer.byteLength(doc.text); documents.push({ file: path.relative(cwd, path.join(rootDir, doc.file)).split(path.sep).join('/'), text: doc.text }); } }) });
109
+ if (mode === 'none')
110
+ return { ...inventory, audited: false, audit: null };
111
+ if (!runner)
112
+ throw new Error('OSV 审计需要宿主 subprocess 服务');
113
+ const audit = await auditDependencies(documents, mode, cfg, runner, signal);
114
+ if (truncated && audit.coverage.status === 'completed')
115
+ audit.coverage = { ...audit.coverage, status: 'partial', reason: 'snapshot-size-limit' };
116
+ return { ...inventory, audited: audit.coverage.status === 'completed', audit, passed: inventory.complete && audit.coverage.status === 'completed' && audit.vulnerabilities.length === 0 };
92
117
  },
93
118
  timeoutMs: 30000,
94
119
  };
package/lib/index.d.ts CHANGED
@@ -2,8 +2,9 @@
2
2
  * dsh-code-security —— AI 代码安全审查插件(node 半身,配置走 cordis.patch.yml)。
3
3
  *
4
4
  * 插件导出 apply(ctx, config):注册 secure_scan / secure_diff / secure_fix_verify /
5
- * secure_report / secure_export / secure_policy_show / secure_policy_set 七个工具。扫描为纯确定性规则,
6
- * git diff 通过官方 subprocess 服务执行,仅依赖文件锁库。
5
+ * secure_report / secure_export / secure_baseline / secure_deps / secure_policy_show /
6
+ * secure_policy_set / secure_health 十个工具。内置规则与可选本地引擎共用
7
+ * 脱敏、覆盖状态与基线;子进程通过官方服务执行,设置面板与自动检查按需挂载。
7
8
  *
8
9
  * @module dsh-code-security
9
10
  */
@@ -22,6 +23,7 @@ export interface SecurePluginContext {
22
23
  on?(event: string, listener: (...args: any[]) => unknown, options?: {
23
24
  prepend?: boolean;
24
25
  }): (() => void) | void;
26
+ inject?(services: string[], callback: (ctx: any) => void): unknown;
25
27
  }
26
28
  export declare function apply(ctx: SecurePluginContext, config?: SecureConfig | null): void;
27
29
  export * from './config.js';
@@ -34,3 +36,8 @@ export * from './scanner.js';
34
36
  export * from './state.js';
35
37
  export * from './extra.js';
36
38
  export * from './tools.js';
39
+ export * from './engines.js';
40
+ export * from './audit.js';
41
+ export * from './privacy.js';
42
+ export * from './workbench.js';
43
+ export * from './automatic.js';
package/lib/index.js CHANGED
@@ -2,30 +2,66 @@
2
2
  * dsh-code-security —— AI 代码安全审查插件(node 半身,配置走 cordis.patch.yml)。
3
3
  *
4
4
  * 插件导出 apply(ctx, config):注册 secure_scan / secure_diff / secure_fix_verify /
5
- * secure_report / secure_export / secure_policy_show / secure_policy_set 七个工具。扫描为纯确定性规则,
6
- * git diff 通过官方 subprocess 服务执行,仅依赖文件锁库。
5
+ * secure_report / secure_export / secure_baseline / secure_deps / secure_policy_show /
6
+ * secure_policy_set / secure_health 十个工具。内置规则与可选本地引擎共用
7
+ * 脱敏、覆盖状态与基线;子进程通过官方服务执行,设置面板与自动检查按需挂载。
7
8
  *
8
9
  * @module dsh-code-security
9
10
  */
10
11
  import { resolveConfig } from './config.js';
11
12
  import { createSubprocessRunner } from './runner.js';
12
13
  import { buildSecureTools } from './tools.js';
14
+ import { SecurityWorkbench, installSecurityWorkbench } from './workbench.js';
15
+ import { AutomaticSecurity } from './automatic.js';
13
16
  export const name = 'code-security';
14
17
  export const inject = ['tools', 'subprocess'];
15
18
  export function apply(ctx, config) {
16
19
  let cfg;
20
+ let configurationError;
17
21
  try {
18
22
  cfg = resolveConfig(config);
19
23
  }
20
24
  catch (error) {
21
- console.warn('[dsh-code-security] ' + (error instanceof Error ? error.message : String(error)));
25
+ configurationError = error instanceof Error ? error.message : String(error);
26
+ console.warn('[dsh-code-security] ' + configurationError);
22
27
  cfg = resolveConfig(null);
23
28
  }
24
29
  const runner = createSubprocessRunner((spec) => ctx.subprocess.spawn(spec), 10000, 60000);
25
- const tools = buildSecureTools(cfg, process.cwd(), runner);
30
+ const configuredTools = (config, root) => buildSecureTools(config, root, runner).map(definition => ({ ...definition, async execute(args, exec) {
31
+ if (configurationError && ['secure_scan', 'secure_diff', 'secure_fix_verify', 'secure_deps', 'secure_baseline'].includes(definition.name))
32
+ throw new Error('请先修复插件配置,检查未运行 / Fix the plugin configuration before scanning: ' + configurationError);
33
+ const value = await definition.execute(args, exec);
34
+ return configurationError && definition.name === 'secure_health' ? { ...value, ok: false, configurationError } : value;
35
+ } }));
36
+ const tools = configuredTools(cfg, process.cwd());
26
37
  const disposers = [];
38
+ const workbench = typeof ctx.inject === 'function' ? new SecurityWorkbench(cfg, { tools: configuredTools }) : undefined;
39
+ if (workbench) {
40
+ installSecurityWorkbench(ctx, workbench);
41
+ const automatic = new AutomaticSecurity(workbench);
42
+ ctx.inject?.(['sessions', 'systemPrompt'], host => {
43
+ const detach = automatic.attach(host);
44
+ if (typeof host.effect === 'function')
45
+ host.effect(() => detach);
46
+ else
47
+ host.on?.('dispose', detach);
48
+ });
49
+ ctx.on?.('dispose', async () => { automatic.dispose(); await automatic.whenIdle(); await workbench.dispose(); });
50
+ }
27
51
  for (const definition of tools) {
28
- disposers.push(ctx.tools.register(definition));
52
+ disposers.push(ctx.tools.register({ ...definition, async execute(args, exec) {
53
+ if (!workbench)
54
+ return definition.execute(args, exec);
55
+ const context = exec;
56
+ const root = context?.agent?.session?.header?.cwd || process.cwd();
57
+ const configured = configuredTools(await workbench.configFor(root), root).find(t => t.name === definition.name);
58
+ const value = await configured.execute(args, exec);
59
+ try {
60
+ await workbench.remember(root, context?.agent?.session?.id);
61
+ }
62
+ catch { /* Indexing must not turn a successful check into an error. */ }
63
+ return value;
64
+ } }));
29
65
  }
30
66
  if (typeof ctx.on === 'function') {
31
67
  ctx.on('tools/pre-execute', async (exec, next) => {
@@ -55,3 +91,8 @@ export * from './scanner.js';
55
91
  export * from './state.js';
56
92
  export * from './extra.js';
57
93
  export * from './tools.js';
94
+ export * from './engines.js';
95
+ export * from './audit.js';
96
+ export * from './privacy.js';
97
+ export * from './workbench.js';
98
+ export * from './automatic.js';
@@ -0,0 +1,6 @@
1
+ import type { Finding } from './scanner.js';
2
+ export declare function evidenceFingerprint(finding: Pick<Finding, 'ruleId' | 'file' | 'snippet'> & {
3
+ fingerprint?: string;
4
+ }): string;
5
+ export declare function redactText(text: string): string;
6
+ export declare function protectFinding(finding: Finding): Finding;
package/lib/privacy.js ADDED
@@ -0,0 +1,39 @@
1
+ /** Findings crossing the scanner boundary never contain complete credentials. */
2
+ import { createHash } from 'node:crypto';
3
+ import path from 'node:path';
4
+ export function evidenceFingerprint(finding) {
5
+ if (/^[a-f0-9]{64}$/.test(finding.fingerprint ?? ''))
6
+ return finding.fingerprint;
7
+ const file = finding.file.split(path.sep).join('/').replace(/^(?:\.\/)+/, '');
8
+ return createHash('sha256').update(finding.ruleId + '|' + file + '|' + finding.snippet.trim()).digest('hex');
9
+ }
10
+ export function redactText(text) {
11
+ return text
12
+ .replace(/-----BEGIN (?:[A-Z]+ )?PRIVATE KEY-----[\s\S]*?(?:-----END (?:[A-Z]+ )?PRIVATE KEY-----|$)/g, '[REDACTED PRIVATE KEY]')
13
+ .replace(/((?:[\w.-]*(?:password|passwd|pwd|secret|token|api[_-]?key|access[_-]?key|authorization)[\w.-]*)["']?\s*[:=]\s*)(["'`])([^\r\n]*?)(?:\2|$)/gim, '$1$2[REDACTED]$2')
14
+ .replace(/((?:[\w.-]*(?:password|passwd|pwd|secret|token|api[_-]?key|access[_-]?key|authorization)[\w.-]*)["']?\s*[:=]\s*)(?!["'`\s]|\[REDACTED\b)[^\s,;}\]]+/gi, '$1[REDACTED]')
15
+ .replace(/(^\s*[A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|PASS)[A-Z0-9_]*\s*=\s*)([^\r\n]+)/gm, '$1[REDACTED]')
16
+ .replace(/\b(?:gh[pousr]_[A-Za-z0-9_]{16,}|github_pat_[A-Za-z0-9_]{20,}|sk-(?:proj-)?[A-Za-z0-9_-]{16,}|AKIA[A-Z0-9]{16})\b/g, '[REDACTED]')
17
+ .replace(/(\bBearer\s+)[A-Za-z0-9._~+\/-]{12,}/gi, '$1[REDACTED]')
18
+ .replace(/(https?:\/\/)[^\s/@]+:[^\s/@]+@/g, '$1[REDACTED]@');
19
+ }
20
+ export function protectFinding(finding) {
21
+ const fingerprint = evidenceFingerprint(finding);
22
+ let snippet = redactText(finding.snippet);
23
+ if (finding.ruleId === 'SEC-203')
24
+ snippet = snippet.replace(/(["'])([^\r\n]*?)\1/g, '$1[REDACTED]$1');
25
+ // Entropy/external secret rules can identify values without an assignment.
26
+ if (finding.ruleId === 'SEC-304' || finding.engine === 'betterleaks')
27
+ snippet = '[REDACTED SECRET]';
28
+ if (finding.ruleId === 'SEC-303')
29
+ snippet = '[REDACTED PRIVATE KEY]';
30
+ if (finding.ruleId === 'SEC-305')
31
+ snippet = snippet.replace(/(=)[\s\S]*/, '$1[REDACTED]');
32
+ return { id: finding.id, ruleId: finding.ruleId, file: finding.file, line: finding.line,
33
+ severity: finding.severity, confidence: finding.confidence, cwe: finding.cwe,
34
+ ...(finding.endLine === undefined ? {} : { endLine: finding.endLine }),
35
+ ...(finding.engine === undefined ? {} : { engine: finding.engine }),
36
+ ...(finding.evidence === undefined ? {} : { evidence: finding.evidence }),
37
+ ...(finding.sources === undefined ? {} : { sources: finding.sources }),
38
+ fingerprint, snippet, message: redactText(finding.message), title: redactText(finding.title) };
39
+ }
package/lib/rules.js CHANGED
@@ -19,7 +19,7 @@ export const RULES = Object.freeze([
19
19
  { id: 'SEC-006', title: 'Shell 管道下载执行', cwe: 'CWE-78', severity: 'critical', confidence: 'high', languages: SHELL, patterns: [/\bcurl\b[^|\n]*\|\s*(?:sudo\s+)?(?:ba|z|k|da)?sh\b/i, /\bwget\b[^|\n]*\|\s*(?:sudo\s+)?(?:ba|z|k|da)?sh\b/i], message: '下载内容直接通过管道交给 shell 执行。' },
20
20
  { id: 'SEC-007', title: 'JavaScript SQL 字符串拼接', cwe: 'CWE-89', severity: 'high', confidence: 'medium', languages: JS, patterns: [/['"](?:SELECT|INSERT|UPDATE|DELETE)\b[^'"]*['"]\s*\+/i, /\bquery\s*=\s*['"][^'"]*['"]\s*\+/i], message: 'SQL 语句与变量拼接,疑似可注入。' },
21
21
  { id: 'SEC-008', title: 'Python SQL 字符串格式化', cwe: 'CWE-89', severity: 'high', confidence: 'medium', languages: PY, patterns: [/\.execute\s*\([^)]*(?:%\s*\(|%\s*[a-z_]|\bf\s*['"]|\+\s*str\()/i], message: 'SQL 执行语句使用字符串格式化或拼接。' },
22
- { id: 'SEC-009', title: 'JavaScript innerHTML / 危险 HTML 注入', cwe: 'CWE-79', severity: 'high', confidence: 'medium', languages: JS, patterns: [/\.innerHTML\s*=/, /\bdangerouslySetInnerHTML\b/], message: '将不可信内容写入 HTML 插值点。' },
22
+ { id: 'SEC-009', title: 'HTML 写入点需要核对输入来源', cwe: 'CWE-79', severity: 'medium', confidence: 'low', languages: JS, patterns: [/\.innerHTML\s*=/, /\bdangerouslySetInnerHTML\b/], message: '发现 HTML 写入接口。这是模式提示,尚未证明输入未经净化;已验证绑定的默认 DOMPurify.sanitize 调用不报告。' },
23
23
  { id: 'SEC-010', title: '开放重定向', cwe: 'CWE-601', severity: 'medium', confidence: 'medium', languages: JS, patterns: [/\bredirect\s*\(\s*(?:req|request)\.(?:query|params|body)/i, /\blocation\s*=\s*(?:req|request)\.(?:query|params|body)/i], message: '跳转目标直接取自请求参数。' },
24
24
  { id: 'SEC-101', title: 'Python pickle 反序列化', cwe: 'CWE-502', severity: 'critical', confidence: 'high', languages: PY, patterns: [/\bpickle\.(?:loads?)\s*\(/], message: 'pickle 反序列化不可信数据。' },
25
25
  { id: 'SEC-102', title: 'Python yaml.load 不安全解析', cwe: 'CWE-502', severity: 'high', confidence: 'medium', languages: PY, patterns: [/\byaml\.load\s*\(/], message: 'yaml.load 未使用 SafeLoader。' },
package/lib/runner.d.ts CHANGED
@@ -14,6 +14,7 @@ export interface ProcessRunner {
14
14
  timeoutMs?: number;
15
15
  cwd?: string;
16
16
  signal?: AbortSignal;
17
+ env?: Record<string, string>;
17
18
  }): Promise<RunResult>;
18
19
  }
19
20
  export interface SubprocessHandleLike {
@@ -25,11 +26,13 @@ export interface SubprocessHandleLike {
25
26
  stdout?: {
26
27
  readFrom(offset: number): {
27
28
  text: string;
29
+ lossy?: boolean;
28
30
  };
29
31
  };
30
32
  stderr?: {
31
33
  readFrom(offset: number): {
32
34
  text: string;
35
+ lossy?: boolean;
33
36
  };
34
37
  };
35
38
  };
@@ -50,6 +53,7 @@ export interface SubprocessSpawnLike {
50
53
  };
51
54
  graceMs: number;
52
55
  signal?: AbortSignal;
56
+ env?: Record<string, string>;
53
57
  }): SubprocessHandleLike;
54
58
  }
55
59
  export declare function createSubprocessRunner(spawn: SubprocessSpawnLike, graceMs: number, defaultTimeoutMs: number): ProcessRunner;
package/lib/runner.js CHANGED
@@ -11,7 +11,7 @@ export function createSubprocessRunner(spawn, graceMs, defaultTimeoutMs) {
11
11
  const controller = new AbortController();
12
12
  const signal = options?.signal ? AbortSignal.any([options.signal, controller.signal]) : controller.signal;
13
13
  signal.throwIfAborted();
14
- const timer = setTimeout(() => controller.abort(new Error('git diff timed out')), timeoutMs);
14
+ const timer = setTimeout(() => controller.abort(new Error('scanner subprocess timed out')), timeoutMs);
15
15
  try {
16
16
  const handle = spawn({
17
17
  argv,
@@ -19,13 +19,18 @@ export function createSubprocessRunner(spawn, graceMs, defaultTimeoutMs) {
19
19
  stdio: { stdin: 'ignore', stdout: { maxBytes: COLLECT_BYTES }, stderr: { maxBytes: COLLECT_BYTES } },
20
20
  graceMs,
21
21
  signal,
22
+ ...(options?.env ? { env: options.env } : {}),
22
23
  });
23
24
  const outcome = await handle.done;
25
+ signal.throwIfAborted();
26
+ const stdout = handle.collected.stdout?.readFrom(0), stderr = handle.collected.stderr?.readFrom(0);
27
+ if (stdout?.lossy || stderr?.lossy)
28
+ throw new Error('scanner subprocess output exceeded 8 MiB; this check is incomplete');
24
29
  return {
25
30
  exitCode: outcome.exitCode,
26
31
  signal: outcome.signal,
27
- stdout: handle.collected.stdout?.readFrom(0).text ?? '',
28
- stderr: handle.collected.stderr?.readFrom(0).text ?? '',
32
+ stdout: stdout?.text ?? '',
33
+ stderr: stderr?.text ?? '',
29
34
  };
30
35
  }
31
36
  finally {
package/lib/scanner.d.ts CHANGED
@@ -6,6 +6,11 @@
6
6
  import { type Severity } from './config.js';
7
7
  import { type SecurePolicy } from './policy.js';
8
8
  export interface Finding {
9
+ endLine?: number;
10
+ fingerprint?: string;
11
+ engine?: 'builtin' | 'betterleaks' | 'semgrep';
12
+ evidence?: 'pattern' | 'dataflow';
13
+ sources?: string[];
9
14
  id: string;
10
15
  ruleId: string;
11
16
  title: string;
@@ -18,6 +23,8 @@ export interface Finding {
18
23
  message: string;
19
24
  }
20
25
  export interface ScanResult {
26
+ complete: boolean;
27
+ warnings: string[];
21
28
  root: string;
22
29
  filesScanned: number;
23
30
  filesSkipped: number;
@@ -25,6 +32,10 @@ export interface ScanResult {
25
32
  durationMs: number;
26
33
  }
27
34
  export interface ScanOptions {
35
+ onDocument?: (document: {
36
+ file: string;
37
+ text: string;
38
+ }) => void;
28
39
  cwd: string;
29
40
  target?: string;
30
41
  maxFiles: number;
@@ -37,8 +48,10 @@ export interface LineEntry {
37
48
  text: string;
38
49
  }
39
50
  /** 对带行号的文本行应用规则(全量扫描与 git diff 增量扫描共用)。 */
40
- export declare function scanLineSet(entries: LineEntry[], rel: string, policy: SecurePolicy): Finding[];
51
+ export declare function scanLineSet(entries: LineEntry[], rel: string, policy: SecurePolicy, selectedLines?: ReadonlySet<number>, sanitized?: ReadonlySet<number>): Finding[];
41
52
  /** 扫描单个文件内容,按语言与行应用规则。 */
42
53
  export declare function scanText(content: string, file: string, rel: string, policy: SecurePolicy): Finding[];
54
+ /** Apply the same exclusions to full scans, directly selected files and diffs. */
55
+ export declare function isExcludedPath(rel: string, policy: SecurePolicy): boolean;
43
56
  /** 扫描目录或单个文件。 */
44
57
  export declare function scanPath(options: ScanOptions): Promise<ScanResult>;