dsh-code-security 0.3.7 → 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/CHANGELOG.md CHANGED
@@ -4,6 +4,14 @@
4
4
 
5
5
  [历史英文记录](docs/CHANGELOG.en.md)
6
6
 
7
+ ## 0.3.8 (2026-10-12)
8
+
9
+ - 修复增量扫描遗漏中文/转义文件名、私钥、`.env` 敏感项和禁用依赖审计配置的问题,报告真实证据行号。
10
+ - 结合有限上下文识别已有豁免和跨行配置,仅报告涉及新增行的问题;校验差异块行数,格式错误或不完整时明确报错。
11
+ - 全量、单文件与增量扫描统一排除策略和工作区相对路径,兼容旧记录中 `./` 前缀的基线指纹。
12
+ - 固定 Git 输出格式与当前工作区范围,禁用外部 diff/textconv,拒绝被误当成 Git 选项的基线参数和错误类型的 `staged` 参数。
13
+ - 补充隔离 Git 回归场景;范围、兼容注意事项及后续计划见[本轮迭代记录](docs/ITERATION-2026-10-12.md)。
14
+
7
15
  ## 0.3.7 (2026-10-05)
8
16
 
9
17
  - 扫描、策略、报告和 git diff 使用当前会话工作区;支持取消并跳过目录联接。锁定合并历史与基线写入,损坏记录和无效策略明确报错并保留原文件。
package/README.en.md CHANGED
@@ -8,6 +8,8 @@ Run local static security checks on code or Git changes and verify fixes.
8
8
 
9
9
  [![npm](https://img.shields.io/npm/v/dsh-code-security)](https://www.npmjs.com/package/dsh-code-security) [![downloads](https://raw.githubusercontent.com/STARDUSTLC666/dsh-suite/npm-downloads/assets/dsh-code-security-downloads.svg)](https://www.npmjs.com/package/dsh-code-security)
10
10
 
11
+ Feedback and contributions are welcome: report [issues](https://github.com/STARDUSTLC666/dsh-code-security/issues) or submit [pull requests](https://github.com/STARDUSTLC666/dsh-code-security/pulls).
12
+
11
13
  ## What it does
12
14
 
13
15
  - Scan files, directories or Git diffs with locations and code evidence.
package/README.md CHANGED
@@ -8,6 +8,8 @@
8
8
 
9
9
  [![npm](https://img.shields.io/npm/v/dsh-code-security)](https://www.npmjs.com/package/dsh-code-security) [![downloads](https://raw.githubusercontent.com/STARDUSTLC666/dsh-suite/npm-downloads/assets/dsh-code-security-downloads.svg)](https://www.npmjs.com/package/dsh-code-security)
10
10
 
11
+ 欢迎使用,遇到问题或有改进建议,请提交 [issues](https://github.com/STARDUSTLC666/dsh-code-security/issues) 和 [PR](https://github.com/STARDUSTLC666/dsh-code-security/pulls)。
12
+
11
13
  ## 功能
12
14
 
13
15
  - 扫描文件、目录或 Git diff,报告规则、位置与代码证据。
@@ -0,0 +1,53 @@
1
+ # dsh-code-security 第一轮迭代
2
+
3
+ 日期:2026-10-12。基于 `91d4d8c770cfaa796d07b7b770a627c3bce8f49e`,本轮版本为 `0.3.8`。此页记录源码迭代和本地验证;发布 CI、npm 完整性及隔离安装结果在发布后独立核验。
4
+
5
+ 这轮先解决增量审查的漏报、范围和证据一致性。原有 37 项测试全部通过,但新增回归测试在旧实现上复现了中文路径、文件级规则等问题被误判为通过。
6
+
7
+ ## 已实现
8
+
9
+ | 场景 | 原行为 | 当前行为 |
10
+ | :-- | :-- | :-- |
11
+ | 中文/转义文件名 | Git 引号与八进制路径解析失败,可能返回零发现并通过 | 解码 UTF-8 与 Git 的 C 转义,保留正确文件和行号 |
12
+ | 新增私钥、`.env` 敏感项、`audit=false` | 增量扫描跳过文件级规则 | 全量和增量共用匹配,定位实际证据;保留跨行配置检测 |
13
+ | 未改动的豁免注释、危险代码 | 缺少上下文,已有 next-line 豁免失效 | 读取少量上下文,只报告证据涉及新增行的问题 |
14
+ | 仓库子目录作为会话工作区 | 带入兄弟目录改动,路径基准偏离工作区 | Git 范围和证据路径均相对当前会话工作区 |
15
+ | 策略排除 | 增量扫描未应用排除,直接选择文件也绕过排除 | 三种扫描入口统一排除;报告实际扫描和跳过数量 |
16
+ | 基线一致性 | 全量的 `./src/a.js` 与增量的 `src/a.js` 指纹不同 | 规范相对路径,并从保存的证据重算旧基线指纹 |
17
+ | Git 个性化配置/参数 | 颜色、前缀影响解析;`--quiet` 可误当基线 | 固定输出格式,禁用外部 diff/textconv,校验参数类型 |
18
+ | 差异块缺失行 | 接受不完整差异并给出部分结果 | 校验差异块行数,异常时保留原扫描状态并报错 |
19
+
20
+ 主要代码:[diff.ts](../src/diff.ts)、[scanner.ts](../src/scanner.ts)、[tools.ts](../src/tools.ts)、[state.ts](../src/state.ts)。已同步仓库跟踪的 `lib/` 构建产物。
21
+
22
+ ## 验证
23
+
24
+ - `pnpm test`:53/53 通过,0 失败,0 跳过;包含 TypeScript 构建。
25
+ - 新增 16 项回归测试,其中 9 项使用本地隔离 Git 仓库。覆盖中文路径、暂存区与工作树不一致、Git 输出偏好、子工作区、基线兼容、外部转换器与排除计数等。
26
+ - 工具调用链覆盖 `secure_scan` / `secure_diff` / `secure_baseline` / `secure_fix_verify` / `secure_report` / `secure_export`,验证写入状态、结果渲染文本和 SARIF 路径。
27
+ - 环境:Windows,Node `24.16.0`,pnpm `11.7.0`,Git `2.54.0.windows.1`。
28
+ - 本页所列测试未在真实 DSH Web/桌面界面执行点击、输入或视觉验收;本地运行环境为 Windows/Node 24,Linux CI 与 npm 发布验收另行记录。
29
+
30
+ 回归入口:[diff.test.mjs](../test/diff.test.mjs)、[git-diff.test.mjs](../test/git-diff.test.mjs)。测试仓库位于 E 盘项目内的 `test/.tmp/`,测试结束后仅清理经过路径校验的自建子目录。
31
+
32
+ ## 兼容注意
33
+
34
+ 未新增依赖;继续沿用项目已有的 MIT 许可与 `proper-lockfile@4.1.2`(MIT),Node 声明保持 `^22.19.0 || >=24.0.0`。原工具名和参数结构保留,增量输出增加明确的扫描/跳过计数。
35
+
36
+ 旧记录的 `./` 前缀兼容已验证。`SEC-303`、`SEC-305`、`SEC-404` 的证据由“整个文件开头”改成“实际匹配片段”;旧基线若使用前一种片段,可能被视为新增。应复扫并人工确认后重新接受,不能自动把新证据全部豁免。
37
+
38
+ 增量检查只涵盖 Git 显示的文本差异,仍不包含未跟踪文件。上下文为前后各 3 行;超出上下文的跨行结构、通用数据流和复杂语言语义未由本轮解决。差异块校验可以发现块内截断,但不代表已解决所有收集上限下的完整性判断。
39
+
40
+ ## 下一轮优先项
41
+
42
+ 以下来自当前代码静态检查,尚未在本轮实现或验收。
43
+
44
+ 1. **扫描完整性门禁。** `scanPath` 遇到不存在目标、读取失败或文件数上限时,应返回明确的完整性状态;`passed` 不能仅因发现为空而为真。还应检查宿主 stdout 收集上限带来的整体截断。
45
+ 2. **复扫范围校验。** `secure_fix_verify` 应核对上次与本次目标/覆盖范围,避免把没有重新扫描的问题误报为关闭;部分复扫应输出“未验证”。
46
+ 3. **规则准确性与隐私。** 补充安全/危险样例集,优先处理普通字符串、块注释、已使用安全 Loader 等误报;为密钥证据增加默认脱敏,并设计不依赖明文片段的稳定指纹。
47
+ 4. **依赖清单解析。** 为标准 `pyproject.toml`、Cargo 依赖区段和损坏清单补测试,再评估采用 MIT/兼容许可的 TOML 解析器;当前 SBOM-lite 不能当作漏洞数据库扫描。
48
+
49
+ 后续功能迭代仍需补单插件真实 DSH 界面验收和更广的跨平台 CI;本版发布验证独立进行。
50
+
51
+ ## Git 行为依据
52
+
53
+ 输出格式、工作区相对范围和外部转换器开关按 [Git diff 官方文档](https://git-scm.com/docs/git-diff) 实现;文件名转义依据 [core.quotePath 官方说明](https://git-scm.com/docs/git-config#Documentation/git-config.txt-corequotePath)。
package/docs/USAGE.en.md CHANGED
@@ -6,6 +6,12 @@
6
6
 
7
7
  Relative stateDir defaults to .code-security in the session workspace. Concurrent scans retain both histories. Damaged records must be inspected after backup rather than silently recreated. Scans skip symlinks and junctions; static rules do not replace a full security audit.
8
8
 
9
+ 0.3.8 (2026-10-12): incremental reviews decode Git's quoted paths and check added private keys, `.env` secrets and disabled audit settings. Three context lines on each side support existing next-line suppressions and multiline settings; only evidence intersecting added lines is reported. Multiline locations point to the match's first line. Staged reviews use index content rather than later working-tree edits.
10
+
11
+ Full, directly selected file and incremental scans share default and policy exclusions. Paths and Git scope are relative to the session workspace, including nested workspaces. `filesScanned` counts reviewed files and `filesSkipped` includes exclusions; incremental results also provide `filesChanged` and reviewed `addedLines`. Git output uses fixed colors/prefixes and disables external diff/textconv helpers. Invalid `base`/`staged` arguments and malformed or incomplete hunks fail without replacing scan state.
12
+
13
+ Saved evidence with a legacy `./` path prefix still matches baselines. File-level findings now use actual matching snippets; older baselines that saved the beginning of an entire file may require a fresh scan and human review before acceptance.
14
+
9
15
  ## Tools
10
16
 
11
17
  | Tool | Purpose | Write |
package/docs/USAGE.md CHANGED
@@ -6,6 +6,14 @@
6
6
 
7
7
  相对 stateDir 默认在会话工作区的 .code-security。并发扫描保留双方历史;记录损坏时先备份排查,不会默默重建。扫描不跟随符号链接或目录联接;静态规则不能代替完整安全审计。
8
8
 
9
+ 0.3.8 改进(2026-10-12):`secure_diff` 支持 Git 转义的中文/特殊文件名,并检查新增私钥、`.env` 敏感项与禁用审计配置。它读取前后各 3 行上下文以处理已有豁免和跨行配置,只报告证据涉及新增行的问题;跨行证据的行号指向匹配的起始行。暂存扫描以暂存区内容为准。
10
+
11
+ 全量、直接指定文件与增量扫描均遵守默认目录排除及策略 `exclude`。文件路径统一相对当前会话工作区;在仓库子目录作为工作区时,增量扫描不包含该目录外的改动。返回的 `filesScanned` 为实际审查的文件数,`filesSkipped` 包含排除项;增量结果另有 `filesChanged` 和实际审查的 `addedLines`。
12
+
13
+ `base` 必须是非空 Git ref,不能以 `-` 开头;`staged` 必须是布尔值。差异块行数不完整或格式错误会报错,本次不会覆盖已有扫描记录。Git 的颜色/前缀偏好不会改变解析格式,外部 diff 和 textconv 转换器被禁用。
14
+
15
+ 旧记录的 `./` 路径前缀可继续匹配基线。私钥、`.env` 和禁用审计配置现在使用实际匹配片段;旧基线若保存的是文件开头的大段文本,可能被重新识别为新增,需复扫并人工复核后再接受。详见[本轮迭代记录](ITERATION-2026-10-12.md)。
16
+
9
17
  ## 工具
10
18
 
11
19
  | 工具 | 作用 | 写操作 |
package/lib/diff.d.ts CHANGED
@@ -13,10 +13,13 @@ export interface DiffLine {
13
13
  export interface DiffFile {
14
14
  file: string;
15
15
  added: DiffLine[];
16
+ context?: DiffLine[];
16
17
  }
17
18
  export interface DiffScanResult {
18
19
  base: string;
19
20
  filesChanged: number;
21
+ filesScanned: number;
22
+ filesSkipped: number;
20
23
  addedLines: number;
21
24
  findings: Finding[];
22
25
  diff: string;
package/lib/diff.js CHANGED
@@ -5,15 +5,18 @@
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());
19
+ argv.push(target.trim());
17
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(' | '));
@@ -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.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 = [];
@@ -48,7 +48,7 @@ export function buildExtraTools(cfg, cwd, signal) {
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
54
  const file = await saveBaseline(stateDir, baseline, signal);
package/lib/index.d.ts CHANGED
@@ -2,7 +2,8 @@
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 七个工具。扫描为纯确定性规则,
5
+ * secure_report / secure_export / secure_baseline / secure_deps / secure_policy_show /
6
+ * secure_policy_set / secure_health 十个工具。扫描为纯确定性规则,
6
7
  * git diff 通过官方 subprocess 服务执行,仅依赖文件锁库。
7
8
  *
8
9
  * @module dsh-code-security
package/lib/index.js CHANGED
@@ -2,7 +2,8 @@
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 七个工具。扫描为纯确定性规则,
5
+ * secure_report / secure_export / secure_baseline / secure_deps / secure_policy_show /
6
+ * secure_policy_set / secure_health 十个工具。扫描为纯确定性规则,
6
7
  * git diff 通过官方 subprocess 服务执行,仅依赖文件锁库。
7
8
  *
8
9
  * @module dsh-code-security
package/lib/scanner.d.ts CHANGED
@@ -37,8 +37,10 @@ export interface LineEntry {
37
37
  text: string;
38
38
  }
39
39
  /** 对带行号的文本行应用规则(全量扫描与 git diff 增量扫描共用)。 */
40
- 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[];
41
41
  /** 扫描单个文件内容,按语言与行应用规则。 */
42
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;
43
45
  /** 扫描目录或单个文件。 */
44
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,9 +210,13 @@ 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) => {
182
215
  options.signal?.throwIfAborted();
216
+ if (isExcludedPath(rel, policy)) {
217
+ filesSkipped += 1;
218
+ return;
219
+ }
183
220
  let stat;
184
221
  try {
185
222
  stat = await fs.lstat(absolute);
@@ -194,7 +231,7 @@ export async function scanPath(options) {
194
231
  return;
195
232
  }
196
233
  if (stat.isDirectory()) {
197
- if (DEFAULT_EXCLUDE_DIRS.includes(path.basename(absolute)) || policyExcludesDir(policy, rel))
234
+ if (DEFAULT_EXCLUDE_DIRS.includes(path.basename(absolute)))
198
235
  return;
199
236
  let entries;
200
237
  try {
package/lib/state.js CHANGED
@@ -11,7 +11,8 @@ import lockfile from 'proper-lockfile';
11
11
  const STATE_FILE = 'state.json';
12
12
  const HISTORY_LIMIT = 50;
13
13
  export function findingFingerprint(finding) {
14
- const raw = finding.ruleId + '|' + finding.file + '|' + finding.snippet.trim();
14
+ const file = finding.file.split(path.sep).join('/').replace(/^(?:\.\/)+/, '');
15
+ const raw = finding.ruleId + '|' + file + '|' + finding.snippet.trim();
15
16
  return createHash('sha256').update(raw).digest('hex');
16
17
  }
17
18
  export function statePath(stateDir) {
@@ -130,7 +131,8 @@ export function compareFingerprints(baseline, current) {
130
131
  export function splitByBaseline(findings, baseline) {
131
132
  if (baseline === null)
132
133
  return { fresh: findings, accepted: [] };
133
- const acceptedSet = new Set(baseline.fingerprints);
134
+ // Recompute from saved evidence so older "./file" fingerprints remain usable.
135
+ const acceptedSet = new Set(baseline.findings.map(findingFingerprint));
134
136
  const fresh = [];
135
137
  const accepted = [];
136
138
  for (const finding of findings) {
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
  */
package/lib/tools.js 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
  */
@@ -48,6 +48,12 @@ function scanArguments(raw, allowed = ['target']) {
48
48
  if (Object.hasOwn(args, 'target') && (typeof args.target !== 'string' || args.target.trim() === '')) {
49
49
  throw new Error('扫描参数 target 必须是非空的文件或目录字符串。扫描整个工作区请省略 target 或使用 "."。');
50
50
  }
51
+ if (Object.hasOwn(args, 'base') && (typeof args.base !== 'string' || args.base.trim() === '' || args.base.trim().startsWith('-') || args.base.includes('\0'))) {
52
+ throw new Error('扫描参数 base 必须是非空的 Git ref,不能是 Git 命令选项。');
53
+ }
54
+ if (Object.hasOwn(args, 'staged') && typeof args.staged !== 'boolean') {
55
+ throw new Error('扫描参数 staged 必须是布尔值 true 或 false。');
56
+ }
51
57
  return args;
52
58
  }
53
59
  const findingSchema = {
@@ -68,6 +74,7 @@ const scanSchema = {
68
74
  type: 'object',
69
75
  properties: {
70
76
  root: { type: 'string' }, filesScanned: { type: 'integer' }, filesSkipped: { type: 'integer' },
77
+ filesChanged: { type: 'integer' }, addedLines: { type: 'integer' },
71
78
  findings: { type: 'array', items: findingSchema }, counts: countsSchema, passed: { type: 'boolean' },
72
79
  failOn: { type: 'string' }, durationMs: { type: 'integer' },
73
80
  newFindings: { type: 'array', items: findingSchema }, newCounts: countsSchema,
@@ -96,6 +103,8 @@ function renderFindings(_args, value) {
96
103
  '安全审查:' + (rec.passed === true ? '通过' : '未通过'),
97
104
  '文件 ' + String(rec.filesScanned ?? 0) + ' 个,发现 ' + findings.length + ' 个问题(critical ' + String(counts?.critical ?? 0) + ' / high ' + String(counts?.high ?? 0) + ' / medium ' + String(counts?.medium ?? 0) + ' / low ' + String(counts?.low ?? 0) + ');基线外新增 ' + split.fresh.length + ' 个。',
98
105
  ];
106
+ if (typeof rec.filesSkipped === 'number' && rec.filesSkipped > 0)
107
+ lines.push('已跳过 ' + rec.filesSkipped + ' 项;跳过项不计入已扫描文件数。');
99
108
  for (const finding of findings) {
100
109
  lines.push('- ' + finding.file + ':' + finding.line + ' [' + finding.severity + '] ' + finding.ruleId + ' ' + finding.title);
101
110
  }
@@ -147,7 +156,7 @@ function buildWorkspaceTools(cfg, cwd, runner, signal) {
147
156
  };
148
157
  const secureDiff = {
149
158
  name: 'secure_diff',
150
- description: '只审查 git diff 的新增行(默认 HEAD,即未提交改动)。base 可用任意 git ref;staged=true 审查已暂存改动;target 可限定文件。结果同样写入状态。',
159
+ description: '结合少量上下文审查 git diff,只报告涉及新增行的问题(默认 HEAD,即未提交改动)。base 可用 Git ref;staged=true 审查已暂存改动;target 可限定文件。范围与路径相对当前会话工作区,遵守项目排除策略。结果同样写入状态。',
151
160
  parameters: { ...compileParameters({
152
161
  base: { type: 'string', description: 'git 基线(默认 HEAD)。' },
153
162
  target: { type: 'string', description: '限定文件路径(可选)。' },
@@ -169,13 +178,13 @@ function buildWorkspaceTools(cfg, cwd, runner, signal) {
169
178
  mode: 'diff:' + base,
170
179
  target: target ?? '.',
171
180
  time: new Date().toISOString(),
172
- filesScanned: result.filesChanged,
181
+ filesScanned: result.filesScanned,
173
182
  findings: result.findings,
174
183
  fingerprints: result.findings.map(findingFingerprint),
175
184
  counts,
176
185
  };
177
186
  await saveState(stateDir, entry, signal);
178
- return { root: base, filesScanned: result.filesChanged, filesSkipped: 0, addedLines: result.addedLines, findings: result.findings, counts, newFindings: split.fresh, newCounts: countFindings(split.fresh), acceptedFindings: split.accepted, baseline: state.baseline, passed: verdictOf(split.fresh, failOn), failOn, durationMs: 0 };
187
+ return { root: base, filesChanged: result.filesChanged, filesScanned: result.filesScanned, filesSkipped: result.filesSkipped, addedLines: result.addedLines, findings: result.findings, counts, newFindings: split.fresh, newCounts: countFindings(split.fresh), acceptedFindings: split.accepted, baseline: state.baseline, passed: verdictOf(split.fresh, failOn), failOn, durationMs: 0 };
179
188
  },
180
189
  timeoutMs: 60000,
181
190
  };
@@ -211,7 +220,7 @@ function buildWorkspaceTools(cfg, cwd, runner, signal) {
211
220
  return { baselineMissing: true, baselineTime: '', closedCount: 0, remainingCount: 0, freshCount: result.findings.length, closed: [], remaining: [], fresh: result.findings, passed: false };
212
221
  }
213
222
  const current = result.findings.map(findingFingerprint);
214
- const cmp = compareFingerprints(state.last.fingerprints, current);
223
+ const cmp = compareFingerprints(state.last.findings.map(findingFingerprint), current);
215
224
  const byFingerprint = new Map(result.findings.map((finding) => [findingFingerprint(finding), finding]));
216
225
  const closed = cmp.closed.map((fp) => state.last.findings.find((finding) => findingFingerprint(finding) === fp)).filter((x) => x !== undefined);
217
226
  const remaining = cmp.remaining.map((fp) => byFingerprint.get(fp)).filter((x) => x !== undefined);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-code-security",
3
- "version": "0.3.7",
3
+ "version": "0.3.8",
4
4
  "description": "对代码与 Git 改动进行本地静态安全检查,并追踪修复结果。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",