@4da/mcp-server 6.2.3 → 6.2.5
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 +520 -0
- package/README.md +22 -6
- package/dist/db.d.ts +38 -0
- package/dist/db.js +78 -23
- package/dist/doctor.js +6 -3
- package/dist/embeddings.d.ts +1 -1
- package/dist/embeddings.js +16 -14
- package/dist/index.js +73 -103
- package/dist/live/advisory-groups.d.ts +68 -0
- package/dist/live/advisory-groups.js +131 -0
- package/dist/live/cache.js +11 -3
- package/dist/live/cargo-platform.d.ts +6 -0
- package/dist/live/cargo-platform.js +8 -2
- package/dist/live/changelog.js +38 -6
- package/dist/live/dependency-set.d.ts +9 -1
- package/dist/live/dependency-set.js +10 -2
- package/dist/live/fix-paths.d.ts +289 -16
- package/dist/live/fix-paths.js +876 -93
- package/dist/live/github-releases.js +3 -0
- package/dist/live/held-dependencies.d.ts +93 -0
- package/dist/live/held-dependencies.js +212 -0
- package/dist/live/held-targets.d.ts +30 -0
- package/dist/live/held-targets.js +107 -0
- package/dist/live/http-utils.js +13 -3
- package/dist/live/index.d.ts +7 -4
- package/dist/live/index.js +24 -19
- package/dist/live/install-state.js +6 -2
- package/dist/live/js-lockfile-readers.d.ts +8 -1
- package/dist/live/js-lockfile-readers.js +43 -6
- package/dist/live/lockfile-parents.d.ts +15 -0
- package/dist/live/lockfile-parents.js +85 -10
- package/dist/live/lockfile-parsers-pygo.d.ts +47 -2
- package/dist/live/lockfile-parsers-pygo.js +230 -12
- package/dist/live/lockfile-parsers-rbphp.d.ts +38 -0
- package/dist/live/lockfile-parsers-rbphp.js +123 -0
- package/dist/live/lockfile-parsers.d.ts +6 -0
- package/dist/live/lockfile-parsers.js +116 -4
- package/dist/live/lockfile-types.d.ts +28 -1
- package/dist/live/maintenance.d.ts +20 -4
- package/dist/live/maintenance.js +31 -2
- package/dist/live/malware.d.ts +20 -0
- package/dist/live/malware.js +31 -0
- package/dist/live/npm-packument.d.ts +4 -1
- package/dist/live/npm-packument.js +5 -2
- package/dist/live/offered-release.d.ts +53 -0
- package/dist/live/offered-release.js +117 -0
- package/dist/live/osv-ranges.d.ts +111 -0
- package/dist/live/osv-ranges.js +173 -14
- package/dist/live/osv-scanner.d.ts +87 -5
- package/dist/live/osv-scanner.js +310 -59
- package/dist/live/pinned-advisories.d.ts +2 -2
- package/dist/live/pinned-advisories.js +3 -3
- package/dist/live/pypi-advice.d.ts +36 -0
- package/dist/live/pypi-advice.js +158 -0
- package/dist/live/range-satisfies.d.ts +4 -2
- package/dist/live/range-satisfies.js +7 -5
- package/dist/live/rate-limiter.d.ts +7 -0
- package/dist/live/rate-limiter.js +22 -1
- package/dist/live/refresh-rule.d.ts +36 -0
- package/dist/live/refresh-rule.js +49 -0
- package/dist/live/release-metadata.d.ts +5 -0
- package/dist/live/resolution.d.ts +3 -0
- package/dist/live/resolution.js +6 -3
- package/dist/live/scan-coverage.d.ts +26 -0
- package/dist/live/scan-coverage.js +70 -0
- package/dist/live/semver-utils.d.ts +8 -0
- package/dist/live/semver-utils.js +26 -3
- package/dist/live/severity-scope.d.ts +26 -1
- package/dist/live/severity-scope.js +32 -0
- package/dist/live/shell-safe.d.ts +19 -0
- package/dist/live/shell-safe.js +36 -0
- package/dist/live/types.d.ts +134 -8
- package/dist/live/version-compare.d.ts +51 -0
- package/dist/live/version-compare.js +152 -1
- package/dist/live/withdrawn-releases.d.ts +50 -0
- package/dist/live/withdrawn-releases.js +125 -0
- package/dist/project-manifests.d.ts +7 -0
- package/dist/project-manifests.js +45 -5
- package/dist/project-scanner.js +6 -4
- package/dist/project-tree.d.ts +56 -0
- package/dist/project-tree.js +174 -5
- package/dist/schema-registry.d.ts +47 -4
- package/dist/schema-registry.js +76 -9
- package/dist/server-instructions.js +4 -2
- package/dist/setup.d.ts +8 -0
- package/dist/setup.js +21 -2
- package/dist/tool-dispatch.js +3 -3
- package/dist/tools/agent-memory.d.ts +5 -0
- package/dist/tools/agent-memory.js +9 -0
- package/dist/tools/briefing-task-scope.js +5 -2
- package/dist/tools/decision-memory.js +2 -0
- package/dist/tools/dependency-check-held.d.ts +44 -0
- package/dist/tools/dependency-check-held.js +98 -0
- package/dist/tools/dependency-check-signals.d.ts +19 -2
- package/dist/tools/dependency-check-signals.js +84 -13
- package/dist/tools/dependency-check-versions.d.ts +37 -4
- package/dist/tools/dependency-check-versions.js +48 -13
- package/dist/tools/dependency-check.d.ts +11 -1
- package/dist/tools/dependency-check.js +81 -29
- package/dist/tools/dependency-health.js +209 -45
- package/dist/tools/get-context.d.ts +3 -0
- package/dist/tools/get-context.js +4 -0
- package/dist/tools/project-scope.d.ts +8 -0
- package/dist/tools/project-scope.js +21 -0
- package/dist/tools/upgrade-impact-features.d.ts +37 -0
- package/dist/tools/upgrade-impact-features.js +176 -0
- package/dist/tools/upgrade-impact-literals.js +26 -4
- package/dist/tools/upgrade-impact-report.d.ts +5 -1
- package/dist/tools/upgrade-impact-report.js +26 -5
- package/dist/tools/upgrade-impact.js +10 -3
- package/dist/tools/upgrade-planner-fixpaths.d.ts +164 -5
- package/dist/tools/upgrade-planner-fixpaths.js +0 -0
- package/dist/tools/upgrade-planner.d.ts +70 -7
- package/dist/tools/upgrade-planner.js +237 -61
- package/dist/tools/vulnerability-scan-concise.d.ts +47 -2
- package/dist/tools/vulnerability-scan-concise.js +37 -3
- package/dist/tools/vulnerability-scan-format.d.ts +126 -24
- package/dist/tools/vulnerability-scan-format.js +193 -26
- package/dist/tools/vulnerability-scan.d.ts +224 -63
- package/dist/tools/vulnerability-scan.js +50 -15
- package/dist/tools/what-should-i-know.d.ts +6 -1
- package/dist/tools/what-should-i-know.js +72 -15
- package/package.json +2 -2
|
@@ -9,6 +9,15 @@
|
|
|
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
|
+
/** GHSA alias records read per scan to rate records that carry no severity (rateFromAliases). */
|
|
13
|
+
export declare const MAX_ALIAS_LOOKUPS = 50;
|
|
14
|
+
/** The whole alias lookup's time budget per scan; past it the records stay unknown, said so. */
|
|
15
|
+
export declare const ALIAS_BUDGET_MS = 5000;
|
|
16
|
+
/** Per-dependency advisory ids, null where there is no answer, and why. */
|
|
17
|
+
export interface AdvisoryAnswer {
|
|
18
|
+
ids: Array<string[] | null>;
|
|
19
|
+
failure: string | null;
|
|
20
|
+
}
|
|
12
21
|
/**
|
|
13
22
|
* Where advisories come from. The scanner asks which advisory ids affect each
|
|
14
23
|
* exact (ecosystem, name, version), then for the records behind those ids.
|
|
@@ -22,8 +31,13 @@ import type { ResolvedDependency, OsvVulnerability, VulnerabilityEntry, Vulnerab
|
|
|
22
31
|
export interface AdvisorySource {
|
|
23
32
|
/** True when answers come over the network: rate-limited, and cached per dependency. */
|
|
24
33
|
readonly network: boolean;
|
|
25
|
-
/**
|
|
26
|
-
|
|
34
|
+
/**
|
|
35
|
+
* The advisory ids affecting each dependency, in input order. An entry is
|
|
36
|
+
* null when the source has NO answer for that dependency (unreachable,
|
|
37
|
+
* failed, timed out, out of query budget): never an empty list, which means
|
|
38
|
+
* "checked, nothing found". `failure` says why when any entry is null.
|
|
39
|
+
*/
|
|
40
|
+
query(deps: ResolvedDependency[]): Promise<AdvisoryAnswer>;
|
|
27
41
|
/** The full record of each id it can find; a missing id is left out, never fatal. */
|
|
28
42
|
details(ids: string[]): Promise<Map<string, OsvVulnerability>>;
|
|
29
43
|
}
|
|
@@ -31,9 +45,15 @@ export interface AdvisorySource {
|
|
|
31
45
|
export declare class OsvHttpSource implements AdvisorySource {
|
|
32
46
|
private readonly cache;
|
|
33
47
|
private readonly rateLimiter;
|
|
48
|
+
/** Total wait for query budget per scan (tests shorten it). */
|
|
49
|
+
private readonly tokenWaitMs;
|
|
34
50
|
readonly network = true;
|
|
35
|
-
constructor(cache: LiveCache, rateLimiter: RateLimiter
|
|
36
|
-
query(
|
|
51
|
+
constructor(cache: LiveCache, rateLimiter: RateLimiter,
|
|
52
|
+
/** Total wait for query budget per scan (tests shorten it). */
|
|
53
|
+
tokenWaitMs?: number);
|
|
54
|
+
query(deps: ResolvedDependency[]): Promise<AdvisoryAnswer>;
|
|
55
|
+
/** One querybatch request, retried once after a throttle, a server error, a timeout or a network failure. */
|
|
56
|
+
private sendBatch;
|
|
37
57
|
/**
|
|
38
58
|
* Hydrate advisory detail for the given IDs. Cache-first (24h), then fetch the
|
|
39
59
|
* misses from `/v1/vulns/{id}` with bounded concurrency. A failed fetch is
|
|
@@ -48,8 +68,29 @@ export declare class OsvScanner {
|
|
|
48
68
|
private cache;
|
|
49
69
|
private rateLimiter;
|
|
50
70
|
private source;
|
|
51
|
-
|
|
71
|
+
private aliasBudgetMs;
|
|
72
|
+
constructor(cache: LiveCache, rateLimiter: RateLimiter, source?: AdvisorySource, options?: {
|
|
73
|
+
aliasBudgetMs?: number;
|
|
74
|
+
});
|
|
52
75
|
scan(deps: ResolvedDependency[], projectPath: string): Promise<VulnerabilityScanResult>;
|
|
76
|
+
/**
|
|
77
|
+
* A severity for records that carry none, from their GHSA alias: one
|
|
78
|
+
* advisory, one grade, in every tool. Go's vulnerability database rates
|
|
79
|
+
* nothing: GO-2024-3333 (golang.org/x/net) has no severity, its alias
|
|
80
|
+
* GHSA-w32m-9786-jp63 is HIGH, and OSV returns only the GO record for the
|
|
81
|
+
* module (the GHSA names golang.org/x/net/html). dependency_check said
|
|
82
|
+
* `avoid` while vulnerability_scan graded the same record unknown (review
|
|
83
|
+
* of 6.2.5 M6, 2026-10-18). The two records name each other, so the
|
|
84
|
+
* alias's rating applies; it is read by the same rule as any record and
|
|
85
|
+
* the entry says where it came from (`severityFrom`).
|
|
86
|
+
*
|
|
87
|
+
* Through this scanner's own source: a pinned source answers from its
|
|
88
|
+
* records, never the network; with live lookups off nothing is fetched.
|
|
89
|
+
* At most MAX_ALIAS_LOOKUPS aliases, within ALIAS_BUDGET_MS in total.
|
|
90
|
+
* Anything not rated stays unknown and is listed as unresolved, with why;
|
|
91
|
+
* unknown is never clean.
|
|
92
|
+
*/
|
|
93
|
+
private rateFromAliases;
|
|
53
94
|
private batchQuery;
|
|
54
95
|
}
|
|
55
96
|
/**
|
|
@@ -63,6 +104,33 @@ export declare class OsvScanner {
|
|
|
63
104
|
* next hour repeated both errors while the fixed resolver sat idle.
|
|
64
105
|
*/
|
|
65
106
|
export declare function restampDepContext(cached: VulnerabilityEntry, dep: ResolvedDependency): VulnerabilityEntry;
|
|
107
|
+
/**
|
|
108
|
+
* The shared rule for an unrated record (the 4DA app implements it
|
|
109
|
+
* identically): the HIGHEST grade among every GHSA alias that could be read;
|
|
110
|
+
* unknown when none could. `from` names the alias that gave it, the
|
|
111
|
+
* lexicographically smallest id on a tie. An alias that could not be read
|
|
112
|
+
* never lowers the grade; it is reported (`consulted`, grade "unreadable").
|
|
113
|
+
* Umbrella records name several: RUSTSEC-2020-0014 lists 8 GHSAs, so taking
|
|
114
|
+
* the first made the grade depend on alias order.
|
|
115
|
+
*/
|
|
116
|
+
export declare function gradeFromAliases(ghsas: string[], read: ReadonlyMap<string, VulnerabilityEntry["severity"]>): {
|
|
117
|
+
severity: VulnerabilityEntry["severity"];
|
|
118
|
+
from: string | null;
|
|
119
|
+
consulted: Array<{
|
|
120
|
+
id: string;
|
|
121
|
+
grade: VulnerabilityEntry["severity"] | "unreadable";
|
|
122
|
+
}>;
|
|
123
|
+
};
|
|
124
|
+
/**
|
|
125
|
+
* Derive a severity bucket honestly: a CVSS base score (numeric or computed
|
|
126
|
+
* from a vector) wins; otherwise the GitHub-advisory label; otherwise unknown.
|
|
127
|
+
* Never fabricates a default bucket — an advisory with no severity signal is
|
|
128
|
+
* reported as `unknown`, not silently labeled "medium".
|
|
129
|
+
*/
|
|
130
|
+
export declare function deriveSeverity(vuln: Partial<OsvVulnerability>): {
|
|
131
|
+
severity: VulnerabilityEntry["severity"];
|
|
132
|
+
cvssScore: number | null;
|
|
133
|
+
};
|
|
66
134
|
export declare function extractCvssScore(severity: Array<{
|
|
67
135
|
type: string;
|
|
68
136
|
score: string;
|
|
@@ -86,3 +154,17 @@ export declare function extractCvssScore(severity: Array<{
|
|
|
86
154
|
* Hard guard: the returned version is never below the installed version.
|
|
87
155
|
*/
|
|
88
156
|
export declare function extractFixedVersion(affected: OsvVulnerability["affected"] | undefined, packageName: string, ecosystem: string, installedVersion: string | null): string | null;
|
|
157
|
+
/**
|
|
158
|
+
* npm's security holding placeholder: after a malicious package is taken down,
|
|
159
|
+
* npm republishes the name as an empty `0.0.<n>-security` release ("security
|
|
160
|
+
* holding package"). It has no code, so NO advisory applies to it, whichever
|
|
161
|
+
* database's id flags the name: crossenv 0.0.2-security under
|
|
162
|
+
* GHSA-c2m4-w5hm-vqjw read as installed malware when only MAL- ids were held
|
|
163
|
+
* (4DA audit L4, 2026-10-16). Only npm's own shape counts: a publisher's
|
|
164
|
+
* `1.0.0-security` prerelease is real code and stays affected. The same rule
|
|
165
|
+
* as the app's `fourda_depengine::matching::is_npm_security_holding`
|
|
166
|
+
* (shared vectors `holding.jsonl`).
|
|
167
|
+
*/
|
|
168
|
+
export declare function isSecurityHoldingRelease(ecosystem: string, version: string): boolean;
|
|
169
|
+
/** A finding on an npm security holding placeholder (any advisory id): not installed code. */
|
|
170
|
+
export declare function isNpmSecurityHolding(v: Pick<VulnerabilityEntry, "ecosystem" | "currentVersion">): boolean;
|
package/dist/live/osv-scanner.js
CHANGED
|
@@ -9,8 +9,11 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import { fetchWithTimeout } from "./http-utils.js";
|
|
11
11
|
import { cvssBaseScore } from "./cvss.js";
|
|
12
|
-
import { compareVersions, isComparable, maxVersion } from "./version-compare.js";
|
|
12
|
+
import { compareToLowerBound, compareVersions, isComparable, maxVersion } from "./version-compare.js";
|
|
13
13
|
import { collectAffectedSpans } from "./osv-ranges.js";
|
|
14
|
+
import { isMaintenanceNotice, isMaintenanceRecord } from "./maintenance.js";
|
|
15
|
+
import { gradeAliasGroups } from "./advisory-groups.js";
|
|
16
|
+
import { cweIdsOf, isMalware } from "./malware.js";
|
|
14
17
|
const OSV_BATCH_URL = "https://api.osv.dev/v1/querybatch";
|
|
15
18
|
const OSV_VULN_URL = "https://api.osv.dev/v1/vulns/";
|
|
16
19
|
const OSV_TIMEOUT_MS = 15_000;
|
|
@@ -29,6 +32,10 @@ const HYDRATE_CONCURRENCY = 24;
|
|
|
29
32
|
/** querybatch chunks (1,000 dependencies each) sent at once. */
|
|
30
33
|
const BATCH_CONCURRENCY = 4;
|
|
31
34
|
const DETAIL_RETRY_DELAY_MS = 750;
|
|
35
|
+
/** GHSA alias records read per scan to rate records that carry no severity (rateFromAliases). */
|
|
36
|
+
export const MAX_ALIAS_LOOKUPS = 50;
|
|
37
|
+
/** The whole alias lookup's time budget per scan; past it the records stay unknown, said so. */
|
|
38
|
+
export const ALIAS_BUDGET_MS = 5_000;
|
|
32
39
|
/**
|
|
33
40
|
* Detail fetches in flight, shared by every scanner in the process. The
|
|
34
41
|
* startup warm-up scan and the first tool call's scan used to run side by
|
|
@@ -49,14 +56,39 @@ async function pool(items, limit, work) {
|
|
|
49
56
|
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
|
|
50
57
|
return out;
|
|
51
58
|
}
|
|
59
|
+
/** How long a scan may wait, in total, for query-budget tokens before the rest of its batches go unanswered. */
|
|
60
|
+
const MAX_TOKEN_WAIT_MS = 60_000;
|
|
61
|
+
/** A failed querybatch request (network, timeout, 429, 5xx) is retried once after this. */
|
|
62
|
+
const BATCH_RETRY_DELAY_MS = 1_000;
|
|
63
|
+
/** Why one querybatch request failed, in words a reader can act on. */
|
|
64
|
+
function describeFailure(error) {
|
|
65
|
+
if (error instanceof OsvHttpError)
|
|
66
|
+
return `OSV.dev returned HTTP ${error.status}`;
|
|
67
|
+
const name = error?.name;
|
|
68
|
+
if (name === "TimeoutError" || name === "AbortError")
|
|
69
|
+
return `OSV.dev did not answer within ${OSV_TIMEOUT_MS / 1000} s`;
|
|
70
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
71
|
+
return `OSV.dev unreachable (${message.slice(0, 120)})`;
|
|
72
|
+
}
|
|
73
|
+
class OsvHttpError extends Error {
|
|
74
|
+
status;
|
|
75
|
+
constructor(status) {
|
|
76
|
+
super(`OSV API error: ${status}`);
|
|
77
|
+
this.status = status;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
52
80
|
/** OSV.dev over HTTP: querybatch for membership, per-id hydration for records. */
|
|
53
81
|
export class OsvHttpSource {
|
|
54
82
|
cache;
|
|
55
83
|
rateLimiter;
|
|
84
|
+
tokenWaitMs;
|
|
56
85
|
network = true;
|
|
57
|
-
constructor(cache, rateLimiter
|
|
86
|
+
constructor(cache, rateLimiter,
|
|
87
|
+
/** Total wait for query budget per scan (tests shorten it). */
|
|
88
|
+
tokenWaitMs = MAX_TOKEN_WAIT_MS) {
|
|
58
89
|
this.cache = cache;
|
|
59
90
|
this.rateLimiter = rateLimiter;
|
|
91
|
+
this.tokenWaitMs = tokenWaitMs;
|
|
60
92
|
}
|
|
61
93
|
async query(deps) {
|
|
62
94
|
// querybatch returns only `{ id, modified }`; the rich fields (severity,
|
|
@@ -64,31 +96,72 @@ export class OsvHttpSource {
|
|
|
64
96
|
const chunks = [];
|
|
65
97
|
for (let offset = 0; offset < deps.length; offset += MAX_BATCH_SIZE)
|
|
66
98
|
chunks.push(deps.slice(offset, offset + MAX_BATCH_SIZE));
|
|
67
|
-
//
|
|
68
|
-
// the
|
|
69
|
-
//
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
99
|
+
// Batches go out as query budget allows, waiting for it to refill (bounded),
|
|
100
|
+
// instead of the whole scan failing when a tree needs more batches than the
|
|
101
|
+
// bucket holds. A batch that gets no budget, or fails twice, is unanswered:
|
|
102
|
+
// its dependencies come back null, and the scan reports them as unchecked.
|
|
103
|
+
const deadline = Date.now() + this.tokenWaitMs;
|
|
104
|
+
let failure = null;
|
|
105
|
+
// A batch that failed twice means OSV.dev is down or refusing: batches not
|
|
106
|
+
// yet sent are not tried (an outage would otherwise cost a timeout each).
|
|
107
|
+
let outage = false;
|
|
75
108
|
const answered = await pool(chunks, BATCH_CONCURRENCY, async (chunk) => {
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
|
|
109
|
+
if (outage)
|
|
110
|
+
return chunk.map(() => null);
|
|
111
|
+
if (!(await this.rateLimiter.acquire("osv", deadline))) {
|
|
112
|
+
failure ??= `the OSV.dev query budget (${MAX_BATCH_SIZE.toLocaleString("en-US")} dependencies per request) ran out for ${Math.round(this.tokenWaitMs / 1000)} s`;
|
|
113
|
+
return chunk.map(() => null);
|
|
114
|
+
}
|
|
115
|
+
try {
|
|
116
|
+
return await this.sendBatch(chunk);
|
|
117
|
+
}
|
|
118
|
+
catch (error) {
|
|
119
|
+
failure = describeFailure(error);
|
|
120
|
+
outage = true;
|
|
121
|
+
return chunk.map(() => null);
|
|
87
122
|
}
|
|
88
|
-
const data = (await response.json());
|
|
89
|
-
return chunk.map((_, i) => (data.results[i]?.vulns ?? []).map((v) => v.id));
|
|
90
123
|
});
|
|
91
|
-
return answered.flat();
|
|
124
|
+
return { ids: answered.flat(), failure };
|
|
125
|
+
}
|
|
126
|
+
/** One querybatch request, retried once after a throttle, a server error, a timeout or a network failure. */
|
|
127
|
+
async sendBatch(chunk) {
|
|
128
|
+
const queries = chunk.map((d) => ({
|
|
129
|
+
package: { name: d.name, ecosystem: d.ecosystem },
|
|
130
|
+
...(d.version ? { version: d.version } : {}),
|
|
131
|
+
}));
|
|
132
|
+
let lastError = null;
|
|
133
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
134
|
+
if (attempt > 0)
|
|
135
|
+
await new Promise((resolve) => setTimeout(resolve, BATCH_RETRY_DELAY_MS));
|
|
136
|
+
try {
|
|
137
|
+
const response = await fetchWithTimeout(OSV_BATCH_URL, {
|
|
138
|
+
method: "POST",
|
|
139
|
+
headers: { "Content-Type": "application/json" },
|
|
140
|
+
body: JSON.stringify({ queries }),
|
|
141
|
+
}, OSV_TIMEOUT_MS);
|
|
142
|
+
if (!response.ok) {
|
|
143
|
+
lastError = new OsvHttpError(response.status);
|
|
144
|
+
// A client error other than a throttle will not change on a retry.
|
|
145
|
+
if (response.status !== 429 && response.status < 500)
|
|
146
|
+
break;
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
const data = (await response.json());
|
|
150
|
+
if (!Array.isArray(data?.results) || data.results.length !== chunk.length) {
|
|
151
|
+
throw new Error(`OSV.dev answered ${Array.isArray(data?.results) ? data.results.length : "no"} results for ${chunk.length} queries`);
|
|
152
|
+
}
|
|
153
|
+
// `{}` is OSV's own "nothing found"; null, a string or a non-array `vulns` is not an answer
|
|
154
|
+
// and was read as clean, then cached for an hour (audit 6.2.5 B-4).
|
|
155
|
+
if (data.results.some((r) => r === null || typeof r !== "object" || ("vulns" in r && !Array.isArray(r.vulns)))) {
|
|
156
|
+
throw new Error("OSV.dev answered a malformed result");
|
|
157
|
+
}
|
|
158
|
+
return chunk.map((_, i) => (data.results[i]?.vulns ?? []).map((v) => v.id));
|
|
159
|
+
}
|
|
160
|
+
catch (error) {
|
|
161
|
+
lastError = error;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
throw lastError ?? new Error("OSV.dev querybatch failed");
|
|
92
165
|
}
|
|
93
166
|
/**
|
|
94
167
|
* Hydrate advisory detail for the given IDs. Cache-first (24h), then fetch the
|
|
@@ -127,8 +200,14 @@ export class OsvHttpSource {
|
|
|
127
200
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
128
201
|
try {
|
|
129
202
|
const response = await fetchWithTimeout(`${OSV_VULN_URL}${encodeURIComponent(id)}`, {}, OSV_TIMEOUT_MS);
|
|
130
|
-
if (response.ok)
|
|
131
|
-
|
|
203
|
+
if (response.ok) {
|
|
204
|
+
// Only the record asked for is an answer: a 200 `{}` (or any other record) crashed the
|
|
205
|
+
// scan downstream and was cached for 24 h (audit 6.2.5 B-8). Anything else is retried.
|
|
206
|
+
const record = (await response.json());
|
|
207
|
+
if (record && typeof record === "object" && record.id === id)
|
|
208
|
+
return record;
|
|
209
|
+
throw new Error(`OSV.dev answered a malformed record for ${id}`);
|
|
210
|
+
}
|
|
132
211
|
// 404 and other client errors are answers, not failures to retry.
|
|
133
212
|
if (response.status !== 429 && response.status < 500)
|
|
134
213
|
return null;
|
|
@@ -146,7 +225,9 @@ export class OsvScanner {
|
|
|
146
225
|
cache;
|
|
147
226
|
rateLimiter;
|
|
148
227
|
source;
|
|
149
|
-
|
|
228
|
+
aliasBudgetMs;
|
|
229
|
+
constructor(cache, rateLimiter, source, options = {}) {
|
|
230
|
+
this.aliasBudgetMs = options.aliasBudgetMs ?? ALIAS_BUDGET_MS;
|
|
150
231
|
this.cache = cache;
|
|
151
232
|
this.rateLimiter = rateLimiter;
|
|
152
233
|
this.source = source ?? new OsvHttpSource(cache, rateLimiter);
|
|
@@ -162,7 +243,7 @@ export class OsvScanner {
|
|
|
162
243
|
const uncached = [];
|
|
163
244
|
const cachedVulns = [];
|
|
164
245
|
for (const dep of scannable) {
|
|
165
|
-
const cacheKey = `osv:${dep.ecosystem}:${dep.name}:${dep.version}`;
|
|
246
|
+
const cacheKey = `osv:v3:${dep.ecosystem}:${dep.name}:${dep.version}`;
|
|
166
247
|
const cached = network ? this.cache.get(cacheKey) : null;
|
|
167
248
|
if (cached !== null) {
|
|
168
249
|
cachedVulns.push(...cached.map((v) => restampDepContext(v, dep)));
|
|
@@ -171,32 +252,51 @@ export class OsvScanner {
|
|
|
171
252
|
uncached.push(dep);
|
|
172
253
|
}
|
|
173
254
|
}
|
|
174
|
-
// Fetch uncached from OSV
|
|
255
|
+
// Fetch uncached from OSV. Dependencies it has no answer for fall back to
|
|
256
|
+
// an expired cached answer; with none they are UNCHECKED, counted and
|
|
257
|
+
// reported, and never counted clean.
|
|
175
258
|
let fetchedVulns = [];
|
|
176
|
-
let
|
|
259
|
+
let unanswered = [];
|
|
260
|
+
let failure = null;
|
|
177
261
|
if (uncached.length > 0) {
|
|
178
|
-
|
|
179
|
-
|
|
262
|
+
try {
|
|
263
|
+
const outcome = await this.batchQuery(uncached);
|
|
264
|
+
fetchedVulns = outcome.vulns;
|
|
265
|
+
unanswered = outcome.unanswered;
|
|
266
|
+
failure = outcome.failure;
|
|
267
|
+
}
|
|
268
|
+
catch (error) {
|
|
269
|
+
// The source layer turns network failures into `outcome.failure`; an
|
|
270
|
+
// exception here is a fault in this server, not an OSV outage, and
|
|
271
|
+
// telling the agent to retry would not help (audit 6.2.5 D-2).
|
|
272
|
+
unanswered = uncached;
|
|
273
|
+
const detail = error instanceof Error ? error.message.slice(0, 120) : String(error).slice(0, 120);
|
|
274
|
+
failure = `an internal error in this server stopped the vulnerability lookup (${detail}); retrying will not help, please report it at https://github.com/4DA-Systems/4da-mcp-server/issues`;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
let staleAnswers = 0;
|
|
278
|
+
let staleOldest = null;
|
|
279
|
+
const unchecked = [];
|
|
280
|
+
for (const dep of unanswered) {
|
|
281
|
+
const stale = network ? this.cache.getStale(`osv:v3:${dep.ecosystem}:${dep.name}:${dep.version}`) : null;
|
|
282
|
+
if (stale) {
|
|
283
|
+
staleAnswers++;
|
|
284
|
+
if (!staleOldest || stale.fetchedAt < staleOldest)
|
|
285
|
+
staleOldest = stale.fetchedAt;
|
|
286
|
+
cachedVulns.push(...stale.data.map((v) => restampDepContext(v, dep)));
|
|
180
287
|
}
|
|
181
288
|
else {
|
|
182
|
-
|
|
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
|
-
}
|
|
289
|
+
unchecked.push(dep);
|
|
197
290
|
}
|
|
198
291
|
}
|
|
199
|
-
const
|
|
292
|
+
const offline = unchecked.length > 0;
|
|
293
|
+
// npm's security holding packages (`fs@0.0.1-security`) are empty
|
|
294
|
+
// placeholders npm publishes after taking a malicious package down; the
|
|
295
|
+
// malware advisory covering every version does not apply to them (the
|
|
296
|
+
// desktop app's osv::matching::is_npm_security_holding; audit A5).
|
|
297
|
+
const rated = await this.rateFromAliases([...cachedVulns, ...fetchedVulns].filter((v) => !isNpmSecurityHolding(v)));
|
|
298
|
+
// One severity per advisory: every record of an alias group carries the group's most severe grade.
|
|
299
|
+
const allVulns = gradeAliasGroups(rated.vulns);
|
|
200
300
|
const vulnerablePackages = new Set(allVulns.map((v) => v.package));
|
|
201
301
|
const platformInactiveVulnerable = new Set(allVulns.filter((v) => !v.platformActive).map((v) => v.package)).size;
|
|
202
302
|
const bySeverity = { critical: 0, high: 0, medium: 0, low: 0, unknown: 0 };
|
|
@@ -212,16 +312,115 @@ export class OsvScanner {
|
|
|
212
312
|
platformInactiveVulnerable,
|
|
213
313
|
bySeverity,
|
|
214
314
|
vulnerabilities: allVulns,
|
|
215
|
-
|
|
315
|
+
// Answered dependencies with no advisory; an unchecked one is never clean.
|
|
316
|
+
cleanCount: Math.max(0, scannable.length - unchecked.length - vulnerableDependencyCount(allVulns)),
|
|
216
317
|
scanDurationMs: Date.now() - start,
|
|
217
318
|
cached: uncached.length === 0,
|
|
218
319
|
offline,
|
|
320
|
+
unchecked: unchecked.length,
|
|
321
|
+
uncheckedReason: offline ? (failure ?? "OSV.dev could not be queried") : null,
|
|
322
|
+
staleAnswers,
|
|
323
|
+
// An expired answer is not a current one: every consumer says so (audit 6.2.5 B-2).
|
|
324
|
+
...(staleAnswers > 0 ? { staleReason: failure ?? "OSV.dev could not be queried", staleOldestFetchedAt: staleOldest } : {}),
|
|
325
|
+
...(rated.report ? { aliasSeverity: rated.report } : {}),
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* A severity for records that carry none, from their GHSA alias: one
|
|
330
|
+
* advisory, one grade, in every tool. Go's vulnerability database rates
|
|
331
|
+
* nothing: GO-2024-3333 (golang.org/x/net) has no severity, its alias
|
|
332
|
+
* GHSA-w32m-9786-jp63 is HIGH, and OSV returns only the GO record for the
|
|
333
|
+
* module (the GHSA names golang.org/x/net/html). dependency_check said
|
|
334
|
+
* `avoid` while vulnerability_scan graded the same record unknown (review
|
|
335
|
+
* of 6.2.5 M6, 2026-10-18). The two records name each other, so the
|
|
336
|
+
* alias's rating applies; it is read by the same rule as any record and
|
|
337
|
+
* the entry says where it came from (`severityFrom`).
|
|
338
|
+
*
|
|
339
|
+
* Through this scanner's own source: a pinned source answers from its
|
|
340
|
+
* records, never the network; with live lookups off nothing is fetched.
|
|
341
|
+
* At most MAX_ALIAS_LOOKUPS aliases, within ALIAS_BUDGET_MS in total.
|
|
342
|
+
* Anything not rated stays unknown and is listed as unresolved, with why;
|
|
343
|
+
* unknown is never clean.
|
|
344
|
+
*/
|
|
345
|
+
async rateFromAliases(entries) {
|
|
346
|
+
// A GHSA alias that is itself one of this scan's records is graded already: no fetch.
|
|
347
|
+
const known = new Map();
|
|
348
|
+
for (const e of entries)
|
|
349
|
+
if (/^GHSA-/i.test(e.vulnId) && e.severity !== "unknown")
|
|
350
|
+
known.set(e.vulnId, e.severity);
|
|
351
|
+
const wanted = new Map(); // record id -> every GHSA alias it names
|
|
352
|
+
for (const e of entries) {
|
|
353
|
+
if (e.severity !== "unknown" || e.severityFrom || wanted.has(e.vulnId) || isMaintenanceNotice(e))
|
|
354
|
+
continue;
|
|
355
|
+
const ghsas = [...new Set(e.aliases.filter((a) => /^GHSA-/i.test(a) && a !== e.vulnId))];
|
|
356
|
+
if (ghsas.length > 0)
|
|
357
|
+
wanted.set(e.vulnId, ghsas);
|
|
358
|
+
}
|
|
359
|
+
if (wanted.size === 0)
|
|
360
|
+
return { vulns: entries, report: null };
|
|
361
|
+
// The cap and the budget count distinct GHSA ids.
|
|
362
|
+
const toRead = [...new Set([...wanted.values()].flat())].filter((id) => !known.has(id)).sort();
|
|
363
|
+
const asked = toRead.slice(0, MAX_ALIAS_LOOKUPS);
|
|
364
|
+
let records = new Map();
|
|
365
|
+
const reasons = toRead.length > asked.length ? [`more than ${MAX_ALIAS_LOOKUPS} aliases to read; the rest were not asked for`] : [];
|
|
366
|
+
if (asked.length > 0 && this.source.network && process.env.FOURDA_OFFLINE === "true") {
|
|
367
|
+
reasons.push("live lookups are disabled (FOURDA_OFFLINE=true)");
|
|
368
|
+
}
|
|
369
|
+
else if (asked.length > 0) {
|
|
370
|
+
let timer;
|
|
371
|
+
const deadline = new Promise((resolve) => {
|
|
372
|
+
timer = setTimeout(() => resolve(null), this.aliasBudgetMs);
|
|
373
|
+
});
|
|
374
|
+
try {
|
|
375
|
+
// Fetches past the deadline keep running and fill the detail cache for the next call.
|
|
376
|
+
const answer = await Promise.race([this.source.details(asked).catch(() => new Map()), deadline]);
|
|
377
|
+
if (answer === null)
|
|
378
|
+
reasons.push(`the alias records did not arrive within ${this.aliasBudgetMs / 1000} s`);
|
|
379
|
+
else
|
|
380
|
+
records = answer;
|
|
381
|
+
}
|
|
382
|
+
finally {
|
|
383
|
+
if (timer !== undefined)
|
|
384
|
+
clearTimeout(timer);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
const read = new Map(known);
|
|
388
|
+
for (const [id, record] of records)
|
|
389
|
+
read.set(id, deriveSeverity(record).severity);
|
|
390
|
+
const unresolved = new Set();
|
|
391
|
+
const ungraded = [];
|
|
392
|
+
let ratedCount = 0;
|
|
393
|
+
const vulns = entries.map((e) => {
|
|
394
|
+
const ghsas = wanted.get(e.vulnId);
|
|
395
|
+
if (!ghsas)
|
|
396
|
+
return e;
|
|
397
|
+
const graded = gradeFromAliases(ghsas, read);
|
|
398
|
+
for (const c of graded.consulted)
|
|
399
|
+
if (c.grade === "unreadable")
|
|
400
|
+
unresolved.add(c.id);
|
|
401
|
+
if (graded.severity === "unknown") {
|
|
402
|
+
if (!ungraded.includes(e.vulnId))
|
|
403
|
+
ungraded.push(e.vulnId);
|
|
404
|
+
return e;
|
|
405
|
+
}
|
|
406
|
+
ratedCount++;
|
|
407
|
+
return { ...e, severity: graded.severity, severityFrom: graded.from };
|
|
408
|
+
});
|
|
409
|
+
if (unresolved.size > 0 && reasons.length === 0)
|
|
410
|
+
reasons.push("an alias record could not be read");
|
|
411
|
+
return {
|
|
412
|
+
vulns,
|
|
413
|
+
report: { rated: ratedCount, unresolved: [...unresolved].sort(), ungraded, reason: unresolved.size > 0 || ungraded.length > 0 ? reasons.join("; ") || "the aliases are unrated themselves" : null },
|
|
219
414
|
};
|
|
220
415
|
}
|
|
221
416
|
async batchQuery(deps) {
|
|
222
417
|
// Phase 1 — enumerate advisory IDs per dep (OSV: the lightweight batch index).
|
|
223
|
-
const
|
|
224
|
-
const
|
|
418
|
+
const answer = await this.source.query(deps);
|
|
419
|
+
const unanswered = deps.filter((_, i) => !Array.isArray(answer.ids[i]));
|
|
420
|
+
const matches = deps.flatMap((dep, i) => {
|
|
421
|
+
const ids = answer.ids[i];
|
|
422
|
+
return Array.isArray(ids) ? [{ dep, ids }] : [];
|
|
423
|
+
});
|
|
225
424
|
// Phase 2 — the record behind each unique advisory, once.
|
|
226
425
|
const uniqueIds = [...new Set(matches.flatMap((m) => m.ids))];
|
|
227
426
|
const detailById = await this.source.details(uniqueIds);
|
|
@@ -235,14 +434,18 @@ export class OsvScanner {
|
|
|
235
434
|
// and dropping a real advisory is worse than keeping an un-enriched one.
|
|
236
435
|
const liveIds = ids.filter((id) => !detailById.get(id)?.withdrawn);
|
|
237
436
|
const depVulns = liveIds.map((id) => mapVulnerability(detailById.get(id) ?? { id }, dep));
|
|
238
|
-
rows.push([`osv:${dep.ecosystem}:${dep.name}:${dep.version}`, depVulns]);
|
|
437
|
+
rows.push([`osv:v3:${dep.ecosystem}:${dep.name}:${dep.version}`, depVulns]);
|
|
239
438
|
results.push(...depVulns);
|
|
240
439
|
}
|
|
241
440
|
if (this.source.network)
|
|
242
441
|
this.cache.setMany(rows, "osv", OSV_CACHE_TTL);
|
|
243
|
-
return results;
|
|
442
|
+
return { vulns: results, unanswered, failure: unanswered.length > 0 ? (answer.failure ?? "OSV.dev gave no answer") : null };
|
|
244
443
|
}
|
|
245
444
|
}
|
|
445
|
+
/** Distinct (ecosystem, name, version) instances with at least one advisory row. */
|
|
446
|
+
function vulnerableDependencyCount(vulns) {
|
|
447
|
+
return new Set(vulns.map((v) => `${v.ecosystem}\0${v.package}\0${v.currentVersion}`)).size;
|
|
448
|
+
}
|
|
246
449
|
/**
|
|
247
450
|
* Overwrite the dep-context fields of a cached advisory entry with the CURRENT
|
|
248
451
|
* resolution's context. The advisory facts (id, severity, summary, fix version)
|
|
@@ -272,7 +475,11 @@ export function restampDepContext(cached, dep) {
|
|
|
272
475
|
};
|
|
273
476
|
}
|
|
274
477
|
function mapVulnerability(vuln, dep) {
|
|
275
|
-
const
|
|
478
|
+
const graded = deriveSeverity(vuln);
|
|
479
|
+
const malware = isMalware(vuln.id, vuln.aliases || [], cweIdsOf(vuln), vuln.summary ?? "");
|
|
480
|
+
// An installed malicious copy is critical in every tool, whatever the record grades (the 4DA lead, 2026-10-18).
|
|
481
|
+
const severity = malware ? "critical" : graded.severity;
|
|
482
|
+
const cvssScore = graded.cvssScore;
|
|
276
483
|
const fixedVersion = extractFixedVersion(vuln.affected, dep.name, dep.ecosystem, dep.version);
|
|
277
484
|
return {
|
|
278
485
|
package: dep.name,
|
|
@@ -285,6 +492,8 @@ function mapVulnerability(vuln, dep) {
|
|
|
285
492
|
aliases: vuln.aliases || [],
|
|
286
493
|
severity,
|
|
287
494
|
cvssScore,
|
|
495
|
+
...(malware ? { malware: true } : {}),
|
|
496
|
+
maintenance: isMaintenanceRecord(vuln, dep.name, dep.ecosystem),
|
|
288
497
|
summary: vuln.summary || vuln.details?.slice(0, 200) || vuln.id,
|
|
289
498
|
fixedVersion,
|
|
290
499
|
published: vuln.published || vuln.modified || "",
|
|
@@ -299,19 +508,43 @@ function mapVulnerability(vuln, dep) {
|
|
|
299
508
|
installFix: dep.installFix,
|
|
300
509
|
};
|
|
301
510
|
}
|
|
511
|
+
const GRADE_RANK = { unknown: 0, low: 1, medium: 2, high: 3, critical: 4 };
|
|
512
|
+
/**
|
|
513
|
+
* The shared rule for an unrated record (the 4DA app implements it
|
|
514
|
+
* identically): the HIGHEST grade among every GHSA alias that could be read;
|
|
515
|
+
* unknown when none could. `from` names the alias that gave it, the
|
|
516
|
+
* lexicographically smallest id on a tie. An alias that could not be read
|
|
517
|
+
* never lowers the grade; it is reported (`consulted`, grade "unreadable").
|
|
518
|
+
* Umbrella records name several: RUSTSEC-2020-0014 lists 8 GHSAs, so taking
|
|
519
|
+
* the first made the grade depend on alias order.
|
|
520
|
+
*/
|
|
521
|
+
export function gradeFromAliases(ghsas, read) {
|
|
522
|
+
const consulted = [...new Set(ghsas)].sort().map((id) => ({ id, grade: read.get(id) ?? "unreadable" }));
|
|
523
|
+
let best = null;
|
|
524
|
+
for (const c of consulted) {
|
|
525
|
+
if (c.grade === "unreadable" || c.grade === "unknown")
|
|
526
|
+
continue;
|
|
527
|
+
if (!best || GRADE_RANK[c.grade] > GRADE_RANK[best.grade])
|
|
528
|
+
best = { id: c.id, grade: c.grade };
|
|
529
|
+
}
|
|
530
|
+
return { severity: best?.grade ?? "unknown", from: best?.id ?? null, consulted };
|
|
531
|
+
}
|
|
302
532
|
/**
|
|
303
533
|
* Derive a severity bucket honestly: a CVSS base score (numeric or computed
|
|
304
534
|
* from a vector) wins; otherwise the GitHub-advisory label; otherwise unknown.
|
|
305
535
|
* Never fabricates a default bucket — an advisory with no severity signal is
|
|
306
536
|
* reported as `unknown`, not silently labeled "medium".
|
|
307
537
|
*/
|
|
308
|
-
function deriveSeverity(vuln) {
|
|
538
|
+
export function deriveSeverity(vuln) {
|
|
539
|
+
// The curated label first, the CVSS band second (the rule shared with the 4DA app, 2026-10-18,
|
|
540
|
+
// severity vectors sv-015..sv-017): on the pinned corpus 123 of 3,923 records disagree, all GHSA,
|
|
541
|
+
// 113 beside a CVSS v4 vector neither engine scores and the label follows. A CVSS 0.0 goes by its label.
|
|
309
542
|
const cvssScore = extractCvssScore(vuln.severity);
|
|
310
|
-
if (cvssScore !== null)
|
|
311
|
-
return { severity: cvssToSeverity(cvssScore), cvssScore };
|
|
312
543
|
const label = labelToSeverity(vuln.database_specific?.severity);
|
|
313
544
|
if (label !== "unknown")
|
|
314
|
-
return { severity: label, cvssScore
|
|
545
|
+
return { severity: label, cvssScore };
|
|
546
|
+
if (cvssScore !== null)
|
|
547
|
+
return { severity: cvssToSeverity(cvssScore), cvssScore };
|
|
315
548
|
return { severity: "unknown", cvssScore: null };
|
|
316
549
|
}
|
|
317
550
|
function labelToSeverity(label) {
|
|
@@ -399,7 +632,7 @@ export function extractFixedVersion(affected, packageName, ecosystem, installedV
|
|
|
399
632
|
// The interval whose release line the installed version sits on. An
|
|
400
633
|
// unreadable bound compares as 0, which keeps those intervals eligible on
|
|
401
634
|
// the introduced side only; "0" (since inception) orders below everything.
|
|
402
|
-
const containing = intervals.find((i) =>
|
|
635
|
+
const containing = intervals.find((i) => compareToLowerBound(installedVersion, i.introduced, ecosystem) >= 0 &&
|
|
403
636
|
(i.fixed !== null
|
|
404
637
|
? cmp(installedVersion, i.fixed) < 0
|
|
405
638
|
: i.lastAffected === null || cmp(installedVersion, i.lastAffected) <= 0));
|
|
@@ -413,3 +646,21 @@ export function extractFixedVersion(affected, packageName, ecosystem, installedV
|
|
|
413
646
|
return null;
|
|
414
647
|
return above.reduce((min, v) => (cmp(v, min) < 0 ? v : min), above[0]);
|
|
415
648
|
}
|
|
649
|
+
/**
|
|
650
|
+
* npm's security holding placeholder: after a malicious package is taken down,
|
|
651
|
+
* npm republishes the name as an empty `0.0.<n>-security` release ("security
|
|
652
|
+
* holding package"). It has no code, so NO advisory applies to it, whichever
|
|
653
|
+
* database's id flags the name: crossenv 0.0.2-security under
|
|
654
|
+
* GHSA-c2m4-w5hm-vqjw read as installed malware when only MAL- ids were held
|
|
655
|
+
* (4DA audit L4, 2026-10-16). Only npm's own shape counts: a publisher's
|
|
656
|
+
* `1.0.0-security` prerelease is real code and stays affected. The same rule
|
|
657
|
+
* as the app's `fourda_depengine::matching::is_npm_security_holding`
|
|
658
|
+
* (shared vectors `holding.jsonl`).
|
|
659
|
+
*/
|
|
660
|
+
export function isSecurityHoldingRelease(ecosystem, version) {
|
|
661
|
+
return ecosystem.trim().toLowerCase() === "npm" && /^0\.0\.\d+-security$/.test(version.trim());
|
|
662
|
+
}
|
|
663
|
+
/** A finding on an npm security holding placeholder (any advisory id): not installed code. */
|
|
664
|
+
export function isNpmSecurityHolding(v) {
|
|
665
|
+
return isSecurityHoldingRelease(v.ecosystem, v.currentVersion);
|
|
666
|
+
}
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* Withdrawn records are still returned by `query`: the scanner drops them
|
|
15
15
|
* after reading the record, exactly as it does with OSV's answers.
|
|
16
16
|
*/
|
|
17
|
-
import type { AdvisorySource } from "./osv-scanner.js";
|
|
17
|
+
import type { AdvisoryAnswer, AdvisorySource } from "./osv-scanner.js";
|
|
18
18
|
import type { OsvVulnerability, ResolvedDependency } from "./types.js";
|
|
19
19
|
export declare class PinnedRecordsSource implements AdvisorySource {
|
|
20
20
|
readonly network = false;
|
|
@@ -26,6 +26,6 @@ export declare class PinnedRecordsSource implements AdvisorySource {
|
|
|
26
26
|
get size(): number;
|
|
27
27
|
/** The ids of the pinned records that affect this exact version (withdrawn ones included). */
|
|
28
28
|
affecting(ecosystem: string, name: string, version: string): string[];
|
|
29
|
-
query(deps: ResolvedDependency[]): Promise<
|
|
29
|
+
query(deps: ResolvedDependency[]): Promise<AdvisoryAnswer>;
|
|
30
30
|
details(ids: string[]): Promise<Map<string, OsvVulnerability>>;
|
|
31
31
|
}
|