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/CHANGELOG.md +29 -0
- package/README.en.md +31 -27
- package/README.md +23 -66
- package/docs/CHANGELOG.en.md +15 -0
- package/docs/ITERATION-2026-10-12.md +53 -0
- package/docs/USAGE.en.md +37 -0
- package/docs/USAGE.md +85 -0
- package/docs/VALIDATION.md +15 -0
- package/lib/config.d.ts +1 -0
- package/lib/config.js +2 -1
- package/lib/diff.d.ts +3 -0
- package/lib/diff.js +112 -34
- package/lib/extra.d.ts +1 -1
- package/lib/extra.js +4 -4
- package/lib/index.d.ts +3 -2
- package/lib/index.js +3 -2
- package/lib/policy.d.ts +1 -1
- package/lib/policy.js +17 -5
- package/lib/runner.d.ts +2 -0
- package/lib/runner.js +4 -2
- package/lib/scanner.d.ts +4 -1
- package/lib/scanner.js +79 -35
- package/lib/state.d.ts +2 -2
- package/lib/state.js +76 -20
- package/lib/tools.d.ts +2 -2
- package/lib/tools.js +37 -13
- package/package.json +10 -4
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=
|
|
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(
|
|
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
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
if (
|
|
31
|
-
return
|
|
32
|
-
|
|
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
|
-
|
|
41
|
-
if (
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
107
|
+
if (raw.startsWith('diff --git ')) {
|
|
108
|
+
current = null;
|
|
109
|
+
hasFileHeader = false;
|
|
50
110
|
continue;
|
|
51
111
|
}
|
|
52
|
-
if (
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
64
|
-
|
|
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
|
-
|
|
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.
|
|
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 /
|
|
6
|
-
*
|
|
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 /
|
|
6
|
-
*
|
|
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-' +
|
|
48
|
-
|
|
49
|
-
|
|
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
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
|
|
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,
|
|
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
|
-
|
|
137
|
-
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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.
|
|
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))
|
|
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;
|