crypto-hallucination-detector 0.0.0-stage → 0.1.2
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/README.md +46 -2
- package/package.json +16 -4
- package/src/api-misuse.js +70 -0
- package/src/ast-scanner.js +204 -0
- package/src/cli.js +84 -0
- package/src/constant-time.js +29 -0
- package/src/domain-params.js +105 -0
- package/src/index.js +38 -0
- package/src/pattern-scanner.js +153 -0
- package/src/test-coverage.js +42 -0
package/README.md
CHANGED
|
@@ -1,3 +1,47 @@
|
|
|
1
|
-
#
|
|
1
|
+
# crypto-hallucination-detector
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
启发式静态检测器:标记「AI 易编造 / 易漏」的密码代码风险模式。
|
|
4
|
+
|
|
5
|
+
## 定位
|
|
6
|
+
|
|
7
|
+
**不是**漏洞扫描器,**不是** AST 解析器。它基于轻量结构扫描(字符串掩码 + 括号配平)识别需人工核验的**气味信号**:
|
|
8
|
+
|
|
9
|
+
- **侧信道味道**:秘密相关分支 / 数组下标使用秘密 / switch-on-secret / 声明定常却非定常
|
|
10
|
+
- **伪造 API**:AI 常编造的伪 noble / WebCrypto 命名(如 `noble.kem.mlkem`、`crypto.subtle.mlkem`)
|
|
11
|
+
- **测试覆盖**:有实现但缺对应测试文件
|
|
12
|
+
- **非定常比较**:对秘密值用 `!= / ===` 而非 ct-equal
|
|
13
|
+
|
|
14
|
+
每条 finding 给出 file/line/severity/justification,**绝不宣判漏洞**——输出是 `needs-human-review`,由人定夺。
|
|
15
|
+
|
|
16
|
+
## 运行
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
npm test # 自带样本验证(命中幻觉模式 + 不误报良好代码)
|
|
20
|
+
node src/cli.js <dir> # 扫描目录
|
|
21
|
+
node src/cli.js <dir> --fail-on high # CI 门禁:存在 >=high 的 finding 则退出码 1
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
`--fail-on <low|medium|high>` 让 CLI 在存在「该级别及以上」的 finding 时以退出码 1 失败(可作 CI 门禁);缺参或非法值退出码 2。注意:`api-misuse` / `domain-params` / `test-coverage` 三类 finding 没有 `severity` 字段,统一按 `medium` 归并——所以 `--fail-on high` 会放过 `medium` 级 finding(含这三类);若要连这三类一起拦截,用 `--fail-on low`。
|
|
25
|
+
|
|
26
|
+
## 架构
|
|
27
|
+
|
|
28
|
+
| 文件 | 职责 |
|
|
29
|
+
|---|---|
|
|
30
|
+
| `src/ast-scanner.js` | 零依赖结构扫描:注释 / 调用 / 分支 / 函数 / 秘密变量传播 |
|
|
31
|
+
| `src/pattern-scanner.js` | 基于 ast 的启发式模式匹配(侧信道味道) |
|
|
32
|
+
| `src/api-misuse.js` | 伪 API 白名单+可疑命名检测 |
|
|
33
|
+
| `src/test-coverage.js` | 测试文件覆盖检查 |
|
|
34
|
+
| `src/constant-time.js` | 聚合侧信道相关 finding |
|
|
35
|
+
| `src/index.js` | 统一入口(聚合输出结构化报告) |
|
|
36
|
+
| `src/cli.js` | 命令行入口 |
|
|
37
|
+
|
|
38
|
+
## 关键纪律(对应 FIBEMATE 工程纪律)
|
|
39
|
+
|
|
40
|
+
- **API 白名单不是真理**:`api-misuse.js` 的白名单针对 FIBEMATE 技术栈,**任何新项目使用前须按自家依赖核实**("不拿推测当事实"的代码化)。
|
|
41
|
+
- **零外部依赖**:不引入 acorn/esprima,避免把"完整 AST"当承诺;掩码扫描是启发式,定位透明。
|
|
42
|
+
- **不误报优先**:良样本 `sample.good.js` 必须零 finding,测试守护此约束。
|
|
43
|
+
|
|
44
|
+
## 已知局限
|
|
45
|
+
|
|
46
|
+
- 启发式,可能漏报(混淆写法)或误报(字符串里恰好出现模式);输出供人复核。
|
|
47
|
+
- 不解析语言语义,仅结构特征。生产集成建议作为 PR 非阻塞检查。
|
package/package.json
CHANGED
|
@@ -1,6 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "crypto-hallucination-detector",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
}
|
|
3
|
+
"version": "0.1.2",
|
|
4
|
+
"description": "Heuristic static detector for AI-hallucinated or side-channel-unsafe crypto patterns",
|
|
5
|
+
"main": "src/index.js",
|
|
6
|
+
"bin": { "crypto-hallucination-detector": "src/cli.js" },
|
|
7
|
+
"scripts": {
|
|
8
|
+
"test": "node test/sample.test.js && node test/contract.test.js"
|
|
9
|
+
},
|
|
10
|
+
"license": "Apache-2.0",
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "git+https://github.com/Lennonhaha/fibemate-tools.git",
|
|
14
|
+
"directory": "hallucination-detector"
|
|
15
|
+
},
|
|
16
|
+
"files": ["src"],
|
|
17
|
+
"engines": { "node": ">=16" }
|
|
18
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/*
|
|
3
|
+
* api-misuse.js — 检测「AI 易编造的伪 API / 错误调用」启发式
|
|
4
|
+
* 方法:基于白名单(真实存在、可复核的 API)核对代码中的调用点。
|
|
5
|
+
* 注意:白名单是针对「FIBEMATE 技术栈」的示例集合,非通用真理;
|
|
6
|
+
* 任何新项目使用前须按自家依赖核实(这正是「不拿推测当事实」纪律的代码化)。
|
|
7
|
+
* 报告模式:调用名不在白名单中、且形似「知名库 API 变体」时为 suspect。
|
|
8
|
+
*/
|
|
9
|
+
const { scanFile } = require('./ast-scanner');
|
|
10
|
+
|
|
11
|
+
// @whitelist-version 2026-09-12
|
|
12
|
+
// 白名单针对 FIBEMATE 技术栈(noble-ciphers/noble-hashes/@noble/curves + WebCrypto + 内部封装)。
|
|
13
|
+
// 新项目使用前须按自家依赖核实(这正是「不拿推测当事实」纪律的代码化)。
|
|
14
|
+
// 审计者可见:白名单是此日期的快照,库更新后需同步此标记。
|
|
15
|
+
|
|
16
|
+
// 示例白名单(FIBEMATE 实际依赖的真实导出,逐条可核实)
|
|
17
|
+
const KNOWN_OK = new Set([
|
|
18
|
+
// noble (noble-ciphers / noble-hashes / @noble/curves)
|
|
19
|
+
'ml_kem768', 'x25519', 'ed25519', 'sha256', 'sha3_256', 'shake256', 'hmac',
|
|
20
|
+
'randomBytes', 'crypto_subtle', 'getRandomValues', 'aeadEncrypt', 'aeadDecrypt',
|
|
21
|
+
'keccak_256', 'cshake256', 'mod', 'pow', 'inv', 'hashToField',
|
|
22
|
+
// WebCrypto
|
|
23
|
+
'crypto.subtle.encrypt', 'crypto.subtle.decrypt', 'crypto.subtle.importKey',
|
|
24
|
+
'crypto.subtle.deriveBits', 'crypto.subtle.sign', 'crypto.subtle.verify',
|
|
25
|
+
// Node
|
|
26
|
+
'createCipheriv', 'createDecipheriv', 'timingSafeEqual', 'scryptSync',
|
|
27
|
+
// FIBEMATE 内部
|
|
28
|
+
'doubleRatchetAdvance', 'ratchetStep', 'symmetricEncrypt', 'symmetricDecrypt',
|
|
29
|
+
]);
|
|
30
|
+
|
|
31
|
+
// 常见「AI 会编出来」的伪 noble/WebCrypto API(仅作可疑标记,不判违规)
|
|
32
|
+
const SUSPECT_PATTERNS = [
|
|
33
|
+
{ re: /noble\.(kem|pqc|mlkem)/i, note: 'noble 命名空间下常见编造:noble.kem/mlkem(真实为 @noble/ciphers 的 ml_kem768)' },
|
|
34
|
+
{ re: /crypto\.subtle\.mlkem/i, note: 'WebCrypto 尚无 ML-KEM 标准 API,疑似编造' },
|
|
35
|
+
{ re: /noble\.sha3/i, note: 'noble 下无 sha3 顶层导出,真实为 noble-hashes 的 sha3_256' },
|
|
36
|
+
{ re: /x25519\.(derive|keyFrom)/i, note: 'x25519 在 @noble/curves 的真实方法名需注意大小写/挂载' },
|
|
37
|
+
// 仅当 .open( 前接密码学对象(cipher/box/ratchet/aead/seal 等)才可疑;排除 indexedDB.open 等通用 API
|
|
38
|
+
{ re: /\b(cipher|box|aead|ratchet|secretbox|secretBox|symmetric)\.open\(/i, note: 'seal/open 常见于某些封装库,须确认是否真实存在' },
|
|
39
|
+
{ re: /\.seal\(/i, note: 'seal/open 常见于某些封装库,须确认是否真实存在' },
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
function analyzeApiMisuse(source, filename) {
|
|
43
|
+
const scan = scanFile(source, filename);
|
|
44
|
+
const suspects = [];
|
|
45
|
+
for (const call of scan.calls) {
|
|
46
|
+
const full = call.name;
|
|
47
|
+
if (KNOWN_OK.has(full)) continue;
|
|
48
|
+
for (const sp of SUSPECT_PATTERNS) {
|
|
49
|
+
if (sp.re.test(full)) {
|
|
50
|
+
suspects.push({ file: filename, line: lineOf(scan, call.index), api: full, note: sp.note });
|
|
51
|
+
break;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
// 同时扫描源码整体中的可疑 API 命名空间(成员访问链等,不限于调用名)
|
|
56
|
+
// 非全局正则用单次 test 即可(启发式:每文件每模式命中一次足够),避免 exec 死循环
|
|
57
|
+
for (const sp of SUSPECT_PATTERNS) {
|
|
58
|
+
if (sp.re.test(source)) {
|
|
59
|
+
const line = source.search(sp.re) >= 0
|
|
60
|
+
? source.slice(0, source.search(sp.re)).split('\n').length
|
|
61
|
+
: 0;
|
|
62
|
+
suspects.push({ file: filename, line, api: source.match(sp.re)[0], note: sp.note });
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return { file: filename, suspects, verdict: suspects.length ? 'needs-human-review' : 'ok' };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function lineOf(scan, index) { return scan.source.slice(0, index).split('\n').length; }
|
|
69
|
+
|
|
70
|
+
module.exports = { analyzeApiMisuse };
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/*
|
|
3
|
+
* ast-scanner.js — 轻量结构扫描器(零依赖)
|
|
4
|
+
* 不调用完整 JS 解析器,基于「掩码源码 + 括号配平」提取:
|
|
5
|
+
* - comments: 注释文本(含 @secret 标注、constant-time/secure/FIPS 声明)
|
|
6
|
+
* - calls: 调用点(函数名 + 参数片段)
|
|
7
|
+
* - branches: 分支条件(if/else if/while/for/三元)
|
|
8
|
+
* - functions: 函数声明(name + 参数)
|
|
9
|
+
* - secretVars: 秘密变量集合(显式 @secret + 命名启发 + 赋值传播)
|
|
10
|
+
* 启发式,非完整 AST。定位为「静态启发式检测器」,不宣称等效于 esprima/acorn。
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
// 把字符串与注释替换成等宽空格,保留索引对齐,避免括号配平被串内 ( ) 干扰
|
|
14
|
+
function maskSource(src) {
|
|
15
|
+
let out = '';
|
|
16
|
+
let i = 0;
|
|
17
|
+
const n = src.length;
|
|
18
|
+
while (i < n) {
|
|
19
|
+
const c = src[i];
|
|
20
|
+
const c2 = src[i + 1];
|
|
21
|
+
if (c === '/' && c2 === '/') {
|
|
22
|
+
let j = i; while (j < n && src[j] !== '\n') j++;
|
|
23
|
+
out += ' '.repeat(j - i); i = j; continue;
|
|
24
|
+
}
|
|
25
|
+
if (c === '/' && c2 === '*') {
|
|
26
|
+
let j = i + 2; while (j < n && !(src[j] === '*' && src[j + 1] === '/')) j++;
|
|
27
|
+
j = Math.min(n, j + 2); out += ' '.repeat(j - i); i = j; continue;
|
|
28
|
+
}
|
|
29
|
+
if (c === '"' || c === "'" || c === '`') {
|
|
30
|
+
const q = c; let j = i + 1;
|
|
31
|
+
while (j < n) {
|
|
32
|
+
if (src[j] === '\\') { j += 2; continue; }
|
|
33
|
+
if (src[j] === q) { j++; break; }
|
|
34
|
+
j++;
|
|
35
|
+
}
|
|
36
|
+
out += ' '.repeat(j - i); i = j; continue;
|
|
37
|
+
}
|
|
38
|
+
out += c; i++;
|
|
39
|
+
}
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function extractComments(src) {
|
|
44
|
+
const comments = [];
|
|
45
|
+
const lineRe = /\/\/(.*)$/gm;
|
|
46
|
+
let m;
|
|
47
|
+
while ((m = lineRe.exec(src))) {
|
|
48
|
+
comments.push({ type: 'line', text: m[1].trim(), line: src.slice(0, m.index).split('\n').length });
|
|
49
|
+
}
|
|
50
|
+
const blockRe = /\/\*([\s\S]*?)\*\//g;
|
|
51
|
+
while ((m = blockRe.exec(src))) {
|
|
52
|
+
comments.push({ type: 'block', text: m[1].replace(/\s+/g, ' ').trim(),
|
|
53
|
+
line: src.slice(0, m.index).split('\n').length });
|
|
54
|
+
}
|
|
55
|
+
return comments;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function balancedSlice(src, openIdx) {
|
|
59
|
+
if (src[openIdx] !== '(') return null;
|
|
60
|
+
let depth = 0;
|
|
61
|
+
for (let i = openIdx; i < src.length; i++) {
|
|
62
|
+
const c = src[i];
|
|
63
|
+
if (c === '(') depth++;
|
|
64
|
+
else if (c === ')') { depth--; if (depth === 0) return src.slice(openIdx + 1, i); }
|
|
65
|
+
}
|
|
66
|
+
return src.slice(openIdx + 1);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const WORD = '[A-Za-z_$][\\w$]*';
|
|
70
|
+
|
|
71
|
+
function scanFile(source, filename) {
|
|
72
|
+
const masked = maskSource(source);
|
|
73
|
+
const comments = extractComments(source);
|
|
74
|
+
|
|
75
|
+
const calls = [];
|
|
76
|
+
const callRe = new RegExp('(' + WORD + ')\\s*\\(', 'g');
|
|
77
|
+
let m;
|
|
78
|
+
while ((m = callRe.exec(masked))) {
|
|
79
|
+
const name = m[1];
|
|
80
|
+
const argsRaw = balancedSlice(masked, m.index + name.length);
|
|
81
|
+
calls.push({ name, args: argsRaw ? argsRaw.trim() : '', index: m.index });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const branches = [];
|
|
85
|
+
const branchRe = new RegExp('\\b(if|else\\s+if|while|for|switch)\\s*\\(', 'g');
|
|
86
|
+
while ((m = branchRe.exec(masked))) {
|
|
87
|
+
const kind = m[1].replace(/\s+/g, ' ');
|
|
88
|
+
const cond = balancedSlice(masked, m.index + m[0].length - 1);
|
|
89
|
+
branches.push({ kind, condition: cond ? cond.trim() : '', index: m.index });
|
|
90
|
+
}
|
|
91
|
+
// 三元表达式(?: 不在括号内)
|
|
92
|
+
let depth = 0;
|
|
93
|
+
for (let i = 0; i < masked.length; i++) {
|
|
94
|
+
const c = masked[i];
|
|
95
|
+
if (c === '(') depth++;
|
|
96
|
+
else if (c === ')') depth--;
|
|
97
|
+
else if (c === '?' && depth === 0) {
|
|
98
|
+
// 找匹配的 :
|
|
99
|
+
let d2 = depth, j = i + 1, startLine = source.slice(0, i).split('\n').length;
|
|
100
|
+
while (j < masked.length) {
|
|
101
|
+
if (masked[j] === '(') d2++;
|
|
102
|
+
else if (masked[j] === ')') d2--;
|
|
103
|
+
else if (masked[j] === ':' && d2 === 0) {
|
|
104
|
+
branches.push({ kind: 'ternary', condition: source.slice(i + 1, j).trim(), index: i, line: startLine });
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
107
|
+
j++;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const functions = [];
|
|
113
|
+
const fnRe = new RegExp('function\\s+(' + WORD + ')\\s*\\(([^)]*)\\)', 'g');
|
|
114
|
+
while ((m = fnRe.exec(masked))) {
|
|
115
|
+
functions.push({ name: m[1], params: m[2].trim(), index: m.index });
|
|
116
|
+
}
|
|
117
|
+
const arrowRe = new RegExp('(' + WORD + ')\\s*=\\s*\\(([^)]*)\\)\\s*=>', 'g');
|
|
118
|
+
while ((m = arrowRe.exec(masked))) {
|
|
119
|
+
functions.push({ name: m[1], params: m[2].trim(), index: m.index });
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// 赋值收集(用于秘密传播):lhs = rhs
|
|
123
|
+
const assigns = [];
|
|
124
|
+
const assignRe = new RegExp('(' + WORD + ')\\s*=\\s*([^;=].*?);', 'g');
|
|
125
|
+
while ((m = assignRe.exec(masked))) {
|
|
126
|
+
assigns.push({ lhs: m[1], rhs: m[2].trim() });
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// 秘密变量初集
|
|
130
|
+
const secretVars = new Set();
|
|
131
|
+
// 秘密变量命名判定:白名单 + 黑名单双向,黑名单优先(避免 rootKey/chainKey 等状态键被误判为秘密)
|
|
132
|
+
// 白名单:确为密码学秘密的常见命名(使用词边界,避免 section/second/privacy 等误中)
|
|
133
|
+
const SECRET_WHITE = [
|
|
134
|
+
/\bsecret\b/i,
|
|
135
|
+
/\bprivateKey\b/i, /\bprivKey\b/i,
|
|
136
|
+
/\bsharedSecret\b/i,
|
|
137
|
+
/\bsk\b/i,
|
|
138
|
+
/\bhmac\b/i,
|
|
139
|
+
/\bpassword\b/i, /\bpasswd\b/i,
|
|
140
|
+
/\btoken\b/i,
|
|
141
|
+
/\bsalt\b/i,
|
|
142
|
+
/\biv\b/i,
|
|
143
|
+
/\bnonce\b/i,
|
|
144
|
+
];
|
|
145
|
+
// 黑名单:ratchet 等状态键,虽含 key 但不是侧信道敏感秘密(长度/存在性校验非时序敏感)
|
|
146
|
+
const SECRET_BLACK = [
|
|
147
|
+
/\brootKey\b/i,
|
|
148
|
+
/\bchainKey\b/i,
|
|
149
|
+
/\breceivingChainKey\b/i,
|
|
150
|
+
/\bsendingChainKey\b/i,
|
|
151
|
+
/\bmessageKey\b/i,
|
|
152
|
+
/\bheaderKey\b/i,
|
|
153
|
+
];
|
|
154
|
+
function isSecretName(name) {
|
|
155
|
+
if (!name) return false;
|
|
156
|
+
if (SECRET_BLACK.some((r) => r.test(name))) return false;
|
|
157
|
+
return SECRET_WHITE.some((r) => r.test(name));
|
|
158
|
+
}
|
|
159
|
+
for (const c of comments) {
|
|
160
|
+
const mm = c.text.match(/@secret\s+([\w\s,]+)/i);
|
|
161
|
+
if (mm) {
|
|
162
|
+
mm[1].split(/[\s,]+/).filter(Boolean).forEach((nm) => secretVars.add(nm));
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
// 参数/变量名启发
|
|
166
|
+
for (const f of functions) {
|
|
167
|
+
f.params.split(',').map((s) => s.trim().split(/\s+/)[0]).filter(Boolean)
|
|
168
|
+
.forEach((p) => { if (isSecretName(p)) secretVars.add(p); });
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// 赋值传播(定点迭代,小图足够)
|
|
172
|
+
let changed = true, guard = 0;
|
|
173
|
+
while (changed && guard++ < 20) {
|
|
174
|
+
changed = false;
|
|
175
|
+
for (const a of assigns) {
|
|
176
|
+
if ([...secretVars].some((s) => new RegExp('\\b' + s + '\\b').test(a.rhs))) {
|
|
177
|
+
if (!secretVars.has(a.lhs)) { secretVars.add(a.lhs); changed = true; }
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// 声明中提及的声明式秘密(如 const secretKey = ...)
|
|
183
|
+
for (const a of assigns) {
|
|
184
|
+
if (isSecretName(a.lhs) && !secretVars.has(a.lhs)) secretVars.add(a.lhs);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return {
|
|
188
|
+
filename,
|
|
189
|
+
comments,
|
|
190
|
+
calls,
|
|
191
|
+
branches,
|
|
192
|
+
functions,
|
|
193
|
+
secretVars: [...secretVars],
|
|
194
|
+
masked,
|
|
195
|
+
source,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// 判断某段文本是否引用了秘密变量(词边界)
|
|
200
|
+
function referencesSecret(text, secretVars) {
|
|
201
|
+
return secretVars.some((s) => new RegExp('\\b' + s + '\\b').test(text));
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
module.exports = { scanFile, referencesSecret, maskSource, extractComments };
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
/*
|
|
4
|
+
* cli.js — 命令行入口
|
|
5
|
+
* 用法: node src/cli.js <file-or-dir> [rootDir] [--fail-on <low|medium|high>]
|
|
6
|
+
*/
|
|
7
|
+
const fs = require('fs');
|
|
8
|
+
const path = require('path');
|
|
9
|
+
const { analyzeProject } = require('./index');
|
|
10
|
+
|
|
11
|
+
const EXCLUDE_DIRS = new Set(['node_modules', 'test', '.git', 'dist', 'build', 'coverage', 'out', '.next', 'vendor', '.cache', 'tmp']);
|
|
12
|
+
const MAX_FILE_SIZE = 1024 * 1024; // 1 MB — skip files larger than this
|
|
13
|
+
|
|
14
|
+
function collect(dirOrFile, acc) {
|
|
15
|
+
acc = acc || [];
|
|
16
|
+
const stat = fs.statSync(dirOrFile);
|
|
17
|
+
if (stat.isDirectory()) {
|
|
18
|
+
for (const e of fs.readdirSync(dirOrFile)) {
|
|
19
|
+
const full = path.join(dirOrFile, e);
|
|
20
|
+
const s = fs.statSync(full);
|
|
21
|
+
if (s.isDirectory()) {
|
|
22
|
+
if (EXCLUDE_DIRS.has(e)) continue;
|
|
23
|
+
collect(full, acc);
|
|
24
|
+
} else if (/\.(js|ts)$/.test(e)) {
|
|
25
|
+
if (s.size > MAX_FILE_SIZE) continue; // skip oversized files
|
|
26
|
+
acc.push({ filename: full, source: fs.readFileSync(full, 'utf-8') });
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
} else {
|
|
30
|
+
acc.push({ filename: dirOrFile, source: fs.readFileSync(dirOrFile, 'utf-8') });
|
|
31
|
+
}
|
|
32
|
+
return acc;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const SEVERITY_RANK = { low: 0, medium: 1, high: 2 };
|
|
36
|
+
|
|
37
|
+
// 解析参数:--fail-on <level> 与位置参数分离;位置参数顺序保持不变
|
|
38
|
+
function parseArgs(argv) {
|
|
39
|
+
const flags = {};
|
|
40
|
+
const positional = [];
|
|
41
|
+
for (let i = 0; i < argv.length; i++) {
|
|
42
|
+
const a = argv[i];
|
|
43
|
+
if (a === '--fail-on') {
|
|
44
|
+
flags.failOn = argv[++i];
|
|
45
|
+
} else if (a.startsWith('--')) {
|
|
46
|
+
flags[a.slice(2)] = true;
|
|
47
|
+
} else {
|
|
48
|
+
positional.push(a);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return { flags, positional };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// finding 的 severity 归一化:无 severity 字段(api-misuse / domain-params /
|
|
55
|
+
// test-coverage)视为 medium——它们是「需人工核验」而非「确定违规」。
|
|
56
|
+
function severityOf(f) {
|
|
57
|
+
return f.severity || 'medium';
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function main() {
|
|
61
|
+
const { flags, positional } = parseArgs(process.argv.slice(2));
|
|
62
|
+
if (flags.failOn !== undefined && !Object.prototype.hasOwnProperty.call(SEVERITY_RANK, flags.failOn)) {
|
|
63
|
+
console.error('--fail-on requires one of: low | medium | high');
|
|
64
|
+
process.exit(2);
|
|
65
|
+
}
|
|
66
|
+
const target = positional[0];
|
|
67
|
+
if (!target) { console.error('usage: node src/cli.js <file-or-dir> [rootDir] [--fail-on <low|medium|high>]'); process.exit(2); }
|
|
68
|
+
const rootDir = positional[1] || (fs.statSync(target).isDirectory() ? target : '.');
|
|
69
|
+
const sources = collect(target);
|
|
70
|
+
const report = analyzeProject(sources, rootDir);
|
|
71
|
+
console.log(JSON.stringify(report, null, 2));
|
|
72
|
+
const n = report.findings.length;
|
|
73
|
+
console.log('\n[summary] files=' + sources.length + ' findings=' + n);
|
|
74
|
+
if (flags.failOn !== undefined && n > 0) {
|
|
75
|
+
const threshold = SEVERITY_RANK[flags.failOn];
|
|
76
|
+
const offending = report.findings.filter((f) => SEVERITY_RANK[severityOf(f)] >= threshold);
|
|
77
|
+
if (offending.length > 0) {
|
|
78
|
+
console.error('[fail-on] ' + offending.length + ' finding(s) at or above severity "' + flags.failOn + '"');
|
|
79
|
+
process.exit(1);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
main();
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/*
|
|
3
|
+
* constant-time.js — 检测「非定常实现」启发式
|
|
4
|
+
* 真实来源:计时/功率侧信道通常源于:秘密相关分支、秘密做数组下标、
|
|
5
|
+
* 对秘密的非定常比较。这里聚合 ast-scanner + pattern-scanner 的产出,
|
|
6
|
+
* 给出「需人工核验」而非「确定违规」的标记。
|
|
7
|
+
*/
|
|
8
|
+
const { scanFile } = require('./ast-scanner');
|
|
9
|
+
const { scanPatterns } = require('./pattern-scanner');
|
|
10
|
+
|
|
11
|
+
function analyzeConstantTime(source, filename) {
|
|
12
|
+
const scan = scanFile(source, filename);
|
|
13
|
+
const suspicious = scanPatterns(scan).filter((f) =>
|
|
14
|
+
f.pattern === 'secret-dependent-branch' ||
|
|
15
|
+
f.pattern === 'array-index-by-secret' ||
|
|
16
|
+
f.pattern === 'switch-on-secret' ||
|
|
17
|
+
f.pattern === 'declared-secure-but-branches-secret');
|
|
18
|
+
const score = suspicious.filter((s) => s.severity === 'high').length * 2 +
|
|
19
|
+
suspicious.filter((s) => s.severity === 'medium').length;
|
|
20
|
+
return {
|
|
21
|
+
file: filename,
|
|
22
|
+
secretVars: scan.secretVars,
|
|
23
|
+
findings: suspicious,
|
|
24
|
+
score,
|
|
25
|
+
verdict: score === 0 ? 'no-side-channel-smell' : 'needs-human-review',
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
module.exports = { analyzeConstantTime };
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/*
|
|
3
|
+
* domain-params.js — 类 9:参数/域错误检测(启发式)
|
|
4
|
+
*
|
|
5
|
+
* 方法:从源码中按「文件名或源码内容识别算法」锚定,标记「疑似参数错误」。
|
|
6
|
+
* - 文件名或文件内容含算法标识(如 ml-kem-768 / ml-dsa / slh-dsa)时才查表(减少误报)
|
|
7
|
+
* - 该文件内扫描 PARAM_NAMES(q/n/k/h/d)的常量赋值,与 NIST 白名单精确比对
|
|
8
|
+
* - 若常量值不在该算法的 NIST 标准白名单中,标记为 suspect(需人工核验,不判违规)
|
|
9
|
+
* (注:文件名或内容任一命中算法标识即视为「该文件为对应算法实现」;
|
|
10
|
+
* 避免漏报;误报风险由文件名识别已挡掉大部分无关文件。)
|
|
11
|
+
*
|
|
12
|
+
* 设计约束(据本次起草评审):
|
|
13
|
+
* - 找参数:正则 + 关键字(不引入 AST 解析),先简单、能跑
|
|
14
|
+
* - 查表:精确匹配,不搞 ±误差
|
|
15
|
+
* - 疑似判定:标准算法(文件名或内容命中) + 常量不在白名单 = suspicious(双条件)
|
|
16
|
+
* - 接口与 api-misuse 完全同形:{ file, suspects:[{file,line,param,value,note}], verdict }
|
|
17
|
+
*
|
|
18
|
+
* 白名单针对 FIBEMATE 技术栈中的 NIST 后量子算法;新项目使用前须按自家依赖核实。
|
|
19
|
+
* 审计者可见:白名单是此日期的快照,库/标准更新后需同步此标记。
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
// @whitelist-version 2026-09-13
|
|
23
|
+
// NIST 标准参数快照(FIBEMATE 实际依赖的真实参数,逐条可核实)。
|
|
24
|
+
// 数据来源:FIPS 203 (ML-KEM) / FIPS 204 (ML-DSA) / FIPS 205 (SLH-DSA)。
|
|
25
|
+
const NIST_PARAMS = {
|
|
26
|
+
'ml-kem-768': { q: 3329, n: 256, k: 3 },
|
|
27
|
+
'ml-kem-1024': { q: 3329, n: 256, k: 4 },
|
|
28
|
+
'ml-dsa-65': { q: 8380417, n: 256, k: 6 },
|
|
29
|
+
'slh-dsa-sha2-128f': { n: 16, h: 66, d: 22 },
|
|
30
|
+
'slh-dsa-sha2-128s': { n: 16, h: 63, d: 7 },
|
|
31
|
+
// TODO: SM 系列(SM2/SM3/SM4)参数白名单待补——需先与国密标准核实
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
// 文件名 → 算法 key 的识别(保守小写匹配)
|
|
35
|
+
const ALGO_HINTS = [
|
|
36
|
+
{ key: 'ml-kem-768', re: /ml[-_]?kem[-_]?768/i },
|
|
37
|
+
{ key: 'ml-kem-1024', re: /ml[-_]?kem[-_]?1024/i },
|
|
38
|
+
{ key: 'ml-dsa-65', re: /ml[-_]?dsa[-_]?65/i },
|
|
39
|
+
{ key: 'slh-dsa-sha2-128f', re: /slh[-_]?dsa[-_]?sha2[-_]?128f/i },
|
|
40
|
+
{ key: 'slh-dsa-sha2-128s', re: /slh[-_]?dsa[-_]?sha2[-_]?128s/i },
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
// 参数名白名单(只在出现这些赋值名时才纳入查表,避免把任意数字当参数)
|
|
44
|
+
const PARAM_NAMES = ['q', 'n', 'k', 'h', 'd'];
|
|
45
|
+
|
|
46
|
+
function inferAlgorithm(filename, source) {
|
|
47
|
+
const hay = filename + '\n' + (source || '');
|
|
48
|
+
for (const h of ALGO_HINTS) {
|
|
49
|
+
if (h.re.test(hay)) return h.key;
|
|
50
|
+
}
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// 在密码学上下文里扫描形如 `name = <int>` / `name: <int>` 的常量赋值
|
|
55
|
+
function extractParamConstants(source) {
|
|
56
|
+
const hits = [];
|
|
57
|
+
const lines = source.split('\n');
|
|
58
|
+
// 上下文判定:文件名或内容已识别为标准算法(见 analyzeDomainParams)的前提下,
|
|
59
|
+
// 全文件扫描 PARAM_NAMES 的赋值(不再要求行内密码学关键词,避免过度漏报;
|
|
60
|
+
// 误报风险已由「算法标识识别」这一条件挡掉大部分非密码学文件)。
|
|
61
|
+
const paramRe = new RegExp('\\b(' + PARAM_NAMES.join('|') + ')\\s*[:=]\\s*([0-9]+)', 'g');
|
|
62
|
+
for (let li = 0; li < lines.length; li++) {
|
|
63
|
+
const line = lines[li];
|
|
64
|
+
let m;
|
|
65
|
+
while ((m = paramRe.exec(line))) {
|
|
66
|
+
hits.push({ name: m[1], value: parseInt(m[2], 10), line: li + 1, snippet: line.trim() });
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return hits;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function isKnownConstantFor(algo, name, value) {
|
|
73
|
+
const params = NIST_PARAMS[algo];
|
|
74
|
+
if (!params) return true; // 未知算法不报(保守)
|
|
75
|
+
if (!(name in params)) return true; // 该算法无此参数名不报(避免误伤无关常量)
|
|
76
|
+
return params[name] === value;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function analyzeDomainParams(source, filename) {
|
|
80
|
+
const algo = inferAlgorithm(filename, source);
|
|
81
|
+
const suspects = [];
|
|
82
|
+
if (!algo) {
|
|
83
|
+
// 文件名未识别为标准算法:不查表(双条件之一不满足)
|
|
84
|
+
return { file: filename, suspects, verdict: 'ok' };
|
|
85
|
+
}
|
|
86
|
+
const constants = extractParamConstants(source);
|
|
87
|
+
for (const c of constants) {
|
|
88
|
+
if (!isKnownConstantFor(algo, c.name, c.value)) {
|
|
89
|
+
suspects.push({
|
|
90
|
+
file: filename,
|
|
91
|
+
line: c.line,
|
|
92
|
+
param: c.name,
|
|
93
|
+
value: c.value,
|
|
94
|
+
note: `疑似参数错误: ${c.name}=${c.value} 不在 ${algo} 的 NIST 白名单中`,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return {
|
|
99
|
+
file: filename,
|
|
100
|
+
suspects,
|
|
101
|
+
verdict: suspects.length ? 'needs-human-review' : 'ok',
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
module.exports = { analyzeDomainParams, NIST_PARAMS, inferAlgorithm, extractParamConstants };
|
package/src/index.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/*
|
|
3
|
+
* index.js — 聚合四个检测器的统一入口
|
|
4
|
+
* 输出结构化报告(供 CLI / 其他工具消费)。
|
|
5
|
+
* 定位:启发式静态检查,标记「需人工核验」,不宣判漏洞。
|
|
6
|
+
*/
|
|
7
|
+
const ct = require('./constant-time');
|
|
8
|
+
const api = require('./api-misuse');
|
|
9
|
+
const cov = require('./test-coverage');
|
|
10
|
+
const dp = require('./domain-params');
|
|
11
|
+
|
|
12
|
+
function analyzeFile(source, filename) {
|
|
13
|
+
const ctRes = ct.analyzeConstantTime(source, filename);
|
|
14
|
+
const apiRes = api.analyzeApiMisuse(source, filename);
|
|
15
|
+
const dpRes = dp.analyzeDomainParams(source, filename);
|
|
16
|
+
return {
|
|
17
|
+
file: filename,
|
|
18
|
+
constantTime: ctRes,
|
|
19
|
+
apiMisuse: apiRes,
|
|
20
|
+
domainParams: dpRes,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function analyzeProject(sources /* [{filename, source}] */, rootDir) {
|
|
25
|
+
const perFile = sources.map((s) => analyzeFile(s.source, s.filename));
|
|
26
|
+
const srcPaths = sources.map((s) => s.filename);
|
|
27
|
+
const covRes = cov.analyzeTestCoverage(srcPaths, rootDir);
|
|
28
|
+
const allFindings = [];
|
|
29
|
+
for (const f of perFile) {
|
|
30
|
+
for (const x of f.constantTime.findings) allFindings.push({ type: 'constant-time', ...x });
|
|
31
|
+
for (const s of f.apiMisuse.suspects) allFindings.push({ type: 'api-misuse', ...s });
|
|
32
|
+
for (const d of f.domainParams.suspects) allFindings.push({ type: 'domain-params', ...d });
|
|
33
|
+
}
|
|
34
|
+
for (const u of covRes.uncovered) allFindings.push({ type: 'test-coverage', file: u.file, note: 'no corresponding test file' });
|
|
35
|
+
return { perFile, coverage: covRes, findings: allFindings };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
module.exports = { analyzeFile, analyzeProject };
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/*
|
|
3
|
+
* pattern-scanner.js — 基于 ast-scanner 的启发式模式匹配
|
|
4
|
+
* 检测「AI 易编造 / 易漏」的危险代码模式(命名来源透明):
|
|
5
|
+
* - secret-dependent branch / loop → 可能的时序侧信道(SPA/DPA 基础)
|
|
6
|
+
* - array/string index by secret → 缓存时序 / 越界推测
|
|
7
|
+
* - early return on failure → 可区分错误响应(时序/消息)
|
|
8
|
+
* - switch on secret byte → 经典非定常实现
|
|
9
|
+
* - comparison via != / !== on secret→ 可能非定常比较
|
|
10
|
+
* - comment declares secure but code branches on secret → 声明与实现矛盾
|
|
11
|
+
* 每个 pattern 给出 file/line/severity/justification,绝不臆断「一定是漏洞」。
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const { referencesSecret } = require('./ast-scanner');
|
|
15
|
+
|
|
16
|
+
// 判断一个条件是否可能是「秘密相关」(保守)
|
|
17
|
+
function conditionSecretRelated(cond, secretVars) {
|
|
18
|
+
if (!cond) return false;
|
|
19
|
+
return referencesSecret(cond, secretVars);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const PATTERNS = [
|
|
23
|
+
{
|
|
24
|
+
id: 'secret-dependent-branch',
|
|
25
|
+
severity: 'high',
|
|
26
|
+
desc: '分支条件引用了秘密变量(潜在时序/功率侧信道)',
|
|
27
|
+
test(ctx) {
|
|
28
|
+
const hits = [];
|
|
29
|
+
for (const b of ctx.branches) {
|
|
30
|
+
if (b.kind === 'if' || b.kind === 'else if' || b.kind === 'while' || b.kind === 'for' || b.kind === 'ternary') {
|
|
31
|
+
if (conditionSecretRelated(b.condition, ctx.secretVars)) {
|
|
32
|
+
hits.push({ line: lineOf(ctx, b.index), snippet: b.condition.slice(0, 80) });
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return hits;
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
id: 'array-index-by-secret',
|
|
41
|
+
severity: 'high',
|
|
42
|
+
desc: '数组/对象下标或切片使用了秘密变量(潜在缓存时序)',
|
|
43
|
+
test(ctx) {
|
|
44
|
+
const hits = [];
|
|
45
|
+
// 形如 x[secretExpr] 或 x[ ... secret ... ]
|
|
46
|
+
const idxRe = /\[\s*([^\]]*?)\s*\]/g;
|
|
47
|
+
let m;
|
|
48
|
+
const code = ctx.masked;
|
|
49
|
+
while ((m = idxRe.exec(code))) {
|
|
50
|
+
if (m.index === idxRe.lastIndex) idxRe.lastIndex++; // 防零宽死循环
|
|
51
|
+
const inside = m[1];
|
|
52
|
+
if (referencesSecret(inside, ctx.secretVars)) {
|
|
53
|
+
hits.push({ line: lineOf(ctx, m.index), snippet: m[0].slice(0, 80) });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return hits;
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
id: 'switch-on-secret',
|
|
61
|
+
severity: 'medium',
|
|
62
|
+
desc: 'switch 条件引用了秘密变量',
|
|
63
|
+
test(ctx) {
|
|
64
|
+
const hits = [];
|
|
65
|
+
for (const b of ctx.branches) {
|
|
66
|
+
if (b.kind === 'switch' && conditionSecretRelated(b.condition, ctx.secretVars)) {
|
|
67
|
+
hits.push({ line: lineOf(ctx, b.index), snippet: b.condition.slice(0, 80) });
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return hits;
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
id: 'declared-secure-but-branches-secret',
|
|
75
|
+
severity: 'medium',
|
|
76
|
+
desc: '注释声明安全/定常/constant-time,但代码存在秘密相关分支(声明与实现矛盾)',
|
|
77
|
+
test(ctx) {
|
|
78
|
+
const hits = [];
|
|
79
|
+
// 声明判定改为「就近」:仅当分支所在行前后 3 行内注释声明安全才判矛盾,
|
|
80
|
+
// 且排除反向说明(not / non- / TODO / FIXME / unsafe),避免 PKCS8 等被 secure 词边界误中
|
|
81
|
+
const lines = ctx.source.split('\n');
|
|
82
|
+
for (const b of ctx.branches) {
|
|
83
|
+
if (!(b.kind === 'if' || b.kind === 'else if' || b.kind === 'while' || b.kind === 'ternary')) continue;
|
|
84
|
+
if (!conditionSecretRelated(b.condition, ctx.secretVars)) continue;
|
|
85
|
+
const bLine = lineOf(ctx, b.index);
|
|
86
|
+
// bLine 是 1-based 行号;转 0-based 索引后向前 4 行 / 向后 1 行覆盖就近注释
|
|
87
|
+
const bIdx0 = bLine - 1;
|
|
88
|
+
const nearby = [];
|
|
89
|
+
for (let i = Math.max(0, bIdx0 - 4); i <= Math.min(lines.length - 1, bIdx0 + 1); i++) nearby.push(lines[i]);
|
|
90
|
+
const declaresSafe = nearby.some((ln) => {
|
|
91
|
+
const t = ln.trim();
|
|
92
|
+
if (/^\/\//.test(t)) {
|
|
93
|
+
const body = t.replace(/^\/\/\s*/, '');
|
|
94
|
+
// 反向说明优先排除
|
|
95
|
+
if (/\b(not|non[- ]?|TODO|FIXME|unsafe|insecure|downgrade)\b/i.test(body)) return false;
|
|
96
|
+
return /\b(constant[- ]?time|timing[- ]?safe|side[- ]?channel[- ]?free|secure|FIPS)\b/i.test(body);
|
|
97
|
+
}
|
|
98
|
+
return false;
|
|
99
|
+
});
|
|
100
|
+
if (declaresSafe) {
|
|
101
|
+
hits.push({ line: bLine, snippet: b.condition.slice(0, 80) });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return hits;
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
id: 'nonconstant-comparison',
|
|
109
|
+
severity: 'low',
|
|
110
|
+
desc: '疑似对秘密值使用非定常比较(!= / !== / == / ===),建议 ct-equal',
|
|
111
|
+
test(ctx) {
|
|
112
|
+
const hits = [];
|
|
113
|
+
// 按行扫描,避免长文本贪婪回溯
|
|
114
|
+
const lines = ctx.masked.split('\n');
|
|
115
|
+
for (let li = 0; li < lines.length; li++) {
|
|
116
|
+
const line = lines[li];
|
|
117
|
+
const re = /([!=]={1,2})\s*([^;\n]+)/g;
|
|
118
|
+
let mm;
|
|
119
|
+
while ((mm = re.exec(line))) {
|
|
120
|
+
if (referencesSecret(mm[2], ctx.secretVars)) {
|
|
121
|
+
hits.push({ line: li + 1, snippet: mm[0].slice(0, 80) });
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return hits;
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
];
|
|
129
|
+
|
|
130
|
+
function lineOf(ctx, index) {
|
|
131
|
+
return ctx.source.slice(0, index).split('\n').length;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function scanPatterns(scan) {
|
|
135
|
+
const findings = [];
|
|
136
|
+
for (const p of PATTERNS) {
|
|
137
|
+
let hits = [];
|
|
138
|
+
try { hits = p.test(scan) || []; } catch (e) { hits = [{ line: 0, snippet: 'PATTERN ERROR: ' + e.message }]; }
|
|
139
|
+
for (const h of hits) {
|
|
140
|
+
findings.push({
|
|
141
|
+
file: scan.filename,
|
|
142
|
+
line: h.line,
|
|
143
|
+
pattern: p.id,
|
|
144
|
+
severity: p.severity,
|
|
145
|
+
desc: p.desc,
|
|
146
|
+
snippet: h.snippet,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return findings;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
module.exports = { scanPatterns, PATTERNS };
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/*
|
|
3
|
+
* test-coverage.js — 检测「有实现但缺测试 / 测试与实现声明不符」启发式
|
|
4
|
+
* 方法:扫描某文件是否有对应测试文件、以及测试中是否真正调用被测函数。
|
|
5
|
+
* 极简:给定一组源文件路径,检查同目录或 test/ 下是否存在覆盖它的测试。
|
|
6
|
+
*/
|
|
7
|
+
const fs = require('fs');
|
|
8
|
+
const path = require('path');
|
|
9
|
+
|
|
10
|
+
function findTestFor(srcPath, rootDir) {
|
|
11
|
+
const base = path.basename(srcPath, path.extname(srcPath));
|
|
12
|
+
const candidates = [
|
|
13
|
+
path.join(path.dirname(srcPath), base + '.test' + path.extname(srcPath)),
|
|
14
|
+
path.join(path.dirname(srcPath), '__tests__', base + '.test' + path.extname(srcPath)),
|
|
15
|
+
path.join(rootDir, 'test', base + '.test' + path.extname(srcPath)),
|
|
16
|
+
path.join(rootDir, 'test', base + path.extname(srcPath)),
|
|
17
|
+
];
|
|
18
|
+
// Exclude self-match: when srcPath itself sits under a candidate path
|
|
19
|
+
// (e.g. source lives in test/), candidate 4 (`rootDir/test/base+ext`)
|
|
20
|
+
// resolves to srcPath itself, so fs.existsSync always returns true and
|
|
21
|
+
// `uncovered` is never populated. Drop that before the existence check.
|
|
22
|
+
// Compare *resolved* paths, not raw strings: a `./`-prefixed or otherwise
|
|
23
|
+
// unnormalized srcPath (e.g. `./test/foo.js` from `cli.js ./test`) used to
|
|
24
|
+
// slip past the string equality check and self-match.
|
|
25
|
+
// The bare `test/foo.js` candidate is intentionally kept: it matches
|
|
26
|
+
// Mocha-style default discovery (test/*.js); removing it would cause
|
|
27
|
+
// false negatives.
|
|
28
|
+
return candidates
|
|
29
|
+
.filter((c) => path.resolve(c) !== path.resolve(srcPath))
|
|
30
|
+
.find((c) => fs.existsSync(c)) || null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function analyzeTestCoverage(srcPaths, rootDir) {
|
|
34
|
+
const uncovered = [];
|
|
35
|
+
for (const p of srcPaths) {
|
|
36
|
+
const t = findTestFor(p, rootDir);
|
|
37
|
+
if (!t) uncovered.push({ file: p, test: null });
|
|
38
|
+
}
|
|
39
|
+
return { uncovered, verdict: uncovered.length ? 'needs-human-review' : 'ok' };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
module.exports = { analyzeTestCoverage, findTestFor };
|