euthyna 0.1.0 → 0.2.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/.agents/skills/euthyna/SKILL.md +256 -0
- package/.agents/skills/euthyna/references/bug-classes.md +130 -0
- package/.agents/skills/euthyna/references/change-audit.md +219 -0
- package/.agents/skills/euthyna/references/dependency-audit.md +116 -0
- package/.agents/skills/euthyna/references/fact-contract.md +129 -0
- package/.agents/skills/euthyna/references/fact-producers.md +274 -0
- package/.agents/skills/euthyna/references/meta-mechanisms.md +173 -0
- package/.agents/skills/euthyna/references/verification-gates.md +293 -0
- package/LICENSE +202 -202
- package/README.md +65 -17
- package/cordis.patch.yml +4 -0
- package/package.json +23 -3
- package/plugin/index.js +60 -0
- package/src/cli.js +157 -6
- package/src/contract.js +2 -1
- package/src/facts/coverage.js +55 -2
- package/src/facts/deps.js +366 -0
- package/src/facts/history.js +322 -25
- package/src/gate.js +381 -0
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dependency facts: what versions does the lockfile actually pin?
|
|
3
|
+
*
|
|
4
|
+
* This closes the INCONCLUSIVE gap for supply-chain claims (issue #8). A claim
|
|
5
|
+
* like "the app depends on a vulnerable version of X" cannot be adjudicated
|
|
6
|
+
* without knowing what version is actually in the tree, and the lockfile is the
|
|
7
|
+
* deterministic ground truth for that. A model can guess at it from package.json
|
|
8
|
+
* ranges; this producer reads the lockfile and reports the pinned versions.
|
|
9
|
+
*
|
|
10
|
+
* It never emits "vulnerable", "risky" or a severity: mapping a version to a CVE
|
|
11
|
+
* is the adjudication layer's job, and the fact contract forbids verdict fields.
|
|
12
|
+
* Absence is handled honestly too — a dep missing from a well-formed lockfile is
|
|
13
|
+
* an established "not in the resolved tree" fact, never a silent skip.
|
|
14
|
+
*
|
|
15
|
+
* Tier 0 scope is three formats, all parseable with zero dependencies:
|
|
16
|
+
* - package-lock.json (npm v1/v2/v3)
|
|
17
|
+
* - Cargo.lock (Rust)
|
|
18
|
+
* - go.mod (Go; the DECLARED requirement version, not the resolved one —
|
|
19
|
+
* go.sum has no versions, so this producer says "declared",
|
|
20
|
+
* never "locked", for Go)
|
|
21
|
+
* Anything else found at auto-detect (pnpm/yarn/poetry/...) is reported as
|
|
22
|
+
* notEvaluated with the filenames named, rather than guessed at.
|
|
23
|
+
*/
|
|
24
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
25
|
+
import path from 'node:path';
|
|
26
|
+
import { makeFact, notEvaluated as notEvaluatedEntry, KIND, STATUS, shellQuote } from '../contract.js';
|
|
27
|
+
|
|
28
|
+
/** Formats this producer can parse, and the filenames that select them. */
|
|
29
|
+
const SUPPORTED = [
|
|
30
|
+
{ file: 'package-lock.json', type: 'npm' },
|
|
31
|
+
{ file: 'Cargo.lock', type: 'cargo' },
|
|
32
|
+
{ file: 'go.mod', type: 'go' }
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
/** Recognised formats we deliberately do not parse yet, named in the notEvaluated reason. */
|
|
36
|
+
const UNSUPPORTED = [
|
|
37
|
+
'pnpm-lock.yaml',
|
|
38
|
+
'yarn.lock',
|
|
39
|
+
'poetry.lock',
|
|
40
|
+
'composer.lock',
|
|
41
|
+
'Gemfile.lock',
|
|
42
|
+
'bun.lockb',
|
|
43
|
+
'bun.lock'
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
/** 1-indexed line of the first line containing `needle`, or null. */
|
|
47
|
+
function lineOf(text, needle) {
|
|
48
|
+
return lineOfAt(text, text.indexOf(needle));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** 1-indexed line of the absolute text index, or null when it is not found. */
|
|
52
|
+
function lineOfAt(text, index) {
|
|
53
|
+
if (index < 0) return null;
|
|
54
|
+
let line = 1;
|
|
55
|
+
for (let i = 0; i < index; i++) {
|
|
56
|
+
if (text[i] === '\n') line++;
|
|
57
|
+
}
|
|
58
|
+
return line;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* npm package-lock.json, any lockfileVersion. Returns [{version, location, line}].
|
|
63
|
+
*
|
|
64
|
+
* v2/v3: `packages` maps "node_modules/<name>" (possibly nested, and possibly
|
|
65
|
+
* scoped "@scope/name") to metadata; the package name is the path segment after
|
|
66
|
+
* the last "node_modules/". v1: a recursive `dependencies` tree.
|
|
67
|
+
*/
|
|
68
|
+
function resolveNpm(root, dep, text) {
|
|
69
|
+
const found = [];
|
|
70
|
+
|
|
71
|
+
if (root.packages && typeof root.packages === 'object') {
|
|
72
|
+
for (const [key, meta] of Object.entries(root.packages)) {
|
|
73
|
+
if (!key.includes('node_modules/')) continue;
|
|
74
|
+
if (!meta || typeof meta !== 'object' || !meta.version) continue;
|
|
75
|
+
if (key.split('node_modules/').pop() !== dep) continue;
|
|
76
|
+
found.push({ version: meta.version, location: key, line: lineOf(text, `"${key}":`) });
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (root.dependencies && typeof root.dependencies === 'object') {
|
|
81
|
+
const walk = (tree) => {
|
|
82
|
+
for (const [name, meta] of Object.entries(tree)) {
|
|
83
|
+
if (!meta || typeof meta !== 'object') continue;
|
|
84
|
+
if (name === dep && meta.version) {
|
|
85
|
+
// Anchor at this dep's own block: a same-version string earlier in the
|
|
86
|
+
// file (some other package) must not hijack the evidence pointer.
|
|
87
|
+
const blockAt = text.indexOf(`"${name}":`);
|
|
88
|
+
const verAt = blockAt < 0 ? -1 : text.indexOf(`"version": "${meta.version}"`, blockAt);
|
|
89
|
+
found.push({
|
|
90
|
+
version: meta.version,
|
|
91
|
+
location: `dependencies.${name}`,
|
|
92
|
+
line: lineOfAt(text, verAt)
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
if (meta.dependencies && typeof meta.dependencies === 'object') walk(meta.dependencies);
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
walk(root.dependencies);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return found;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Cargo.lock: [[package]] blocks with `name = "..."` and `version = "..."`. */
|
|
105
|
+
function resolveCargo(text, dep) {
|
|
106
|
+
const found = [];
|
|
107
|
+
for (const block of text.split('[[package]]')) {
|
|
108
|
+
const name = /^\s*name\s*=\s*"([^"]+)"/m.exec(block);
|
|
109
|
+
const version = /^\s*version\s*=\s*"([^"]+)"/m.exec(block);
|
|
110
|
+
if (!name || !version || name[1] !== dep) continue;
|
|
111
|
+
// Package names are unique in a Cargo.lock, so anchoring the version line
|
|
112
|
+
// at this dep's own name block keeps the pointer on this crate even when a
|
|
113
|
+
// different crate earlier in the file pins the same version string.
|
|
114
|
+
const nameAt = text.indexOf(`name = "${dep}"`);
|
|
115
|
+
const verAt = nameAt < 0 ? -1 : text.indexOf(`version = "${version[1]}"`, nameAt);
|
|
116
|
+
found.push({
|
|
117
|
+
version: version[1],
|
|
118
|
+
location: `[[package]] ${dep}`,
|
|
119
|
+
line: lineOfAt(text, verAt)
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
return found;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** go.mod require lines, single or inside a require (...) block. */
|
|
126
|
+
function resolveGo(text, dep) {
|
|
127
|
+
const found = [];
|
|
128
|
+
let inBlock = false;
|
|
129
|
+
text.split('\n').forEach((raw, index) => {
|
|
130
|
+
const line = raw.trim();
|
|
131
|
+
if (line.startsWith('require (')) {
|
|
132
|
+
inBlock = true;
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
if (line === ')') {
|
|
136
|
+
inBlock = false;
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
let match = /^require\s+(\S+)\s+(v[\w.+-]+)/.exec(line);
|
|
140
|
+
if (!match && inBlock) match = /^(\S+)\s+(v[\w.+-]+)/.exec(line);
|
|
141
|
+
if (!match || match[1] !== dep) return;
|
|
142
|
+
found.push({ version: match[2], location: `require ${dep}`, line: index + 1 });
|
|
143
|
+
});
|
|
144
|
+
return found;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Build a per-dependency resolver for a parsed manifest, or throw on a format
|
|
149
|
+
* that cannot be read. The throw happens once, up front, so a malformed file
|
|
150
|
+
* becomes one notEvaluated reason instead of one failed fact per dependency.
|
|
151
|
+
*/
|
|
152
|
+
function makeResolver(type, text) {
|
|
153
|
+
if (type === 'npm') {
|
|
154
|
+
const root = JSON.parse(text); // throws on malformed JSON
|
|
155
|
+
// A syntactically valid but structurally empty file (e.g. `{}`) is not a
|
|
156
|
+
// lockfile; treating it as one would report every dependency as "absent"
|
|
157
|
+
// and exit clean on garbage.
|
|
158
|
+
if (
|
|
159
|
+
!root ||
|
|
160
|
+
typeof root !== 'object' ||
|
|
161
|
+
(root.packages === undefined && root.dependencies === undefined && root.lockfileVersion === undefined)
|
|
162
|
+
) {
|
|
163
|
+
throw new Error('package-lock.json 结构不完整(缺 lockfileVersion/packages/dependencies),不像真实的 npm 锁文件');
|
|
164
|
+
}
|
|
165
|
+
return (dep) => resolveNpm(root, dep, text);
|
|
166
|
+
}
|
|
167
|
+
if (type === 'cargo') {
|
|
168
|
+
if (!/\[\[package\]\]|^version\s*=/m.test(text)) {
|
|
169
|
+
throw new Error('Cargo.lock 结构不完整(无 [[package]] 块或 version 头),不像真实的 Cargo 锁文件');
|
|
170
|
+
}
|
|
171
|
+
return (dep) => resolveCargo(text, dep);
|
|
172
|
+
}
|
|
173
|
+
if (type === 'go') {
|
|
174
|
+
if (!/^module\s+\S+/m.test(text)) {
|
|
175
|
+
throw new Error('go.mod 结构不完整(无 module 行),不像真实的 go.mod');
|
|
176
|
+
}
|
|
177
|
+
return (dep) => resolveGo(text, dep);
|
|
178
|
+
}
|
|
179
|
+
throw new Error(`不受支持的依赖清单格式: ${type}`);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function typeForFile(name) {
|
|
183
|
+
return SUPPORTED.find((s) => s.file === name)?.type ?? null;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function detectIn(cwd) {
|
|
187
|
+
const found = [];
|
|
188
|
+
for (const spec of SUPPORTED) {
|
|
189
|
+
const filePath = path.join(cwd, spec.file);
|
|
190
|
+
try {
|
|
191
|
+
await stat(filePath);
|
|
192
|
+
found.push({ ...spec, path: filePath });
|
|
193
|
+
} catch {
|
|
194
|
+
// not present
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return found;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async function unsupportedIn(cwd) {
|
|
201
|
+
const seen = [];
|
|
202
|
+
for (const name of UNSUPPORTED) {
|
|
203
|
+
try {
|
|
204
|
+
await stat(path.join(cwd, name));
|
|
205
|
+
seen.push(name);
|
|
206
|
+
} catch {
|
|
207
|
+
// not present
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return seen;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Collect dependency facts.
|
|
215
|
+
*
|
|
216
|
+
* `measured` is false exactly when no lockfile could be read or parsed — never
|
|
217
|
+
* when a dep is simply absent from a readable lockfile (that absence IS the
|
|
218
|
+
* measurement, and the fact records it).
|
|
219
|
+
*
|
|
220
|
+
* @param {object} options
|
|
221
|
+
* @param {string} [options.cwd] directory to auto-detect in
|
|
222
|
+
* @param {string} [options.lockfile] explicit manifest path (overrides detection)
|
|
223
|
+
* @param {string[]} options.deps dependency names to query (repeatable)
|
|
224
|
+
*/
|
|
225
|
+
export async function collectDependencyFacts({ cwd = process.cwd(), lockfile, deps = [] } = {}) {
|
|
226
|
+
const facts = [];
|
|
227
|
+
const evaluated = [];
|
|
228
|
+
const notEvaluated = [];
|
|
229
|
+
let counter = 0;
|
|
230
|
+
|
|
231
|
+
if (deps.length === 0) {
|
|
232
|
+
notEvaluated.push(notEvaluatedEntry(KIND.DEPENDENCY, '没有指定要查询的依赖(--dep)'));
|
|
233
|
+
return { facts, evaluated, notEvaluated, measured: false };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
let target;
|
|
237
|
+
if (lockfile) {
|
|
238
|
+
const filePath = path.resolve(lockfile);
|
|
239
|
+
const type = typeForFile(path.basename(filePath));
|
|
240
|
+
try {
|
|
241
|
+
await stat(filePath);
|
|
242
|
+
} catch {
|
|
243
|
+
notEvaluated.push(
|
|
244
|
+
notEvaluatedEntry(KIND.DEPENDENCY, `无法读取依赖清单 ${filePath}: ENOENT`)
|
|
245
|
+
);
|
|
246
|
+
return { facts, evaluated, notEvaluated, measured: false };
|
|
247
|
+
}
|
|
248
|
+
if (!type) {
|
|
249
|
+
notEvaluated.push(
|
|
250
|
+
notEvaluatedEntry(
|
|
251
|
+
KIND.DEPENDENCY,
|
|
252
|
+
`${path.basename(filePath)} 不是受支持的依赖清单格式;本产出器支持 package-lock.json / Cargo.lock / go.mod`
|
|
253
|
+
)
|
|
254
|
+
);
|
|
255
|
+
return { facts, evaluated, notEvaluated, measured: false };
|
|
256
|
+
}
|
|
257
|
+
target = { path: filePath, type };
|
|
258
|
+
} else {
|
|
259
|
+
const found = await detectIn(cwd);
|
|
260
|
+
if (found.length === 0) {
|
|
261
|
+
const unsupported = await unsupportedIn(cwd);
|
|
262
|
+
const reason = unsupported.length
|
|
263
|
+
? `检测到 ${unsupported.join('、')},但 Tier 0 暂不支持;本产出器支持 package-lock.json / Cargo.lock / go.mod`
|
|
264
|
+
: '未找到受支持的依赖清单(package-lock.json / Cargo.lock / go.mod),没有可测量的锁定版本';
|
|
265
|
+
notEvaluated.push(notEvaluatedEntry(KIND.DEPENDENCY, reason));
|
|
266
|
+
return { facts, evaluated, notEvaluated, measured: false };
|
|
267
|
+
}
|
|
268
|
+
target = found[0];
|
|
269
|
+
// A repo can carry both a supported and an unsupported lockfile (e.g. a
|
|
270
|
+
// pnpm-lock.yaml alongside a leftover package-lock.json). The unsupported
|
|
271
|
+
// one must still be named, so a reader knows the tree it measured is not
|
|
272
|
+
// necessarily the tree the project actually installs from.
|
|
273
|
+
const unsupported = await unsupportedIn(cwd);
|
|
274
|
+
if (unsupported.length > 0) {
|
|
275
|
+
notEvaluated.push(
|
|
276
|
+
notEvaluatedEntry(
|
|
277
|
+
KIND.DEPENDENCY,
|
|
278
|
+
`同目录还检测到 ${unsupported.join('、')}(Tier 0 暂不支持),本次只测了 ${target.file}`
|
|
279
|
+
)
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
let text;
|
|
285
|
+
try {
|
|
286
|
+
text = await readFile(target.path, 'utf8');
|
|
287
|
+
} catch (error) {
|
|
288
|
+
notEvaluated.push(
|
|
289
|
+
notEvaluatedEntry(KIND.DEPENDENCY, `无法读取 ${target.path}: ${error.code ?? error.message}`)
|
|
290
|
+
);
|
|
291
|
+
return { facts, evaluated, notEvaluated, measured: false };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
let resolver;
|
|
295
|
+
try {
|
|
296
|
+
resolver = makeResolver(target.type, text);
|
|
297
|
+
} catch (error) {
|
|
298
|
+
notEvaluated.push(
|
|
299
|
+
notEvaluatedEntry(
|
|
300
|
+
KIND.DEPENDENCY,
|
|
301
|
+
`${target.path} 解析失败(${target.type}):${error.message}`
|
|
302
|
+
)
|
|
303
|
+
);
|
|
304
|
+
return { facts, evaluated, notEvaluated, measured: false };
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const verb = target.type === 'go' ? '声明' : '锁定';
|
|
308
|
+
const listLabel = target.type === 'go' ? 'go.mod' : 'lockfile';
|
|
309
|
+
|
|
310
|
+
for (const dep of deps) {
|
|
311
|
+
const hits = resolver(dep);
|
|
312
|
+
const reproduce = `euthyna deps --lockfile ${shellQuote(target.path)} --dep ${shellQuote(dep)}`;
|
|
313
|
+
|
|
314
|
+
if (hits.length > 0) {
|
|
315
|
+
const versions = [...new Set(hits.map((h) => h.version))];
|
|
316
|
+
const first = hits[0];
|
|
317
|
+
facts.push(
|
|
318
|
+
makeFact({
|
|
319
|
+
id: `dependency-${++counter}`,
|
|
320
|
+
kind: KIND.DEPENDENCY,
|
|
321
|
+
statement:
|
|
322
|
+
`依赖 ${dep} 在 ${listLabel}(${target.type})中被${verb}为版本 ${versions.join(' / ')}` +
|
|
323
|
+
(target.type === 'go'
|
|
324
|
+
? ' —— 这是声明的需求版本,非最终解析版本(go.sum 不含版本,无法在此验证解析结果)'
|
|
325
|
+
: ''),
|
|
326
|
+
status: STATUS.ESTABLISHED,
|
|
327
|
+
evidence: { file: target.path, ...(first.line ? { line: first.line } : {}) },
|
|
328
|
+
method: 'command',
|
|
329
|
+
command: reproduce,
|
|
330
|
+
detail: {
|
|
331
|
+
lockfileType: target.type,
|
|
332
|
+
dependency: dep,
|
|
333
|
+
verb,
|
|
334
|
+
// go.mod declares a requirement; calling it "resolved" would let a
|
|
335
|
+
// consumer mistake it for the final build version. The field name
|
|
336
|
+
// must not oversell what the source can prove.
|
|
337
|
+
...(target.type === 'go' ? { declaredVersions: versions } : { resolvedVersions: versions }),
|
|
338
|
+
locations: hits.map((h) => h.location)
|
|
339
|
+
}
|
|
340
|
+
})
|
|
341
|
+
);
|
|
342
|
+
} else {
|
|
343
|
+
facts.push(
|
|
344
|
+
makeFact({
|
|
345
|
+
id: `dependency-${++counter}`,
|
|
346
|
+
kind: KIND.DEPENDENCY,
|
|
347
|
+
statement: `依赖 ${dep} 未出现在 ${listLabel}(${target.type})的依赖树中 —— 声称它影响本应用的声明在此被证伪`,
|
|
348
|
+
status: STATUS.ESTABLISHED,
|
|
349
|
+
evidence: { file: target.path },
|
|
350
|
+
method: 'command',
|
|
351
|
+
command: reproduce,
|
|
352
|
+
detail: {
|
|
353
|
+
lockfileType: target.type,
|
|
354
|
+
dependency: dep,
|
|
355
|
+
...(target.type === 'go' ? { declaredVersions: [] } : { resolvedVersions: [] }),
|
|
356
|
+
reason: 'absent_from_lockfile'
|
|
357
|
+
}
|
|
358
|
+
})
|
|
359
|
+
);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
evaluated.push({ kind: KIND.DEPENDENCY, producer: 'euthyna-deps', count: deps.length });
|
|
364
|
+
|
|
365
|
+
return { facts, evaluated, notEvaluated, measured: true };
|
|
366
|
+
}
|