dsh-code-security 0.3.6 → 0.3.8

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/diff.js CHANGED
@@ -5,16 +5,19 @@
5
5
  */
6
6
  import path from 'node:path';
7
7
  import { createHash } from 'node:crypto';
8
- import { scanLineSet } from './scanner.js';
8
+ import { isExcludedPath, scanLineSet } from './scanner.js';
9
9
  /** 运行 git diff(默认 HEAD,工作区未提交改动)。 */
10
10
  export async function runGitDiff(runner, cwd, base, target, timeoutMs = 30000, staged = false) {
11
+ if (typeof base !== 'string' || base.trim() === '' || base.trim().startsWith('-') || base.includes('\0')) {
12
+ throw new Error('参数 base 必须是非空的 Git ref,不能是 Git 命令选项。');
13
+ }
11
14
  const argv = ['git', 'diff'];
12
15
  if (staged)
13
16
  argv.push('--cached');
14
- argv.push('--no-ext-diff', '--unified=0', base);
17
+ argv.push('--no-ext-diff', '--no-textconv', '--no-color', '--relative', '--src-prefix=a/', '--dst-prefix=b/', '--unified=3', '--output-indicator-new=+', '--output-indicator-old=-', '--output-indicator-context= ', base.trim(), '--');
15
18
  if (target !== undefined && target.trim() !== '')
16
- argv.push('--', target.trim());
17
- const result = await runner.run(argv, { timeoutMs });
19
+ argv.push(target.trim());
20
+ const result = await runner.run(argv, { timeoutMs, cwd });
18
21
  if (result.exitCode !== 0 && result.exitCode !== 1) {
19
22
  throw new Error('git diff 失败(退出码 ' + String(result.exitCode) + '):' + result.stderr.trim().split(/\r?\n/).slice(-4).join(' | '));
20
23
  }
@@ -22,48 +25,117 @@ export async function runGitDiff(runner, cwd, base, target, timeoutMs = 30000, s
22
25
  return '';
23
26
  return result.stdout;
24
27
  }
25
- function isNewFileHeader(line) {
26
- const m = /^\+\+\+ b\/(.*)$/.exec(line);
27
- if (m === null)
28
- return null;
29
- const file = m[1].replace(/\t.*$/, '');
30
- if (file === '' || file === '/dev/null')
31
- return null;
32
- return file;
28
+ function invalidDiff() {
29
+ return new Error('git diff 不完整或格式无效,无法确认已审查全部新增行。');
30
+ }
31
+ /** Git quotes unusual path bytes using C escapes, including octal UTF-8. */
32
+ function decodeGitPath(value) {
33
+ if (!value.startsWith('"'))
34
+ return value.replace(/\t.*$/, '');
35
+ const chunks = [];
36
+ const escapes = { a: 7, b: 8, f: 12, n: 10, r: 13, t: 9, v: 11, '\\': 92, '"': 34 };
37
+ let literal = '';
38
+ for (let index = 1; index < value.length; index++) {
39
+ const char = value[index];
40
+ if (char === '"') {
41
+ if (!/^(?:\t.*)?$/.test(value.slice(index + 1)))
42
+ throw invalidDiff();
43
+ chunks.push(Buffer.from(literal));
44
+ try {
45
+ return new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks));
46
+ }
47
+ catch {
48
+ throw invalidDiff();
49
+ }
50
+ }
51
+ if (char !== '\\') {
52
+ literal += char;
53
+ continue;
54
+ }
55
+ chunks.push(Buffer.from(literal));
56
+ literal = '';
57
+ const octal = /^[0-7]{1,3}/.exec(value.slice(index + 1));
58
+ if (octal !== null) {
59
+ const byte = Number.parseInt(octal[0], 8);
60
+ if (byte > 255)
61
+ throw invalidDiff();
62
+ chunks.push(Buffer.from([byte]));
63
+ index += octal[0].length;
64
+ }
65
+ else {
66
+ const byte = escapes[value[++index]];
67
+ if (byte === undefined)
68
+ throw invalidDiff();
69
+ chunks.push(Buffer.from([byte]));
70
+ }
71
+ }
72
+ throw invalidDiff();
33
73
  }
34
74
  /** 解析 unified diff,返回每个文件的新增行与新行号。 */
35
75
  export function parseDiff(diff, cwd) {
36
76
  const files = [];
37
77
  let current = null;
38
78
  let newLine = 0;
79
+ let oldRemaining = 0;
80
+ let newRemaining = 0;
81
+ let hasFileHeader = false;
39
82
  for (const raw of diff.split(/\r?\n/)) {
40
- const file = isNewFileHeader(raw);
41
- if (file !== null) {
42
- current = { file: file.replace(/\\/g, '/'), added: [] };
43
- files.push(current);
44
- newLine = 0;
83
+ // Interpret hunk bodies before headers: source text may itself start "+++".
84
+ if (oldRemaining > 0 || newRemaining > 0) {
85
+ if (raw.startsWith('\\'))
86
+ continue;
87
+ if (raw.startsWith('+') && newRemaining > 0) {
88
+ current?.added.push({ line: newLine, text: raw.slice(1) });
89
+ newRemaining--;
90
+ newLine++;
91
+ }
92
+ else if (raw.startsWith('-') && oldRemaining > 0) {
93
+ oldRemaining--;
94
+ }
95
+ else if (raw.startsWith(' ') && oldRemaining > 0 && newRemaining > 0) {
96
+ if (current !== null)
97
+ (current.context ??= []).push({ line: newLine, text: raw.slice(1) });
98
+ oldRemaining--;
99
+ newRemaining--;
100
+ newLine++;
101
+ }
102
+ else {
103
+ throw invalidDiff();
104
+ }
45
105
  continue;
46
106
  }
47
- const hunk = /^@@\s+-\d+(?:,\d+)?\s+\+(\d+)(?:,\d+)?\s+@@/.exec(raw);
48
- if (hunk !== null && current !== null) {
49
- newLine = Number(hunk[1]);
107
+ if (raw.startsWith('diff --git ')) {
108
+ current = null;
109
+ hasFileHeader = false;
50
110
  continue;
51
111
  }
52
- if (current === null)
53
- continue;
54
- if (raw.startsWith('+++') || raw.startsWith('---'))
112
+ if (raw.startsWith('+++ ')) {
113
+ const file = decodeGitPath(raw.slice(4));
114
+ if (file !== '/dev/null' && (!file.startsWith('b/') || file.length <= 2))
115
+ throw invalidDiff();
116
+ current = file === '/dev/null' ? null : { file: file.slice(2), added: [] };
117
+ if (current !== null)
118
+ files.push(current);
119
+ hasFileHeader = true;
120
+ newLine = 0;
55
121
  continue;
56
- if (raw.startsWith('+')) {
57
- current.added.push({ line: newLine, text: raw.slice(1) });
58
- newLine += 1;
59
- }
60
- else if (raw.startsWith('-')) {
61
- // removed line: new line number stays
62
122
  }
63
- else {
64
- newLine += 1;
123
+ const hunk = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/.exec(raw);
124
+ if (hunk !== null) {
125
+ if (!hasFileHeader)
126
+ throw invalidDiff();
127
+ oldRemaining = Number(hunk[2] ?? 1);
128
+ newRemaining = Number(hunk[4] ?? 1);
129
+ newLine = Number(hunk[3]);
130
+ if (![oldRemaining, newRemaining, newLine].every(Number.isSafeInteger) || newRemaining > 0 && newLine < 1)
131
+ throw invalidDiff();
132
+ continue;
65
133
  }
134
+ if (raw.startsWith('@@') || raw.startsWith('+') || raw.startsWith('-') && !raw.startsWith('--- '))
135
+ throw invalidDiff();
66
136
  }
137
+ if (oldRemaining > 0 || newRemaining > 0)
138
+ throw invalidDiff();
67
139
  return files;
68
140
  }
69
141
  function makeDiffId(ruleId, file, line, snippet) {
@@ -75,13 +147,19 @@ export function scanDiff(diff, cwd, policy) {
75
147
  const parsed = parseDiff(diff, cwd);
76
148
  const findings = [];
77
149
  let addedLines = 0;
150
+ let filesSkipped = 0;
78
151
  for (const file of parsed) {
79
- addedLines += file.added.length;
80
152
  const rel = path.isAbsolute(file.file) ? path.relative(cwd, file.file) : file.file;
81
- findings.push(...scanLineSet(file.added, rel, policy).map((finding) => ({ ...finding, id: makeDiffId(finding.ruleId, rel, finding.line, finding.snippet) })));
153
+ if (isExcludedPath(rel, policy)) {
154
+ filesSkipped++;
155
+ continue;
156
+ }
157
+ addedLines += file.added.length;
158
+ const entries = [...(file.context ?? []), ...file.added].sort((a, b) => a.line - b.line);
159
+ findings.push(...scanLineSet(entries, rel, policy, new Set(file.added.map(line => line.line))).map((finding) => ({ ...finding, id: makeDiffId(finding.ruleId, rel, finding.line, finding.snippet) })));
82
160
  }
83
161
  findings.sort((a, b) => severityOrder(b.severity) - severityOrder(a.severity) || a.file.localeCompare(b.file) || a.line - b.line);
84
- return { base: 'HEAD', filesChanged: parsed.length, addedLines, findings, diff };
162
+ return { base: 'HEAD', filesChanged: parsed.length, filesScanned: parsed.length - filesSkipped, filesSkipped, addedLines, findings, diff };
85
163
  }
86
164
  function severityOrder(severity) {
87
165
  return severity === 'critical' ? 4 : severity === 'high' ? 3 : severity === 'medium' ? 2 : 1;
package/lib/extra.d.ts CHANGED
@@ -5,4 +5,4 @@
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): SecureToolDefinition[];
8
+ export declare function buildExtraTools(cfg: ResolvedSecureConfig, cwd: string, signal?: AbortSignal): SecureToolDefinition[];
package/lib/extra.js CHANGED
@@ -6,7 +6,7 @@
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
10
  function compileParameters(spec) {
11
11
  const properties = {};
12
12
  const required = [];
@@ -22,7 +22,7 @@ function compileParameters(spec) {
22
22
  }
23
23
  return { type: 'object', properties, ...(required.length > 0 ? { required } : {}) };
24
24
  }
25
- export function buildExtraTools(cfg, cwd) {
25
+ export function buildExtraTools(cfg, cwd, signal) {
26
26
  const stateDir = cfg.stateDir;
27
27
  const secureBaseline = {
28
28
  name: 'secure_baseline',
@@ -48,10 +48,10 @@ export function buildExtraTools(cfg, cwd) {
48
48
  target: state.last.target,
49
49
  reason,
50
50
  findings: state.last.findings,
51
- fingerprints: state.last.fingerprints,
51
+ fingerprints: state.last.findings.map(findingFingerprint),
52
52
  counts: state.last.counts,
53
53
  };
54
- const file = await saveBaseline(stateDir, baseline);
54
+ const file = await saveBaseline(stateDir, baseline, signal);
55
55
  return { file, acceptedCount: baseline.findings.length, baseline };
56
56
  },
57
57
  timeoutMs: 10000,
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
+ * git diff 通过官方 subprocess 服务执行,仅依赖文件锁库。
7
8
  *
8
9
  * @module dsh-code-security
9
10
  */
package/lib/index.js 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
+ * git diff 通过官方 subprocess 服务执行,仅依赖文件锁库。
7
8
  *
8
9
  * @module dsh-code-security
9
10
  */
package/lib/policy.d.ts CHANGED
@@ -19,6 +19,6 @@ export declare const POLICY_FILE = ".code-security.json";
19
19
  export declare function policyPath(cwd: string): string;
20
20
  export declare function loadPolicy(cwd: string): Promise<SecurePolicy>;
21
21
  export declare function normalizePolicy(obj: Record<string, unknown>): SecurePolicy;
22
- export declare function savePolicy(cwd: string, policy: SecurePolicy): Promise<string>;
22
+ export declare function savePolicy(cwd: string, policy: SecurePolicy, signal?: AbortSignal): Promise<string>;
23
23
  export declare function policyExcludesDir(policy: SecurePolicy, rel: string): boolean;
24
24
  export declare function policyIgnores(policy: SecurePolicy, ruleId: string, file: string): boolean;
package/lib/policy.js CHANGED
@@ -5,6 +5,7 @@
5
5
  */
6
6
  import fs from 'node:fs/promises';
7
7
  import path from 'node:path';
8
+ import { randomUUID } from 'node:crypto';
8
9
  export const DEFAULT_POLICY = Object.freeze({
9
10
  version: 1,
10
11
  exclude: [],
@@ -21,7 +22,9 @@ export async function loadPolicy(cwd) {
21
22
  const obj = (parsed ?? {});
22
23
  return normalizePolicy(obj);
23
24
  }
24
- catch {
25
+ catch (error) {
26
+ if (error.code !== 'ENOENT')
27
+ throw new Error('安全策略无法读取或格式无效,原文件已保留:' + (error instanceof Error ? error.message : String(error)));
25
28
  return { ...DEFAULT_POLICY, exclude: [...DEFAULT_POLICY.exclude], ignore: [] };
26
29
  }
27
30
  }
@@ -33,6 +36,8 @@ export function normalizePolicy(obj) {
33
36
  if (typeof item !== 'object' || item === null)
34
37
  continue;
35
38
  const rec = item;
39
+ if (!(typeof rec.ruleId === 'string' && rec.ruleId.trim() !== '') && !(typeof rec.file === 'string' && rec.file.trim() !== ''))
40
+ throw new Error('每条忽略规则必须指定 ruleId 或 file,空规则不能禁用全部检查。');
36
41
  ignore.push({
37
42
  ruleId: typeof rec.ruleId === 'string' && rec.ruleId !== '' ? rec.ruleId : undefined,
38
43
  file: typeof rec.file === 'string' && rec.file !== '' ? rec.file : undefined,
@@ -42,11 +47,18 @@ export function normalizePolicy(obj) {
42
47
  const failOn = obj.failOn === 'critical' || obj.failOn === 'high' || obj.failOn === 'medium' || obj.failOn === 'low' ? obj.failOn : undefined;
43
48
  return { version: 1, exclude, ignore, failOn };
44
49
  }
45
- export async function savePolicy(cwd, policy) {
50
+ export async function savePolicy(cwd, policy, signal) {
51
+ signal?.throwIfAborted();
46
52
  const file = policyPath(cwd);
47
- const tmp = file + '.tmp-' + process.pid;
48
- await fs.writeFile(tmp, JSON.stringify(policy, null, 2) + '\n', 'utf8');
49
- await fs.rename(tmp, file);
53
+ const tmp = file + '.tmp-' + randomUUID();
54
+ try {
55
+ await fs.writeFile(tmp, JSON.stringify(policy, null, 2) + '\n', { flag: 'wx', mode: 0o600, signal });
56
+ signal?.throwIfAborted();
57
+ await fs.rename(tmp, file);
58
+ }
59
+ finally {
60
+ await fs.unlink(tmp).catch(() => { });
61
+ }
50
62
  return file;
51
63
  }
52
64
  function globToRegex(glob) {
package/lib/runner.d.ts CHANGED
@@ -12,6 +12,8 @@ export interface RunResult {
12
12
  export interface ProcessRunner {
13
13
  run(argv: readonly string[], options?: {
14
14
  timeoutMs?: number;
15
+ cwd?: string;
16
+ signal?: AbortSignal;
15
17
  }): Promise<RunResult>;
16
18
  }
17
19
  export interface SubprocessHandleLike {
package/lib/runner.js CHANGED
@@ -9,14 +9,16 @@ export function createSubprocessRunner(spawn, graceMs, defaultTimeoutMs) {
9
9
  async run(argv, options) {
10
10
  const timeoutMs = options?.timeoutMs ?? defaultTimeoutMs;
11
11
  const controller = new AbortController();
12
+ const signal = options?.signal ? AbortSignal.any([options.signal, controller.signal]) : controller.signal;
13
+ signal.throwIfAborted();
12
14
  const timer = setTimeout(() => controller.abort(new Error('git diff timed out')), timeoutMs);
13
15
  try {
14
16
  const handle = spawn({
15
17
  argv,
16
- cwd: process.cwd(),
18
+ cwd: options?.cwd ?? process.cwd(),
17
19
  stdio: { stdin: 'ignore', stdout: { maxBytes: COLLECT_BYTES }, stderr: { maxBytes: COLLECT_BYTES } },
18
20
  graceMs,
19
- signal: controller.signal,
21
+ signal,
20
22
  });
21
23
  const outcome = await handle.done;
22
24
  return {
package/lib/scanner.d.ts CHANGED
@@ -30,14 +30,17 @@ export interface ScanOptions {
30
30
  maxFiles: number;
31
31
  maxFileBytes: number;
32
32
  policy: SecurePolicy;
33
+ signal?: AbortSignal;
33
34
  }
34
35
  export interface LineEntry {
35
36
  line: number;
36
37
  text: string;
37
38
  }
38
39
  /** 对带行号的文本行应用规则(全量扫描与 git diff 增量扫描共用)。 */
39
- export declare function scanLineSet(entries: LineEntry[], rel: string, policy: SecurePolicy): Finding[];
40
+ export declare function scanLineSet(entries: LineEntry[], rel: string, policy: SecurePolicy, selectedLines?: ReadonlySet<number>): Finding[];
40
41
  /** 扫描单个文件内容,按语言与行应用规则。 */
41
42
  export declare function scanText(content: string, file: string, rel: string, policy: SecurePolicy): Finding[];
43
+ /** Apply the same exclusions to full scans, directly selected files and diffs. */
44
+ export declare function isExcludedPath(rel: string, policy: SecurePolicy): boolean;
42
45
  /** 扫描目录或单个文件。 */
43
46
  export declare function scanPath(options: ScanOptions): Promise<ScanResult>;
package/lib/scanner.js CHANGED
@@ -64,7 +64,7 @@ function shannon(text) {
64
64
  }
65
65
  const TOKEN_PATTERN = /['"]([A-Za-z0-9+/=_-]{24,})['"]/g;
66
66
  const HIGH_ENTROPY_THRESHOLD = 3.8;
67
- function detectHighEntropy(line, lineNumber, file, rel, rule) {
67
+ function detectHighEntropy(line, lineNumber, rel, rule) {
68
68
  const findings = [];
69
69
  for (const match of line.matchAll(TOKEN_PATTERN)) {
70
70
  const token = match[1];
@@ -94,24 +94,68 @@ function makeId(ruleId, file, line, snippet) {
94
94
  const hash = createHash('sha256').update(ruleId + '|' + file + '|' + line + '|' + snippet.slice(0, 80)).digest('hex').slice(0, 10);
95
95
  return 'SR-' + hash;
96
96
  }
97
+ function scanFileRule(entries, rel, rule, disabledNext, selectedLines) {
98
+ let block = [];
99
+ const checkBlock = () => {
100
+ if (block.length === 0)
101
+ return undefined;
102
+ const text = block.map(entry => entry.text).join('\n');
103
+ let best;
104
+ for (const pattern of rule.patterns) {
105
+ const regex = new RegExp(pattern.source, pattern.flags.includes('g') ? pattern.flags : pattern.flags + 'g');
106
+ for (const match of text.matchAll(regex)) {
107
+ const index = match.index;
108
+ const start = text.slice(0, index).split('\n').length - 1;
109
+ const end = start + match[0].split('\n').length - 1;
110
+ if (selectedLines !== undefined && !block.slice(start, end + 1).some(entry => selectedLines.has(entry.line)))
111
+ continue;
112
+ if (best === undefined || index < best.index)
113
+ best = { index, start, end };
114
+ break;
115
+ }
116
+ }
117
+ if (best === undefined)
118
+ return undefined;
119
+ const line = block[best.start].line;
120
+ const snippet = block.slice(best.start, best.end + 1).map(entry => entry.text).join('\n');
121
+ return {
122
+ id: makeId(rule.id, rel, line, snippet), ruleId: rule.id, title: rule.title,
123
+ cwe: rule.cwe, severity: rule.severity, confidence: rule.confidence,
124
+ file: rel, line, snippet: snippet.trim().slice(0, 200), message: rule.message,
125
+ };
126
+ };
127
+ for (const entry of entries) {
128
+ const suppressed = isComment(entry.text) || shouldSkipLine(rule, disabledNext.get(entry.line));
129
+ if (suppressed || block.length > 0 && entry.line !== block.at(-1).line + 1) {
130
+ const finding = checkBlock();
131
+ if (finding !== undefined)
132
+ return finding;
133
+ block = [];
134
+ }
135
+ if (!suppressed)
136
+ block.push(entry);
137
+ }
138
+ return checkBlock();
139
+ }
97
140
  /** 对带行号的文本行应用规则(全量扫描与 git diff 增量扫描共用)。 */
98
- export function scanLineSet(entries, rel, policy) {
141
+ export function scanLineSet(entries, rel, policy, selectedLines) {
99
142
  const language = languageForFile(rel);
100
143
  const findings = [];
101
- const applied = RULES.filter((rule) => rule.languages.includes(language) || rule.languages.includes('any'));
144
+ const applied = RULES.filter((rule) => (rule.languages.includes(language) || rule.languages.includes('any')) && !policyIgnores(policy, rule.id, rel));
145
+ const entropyRule = applied.find((rule) => rule.id === 'SEC-304');
102
146
  const prevDisable = new Map();
103
147
  for (const entry of entries) {
104
148
  if (isComment(entry.text))
105
149
  prevDisable.set(entry.line + 1, disabledRules(entry.text, DISABLE_NEXT));
106
150
  }
107
151
  for (const entry of entries) {
152
+ if (selectedLines !== undefined && !selectedLines.has(entry.line))
153
+ continue;
108
154
  const isCommentLine = isComment(entry.text);
109
155
  const currentDisable = isCommentLine ? disabledRules(entry.text, DISABLE_LINE) : null;
110
156
  for (const rule of applied) {
111
157
  if (rule.id === 'SEC-304')
112
158
  continue;
113
- if (policyIgnores(policy, rule.id, rel))
114
- continue;
115
159
  if (shouldSkipLine(rule, currentDisable) || shouldSkipLine(rule, prevDisable.get(entry.line)))
116
160
  continue;
117
161
  if (rule.fileLevel === true)
@@ -133,41 +177,30 @@ export function scanLineSet(entries, rel, policy) {
133
177
  message: rule.message,
134
178
  });
135
179
  }
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));
180
+ if (entropyRule !== undefined && !isCommentLine && !shouldSkipLine(entropyRule, currentDisable) && !shouldSkipLine(entropyRule, prevDisable.get(entry.line))) {
181
+ findings.push(...detectHighEntropy(entry.text, entry.line, rel, entropyRule));
139
182
  }
140
183
  }
184
+ for (const rule of applied) {
185
+ if (rule.fileLevel !== true)
186
+ continue;
187
+ const finding = scanFileRule(entries, rel, rule, prevDisable, selectedLines);
188
+ if (finding !== undefined)
189
+ findings.push(finding);
190
+ }
141
191
  return findings;
142
192
  }
143
193
  /** 扫描单个文件内容,按语言与行应用规则。 */
144
194
  export function scanText(content, file, rel, policy) {
145
195
  const lines = content.split(/\r?\n/);
146
196
  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;
197
+ // Match file rules over contiguous lines, sharing locations and suppressions
198
+ // with incremental scans without joining unrelated diff hunks.
199
+ return scanLineSet(entries, rel, policy);
200
+ }
201
+ /** Apply the same exclusions to full scans, directly selected files and diffs. */
202
+ export function isExcludedPath(rel, policy) {
203
+ return rel.split('/').some((part) => DEFAULT_EXCLUDE_DIRS.includes(part)) || policyExcludesDir(policy, rel);
171
204
  }
172
205
  /** 扫描目录或单个文件。 */
173
206
  export async function scanPath(options) {
@@ -177,18 +210,28 @@ export async function scanPath(options) {
177
210
  let filesScanned = 0;
178
211
  let filesSkipped = 0;
179
212
  const findings = [];
180
- const relRoot = path.relative(cwd, root) || '.';
213
+ const relRoot = path.relative(cwd, root).split(path.sep).join('/');
181
214
  const visit = async (absolute, rel) => {
215
+ options.signal?.throwIfAborted();
216
+ if (isExcludedPath(rel, policy)) {
217
+ filesSkipped += 1;
218
+ return;
219
+ }
182
220
  let stat;
183
221
  try {
184
- stat = await fs.stat(absolute);
222
+ stat = await fs.lstat(absolute);
185
223
  }
186
224
  catch {
187
225
  filesSkipped += 1;
188
226
  return;
189
227
  }
228
+ options.signal?.throwIfAborted();
229
+ if (stat.isSymbolicLink()) {
230
+ filesSkipped += 1;
231
+ return;
232
+ }
190
233
  if (stat.isDirectory()) {
191
- if (DEFAULT_EXCLUDE_DIRS.includes(path.basename(absolute)) || policyExcludesDir(policy, rel))
234
+ if (DEFAULT_EXCLUDE_DIRS.includes(path.basename(absolute)))
192
235
  return;
193
236
  let entries;
194
237
  try {
@@ -226,6 +269,7 @@ export async function scanPath(options) {
226
269
  return;
227
270
  }
228
271
  filesScanned += 1;
272
+ options.signal?.throwIfAborted();
229
273
  const text = new TextDecoder('utf-8', { fatal: false }).decode(buffer);
230
274
  findings.push(...scanText(text, absolute, rel, policy));
231
275
  };
package/lib/state.d.ts CHANGED
@@ -40,8 +40,8 @@ export interface StateDocument {
40
40
  export declare function findingFingerprint(finding: Finding): string;
41
41
  export declare function statePath(stateDir: string): string;
42
42
  export declare function loadState(stateDir: string): Promise<StateDocument>;
43
- export declare function saveState(stateDir: string, entry: ScanStateEntry): Promise<string>;
44
- export declare function saveBaseline(stateDir: string, baseline: BaselineEntry): Promise<string>;
43
+ export declare function saveState(stateDir: string, entry: ScanStateEntry, signal?: AbortSignal): Promise<string>;
44
+ export declare function saveBaseline(stateDir: string, baseline: BaselineEntry, signal?: AbortSignal): Promise<string>;
45
45
  export declare function countFindings(findings: Finding[]): {
46
46
  critical: number;
47
47
  high: number;