euthyna 0.1.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.
@@ -0,0 +1,399 @@
1
+ /**
2
+ * History facts: what did this change delete, and where did the deleted code come from?
3
+ *
4
+ * The upstream methodology states the rule this mechanises: code deleted by a
5
+ * commit whose message says it was a security fix is a regression risk, and code
6
+ * that was removed and is now being added back is a reintroduction. Both are
7
+ * answerable from git alone, deterministically, and both are things a language
8
+ * model guesses at rather than measures.
9
+ *
10
+ * This module produces facts. It does not decide whether a regression happened.
11
+ */
12
+ import { git, commitSummaries } from '../git.js';
13
+ import { makeFact, notEvaluated as notEvaluatedEntry, KIND, STATUS, shellQuote } from '../contract.js';
14
+
15
+ /**
16
+ * Strong signal: the commit is about security.
17
+ *
18
+ * Tuned asymmetrically on purpose. The two ways this classifier can be wrong do
19
+ * not cost the same: over-classifying yields a noisy report a reader dismisses,
20
+ * while under-classifying hides exactly the deleted-code-origin case this tool
21
+ * exists to surface. So it errs broad across vocabulary that is unambiguously
22
+ * security, and stops there - it does not guess from general words such as
23
+ * "validate" or "check", which appear in ordinary refactors.
24
+ */
25
+ const SECURITY_PATTERN =
26
+ /\bcve-\d{4}-\d+\b|\bsecurity\b|\bvuln(?:erabilit(?:y|ies))?\b|\bexploit\b|\bxss\b|\bcsrf\b|\bssrf\b|\binjection\b|\bsanitiz|\bpermission\b|\bauthoriz|\bauthenticat|\bauthn?\b|\boauth\b|\bcredential|\bprivileg|\baccess control\b|\bhardening\b|\bmalicious\b|\bmalware\b|\bunsafe\b|漏洞|安全/i;
27
+
28
+ /** Weak signal: an ordinary fix. Worth reporting, weaker than the above. */
29
+ const FIX_PATTERN = /^\s*(?:fix|bugfix|hotfix|patch)\b|\bfixes\b|\bfixed\b|\bfixing\b|\bpatches\b|\brevert/i;
30
+
31
+ /** Longest-first so a longer added line is preferred over a substring of it. */
32
+ const MIN_PICKAXE_LINE_LENGTH = 12;
33
+
34
+ /**
35
+ * Parse `git diff --unified=0` hunk headers into deleted line ranges.
36
+ * With --unified=0 every hunk is exactly the changed lines, so a hunk's old
37
+ * range is precisely the set of lines this change removed.
38
+ *
39
+ * @returns {Array<{start: number, count: number}>}
40
+ */
41
+ export function parseDeletedRanges(diffText) {
42
+ const ranges = [];
43
+ const hunk = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/;
44
+ for (const line of diffText.split('\n')) {
45
+ const match = hunk.exec(line);
46
+ if (!match) continue;
47
+ const start = Number(match[1]);
48
+ // A missing count means 1; a count of 0 means pure insertion, so nothing was deleted.
49
+ const count = match[2] === undefined ? 1 : Number(match[2]);
50
+ if (count > 0) ranges.push({ start, count });
51
+ }
52
+ return ranges;
53
+ }
54
+
55
+ /**
56
+ * Attribute every line in `ranges` to the commit that last touched it, as of `rev`.
57
+ *
58
+ * Returns a Map of full commit hash -> array of line numbers in `rev`. Line
59
+ * numbers rather than a bare count, because the fact has to carry a command a
60
+ * reader can actually paste: `git blame -L 104,104` reproduces the claim, while
61
+ * `git blame -L <deleted-range>` is a placeholder, not evidence.
62
+ */
63
+ export async function blameRanges({ cwd, rev, file, ranges }) {
64
+ const byCommit = new Map();
65
+ for (const range of ranges) {
66
+ const end = range.start + range.count - 1;
67
+ const out = await git(
68
+ ['blame', '--porcelain', '-L', `${range.start},${end}`, rev, '--', file],
69
+ { cwd, allowFailure: true }
70
+ );
71
+ if (!out) continue;
72
+
73
+ let current = null;
74
+ // Porcelain emits `<sha> <origLine> <finalLine> [<groupSize>]`. `origLine`
75
+ // is the line number in the commit that introduced the line, which is NOT
76
+ // where the line sits in the revision being blamed; `finalLine` is. Using
77
+ // the wrong one produces a command that looks right and blames other lines.
78
+ let lineNumber = null;
79
+ for (const raw of out.split('\n')) {
80
+ const header = /^([0-9a-f]{40}) (\d+) (\d+)(?: (\d+))?$/.exec(raw);
81
+ if (header) {
82
+ current = header[1];
83
+ lineNumber = Number(header[3]);
84
+ continue;
85
+ }
86
+ if (raw.startsWith('\t') && current && lineNumber !== null) {
87
+ const list = byCommit.get(current) ?? [];
88
+ list.push(lineNumber);
89
+ byCommit.set(current, list);
90
+ lineNumber++;
91
+ }
92
+ }
93
+ }
94
+ return byCommit;
95
+ }
96
+
97
+ /**
98
+ * Compress sorted line numbers into inclusive ranges, so a command can name
99
+ * them as `git blame -L 104,104 -L 114,114` instead of enumerating every line.
100
+ */
101
+ export function compressRanges(lineNumbers) {
102
+ const sorted = [...new Set(lineNumbers)].sort((a, b) => a - b);
103
+ const ranges = [];
104
+ for (const line of sorted) {
105
+ const last = ranges[ranges.length - 1];
106
+ if (last && line === last.end + 1) last.end = line;
107
+ else ranges.push({ start: line, end: line });
108
+ }
109
+ return ranges;
110
+ }
111
+
112
+ /** Classify a commit subject. Returns 'security', 'fix', or 'none'. */
113
+ export function classifySubject(subject, { securityPattern = SECURITY_PATTERN, fixPattern = FIX_PATTERN } = {}) {
114
+ if (!subject) return 'none';
115
+ if (securityPattern.test(subject)) return 'security';
116
+ if (fixPattern.test(subject)) return 'fix';
117
+ return 'none';
118
+ }
119
+
120
+ /**
121
+ * Collect history facts for a revision range.
122
+ *
123
+ * @param {object} options
124
+ * @param {string} options.cwd
125
+ * @param {string} options.base
126
+ * @param {string} options.head
127
+ * @param {boolean} [options.pickaxe] also look for reintroduced lines
128
+ * @param {number} [options.maxPickaxe] cap on pickaxe probes (reported when hit)
129
+ */
130
+ export async function collectHistoryFacts({
131
+ cwd,
132
+ base,
133
+ head = 'HEAD',
134
+ pickaxe = false,
135
+ maxPickaxe = 40,
136
+ securityPattern = SECURITY_PATTERN,
137
+ fixPattern = FIX_PATTERN
138
+ } = {}) {
139
+ const facts = [];
140
+ const evaluated = [];
141
+ const notEvaluated = [];
142
+ let counter = 0;
143
+
144
+ // NUL-separated so filenames containing newlines survive.
145
+ const fileList = await git(['diff', '--name-only', '-z', `${base}..${head}`], { cwd });
146
+ const files = fileList.split('\0').filter(Boolean);
147
+
148
+ if (files.length === 0) {
149
+ notEvaluated.push(
150
+ notEvaluatedEntry('history', `范围 ${base}..${head} 内没有文件变更,没有可归属的删除行`)
151
+ );
152
+ return { facts, evaluated, notEvaluated, files, measured: true };
153
+ }
154
+
155
+ // Deleted code whose origin commit was itself removed by the range is not
156
+ // attributable to an earlier commit, so it is reported rather than dropped.
157
+ const blameByCommit = new Map();
158
+ const linesByFile = new Map();
159
+
160
+ for (const file of files) {
161
+ const diff = await git(['diff', '--unified=0', `${base}..${head}`, '--', file], { cwd });
162
+ const ranges = parseDeletedRanges(diff);
163
+ if (ranges.length === 0) continue;
164
+
165
+ const deletedLines = ranges.reduce((sum, r) => sum + r.count, 0);
166
+ linesByFile.set(file, { ranges, deletedLines });
167
+
168
+ const byCommit = await blameRanges({ cwd, rev: base, file, ranges });
169
+ for (const [hash, lineNumbers] of byCommit) {
170
+ const entry = blameByCommit.get(hash) ?? { lines: 0, byFile: new Map() };
171
+ const existing = entry.byFile.get(file) ?? [];
172
+ entry.byFile.set(file, [...existing, ...lineNumbers]);
173
+ entry.lines += lineNumbers.length;
174
+ blameByCommit.set(hash, entry);
175
+ }
176
+ }
177
+
178
+ evaluated.push({
179
+ kind: KIND.HISTORY,
180
+ producer: 'euthyna-history',
181
+ count: [...linesByFile.values()].reduce((sum, v) => sum + v.deletedLines, 0)
182
+ });
183
+
184
+ if (linesByFile.size === 0) {
185
+ // Files changed but nothing was deleted. Saying nothing here would render as
186
+ // "no facts", which a reader could mistake for "measured and clean".
187
+ notEvaluated.push(
188
+ notEvaluatedEntry(
189
+ 'history',
190
+ `范围 ${base}..${head} 内有 ${files.length} 个文件变更,但没有任何一行被删除,` +
191
+ '因此没有可归属的历史来源'
192
+ )
193
+ );
194
+ }
195
+
196
+ const summaries = await commitSummaries(cwd, [...blameByCommit.keys()]);
197
+
198
+ // Report the security-relevant origins first: they are what an adjudicator
199
+ // must look at, and the ordering is stable so two runs produce the same report.
200
+ const ranked = [...blameByCommit.entries()]
201
+ .map(([hash, entry]) => {
202
+ const summary = summaries.get(hash) ?? { subject: '(提交信息不可读)', author: null, date: null };
203
+ return { hash, entry, summary, classification: classifySubject(summary.subject, { securityPattern, fixPattern }) };
204
+ })
205
+ .sort((a, b) => rank(a.classification) - rank(b.classification) || b.entry.lines - a.entry.lines);
206
+
207
+ for (const { hash, entry, summary, classification } of ranked) {
208
+ const byFile = [...entry.byFile.entries()]
209
+ .map(([file, lineNumbers]) => ({
210
+ file,
211
+ lines: lineNumbers.length,
212
+ ranges: compressRanges(lineNumbers)
213
+ }))
214
+ .sort((a, b) => b.lines - a.lines || a.file.localeCompare(b.file));
215
+
216
+ const fileCount = byFile.length;
217
+ // Say how many files the lines are spread over. Reporting a total against a
218
+ // single file path reads as "all of these are here", which sends a reader to
219
+ // the wrong place and makes an accurate attribution look wrong.
220
+ const statement =
221
+ `本次变更删除了 ${entry.lines} 行来自提交 ${hash.slice(0, 10)} 的代码` +
222
+ (fileCount > 1 ? `,分布在 ${fileCount} 个文件` : `(文件:${byFile[0].file})`) +
223
+ `。提交信息:${JSON.stringify(summary.subject)},分类:${classification}`;
224
+
225
+ // Multiple -L flags reproduce every attributed line in the primary file.
226
+ const primary = byFile[0];
227
+ const lineFlags = primary.ranges.map(r => `-L ${r.start},${r.end}`).join(' ');
228
+
229
+ facts.push(
230
+ makeFact({
231
+ id: `history-${++counter}`,
232
+ kind: KIND.HISTORY,
233
+ statement,
234
+ status: STATUS.ESTABLISHED,
235
+ evidence: { file: primary.file, commit: hash, files: byFile.map(f => f.file) },
236
+ method: 'command',
237
+ command: `git blame --porcelain ${lineFlags} ${base} -- ${shellQuote(primary.file)}`,
238
+ detail: {
239
+ classification,
240
+ commitSubject: summary.subject,
241
+ commitAuthor: summary.author,
242
+ commitDate: summary.date,
243
+ blamedLines: entry.lines,
244
+ byFile,
245
+ // Stated explicitly so a reader is not left thinking the command above
246
+ // covers lines in the other files too.
247
+ reproductionNote:
248
+ fileCount > 1
249
+ ? `上面的命令只复现「${primary.file}」中的归属;其余 ${fileCount - 1} 个文件的行区间见 byFile`
250
+ : '上面的命令复现本事实涉及的全部行'
251
+ }
252
+ })
253
+ );
254
+ }
255
+
256
+ if (ranked.length === 0 && linesByFile.size > 0) {
257
+ notEvaluated.push(
258
+ notEvaluatedEntry('history', '有删除行但 blame 未能归属到任何提交(可能是二进制文件或路径异常)')
259
+ );
260
+ }
261
+
262
+ if (pickaxe) {
263
+ const reintro = await collectReintroductionFacts({
264
+ cwd,
265
+ base,
266
+ head,
267
+ files,
268
+ maxPickaxe,
269
+ securityPattern,
270
+ fixPattern,
271
+ startCounter: counter
272
+ });
273
+ facts.push(...reintro.facts);
274
+ counter = reintro.counter;
275
+ if (reintro.evaluated) evaluated.push(reintro.evaluated);
276
+ notEvaluated.push(...reintro.notEvaluated);
277
+ } else {
278
+ notEvaluated.push(
279
+ notEvaluatedEntry('reintroduction', '未启用 --pickaxe,未检查「被移除又加回」的代码行')
280
+ );
281
+ }
282
+
283
+ return { facts, evaluated, notEvaluated, files, measured: true };
284
+ }
285
+
286
+ function rank(classification) {
287
+ if (classification === 'security') return 0;
288
+ if (classification === 'fix') return 1;
289
+ return 2;
290
+ }
291
+
292
+ /**
293
+ * Reintroduction: an added line that exists nowhere at `base`, yet `git log -S`
294
+ * reports commits that changed its occurrence count before `base`.
295
+ *
296
+ * The inference is sound rather than heuristic: the line is absent at base and
297
+ * present at head, so any earlier commit that changed its count must have
298
+ * removed it. Adding it back is the reintroduction.
299
+ */
300
+ async function collectReintroductionFacts({
301
+ cwd,
302
+ base,
303
+ head,
304
+ files,
305
+ maxPickaxe,
306
+ securityPattern,
307
+ fixPattern,
308
+ startCounter
309
+ }) {
310
+ const facts = [];
311
+ const notEvaluated = [];
312
+ let counter = startCounter;
313
+ let probed = 0;
314
+ let candidates = 0;
315
+
316
+ for (const file of files) {
317
+ const diff = await git(['diff', '--unified=0', `${base}..${head}`, '--', file], { cwd });
318
+ const added = diff
319
+ .split('\n')
320
+ .filter(line => line.startsWith('+') && !line.startsWith('+++'))
321
+ .map(line => line.slice(1));
322
+
323
+ // Longest first: a longer line is the more specific probe.
324
+ const unique = [...new Set(added.map(l => l.trim()))]
325
+ .filter(l => l.length >= MIN_PICKAXE_LINE_LENGTH)
326
+ .sort((a, b) => b.length - a.length);
327
+ candidates += unique.length;
328
+
329
+ for (const line of unique) {
330
+ if (probed >= maxPickaxe) break;
331
+ probed++;
332
+
333
+ const log = await git(
334
+ ['log', '--format=%H', `-S${line}`, base, '--', file],
335
+ { cwd, allowFailure: true }
336
+ );
337
+ const hashes = log.split('\n').map(s => s.trim()).filter(Boolean);
338
+ if (hashes.length === 0) continue;
339
+
340
+ // Present at head by construction; confirm it is genuinely absent at base.
341
+ const atBase = await git(['grep', '-F', '-q', '-e', line, base, '--', file], {
342
+ cwd,
343
+ allowFailure: true
344
+ });
345
+ if (atBase.trim()) continue;
346
+
347
+ const summaries = await commitSummaries(cwd, hashes.slice(0, 5));
348
+ const classified = [...summaries.entries()].map(([hash, summary]) => ({
349
+ hash,
350
+ summary,
351
+ classification: classifySubject(summary.subject, { securityPattern, fixPattern })
352
+ }));
353
+ const securityOrigin = classified.find(c => c.classification === 'security');
354
+
355
+ facts.push(
356
+ makeFact({
357
+ id: `reintroduction-${++counter}`,
358
+ kind: KIND.REINTRODUCTION,
359
+ statement:
360
+ `本次变更加回了一行在 ${base} 中不存在的代码,而该行的出现次数曾被 ${hashes.length} 个提交改变` +
361
+ (securityOrigin
362
+ ? `,其中提交 ${securityOrigin.hash.slice(0, 10)} 的提交信息含安全关键词`
363
+ : ''),
364
+ status: STATUS.ESTABLISHED,
365
+ evidence: { file, snippet: line.slice(0, 160), commit: hashes[0] },
366
+ method: 'command',
367
+ command: `git log --format=%H -S${shellQuote(line.slice(0, 60))} ${base} -- ${shellQuote(file)}`,
368
+ detail: {
369
+ line: line.slice(0, 400),
370
+ candidateCommits: classified.map(c => ({
371
+ hash: c.hash,
372
+ subject: c.summary.subject,
373
+ classification: c.classification
374
+ })),
375
+ securityOrigin: securityOrigin ? securityOrigin.hash : null
376
+ }
377
+ })
378
+ );
379
+ }
380
+
381
+ if (probed >= maxPickaxe) break;
382
+ }
383
+
384
+ if (probed >= maxPickaxe && candidates > probed) {
385
+ notEvaluated.push(
386
+ notEvaluatedEntry(
387
+ 'reintroduction',
388
+ `pickaxe 探针上限 ${maxPickaxe} 已用尽,另有 ${candidates - probed} 行新增代码未检查`
389
+ )
390
+ );
391
+ }
392
+
393
+ return {
394
+ facts,
395
+ counter,
396
+ evaluated: { kind: KIND.REINTRODUCTION, producer: 'euthyna-history', count: probed },
397
+ notEvaluated
398
+ };
399
+ }
package/src/git.js ADDED
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Thin wrapper around the `git` binary.
3
+ *
4
+ * Deliberately shells out to git rather than using a library: git is the
5
+ * authority on git semantics, and a library would be a dependency the audit
6
+ * itself would have to trust.
7
+ */
8
+ import { execFile } from 'node:child_process';
9
+ import { promisify } from 'node:util';
10
+
11
+ import { safeText, shellQuote } from './contract.js';
12
+
13
+ const execFileAsync = promisify(execFile);
14
+
15
+ // Large repositories produce large diffs; the default 1 MB buffer is not enough.
16
+ const MAX_BUFFER = 256 * 1024 * 1024;
17
+
18
+ /**
19
+ * The message for a failed git invocation.
20
+ *
21
+ * Two transforms, both required. The argv is shell-quoted so the message can be
22
+ * pasted back into a shell to reproduce the failure — the same shellQuote the
23
+ * emitted commands use; an unquoted argv with a hostile filename in it would
24
+ * execute as code when the reader does exactly that. The assembled message is
25
+ * then made terminal-safe as a whole: the argv itself carries repo-controlled
26
+ * filenames in the measurement flows (git diff -- <file>), and quoting alone
27
+ * does not neutralize a control byte — a BEL inside single quotes still beeps.
28
+ * git's stderr gets the same treatment because git embeds repository-controlled
29
+ * text (filenames, refs) in its messages.
30
+ *
31
+ * Exported so both transforms are testable against hostile input directly: a
32
+ * repository carrying a control byte in a filename cannot be built on every
33
+ * host this suite runs on.
34
+ */
35
+ export function gitFailureMessage(args, detail) {
36
+ const argv = args.map(shellQuote).join(' ');
37
+ return safeText(`git ${argv} failed: ${detail}`);
38
+ }
39
+
40
+ /**
41
+ * Run a git command and return stdout. Throws on a non-zero exit.
42
+ *
43
+ * @param {string[]} args
44
+ * @param {{cwd?: string, allowFailure?: boolean}} [options]
45
+ * @returns {Promise<string>} stdout, with the trailing newline preserved
46
+ */
47
+ export async function git(args, options = {}) {
48
+ const { cwd, allowFailure = false } = options;
49
+ try {
50
+ const { stdout } = await execFileAsync('git', args, {
51
+ cwd,
52
+ maxBuffer: MAX_BUFFER,
53
+ encoding: 'utf8',
54
+ // Keep output byte-exact: a security fact must not be altered by locale.
55
+ env: { ...process.env, LC_ALL: 'C' }
56
+ });
57
+ return stdout;
58
+ } catch (error) {
59
+ if (allowFailure) return '';
60
+ // git writes its own diagnosis to stderr; when it wrote none, the exit
61
+ // status is all there is. error.message is deliberately not used: it
62
+ // embeds the argv unquoted, which is the shape this path exists to remove.
63
+ const stderr = (error.stderr || '').trim();
64
+ const detail = stderr || `exit status ${error.code ?? error.signal ?? 'unknown'}`;
65
+ throw new Error(gitFailureMessage(args, detail));
66
+ }
67
+ }
68
+
69
+ /** Resolve the repository top level, or null when cwd is not inside a repo. */
70
+ export async function repoToplevel(cwd) {
71
+ const out = await git(['rev-parse', '--show-toplevel'], { cwd, allowFailure: true });
72
+ return out.trim() || null;
73
+ }
74
+
75
+ /** Resolve a revision to a full commit hash, or null when it does not exist. */
76
+ export async function revParse(cwd, rev) {
77
+ const out = await git(['rev-parse', '--verify', `${rev}^{commit}`], {
78
+ cwd,
79
+ allowFailure: true
80
+ });
81
+ return out.trim() || null;
82
+ }
83
+
84
+ /**
85
+ * One-line summaries for a set of commits, as a Map of hash -> {subject, author, date}.
86
+ * Uses a single `git log` invocation so the cost does not scale per commit.
87
+ */
88
+ export async function commitSummaries(cwd, hashes) {
89
+ const result = new Map();
90
+ if (hashes.length === 0) return result;
91
+
92
+ // A NUL-separated record format survives commit subjects containing any
93
+ // character except NUL, so subjects with newlines or pipes stay intact.
94
+ const out = await git(
95
+ ['log', '--no-walk', '--format=%H%x00%s%x00%an%x00%cI%x00%x00', ...hashes],
96
+ { cwd, allowFailure: true }
97
+ );
98
+
99
+ for (const record of out.split('\0\0')) {
100
+ const trimmed = record.replace(/^\n+/, '');
101
+ if (!trimmed) continue;
102
+ const [hash, subject, author, date] = trimmed.split('\0');
103
+ if (!hash) continue;
104
+ result.set(hash, { subject, author, date });
105
+ }
106
+ return result;
107
+ }