dsh-code-security 0.3.8 → 0.4.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/deps.js CHANGED
@@ -1,179 +1,206 @@
1
- /**
2
- * SBOM-lite:解析常见依赖清单,输出依赖名称、版本约束与风险标记。
3
- * 只做确定性解析,不联网、不执行包管理器。
4
- *
5
- * @module dsh-code-security/deps
6
- */
1
+ /** Offline manifest inventory. Parsing/coverage failures are explicit, never a clean audit. */
7
2
  import fs from 'node:fs/promises';
8
3
  import path from 'node:path';
4
+ import { parse as parseToml } from 'smol-toml';
9
5
  import { DEFAULT_EXCLUDE_DIRS } from './config.js';
6
+ import { redactText } from './privacy.js';
7
+ import { policyExcludesDir } from './policy.js';
8
+ const LOCKFILES = ['package-lock.json', 'npm-shrinkwrap.json', 'pnpm-lock.yaml', 'yarn.lock', 'bun.lock', 'uv.lock', 'poetry.lock', 'Pipfile.lock', 'pdm.lock', 'pylock.toml', 'Cargo.lock', 'composer.lock', 'Gemfile.lock', 'go.mod', 'requirements.txt', 'pom.xml'];
10
9
  const MANIFESTS = ['package.json', 'requirements.txt', 'pyproject.toml', 'go.mod', 'Cargo.toml', 'composer.json', 'Gemfile'];
11
- function asRecord(value) {
12
- return typeof value === 'object' && value !== null ? value : {};
13
- }
10
+ const record = (value) => value && typeof value === 'object' && !Array.isArray(value) ? value : {};
14
11
  function riskOfSpec(spec) {
15
12
  const s = spec.trim();
16
- if (s === '')
13
+ if (!s)
17
14
  return { risk: 'unpinned', note: '未声明版本约束' };
18
- if (s === '*' || /^latest$/i.test(s) || /^x$/i.test(s))
19
- return { risk: 'wildcard', note: '版本约束为通配' };
20
- if (/^(?:https?|git|git+|github:)/i.test(s))
21
- return { risk: /#([0-9a-f]{7,40})\b/i.test(s) ? 'exact' : 'git', note: /#/.test(s) ? 'git 源已带提交锁定' : 'git 源未锁定到提交' };
22
- if (/^(?:file|link):/i.test(s))
23
- return { risk: 'local', note: '本地/链接依赖,发布时会失配' };
24
- if (/^[0-9]+(?:\.[A-Za-z0-9.*-]+)+$/.test(s) || /^==[0-9]/.test(s))
15
+ if (/^(?:file|link|workspace|path):/i.test(s))
16
+ return { risk: 'local', note: '本地或工作区依赖' };
17
+ if (/^(?:https?|git|github:)/i.test(s))
18
+ return /#[a-f0-9]{7,40}$/i.test(s) ? { risk: 'exact', note: 'Git 提交已固定' } : { risk: 'git', note: '外部源未固定 Git 提交' };
19
+ if (s === '*' || /^latest$/i.test(s) || /(?:^|\.)[x*](?:\.|$)/i.test(s))
20
+ return { risk: 'wildcard', note: '浮动版本' };
21
+ if (/^(?:==)?v?\d+(?:\.\d+)+(?:[-+][\w.-]+)?$/.test(s))
25
22
  return { risk: 'exact', note: '' };
26
23
  if (/[<>=~^]/.test(s) || s.includes('||'))
27
24
  return { risk: 'range', note: '' };
28
- return { risk: 'unpinned', note: '无法识别版本约束' };
29
- }
30
- function pushDeps(list, entries, manager, file, kind) {
31
- for (const [name, rawSpec] of Object.entries(entries)) {
32
- const spec = typeof rawSpec === 'string' ? rawSpec : '';
33
- const { risk, note } = riskOfSpec(spec);
34
- list.push({ name, spec, manager, file, kind, risk, note });
35
- }
25
+ return { risk: 'unpinned', note: '未能识别固定版本' };
36
26
  }
37
- function parsePackageJson(text, file) {
38
- try {
39
- const pkg = JSON.parse(text);
40
- const list = [];
41
- pushDeps(list, asRecord(pkg.dependencies), 'npm', file, 'runtime');
42
- pushDeps(list, asRecord(pkg.devDependencies), 'npm', file, 'dev');
43
- pushDeps(list, asRecord(pkg.peerDependencies), 'npm', file, 'peer');
44
- pushDeps(list, asRecord(pkg.optionalDependencies), 'npm', file, 'optional');
45
- return list;
27
+ export async function scanDeps(root, options = {}) {
28
+ const dependencies = [], warnings = [];
29
+ const lockfiles = [];
30
+ let filesScanned = 0, filesSkipped = 0, visited = 0, complete = true;
31
+ const warn = (text) => { complete = false; if (warnings.length < 100)
32
+ warnings.push(text); };
33
+ function add(name, rawSpec, manager, file, kind) {
34
+ dependencies.push({ name: redactText(name), spec: redactText(rawSpec), manager, file, kind, ...riskOfSpec(rawSpec) });
46
35
  }
47
- catch {
48
- return [];
36
+ function table(value, manager, file, kind) {
37
+ if (value === undefined)
38
+ return;
39
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
40
+ warn(file + ' 的依赖表格式无效');
41
+ return;
42
+ }
43
+ for (const [name, valueSpec] of Object.entries(record(value))) {
44
+ if (typeof valueSpec !== 'string' && (!valueSpec || typeof valueSpec !== 'object' || Array.isArray(valueSpec))) {
45
+ warn(file + ' 含无效依赖声明');
46
+ continue;
47
+ }
48
+ const obj = record(valueSpec);
49
+ const spec = typeof valueSpec === 'string' ? valueSpec : typeof obj.git === 'string' ? obj.git + (typeof obj.rev === 'string' ? '#' + obj.rev : '') : typeof obj.path === 'string' ? 'path:' + obj.path : typeof obj.version === 'string' ? obj.version : '';
50
+ add(name, spec, manager, file, kind);
51
+ }
49
52
  }
50
- }
51
- function parseRequirements(text, file) {
52
- const list = [];
53
- for (const raw of text.split(/\r?\n/)) {
54
- const line = raw.trim();
55
- if (line === '' || line.startsWith('#') || line.startsWith('-'))
56
- continue;
57
- const m = /^([A-Za-z0-9_.\-]+)(.*)$/.exec(line);
58
- if (m === null)
59
- continue;
60
- const spec = m[2]?.trim() ?? '';
61
- const { risk, note } = riskOfSpec(spec);
62
- list.push({ name: m[1], spec, manager: 'pypi', file, kind: 'runtime', risk, note });
53
+ function requirement(value, file, kind) {
54
+ if (typeof value !== 'string') {
55
+ warn(file + ' 含尚未展开的依赖组引用');
56
+ return;
57
+ }
58
+ const match = /^\s*([A-Za-z0-9_.-]+)(?:\[[^\]]+\])?\s*(.*)$/.exec(value);
59
+ if (!match) {
60
+ warn(file + ' 有未识别的 Python 依赖');
61
+ return;
62
+ }
63
+ add(match[1], match[2].split(';', 1)[0].trim().replace(/^@\s*/, ''), 'pypi', file, kind);
63
64
  }
64
- return list;
65
- }
66
- function parsePyproject(text, file) {
67
- const list = [];
68
- const re = /^\s*([A-Za-z0-9_.\-]+)\s*(?:[<>=!~^]+[^\s,]+)?/gm;
69
- for (const section of text.matchAll(/\[project\.(?:optional-)?dependencies\][\s\S]*?(?=\n\[|$)/g)) {
70
- for (const line of section[0].split(/\r?\n/)) {
71
- const trimmed = line.trim();
72
- if (trimmed === '' || trimmed.startsWith('['))
65
+ function pyproject(text, file) {
66
+ const doc = parseToml(text), project = record(doc.project);
67
+ if (project.dependencies !== undefined && !Array.isArray(project.dependencies))
68
+ throw new Error('invalid dependencies');
69
+ for (const item of project.dependencies ?? [])
70
+ requirement(item, file, 'runtime');
71
+ for (const items of Object.values(record(project['optional-dependencies']))) {
72
+ if (!Array.isArray(items)) {
73
+ warn(file + ' 可选依赖不是数组');
73
74
  continue;
74
- const m = /^([A-Za-z0-9_.\-]+)\s*(.*)$/.exec(trimmed.replace(/^"|"$/g, ''));
75
- if (m === null)
75
+ }
76
+ items.forEach(item => requirement(item, file, 'optional'));
77
+ }
78
+ for (const items of Object.values(record(doc['dependency-groups']))) {
79
+ if (!Array.isArray(items)) {
80
+ warn(file + ' 依赖组不是数组');
76
81
  continue;
77
- const spec = m[2]?.trim() ?? '';
78
- const { risk, note } = riskOfSpec(spec);
79
- list.push({ name: m[1], spec, manager: 'pypi', file, kind: 'runtime', risk, note });
82
+ }
83
+ items.forEach(item => requirement(item, file, 'dev'));
80
84
  }
85
+ const poetry = record(record(doc.tool).poetry);
86
+ const poetryTable = (value, kind) => table(Object.fromEntries(Object.entries(record(value)).filter(([name]) => name !== 'python')), 'pypi', file, kind);
87
+ poetryTable(poetry.dependencies, 'runtime');
88
+ poetryTable(poetry['dev-dependencies'], 'dev');
89
+ for (const group of Object.values(record(poetry.group)))
90
+ poetryTable(record(group).dependencies, 'dev');
91
+ if (Array.isArray(project.dynamic) && project.dynamic.includes('dependencies'))
92
+ warn(file + ' 的 dependencies 为动态声明,未执行项目代码读取');
81
93
  }
82
- return list;
83
- }
84
- function parseGoMod(text, file) {
85
- const list = [];
86
- const re = /^\s*([A-Za-z0-9_.\/\-]+)\s+(v[0-9][^\s]*)/gm;
87
- for (const m of text.matchAll(re)) {
88
- list.push({ name: m[1], spec: m[2], manager: 'go', file, kind: 'runtime', risk: 'exact', note: '' });
89
- }
90
- return list;
91
- }
92
- function parseCargo(text, file) {
93
- const list = [];
94
- for (const line of text.split(/\r?\n/)) {
95
- const m = /^\s*([A-Za-z0-9_.\-]+)\s*=\s*(.+)$/.exec(line);
96
- if (m === null)
97
- continue;
98
- const spec = m[2].trim().replace(/^["']|["']$/g, '');
99
- const isGit = /git\s*=|branch\s*=|rev\s*=/.test(spec);
100
- const risk = isGit ? (spec.includes('rev') ? 'exact' : 'git') : 'range';
101
- list.push({ name: m[1], spec, manager: 'cargo', file, kind: 'runtime', risk, note: isGit && !spec.includes('rev') ? 'git 源未锁定 rev' : '' });
102
- }
103
- return list;
104
- }
105
- function parseComposer(text, file) {
106
- const list = [];
107
- try {
108
- const pkg = JSON.parse(text);
109
- for (const [key, entries] of [['require', 'runtime'], ['require-dev', 'dev']]) {
110
- pushDeps(list, asRecord(pkg[key]), 'composer', file, entries);
94
+ function parse(text, base, file) {
95
+ if (base === 'package.json' || base === 'composer.json') {
96
+ const pkg = JSON.parse(text);
97
+ if (!pkg || typeof pkg !== 'object' || Array.isArray(pkg))
98
+ throw new Error('invalid manifest');
99
+ if (base === 'package.json')
100
+ for (const [field, kind] of [['dependencies', 'runtime'], ['devDependencies', 'dev'], ['peerDependencies', 'peer'], ['optionalDependencies', 'optional']])
101
+ table(pkg[field], 'npm', file, kind);
102
+ else {
103
+ table(pkg.require, 'composer', file, 'runtime');
104
+ table(pkg['require-dev'], 'composer', file, 'dev');
105
+ }
111
106
  }
107
+ else if (base === 'pyproject.toml')
108
+ pyproject(text, file);
109
+ else if (base === 'requirements.txt')
110
+ for (const line of text.split(/\r?\n/)) {
111
+ const item = line.trim();
112
+ if (!item || item.startsWith('#'))
113
+ continue;
114
+ if (item.startsWith('-')) {
115
+ warn(file + ' 包含外部文件/安装选项,未执行解析');
116
+ continue;
117
+ }
118
+ requirement(item.split(/\s+#/, 1)[0], file, 'runtime');
119
+ }
120
+ else if (base === 'Cargo.toml') {
121
+ const doc = parseToml(text);
122
+ const cargo = (value) => { const obj = record(value); for (const [field, kind] of [['dependencies', 'runtime'], ['dev-dependencies', 'dev'], ['build-dependencies', 'dev']])
123
+ table(obj[field], 'cargo', file, kind); };
124
+ cargo(doc);
125
+ cargo(record(doc.workspace));
126
+ Object.values(record(doc.target)).forEach(cargo);
127
+ }
128
+ else if (base === 'go.mod') {
129
+ for (const m of text.matchAll(/^\s*(?:require\s+)?([A-Za-z0-9_.\/-]+)\s+(v[0-9][^\s]*)/gm))
130
+ add(m[1], m[2], 'go', file, 'runtime');
131
+ if (/^\s*replace\s/m.test(text))
132
+ warn(file + ' 含 replace,清单不代表最终解析图');
133
+ }
134
+ else
135
+ for (const m of text.matchAll(/^\s*gem\s+['"]([^'"]+)['"]\s*(?:,\s*['"]([^'"]*)['"]\s*)?/gm))
136
+ add(m[1], m[2] ?? '', 'ruby', file, 'runtime');
112
137
  }
113
- catch {
114
- // ignore malformed
115
- }
116
- return list;
117
- }
118
- function parseGemfile(text, file) {
119
- const list = [];
120
- for (const m of text.matchAll(/^\s*gem\s+['"]([^'"]+)['"]\s*(?:,\s*['"]([^'"]*)['"]\s*)?/gm)) {
121
- const spec = m[2] ?? '';
122
- const { risk, note } = riskOfSpec(spec);
123
- list.push({ name: m[1], spec, manager: 'ruby', file, kind: 'runtime', risk, note });
124
- }
125
- return list;
126
- }
127
- /** 扫描依赖清单,返回 SBOM-lite 结果。 */
128
- export async function scanDeps(root) {
129
- const dependencies = [];
130
- const warnings = [];
131
- let filesScanned = 0;
132
- const visit = async (absolute, rel) => {
138
+ async function visit(absolute, rel) {
139
+ options.signal?.throwIfAborted();
140
+ if (++visited > 100000 || filesScanned >= (options.maxFiles ?? 5000)) {
141
+ warn('已达到扫描数量上限');
142
+ return;
143
+ }
144
+ if (rel.split('/').some(p => DEFAULT_EXCLUDE_DIRS.includes(p)) || options.policy && policyExcludesDir(options.policy, rel)) {
145
+ filesSkipped++;
146
+ return;
147
+ }
133
148
  let stat;
134
149
  try {
135
- stat = await fs.stat(absolute);
150
+ stat = await fs.lstat(absolute);
136
151
  }
137
152
  catch {
153
+ filesSkipped++;
154
+ warn('无法读取 ' + rel);
155
+ return;
156
+ }
157
+ if (stat.isSymbolicLink()) {
158
+ filesSkipped++;
159
+ if (absolute === path.resolve(root))
160
+ warn('目标为链接,未读取链接内容');
138
161
  return;
139
162
  }
140
163
  if (stat.isDirectory()) {
141
- if (DEFAULT_EXCLUDE_DIRS.includes(path.basename(absolute)))
164
+ let entries;
165
+ try {
166
+ entries = await fs.readdir(absolute, { withFileTypes: true });
167
+ }
168
+ catch {
169
+ warn('无法遍历 ' + rel);
142
170
  return;
143
- const entries = await fs.readdir(absolute, { withFileTypes: true });
144
- for (const entry of entries)
145
- await visit(path.join(absolute, entry.name), rel === '' ? entry.name : rel + '/' + entry.name);
171
+ }
172
+ for (const entry of entries) {
173
+ if (visited > 100000 || filesScanned >= (options.maxFiles ?? 5000)) {
174
+ warn('已达到扫描数量上限');
175
+ break;
176
+ }
177
+ ;
178
+ await visit(path.join(absolute, entry.name), rel ? rel + '/' + entry.name : entry.name);
179
+ }
146
180
  return;
147
181
  }
148
- const base = path.basename(absolute);
149
- if (!MANIFESTS.includes(base))
182
+ if (!stat.isFile() || !MANIFESTS.includes(path.basename(absolute)) && !LOCKFILES.includes(path.basename(absolute)))
183
+ return;
184
+ if (stat.size > (options.maxFileBytes ?? 2 * 1024 * 1024)) {
185
+ filesSkipped++;
186
+ warn(rel + ' 超过文件大小上限');
150
187
  return;
151
- filesScanned += 1;
152
- let text = '';
188
+ }
189
+ filesScanned++;
153
190
  try {
154
- text = await fs.readFile(absolute, 'utf8');
191
+ const text = await fs.readFile(absolute, { encoding: 'utf8', signal: options.signal }), file = rel || path.basename(absolute);
192
+ if (LOCKFILES.includes(path.basename(absolute))) {
193
+ lockfiles.push(file);
194
+ options.onDocument?.({ file, text });
195
+ }
196
+ if (MANIFESTS.includes(path.basename(absolute)))
197
+ parse(text, path.basename(absolute), file);
155
198
  }
156
199
  catch {
157
- warnings.push('无法读取 ' + rel);
158
- return;
200
+ options.signal?.throwIfAborted();
201
+ warn('无法解析 ' + (rel || path.basename(absolute)) + ':清单格式无效,本文件不计作已确认安全');
159
202
  }
160
- if (base === 'package.json')
161
- dependencies.push(...parsePackageJson(text, rel));
162
- else if (base === 'requirements.txt')
163
- dependencies.push(...parseRequirements(text, rel));
164
- else if (base === 'pyproject.toml')
165
- dependencies.push(...parsePyproject(text, rel));
166
- else if (base === 'go.mod')
167
- dependencies.push(...parseGoMod(text, rel));
168
- else if (base === 'Cargo.toml')
169
- dependencies.push(...parseCargo(text, rel));
170
- else if (base === 'composer.json')
171
- dependencies.push(...parseComposer(text, rel));
172
- else if (base === 'Gemfile')
173
- dependencies.push(...parseGemfile(text, rel));
174
- };
175
- await visit(root, '');
176
- dependencies.sort((a, b) => a.file.localeCompare(b.file) || a.name.localeCompare(b.name));
177
- const risky = dependencies.filter((item) => item.risk !== 'exact' && item.risk !== 'range');
178
- return { root, filesScanned, dependencies, riskyCount: risky.length, warnings };
203
+ }
204
+ await visit(path.resolve(root), '');
205
+ return { root, filesScanned, filesSkipped, dependencies, riskyCount: dependencies.filter(item => !['exact', 'range'].includes(item.risk)).length, warnings, complete, lockfiles };
179
206
  }
@@ -0,0 +1,50 @@
1
+ import type { ResolvedSecureConfig, SourceEngine } from './config.js';
2
+ import type { ProcessRunner } from './runner.js';
3
+ import type { Finding } from './scanner.js';
4
+ export type EngineId = SourceEngine | 'osv';
5
+ export interface EngineCoverage {
6
+ engine: EngineId;
7
+ status: 'completed' | 'partial' | 'not-installed' | 'failed' | 'timed-out' | 'not-run';
8
+ version?: string;
9
+ reason?: string;
10
+ findingsCount?: number;
11
+ durationMs?: number;
12
+ }
13
+ export interface ScanDocument {
14
+ file: string;
15
+ text: string;
16
+ selectedLines?: ReadonlySet<number>;
17
+ }
18
+ /** File modification dates describe the cache, not the age of every advisory inside it. */
19
+ export declare function databaseHealth(cfg: ResolvedSecureConfig): Promise<{
20
+ directory: string;
21
+ available: boolean;
22
+ databases: {
23
+ ecosystem: string;
24
+ fileModifiedAt: string;
25
+ }[];
26
+ }>;
27
+ export declare function probeEngine(engine: Exclude<EngineId, 'builtin'>, cfg: ResolvedSecureConfig, runner: ProcessRunner, signal?: AbortSignal): Promise<EngineCoverage>;
28
+ export declare function scanExternal(engine: 'betterleaks' | 'semgrep', documents: ScanDocument[], cfg: ResolvedSecureConfig, runner: ProcessRunner, signal?: AbortSignal): Promise<{
29
+ findings: Finding[];
30
+ coverage: EngineCoverage;
31
+ }>;
32
+ export declare function mergeFindings(findings: Finding[]): Finding[];
33
+ export declare function auditDependencies(documents: ScanDocument[], mode: 'offline' | 'online', cfg: ResolvedSecureConfig, runner: ProcessRunner, signal?: AbortSignal): Promise<{
34
+ mode: "offline" | "online";
35
+ coverage: EngineCoverage;
36
+ packagesScanned: number;
37
+ vulnerabilities: never[];
38
+ } | {
39
+ mode: "offline" | "online";
40
+ coverage: {
41
+ findingsCount: number;
42
+ reason?: string;
43
+ status: "completed" | "partial" | "not-run";
44
+ engine: EngineId;
45
+ version?: string;
46
+ durationMs?: number;
47
+ };
48
+ packagesScanned: number;
49
+ vulnerabilities: Record<string, unknown>[];
50
+ }>;