euthyna 0.6.0 → 0.7.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.
@@ -48,6 +48,11 @@
48
48
  **目标目录必须有** `package.json` / `pyproject.toml` / `requirements*.txt` / `go.mod` 之一。
49
49
  没有就**直接说没有并停止**,不要为一个采集器不解析的生态即兴审计。
50
50
 
51
+ **清单只按仓库根查找。**多包仓库里子包各自的 lock 不会被 `euthyna audit` 或 `euthyna deps --repo` 测到
52
+ (🧪 实测 2026-10-10:根 `package-lock.json` 锁 `root-a`,子包 `packages/app/package-lock.json` 锁 `sub-b`/`sub-c`,
53
+ 输出只有 `root-a`,**并且没有任何声明**)。要管子包就逐包跑 `euthyna deps --lockfile <路径>`;
54
+ 不跑就明说没测——别把根清单当成整个 monorepo 的事实。
55
+
51
56
  **能精确解析版本的**:`package-lock.json`、`npm-shrinkwrap.json`、`uv.lock`、Go 1.17+ 的 `go.mod`。
52
57
 
53
58
  **不能的**:`yarn.lock`、`pnpm-lock.yaml`、`poetry.lock`。
@@ -294,4 +294,9 @@ node <euthyna 仓库>/bin/euthyna.js gate <报告文件> --verify --cwd <被审
294
294
  - `coverage` **只能证伪**。见上文。
295
295
  - `coverage` 依赖调用计数,**不处理动态派发、反射、字符串调用**。
296
296
  计数为 0 的结论在存在这些机制时要打折扣。
297
+ - `audit` 的两个半边**读的不是同一个东西**:`history` 取 `--base`/`--head` 命名的提交,`deps` 取
298
+ `--repo` 所在**仓库根**当前检出的那份清单(清单只按仓库根查找)。所以判据是**内容**:测到的清单与 `--head` 那份不一致、或在那个提交里根本
299
+ 不存在(被 gitignore 的锁文件就是这种),「未评估的判据」里会多一条写明文件名与 `--head` 的理由;
300
+ `code`(`manifest-not-head` / `manifest-unverified`)只在 `--json` 通道上。**看到它就检出到范围的一端再重跑**
301
+ ——声明只保证你不静默踩坑,不会替你把事实测对。
297
302
  - 两者都**不产出分数、严重性、修复建议**。这是契约,不是遗漏。
@@ -48,6 +48,12 @@ Available discovery sources (use whichever is installed — **audit the source b
48
48
  **The target directory must have one of** `package.json` / `pyproject.toml` / `requirements*.txt` / `go.mod`.
49
49
  If none exists, **say so and stop** — do not improvise an audit for an ecosystem the collector does not parse.
50
50
 
51
+ **Manifests are located from the repository root only.** A multi-package repository's per-package locks are not measured by
52
+ `euthyna audit` or `euthyna deps --repo` (🧪 measured 2026-10-10: the root `package-lock.json` pins `root-a` while
53
+ `packages/app/package-lock.json` pins `sub-b` and `sub-c`, and the output reports only `root-a`, **with no declaration at
54
+ all**). Measuring a sub-package means running `euthyna deps --lockfile <path>` per package; if that is not done, say the
55
+ sub-packages were not measured — do not treat the root manifest's facts as facts about the whole monorepo.
56
+
51
57
  **Exactly resolvable**: `package-lock.json`, `npm-shrinkwrap.json`, `uv.lock`, `go.mod` for Go 1.17+.
52
58
 
53
59
  **Not resolvable**: `yarn.lock`, `pnpm-lock.yaml`, `poetry.lock`.
@@ -280,4 +280,5 @@ that a conclusion without evidence is not handed over wearing the face of "estab
280
280
  - `history` only covers **deleted lines**. It does not handle newly added code (except with `--pickaxe`).
281
281
  - `coverage` **can only falsify**. See above.
282
282
  - `coverage` relies on invocation counts and **does not handle dynamic dispatch, reflection, or string-based calls**. Conclusions of count 0 must be discounted when such mechanisms exist.
283
+ - The two halves of `audit` **do not read the same thing**: `history` takes the commits named by `--base`/`--head`, while `deps` takes the manifest **currently checked out at the root of the repository** `--repo` points into (manifests are located from the root only). So the criterion is **content**: when the manifest measured differs from the one at `--head`, or does not exist in that commit at all (a gitignored lockfile is exactly that), *Criteria not evaluated* gains an entry naming the file and `--head`. The `code` (`manifest-not-head` / `manifest-unverified`) appears only on the `--json` channel. **Seeing one means checking out an end of the range and re-running** — the declaration keeps the trap from being silent; it does not measure the fact for you.
283
284
  - Neither **produces scores, severity, or fix suggestions**. That is the contract, not an omission.
package/README.md CHANGED
@@ -31,14 +31,19 @@ When an AI coding agent touches security, it fails in two specific ways:
31
31
 
32
32
  1. **It reports things that are not real.** Code that *looks* dangerous gets called a
33
33
  vulnerability, without tracing the data. In validation runs on real codebases — a
34
- JavaScript one, a Python one, and a Go one — the pattern-matched "vulnerabilities" were
35
- mostly false: **5 out of 5** refuted at the gates on the first run; **2 out of 3**
36
- refuted, the third unresolved (INCONCLUSIVE, supply-chain-dependent) on the second;
37
- **4 out of 4** refuted on the third. See the
34
+ JavaScript one, a Python one, a Go one, and a Rust one — the pattern-matched
35
+ "vulnerabilities" were mostly false: **5 out of 5** refuted at the gates on the first run;
36
+ **2 out of 3** refuted, the third unresolved (INCONCLUSIVE, supply-chain-dependent) on the
37
+ second; **4 out of 4** refuted on the third. The fourth run is the one that tests the other
38
+ half: it faced a real vulnerability, and the gates let it through — **1 TRUE POSITIVE**
39
+ (fourth gate evidenced by a PoC run against both the fixed and the unfixed release), 3
40
+ refuted, 1 INCONCLUSIVE. A sieve that only ever says no would have refuted that one too. See
41
+ the
38
42
  [case studies](https://github.com/slow-stack/euthyna/blob/main/docs/case-study-crewai.md)
39
43
  ([Python/crewAI](https://github.com/slow-stack/euthyna/blob/main/docs/case-study-crewai.md),
40
44
  [JavaScript/axe-core](https://github.com/slow-stack/euthyna/blob/main/docs/case-study-axe-core.md),
41
- [Go/nektos-act](https://github.com/slow-stack/euthyna/blob/main/docs/case-study-act.md)).
45
+ [Go/nektos-act](https://github.com/slow-stack/euthyna/blob/main/docs/case-study-act.md),
46
+ [Rust/rustls](https://github.com/slow-stack/euthyna/blob/main/docs/case-study-rust.md)).
42
47
  2. **Its reassurances cannot be checked.** "I'm done." "The tests cover this." "It's
43
48
  safe now." These are assertions. You cannot tell a done-claim from a done-deal.
44
49
 
@@ -187,7 +192,7 @@ From a checkout instead (development):
187
192
 
188
193
  ```sh
189
194
  git clone https://github.com/slow-stack/euthyna
190
- cd euthyna && npm test # 207 tests; no install step exists
195
+ cd euthyna && npm test # the whole suite; no install step exists
191
196
  node bin/euthyna.js audit --repo <path> --base main --head HEAD
192
197
  ```
193
198
 
@@ -241,19 +246,23 @@ This project tries to be explicit about the difference. Current state:
241
246
 
242
247
  ### Verified
243
248
 
244
- - **`history` attribution against real repositories — in two languages.** Run against
249
+ - **`history` attribution against real repositories — in four languages.** Run against
245
250
  [axe-core](https://github.com/dequelabs/axe-core) (JavaScript) and
246
251
  [crewAI](https://github.com/crewAIInc/crewAI) (Python); deleted lines attributed to the
247
252
  commits that introduced them, then checked **by hand** against `git blame`. The checks
248
- developed for that comparison now run as regression tests in the suite.
253
+ developed for that comparison now run as regression tests in the suite. The producer ran
254
+ unchanged on a Go repository ([nektos/act](https://github.com/nektos/act), where the deleted
255
+ lines of a security fix were traced back to the commit that introduced them) and on a Rust one
256
+ ([rustls](https://github.com/rustls/rustls), whose fix range yielded 43 provenance facts).
249
257
  - **`coverage` on real output in three formats** — c8/V8 JSON, classic istanbul (jest/nyc, same
250
258
  fnMap/f shape) and coverage.py JSON (format 3) — distinguishing all three states correctly, and
251
259
  refusing anything that is not a recognizable coverage report instead of answering "symbol not
252
260
  located" against it.
253
- - **`deps` against a real lockfile** — resolved versions reported with a line-level evidence
261
+ - **`deps` against real manifests** — resolved versions reported with a line-level evidence
254
262
  pointer into the lockfile, and absent dependencies reported as established absences rather
255
- than silent skips. Verified against a populated npm v3 lockfile and fixture lockfiles for
256
- Cargo.lock and go.mod.
263
+ than silent skips. Verified against a populated npm v3 lockfile, a real `go.mod` (the *declared*
264
+ requirement, not a resolved build graph) and a real `Cargo.lock` (366 pinned-version facts on
265
+ the rustls run), plus fixture lockfiles for npm v1/v2/v3, Cargo.lock and go.mod in the suite.
257
266
  - **Adjudication recall and specificity**, measured blind: **10/10 cases**, 4 real
258
267
  vulnerabilities all caught, 6 non-vulnerabilities all correctly cleared, no abstentions.
259
268
  Round 2 repeated every case three times — **30 adjudications, zero flips**, four of them
@@ -290,9 +299,12 @@ This project tries to be explicit about the difference. Current state:
290
299
  discipline reaches the right verdict *on a claim*. It does not measure whether the claims would
291
300
  be found in the first place. The cases are deliberately constructed. The crewAI case study
292
301
  surfaced one INCONCLUSIVE (pickle deserialization, supply-chain-dependent) and refuted the
293
- rest — a directional signal, not a rate.
294
- - **Recall in the field.** The benchmark's real-bug cases are constructed; whether the
295
- discipline helps on code nobody staged for it is unmeasured.
302
+ rest — a directional signal, not a rate. The rustls run reached a TRUE POSITIVE on a real
303
+ flaw, but the range was chosen *from* the published advisory, so it shows the gates let a
304
+ genuine finding through; it does not show the coarse screen would have found it unaided.
305
+ - **Recall in the field.** The benchmark's real-bug cases are constructed, and the real-repository
306
+ runs so far aimed at ranges already known to contain a fix. Whether the discipline helps on code
307
+ nobody staged for it is unmeasured.
296
308
  - **Source maps, bundlers, monorepos** for the coverage producer. Untested.
297
309
  - **A real model round on CI.** The golden round is deterministic plumbing, not an
298
310
  adjudication: it writes the ground truth into the reports by design. A model round needs
@@ -304,11 +316,15 @@ This project tries to be explicit about the difference. Current state:
304
316
  detected but not parsed (reported as *not evaluated*, never guessed at); go.mod reports the
305
317
  *declared* requirement, not the resolved build version; and the producer never maps a version
306
318
  to a CVE — that mapping is deliberately left to the adjudication layer.
307
- - **Anything about the case-study targets' security** — the runs found nothing to endorse or
308
- condemn; that is not a statement about any of the projects. See
319
+ - **Anything about the case-study targets' security.** These are method runs, not assessments.
320
+ Three of them found nothing to report. The fourth confirmed a real flaw in rustls 0.23.44, but
321
+ one already published and already fixed upstream (RUSTSEC-2026-0285 / GHSA-2mjx-qc3c-rqvc) which
322
+ the run re-verified rather than discovered, so none of them is a statement about any project's
323
+ security posture. See
309
324
  [`docs/case-study-crewai.md`](https://github.com/slow-stack/euthyna/blob/main/docs/case-study-crewai.md),
310
325
  [`docs/case-study-axe-core.md`](https://github.com/slow-stack/euthyna/blob/main/docs/case-study-axe-core.md),
311
- and [`docs/case-study-act.md`](https://github.com/slow-stack/euthyna/blob/main/docs/case-study-act.md).
326
+ [`docs/case-study-act.md`](https://github.com/slow-stack/euthyna/blob/main/docs/case-study-act.md),
327
+ and [`docs/case-study-rust.md`](https://github.com/slow-stack/euthyna/blob/main/docs/case-study-rust.md).
312
328
  - **A CI recipe, by running it on GitHub.** The exit-code gate and the workflow wiring are
313
329
  documented in [`docs/ci-integration.md`](https://github.com/slow-stack/euthyna/blob/main/docs/ci-integration.md);
314
330
  the workflow snippet has not been exercised as a live Actions run.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "euthyna",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Deterministic fact producers for security auditing: which deleted code came from a security fix, and which changed symbols no test ever executed.",
5
5
  "type": "module",
6
6
  "main": "./plugin/index.js",
package/plugin.yaml CHANGED
@@ -1,5 +1,5 @@
1
1
  name: euthyna
2
- version: 0.6.0
2
+ version: 0.7.0
3
3
  description: euthyna audit discipline — slash commands that drive the euthyna fact-producing CLI (history / coverage / deps / gate)
4
4
  license: Apache-2.0
5
5
  homepage: https://github.com/slow-stack/euthyna
package/src/cli.js CHANGED
@@ -21,7 +21,7 @@ import { collectCoverageFacts } from './facts/coverage.js';
21
21
  import { collectDependencyFacts, listDependencies, detectManifest, manifestTypeForFile } from './facts/deps.js';
22
22
  import { parseGateReport, validateFindings, verifyFinding, renderGateReport } from './gate.js';
23
23
  import { makeReport, renderReport, safeTextLines, KIND } from './contract.js';
24
- import { repoToplevel, revParse } from './git.js';
24
+ import { repoToplevel, revParse, git } from './git.js';
25
25
  import { resolveLang, T, isLang, DEFAULT_LANG } from './lang.js';
26
26
 
27
27
  export const EXIT = Object.freeze({
@@ -409,6 +409,50 @@ async function runDeps(flags, lang) {
409
409
  * symbol names only the adjudicator knows, so there is nothing honest to
410
410
  * auto-measure there.
411
411
  */
412
+ /**
413
+ * Decide whether the manifest `audit` measured is the manifest --head holds.
414
+ *
415
+ * Pure, so that the fail-closed branch — a probe that could not run — is reachable
416
+ * from a test. Nothing else here can make git fail on demand.
417
+ */
418
+ export function manifestVersionGap({ probeFailed, existsAtHead, differsFromHead, head, worktree, manifest }, t) {
419
+ const short = (sha) => (sha ?? '').slice(0, 10);
420
+ const where = worktree && worktree !== head ? `(工作区检出在 ${short(worktree)})` : '';
421
+ const whereEn = worktree && worktree !== head ? ` (the worktree is at ${short(worktree)})` : '';
422
+
423
+ if (probeFailed) {
424
+ return {
425
+ kind: KIND.DEPENDENCY,
426
+ code: 'manifest-unverified',
427
+ reason: t(
428
+ `无法确认依赖清单 ${manifest} 与 --head ${short(head)} 的关系:git 探测失败,这不是匹配的证据`,
429
+ `the relationship between the manifest ${manifest} and --head ${short(head)} could not be probed: git failed, which is not evidence of a match`
430
+ )
431
+ };
432
+ }
433
+ if (!existsAtHead) {
434
+ return {
435
+ kind: KIND.DEPENDENCY,
436
+ code: 'manifest-not-head',
437
+ reason: t(
438
+ `测到的依赖清单 ${manifest} 在 --head ${short(head)} 里不存在,它不属于任何被审的提交${where}`,
439
+ `the dependency manifest ${manifest} measured here does not exist at --head ${short(head)}; it belongs to no reviewed commit${whereEn}`
440
+ )
441
+ };
442
+ }
443
+ if (differsFromHead) {
444
+ return {
445
+ kind: KIND.DEPENDENCY,
446
+ code: 'manifest-not-head',
447
+ reason: t(
448
+ `测到的依赖清单 ${manifest} 与 --head ${short(head)} 那份内容不一致${where},这些事实不是该提交的声明版本`,
449
+ `the dependency manifest ${manifest} measured here differs in content from the one at --head ${short(head)}${whereEn}, so these facts are not that commit's declared versions`
450
+ )
451
+ };
452
+ }
453
+ return null;
454
+ }
455
+
412
456
  async function runAudit(flags, lang) {
413
457
  const t = T(lang);
414
458
  const cwd = path.resolve(flags.repo ? String(flags.repo) : process.cwd());
@@ -476,6 +520,38 @@ async function runAudit(flags, lang) {
476
520
  depNotEvaluated.push({ kind: KIND.DEPENDENCY, reason: depResult.error });
477
521
  }
478
522
 
523
+ // history takes the commits named by --base/--head; deps takes the manifest
524
+ // checked out in --repo. The honest question is therefore about CONTENT: is the
525
+ // file that was measured the file --head holds? Comparing commit shas instead was
526
+ // measured to be wrong in both directions — it fired on a PR merge commit whose
527
+ // lockfile is byte-identical to --head's (which, per fact-contract rule 1, forces
528
+ // every finding citing a dependency fact to not_evaluated), and stayed silent on a
529
+ // gitignored lockfile that belongs to no commit at all.
530
+ const manifest = typeof depResult.report?.subject?.lockfile === 'string'
531
+ ? depResult.report.subject.lockfile
532
+ : undefined;
533
+ if (manifest) {
534
+ const probe = { probeFailed: false, existsAtHead: false, differsFromHead: true };
535
+ if (path.isAbsolute(manifest)) {
536
+ const rel = path.relative(toplevel, manifest).split(path.sep).join('/');
537
+ try {
538
+ probe.existsAtHead = (await git(['ls-tree', head, '--', rel], { cwd: toplevel })).trim() !== '';
539
+ probe.differsFromHead = !probe.existsAtHead ||
540
+ (await git(['diff', '--name-only', head, '--', rel], { cwd: toplevel })).trim() !== '';
541
+ } catch {
542
+ // A probe that could not run is not evidence of a match.
543
+ probe.probeFailed = true;
544
+ }
545
+ } else {
546
+ probe.probeFailed = true;
547
+ }
548
+ const versionGap = manifestVersionGap(
549
+ { ...probe, head, worktree: await revParse(toplevel, 'HEAD'), manifest: path.basename(manifest) },
550
+ t
551
+ );
552
+ if (versionGap) depNotEvaluated.push(versionGap);
553
+ }
554
+
479
555
  const facts = [...history.facts, ...depFacts];
480
556
  const evaluated = [...history.evaluated, ...depEvaluated];
481
557
  const notEvaluated = [...history.notEvaluated, ...depNotEvaluated];