@4da/mcp-server 6.2.1 → 6.2.3
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/CHANGELOG.md +67 -0
- package/dist/live/fix-paths.js +4 -1
- package/dist/live/index.d.ts +9 -1
- package/dist/live/index.js +8 -3
- package/dist/live/js-lockfile-readers.d.ts +27 -0
- package/dist/live/js-lockfile-readers.js +141 -17
- package/dist/live/lockfile-parents.d.ts +12 -0
- package/dist/live/lockfile-parents.js +49 -25
- package/dist/live/lockfile-parsers-pygo.d.ts +37 -7
- package/dist/live/lockfile-parsers-pygo.js +89 -34
- package/dist/live/osv-ranges.d.ts +8 -1
- package/dist/live/osv-ranges.js +11 -1
- package/dist/live/osv-scanner.d.ts +33 -6
- package/dist/live/osv-scanner.js +109 -87
- package/dist/live/pinned-advisories.d.ts +31 -0
- package/dist/live/pinned-advisories.js +80 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,72 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 6.2.3 — 2026-10-11
|
|
4
|
+
|
|
5
|
+
### Yarn 1 refresh instruction covers alias entries
|
|
6
|
+
|
|
7
|
+
The yarn 1 refresh step said "delete the `<name>@` entries from yarn.lock". A
|
|
8
|
+
yarn 1 block is keyed by every spec that resolved to it, and an alias block's
|
|
9
|
+
first key names the alias (`"string-width-cjs@npm:string-width@^4.2.0"`), so
|
|
10
|
+
following the step left those blocks and kept the vulnerable version. It now
|
|
11
|
+
names both forms: keys `"<name>@…"` and alias keys `"…@npm:<name>@…"`.
|
|
12
|
+
|
|
13
|
+
### Fix-path parents name the real package
|
|
14
|
+
|
|
15
|
+
The parent reader behind the fix-path rule ("a lockfile refresh is enough"
|
|
16
|
+
vs "the parent must move") parsed lockfiles with its own code and still had
|
|
17
|
+
the two naming defects 6.2.2 fixed in the package readers. It now names
|
|
18
|
+
packages with the readers' helpers.
|
|
19
|
+
|
|
20
|
+
- **yarn (v1 and berry): npm aliases.** A parent installed under an alias was
|
|
21
|
+
reported by the alias: on hono's benchmarks/routers lockfile, string-width
|
|
22
|
+
4.2.3 and wrap-ansi 7.0.0 were named `string-width-cjs` and `wrap-ansi-cjs`.
|
|
23
|
+
An aliased dependency edge (`string-width-cjs: "npm:string-width@^4.2.0"`)
|
|
24
|
+
was never matched, so @isaacs/cliui was missing from string-width 4.2.3's
|
|
25
|
+
parents, and its range is now the real package's `^4.2.0`.
|
|
26
|
+
- **pnpm 5: entries keyed by a tarball URL.** Such an entry was dropped as a
|
|
27
|
+
parent and as a dependency: trpc's tarball-keyed @prisma/client 4.7.1 was
|
|
28
|
+
missing from prisma 4.7.1's parents, and the four workspace projects that
|
|
29
|
+
depend on it were missing from @prisma/client's. A dependency value that
|
|
30
|
+
points at another entry (that tarball key, or a pnpm alias's
|
|
31
|
+
`/real/1.2.3`) is now an edge to the package it installs.
|
|
32
|
+
|
|
33
|
+
## 6.2.2 — 2026-10-11
|
|
34
|
+
|
|
35
|
+
### Lockfile readers agree with the 4DA conformance corpus
|
|
36
|
+
|
|
37
|
+
The dependency engine is now scored in CI against the conformance corpus of
|
|
38
|
+
the 4DA desktop app (35 public repositories and synthetic cases, pinned OSV
|
|
39
|
+
records, adjudicated truth; see `conformance/README.md`), the same truth the
|
|
40
|
+
app's own engine is held to. The first run found five reader defects, all
|
|
41
|
+
fixed. On the corpus, findings and inventory precision and recall are now
|
|
42
|
+
1.0 in npm, crates.io, PyPI and Go, with no silent drops.
|
|
43
|
+
|
|
44
|
+
- **Go modules before go 1.17 report what is built.** go.sum records the
|
|
45
|
+
go.mod hash of every module version the graph walk visits, but the source
|
|
46
|
+
only of modules the build compiles. A module with only a `/go.mod` line at
|
|
47
|
+
its selected version was reported as installed: 728 never-built modules on
|
|
48
|
+
hugo, node_exporter and traefik, with 182 advisory findings that did not apply. A
|
|
49
|
+
module now counts when go.sum hashes its source at the version minimal
|
|
50
|
+
version selection picks, and `replace` applies to these modules as it does
|
|
51
|
+
to go.mod's own requirements (including `old v1.2.3 => new v1.3.0`).
|
|
52
|
+
- **Versions an advisory lists match by value, not spelling.** OSV lists
|
|
53
|
+
certifi `2020.12.05` (from the git tag) and pip installs `2020.12.5`, the
|
|
54
|
+
same PEP 440 version. The local range check (fix targets, `range_check`,
|
|
55
|
+
`dependency_check`) did not see it as affected by PYSEC-2024-230.
|
|
56
|
+
- **uv and poetry: a git, URL or local source is not a PyPI release.**
|
|
57
|
+
pydantic's uv.lock pins pydantic-extra-types 2.10.0 from git `main`; it was
|
|
58
|
+
scanned as the PyPI release of that number. poetry `[package.source]` types
|
|
59
|
+
`git`, `url`, `file` and `directory` are skipped too; `legacy` (a private
|
|
60
|
+
index) still counts.
|
|
61
|
+
- **yarn: an npm alias is scanned as the package it installs.**
|
|
62
|
+
`"string-width-cjs@npm:string-width@^4.2.0"` was read as a package named
|
|
63
|
+
`string-width-cjs`, which OSV does not know, so string-width 4.2.3 was never
|
|
64
|
+
checked. berry's `resolution:` and v1's alias spec now name the package.
|
|
65
|
+
- **pnpm 5: entries keyed by a tarball URL are read.** pnpm writes `name:`
|
|
66
|
+
and `version:` fields under such a key; trpc's lockfile installs
|
|
67
|
+
@prisma/client 4.7.1 four times that way and none was read. A tarball that
|
|
68
|
+
is not a registry artifact (codeload, a git archive) is still skipped.
|
|
69
|
+
|
|
3
70
|
## 6.2.1 — 2026-10-11
|
|
4
71
|
|
|
5
72
|
### Feed tools leave out sources you turned off in the 4DA app
|
package/dist/live/fix-paths.js
CHANGED
|
@@ -312,7 +312,10 @@ export function refreshCommand(format, name, installed, target) {
|
|
|
312
312
|
case "yarn-berry":
|
|
313
313
|
return `yarn up -R ${name}`;
|
|
314
314
|
case "yarn":
|
|
315
|
-
|
|
315
|
+
// Yarn 1 has no command that refreshes a transitive copy. Its blocks are keyed
|
|
316
|
+
// by every spec that resolved to them, and an alias block's first key names
|
|
317
|
+
// the alias ("string-width-cjs@npm:string-width@^4.2.0"), so name both forms.
|
|
318
|
+
return `delete every yarn.lock entry that resolves to ${name} (keys "${name}@…" and alias keys "…@npm:${name}@…"), then run yarn install`;
|
|
316
319
|
case "bun":
|
|
317
320
|
return `delete the ${name} entries from bun.lock, then run bun install`;
|
|
318
321
|
case "cargo":
|
package/dist/live/index.d.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* Set FOURDA_OFFLINE=true to disable all network calls.
|
|
9
9
|
*/
|
|
10
10
|
import type { SqliteDatabase } from "../sqlite-driver.js";
|
|
11
|
+
import { type AdvisorySource } from "./osv-scanner.js";
|
|
11
12
|
import { FixPathSources } from "./fix-paths.js";
|
|
12
13
|
import type { CheckEcosystem, ReleaseMetadataSource } from "./release-metadata.js";
|
|
13
14
|
import { type ResolutionGroup } from "./resolution.js";
|
|
@@ -70,7 +71,14 @@ export declare class LiveIntelligence {
|
|
|
70
71
|
private warmup;
|
|
71
72
|
/** When the last warmup came back offline; gates the retry so an offline host is not re-probed on every call. */
|
|
72
73
|
private warmupFailedAt;
|
|
73
|
-
|
|
74
|
+
/**
|
|
75
|
+
* `advisorySource` replaces OSV.dev for vulnerability scans (the conformance
|
|
76
|
+
* harness passes pinned records). A source that needs no network keeps
|
|
77
|
+
* scanning enabled under FOURDA_OFFLINE; nothing else changes.
|
|
78
|
+
*/
|
|
79
|
+
constructor(db: SqliteDatabase | null, options?: {
|
|
80
|
+
advisorySource?: AdvisorySource;
|
|
81
|
+
});
|
|
74
82
|
/** Advisory, registry and parent-requirement lookups for fix paths; null when offline. */
|
|
75
83
|
getFixPathSources(): FixPathSources | null;
|
|
76
84
|
/**
|
package/dist/live/index.js
CHANGED
|
@@ -84,12 +84,17 @@ export class LiveIntelligence {
|
|
|
84
84
|
warmup = null;
|
|
85
85
|
/** When the last warmup came back offline; gates the retry so an offline host is not re-probed on every call. */
|
|
86
86
|
warmupFailedAt = null;
|
|
87
|
-
|
|
87
|
+
/**
|
|
88
|
+
* `advisorySource` replaces OSV.dev for vulnerability scans (the conformance
|
|
89
|
+
* harness passes pinned records). A source that needs no network keeps
|
|
90
|
+
* scanning enabled under FOURDA_OFFLINE; nothing else changes.
|
|
91
|
+
*/
|
|
92
|
+
constructor(db, options = {}) {
|
|
88
93
|
this.db = db;
|
|
89
|
-
this.enabled = process.env.FOURDA_OFFLINE !== "true";
|
|
94
|
+
this.enabled = process.env.FOURDA_OFFLINE !== "true" || options.advisorySource?.network === false;
|
|
90
95
|
this.cache = new LiveCache(db);
|
|
91
96
|
this.rateLimiter = new RateLimiter(DEFAULT_RATE_LIMITS);
|
|
92
|
-
this.osvScanner = new OsvScanner(this.cache, this.rateLimiter);
|
|
97
|
+
this.osvScanner = new OsvScanner(this.cache, this.rateLimiter, options.advisorySource);
|
|
93
98
|
this.hnFetcher = new HNFetcher(this.cache, this.rateLimiter);
|
|
94
99
|
this.npmRegistry = new NpmRegistry(this.cache, this.rateLimiter);
|
|
95
100
|
this.cratesRegistry = new CratesRegistry(this.cache, this.rateLimiter);
|
|
@@ -17,12 +17,30 @@
|
|
|
17
17
|
* file, link) is dropped instead of being queried as a package name.
|
|
18
18
|
*/
|
|
19
19
|
import type { InstanceSet } from "./lockfile-types.js";
|
|
20
|
+
/** An exact version with pnpm's peer suffixes removed: v6/v9 `1.2.3(react@18)`, v5 `1.2.3_react@18`. */
|
|
21
|
+
export declare function exactVersion(raw: string): string | null;
|
|
20
22
|
/**
|
|
21
23
|
* Splits one `packages:` or `snapshots:` key into [name, version]:
|
|
22
24
|
* v9 `'@scope/pkg@1.2.3(peer@4.5.6)'`, v6 `/pkg@1.2.3`, v5 `/@scope/pkg/1.2.3_peer@4.5.6`.
|
|
23
25
|
* Returns null for a key that does not pin a registry version.
|
|
24
26
|
*/
|
|
25
27
|
export declare function parsePnpmKey(rawKey: string): [string, string] | null;
|
|
28
|
+
/** A `packages:`/`snapshots:` key, or a dependency value that points at one, in one spelling: unquoted, no leading `/`. */
|
|
29
|
+
export declare function pnpmKeyRef(raw: string): string;
|
|
30
|
+
export interface PnpmEntryReader {
|
|
31
|
+
/** One line inside a `packages:` or `snapshots:` section (indent > 0). */
|
|
32
|
+
line(indent: number, text: string): void;
|
|
33
|
+
/** Ends the entry being read: call at every section change and at the end of the file. */
|
|
34
|
+
finish(): void;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Reads `packages:`/`snapshots:` entries line by line and reports the
|
|
38
|
+
* registry (name, version) each installs (see pnpmEntryIdentity); entries
|
|
39
|
+
* that install no registry release are not reported.
|
|
40
|
+
*/
|
|
41
|
+
export declare function pnpmEntryReader(onEntry: (key: string, id: [string, string], dev: boolean | undefined) => void): PnpmEntryReader;
|
|
42
|
+
/** Every `packages:`/`snapshots:` entry's registry (name, version), by pnpmKeyRef of its key. */
|
|
43
|
+
export declare function pnpmEntryIdentities(content: string): Map<string, [string, string]>;
|
|
26
44
|
/**
|
|
27
45
|
* Reads a pnpm lockfile (v5.x, v6 or v9) into `versions`; the first version seen wins.
|
|
28
46
|
*
|
|
@@ -37,6 +55,15 @@ export declare function parsePnpmKey(rawKey: string): [string, string] | null;
|
|
|
37
55
|
* package key. pnpm 9 dropped that field, so its copies have unknown scope.
|
|
38
56
|
*/
|
|
39
57
|
export declare function readPnpmLock(content: string, versions: Map<string, string>, instances?: InstanceSet): void;
|
|
58
|
+
/**
|
|
59
|
+
* The registry package a yarn block installs. An npm alias
|
|
60
|
+
* (`"string-width-cjs": "npm:string-width@^4.2.0"`) keys the block by the
|
|
61
|
+
* alias, which is not a package: OSV knows `string-width`, so the alias was
|
|
62
|
+
* queried for nothing and the real package went unscanned (hono's
|
|
63
|
+
* benchmarks/routers yarn.lock, 4DA conformance corpus, 2026-10-12). berry
|
|
64
|
+
* names the real package in `resolution:`; v1 only in the alias spec.
|
|
65
|
+
*/
|
|
66
|
+
export declare function yarnPackageName(block: string, firstLine: string, headerName: string): string;
|
|
40
67
|
/**
|
|
41
68
|
* Reads a yarn lockfile (v1 or berry) into `versions`.
|
|
42
69
|
* v1: `"@scope/pkg@^1.0.0", "@scope/pkg@^1.1.0":` then ` version "1.2.3"`.
|
|
@@ -24,7 +24,7 @@ function unquote(s) {
|
|
|
24
24
|
return s.length >= 2 && (s[0] === "'" || s[0] === '"') && s[s.length - 1] === s[0] ? s.slice(1, -1) : s;
|
|
25
25
|
}
|
|
26
26
|
/** An exact version with pnpm's peer suffixes removed: v6/v9 `1.2.3(react@18)`, v5 `1.2.3_react@18`. */
|
|
27
|
-
function exactVersion(raw) {
|
|
27
|
+
export function exactVersion(raw) {
|
|
28
28
|
const version = unquote(raw.trim()).split("(")[0].split("_")[0].trim();
|
|
29
29
|
return /^\d/.test(version) ? version : null;
|
|
30
30
|
}
|
|
@@ -57,6 +57,119 @@ export function parsePnpmKey(rawKey) {
|
|
|
57
57
|
}
|
|
58
58
|
return null;
|
|
59
59
|
}
|
|
60
|
+
function yamlScalar(text, key) {
|
|
61
|
+
if (!text.startsWith(`${key}:`))
|
|
62
|
+
return null;
|
|
63
|
+
const value = unquote(text.slice(key.length + 1).trim()).trim();
|
|
64
|
+
return value === "" ? null : value;
|
|
65
|
+
}
|
|
66
|
+
function pnpmEntryField(entry, text) {
|
|
67
|
+
entry.inResolution = false;
|
|
68
|
+
const dev = /^dev:\s*(true|false)\s*$/.exec(text);
|
|
69
|
+
const name = yamlScalar(text, "name");
|
|
70
|
+
const version = yamlScalar(text, "version");
|
|
71
|
+
if (dev)
|
|
72
|
+
entry.dev = dev[1] === "true";
|
|
73
|
+
else if (name)
|
|
74
|
+
entry.name = name;
|
|
75
|
+
else if (version)
|
|
76
|
+
entry.version = exactVersion(version) ?? undefined;
|
|
77
|
+
else if (text.startsWith("resolution:")) {
|
|
78
|
+
entry.inResolution = true;
|
|
79
|
+
pnpmResolutionText(entry, text.slice("resolution:".length));
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
/** `resolution:` is a flow mapping, on one line or (pnpm 5) spread over several. */
|
|
83
|
+
function pnpmResolutionText(entry, text) {
|
|
84
|
+
for (const part of text.split(/[,{}]/)) {
|
|
85
|
+
const field = part.trim();
|
|
86
|
+
if (field.startsWith("tarball:"))
|
|
87
|
+
entry.tarball = unquote(field.slice("tarball:".length).trim());
|
|
88
|
+
else if (/^(?:commit|repo|directory):/.test(field))
|
|
89
|
+
entry.local = true;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
/** A registry tarball (`https://<registry>/<name>/-/<name>-<version>.tgz`, query allowed), not a codeload/git/arbitrary archive. */
|
|
93
|
+
function isRegistryTarball(url) {
|
|
94
|
+
const pathPart = url.split(/[?#]/)[0];
|
|
95
|
+
return pathPart.includes("/-/") && /\.tgz$/i.test(pathPart);
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* The (name, version) a pnpm entry installs from a registry, or null.
|
|
99
|
+
*
|
|
100
|
+
* The key is not always `name@version`: pnpm 5 keys a package fetched by
|
|
101
|
+
* tarball URL by that URL and writes `name:`/`version:` fields under it.
|
|
102
|
+
* trpc's lockfile installs @prisma/client 4.7.1 four times that way, from
|
|
103
|
+
* registry.npmjs.com, and none of them was read (4DA conformance corpus,
|
|
104
|
+
* 2026-10-12). The fields win when present; a tarball that is not a registry
|
|
105
|
+
* artifact, a git checkout or a directory is not a registry release.
|
|
106
|
+
*/
|
|
107
|
+
function pnpmEntryIdentity(entry) {
|
|
108
|
+
if (entry.local)
|
|
109
|
+
return null;
|
|
110
|
+
const parsed = parsePnpmKey(entry.key);
|
|
111
|
+
if (entry.name && PACKAGE_NAME.test(entry.name)) {
|
|
112
|
+
if (entry.tarball && !isRegistryTarball(entry.tarball))
|
|
113
|
+
return null;
|
|
114
|
+
const version = entry.version ?? parsed?.[1];
|
|
115
|
+
return version ? [entry.name, version] : null;
|
|
116
|
+
}
|
|
117
|
+
return parsed;
|
|
118
|
+
}
|
|
119
|
+
/** A `packages:`/`snapshots:` key, or a dependency value that points at one, in one spelling: unquoted, no leading `/`. */
|
|
120
|
+
export function pnpmKeyRef(raw) {
|
|
121
|
+
return unquote(raw.trim()).replace(/^\//, "");
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Reads `packages:`/`snapshots:` entries line by line and reports the
|
|
125
|
+
* registry (name, version) each installs (see pnpmEntryIdentity); entries
|
|
126
|
+
* that install no registry release are not reported.
|
|
127
|
+
*/
|
|
128
|
+
export function pnpmEntryReader(onEntry) {
|
|
129
|
+
let entry = null;
|
|
130
|
+
const finish = () => {
|
|
131
|
+
const id = entry ? pnpmEntryIdentity(entry) : null;
|
|
132
|
+
if (entry && id)
|
|
133
|
+
onEntry(entry.key, id, entry.dev);
|
|
134
|
+
entry = null;
|
|
135
|
+
};
|
|
136
|
+
return {
|
|
137
|
+
line(indent, text) {
|
|
138
|
+
if (indent === 2 && text.endsWith(":")) {
|
|
139
|
+
finish();
|
|
140
|
+
entry = { key: text.slice(0, -1), inResolution: false, local: false };
|
|
141
|
+
}
|
|
142
|
+
else if (entry && indent === 4) {
|
|
143
|
+
pnpmEntryField(entry, text);
|
|
144
|
+
}
|
|
145
|
+
else if (entry?.inResolution && indent > 4) {
|
|
146
|
+
pnpmResolutionText(entry, text);
|
|
147
|
+
}
|
|
148
|
+
},
|
|
149
|
+
finish,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
/** Every `packages:`/`snapshots:` entry's registry (name, version), by pnpmKeyRef of its key. */
|
|
153
|
+
export function pnpmEntryIdentities(content) {
|
|
154
|
+
const ids = new Map();
|
|
155
|
+
const reader = pnpmEntryReader((key, id) => ids.set(pnpmKeyRef(key), id));
|
|
156
|
+
let inEntries = false;
|
|
157
|
+
for (const line of content.split(/\r?\n/)) {
|
|
158
|
+
const text = line.trim();
|
|
159
|
+
if (text === "" || text.startsWith("#"))
|
|
160
|
+
continue;
|
|
161
|
+
const indent = line.length - line.trimStart().length;
|
|
162
|
+
if (indent === 0) {
|
|
163
|
+
reader.finish();
|
|
164
|
+
inEntries = text === "packages:" || text === "snapshots:";
|
|
165
|
+
}
|
|
166
|
+
else if (inEntries) {
|
|
167
|
+
reader.line(indent, text);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
reader.finish();
|
|
171
|
+
return ids;
|
|
172
|
+
}
|
|
60
173
|
/**
|
|
61
174
|
* Reads a pnpm lockfile (v5.x, v6 or v9) into `versions`; the first version seen wins.
|
|
62
175
|
*
|
|
@@ -80,31 +193,25 @@ export function readPnpmLock(content, versions, instances) {
|
|
|
80
193
|
let section = "";
|
|
81
194
|
let blockIndent = -1; // column of the dependency block being read, or -1
|
|
82
195
|
let pending = null; // v6/v9: a name whose `version:` line follows
|
|
83
|
-
|
|
196
|
+
const entries = pnpmEntryReader((_key, id, dev) => {
|
|
197
|
+
transitive.push(id);
|
|
198
|
+
if (dev !== undefined)
|
|
199
|
+
devByKey.set(`${id[0]}\0${id[1]}`, dev);
|
|
200
|
+
});
|
|
84
201
|
for (const line of content.split(/\r?\n/)) {
|
|
85
202
|
const text = line.trim();
|
|
86
203
|
if (text === "" || text.startsWith("#"))
|
|
87
204
|
continue;
|
|
88
205
|
const indent = line.length - line.trimStart().length;
|
|
89
206
|
if (indent === 0) {
|
|
207
|
+
entries.finish();
|
|
90
208
|
section = text.endsWith(":") ? text.slice(0, -1) : "";
|
|
91
209
|
blockIndent = DEPENDENCY_BLOCKS.has(text) ? 0 : -1;
|
|
92
210
|
pending = null;
|
|
93
|
-
packageKey = null;
|
|
94
211
|
continue;
|
|
95
212
|
}
|
|
96
213
|
if (section === "packages" || section === "snapshots") {
|
|
97
|
-
|
|
98
|
-
const parsed = parsePnpmKey(text.slice(0, -1));
|
|
99
|
-
packageKey = parsed ? `${parsed[0]}\0${parsed[1]}` : null;
|
|
100
|
-
if (parsed)
|
|
101
|
-
transitive.push(parsed);
|
|
102
|
-
}
|
|
103
|
-
else if (indent === 4 && packageKey) {
|
|
104
|
-
const dev = /^dev:\s*(true|false)\s*$/.exec(text);
|
|
105
|
-
if (dev)
|
|
106
|
-
devByKey.set(packageKey, dev[1] === "true");
|
|
107
|
-
}
|
|
214
|
+
entries.line(indent, text);
|
|
108
215
|
continue;
|
|
109
216
|
}
|
|
110
217
|
if (section === "importers" && indent <= 4) {
|
|
@@ -137,6 +244,7 @@ export function readPnpmLock(content, versions, instances) {
|
|
|
137
244
|
pending = null;
|
|
138
245
|
}
|
|
139
246
|
}
|
|
247
|
+
entries.finish();
|
|
140
248
|
for (const [name, version] of transitive)
|
|
141
249
|
setFirst(name, version);
|
|
142
250
|
if (!instances)
|
|
@@ -148,6 +256,21 @@ export function readPnpmLock(content, versions, instances) {
|
|
|
148
256
|
for (const [name, version] of transitive)
|
|
149
257
|
instances.add(name, version, devByKey.get(`${name}\0${version}`));
|
|
150
258
|
}
|
|
259
|
+
/**
|
|
260
|
+
* The registry package a yarn block installs. An npm alias
|
|
261
|
+
* (`"string-width-cjs": "npm:string-width@^4.2.0"`) keys the block by the
|
|
262
|
+
* alias, which is not a package: OSV knows `string-width`, so the alias was
|
|
263
|
+
* queried for nothing and the real package went unscanned (hono's
|
|
264
|
+
* benchmarks/routers yarn.lock, 4DA conformance corpus, 2026-10-12). berry
|
|
265
|
+
* names the real package in `resolution:`; v1 only in the alias spec.
|
|
266
|
+
*/
|
|
267
|
+
export function yarnPackageName(block, firstLine, headerName) {
|
|
268
|
+
const resolution = /^\s+resolution:\s+"?((?:@[^\s/@"]+\/)?[^\s/@"]+)@npm:/m.exec(block);
|
|
269
|
+
if (resolution)
|
|
270
|
+
return resolution[1];
|
|
271
|
+
const alias = /^"?(?:@[^\s/@"]+\/)?[^\s/@"]+@npm:((?:@[^\s/@"]+\/)?[^\s/@"]+)@/.exec(firstLine);
|
|
272
|
+
return alias ? alias[1] : headerName;
|
|
273
|
+
}
|
|
151
274
|
/**
|
|
152
275
|
* Reads a yarn lockfile (v1 or berry) into `versions`.
|
|
153
276
|
* v1: `"@scope/pkg@^1.0.0", "@scope/pkg@^1.1.0":` then ` version "1.2.3"`.
|
|
@@ -166,12 +289,13 @@ export function readYarnLock(content, versions, instances, specs) {
|
|
|
166
289
|
const version = line ? exactVersion(line[1]) : null;
|
|
167
290
|
if (!version)
|
|
168
291
|
continue;
|
|
292
|
+
const name = yarnPackageName(block, firstLine, header[1]);
|
|
169
293
|
// One block per resolved version. Blocks are sorted by spec, so the first
|
|
170
294
|
// block of a name can be an old nested copy; `specs` lets the caller pick
|
|
171
295
|
// the copy a package.json range resolves to instead.
|
|
172
|
-
if (!versions.has(
|
|
173
|
-
versions.set(
|
|
174
|
-
instances?.add(
|
|
296
|
+
if (!versions.has(name))
|
|
297
|
+
versions.set(name, version);
|
|
298
|
+
instances?.add(name, version);
|
|
175
299
|
if (specs) {
|
|
176
300
|
for (const spec of firstLine.replace(/:\s*$/, "").split(",")) {
|
|
177
301
|
specs.set(unquote(spec.trim()).replace(/@npm:/, "@"), version);
|
|
@@ -45,8 +45,20 @@ export declare function packageLockParents(lock: {
|
|
|
45
45
|
* dependency blocks resolve `name` to `version`. pnpm records resolved
|
|
46
46
|
* versions only, so `range` is null. Importers (the project's own manifests)
|
|
47
47
|
* become local edges with their `specifier` when the lockfile records one.
|
|
48
|
+
*
|
|
49
|
+
* Entries and dependency values are named the way the package reader names
|
|
50
|
+
* them (pnpmEntryIdentities): a pnpm 5 entry keyed by its tarball URL is the
|
|
51
|
+
* package its `name:`/`version:` fields say, and a dependency value that is a
|
|
52
|
+
* key rather than a version (that tarball key, or an alias's
|
|
53
|
+
* `/real/1.2.3`, `/real@1.2.3`, `real@1.2.3`) is an edge to the package it points at.
|
|
48
54
|
*/
|
|
49
55
|
export declare function pnpmParents(content: string, name: string, version: string): ParentEdge[];
|
|
56
|
+
/**
|
|
57
|
+
* Blocks whose dependencies resolve `name` to `version`. An npm alias edge
|
|
58
|
+
* (`string-width-cjs: "npm:string-width@^4.2.0"`) is an edge to the real
|
|
59
|
+
* package, with the real package's range; it resolves through the block whose
|
|
60
|
+
* spec is `string-width-cjs@npm:string-width@^4.2.0`.
|
|
61
|
+
*/
|
|
50
62
|
export declare function yarnParents(content: string, name: string, version: string): ParentEdge[];
|
|
51
63
|
export declare function bunParents(lock: {
|
|
52
64
|
packages?: Record<string, unknown>;
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
import * as fs from "node:fs";
|
|
12
12
|
import * as path from "node:path";
|
|
13
13
|
import { stripTrailingCommas } from "./bun-lockfile.js";
|
|
14
|
-
import { parsePnpmKey } from "./js-lockfile-readers.js";
|
|
14
|
+
import { exactVersion, parsePnpmKey, pnpmEntryIdentities, pnpmKeyRef, yarnPackageName } from "./js-lockfile-readers.js";
|
|
15
15
|
import { packageNameFromNodeModulesPath, parseCargoLockPackages } from "./lockfile-parsers.js";
|
|
16
16
|
const DEP_FIELDS = ["dependencies", "optionalDependencies", "peerDependencies"];
|
|
17
17
|
/** The lockfile format of a path, or null for a file this module does not read. */
|
|
@@ -123,18 +123,32 @@ export function packageLockParents(lock, name, version) {
|
|
|
123
123
|
// ---------------------------------------------------------------------------
|
|
124
124
|
// pnpm-lock.yaml (v5, v6, v9)
|
|
125
125
|
// ---------------------------------------------------------------------------
|
|
126
|
-
function exactVersion(raw) {
|
|
127
|
-
const unquoted = raw.trim().replace(/^['"]|['"]$/g, "");
|
|
128
|
-
const v = unquoted.split("(")[0].split("_")[0].trim();
|
|
129
|
-
return /^\d/.test(v) ? v : null;
|
|
130
|
-
}
|
|
131
126
|
/**
|
|
132
127
|
* Parents from `packages:` (v5/v6) or `snapshots:` (v9) entries whose
|
|
133
128
|
* dependency blocks resolve `name` to `version`. pnpm records resolved
|
|
134
129
|
* versions only, so `range` is null. Importers (the project's own manifests)
|
|
135
130
|
* become local edges with their `specifier` when the lockfile records one.
|
|
131
|
+
*
|
|
132
|
+
* Entries and dependency values are named the way the package reader names
|
|
133
|
+
* them (pnpmEntryIdentities): a pnpm 5 entry keyed by its tarball URL is the
|
|
134
|
+
* package its `name:`/`version:` fields say, and a dependency value that is a
|
|
135
|
+
* key rather than a version (that tarball key, or an alias's
|
|
136
|
+
* `/real/1.2.3`, `/real@1.2.3`, `real@1.2.3`) is an edge to the package it points at.
|
|
136
137
|
*/
|
|
137
138
|
export function pnpmParents(content, name, version) {
|
|
139
|
+
const ids = pnpmEntryIdentities(content);
|
|
140
|
+
/** The (name, version) a dependency line installs: `dep: 1.2.3`, or a value that points at an entry. */
|
|
141
|
+
const installs = (dep, value) => {
|
|
142
|
+
const exact = exactVersion(value);
|
|
143
|
+
if (exact)
|
|
144
|
+
return [dep, exact];
|
|
145
|
+
const ref = pnpmKeyRef(value);
|
|
146
|
+
return ids.get(ref) ?? parsePnpmKey(ref);
|
|
147
|
+
};
|
|
148
|
+
const isTarget = (dep, value) => {
|
|
149
|
+
const id = installs(dep, value);
|
|
150
|
+
return id !== null && id[0] === name && id[1] === version;
|
|
151
|
+
};
|
|
138
152
|
const edges = [];
|
|
139
153
|
const seen = new Set();
|
|
140
154
|
let section = "";
|
|
@@ -142,7 +156,7 @@ export function pnpmParents(content, name, version) {
|
|
|
142
156
|
let importer = null;
|
|
143
157
|
let inDeps = false;
|
|
144
158
|
let depIndent = -1;
|
|
145
|
-
let
|
|
159
|
+
let pendingDep = null;
|
|
146
160
|
let importerSpecifier = null;
|
|
147
161
|
const add = (edge) => {
|
|
148
162
|
const key = `${edge.parent}\0${edge.parentVersion}`;
|
|
@@ -166,7 +180,7 @@ export function pnpmParents(content, name, version) {
|
|
|
166
180
|
}
|
|
167
181
|
if (section === "packages" || section === "snapshots") {
|
|
168
182
|
if (indent === 2 && text.endsWith(":")) {
|
|
169
|
-
parent =
|
|
183
|
+
parent = ids.get(pnpmKeyRef(text.slice(0, -1))) ?? null;
|
|
170
184
|
inDeps = false;
|
|
171
185
|
}
|
|
172
186
|
else if (indent === 4 && parent) {
|
|
@@ -174,7 +188,7 @@ export function pnpmParents(content, name, version) {
|
|
|
174
188
|
}
|
|
175
189
|
else if (indent === 6 && parent && inDeps) {
|
|
176
190
|
const m = /^(?:'([^']+)'|"([^"]+)"|([^\s:'"]+)):\s*(.+)$/.exec(text);
|
|
177
|
-
if (m && (m[1] ?? m[2] ?? m[3]
|
|
191
|
+
if (m && isTarget(m[1] ?? m[2] ?? m[3], m[4])) {
|
|
178
192
|
add({ parent: parent[0], parentVersion: parent[1], range: null, local: false });
|
|
179
193
|
}
|
|
180
194
|
}
|
|
@@ -197,21 +211,23 @@ export function pnpmParents(content, name, version) {
|
|
|
197
211
|
// A dependency of the project itself: v5 `name: 1.2.3`, v6/v9 `name:` + `specifier:` + `version:`.
|
|
198
212
|
if (indent === depIndent + 2) {
|
|
199
213
|
const m = /^(?:'([^']+)'|"([^"]+)"|([^\s:'"]+)):(?:\s+(.*))?$/.exec(text);
|
|
200
|
-
|
|
214
|
+
pendingDep = m ? m[1] ?? m[2] ?? m[3] : null;
|
|
201
215
|
importerSpecifier = null;
|
|
202
|
-
if (
|
|
203
|
-
|
|
204
|
-
|
|
216
|
+
if (pendingDep && m?.[4]) {
|
|
217
|
+
if (isTarget(pendingDep, m[4]))
|
|
218
|
+
add({ parent: importer, parentVersion: null, range: null, local: true });
|
|
219
|
+
pendingDep = null;
|
|
205
220
|
}
|
|
206
221
|
}
|
|
207
|
-
else if (indent === depIndent + 4 &&
|
|
222
|
+
else if (indent === depIndent + 4 && pendingDep) {
|
|
208
223
|
const spec = /^specifier:\s*(.+)$/.exec(text);
|
|
209
224
|
if (spec)
|
|
210
225
|
importerSpecifier = spec[1].replace(/^['"]|['"]$/g, "");
|
|
211
226
|
const ver = /^version:\s*(.+)$/.exec(text);
|
|
212
|
-
if (ver
|
|
213
|
-
|
|
214
|
-
|
|
227
|
+
if (ver) {
|
|
228
|
+
if (isTarget(pendingDep, ver[1]))
|
|
229
|
+
add({ parent: importer, parentVersion: null, range: importerSpecifier, local: true });
|
|
230
|
+
pendingDep = null;
|
|
215
231
|
}
|
|
216
232
|
}
|
|
217
233
|
}
|
|
@@ -226,9 +242,11 @@ function parseYarnBlocks(content) {
|
|
|
226
242
|
for (const block of content.split(/\r?\n(?=\S)/)) {
|
|
227
243
|
const lines = block.split(/\r?\n/);
|
|
228
244
|
const header = lines[0];
|
|
229
|
-
const
|
|
230
|
-
if (!
|
|
245
|
+
const headerName = /^"?(@?[^@\s"]+)@/.exec(header)?.[1];
|
|
246
|
+
if (!headerName || headerName.startsWith("__"))
|
|
231
247
|
continue;
|
|
248
|
+
// An npm alias block (`string-width-cjs@npm:string-width@^4.2.0`) installs the real package.
|
|
249
|
+
const name = yarnPackageName(block, header, headerName);
|
|
232
250
|
const version = /^\s+version:?\s+"?([^"\s]+)"?\s*$/m.exec(block)?.[1];
|
|
233
251
|
if (!version)
|
|
234
252
|
continue;
|
|
@@ -260,21 +278,27 @@ function parseYarnBlocks(content) {
|
|
|
260
278
|
}
|
|
261
279
|
return blocks;
|
|
262
280
|
}
|
|
281
|
+
/**
|
|
282
|
+
* Blocks whose dependencies resolve `name` to `version`. An npm alias edge
|
|
283
|
+
* (`string-width-cjs: "npm:string-width@^4.2.0"`) is an edge to the real
|
|
284
|
+
* package, with the real package's range; it resolves through the block whose
|
|
285
|
+
* spec is `string-width-cjs@npm:string-width@^4.2.0`.
|
|
286
|
+
*/
|
|
263
287
|
export function yarnParents(content, name, version) {
|
|
264
288
|
const blocks = parseYarnBlocks(content);
|
|
265
289
|
const resolved = new Map();
|
|
266
290
|
for (const b of blocks)
|
|
267
291
|
for (const spec of b.specs)
|
|
268
|
-
resolved.set(spec, b
|
|
292
|
+
resolved.set(spec, b);
|
|
269
293
|
const edges = [];
|
|
270
294
|
for (const b of blocks) {
|
|
271
295
|
for (const [dep, rawRange] of b.deps) {
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
if (resolved.get(`${name}@${range}`) !== version)
|
|
296
|
+
const spec = rawRange.replace(/^npm:/, "");
|
|
297
|
+
const target = resolved.get(`${dep}@${spec}`);
|
|
298
|
+
if (!target || target.name !== name || target.version !== version)
|
|
276
299
|
continue;
|
|
277
|
-
|
|
300
|
+
const alias = /^npm:((?:@[^\s/@]+\/)?[^\s/@]+)@(.+)$/.exec(rawRange);
|
|
301
|
+
edges.push({ parent: b.name, parentVersion: b.version, range: alias ? alias[2] : spec, local: false });
|
|
278
302
|
}
|
|
279
303
|
}
|
|
280
304
|
return edges;
|
|
@@ -69,23 +69,53 @@ export declare function parseRequirements(content: string): Array<{
|
|
|
69
69
|
version: string;
|
|
70
70
|
}>;
|
|
71
71
|
export declare function resolveGo(cwd: string): VersionSource;
|
|
72
|
-
/** `
|
|
72
|
+
/** A `replace` target: another module version, or null for a local directory. */
|
|
73
|
+
type GoReplacement = {
|
|
74
|
+
module: string;
|
|
75
|
+
version: string;
|
|
76
|
+
} | null;
|
|
77
|
+
/**
|
|
78
|
+
* `require` lines (block and single form) with `replace` applied, the same
|
|
79
|
+
* lines as written, `replace` for any other module version, and whether the
|
|
80
|
+
* go directive is >= 1.17. A `replace` may name the version it replaces
|
|
81
|
+
* (`old v1.2.3 => new v1.3.0`); that one wins over a versionless one.
|
|
82
|
+
*/
|
|
73
83
|
export declare function parseGoMod(content: string): {
|
|
74
84
|
requires: Array<{
|
|
75
85
|
module: string;
|
|
76
86
|
version: string;
|
|
77
87
|
indirect: boolean;
|
|
78
88
|
}>;
|
|
89
|
+
/** Requirements as written, before `replace`. */
|
|
90
|
+
rawRequires: Array<{
|
|
91
|
+
module: string;
|
|
92
|
+
version: string;
|
|
93
|
+
indirect: boolean;
|
|
94
|
+
}>;
|
|
95
|
+
/** What `replace` maps a module version to: undefined for nothing, null for local code. */
|
|
96
|
+
replacement: (module: string, version: string) => GoReplacement | undefined;
|
|
79
97
|
goVersionAtLeast117: boolean;
|
|
80
98
|
};
|
|
81
99
|
/**
|
|
82
|
-
* Per module, the
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
* (
|
|
87
|
-
*
|
|
100
|
+
* Per module, the version the build selected according to go.sum (go < 1.17):
|
|
101
|
+
* the HIGHEST version recorded there by either line, since minimal version
|
|
102
|
+
* selection picks the highest version the module graph requires and go.sum
|
|
103
|
+
* records every one, kept only when go.sum also hashes that version's SOURCE
|
|
104
|
+
* (`module v1.2.3 h1:...`, not only `module v1.2.3/go.mod h1:...`).
|
|
105
|
+
*
|
|
106
|
+
* The go command records the go.mod hash of every module version it visits
|
|
107
|
+
* while walking the graph, but downloads and hashes the source only of
|
|
108
|
+
* modules whose packages the build imports. A module with only a `/go.mod`
|
|
109
|
+
* line at its selected version is in `go list -m all` but is never compiled.
|
|
110
|
+
* Counting those (the 6.2 rule) read 728 never-built modules as installed
|
|
111
|
+
* on hugo, node_exporter and traefik: 4DA conformance corpus, 2026-10-12,
|
|
112
|
+
* whose truth is the build list restricted to source-hashed modules, as the
|
|
113
|
+
* desktop app reads it. An older version's leftover source hash does not make
|
|
114
|
+
* that older version built either: node_exporter hashes gogo/protobuf
|
|
115
|
+
* v1.1.1's source while the graph selects v1.2.1 (go.mod hash only), so
|
|
116
|
+
* neither is installed.
|
|
88
117
|
*/
|
|
89
118
|
export declare function selectedFromGoSum(content: string): Map<string, string>;
|
|
90
119
|
/** Semver precedence over Go versions; build metadata (`+incompatible`) is ignored. */
|
|
91
120
|
export declare function compareGoVersions(a: string, b: string): number;
|
|
121
|
+
export {};
|
|
@@ -226,6 +226,24 @@ function unpinnedRequirementNames(content) {
|
|
|
226
226
|
}
|
|
227
227
|
return out;
|
|
228
228
|
}
|
|
229
|
+
/**
|
|
230
|
+
* Whether a lockfile [[package]] entry is a release from a package index.
|
|
231
|
+
* Only those are what PyPI advisories describe. Not one: the project itself
|
|
232
|
+
* and local code (uv `editable`/`virtual`/`directory`/`path`), a git checkout
|
|
233
|
+
* or a direct-URL archive (uv `git`/`url`; poetry `[package.source] type =
|
|
234
|
+
* "git" | "url" | "file" | "directory"`). A git checkout keeps the version
|
|
235
|
+
* number of a release it is not: pydantic's uv.lock pins
|
|
236
|
+
* pydantic-extra-types 2.10.0 from git `main` (4DA conformance corpus).
|
|
237
|
+
* poetry's `legacy` source is a private index, so its packages are releases.
|
|
238
|
+
*/
|
|
239
|
+
function fromPackageIndex(block, body) {
|
|
240
|
+
const uv = /^source\s*=\s*\{\s*([a-z-]+)\s*=/m.exec(body)?.[1];
|
|
241
|
+
if (uv && uv !== "registry")
|
|
242
|
+
return false;
|
|
243
|
+
const poetrySource = /^\[package\.source\]\s*$([\s\S]*?)(?=^\[|(?![\s\S]))/m.exec(block)?.[1] ?? "";
|
|
244
|
+
const poetryType = /^type\s*=\s*"([^"]*)"/m.exec(poetrySource)?.[1];
|
|
245
|
+
return !(poetryType && ["git", "url", "file", "directory"].includes(poetryType));
|
|
246
|
+
}
|
|
229
247
|
/**
|
|
230
248
|
* [[package]] tables from poetry.lock (any version) or uv.lock. A pre-1.2
|
|
231
249
|
* poetry.lock records `category = "dev"` for dev-only packages; newer files
|
|
@@ -242,8 +260,7 @@ export function readTomlPackageLock(content) {
|
|
|
242
260
|
const version = field("version");
|
|
243
261
|
if (!name || !version)
|
|
244
262
|
continue;
|
|
245
|
-
|
|
246
|
-
if (/^source\s*=\s*\{\s*(?:editable|virtual)\s*=/m.test(body))
|
|
263
|
+
if (!fromPackageIndex(block, body))
|
|
247
264
|
continue;
|
|
248
265
|
const category = field("category");
|
|
249
266
|
const normalized = normalizePythonName(name);
|
|
@@ -294,35 +311,47 @@ export function resolveGo(cwd) {
|
|
|
294
311
|
catch {
|
|
295
312
|
return emptySource();
|
|
296
313
|
}
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
versions.set(module, version);
|
|
301
|
-
instances.add(module, version);
|
|
302
|
-
}
|
|
314
|
+
// The build list: go.mod's requirements and, before go 1.17, the modules
|
|
315
|
+
// go.sum says the build selected; `replace` applies to both.
|
|
316
|
+
const selected = new Map(mod.rawRequires.map((r) => [r.module, r.version]));
|
|
303
317
|
// From go 1.17 the go.mod `require` list names every module the build needs
|
|
304
318
|
// (module-graph pruning), so it IS the selected set and go.sum (which keeps
|
|
305
319
|
// hashes for versions the build never selects) would only over-report.
|
|
306
320
|
// Before 1.17 go.mod lists direct requirements only; transitives come from
|
|
307
|
-
// go.sum
|
|
308
|
-
// minimal version selection picked.
|
|
321
|
+
// go.sum (selectedFromGoSum: the version MVS picked, when its source is built).
|
|
309
322
|
if (!mod.goVersionAtLeast117) {
|
|
310
323
|
const goSumPath = path.join(cwd, "go.sum");
|
|
311
324
|
if (fs.existsSync(goSumPath)) {
|
|
312
325
|
try {
|
|
313
326
|
for (const [module, version] of selectedFromGoSum(fs.readFileSync(goSumPath, "utf-8"))) {
|
|
314
|
-
if (
|
|
315
|
-
|
|
316
|
-
versions.set(module, version);
|
|
317
|
-
instances.add(module, version);
|
|
327
|
+
if (!selected.has(module))
|
|
328
|
+
selected.set(module, version);
|
|
318
329
|
}
|
|
319
330
|
}
|
|
320
331
|
catch { /* skip */ }
|
|
321
332
|
}
|
|
322
333
|
}
|
|
334
|
+
const versions = new Map();
|
|
335
|
+
for (const [module, version] of selected) {
|
|
336
|
+
const target = mod.replacement(module, version);
|
|
337
|
+
if (target === null)
|
|
338
|
+
continue; // replaced by a local directory: local code, not a registry module
|
|
339
|
+
if (target)
|
|
340
|
+
versions.set(target.module, target.version);
|
|
341
|
+
else if (!versions.has(module))
|
|
342
|
+
versions.set(module, version);
|
|
343
|
+
}
|
|
344
|
+
const instances = new InstanceSet();
|
|
345
|
+
for (const [module, version] of versions)
|
|
346
|
+
instances.add(module, version);
|
|
323
347
|
return found(versions, instances, goModPath, "lockfile");
|
|
324
348
|
}
|
|
325
|
-
/**
|
|
349
|
+
/**
|
|
350
|
+
* `require` lines (block and single form) with `replace` applied, the same
|
|
351
|
+
* lines as written, `replace` for any other module version, and whether the
|
|
352
|
+
* go directive is >= 1.17. A `replace` may name the version it replaces
|
|
353
|
+
* (`old v1.2.3 => new v1.3.0`); that one wins over a versionless one.
|
|
354
|
+
*/
|
|
326
355
|
export function parseGoMod(content) {
|
|
327
356
|
const requires = new Map();
|
|
328
357
|
const replaces = new Map();
|
|
@@ -354,42 +383,68 @@ export function parseGoMod(content) {
|
|
|
354
383
|
const [left, right] = rest.split("=>").map((s) => s.trim().split(/\s+/));
|
|
355
384
|
if (!left?.[0] || !right?.[0])
|
|
356
385
|
continue;
|
|
357
|
-
|
|
386
|
+
const key = left[1] ? `${left[0]} ${left[1]}` : left[0];
|
|
387
|
+
replaces.set(key, right[1] && GO_VERSION.test(right[1]) ? { module: right[0], version: right[1] } : null);
|
|
358
388
|
}
|
|
359
389
|
}
|
|
390
|
+
const replacement = (module, version) => {
|
|
391
|
+
const exact = `${module} ${version}`;
|
|
392
|
+
if (replaces.has(exact))
|
|
393
|
+
return replaces.get(exact);
|
|
394
|
+
return replaces.has(module) ? replaces.get(module) : undefined;
|
|
395
|
+
};
|
|
396
|
+
const rawRequires = [...requires.values()];
|
|
360
397
|
const result = [];
|
|
361
|
-
for (const req of
|
|
362
|
-
|
|
398
|
+
for (const req of rawRequires) {
|
|
399
|
+
const target = replacement(req.module, req.version);
|
|
400
|
+
if (target === undefined)
|
|
363
401
|
result.push(req);
|
|
364
|
-
|
|
365
|
-
}
|
|
366
|
-
const target = replaces.get(req.module);
|
|
367
|
-
if (target)
|
|
402
|
+
else if (target)
|
|
368
403
|
result.push({ module: target.module, version: target.version, indirect: req.indirect });
|
|
369
404
|
}
|
|
370
405
|
const go = /^go\s+(\d+)\.(\d+)/m.exec(content);
|
|
371
406
|
const goVersionAtLeast117 = go ? Number(go[1]) > 1 || Number(go[2]) >= 17 : false;
|
|
372
|
-
return { requires: result, goVersionAtLeast117 };
|
|
407
|
+
return { requires: result, rawRequires, replacement, goVersionAtLeast117 };
|
|
373
408
|
}
|
|
374
409
|
/**
|
|
375
|
-
* Per module, the
|
|
376
|
-
*
|
|
377
|
-
*
|
|
378
|
-
*
|
|
379
|
-
* (
|
|
380
|
-
*
|
|
410
|
+
* Per module, the version the build selected according to go.sum (go < 1.17):
|
|
411
|
+
* the HIGHEST version recorded there by either line, since minimal version
|
|
412
|
+
* selection picks the highest version the module graph requires and go.sum
|
|
413
|
+
* records every one, kept only when go.sum also hashes that version's SOURCE
|
|
414
|
+
* (`module v1.2.3 h1:...`, not only `module v1.2.3/go.mod h1:...`).
|
|
415
|
+
*
|
|
416
|
+
* The go command records the go.mod hash of every module version it visits
|
|
417
|
+
* while walking the graph, but downloads and hashes the source only of
|
|
418
|
+
* modules whose packages the build imports. A module with only a `/go.mod`
|
|
419
|
+
* line at its selected version is in `go list -m all` but is never compiled.
|
|
420
|
+
* Counting those (the 6.2 rule) read 728 never-built modules as installed
|
|
421
|
+
* on hugo, node_exporter and traefik: 4DA conformance corpus, 2026-10-12,
|
|
422
|
+
* whose truth is the build list restricted to source-hashed modules, as the
|
|
423
|
+
* desktop app reads it. An older version's leftover source hash does not make
|
|
424
|
+
* that older version built either: node_exporter hashes gogo/protobuf
|
|
425
|
+
* v1.1.1's source while the graph selects v1.2.1 (go.mod hash only), so
|
|
426
|
+
* neither is installed.
|
|
381
427
|
*/
|
|
382
428
|
export function selectedFromGoSum(content) {
|
|
383
|
-
const
|
|
429
|
+
const highest = new Map();
|
|
430
|
+
const sourceHashed = new Set();
|
|
384
431
|
for (const line of content.split(/\r?\n/)) {
|
|
385
432
|
const [module, rawVersion] = line.trim().split(/\s+/);
|
|
386
|
-
|
|
387
|
-
|
|
433
|
+
if (!module || !rawVersion)
|
|
434
|
+
continue;
|
|
435
|
+
const version = rawVersion.replace(/\/go\.mod$/, "");
|
|
436
|
+
if (!GO_VERSION.test(version))
|
|
388
437
|
continue;
|
|
389
|
-
|
|
438
|
+
if (version === rawVersion)
|
|
439
|
+
sourceHashed.add(`${module} ${version}`);
|
|
440
|
+
const current = highest.get(module);
|
|
390
441
|
if (!current || compareGoVersions(version, current) > 0)
|
|
391
|
-
|
|
442
|
+
highest.set(module, version);
|
|
392
443
|
}
|
|
444
|
+
const selected = new Map();
|
|
445
|
+
for (const [module, version] of highest)
|
|
446
|
+
if (sourceHashed.has(`${module} ${version}`))
|
|
447
|
+
selected.set(module, version);
|
|
393
448
|
return selected;
|
|
394
449
|
}
|
|
395
450
|
/** Semver precedence over Go versions; build metadata (`+incompatible`) is ignored. */
|
|
@@ -41,7 +41,14 @@ export interface AdvisoryRanges {
|
|
|
41
41
|
export declare function collectAffectedSpans(affected: OsvVulnerability["affected"] | undefined, packageName: string, ecosystem: string): AffectedSpan[];
|
|
42
42
|
/** One advisory's ranges for one package; null when the advisory is withdrawn. */
|
|
43
43
|
export declare function advisoryRanges(vuln: OsvVulnerability, packageName: string, ecosystem: string): AdvisoryRanges | null;
|
|
44
|
-
/**
|
|
44
|
+
/**
|
|
45
|
+
* True when `version` lies in a span (or the explicit list) of this advisory.
|
|
46
|
+
*
|
|
47
|
+
* A listed version matches when it is the same version in the ecosystem's
|
|
48
|
+
* order, not only the same string: OSV lists certifi `2020.12.05` (from the
|
|
49
|
+
* git tag) and pip installs `2020.12.5`, the same PEP 440 version, which OSV
|
|
50
|
+
* reports as affected by PYSEC-2024-230 (4DA conformance corpus, 2026-10-12).
|
|
51
|
+
*/
|
|
45
52
|
export declare function advisoryAffects(advisory: AdvisoryRanges, version: string, ecosystem: string): boolean;
|
|
46
53
|
/** Whether a version is a prerelease in its ecosystem's own terms. */
|
|
47
54
|
export declare function isPrereleaseVersion(version: string, ecosystem: string): boolean;
|
package/dist/live/osv-ranges.js
CHANGED
|
@@ -78,11 +78,21 @@ export function advisoryRanges(vuln, packageName, ecosystem) {
|
|
|
78
78
|
maintenance: isMaintenanceNotice({ summary: vuln.summary ?? "" }) || informational === "unmaintained" || informational === "notice",
|
|
79
79
|
};
|
|
80
80
|
}
|
|
81
|
-
/**
|
|
81
|
+
/**
|
|
82
|
+
* True when `version` lies in a span (or the explicit list) of this advisory.
|
|
83
|
+
*
|
|
84
|
+
* A listed version matches when it is the same version in the ecosystem's
|
|
85
|
+
* order, not only the same string: OSV lists certifi `2020.12.05` (from the
|
|
86
|
+
* git tag) and pip installs `2020.12.5`, the same PEP 440 version, which OSV
|
|
87
|
+
* reports as affected by PYSEC-2024-230 (4DA conformance corpus, 2026-10-12).
|
|
88
|
+
*/
|
|
82
89
|
export function advisoryAffects(advisory, version, ecosystem) {
|
|
83
90
|
if (advisory.versions.includes(version))
|
|
84
91
|
return true;
|
|
85
92
|
const cmp = (a, b) => compareVersions(a, b, ecosystem);
|
|
93
|
+
if (isComparable(version, ecosystem) && advisory.versions.some((v) => v !== "0" && isComparable(v, ecosystem) && cmp(v, version) === 0)) {
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
86
96
|
return advisory.spans.some((s) => cmp(version, s.introduced) >= 0 &&
|
|
87
97
|
(s.fixed !== null ? cmp(version, s.fixed) < 0 : s.lastAffected !== null ? cmp(version, s.lastAffected) <= 0 : true));
|
|
88
98
|
}
|
|
@@ -9,22 +9,49 @@
|
|
|
9
9
|
import type { LiveCache } from "./cache.js";
|
|
10
10
|
import type { RateLimiter } from "./rate-limiter.js";
|
|
11
11
|
import type { ResolvedDependency, OsvVulnerability, VulnerabilityEntry, VulnerabilityScanResult } from "./types.js";
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
12
|
+
/**
|
|
13
|
+
* Where advisories come from. The scanner asks which advisory ids affect each
|
|
14
|
+
* exact (ecosystem, name, version), then for the records behind those ids.
|
|
15
|
+
*
|
|
16
|
+
* - OsvHttpSource (the server's): OSV.dev's own evaluator decides membership
|
|
17
|
+
* (`/v1/querybatch`), records come from `/v1/vulns/{id}`, both cached.
|
|
18
|
+
* - PinnedRecordsSource (pinned-advisories.ts): a fixed set of records on
|
|
19
|
+
* disk, membership decided locally by osv-ranges.ts. No network. The
|
|
20
|
+
* conformance harness scores the engine with it against the 4DA corpus.
|
|
21
|
+
*/
|
|
22
|
+
export interface AdvisorySource {
|
|
23
|
+
/** True when answers come over the network: rate-limited, and cached per dependency. */
|
|
24
|
+
readonly network: boolean;
|
|
25
|
+
/** The advisory ids affecting each dependency, in input order. Throws when it cannot answer for all of them. */
|
|
26
|
+
query(deps: ResolvedDependency[]): Promise<string[][]>;
|
|
27
|
+
/** The full record of each id it can find; a missing id is left out, never fatal. */
|
|
28
|
+
details(ids: string[]): Promise<Map<string, OsvVulnerability>>;
|
|
29
|
+
}
|
|
30
|
+
/** OSV.dev over HTTP: querybatch for membership, per-id hydration for records. */
|
|
31
|
+
export declare class OsvHttpSource implements AdvisorySource {
|
|
32
|
+
private readonly cache;
|
|
33
|
+
private readonly rateLimiter;
|
|
34
|
+
readonly network = true;
|
|
15
35
|
constructor(cache: LiveCache, rateLimiter: RateLimiter);
|
|
16
|
-
|
|
17
|
-
private batchQuery;
|
|
36
|
+
query(deps: ResolvedDependency[]): Promise<string[][]>;
|
|
18
37
|
/**
|
|
19
38
|
* Hydrate advisory detail for the given IDs. Cache-first (24h), then fetch the
|
|
20
39
|
* misses from `/v1/vulns/{id}` with bounded concurrency. A failed fetch is
|
|
21
40
|
* skipped (not fatal) — the caller falls back to the index-only shape so a
|
|
22
41
|
* real advisory is never silently dropped just because its detail 404'd.
|
|
23
42
|
*/
|
|
24
|
-
|
|
43
|
+
details(ids: string[]): Promise<Map<string, OsvVulnerability>>;
|
|
25
44
|
/** One advisory's record; retried once after a throttle, a server error or a network failure. Null when it cannot be had. */
|
|
26
45
|
private fetchDetail;
|
|
27
46
|
}
|
|
47
|
+
export declare class OsvScanner {
|
|
48
|
+
private cache;
|
|
49
|
+
private rateLimiter;
|
|
50
|
+
private source;
|
|
51
|
+
constructor(cache: LiveCache, rateLimiter: RateLimiter, source?: AdvisorySource);
|
|
52
|
+
scan(deps: ResolvedDependency[], projectPath: string): Promise<VulnerabilityScanResult>;
|
|
53
|
+
private batchQuery;
|
|
54
|
+
}
|
|
28
55
|
/**
|
|
29
56
|
* Overwrite the dep-context fields of a cached advisory entry with the CURRENT
|
|
30
57
|
* resolution's context. The advisory facts (id, severity, summary, fix version)
|
package/dist/live/osv-scanner.js
CHANGED
|
@@ -49,79 +49,18 @@ async function pool(items, limit, work) {
|
|
|
49
49
|
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
|
|
50
50
|
return out;
|
|
51
51
|
}
|
|
52
|
-
|
|
52
|
+
/** OSV.dev over HTTP: querybatch for membership, per-id hydration for records. */
|
|
53
|
+
export class OsvHttpSource {
|
|
53
54
|
cache;
|
|
54
55
|
rateLimiter;
|
|
56
|
+
network = true;
|
|
55
57
|
constructor(cache, rateLimiter) {
|
|
56
58
|
this.cache = cache;
|
|
57
59
|
this.rateLimiter = rateLimiter;
|
|
58
60
|
}
|
|
59
|
-
async
|
|
60
|
-
const start = Date.now();
|
|
61
|
-
const scannable = deps.filter((d) => d.version !== null);
|
|
62
|
-
const ecosystems = [...new Set(scannable.map((d) => d.ecosystem))];
|
|
63
|
-
// Check cache for each dep individually
|
|
64
|
-
const uncached = [];
|
|
65
|
-
const cachedVulns = [];
|
|
66
|
-
for (const dep of scannable) {
|
|
67
|
-
const cacheKey = `osv:${dep.ecosystem}:${dep.name}:${dep.version}`;
|
|
68
|
-
const cached = this.cache.get(cacheKey);
|
|
69
|
-
if (cached !== null) {
|
|
70
|
-
cachedVulns.push(...cached.map((v) => restampDepContext(v, dep)));
|
|
71
|
-
}
|
|
72
|
-
else {
|
|
73
|
-
uncached.push(dep);
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
// Fetch uncached from OSV
|
|
77
|
-
let fetchedVulns = [];
|
|
78
|
-
let offline = false;
|
|
79
|
-
if (uncached.length > 0) {
|
|
80
|
-
if (!this.rateLimiter.canProceed("osv")) {
|
|
81
|
-
offline = true;
|
|
82
|
-
}
|
|
83
|
-
else {
|
|
84
|
-
try {
|
|
85
|
-
fetchedVulns = await this.batchQuery(uncached);
|
|
86
|
-
}
|
|
87
|
-
catch {
|
|
88
|
-
offline = true;
|
|
89
|
-
// Try stale cache for uncached deps
|
|
90
|
-
for (const dep of uncached) {
|
|
91
|
-
const cacheKey = `osv:${dep.ecosystem}:${dep.name}:${dep.version}`;
|
|
92
|
-
const stale = this.cache.getStale(cacheKey);
|
|
93
|
-
if (stale)
|
|
94
|
-
cachedVulns.push(...stale.data.map((v) => restampDepContext(v, dep)));
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
const allVulns = [...cachedVulns, ...fetchedVulns];
|
|
100
|
-
const vulnerablePackages = new Set(allVulns.map((v) => v.package));
|
|
101
|
-
const platformInactiveVulnerable = new Set(allVulns.filter((v) => !v.platformActive).map((v) => v.package)).size;
|
|
102
|
-
const bySeverity = { critical: 0, high: 0, medium: 0, low: 0, unknown: 0 };
|
|
103
|
-
for (const v of allVulns) {
|
|
104
|
-
bySeverity[v.severity]++;
|
|
105
|
-
}
|
|
106
|
-
return {
|
|
107
|
-
scannedAt: new Date().toISOString(),
|
|
108
|
-
projectPath,
|
|
109
|
-
ecosystemsScanned: ecosystems,
|
|
110
|
-
totalScanned: scannable.length,
|
|
111
|
-
totalVulnerable: vulnerablePackages.size,
|
|
112
|
-
platformInactiveVulnerable,
|
|
113
|
-
bySeverity,
|
|
114
|
-
vulnerabilities: allVulns,
|
|
115
|
-
cleanCount: offline ? 0 : scannable.length - vulnerablePackages.size,
|
|
116
|
-
scanDurationMs: Date.now() - start,
|
|
117
|
-
cached: uncached.length === 0,
|
|
118
|
-
offline,
|
|
119
|
-
};
|
|
120
|
-
}
|
|
121
|
-
async batchQuery(deps) {
|
|
122
|
-
// Phase 1 — enumerate advisory IDs per dep via the lightweight batch index.
|
|
61
|
+
async query(deps) {
|
|
123
62
|
// querybatch returns only `{ id, modified }`; the rich fields (severity,
|
|
124
|
-
// fixed version, summary, references) come from per-advisory hydration
|
|
63
|
+
// fixed version, summary, references) come from per-advisory hydration.
|
|
125
64
|
const chunks = [];
|
|
126
65
|
for (let offset = 0; offset < deps.length; offset += MAX_BATCH_SIZE)
|
|
127
66
|
chunks.push(deps.slice(offset, offset + MAX_BATCH_SIZE));
|
|
@@ -147,27 +86,9 @@ export class OsvScanner {
|
|
|
147
86
|
throw new Error(`OSV API error: ${response.status}`);
|
|
148
87
|
}
|
|
149
88
|
const data = (await response.json());
|
|
150
|
-
return chunk.map((
|
|
89
|
+
return chunk.map((_, i) => (data.results[i]?.vulns ?? []).map((v) => v.id));
|
|
151
90
|
});
|
|
152
|
-
|
|
153
|
-
// Phase 2 — hydrate each unique advisory once (cached, bounded concurrency).
|
|
154
|
-
const uniqueIds = [...new Set(matches.flatMap((m) => m.ids))];
|
|
155
|
-
const detailById = await this.hydrateDetails(uniqueIds);
|
|
156
|
-
// Phase 3 — map to entries and cache per-dep (empty results cached too, so
|
|
157
|
-
// clean deps are not re-queried).
|
|
158
|
-
const results = [];
|
|
159
|
-
const rows = [];
|
|
160
|
-
for (const { dep, ids } of matches) {
|
|
161
|
-
// Drop advisories OSV has withdrawn/retracted — surfacing them is a false
|
|
162
|
-
// positive. Un-hydrated ids (detail fetch failed) are kept: we can't know,
|
|
163
|
-
// and dropping a real advisory is worse than keeping an un-enriched one.
|
|
164
|
-
const liveIds = ids.filter((id) => !detailById.get(id)?.withdrawn);
|
|
165
|
-
const depVulns = liveIds.map((id) => mapVulnerability(detailById.get(id) ?? { id }, dep));
|
|
166
|
-
rows.push([`osv:${dep.ecosystem}:${dep.name}:${dep.version}`, depVulns]);
|
|
167
|
-
results.push(...depVulns);
|
|
168
|
-
}
|
|
169
|
-
this.cache.setMany(rows, "osv", OSV_CACHE_TTL);
|
|
170
|
-
return results;
|
|
91
|
+
return answered.flat();
|
|
171
92
|
}
|
|
172
93
|
/**
|
|
173
94
|
* Hydrate advisory detail for the given IDs. Cache-first (24h), then fetch the
|
|
@@ -175,7 +96,7 @@ export class OsvScanner {
|
|
|
175
96
|
* skipped (not fatal) — the caller falls back to the index-only shape so a
|
|
176
97
|
* real advisory is never silently dropped just because its detail 404'd.
|
|
177
98
|
*/
|
|
178
|
-
async
|
|
99
|
+
async details(ids) {
|
|
179
100
|
const out = new Map();
|
|
180
101
|
const toFetch = [];
|
|
181
102
|
for (const id of ids) {
|
|
@@ -221,6 +142,107 @@ export class OsvScanner {
|
|
|
221
142
|
return null;
|
|
222
143
|
}
|
|
223
144
|
}
|
|
145
|
+
export class OsvScanner {
|
|
146
|
+
cache;
|
|
147
|
+
rateLimiter;
|
|
148
|
+
source;
|
|
149
|
+
constructor(cache, rateLimiter, source) {
|
|
150
|
+
this.cache = cache;
|
|
151
|
+
this.rateLimiter = rateLimiter;
|
|
152
|
+
this.source = source ?? new OsvHttpSource(cache, rateLimiter);
|
|
153
|
+
}
|
|
154
|
+
async scan(deps, projectPath) {
|
|
155
|
+
const start = Date.now();
|
|
156
|
+
const scannable = deps.filter((d) => d.version !== null);
|
|
157
|
+
const ecosystems = [...new Set(scannable.map((d) => d.ecosystem))];
|
|
158
|
+
// Per-dependency caching and the rate limit are for network answers; a
|
|
159
|
+
// pinned source answers locally and must never seed the shared cache.
|
|
160
|
+
const network = this.source.network;
|
|
161
|
+
// Check cache for each dep individually
|
|
162
|
+
const uncached = [];
|
|
163
|
+
const cachedVulns = [];
|
|
164
|
+
for (const dep of scannable) {
|
|
165
|
+
const cacheKey = `osv:${dep.ecosystem}:${dep.name}:${dep.version}`;
|
|
166
|
+
const cached = network ? this.cache.get(cacheKey) : null;
|
|
167
|
+
if (cached !== null) {
|
|
168
|
+
cachedVulns.push(...cached.map((v) => restampDepContext(v, dep)));
|
|
169
|
+
}
|
|
170
|
+
else {
|
|
171
|
+
uncached.push(dep);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
// Fetch uncached from OSV
|
|
175
|
+
let fetchedVulns = [];
|
|
176
|
+
let offline = false;
|
|
177
|
+
if (uncached.length > 0) {
|
|
178
|
+
if (network && !this.rateLimiter.canProceed("osv")) {
|
|
179
|
+
offline = true;
|
|
180
|
+
}
|
|
181
|
+
else {
|
|
182
|
+
try {
|
|
183
|
+
fetchedVulns = await this.batchQuery(uncached);
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
offline = true;
|
|
187
|
+
// Try stale cache for uncached deps
|
|
188
|
+
for (const dep of uncached) {
|
|
189
|
+
if (!network)
|
|
190
|
+
break;
|
|
191
|
+
const cacheKey = `osv:${dep.ecosystem}:${dep.name}:${dep.version}`;
|
|
192
|
+
const stale = this.cache.getStale(cacheKey);
|
|
193
|
+
if (stale)
|
|
194
|
+
cachedVulns.push(...stale.data.map((v) => restampDepContext(v, dep)));
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
const allVulns = [...cachedVulns, ...fetchedVulns];
|
|
200
|
+
const vulnerablePackages = new Set(allVulns.map((v) => v.package));
|
|
201
|
+
const platformInactiveVulnerable = new Set(allVulns.filter((v) => !v.platformActive).map((v) => v.package)).size;
|
|
202
|
+
const bySeverity = { critical: 0, high: 0, medium: 0, low: 0, unknown: 0 };
|
|
203
|
+
for (const v of allVulns) {
|
|
204
|
+
bySeverity[v.severity]++;
|
|
205
|
+
}
|
|
206
|
+
return {
|
|
207
|
+
scannedAt: new Date().toISOString(),
|
|
208
|
+
projectPath,
|
|
209
|
+
ecosystemsScanned: ecosystems,
|
|
210
|
+
totalScanned: scannable.length,
|
|
211
|
+
totalVulnerable: vulnerablePackages.size,
|
|
212
|
+
platformInactiveVulnerable,
|
|
213
|
+
bySeverity,
|
|
214
|
+
vulnerabilities: allVulns,
|
|
215
|
+
cleanCount: offline ? 0 : scannable.length - vulnerablePackages.size,
|
|
216
|
+
scanDurationMs: Date.now() - start,
|
|
217
|
+
cached: uncached.length === 0,
|
|
218
|
+
offline,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
async batchQuery(deps) {
|
|
222
|
+
// Phase 1 — enumerate advisory IDs per dep (OSV: the lightweight batch index).
|
|
223
|
+
const idsPerDep = await this.source.query(deps);
|
|
224
|
+
const matches = deps.map((dep, i) => ({ dep, ids: idsPerDep[i] ?? [] }));
|
|
225
|
+
// Phase 2 — the record behind each unique advisory, once.
|
|
226
|
+
const uniqueIds = [...new Set(matches.flatMap((m) => m.ids))];
|
|
227
|
+
const detailById = await this.source.details(uniqueIds);
|
|
228
|
+
// Phase 3 — map to entries and cache per-dep (empty results cached too, so
|
|
229
|
+
// clean deps are not re-queried; network answers only).
|
|
230
|
+
const results = [];
|
|
231
|
+
const rows = [];
|
|
232
|
+
for (const { dep, ids } of matches) {
|
|
233
|
+
// Drop advisories OSV has withdrawn/retracted — surfacing them is a false
|
|
234
|
+
// positive. Un-hydrated ids (detail fetch failed) are kept: we can't know,
|
|
235
|
+
// and dropping a real advisory is worse than keeping an un-enriched one.
|
|
236
|
+
const liveIds = ids.filter((id) => !detailById.get(id)?.withdrawn);
|
|
237
|
+
const depVulns = liveIds.map((id) => mapVulnerability(detailById.get(id) ?? { id }, dep));
|
|
238
|
+
rows.push([`osv:${dep.ecosystem}:${dep.name}:${dep.version}`, depVulns]);
|
|
239
|
+
results.push(...depVulns);
|
|
240
|
+
}
|
|
241
|
+
if (this.source.network)
|
|
242
|
+
this.cache.setMany(rows, "osv", OSV_CACHE_TTL);
|
|
243
|
+
return results;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
224
246
|
/**
|
|
225
247
|
* Overwrite the dep-context fields of a cached advisory entry with the CURRENT
|
|
226
248
|
* resolution's context. The advisory facts (id, severity, summary, fix version)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An advisory source that serves a fixed set of OSV records and decides
|
|
3
|
+
* membership itself, with no network.
|
|
4
|
+
*
|
|
5
|
+
* The server asks OSV.dev which advisories affect a version (OsvHttpSource,
|
|
6
|
+
* osv-scanner.ts). The 4DA conformance corpus pins every OSV record that names
|
|
7
|
+
* one of its packages, byte for byte as api.osv.dev served it, and its truth
|
|
8
|
+
* is OSV's own evaluation of each installed version. Scoring the engine
|
|
9
|
+
* against that corpus offline needs the same question answered locally: which
|
|
10
|
+
* pinned records affect this exact (ecosystem, name, version)? That is the
|
|
11
|
+
* job of osv-ranges.ts (spans and explicit version lists, ordered the
|
|
12
|
+
* ecosystem's way), so this also holds the local evaluator to OSV's answers.
|
|
13
|
+
*
|
|
14
|
+
* Withdrawn records are still returned by `query`: the scanner drops them
|
|
15
|
+
* after reading the record, exactly as it does with OSV's answers.
|
|
16
|
+
*/
|
|
17
|
+
import type { AdvisorySource } from "./osv-scanner.js";
|
|
18
|
+
import type { OsvVulnerability, ResolvedDependency } from "./types.js";
|
|
19
|
+
export declare class PinnedRecordsSource implements AdvisorySource {
|
|
20
|
+
readonly network = false;
|
|
21
|
+
private readonly byId;
|
|
22
|
+
private readonly byPackage;
|
|
23
|
+
constructor(records: Iterable<OsvVulnerability>);
|
|
24
|
+
/** Every `*.json` OSV record in a directory (the corpus's `osv/`). */
|
|
25
|
+
static fromDirectory(dir: string): PinnedRecordsSource;
|
|
26
|
+
get size(): number;
|
|
27
|
+
/** The ids of the pinned records that affect this exact version (withdrawn ones included). */
|
|
28
|
+
affecting(ecosystem: string, name: string, version: string): string[];
|
|
29
|
+
query(deps: ResolvedDependency[]): Promise<string[][]>;
|
|
30
|
+
details(ids: string[]): Promise<Map<string, OsvVulnerability>>;
|
|
31
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/**
|
|
3
|
+
* An advisory source that serves a fixed set of OSV records and decides
|
|
4
|
+
* membership itself, with no network.
|
|
5
|
+
*
|
|
6
|
+
* The server asks OSV.dev which advisories affect a version (OsvHttpSource,
|
|
7
|
+
* osv-scanner.ts). The 4DA conformance corpus pins every OSV record that names
|
|
8
|
+
* one of its packages, byte for byte as api.osv.dev served it, and its truth
|
|
9
|
+
* is OSV's own evaluation of each installed version. Scoring the engine
|
|
10
|
+
* against that corpus offline needs the same question answered locally: which
|
|
11
|
+
* pinned records affect this exact (ecosystem, name, version)? That is the
|
|
12
|
+
* job of osv-ranges.ts (spans and explicit version lists, ordered the
|
|
13
|
+
* ecosystem's way), so this also holds the local evaluator to OSV's answers.
|
|
14
|
+
*
|
|
15
|
+
* Withdrawn records are still returned by `query`: the scanner drops them
|
|
16
|
+
* after reading the record, exactly as it does with OSV's answers.
|
|
17
|
+
*/
|
|
18
|
+
import * as fs from "node:fs";
|
|
19
|
+
import * as path from "node:path";
|
|
20
|
+
import { advisoryAffects, collectAffectedSpans } from "./osv-ranges.js";
|
|
21
|
+
import { samePackageName } from "./version-compare.js";
|
|
22
|
+
/** The registry's identity for a package name, as an index key (samePackageName's rules). */
|
|
23
|
+
function nameKey(ecosystem, name) {
|
|
24
|
+
if (ecosystem === "PyPI")
|
|
25
|
+
return name.toLowerCase().replace(/[-_.]+/g, "-");
|
|
26
|
+
if (ecosystem === "crates.io")
|
|
27
|
+
return name.toLowerCase().replace(/_/g, "-");
|
|
28
|
+
return name;
|
|
29
|
+
}
|
|
30
|
+
/** Explicitly listed versions for one package of one record. */
|
|
31
|
+
function listedVersions(vuln, name, ecosystem) {
|
|
32
|
+
return (vuln.affected ?? [])
|
|
33
|
+
.filter((a) => a.package.ecosystem === ecosystem && samePackageName(a.package.name, name, ecosystem))
|
|
34
|
+
.flatMap((a) => a.versions ?? []);
|
|
35
|
+
}
|
|
36
|
+
export class PinnedRecordsSource {
|
|
37
|
+
network = false;
|
|
38
|
+
byId = new Map();
|
|
39
|
+
byPackage = new Map();
|
|
40
|
+
constructor(records) {
|
|
41
|
+
for (const record of records) {
|
|
42
|
+
this.byId.set(record.id, record);
|
|
43
|
+
const keys = new Set((record.affected ?? []).map((a) => `${a.package.ecosystem}\0${nameKey(a.package.ecosystem, a.package.name)}`));
|
|
44
|
+
for (const key of keys) {
|
|
45
|
+
const list = this.byPackage.get(key);
|
|
46
|
+
if (list)
|
|
47
|
+
list.push(record);
|
|
48
|
+
else
|
|
49
|
+
this.byPackage.set(key, [record]);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/** Every `*.json` OSV record in a directory (the corpus's `osv/`). */
|
|
54
|
+
static fromDirectory(dir) {
|
|
55
|
+
const files = fs.readdirSync(dir).filter((f) => f.endsWith(".json")).sort();
|
|
56
|
+
return new PinnedRecordsSource(files.map((f) => JSON.parse(fs.readFileSync(path.join(dir, f), "utf8"))));
|
|
57
|
+
}
|
|
58
|
+
get size() {
|
|
59
|
+
return this.byId.size;
|
|
60
|
+
}
|
|
61
|
+
/** The ids of the pinned records that affect this exact version (withdrawn ones included). */
|
|
62
|
+
affecting(ecosystem, name, version) {
|
|
63
|
+
const candidates = this.byPackage.get(`${ecosystem}\0${nameKey(ecosystem, name)}`) ?? [];
|
|
64
|
+
return candidates
|
|
65
|
+
.filter((vuln) => advisoryAffects({ id: vuln.id, spans: collectAffectedSpans(vuln.affected, name, ecosystem), versions: listedVersions(vuln, name, ecosystem) }, version, ecosystem))
|
|
66
|
+
.map((vuln) => vuln.id);
|
|
67
|
+
}
|
|
68
|
+
async query(deps) {
|
|
69
|
+
return deps.map((dep) => (dep.version === null ? [] : this.affecting(dep.ecosystem, dep.name, dep.version)));
|
|
70
|
+
}
|
|
71
|
+
async details(ids) {
|
|
72
|
+
const out = new Map();
|
|
73
|
+
for (const id of ids) {
|
|
74
|
+
const record = this.byId.get(id);
|
|
75
|
+
if (record)
|
|
76
|
+
out.set(id, record);
|
|
77
|
+
}
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@4da/mcp-server",
|
|
3
|
-
"version": "6.2.
|
|
3
|
+
"version": "6.2.3",
|
|
4
4
|
"mcpName": "io.github.4DA-Systems/4da-mcp-server",
|
|
5
5
|
"description": "Upgrade intelligence for AI coding agents: what changes between your installed and target version (changelog, breaking changes, the files you import it from), a pre-install verdict on each bump, vulnerability scanning at osv-scanner parity across npm/pnpm/yarn, Cargo, Python and Go lockfiles, ranked upgrade plans, task-scoped briefings, decision memory. Local, no account; works standalone, and adds the 4DA desktop app's signals, knowledge gaps and stack-judged news when it is installed.",
|
|
6
6
|
"type": "module",
|