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/CHANGELOG.md +17 -0
- package/README.en.md +7 -4
- package/README.md +7 -4
- package/THIRD_PARTY_NOTICES.md +23 -0
- package/docs/ITERATION-2026-10-12.md +53 -0
- package/docs/USAGE.en.md +83 -20
- package/docs/USAGE.md +72 -52
- package/docs/VALIDATION.md +14 -0
- package/lib/audit.d.ts +27 -0
- package/lib/audit.js +109 -0
- package/lib/automatic.d.ts +18 -0
- package/lib/automatic.js +103 -0
- package/lib/client.js +63 -0
- package/lib/config.d.ts +9 -0
- package/lib/config.js +12 -1
- package/lib/deps.d.ts +14 -8
- package/lib/deps.js +170 -143
- package/lib/diff.d.ts +3 -0
- package/lib/diff.js +111 -33
- package/lib/engines.d.ts +50 -0
- package/lib/engines.js +255 -0
- package/lib/extra.d.ts +2 -1
- package/lib/extra.js +31 -6
- package/lib/index.d.ts +9 -2
- package/lib/index.js +46 -5
- package/lib/privacy.d.ts +6 -0
- package/lib/privacy.js +39 -0
- package/lib/rules.js +1 -1
- package/lib/runner.d.ts +4 -0
- package/lib/runner.js +8 -3
- package/lib/scanner.d.ts +14 -1
- package/lib/scanner.js +90 -36
- package/lib/state.d.ts +7 -0
- package/lib/state.js +30 -7
- package/lib/syntax.d.ts +1 -0
- package/lib/syntax.js +43 -0
- package/lib/tools.d.ts +4 -2
- package/lib/tools.js +67 -25
- package/lib/workbench.d.ts +30 -0
- package/lib/workbench.js +242 -0
- package/package.json +16 -3
- package/rules/betterleaks.toml +3 -0
- package/rules/osv.toml +1 -0
- package/rules/semgrep.yml +72 -0
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,
|
|
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 (
|
|
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
|
-
|
|
137
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
|
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))
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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) {
|
package/lib/syntax.d.ts
ADDED
|
@@ -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[];
|