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/scanner.js CHANGED
@@ -9,6 +9,8 @@ import { createHash } from 'node:crypto';
9
9
  import { DEFAULT_EXCLUDE_DIRS } from './config.js';
10
10
  import { languageForFile, RULES } from './rules.js';
11
11
  import { policyExcludesDir, policyIgnores } from './policy.js';
12
+ import { protectFinding } from './privacy.js';
13
+ import { sanitizedHtmlLines } from './syntax.js';
12
14
  const DISABLE_NEXT = /secure-review-disable-next-line(?:\s+([\w,-]+))?/;
13
15
  const DISABLE_LINE = /secure-review-disable-line(?:\s+([\w,-]+))?/;
14
16
  function isComment(line) {
@@ -64,7 +66,7 @@ function shannon(text) {
64
66
  }
65
67
  const TOKEN_PATTERN = /['"]([A-Za-z0-9+/=_-]{24,})['"]/g;
66
68
  const HIGH_ENTROPY_THRESHOLD = 3.8;
67
- function detectHighEntropy(line, lineNumber, file, rel, rule) {
69
+ function detectHighEntropy(line, lineNumber, rel, rule) {
68
70
  const findings = [];
69
71
  for (const match of line.matchAll(TOKEN_PATTERN)) {
70
72
  const token = match[1];
@@ -94,23 +96,69 @@ function makeId(ruleId, file, line, snippet) {
94
96
  const hash = createHash('sha256').update(ruleId + '|' + file + '|' + line + '|' + snippet.slice(0, 80)).digest('hex').slice(0, 10);
95
97
  return 'SR-' + hash;
96
98
  }
99
+ function scanFileRule(entries, rel, rule, disabledNext, selectedLines) {
100
+ let block = [];
101
+ const checkBlock = () => {
102
+ if (block.length === 0)
103
+ return undefined;
104
+ const text = block.map(entry => entry.text).join('\n');
105
+ let best;
106
+ for (const pattern of rule.patterns) {
107
+ const regex = new RegExp(pattern.source, pattern.flags.includes('g') ? pattern.flags : pattern.flags + 'g');
108
+ for (const match of text.matchAll(regex)) {
109
+ const index = match.index;
110
+ const start = text.slice(0, index).split('\n').length - 1;
111
+ const end = start + match[0].split('\n').length - 1;
112
+ if (selectedLines !== undefined && !block.slice(start, end + 1).some(entry => selectedLines.has(entry.line)))
113
+ continue;
114
+ if (best === undefined || index < best.index)
115
+ best = { index, start, end };
116
+ break;
117
+ }
118
+ }
119
+ if (best === undefined)
120
+ return undefined;
121
+ const line = block[best.start].line;
122
+ const snippet = block.slice(best.start, best.end + 1).map(entry => entry.text).join('\n');
123
+ return {
124
+ id: makeId(rule.id, rel, line, snippet), ruleId: rule.id, title: rule.title,
125
+ cwe: rule.cwe, severity: rule.severity, confidence: rule.confidence,
126
+ file: rel, line, snippet: snippet.trim().slice(0, 200), message: rule.message,
127
+ };
128
+ };
129
+ for (const entry of entries) {
130
+ const suppressed = isComment(entry.text) || shouldSkipLine(rule, disabledNext.get(entry.line));
131
+ if (suppressed || block.length > 0 && entry.line !== block.at(-1).line + 1) {
132
+ const finding = checkBlock();
133
+ if (finding !== undefined)
134
+ return finding;
135
+ block = [];
136
+ }
137
+ if (!suppressed)
138
+ block.push(entry);
139
+ }
140
+ return checkBlock();
141
+ }
97
142
  /** 对带行号的文本行应用规则(全量扫描与 git diff 增量扫描共用)。 */
98
- export function scanLineSet(entries, rel, policy) {
143
+ export function scanLineSet(entries, rel, policy, selectedLines, sanitized = new Set()) {
99
144
  const language = languageForFile(rel);
100
145
  const findings = [];
101
- const applied = RULES.filter((rule) => rule.languages.includes(language) || rule.languages.includes('any'));
146
+ const applied = RULES.filter((rule) => (rule.languages.includes(language) || rule.languages.includes('any')) && !policyIgnores(policy, rule.id, rel));
147
+ const entropyRule = applied.find((rule) => rule.id === 'SEC-304');
102
148
  const prevDisable = new Map();
103
149
  for (const entry of entries) {
104
150
  if (isComment(entry.text))
105
151
  prevDisable.set(entry.line + 1, disabledRules(entry.text, DISABLE_NEXT));
106
152
  }
107
153
  for (const entry of entries) {
154
+ if (selectedLines !== undefined && !selectedLines.has(entry.line))
155
+ continue;
108
156
  const isCommentLine = isComment(entry.text);
109
157
  const currentDisable = isCommentLine ? disabledRules(entry.text, DISABLE_LINE) : null;
110
158
  for (const rule of applied) {
111
159
  if (rule.id === 'SEC-304')
112
160
  continue;
113
- if (policyIgnores(policy, rule.id, rel))
161
+ if (rule.id === 'SEC-009' && sanitized.has(entry.line))
114
162
  continue;
115
163
  if (shouldSkipLine(rule, currentDisable) || shouldSkipLine(rule, prevDisable.get(entry.line)))
116
164
  continue;
@@ -133,41 +181,30 @@ export function scanLineSet(entries, rel, policy) {
133
181
  message: rule.message,
134
182
  });
135
183
  }
136
- const entropyRule = RULES.find((rule) => rule.id === 'SEC-304');
137
- if (entropyRule !== undefined && !policyIgnores(policy, entropyRule.id, rel) && !isCommentLine && !shouldSkipLine(entropyRule, currentDisable) && !shouldSkipLine(entropyRule, prevDisable.get(entry.line))) {
138
- findings.push(...detectHighEntropy(entry.text, entry.line, rel, rel, entropyRule));
184
+ if (entropyRule !== undefined && !isCommentLine && !shouldSkipLine(entropyRule, currentDisable) && !shouldSkipLine(entropyRule, prevDisable.get(entry.line))) {
185
+ findings.push(...detectHighEntropy(entry.text, entry.line, rel, entropyRule));
139
186
  }
140
187
  }
141
- return findings;
188
+ for (const rule of applied) {
189
+ if (rule.fileLevel !== true)
190
+ continue;
191
+ const finding = scanFileRule(entries, rel, rule, prevDisable, selectedLines);
192
+ if (finding !== undefined)
193
+ findings.push(finding);
194
+ }
195
+ return findings.map(finding => protectFinding({ ...finding, engine: 'builtin', evidence: 'pattern' }));
142
196
  }
143
197
  /** 扫描单个文件内容,按语言与行应用规则。 */
144
198
  export function scanText(content, file, rel, policy) {
145
199
  const lines = content.split(/\r?\n/);
146
200
  const entries = lines.map((text, index) => ({ line: index + 1, text }));
147
- const findings = scanLineSet(entries, rel, policy);
148
- const language = languageForFile(rel);
149
- const applied = RULES.filter((rule) => rule.languages.includes(language) || rule.languages.includes('any'));
150
- for (const rule of applied) {
151
- if (rule.fileLevel !== true)
152
- continue;
153
- if (policyIgnores(policy, rule.id, rel))
154
- continue;
155
- if (rule.patterns.some((pattern) => pattern.test(content))) {
156
- findings.push({
157
- id: makeId(rule.id, rel, 1, content.slice(0, 80)),
158
- ruleId: rule.id,
159
- title: rule.title,
160
- cwe: rule.cwe,
161
- severity: rule.severity,
162
- confidence: rule.confidence,
163
- file: rel,
164
- line: 1,
165
- snippet: content.slice(0, 200),
166
- message: rule.message,
167
- });
168
- }
169
- }
170
- return findings;
201
+ // Match file rules over contiguous lines, sharing locations and suppressions
202
+ // with incremental scans without joining unrelated diff hunks.
203
+ return scanLineSet(entries, rel, policy, undefined, languageForFile(rel) === 'javascript' ? sanitizedHtmlLines(content) : new Set());
204
+ }
205
+ /** Apply the same exclusions to full scans, directly selected files and diffs. */
206
+ export function isExcludedPath(rel, policy) {
207
+ return rel.split('/').some((part) => DEFAULT_EXCLUDE_DIRS.includes(part)) || policyExcludesDir(policy, rel);
171
208
  }
172
209
  /** 扫描目录或单个文件。 */
173
210
  export async function scanPath(options) {
@@ -177,24 +214,35 @@ export async function scanPath(options) {
177
214
  let filesScanned = 0;
178
215
  let filesSkipped = 0;
179
216
  const findings = [];
180
- const relRoot = path.relative(cwd, root) || '.';
217
+ const relRoot = path.relative(cwd, root).split(path.sep).join('/');
218
+ let complete = true;
219
+ const warnings = [];
220
+ const incomplete = (message) => { complete = false; if (warnings.length < 50)
221
+ warnings.push(message); };
181
222
  const visit = async (absolute, rel) => {
182
223
  options.signal?.throwIfAborted();
224
+ if (isExcludedPath(rel, policy)) {
225
+ filesSkipped += 1;
226
+ return;
227
+ }
183
228
  let stat;
184
229
  try {
185
230
  stat = await fs.lstat(absolute);
186
231
  }
187
232
  catch {
188
233
  filesSkipped += 1;
234
+ incomplete('无法读取 ' + rel);
189
235
  return;
190
236
  }
191
237
  options.signal?.throwIfAborted();
192
238
  if (stat.isSymbolicLink()) {
193
239
  filesSkipped += 1;
240
+ if (absolute === root)
241
+ incomplete('目标为链接,未读取链接内容');
194
242
  return;
195
243
  }
196
244
  if (stat.isDirectory()) {
197
- if (DEFAULT_EXCLUDE_DIRS.includes(path.basename(absolute)) || policyExcludesDir(policy, rel))
245
+ if (DEFAULT_EXCLUDE_DIRS.includes(path.basename(absolute)))
198
246
  return;
199
247
  let entries;
200
248
  try {
@@ -202,11 +250,14 @@ export async function scanPath(options) {
202
250
  }
203
251
  catch {
204
252
  filesSkipped += 1;
253
+ incomplete('无法遍历 ' + rel);
205
254
  return;
206
255
  }
207
256
  for (const entry of entries) {
208
- if (filesScanned >= maxFiles)
257
+ if (filesScanned >= maxFiles) {
258
+ incomplete('已达到文件数量上限');
209
259
  return;
260
+ }
210
261
  await visit(path.join(absolute, entry.name), rel === '' ? entry.name : rel + '/' + entry.name);
211
262
  }
212
263
  return;
@@ -217,6 +268,7 @@ export async function scanPath(options) {
217
268
  }
218
269
  if (stat.size > maxFileBytes) {
219
270
  filesSkipped += 1;
271
+ incomplete(rel + ' 超过文件大小上限');
220
272
  return;
221
273
  }
222
274
  let buffer;
@@ -225,6 +277,7 @@ export async function scanPath(options) {
225
277
  }
226
278
  catch {
227
279
  filesSkipped += 1;
280
+ incomplete('无法读取 ' + rel);
228
281
  return;
229
282
  }
230
283
  if (fileHasBinary(buffer)) {
@@ -234,11 +287,12 @@ export async function scanPath(options) {
234
287
  filesScanned += 1;
235
288
  options.signal?.throwIfAborted();
236
289
  const text = new TextDecoder('utf-8', { fatal: false }).decode(buffer);
290
+ options.onDocument?.({ file: rel, text });
237
291
  findings.push(...scanText(text, absolute, rel, policy));
238
292
  };
239
293
  await visit(root, relRoot);
240
294
  findings.sort((a, b) => severityRank(b.severity) - severityRank(a.severity) || a.file.localeCompare(b.file) || a.line - b.line);
241
- return { root, filesScanned, filesSkipped, findings, durationMs: Date.now() - started };
295
+ return { root, filesScanned, filesSkipped, findings, durationMs: Date.now() - started, complete, warnings };
242
296
  }
243
297
  function severityRank(severity) {
244
298
  return severity === 'critical' ? 4 : severity === 'high' ? 3 : severity === 'medium' ? 2 : 1;
package/lib/state.d.ts CHANGED
@@ -5,7 +5,14 @@
5
5
  * @module dsh-code-security/state
6
6
  */
7
7
  import type { Finding } from './scanner.js';
8
+ import type { SourceEngine } from './config.js';
9
+ import type { EngineCoverage } from './engines.js';
10
+ import type { ScanScope } from './audit.js';
8
11
  export interface ScanStateEntry {
12
+ complete?: boolean;
13
+ coverage?: EngineCoverage[];
14
+ engines?: SourceEngine[];
15
+ scope?: ScanScope;
9
16
  mode: string;
10
17
  target: string;
11
18
  time: string;
package/lib/state.js CHANGED
@@ -6,18 +6,33 @@
6
6
  */
7
7
  import fs from 'node:fs/promises';
8
8
  import path from 'node:path';
9
- import { createHash, randomUUID } from 'node:crypto';
9
+ import { randomUUID } from 'node:crypto';
10
10
  import lockfile from 'proper-lockfile';
11
+ import { evidenceFingerprint, protectFinding, redactText } from './privacy.js';
11
12
  const STATE_FILE = 'state.json';
12
13
  const HISTORY_LIMIT = 50;
13
14
  export function findingFingerprint(finding) {
14
- const raw = finding.ruleId + '|' + finding.file + '|' + finding.snippet.trim();
15
- return createHash('sha256').update(raw).digest('hex');
15
+ return evidenceFingerprint(finding);
16
16
  }
17
17
  export function statePath(stateDir) {
18
18
  return path.join(stateDir, STATE_FILE);
19
19
  }
20
20
  export async function loadState(stateDir) {
21
+ const initial = await readState(stateDir);
22
+ const protectedState = protectState(initial);
23
+ if (JSON.stringify(initial) === JSON.stringify(protectedState))
24
+ return protectedState;
25
+ const unlock = await lockfile.lock(stateDir, { retries: { retries: 30, minTimeout: 25, maxTimeout: 100 } });
26
+ try {
27
+ const state = protectState(await readState(stateDir));
28
+ await writeState(stateDir, state);
29
+ return state;
30
+ }
31
+ finally {
32
+ await unlock();
33
+ }
34
+ }
35
+ async function readState(stateDir) {
21
36
  try {
22
37
  const info = await fs.lstat(statePath(stateDir));
23
38
  if (!info.isFile() || info.isSymbolicLink() || info.size > 16 * 1024 * 1024)
@@ -38,6 +53,13 @@ export async function loadState(stateDir) {
38
53
  return { last: null, history: [], baseline: null };
39
54
  }
40
55
  }
56
+ function protectState(state) {
57
+ const clean = (entry) => {
58
+ const findings = entry.findings.map(protectFinding);
59
+ return { ...entry, ...('reason' in entry ? { reason: redactText(entry.reason) } : {}), findings, fingerprints: findings.map(findingFingerprint) };
60
+ };
61
+ return { last: state.last ? clean(state.last) : null, history: state.history.map(clean), baseline: state.baseline ? clean(state.baseline) : null };
62
+ }
41
63
  function isEntry(value) {
42
64
  if (typeof value !== 'object' || value === null)
43
65
  return false;
@@ -71,7 +93,7 @@ export async function saveState(stateDir, entry, signal) {
71
93
  const unlock = await lockfile.lock(stateDir, { retries: { retries: 30, minTimeout: 25, maxTimeout: 100 } });
72
94
  try {
73
95
  signal?.throwIfAborted();
74
- const state = await loadState(stateDir);
96
+ const state = protectState(await readState(stateDir));
75
97
  state.last = entry;
76
98
  state.history.unshift(entry);
77
99
  state.history = state.history.slice(0, HISTORY_LIMIT);
@@ -87,7 +109,7 @@ export async function saveBaseline(stateDir, baseline, signal) {
87
109
  const unlock = await lockfile.lock(stateDir, { retries: { retries: 30, minTimeout: 25, maxTimeout: 100 } });
88
110
  try {
89
111
  signal?.throwIfAborted();
90
- const state = await loadState(stateDir);
112
+ const state = protectState(await readState(stateDir));
91
113
  state.baseline = baseline;
92
114
  return await writeState(stateDir, state, signal);
93
115
  }
@@ -98,7 +120,7 @@ export async function saveBaseline(stateDir, baseline, signal) {
98
120
  async function writeState(stateDir, state, signal) {
99
121
  signal?.throwIfAborted();
100
122
  const file = statePath(stateDir);
101
- const tmp = file + '.tmp-' + randomUUID(), bytes = JSON.stringify(state, null, 2);
123
+ const tmp = file + '.tmp-' + randomUUID(), bytes = JSON.stringify(protectState(state), null, 2);
102
124
  if (Buffer.byteLength(bytes) > 16 * 1024 * 1024)
103
125
  throw new Error('安全扫描记录超过 16 MiB,本次没有保存。');
104
126
  try {
@@ -130,7 +152,8 @@ export function compareFingerprints(baseline, current) {
130
152
  export function splitByBaseline(findings, baseline) {
131
153
  if (baseline === null)
132
154
  return { fresh: findings, accepted: [] };
133
- const acceptedSet = new Set(baseline.fingerprints);
155
+ // Recompute from saved evidence so older "./file" fingerprints remain usable.
156
+ const acceptedSet = new Set(baseline.findings.map(findingFingerprint));
134
157
  const fresh = [];
135
158
  const accepted = [];
136
159
  for (const finding of findings) {
@@ -0,0 +1 @@
1
+ export declare function sanitizedHtmlLines(content: string): Set<number>;
package/lib/syntax.js ADDED
@@ -0,0 +1,43 @@
1
+ /** A narrow sanitizer check with real lexical bindings, never string-based suppression. */
2
+ import { parse } from '@babel/parser';
3
+ import traverseModule from '@babel/traverse';
4
+ const traverse = (traverseModule.default ?? traverseModule);
5
+ export function sanitizedHtmlLines(content) {
6
+ const sinks = new Map();
7
+ const add = (line, safe) => { if (line)
8
+ sinks.set(line, [...sinks.get(line) ?? [], safe]); };
9
+ function trustedSanitizer(node, at) {
10
+ if (node?.type !== 'CallExpression' || node.arguments.length !== 1 || node.callee.type !== 'MemberExpression' || node.callee.computed || node.callee.object.type !== 'Identifier' || node.callee.property.type !== 'Identifier' || node.callee.property.name !== 'sanitize')
11
+ return false;
12
+ const binding = at.scope.getBinding(node.callee.object.name);
13
+ if (!binding?.constant || !binding.path.isImportDefaultSpecifier() && !binding.path.isImportNamespaceSpecifier())
14
+ return false;
15
+ if (!binding.path.parentPath.isImportDeclaration() || binding.path.parentPath.node.source.value !== 'dompurify')
16
+ return false;
17
+ return binding.referencePaths.every(ref => {
18
+ const member = ref.parentPath, call = member?.parentPath;
19
+ return member?.isMemberExpression() && !member.node.computed && member.node.property.type === 'Identifier' && member.node.property.name === 'sanitize' && call?.isCallExpression() && call.node.callee === member.node;
20
+ });
21
+ }
22
+ try {
23
+ const ast = parse(content, { sourceType: 'unambiguous', plugins: ['typescript', 'jsx'], allowReturnOutsideFunction: true });
24
+ traverse(ast, {
25
+ AssignmentExpression(at) {
26
+ const left = at.node.left;
27
+ if (left.type === 'MemberExpression' && !left.computed && left.property.type === 'Identifier' && left.property.name === 'innerHTML')
28
+ add(left.loc?.start.line, trustedSanitizer(at.node.right, at));
29
+ },
30
+ JSXAttribute(at) {
31
+ if (at.node.name.type !== 'JSXIdentifier' || at.node.name.name !== 'dangerouslySetInnerHTML')
32
+ return;
33
+ const expression = at.node.value?.type === 'JSXExpressionContainer' ? at.node.value.expression : undefined;
34
+ const html = expression?.type === 'ObjectExpression' ? expression.properties.find(prop => prop.type === 'ObjectProperty' && !prop.computed && prop.key.type === 'Identifier' && prop.key.name === '__html') : undefined;
35
+ add(at.node.loc?.start.line, html?.type === 'ObjectProperty' && trustedSanitizer(html.value, at));
36
+ },
37
+ });
38
+ }
39
+ catch {
40
+ return new Set();
41
+ }
42
+ return new Set([...sinks].filter(([, safe]) => safe.length > 0 && safe.every(Boolean)).map(([line]) => line));
43
+ }
package/lib/tools.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
- * 九个面向模型的安全审查工具:
2
+ * 十个面向模型的安全审查工具:
3
3
  * secure_scan / secure_diff / secure_fix_verify / secure_report / secure_export /
4
- * secure_baseline / secure_deps / secure_policy_show / secure_policy_set。
4
+ * secure_baseline / secure_deps / secure_policy_show / secure_policy_set / secure_health。
5
5
  *
6
6
  * @module dsh-code-security/tools
7
7
  */
@@ -27,4 +27,6 @@ export interface SecureToolDefinition {
27
27
  execute(args: unknown, exec: unknown): Promise<unknown>;
28
28
  timeoutMs?: number;
29
29
  }
30
+ /** Reject ambiguous scopes before reading files, running git, or replacing scan state. */
31
+ export declare function scanArguments(raw: unknown, allowed?: readonly string[]): Record<string, unknown>;
30
32
  export declare function buildSecureTools(cfg: ResolvedSecureConfig, cwd: string, runner: ProcessRunner): SecureToolDefinition[];