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.
- package/.agents/skills/euthyna/references/dependency-audit.md +5 -0
- package/.agents/skills/euthyna/references/fact-producers.md +5 -0
- package/.agents/skills/euthyna-en/references/dependency-audit.md +6 -0
- package/.agents/skills/euthyna-en/references/fact-producers.md +1 -0
- package/README.md +33 -17
- package/package.json +1 -1
- package/plugin.yaml +1 -1
- package/src/cli.js +77 -1
|
@@ -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
|
|
35
|
-
mostly false: **5 out of 5** refuted at the gates on the first run;
|
|
36
|
-
refuted, the third unresolved (INCONCLUSIVE, supply-chain-dependent) on the
|
|
37
|
-
**4 out of 4** refuted on the third.
|
|
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 #
|
|
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
|
|
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
|
|
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
|
|
256
|
-
Cargo.lock
|
|
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
|
-
|
|
295
|
-
|
|
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
|
|
308
|
-
|
|
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
|
-
|
|
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.
|
|
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
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];
|