@4da/mcp-server 6.2.4 → 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 +484 -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 +258 -9
- package/dist/live/fix-paths.js +790 -45
- 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/rate-limiter.d.ts +7 -0
- package/dist/live/rate-limiter.js +22 -1
- 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 +49 -0
- package/dist/live/version-compare.js +131 -0
- 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 +162 -5
- package/dist/tools/upgrade-planner-fixpaths.js +0 -0
- package/dist/tools/upgrade-planner.d.ts +59 -4
- 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
|
@@ -30,7 +30,7 @@ function nameKey(ecosystem, name) {
|
|
|
30
30
|
/** Explicitly listed versions for one package of one record. */
|
|
31
31
|
function listedVersions(vuln, name, ecosystem) {
|
|
32
32
|
return (vuln.affected ?? [])
|
|
33
|
-
.filter((a) => a.package
|
|
33
|
+
.filter((a) => a.package?.ecosystem === ecosystem && samePackageName(a.package.name, name, ecosystem))
|
|
34
34
|
.flatMap((a) => a.versions ?? []);
|
|
35
35
|
}
|
|
36
36
|
export class PinnedRecordsSource {
|
|
@@ -40,7 +40,7 @@ export class PinnedRecordsSource {
|
|
|
40
40
|
constructor(records) {
|
|
41
41
|
for (const record of records) {
|
|
42
42
|
this.byId.set(record.id, record);
|
|
43
|
-
const keys = new Set((record.affected ?? []).
|
|
43
|
+
const keys = new Set((record.affected ?? []).flatMap((a) => (a.package ? [`${a.package.ecosystem}\0${nameKey(a.package.ecosystem, a.package.name)}`] : [])));
|
|
44
44
|
for (const key of keys) {
|
|
45
45
|
const list = this.byPackage.get(key);
|
|
46
46
|
if (list)
|
|
@@ -66,7 +66,7 @@ export class PinnedRecordsSource {
|
|
|
66
66
|
.map((vuln) => vuln.id);
|
|
67
67
|
}
|
|
68
68
|
async query(deps) {
|
|
69
|
-
return deps.map((dep) => (dep.version === null ? [] : this.affecting(dep.ecosystem, dep.name, dep.version)));
|
|
69
|
+
return { ids: deps.map((dep) => (dep.version === null ? [] : this.affecting(dep.ecosystem, dep.name, dep.version))), failure: null };
|
|
70
70
|
}
|
|
71
71
|
async details(ids) {
|
|
72
72
|
const out = new Map();
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How a PyPI pin is moved, worded for how its requirements file is produced and installed
|
|
3
|
+
* (audit 6.2.5 A-4). Two kinds of advice installed nothing when run as written:
|
|
4
|
+
*
|
|
5
|
+
* - A compiled file (`uv pip compile`, `pip-compile`) pins the whole tree. Its `# via requests`
|
|
6
|
+
* pins are transitive, and every pin is regenerated from the input (requirements.in,
|
|
7
|
+
* pyproject.toml) on the next compile. "change `idna==2.7` to `idna==3.15` in requirements.txt"
|
|
8
|
+
* conflicted with requests 2.19.1 (`idna<2.8`), and a hand edit is reverted anyway. The fix is a
|
|
9
|
+
* re-compile with `--upgrade-package "idna==3.15"`, the checked target (a `>=` let the resolver
|
|
10
|
+
* take a newer release nothing checked). uv 0.6.8 and pip-compile 7.6.2 both refuse to resolve
|
|
11
|
+
* when a package that requires idna excludes 3.15 (run 2026-10-26 on Track A's hb-f6b fixture),
|
|
12
|
+
* where a bare `--upgrade-package idna` silently kept 2.7.
|
|
13
|
+
* - A flat file that pins everything with `==`: each edit was presented as safe on its own, but 4
|
|
14
|
+
* of pygoat's 15 do not resolve against the other pins (Django 5.2.17 needs asgiref>=3.8.1,
|
|
15
|
+
* asgiref==3.6.0 is pinned), and neither does the full set. No resolver runs here, so the advice
|
|
16
|
+
* says the edits must be re-resolved together; it never claims they are.
|
|
17
|
+
*
|
|
18
|
+
* A command is spliced only from plain tokens (audit 6.2.5 C-1); otherwise the words name no command.
|
|
19
|
+
*/
|
|
20
|
+
/**
|
|
21
|
+
* The advice for `name` at `installed` when a compiled requirements file in `projectDir` pins it;
|
|
22
|
+
* null when no compiled file does (the caller keeps its own advice). Both a declared pin and an
|
|
23
|
+
* indirect one are moved by re-compiling, never by editing the output.
|
|
24
|
+
*/
|
|
25
|
+
export declare function pypiCompiledInstruction(projectDir: string, name: string, installed: string, target: string): string | null;
|
|
26
|
+
/**
|
|
27
|
+
* The sentence for an `==` edit of `name` in a flat file that pins every requirement exactly (two
|
|
28
|
+
* or more): its edits are not independently safe and must be resolved together. Null otherwise.
|
|
29
|
+
* The file is the first that pins it, in the order directFix reads them.
|
|
30
|
+
*/
|
|
31
|
+
export declare function pypiPinnedTogether(projectDir: string, name: string, installed: string): string | null;
|
|
32
|
+
/**
|
|
33
|
+
* vulnerability_scan's short form, appended to "Upgrade X a → b": a compiled file is re-compiled
|
|
34
|
+
* and a fully pinned one re-resolved, never one `==` edit taken as safe alone (audit 6.2.5 A-4).
|
|
35
|
+
*/
|
|
36
|
+
export declare function pypiScanNote(projectDir: string, name: string, installed: string): string | null;
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/**
|
|
3
|
+
* How a PyPI pin is moved, worded for how its requirements file is produced and installed
|
|
4
|
+
* (audit 6.2.5 A-4). Two kinds of advice installed nothing when run as written:
|
|
5
|
+
*
|
|
6
|
+
* - A compiled file (`uv pip compile`, `pip-compile`) pins the whole tree. Its `# via requests`
|
|
7
|
+
* pins are transitive, and every pin is regenerated from the input (requirements.in,
|
|
8
|
+
* pyproject.toml) on the next compile. "change `idna==2.7` to `idna==3.15` in requirements.txt"
|
|
9
|
+
* conflicted with requests 2.19.1 (`idna<2.8`), and a hand edit is reverted anyway. The fix is a
|
|
10
|
+
* re-compile with `--upgrade-package "idna==3.15"`, the checked target (a `>=` let the resolver
|
|
11
|
+
* take a newer release nothing checked). uv 0.6.8 and pip-compile 7.6.2 both refuse to resolve
|
|
12
|
+
* when a package that requires idna excludes 3.15 (run 2026-10-26 on Track A's hb-f6b fixture),
|
|
13
|
+
* where a bare `--upgrade-package idna` silently kept 2.7.
|
|
14
|
+
* - A flat file that pins everything with `==`: each edit was presented as safe on its own, but 4
|
|
15
|
+
* of pygoat's 15 do not resolve against the other pins (Django 5.2.17 needs asgiref>=3.8.1,
|
|
16
|
+
* asgiref==3.6.0 is pinned), and neither does the full set. No resolver runs here, so the advice
|
|
17
|
+
* says the edits must be re-resolved together; it never claims they are.
|
|
18
|
+
*
|
|
19
|
+
* A command is spliced only from plain tokens (audit 6.2.5 C-1); otherwise the words name no command.
|
|
20
|
+
*/
|
|
21
|
+
import * as fs from "node:fs";
|
|
22
|
+
import * as path from "node:path";
|
|
23
|
+
import { compiledIndirect, compiledRequiredBy, compiledRequirements, confinedInclude, normalizePythonName, parseRequirements, requirementFiles, requirementOptions, } from "./lockfile-parsers-pygo.js";
|
|
24
|
+
import { isShellSafeName, isShellSafeVersion } from "./shell-safe.js";
|
|
25
|
+
/** A relative path of plain segments (`requirements.in`, `requirements/dev.txt`): no `..`, no root, no drive, nothing a shell expands. */
|
|
26
|
+
function isPlainPath(file) {
|
|
27
|
+
return /^[A-Za-z0-9_][A-Za-z0-9_.-]*(?:\/[A-Za-z0-9_][A-Za-z0-9_.-]*)*$/.test(file) && !file.split("/").includes("..") && file.length <= 256;
|
|
28
|
+
}
|
|
29
|
+
function readText(file) {
|
|
30
|
+
try {
|
|
31
|
+
return fs.readFileSync(file, "utf-8");
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/** Requirement files that pin `name` at `installed`, relative to `projectDir`: the ones the scan reads, then their confined `-r` includes. */
|
|
38
|
+
function filesPinning(projectDir, name, installed) {
|
|
39
|
+
const want = normalizePythonName(name);
|
|
40
|
+
const files = ["requirements.txt", "requirements-dev.txt", "requirements/base.txt"];
|
|
41
|
+
for (const f of requirementFiles(projectDir)) {
|
|
42
|
+
const rel = path.relative(projectDir, f).replace(/\\/g, "/");
|
|
43
|
+
if (!files.includes(rel))
|
|
44
|
+
files.push(rel);
|
|
45
|
+
}
|
|
46
|
+
const out = [];
|
|
47
|
+
for (let i = 0; i < files.length && i < 32; i++) {
|
|
48
|
+
const body = readText(path.join(projectDir, files[i]));
|
|
49
|
+
if (body === null)
|
|
50
|
+
continue;
|
|
51
|
+
if (parseRequirements(body).some((p) => p.name === want && p.version === installed))
|
|
52
|
+
out.push({ file: files[i], body });
|
|
53
|
+
for (const { option, target } of requirementOptions(body)) {
|
|
54
|
+
const included = option === "r" ? confinedInclude(path.dirname(path.join(projectDir, files[i])), target, projectDir) : null;
|
|
55
|
+
const rel = included ? path.relative(projectDir, included).replace(/\\/g, "/") : null;
|
|
56
|
+
if (rel && !files.includes(rel))
|
|
57
|
+
files.push(rel);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
/** The input a compiled file names, when it is a plain path that exists inside the project (where the compile runs). */
|
|
63
|
+
function presentInput(projectDir, compiled) {
|
|
64
|
+
const input = compiled.input;
|
|
65
|
+
if (!input || !isPlainPath(input))
|
|
66
|
+
return null;
|
|
67
|
+
const at = confinedInclude(projectDir, input, projectDir);
|
|
68
|
+
return at && fs.existsSync(at) ? input : null;
|
|
69
|
+
}
|
|
70
|
+
/** `uv pip compile` / `pip-compile` with the upgrade, when every spliced value is a plain token. */
|
|
71
|
+
function compileCommand(tool, input, output, name, target) {
|
|
72
|
+
if (!isPlainPath(input) || !isPlainPath(output) || !isShellSafeName(name, "PyPI") || !isShellSafeVersion(target))
|
|
73
|
+
return null;
|
|
74
|
+
// Double quotes: `>` is a redirect outside them on every shell; cmd.exe keeps single quotes.
|
|
75
|
+
const upgrade = `--upgrade-package "${name}==${target}"`;
|
|
76
|
+
return tool === "uv" ? `uv pip compile ${input} -o ${output} ${upgrade}` : `pip-compile ${input} --output-file ${output} ${upgrade}`;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* The advice for `name` at `installed` when a compiled requirements file in `projectDir` pins it;
|
|
80
|
+
* null when no compiled file does (the caller keeps its own advice). Both a declared pin and an
|
|
81
|
+
* indirect one are moved by re-compiling, never by editing the output.
|
|
82
|
+
*/
|
|
83
|
+
export function pypiCompiledInstruction(projectDir, name, installed, target) {
|
|
84
|
+
for (const { file, body } of filesPinning(projectDir, name, installed)) {
|
|
85
|
+
const compiled = compiledRequirements(body);
|
|
86
|
+
if (!compiled)
|
|
87
|
+
continue;
|
|
88
|
+
// Prose too carries only plain tokens: the target comes from advisory data, the rest from the repo.
|
|
89
|
+
const label = isShellSafeName(name, "PyPI") ? name : "this package";
|
|
90
|
+
const version = isShellSafeVersion(target) ? target : "the fixed release";
|
|
91
|
+
const safeFile = isPlainPath(file) ? file : "the requirements file";
|
|
92
|
+
const by = compiled.tool === "uv" ? " by `uv pip compile`" : compiled.tool === "pip-compile" ? " by `pip-compile`" : "";
|
|
93
|
+
const input = presentInput(projectDir, compiled);
|
|
94
|
+
const from = input ? ` from ${input}` : "";
|
|
95
|
+
const indirect = compiledIndirect(compiled, name);
|
|
96
|
+
const parents = compiledRequiredBy(compiled, name).filter((p) => isShellSafeName(p, "PyPI")).slice(0, 3);
|
|
97
|
+
const command = input && compiled.tool && isPlainPath(file) ? compileCommand(compiled.tool, input, file, name, target) : null;
|
|
98
|
+
const upgrade = isShellSafeName(name, "PyPI") && isShellSafeVersion(target) ? `\`--upgrade-package "${name}==${target}"\`` : "an `--upgrade-package` option for it";
|
|
99
|
+
const what = indirect
|
|
100
|
+
? `${label} is not declared: ${safeFile} is compiled${by}${from} and pins it only because ${parents.length > 0 ? parents.join(", ") : "other packages"} require${parents.length === 1 ? "s" : ""} it`
|
|
101
|
+
: `${safeFile} is compiled${by}${from}, so a hand edit of it is reverted by the next compile${input ? `; make sure the ${label} requirement in ${input} admits ${version}` : `; make sure the ${label} requirement in the file it is compiled from admits ${version}`}`;
|
|
102
|
+
const instead = indirect ? " instead of editing it" : "";
|
|
103
|
+
const how = command
|
|
104
|
+
? `${indirect ? "" : "then "}re-compile with the upgrade${instead}: \`${command}\`, keeping the other options its header records`
|
|
105
|
+
: compiled.tool
|
|
106
|
+
? `${indirect ? "" : "then "}re-compile it${input ? "" : " from its input"} with ${upgrade}${instead}, keeping the options its header records`
|
|
107
|
+
: `${indirect ? "" : "then "}re-compile it with the tool that wrote it (\`uv pip compile\` or \`pip-compile\`) and ${upgrade}${instead}`;
|
|
108
|
+
const check = `the resolver refuses when ${indirect && parents.length > 0 ? parents.join(" or ") : "a package that requires it"} excludes ${version}; then raise ${indirect ? "that package" : "the one it names"} in the input as well, and put the other upgrades to ${safeFile} in the same compile (one --upgrade-package each)`;
|
|
109
|
+
return `${what}; ${how}. ${capitalise(check)}`;
|
|
110
|
+
}
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
function capitalise(s) {
|
|
114
|
+
return s.charAt(0).toUpperCase() + s.slice(1);
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* The sentence for an `==` edit of `name` in a flat file that pins every requirement exactly (two
|
|
118
|
+
* or more): its edits are not independently safe and must be resolved together. Null otherwise.
|
|
119
|
+
* The file is the first that pins it, in the order directFix reads them.
|
|
120
|
+
*/
|
|
121
|
+
export function pypiPinnedTogether(projectDir, name, installed) {
|
|
122
|
+
const first = filesPinning(projectDir, name, installed)[0];
|
|
123
|
+
if (!first || compiledRequirements(first.body))
|
|
124
|
+
return null;
|
|
125
|
+
const { file, body } = first;
|
|
126
|
+
const pins = parseRequirements(body).length;
|
|
127
|
+
if (pins < 2 || pins !== requirementCount(body))
|
|
128
|
+
return null;
|
|
129
|
+
const where = isPlainPath(file) ? file : "the requirements file";
|
|
130
|
+
const check = isPlainPath(file) ? ` (\`uv pip compile ${file}\` prints the resolution and writes nothing; \`pip install --dry-run -r ${file}\` also checks)` : "";
|
|
131
|
+
return `${where} pins every requirement exactly, so this edit is not safe on its own: make every pin change to it together, re-resolve the file${check}, and raise any other pin the resolver reports as conflicting before installing`;
|
|
132
|
+
}
|
|
133
|
+
/** Requirement lines in a file (pins and ranges alike), options and comments aside. */
|
|
134
|
+
function requirementCount(body) {
|
|
135
|
+
let n = 0;
|
|
136
|
+
for (const raw of body.replace(/\\\r?\n/g, " ").split(/\r?\n/)) {
|
|
137
|
+
const line = raw.replace(/(^|\s)#.*$/, "").trim();
|
|
138
|
+
if (line && !line.startsWith("-") && /^[A-Za-z0-9]/.test(line))
|
|
139
|
+
n++;
|
|
140
|
+
}
|
|
141
|
+
return n;
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* vulnerability_scan's short form, appended to "Upgrade X a → b": a compiled file is re-compiled
|
|
145
|
+
* and a fully pinned one re-resolved, never one `==` edit taken as safe alone (audit 6.2.5 A-4).
|
|
146
|
+
*/
|
|
147
|
+
export function pypiScanNote(projectDir, name, installed) {
|
|
148
|
+
const first = filesPinning(projectDir, name, installed)[0];
|
|
149
|
+
if (!first)
|
|
150
|
+
return null;
|
|
151
|
+
const where = isPlainPath(first.file) ? first.file : "the requirements file";
|
|
152
|
+
const compiled = compiledRequirements(first.body);
|
|
153
|
+
if (compiled) {
|
|
154
|
+
const input = presentInput(projectDir, compiled);
|
|
155
|
+
return `${where} is compiled${input ? ` from ${input}` : ""}: re-compile it with --upgrade-package instead of editing it${compiledIndirect(compiled, name) ? " (it is pinned only because other packages require it)" : ""}; upgrade_planner words the step`;
|
|
156
|
+
}
|
|
157
|
+
return pypiPinnedTogether(projectDir, name, installed) ? `${where} pins every requirement exactly: re-resolve it with the other pin changes before installing` : null;
|
|
158
|
+
}
|
|
@@ -5,6 +5,13 @@ export declare class RateLimiter {
|
|
|
5
5
|
}>);
|
|
6
6
|
canProceed(source: string): boolean;
|
|
7
7
|
consume(source: string): boolean;
|
|
8
|
+
/**
|
|
9
|
+
* Take a token, waiting for one to refill when the bucket is empty, as long
|
|
10
|
+
* as it arrives by `deadline` (epoch ms). False when it would not: the
|
|
11
|
+
* caller then has no answer for that request and must say so, never treat
|
|
12
|
+
* it as an empty one.
|
|
13
|
+
*/
|
|
14
|
+
acquire(source: string, deadline: number): Promise<boolean>;
|
|
8
15
|
waitTimeMs(source: string): number;
|
|
9
16
|
private refill;
|
|
10
17
|
}
|
|
@@ -28,6 +28,22 @@ export class RateLimiter {
|
|
|
28
28
|
bucket.tokens -= 1;
|
|
29
29
|
return true;
|
|
30
30
|
}
|
|
31
|
+
/**
|
|
32
|
+
* Take a token, waiting for one to refill when the bucket is empty, as long
|
|
33
|
+
* as it arrives by `deadline` (epoch ms). False when it would not: the
|
|
34
|
+
* caller then has no answer for that request and must say so, never treat
|
|
35
|
+
* it as an empty one.
|
|
36
|
+
*/
|
|
37
|
+
async acquire(source, deadline) {
|
|
38
|
+
for (;;) {
|
|
39
|
+
if (this.consume(source))
|
|
40
|
+
return true;
|
|
41
|
+
const wait = Math.max(1, this.waitTimeMs(source));
|
|
42
|
+
if (Date.now() + wait > deadline)
|
|
43
|
+
return false;
|
|
44
|
+
await new Promise((resolve) => setTimeout(resolve, wait));
|
|
45
|
+
}
|
|
46
|
+
}
|
|
31
47
|
waitTimeMs(source) {
|
|
32
48
|
const bucket = this.buckets.get(source);
|
|
33
49
|
if (!bucket)
|
|
@@ -46,7 +62,12 @@ export class RateLimiter {
|
|
|
46
62
|
}
|
|
47
63
|
}
|
|
48
64
|
export const DEFAULT_RATE_LIMITS = {
|
|
49
|
-
|
|
65
|
+
// querybatch requests (1,000 dependencies each). OSV.dev publishes no
|
|
66
|
+
// per-client limit for it and osv-scanner sends a tree's batches back to
|
|
67
|
+
// back; 10 a minute made any tree over 10,000 dependencies fail outright
|
|
68
|
+
// (D:\4DA\src-tauri, 10,568, reported 0 vulnerable on 2026-10-12). A burst
|
|
69
|
+
// of 60 covers 60,000 dependencies; past that a scan waits for tokens.
|
|
70
|
+
osv: { maxPerMinute: 60 },
|
|
50
71
|
// Per-package advisory lists for fix-path checks (/v1/query), one request per vulnerable package.
|
|
51
72
|
"osv-package": { maxPerMinute: 300 },
|
|
52
73
|
// Fix-path lookups on registry CDNs (crates.io sparse index, abbreviated npm
|
|
@@ -51,6 +51,11 @@ export interface PackageReleaseIndex {
|
|
|
51
51
|
/** When the package itself was first published, or null. */
|
|
52
52
|
createdAt: string | null;
|
|
53
53
|
releases: Record<string, ReleaseRecord>;
|
|
54
|
+
/**
|
|
55
|
+
* npm: the `latest` dist-tag. A package whose latest release is deprecated
|
|
56
|
+
* withdraws no deprecated release (withdrawn-releases.ts). Absent elsewhere.
|
|
57
|
+
*/
|
|
58
|
+
latest?: string | null;
|
|
54
59
|
}
|
|
55
60
|
/** The outcome of one registry read: data, a definite "no such package", or "could not reach it". */
|
|
56
61
|
export type RegistryLookup<T> = {
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
*/
|
|
19
19
|
import { type FileSignature } from "./file-signature.js";
|
|
20
20
|
import type { InstallDriftRecord, ResolutionSourceRecord, ResolvedDependency } from "./types.js";
|
|
21
|
+
import type { LockfileProblem } from "./lockfile-types.js";
|
|
21
22
|
/** The inputs of one resolution, kept so the same resolution can be re-run. */
|
|
22
23
|
export interface ResolutionGroup {
|
|
23
24
|
dir: string;
|
|
@@ -45,6 +46,8 @@ export interface GroupResolution {
|
|
|
45
46
|
manifest: string;
|
|
46
47
|
ranges: number;
|
|
47
48
|
} | null;
|
|
49
|
+
/** Lockfiles present in this directory that could not be read, with the reason. */
|
|
50
|
+
problems: LockfileProblem[];
|
|
48
51
|
signatures: Map<string, FileSignature>;
|
|
49
52
|
resolvedAt: string;
|
|
50
53
|
}
|
package/dist/live/resolution.js
CHANGED
|
@@ -35,7 +35,10 @@ export function resolveGroup(group) {
|
|
|
35
35
|
// script in package.json must not throw away a scan of an unchanged lockfile.
|
|
36
36
|
if (manifest && !readFiles.includes(manifest))
|
|
37
37
|
signatures.delete(manifest);
|
|
38
|
-
|
|
38
|
+
// Ruby and PHP: the lockfile (and composer.json) name the direct dependencies; the project scanner does not.
|
|
39
|
+
const deps = group.deps.length === 0 && group.devDeps.length === 0 && read.direct ? read.direct.deps : group.deps;
|
|
40
|
+
const devDeps = group.deps.length === 0 && group.devDeps.length === 0 && read.direct ? read.direct.devDeps : group.devDeps;
|
|
41
|
+
let resolved = resolveVersionsFrom(group.dir, deps, devDeps, ecosystem, read.versions, group.targets);
|
|
39
42
|
// No lockfile: a ranged dependency's version is the range's floor, a label.
|
|
40
43
|
const ranges = read.kind === "declared_ranges" ? read.ranges ?? new Map() : null;
|
|
41
44
|
if (ranges && ranges.size > 0) {
|
|
@@ -57,14 +60,14 @@ export function resolveGroup(group) {
|
|
|
57
60
|
signatures.set(file, signature);
|
|
58
61
|
}
|
|
59
62
|
const audit = [
|
|
60
|
-
...resolveAuditVersionsFrom(group.dir,
|
|
63
|
+
...resolveAuditVersionsFrom(group.dir, deps, devDeps, ecosystem, read.versions, group.targets, resolved, read.instances),
|
|
61
64
|
...driftAudit,
|
|
62
65
|
].filter((dep) => !dep.declaredRange);
|
|
63
66
|
const sources = read.kind
|
|
64
67
|
? readFiles.map((file) => ({ path: file, kind: read.kind, mtimeMs: signatureMtime(signatures.get(file)) }))
|
|
65
68
|
: [];
|
|
66
69
|
const unresolved = ranges && ranges.size > 0 && read.source ? { manifest: read.source, ranges: ranges.size } : null;
|
|
67
|
-
return { group, resolved, audit, drift, source: sources[0] ?? null, sources, unresolved, signatures, resolvedAt };
|
|
70
|
+
return { group, resolved, audit, drift, source: sources[0] ?? null, sources, unresolved, problems: read.problems ?? [], signatures, resolvedAt };
|
|
68
71
|
}
|
|
69
72
|
/** True when any file the resolution depended on changed, appeared, or vanished. */
|
|
70
73
|
export function groupIsStale(result) {
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether a vulnerability scan answered for every dependency it was given.
|
|
3
|
+
*
|
|
4
|
+
* Until 6.2.5 an OSV.dev outage, a 503, a timeout or the server's own rate
|
|
5
|
+
* limit turned a scan into `total_vulnerable_packages: 0`, flagged only by
|
|
6
|
+
* `_meta.offline` at the end of the answer, and dependency_health and
|
|
7
|
+
* upgrade_planner then scored and ranked that as a clean project (10,568
|
|
8
|
+
* dependencies of D:\4DA\src-tauri, 0 vulnerable, 2026-10-12). Every scan
|
|
9
|
+
* consumer now asks this module first, and an incomplete scan leads its
|
|
10
|
+
* answer with what was not checked.
|
|
11
|
+
*/
|
|
12
|
+
import type { VulnerabilityCoverage, VulnerabilityScanResult } from "./types.js";
|
|
13
|
+
/** The reason given when live lookups are switched off. */
|
|
14
|
+
export declare const LOOKUPS_DISABLED = "live lookups are disabled (FOURDA_OFFLINE=true)";
|
|
15
|
+
/**
|
|
16
|
+
* Null when every dependency the scan was given has an advisory answer.
|
|
17
|
+
* Otherwise how many have none, and why. `result` null means no scan backs
|
|
18
|
+
* the answer at all: `expectedTotal` dependencies are then all unchecked.
|
|
19
|
+
*/
|
|
20
|
+
export declare function vulnerabilityCoverage(result: VulnerabilityScanResult | null, opts: {
|
|
21
|
+
enabled: boolean;
|
|
22
|
+
expectedTotal?: number;
|
|
23
|
+
notLoaded?: string;
|
|
24
|
+
}): VulnerabilityCoverage | null;
|
|
25
|
+
/** The first line an incomplete answer leads with. */
|
|
26
|
+
export declare function notCheckedLine(reason: string, unchecked: number, total: number): string;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/**
|
|
3
|
+
* Whether a vulnerability scan answered for every dependency it was given.
|
|
4
|
+
*
|
|
5
|
+
* Until 6.2.5 an OSV.dev outage, a 503, a timeout or the server's own rate
|
|
6
|
+
* limit turned a scan into `total_vulnerable_packages: 0`, flagged only by
|
|
7
|
+
* `_meta.offline` at the end of the answer, and dependency_health and
|
|
8
|
+
* upgrade_planner then scored and ranked that as a clean project (10,568
|
|
9
|
+
* dependencies of D:\4DA\src-tauri, 0 vulnerable, 2026-10-12). Every scan
|
|
10
|
+
* consumer now asks this module first, and an incomplete scan leads its
|
|
11
|
+
* answer with what was not checked.
|
|
12
|
+
*/
|
|
13
|
+
/** The reason given when live lookups are switched off. */
|
|
14
|
+
export const LOOKUPS_DISABLED = "live lookups are disabled (FOURDA_OFFLINE=true)";
|
|
15
|
+
/**
|
|
16
|
+
* Null when every dependency the scan was given has an advisory answer.
|
|
17
|
+
* Otherwise how many have none, and why. `result` null means no scan backs
|
|
18
|
+
* the answer at all: `expectedTotal` dependencies are then all unchecked.
|
|
19
|
+
*/
|
|
20
|
+
export function vulnerabilityCoverage(result, opts) {
|
|
21
|
+
if (!result) {
|
|
22
|
+
const total = opts.expectedTotal ?? 0;
|
|
23
|
+
const reason = opts.enabled ? (opts.notLoaded ?? "the OSV.dev scan did not finish in time or OSV.dev was unreachable") : LOOKUPS_DISABLED;
|
|
24
|
+
return build(total, total, reason);
|
|
25
|
+
}
|
|
26
|
+
// A stub or an older result that says offline without a count answered for nothing.
|
|
27
|
+
const unchecked = result.unchecked ?? (result.offline ? result.totalScanned : 0);
|
|
28
|
+
const stale = result.staleAnswers ?? 0;
|
|
29
|
+
if (unchecked <= 0 && stale <= 0)
|
|
30
|
+
return null;
|
|
31
|
+
if (unchecked > 0) {
|
|
32
|
+
const gap = build(result.totalScanned, unchecked, result.uncheckedReason ?? "OSV.dev could not be queried");
|
|
33
|
+
return stale > 0 ? { ...gap, stale_dependencies: stale, note: `${gap.note} ${staleLine(result, stale)}` } : gap;
|
|
34
|
+
}
|
|
35
|
+
// Every dependency has an answer, but some only from an expired cache because the fresh query
|
|
36
|
+
// failed. That answer can predate an advisory, so it is not a current all-clear (audit 6.2.5
|
|
37
|
+
// B-2: dependency_check said `proceed` and the scan "0 vulnerable" on a 1 h old answer).
|
|
38
|
+
const reason = result.staleReason ?? "OSV.dev could not be queried";
|
|
39
|
+
return {
|
|
40
|
+
complete: false,
|
|
41
|
+
total_dependencies: result.totalScanned,
|
|
42
|
+
checked_dependencies: result.totalScanned,
|
|
43
|
+
unchecked_dependencies: 0,
|
|
44
|
+
stale_dependencies: stale,
|
|
45
|
+
reason,
|
|
46
|
+
note: `STALE: ${reason}. ${staleLine(result, stale)}`,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
/** "N dependencies were answered only from an expired cached answer (the oldest fetched ...)". */
|
|
50
|
+
function staleLine(result, stale) {
|
|
51
|
+
const n = stale.toLocaleString("en-US");
|
|
52
|
+
const oldest = result.staleOldestFetchedAt ? ` (the oldest fetched ${result.staleOldestFetchedAt} UTC)` : "";
|
|
53
|
+
return `${n} dependenc${stale === 1 ? "y was" : "ies were"} answered only from an expired cached answer${oldest}; an advisory published since would not show, so this is not a current all-clear. Call again once OSV.dev answers (force_refresh).`;
|
|
54
|
+
}
|
|
55
|
+
function build(total, unchecked, reason) {
|
|
56
|
+
return {
|
|
57
|
+
complete: false,
|
|
58
|
+
total_dependencies: total,
|
|
59
|
+
checked_dependencies: Math.max(0, total - unchecked),
|
|
60
|
+
unchecked_dependencies: unchecked,
|
|
61
|
+
reason,
|
|
62
|
+
note: notCheckedLine(reason, unchecked, total),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
/** The first line an incomplete answer leads with. */
|
|
66
|
+
export function notCheckedLine(reason, unchecked, total) {
|
|
67
|
+
const n = (x) => x.toLocaleString("en-US");
|
|
68
|
+
const scope = total > 0 ? `${n(unchecked)} of ${n(total)} dependencies have no vulnerability answer` : "no dependency has a vulnerability answer";
|
|
69
|
+
return `NOT CHECKED: ${reason} — ${scope}. Their absence from the findings means nothing: this is not a clean result. Call again once OSV.dev answers (force_refresh), or check them another way.`;
|
|
70
|
+
}
|
|
@@ -9,6 +9,14 @@ export declare function parseSemver(version: string): [number, number, number] |
|
|
|
9
9
|
* The numeric fields stay literal (0.8.6 -> 0.9.0 is major 0, minor 1).
|
|
10
10
|
*/
|
|
11
11
|
export declare function computeSemverDistance(current: string, latest: string): SemverDistance | null;
|
|
12
|
+
/**
|
|
13
|
+
* Whether the suffix after MAJOR.MINOR.PATCH marks a prerelease. Semver: only
|
|
14
|
+
* `-` does; `+build` metadata is ignored for precedence, so `0.25.17+spec-1.1.0`
|
|
15
|
+
* (toml_edit) is a release, and `+incompatible` is a Go release. Until 6.2.5 a
|
|
16
|
+
* `+` counted as a prerelease, which named toml_edit 0.23.9 its latest stable.
|
|
17
|
+
* PEP 440 spellings (PyPI uses this too): `a`/`b`/`rc`/`alpha`/`beta`/`c`/`pre`
|
|
18
|
+
* and `.dev` mark a prerelease; `.post` and `+local` do not.
|
|
19
|
+
*/
|
|
12
20
|
export declare function isPreRelease(version: string): boolean;
|
|
13
21
|
/**
|
|
14
22
|
* Compare two versions by MAJOR.MINOR.PATCH. Returns 1 if a > b, -1 if a < b,
|
|
@@ -5,6 +5,13 @@ export function parseSemver(version) {
|
|
|
5
5
|
return null;
|
|
6
6
|
return [parseInt(match[1]), parseInt(match[2]), parseInt(match[3])];
|
|
7
7
|
}
|
|
8
|
+
/** MAJOR[.MINOR[.PATCH]] at the start of a version, missing parts as 0; null without a leading number. */
|
|
9
|
+
function parseLooseRelease(version) {
|
|
10
|
+
const match = version.replace(/^v/, "").match(/^(\d+)(?:\.(\d+))?(?:\.(\d+))?/);
|
|
11
|
+
if (!match)
|
|
12
|
+
return null;
|
|
13
|
+
return [parseInt(match[1]), parseInt(match[2] ?? "0"), parseInt(match[3] ?? "0")];
|
|
14
|
+
}
|
|
8
15
|
/**
|
|
9
16
|
* How far `current` is behind `latest`. `label` is the kind of bump, by the
|
|
10
17
|
* caret rules npm and Cargo apply: below 1.0 a minor bump is breaking (^0.8.6
|
|
@@ -14,8 +21,11 @@ export function parseSemver(version) {
|
|
|
14
21
|
* The numeric fields stay literal (0.8.6 -> 0.9.0 is major 0, minor 1).
|
|
15
22
|
*/
|
|
16
23
|
export function computeSemverDistance(current, latest) {
|
|
17
|
-
|
|
18
|
-
|
|
24
|
+
// Missing parts count as 0: PyPI and Go write `4.2` and `1.23`. Requiring three parts left
|
|
25
|
+
// Django 4.2 -> 5.2.17 and urllib3 1.23 -> 2.8.0 "unknown", which the planner counted as
|
|
26
|
+
// non-breaking (audit 6.2.5 A-3 / D-9).
|
|
27
|
+
const c = parseLooseRelease(current);
|
|
28
|
+
const l = parseLooseRelease(latest);
|
|
19
29
|
if (!c || !l)
|
|
20
30
|
return null;
|
|
21
31
|
const major = l[0] - c[0];
|
|
@@ -30,8 +40,21 @@ export function computeSemverDistance(current, latest) {
|
|
|
30
40
|
label = c[0] === 0 && c[1] === 0 ? "major" : "patch";
|
|
31
41
|
return { major: Math.max(0, major), minor: Math.max(0, minor), patch: Math.max(0, patch), label };
|
|
32
42
|
}
|
|
43
|
+
/**
|
|
44
|
+
* Whether the suffix after MAJOR.MINOR.PATCH marks a prerelease. Semver: only
|
|
45
|
+
* `-` does; `+build` metadata is ignored for precedence, so `0.25.17+spec-1.1.0`
|
|
46
|
+
* (toml_edit) is a release, and `+incompatible` is a Go release. Until 6.2.5 a
|
|
47
|
+
* `+` counted as a prerelease, which named toml_edit 0.23.9 its latest stable.
|
|
48
|
+
* PEP 440 spellings (PyPI uses this too): `a`/`b`/`rc`/`alpha`/`beta`/`c`/`pre`
|
|
49
|
+
* and `.dev` mark a prerelease; `.post` and `+local` do not.
|
|
50
|
+
*/
|
|
33
51
|
export function isPreRelease(version) {
|
|
34
|
-
|
|
52
|
+
const suffix = version.replace(/^v/, "").replace(/^\d+\.\d+\.\d+/, "").split("+")[0].toLowerCase();
|
|
53
|
+
if (suffix === "")
|
|
54
|
+
return false;
|
|
55
|
+
if (suffix.startsWith("-"))
|
|
56
|
+
return true;
|
|
57
|
+
return /^[._]?(?:a|b|c|rc|alpha|beta|pre|preview|dev)\d*/.test(suffix) && !/^[._-]?post/.test(suffix);
|
|
35
58
|
}
|
|
36
59
|
/**
|
|
37
60
|
* Compare two versions by MAJOR.MINOR.PATCH. Returns 1 if a > b, -1 if a < b,
|
|
@@ -28,6 +28,19 @@ export interface DependencyScope {
|
|
|
28
28
|
isDev: boolean;
|
|
29
29
|
devScopeKnown: boolean;
|
|
30
30
|
}
|
|
31
|
+
/**
|
|
32
|
+
* Dev-only: EVERY installed path to this copy goes through dev dependencies (vulnerability_scan's
|
|
33
|
+
* `is_dev` with a known scope: a direct devDependency, or a transitive the lockfile marks dev).
|
|
34
|
+
* The one population `include_dev: false` leaves out, in vulnerability_scan, dependency_health and
|
|
35
|
+
* upgrade_planner alike. The three meant three things: health left out only direct
|
|
36
|
+
* devDependencies yet said "devDependencies left out" (NodeGoat: 82 vulnerable packages where 34
|
|
37
|
+
* are runtime), the scan kept 225 dev-only rows, and the planner dropped all 90 dev-only copies
|
|
38
|
+
* (final release audit of 6.2.5, finding 6).
|
|
39
|
+
*/
|
|
40
|
+
export declare function isDevOnlyCopy(d: {
|
|
41
|
+
isDev: boolean;
|
|
42
|
+
devScopeKnown?: boolean;
|
|
43
|
+
}): boolean;
|
|
31
44
|
/** Ordering for sorts and "at or above" filters; unknown ranks lowest. */
|
|
32
45
|
export declare const SEVERITY_RANK: Record<SeverityTier, number>;
|
|
33
46
|
/**
|
|
@@ -39,7 +52,7 @@ export declare const SEVERITY_RANK: Record<SeverityTier, number>;
|
|
|
39
52
|
* transitive runtime high -> high.
|
|
40
53
|
*/
|
|
41
54
|
export declare function scopeAdjustedSeverity(advisory: SeverityTier, scope: DependencyScope): SeverityTier;
|
|
42
|
-
type ScopedEntry = Pick<VulnerabilityEntry, "severity" | "isDirect" | "isDev" | "devScopeKnown">;
|
|
55
|
+
type ScopedEntry = Pick<VulnerabilityEntry, "severity" | "isDirect" | "isDev" | "devScopeKnown" | "malware">;
|
|
43
56
|
/**
|
|
44
57
|
* The grade a surface presents for one scan entry. A missing `isDirect`
|
|
45
58
|
* (never produced today; defensive for foreign cache rows) is treated as
|
|
@@ -54,4 +67,16 @@ export declare function scopeAdjustmentReason(v: ScopedEntry): string | null;
|
|
|
54
67
|
export type SeverityCounts = Record<SeverityTier, number>;
|
|
55
68
|
/** Count entries per tier, using whichever tier function the caller names. */
|
|
56
69
|
export declare function countBySeverity<T>(entries: T[], tierOf: (entry: T) => SeverityTier): SeverityCounts;
|
|
70
|
+
/**
|
|
71
|
+
* The tier a finding RANKS by (the 4DA app's base urgency, severity vectors sv-004/sv-010/sv-019),
|
|
72
|
+
* then the scope rule, as presentedSeverity applies it: an ungraded advisory ranks medium, a
|
|
73
|
+
* malicious-package record (MAL-) at least high. A dev-only medium ranks low, as the scan presents
|
|
74
|
+
* it; the planner kept it at medium (final release audit, finding 2 and the 4DA severity parity).
|
|
75
|
+
*/
|
|
76
|
+
export declare function rankedSeverity(v: ScopedEntry & {
|
|
77
|
+
vulnId?: string;
|
|
78
|
+
aliases?: string[];
|
|
79
|
+
malware?: boolean;
|
|
80
|
+
summary?: string;
|
|
81
|
+
}): Exclude<SeverityTier, "unknown">;
|
|
57
82
|
export {};
|
|
@@ -22,6 +22,19 @@
|
|
|
22
22
|
* no discount without evidence. The advisory's own tier is never discarded;
|
|
23
23
|
* surfaces carry it beside the presented grade as `advisory_severity`.
|
|
24
24
|
*/
|
|
25
|
+
import { isMalware } from "./malware.js";
|
|
26
|
+
/**
|
|
27
|
+
* Dev-only: EVERY installed path to this copy goes through dev dependencies (vulnerability_scan's
|
|
28
|
+
* `is_dev` with a known scope: a direct devDependency, or a transitive the lockfile marks dev).
|
|
29
|
+
* The one population `include_dev: false` leaves out, in vulnerability_scan, dependency_health and
|
|
30
|
+
* upgrade_planner alike. The three meant three things: health left out only direct
|
|
31
|
+
* devDependencies yet said "devDependencies left out" (NodeGoat: 82 vulnerable packages where 34
|
|
32
|
+
* are runtime), the scan kept 225 dev-only rows, and the planner dropped all 90 dev-only copies
|
|
33
|
+
* (final release audit of 6.2.5, finding 6).
|
|
34
|
+
*/
|
|
35
|
+
export function isDevOnlyCopy(d) {
|
|
36
|
+
return d.isDev === true && d.devScopeKnown === true;
|
|
37
|
+
}
|
|
25
38
|
const ONE_LEVEL_DOWN = {
|
|
26
39
|
critical: "high",
|
|
27
40
|
high: "medium",
|
|
@@ -59,6 +72,11 @@ export function scopeAdjustedSeverity(advisory, scope) {
|
|
|
59
72
|
* direct, and a missing `devScopeKnown` as unknown: both mean no discount.
|
|
60
73
|
*/
|
|
61
74
|
export function presentedSeverity(v) {
|
|
75
|
+
// An installed malicious copy is critical wherever it sits: a transitive or
|
|
76
|
+
// dev-only copy runs its install scripts all the same. The scan capped it at
|
|
77
|
+
// high for a transitive copy while the planner said critical (audit 6.2.5 D-3).
|
|
78
|
+
if (v.malware === true)
|
|
79
|
+
return "critical";
|
|
62
80
|
return scopeAdjustedSeverity(v.severity, {
|
|
63
81
|
isDirect: v.isDirect !== false,
|
|
64
82
|
isDev: v.isDev === true,
|
|
@@ -87,3 +105,17 @@ export function countBySeverity(entries, tierOf) {
|
|
|
87
105
|
counts[tierOf(entry)]++;
|
|
88
106
|
return counts;
|
|
89
107
|
}
|
|
108
|
+
/**
|
|
109
|
+
* The tier a finding RANKS by (the 4DA app's base urgency, severity vectors sv-004/sv-010/sv-019),
|
|
110
|
+
* then the scope rule, as presentedSeverity applies it: an ungraded advisory ranks medium, a
|
|
111
|
+
* malicious-package record (MAL-) at least high. A dev-only medium ranks low, as the scan presents
|
|
112
|
+
* it; the planner kept it at medium (final release audit, finding 2 and the 4DA severity parity).
|
|
113
|
+
*/
|
|
114
|
+
export function rankedSeverity(v) {
|
|
115
|
+
// An installed malicious copy is critical (the 4DA lead, 2026-10-18), whatever its own grade.
|
|
116
|
+
if (v.malware === true || isMalware(v.vulnId ?? "", v.aliases ?? [], [], v.summary ?? ""))
|
|
117
|
+
return "critical";
|
|
118
|
+
const base = v.severity === "unknown" ? "medium" : v.severity;
|
|
119
|
+
const tier = presentedSeverity({ ...v, severity: base });
|
|
120
|
+
return tier === "unknown" ? "medium" : tier;
|
|
121
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which values may be spliced into a command an agent is told to run.
|
|
3
|
+
*
|
|
4
|
+
* Package names, installed versions, Cargo dependency keys and `[target.'cfg(...)']`
|
|
5
|
+
* headers come from the scanned project's own files: a cloned repository controls
|
|
6
|
+
* them. upgrade_planner put a Cargo.toml target header inside `--target "..."`,
|
|
7
|
+
* where bash and PowerShell still expand `$(...)` and backticks, and a dependency
|
|
8
|
+
* key unquoted after `--rename` (audit 6.2.5 C-1). A command is named only when
|
|
9
|
+
* every spliced value is a plain token no shell (sh, bash, zsh, cmd.exe,
|
|
10
|
+
* PowerShell) reads specially; otherwise the caller falls back to words.
|
|
11
|
+
*/
|
|
12
|
+
export declare function isShellSafeName(name: string, ecosystem: string): boolean;
|
|
13
|
+
export declare function isShellSafeVersion(version: string): boolean;
|
|
14
|
+
/**
|
|
15
|
+
* A Cargo target header that can go inside double quotes on every shell. A `cfg` with a string
|
|
16
|
+
* literal (`cfg(target_os = "linux")`) cannot: its quotes end the argument early. It is left to
|
|
17
|
+
* the words, which was always the only correct answer for it.
|
|
18
|
+
*/
|
|
19
|
+
export declare function isShellSafeCargoTarget(target: string): boolean;
|