fad-checker 1.0.4 → 1.0.6
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/CLAUDE.md +5 -2
- package/data/eol-mapping.json +12 -0
- package/docs/ARCHITECTURE.md +8 -6
- package/fad-checker.js +18 -4
- package/lib/cve-match.js +29 -10
- package/lib/cve-report.js +96 -13
- package/lib/npm/collect.js +25 -1
- package/lib/npm/registry.js +206 -0
- package/lib/osv.js +19 -13
- package/lib/outdated.js +18 -1
- package/package.json +3 -2
- package/test/cve-match.test.js +40 -0
- package/test/npm-registry.test.js +64 -0
- package/test/outdated.test.js +45 -0
- package/test/webjar.test.js +33 -0
package/CLAUDE.md
CHANGED
|
@@ -15,7 +15,7 @@ Code-level orientation for contributors and Claude Code sessions on this repo.
|
|
|
15
15
|
- retire.js (vendored JS signatures),
|
|
16
16
|
- optionally Snyk (`--snyk`).
|
|
17
17
|
4. Cross-checks every match's NVD CPE configurations against the dep version (`lib/cpe.js`) to filter false positives.
|
|
18
|
-
5. Reports EOL frameworks (endoflife.date), obsolete libs (curated), outdated libs (Maven Central).
|
|
18
|
+
5. Reports EOL frameworks (endoflife.date — Maven & npm), obsolete libs (curated Maven + npm-registry per-version `deprecated` field — authoritative, skips nothing), outdated libs (Maven Central + npm registry `dist-tags.latest`). **WebJars** (`org.webjars*`) are reduced to their npm coordinate by `webjarToNpm()` and run through the npm EOL/deprecation/outdated paths — so e.g. `org.webjars:angularjs:1.8.3` is flagged EOL.
|
|
19
19
|
6. Produces a self-contained HTML report + Word-compatible `.doc`, organised by ecosystem and by defining manifest, with per-tool fix recipes and an executive summary.
|
|
20
20
|
|
|
21
21
|
No build tool (`mvn`, `npm install`, `yarn`) is required on PATH — `pom.xml` / `package-lock.json` / `yarn.lock` are parsed directly.
|
|
@@ -67,6 +67,7 @@ lib/retire.js retire.js (vendored-JS scanner) wrapper + cache + n
|
|
|
67
67
|
lib/scan-completeness.js Warnings for deps fad-checker couldn't fully resolve.
|
|
68
68
|
lib/npm/parse.js package.json, package-lock.json (v1/2/3), yarn.lock v1 parsers.
|
|
69
69
|
lib/npm/collect.js Merge across JS manifests → unified resolvedDeps Map.
|
|
70
|
+
lib/npm/registry.js npm registry packument query → per-version deprecation + dist-tags.latest (npm EOL feeds via lib/outdated.js).
|
|
70
71
|
lib/cache-archive.js tar.gz / zip export & import of ~/.fad-checker/.
|
|
71
72
|
lib/config.js Persistent user config in ~/.fad-checker/config.json (mode 0600).
|
|
72
73
|
data/ known-obsolete.json, eol-mapping.json, cpe-coord-map.json, known-public-namespaces.json
|
|
@@ -84,6 +85,7 @@ For the deep dive — pipeline stages, the resolved-deps Map shape, report struc
|
|
|
84
85
|
- **No `process.exit(1)` mid-pipeline**: a parse/rewrite failure for one POM logs and continues so the summary still prints.
|
|
85
86
|
- **HTML report is self-contained**: inline CSS, no external assets. The `.doc` variant is the same HTML with Office XML namespace meta tags — Word opens it natively.
|
|
86
87
|
- **Map keys are ecosystem-namespaced**: Maven uses `g:a`, npm uses `npm:name`. They never collide so they share one resolved-deps Map.
|
|
88
|
+
- **Every distinct version is scanned, not just the highest**: when profiles/modules pin the same `g:a` to different versions, the resolved-dep entry keeps `version` = highest (representative for display/EOL/outdated) but `versions` = all distinct concrete versions. CVE matching (`matchOne`) and OSV (`queryOsvForDeps`) iterate `versions` so a vuln affecting only a lower-versioned profile variant isn't missed. Match dedup keys are `g:a:version|cve.id` (version included) to preserve per-version findings.
|
|
87
89
|
- **Lockfile-only npm**: `package.json` without sibling `package-lock.json`/`yarn.lock` is intentionally skipped (its ranges aren't queryable) and reported in chapter 0.
|
|
88
90
|
- **Source identifiers**: every match carries `source: "fad" | "osv" | "nvd" | "snyk" | "retire"` (or a `+`-joined combination).
|
|
89
91
|
|
|
@@ -106,7 +108,7 @@ Test fixtures live in `test/fixtures/`:
|
|
|
106
108
|
- CVE bundle from CVEProject is ~500 MB unpacked. Shells out to `curl + unzip` (fallback to `fetch()` + `unzip` / `Expand-Archive`). Extracted JSON deleted after index build. Ships as `cves.zip.zip` (nested zip) — `extractZip()` recurses up to 3 levels.
|
|
107
109
|
- `endoflife.date` API responses cached 7 days; Maven Central version lookups cached 24 hours. Cache lives in `~/.fad-checker/`.
|
|
108
110
|
- **Persistent config**: `~/.fad-checker/config.json` (mode 0600). Set NVD key via `fad-checker --set-nvd-key <KEY>` (free, instant from <https://nvd.nist.gov/developers/request-an-api-key> — bumps rate limit from 5/30s to 50/30s).
|
|
109
|
-
- **`--offline` umbrella flag**: skips every network call (CVE/OSV/NVD/Maven Central/endoflife/retire). Falls back to whatever is already cached. Per-source variants (`--cve-offline`, `--no-osv`, `--no-nvd`, `--no-retire`, `--no-transitive`) still work independently.
|
|
111
|
+
- **`--offline` umbrella flag**: skips every network call (CVE/OSV/NVD/Maven Central/endoflife/npm-registry/retire). Falls back to whatever is already cached. Per-source variants (`--cve-offline`, `--no-osv`, `--no-nvd`, `--no-retire`, `--no-transitive`, `--no-js`) still work independently. npm registry deprecation always runs when online; npm (and Maven) outdated is gated by `--no-all-libs`.
|
|
110
112
|
- `snyk` is not a hard dep — shells out via `execFile`. `snyk` exits 1 on findings; the JSON is still on stdout.
|
|
111
113
|
- The cleaned POM is the union of every profile's deps. Counts will be larger than the source POM. Intentional — don't "fix" that.
|
|
112
114
|
- Unresolved `${…}` Maven variables stay verbatim in the rewritten POM. `lib/cve-match.js` resolves them lazily via `resolveDepVersion()` when scanning. Deps that *still* can't be resolved (external BOM not in source tree) surface in chapter 0 as `unresolved-versions` warnings.
|
|
@@ -122,5 +124,6 @@ Test fixtures live in `test/fixtures/`:
|
|
|
122
124
|
| NVD CVE record | `~/.fad-checker/nvd-cache/<cveId>.json` | 7 d |
|
|
123
125
|
| endoflife.date cycles | `~/.fad-checker/eol-cache.json` | 7 d |
|
|
124
126
|
| Maven Central latest | `~/.fad-checker/version-cache.json` | 24 h |
|
|
127
|
+
| npm registry (deprecation + latest) | `~/.fad-checker/npm-registry-cache.json` | 24 h |
|
|
125
128
|
| Transitive POM | `~/.fad-checker/poms-cache/<g>__<a>__<v>.pom` | ∞ (immutable on Maven Central) |
|
|
126
129
|
| retire.js findings | `~/.fad-checker/retire-cache/<md5(src)>.json` | 24 h |
|
package/data/eol-mapping.json
CHANGED
|
@@ -30,5 +30,17 @@
|
|
|
30
30
|
"org.springframework": { "product": "spring-framework", "label": "Spring Framework" },
|
|
31
31
|
"org.hibernate": { "product": "hibernate", "label": "Hibernate ORM" },
|
|
32
32
|
"io.netty": { "product": "netty", "label": "Netty" }
|
|
33
|
+
},
|
|
34
|
+
"by_npm_name": {
|
|
35
|
+
"angular": { "product": "angularjs", "label": "AngularJS" },
|
|
36
|
+
"angularjs": { "product": "angularjs", "label": "AngularJS" },
|
|
37
|
+
"vue": { "product": "vue", "label": "Vue" },
|
|
38
|
+
"react": { "product": "react", "label": "React" },
|
|
39
|
+
"react-dom": { "product": "react", "label": "React" },
|
|
40
|
+
"jquery": { "product": "jquery", "label": "jQuery" },
|
|
41
|
+
"bootstrap": { "product": "bootstrap", "label": "Bootstrap" }
|
|
42
|
+
},
|
|
43
|
+
"by_npm_scope": {
|
|
44
|
+
"@angular/": { "product": "angular", "label": "Angular" }
|
|
33
45
|
}
|
|
34
46
|
}
|
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -21,6 +21,7 @@ lib/retire.js retire.js (vendored-JS scanner) wrapper + cache + n
|
|
|
21
21
|
lib/scan-completeness.js Warnings for deps we couldn't fully resolve.
|
|
22
22
|
lib/npm/parse.js package.json, package-lock.json (v1/2/3), yarn.lock v1 parsers.
|
|
23
23
|
lib/npm/collect.js Merge across JS manifests → unified resolvedDeps Map.
|
|
24
|
+
lib/npm/registry.js npm registry packument query → per-version deprecation + dist-tags.latest.
|
|
24
25
|
lib/cache-archive.js tar.gz / zip export & import of ~/.fad-checker/.
|
|
25
26
|
lib/config.js Persistent user config in ~/.fad-checker/config.json (mode 0600).
|
|
26
27
|
data/ Curated JSON: known-obsolete, eol-mapping, cpe-coord-map, known-public-namespaces.
|
|
@@ -77,10 +78,11 @@ The Maven keyspace and npm keyspace never collide — `:lodash` (Maven groupId-l
|
|
|
77
78
|
- Confirms the dep version actually falls in the vulnerable range (else `cpeFiltered: true` — likely false positive).
|
|
78
79
|
- Upgrades match `confidence` from `possible` → `probable` → `exact` when a curated `cpe-coord-map.json` entry confirms vendor:product → dep coord.
|
|
79
80
|
8. **retire.js** (default on) — shells out to `retire --outputformat json --jspath <src>`. Output normalised to fad-checker match shape, with the vendored file path attached so the report can show where the offending `.js` lives. Cache: `~/.fad-checker/retire-cache/<md5(src)>.json`, 24h TTL.
|
|
80
|
-
9. **EOL / Obsolete / Outdated** — `lib/outdated.js
|
|
81
|
-
- **
|
|
82
|
-
- **
|
|
83
|
-
- **
|
|
81
|
+
9. **EOL / Obsolete / Outdated** — `lib/outdated.js` (Maven) + `lib/npm/registry.js` (npm):
|
|
82
|
+
- **WebJars** (`org.webjars*` — client-side JS shipped as Maven artifacts) are reduced to their npm-equivalent coordinate by `webjarToNpm()` (`lib/npm/collect.js`): `org.webjars.npm` is a deterministic npm mirror (`angular__core` → `@angular/core`); classic `org.webjars`/bower names pass through. They then flow through the **same npm paths** below — no WebJar-specific data.
|
|
83
|
+
- **EOL**: matches dep coord against `data/eol-mapping.json`, fetches the cycle list from endoflife.date (cached 7d), flags cycles past their EOL date. npm packages and WebJars resolve by JS library name via `by_npm_name` / `by_npm_scope` (e.g. npm `angular`/webjar `angularjs` → AngularJS 1.x, `@angular/*` → Angular, `react`/`jquery`/`vue`/`bootstrap`).
|
|
84
|
+
- **Obsolete**: Maven via curated `data/known-obsolete.json` (log4j 1.x, jackson-mapper-asl, joda-time, commons-httpclient 3.x, …); npm **and WebJars** via the registry's per-version `deprecated` field (authoritative maintainer data — every dep is checked, nothing curated, nothing skipped).
|
|
85
|
+
- **Outdated**: Maven Central Solr query; npm registry `dist-tags.latest` (npm deps and WebJars). Both gated by `--no-all-libs`. Cache 24h. Concurrency 8.
|
|
84
86
|
10. **Snyk** (optional, `--snyk`) — runs `snyk test --all-projects --json` against the cleaned target dir. Normalised + merged. Findings in both sources tagged `source: "both"`.
|
|
85
87
|
11. **Render** — `writeReports()` produces `cve-report.html` (self-contained, inline CSS, no external assets) and `cve-report.doc` (same HTML with Office XML namespace meta tags so Word opens it natively). Default output dir: `./fad-checker-report/`.
|
|
86
88
|
|
|
@@ -129,9 +131,9 @@ The Maven keyspace and npm keyspace never collide — `:lodash` (Maven groupId-l
|
|
|
129
131
|
|
|
130
132
|
- The CVE bundle from CVEProject is ~500 MB unpacked. We shell out to `curl + unzip` (Node built-in fallback to `fetch()` + system `unzip` / PowerShell `Expand-Archive`). The extracted JSON is deleted after the index is built.
|
|
131
133
|
- The bundle ships as `cves.zip.zip` (a zip whose sole content is another zip). `extractZip()` recurses up to 3 levels.
|
|
132
|
-
- `endoflife.date` API responses are cached locally for 7 days; Maven Central version lookups for 24 hours.
|
|
134
|
+
- `endoflife.date` API responses are cached locally for 7 days; Maven Central and npm registry version lookups for 24 hours.
|
|
133
135
|
- **Persistent config**: `~/.fad-checker/config.json` (mode 0600) stores per-user state, currently the NVD API key. Set via `fad-checker --set-nvd-key <KEY>`.
|
|
134
|
-
- **`--offline` umbrella flag**: skips every network call (CVE index download, OSV queries, NVD enrichment, endoflife.date lookups, Maven Central version queries, transitive POM fetches, retire.js scans). Falls back to whatever is already cached. Per-source variants (`--cve-offline`, `--no-osv`, `--no-nvd`, `--no-retire`) still work independently.
|
|
136
|
+
- **`--offline` umbrella flag**: skips every network call (CVE index download, OSV queries, NVD enrichment, endoflife.date lookups, Maven Central version queries, npm registry queries, transitive POM fetches, retire.js scans). Falls back to whatever is already cached. Per-source variants (`--cve-offline`, `--no-osv`, `--no-nvd`, `--no-retire`, `--no-js`) still work independently.
|
|
135
137
|
- `snyk` is not a dependency — we shell out via `execFile`. `snyk` exits 1 when it finds vulnerabilities, which is expected (the JSON is still on stdout).
|
|
136
138
|
- The cleaned POM is the union of every profile's deps. Counts will therefore be larger than the source POM. This is intentional — verify your reasoning before "reducing" them.
|
|
137
139
|
- Unresolved `${…}` Maven variables are kept verbatim in the rewritten POM. `lib/cve-match.js` resolves them lazily via `resolveDepVersion()` when collecting deps for the scan. Deps that *still* can't be resolved (external BOM) are surfaced in chapter 0 as `unresolved-versions` warnings.
|
package/fad-checker.js
CHANGED
|
@@ -464,6 +464,17 @@ async function runReportFlow(allPomMetadata, allPropsByPom, ecoFlags = {}) {
|
|
|
464
464
|
catch (err) { console.warn(chalk.yellow("⚠️ Outdated check skipped:"), err.message); }
|
|
465
465
|
}
|
|
466
466
|
|
|
467
|
+
// 4a. npm registry — deprecation (always, authoritative maintainer data) and
|
|
468
|
+
// outdated (gated by --all-libs like Maven Central). Covers npm deps and
|
|
469
|
+
// WebJars (Maven artifacts wrapping npm/bower libs), so it runs even in
|
|
470
|
+
// Maven-only mode. One fetch per package; no-ops when there are no targets.
|
|
471
|
+
try {
|
|
472
|
+
const { checkNpmRegistryDeps } = require("./lib/npm/registry");
|
|
473
|
+
const npmReg = await checkNpmRegistryDeps(resolved, { verbose, offline, allLibs: options.allLibs });
|
|
474
|
+
obsoleteResults = obsoleteResults.concat(npmReg.deprecated);
|
|
475
|
+
outdatedResults = outdatedResults.concat(npmReg.outdated);
|
|
476
|
+
} catch (err) { console.warn(chalk.yellow("⚠️ npm registry check skipped:"), err.message); }
|
|
477
|
+
|
|
467
478
|
// Cross-section dedup: drop entries from outdated that already appear in EOL/Obsolete
|
|
468
479
|
const eolKeys = new Set(eolResults.map(r => `${r.dep.groupId}:${r.dep.artifactId}`));
|
|
469
480
|
const obsKeys = new Set(obsoleteResults.map(r => `${r.dep.groupId}:${r.dep.artifactId}`));
|
|
@@ -574,16 +585,19 @@ async function runReportFlow(allPomMetadata, allPropsByPom, ecoFlags = {}) {
|
|
|
574
585
|
if (retireMatches.length > 10) console.log(` ... and ${retireMatches.length - 10} more (see report)`);
|
|
575
586
|
}
|
|
576
587
|
|
|
588
|
+
// npm deps have no groupId; show them as "npm:name" rather than ":name".
|
|
589
|
+
const coordOf = d => d.ecosystem === "npm" ? `npm:${d.artifactId}` : `${d.groupId}:${d.artifactId}`;
|
|
590
|
+
|
|
577
591
|
console.log(chalk.bold.cyan("\n 2. End-of-Life Frameworks"));
|
|
578
|
-
for (const e of eolResults) console.log(` ${e.product.padEnd(20)} ${e.dep
|
|
592
|
+
for (const e of eolResults) console.log(` ${e.product.padEnd(20)} ${coordOf(e.dep)}:${e.dep.version} ${e.eol}`);
|
|
579
593
|
if (!eolResults.length) console.log(chalk.gray(" (none)"));
|
|
580
594
|
|
|
581
595
|
console.log(chalk.bold.cyan("\n 3. Obsolete / Deprecated Libraries"));
|
|
582
|
-
for (const o of obsoleteResults) console.log(` ${(o.severity || "info").padEnd(8)} ${o.dep
|
|
596
|
+
for (const o of obsoleteResults) console.log(` ${(o.severity || "info").padEnd(8)} ${coordOf(o.dep)}:${o.dep.version} → ${o.replacement || "n/a"}`);
|
|
583
597
|
if (!obsoleteResults.length) console.log(chalk.gray(" (none)"));
|
|
584
598
|
|
|
585
599
|
console.log(chalk.bold.cyan("\n 4. Outdated Libraries"));
|
|
586
|
-
for (const o of outdatedResults.slice(0, 20)) console.log(` ${o.dep
|
|
600
|
+
for (const o of outdatedResults.slice(0, 20)) console.log(` ${coordOf(o.dep)} ${o.dep.version} → ${o.latest}`);
|
|
587
601
|
if (outdatedResults.length > 20) console.log(` ... and ${outdatedResults.length - 20} more`);
|
|
588
602
|
if (!outdatedResults.length && options.allLibs) console.log(chalk.gray(" (none)"));
|
|
589
603
|
if (!options.allLibs) console.log(chalk.gray(" (re-run with -a/--allLibs to query Maven Central)"));
|
|
@@ -634,7 +648,7 @@ async function runReportFlow(allPomMetadata, allPropsByPom, ecoFlags = {}) {
|
|
|
634
648
|
*/
|
|
635
649
|
function mergeBySource(existing, additions) {
|
|
636
650
|
const byKey = new Map();
|
|
637
|
-
const k = m => `${m.dep.groupId}:${m.dep.artifactId}|${m.cve.id}`;
|
|
651
|
+
const k = m => `${m.dep.groupId}:${m.dep.artifactId}:${m.dep.version}|${m.cve.id}`;
|
|
638
652
|
for (const m of existing || []) byKey.set(k(m), { ...m, source: m.source || "fad" });
|
|
639
653
|
for (const m of additions || []) {
|
|
640
654
|
const key = k(m);
|
package/lib/cve-match.js
CHANGED
|
@@ -20,10 +20,13 @@ function resolveDepVersion(rawVersion, allProps) {
|
|
|
20
20
|
|
|
21
21
|
/**
|
|
22
22
|
* Walk allPomMetadata.byPath, collect every {groupId,artifactId,version,scope}
|
|
23
|
-
* triple, dedupe by groupId:artifactId
|
|
24
|
-
*
|
|
23
|
+
* triple, dedupe by groupId:artifactId. `version` is the highest seen
|
|
24
|
+
* (representative for display/EOL/outdated), while `versions` keeps EVERY
|
|
25
|
+
* distinct concrete version (e.g. two profiles pinning the same g:a) so the
|
|
26
|
+
* CVE/OSV scanners check each one, not just the highest. Also includes
|
|
27
|
+
* external parent POMs as scope='parent'.
|
|
25
28
|
*
|
|
26
|
-
* Returns Map<key, { groupId, artifactId, version, scope, pomPaths }>
|
|
29
|
+
* Returns Map<key, { groupId, artifactId, version, versions, scope, pomPaths }>
|
|
27
30
|
*/
|
|
28
31
|
function collectResolvedDeps(allPomMetadata, allPropsByPom, opts = {}) {
|
|
29
32
|
const { ignoreTest, deps2Exclude } = opts;
|
|
@@ -43,14 +46,19 @@ function collectResolvedDeps(allPomMetadata, allPropsByPom, opts = {}) {
|
|
|
43
46
|
const key = `${g}:${a}`;
|
|
44
47
|
const existing = out.get(key);
|
|
45
48
|
const isDev = scope === "test" || scope === "provided";
|
|
49
|
+
// A concrete (resolved, non-property) version worth scanning on its own.
|
|
50
|
+
const concrete = v && !/\$\{/.test(v) ? v : null;
|
|
46
51
|
if (!existing) {
|
|
47
|
-
out.set(key, { groupId: g, artifactId: a, version: v || null, scope, pomPaths: [pomPath], ecosystem: "maven", ecosystemType: "maven", isDev });
|
|
52
|
+
out.set(key, { groupId: g, artifactId: a, version: v || null, versions: concrete ? [concrete] : [], scope, pomPaths: [pomPath], ecosystem: "maven", ecosystemType: "maven", isDev });
|
|
48
53
|
} else {
|
|
49
54
|
if (!existing.pomPaths.includes(pomPath)) existing.pomPaths.push(pomPath);
|
|
50
55
|
if (existing.scope === "test" && scope !== "test") existing.scope = scope;
|
|
51
56
|
if (v && (!existing.version || compareMavenVersions(v, existing.version) > 0)) {
|
|
52
57
|
existing.version = v;
|
|
53
58
|
}
|
|
59
|
+
// Track every distinct concrete version (e.g. different profiles
|
|
60
|
+
// pinning the same g:a) so each gets scanned, not just the highest.
|
|
61
|
+
if (concrete && !existing.versions.includes(concrete)) existing.versions.push(concrete);
|
|
54
62
|
// A dep is "dev" overall only if every occurrence is test/provided.
|
|
55
63
|
if (!isDev) existing.isDev = false;
|
|
56
64
|
}
|
|
@@ -77,6 +85,7 @@ function collectResolvedDeps(allPomMetadata, allPropsByPom, opts = {}) {
|
|
|
77
85
|
if (!out.has(key)) {
|
|
78
86
|
out.set(key, {
|
|
79
87
|
groupId: p.groupId, artifactId: p.artifactId, version: p.version || null,
|
|
88
|
+
versions: p.version && !/\$\{/.test(p.version) ? [p.version] : [],
|
|
80
89
|
scope: "parent", pomPaths: [pomPath], ecosystem: "maven", ecosystemType: "maven",
|
|
81
90
|
});
|
|
82
91
|
}
|
|
@@ -127,6 +136,7 @@ async function expandWithTransitives(resolvedDeps, opts = {}) {
|
|
|
127
136
|
groupId: t.groupId,
|
|
128
137
|
artifactId: t.artifactId,
|
|
129
138
|
version: t.version,
|
|
139
|
+
versions: t.version && !/\$\{/.test(t.version) ? [t.version] : [],
|
|
130
140
|
scope: "transitive",
|
|
131
141
|
pomPaths: [],
|
|
132
142
|
via: t.via,
|
|
@@ -151,12 +161,21 @@ async function expandWithTransitives(resolvedDeps, opts = {}) {
|
|
|
151
161
|
function matchOne(dep, entries, confidence) {
|
|
152
162
|
const matches = [];
|
|
153
163
|
if (!entries) return matches;
|
|
164
|
+
// Scan every distinct version the dep resolves to (e.g. two profiles pinning
|
|
165
|
+
// the same g:a), not just the representative highest — otherwise a CVE that
|
|
166
|
+
// only affects a lower-versioned profile variant would be missed.
|
|
167
|
+
const versions = (dep.versions && dep.versions.length) ? dep.versions : [dep.version];
|
|
154
168
|
for (const e of entries) {
|
|
155
|
-
const
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
169
|
+
for (const ver of versions) {
|
|
170
|
+
const affected = (e.ranges || []).some(r => {
|
|
171
|
+
if (!ver) return r.status === "affected"; // unknown version → assume affected
|
|
172
|
+
return isVersionAffected(ver, r);
|
|
173
|
+
});
|
|
174
|
+
if (affected) {
|
|
175
|
+
const vdep = ver === dep.version ? dep : { ...dep, version: ver };
|
|
176
|
+
matches.push({ dep: vdep, cve: { ...e }, confidence });
|
|
177
|
+
}
|
|
178
|
+
}
|
|
160
179
|
}
|
|
161
180
|
return matches;
|
|
162
181
|
}
|
|
@@ -212,7 +231,7 @@ function matchDepsAgainstCves(resolvedDeps, cveIndex, opts = {}) {
|
|
|
212
231
|
const seen = new Set();
|
|
213
232
|
const deduped = [];
|
|
214
233
|
for (const m of all) {
|
|
215
|
-
const k = `${m.dep.groupId}:${m.dep.artifactId}|${m.cve.id}`;
|
|
234
|
+
const k = `${m.dep.groupId}:${m.dep.artifactId}:${m.dep.version}|${m.cve.id}`;
|
|
216
235
|
if (seen.has(k)) continue;
|
|
217
236
|
seen.add(k);
|
|
218
237
|
deduped.push(m);
|
package/lib/cve-report.js
CHANGED
|
@@ -132,6 +132,7 @@ code.path { font-family: ui-monospace, monospace; font-size: 0.9em; background:
|
|
|
132
132
|
.exec-summary .exec-head { display: flex; align-items: center; gap: 12px; }
|
|
133
133
|
.exec-summary .exec-level { font-size: 12px; font-weight: 700; letter-spacing: 1px; padding: 4px 12px; border-radius: 999px; }
|
|
134
134
|
.exec-summary .exec-title { font-size: 18px; font-weight: 600; color: #111827; }
|
|
135
|
+
.exec-summary .exec-copy-btn { margin-left: auto; }
|
|
135
136
|
.exec-summary .exec-meta { font-size: 12px; color: #6b7280; }
|
|
136
137
|
.exec-summary .exec-bullets { margin: 4px 0 0; padding-left: 22px; font-size: 13px; color: #374151; line-height: 1.6; }
|
|
137
138
|
.exec-summary.exec-critical { border-color: #dc2626; }
|
|
@@ -522,7 +523,7 @@ function renderDetailPanel(m) {
|
|
|
522
523
|
|
|
523
524
|
// 7. Declared-in POMs
|
|
524
525
|
if (dep.pomPaths?.length) {
|
|
525
|
-
const items = dep.pomPaths.slice(0, 10).map(p => `<div>${esc(p)}</div>`).join("");
|
|
526
|
+
const items = dep.pomPaths.slice(0, 10).map(p => `<div>${esc(makeRelative(p, RENDER_CTX.srcRoot))}</div>`).join("");
|
|
526
527
|
sections.push(section("Declared in",
|
|
527
528
|
`<div class="via-list">${items}${dep.pomPaths.length > 10 ? `<div style="color:#9ca3af">+${dep.pomPaths.length - 10} more</div>` : ""}</div>`,
|
|
528
529
|
{ count: `${dep.pomPaths.length} POM${dep.pomPaths.length > 1 ? "s" : ""}`, open: false }));
|
|
@@ -1029,7 +1030,7 @@ function summaryCards(stats, eol, obs, out, extra = {}) {
|
|
|
1029
1030
|
function mergeMatches(matches) {
|
|
1030
1031
|
const byKey = new Map();
|
|
1031
1032
|
for (const m of matches) {
|
|
1032
|
-
const key = `${m.dep.groupId}:${m.dep.artifactId}|${m.cve.id}`;
|
|
1033
|
+
const key = `${m.dep.groupId}:${m.dep.artifactId}:${m.dep.version}|${m.cve.id}`;
|
|
1033
1034
|
if (!byKey.has(key)) {
|
|
1034
1035
|
// Deep-ish copy so we can mutate dep.viaPaths
|
|
1035
1036
|
byKey.set(key, { ...m, dep: { ...m.dep, viaPaths: m.dep.viaPaths ? [...m.dep.viaPaths] : (m.dep.via ? [m.dep.via] : []) } });
|
|
@@ -1150,7 +1151,7 @@ function renderRetireTable(matches) {
|
|
|
1150
1151
|
const rows = sorted.map(m => `<tr>
|
|
1151
1152
|
<td>${badge((m.cve.severity || "MEDIUM").toUpperCase())}</td>
|
|
1152
1153
|
<td class="dep">${esc(m.dep.artifactId)}<br><span style="color:#6b7280">${esc(m.dep.version || "?")}</span></td>
|
|
1153
|
-
<td class="dep"><code class="path">${esc(m.dep.vendoredFile || "")}</code></td>
|
|
1154
|
+
<td class="dep"><code class="path">${esc(makeRelative(m.dep.vendoredFile || "", RENDER_CTX.srcRoot))}</code></td>
|
|
1154
1155
|
<td class="cve">${m.cve.id?.startsWith("CVE-") ? `<a href="https://nvd.nist.gov/vuln/detail/${esc(m.cve.id)}" target="_blank" rel="noopener">${esc(m.cve.id)}</a>` : esc(m.cve.id)}</td>
|
|
1155
1156
|
<td class="dep">${esc(m.cve.fixVersion || "—")}</td>
|
|
1156
1157
|
<td class="desc">${esc(shortDesc(m.cve.description))}</td>
|
|
@@ -1161,7 +1162,22 @@ function renderRetireTable(matches) {
|
|
|
1161
1162
|
</table>`;
|
|
1162
1163
|
}
|
|
1163
1164
|
|
|
1164
|
-
|
|
1165
|
+
/**
|
|
1166
|
+
* One "vulnerable to" clause for a match, in the format requested for the
|
|
1167
|
+
* Word-pasteable summary: prefer the primary CWE ("CWE-79 : Cross-site
|
|
1168
|
+
* Scripting"), fall back to the CVE id when no weakness is recorded.
|
|
1169
|
+
*/
|
|
1170
|
+
function execWeakness(m) {
|
|
1171
|
+
const cwes = Array.isArray(m.cve.cwes) ? m.cve.cwes : [];
|
|
1172
|
+
const primary = cwes.find(c => cweName(c)) || cwes[0];
|
|
1173
|
+
if (primary) {
|
|
1174
|
+
const label = cweName(primary);
|
|
1175
|
+
return label ? `${primary} : ${label}` : primary;
|
|
1176
|
+
}
|
|
1177
|
+
return m.cve.id || "an unspecified weakness";
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
function renderExecutiveSummary({ projectInfo, prodStats, devStats, retireStats, eolResults, obsoleteResults, outdatedResults, warnings, totalDeps, topCriticalMatches, interactive = false }) {
|
|
1165
1181
|
const eolN = eolResults?.length || 0;
|
|
1166
1182
|
const obsN = obsoleteResults?.length || 0;
|
|
1167
1183
|
const outN = outdatedResults?.length || 0;
|
|
@@ -1204,10 +1220,59 @@ function renderExecutiveSummary({ projectInfo, prodStats, devStats, retireStats,
|
|
|
1204
1220
|
}).join("")}</ul>
|
|
1205
1221
|
</div>` : "";
|
|
1206
1222
|
|
|
1223
|
+
// ---- Word-pasteable copy payload (built only for the interactive HTML) ----
|
|
1224
|
+
let copyUi = "";
|
|
1225
|
+
if (interactive) {
|
|
1226
|
+
const stripTags = s => s.replace(/<[^>]+>/g, "");
|
|
1227
|
+
const plainBullets = bullets.map(stripTags);
|
|
1228
|
+
const top = topCriticalMatches || [];
|
|
1229
|
+
const topSentences = top.map(m => ({
|
|
1230
|
+
lib: depDisplayName(m.dep),
|
|
1231
|
+
ver: m.dep.version || "?",
|
|
1232
|
+
weakness: execWeakness(m),
|
|
1233
|
+
crit: (m.cve.severity || "HIGH").toUpperCase(),
|
|
1234
|
+
}));
|
|
1235
|
+
|
|
1236
|
+
// Plain-text version (text/plain) — exact bullet format requested.
|
|
1237
|
+
const plainLines = [];
|
|
1238
|
+
plainLines.push(`EXECUTIVE SUMMARY — ${level}`);
|
|
1239
|
+
plainLines.push(`${totalDeps} dependencies scanned`);
|
|
1240
|
+
plainLines.push("");
|
|
1241
|
+
if (plainBullets.length) for (const b of plainBullets) plainLines.push(`- ${b}`);
|
|
1242
|
+
else plainLines.push("- No findings detected.");
|
|
1243
|
+
if (topSentences.length) {
|
|
1244
|
+
plainLines.push("");
|
|
1245
|
+
plainLines.push(`Top ${topSentences.length} most critical:`);
|
|
1246
|
+
for (const t of topSentences) {
|
|
1247
|
+
plainLines.push(`- The library "${t.lib}" version "${t.ver}" is vulnerable to "${t.weakness}" ( ${t.crit} )`);
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
1250
|
+
const plainText = plainLines.join("\n");
|
|
1251
|
+
|
|
1252
|
+
// Rich version (text/html) — inline styles so Word keeps the formatting.
|
|
1253
|
+
const richBullets = plainBullets.length
|
|
1254
|
+
? `<ul style="margin:4px 0 0; padding-left:22px;">${plainBullets.map(b => `<li style="margin:2px 0;">${esc(b)}</li>`).join("")}</ul>`
|
|
1255
|
+
: `<p style="margin:4px 0;">No findings detected.</p>`;
|
|
1256
|
+
const richTop = topSentences.length
|
|
1257
|
+
? `<p style="font-weight:bold; margin:12px 0 4px;">Top ${topSentences.length} most critical:</p>
|
|
1258
|
+
<ul style="margin:0; padding-left:22px;">${topSentences.map(t =>
|
|
1259
|
+
`<li style="margin:3px 0;">The library "<b>${esc(t.lib)}</b>" version "<b>${esc(t.ver)}</b>" is vulnerable to "<b>${esc(t.weakness)}</b>" ( ${esc(t.crit)} )</li>`).join("")}</ul>`
|
|
1260
|
+
: "";
|
|
1261
|
+
const richHtml = `<div style="font-family:Calibri,Arial,sans-serif; font-size:11pt; color:#1f2937;">`
|
|
1262
|
+
+ `<p style="font-size:14pt; font-weight:bold; margin:0 0 2px;">Executive Summary — ${esc(level)}</p>`
|
|
1263
|
+
+ `<p style="margin:0 0 8px; color:#4b5563;">${esc(totalDeps)} dependencies scanned</p>`
|
|
1264
|
+
+ richBullets + richTop + `</div>`;
|
|
1265
|
+
|
|
1266
|
+
copyUi = `<button class="btn-copy exec-copy-btn" type="button" title="Copy the executive summary — paste it into Word with formatting preserved">📋 Copy summary</button>`
|
|
1267
|
+
+ `<template class="exec-copy-rich">${richHtml}</template>`
|
|
1268
|
+
+ `<textarea class="exec-copy-plain" aria-hidden="true" tabindex="-1" style="position:absolute;left:-9999px;top:0;width:1px;height:1px;opacity:0;">${esc(plainText)}</textarea>`;
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1207
1271
|
return `<div class="exec-summary exec-${esc(level.toLowerCase())}">
|
|
1208
1272
|
<div class="exec-head">
|
|
1209
1273
|
<span class="exec-level">${esc(level)}</span>
|
|
1210
1274
|
<span class="exec-title">Executive Summary</span>
|
|
1275
|
+
${copyUi}
|
|
1211
1276
|
</div>
|
|
1212
1277
|
<div class="exec-meta">${esc(totalDeps)} dependencies scanned</div>
|
|
1213
1278
|
<ul class="exec-bullets">
|
|
@@ -1248,7 +1313,7 @@ function renderWarningItems(items, itemLimit) {
|
|
|
1248
1313
|
const id = it.id || "";
|
|
1249
1314
|
const paths = (it.manifestPaths || []).slice(0, 6);
|
|
1250
1315
|
const pathsHtml = paths.length
|
|
1251
|
-
? `<div class="warn-defined">defined in: ${paths.map(p => `<code class="path">${esc(p)}</code>`).join(", ")}${it.manifestPaths.length > paths.length ? ` <span class="warn-defined-more">+${it.manifestPaths.length - paths.length} more</span>` : ""}</div>`
|
|
1316
|
+
? `<div class="warn-defined">defined in: ${paths.map(p => `<code class="path">${esc(makeRelative(p, RENDER_CTX.srcRoot))}</code>`).join(", ")}${it.manifestPaths.length > paths.length ? ` <span class="warn-defined-more">+${it.manifestPaths.length - paths.length} more</span>` : ""}</div>`
|
|
1252
1317
|
: "";
|
|
1253
1318
|
return `<li><code>${esc(id)}</code>${pathsHtml}</li>`;
|
|
1254
1319
|
}).join("");
|
|
@@ -1298,6 +1363,7 @@ function buildBody({ cveMatches, devCveMatches, retireMatches, eolResults, obsol
|
|
|
1298
1363
|
eolResults, obsoleteResults, outdatedResults,
|
|
1299
1364
|
warnings, totalDeps: resolvedDeps?.size || 0,
|
|
1300
1365
|
topCriticalMatches: pickTopCriticalMatches(prodMatchesActive, retireMatches, 5),
|
|
1366
|
+
interactive: wrapTables, // copy button + scripts only in the HTML output
|
|
1301
1367
|
});
|
|
1302
1368
|
|
|
1303
1369
|
// (C) Table of contents — rendered as a sticky nav. Only enumerates the
|
|
@@ -1488,31 +1554,32 @@ const COPY_SCRIPT = `
|
|
|
1488
1554
|
btn.classList.remove(cls);
|
|
1489
1555
|
}, 1500);
|
|
1490
1556
|
}
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1557
|
+
// Write rich HTML (+ plain-text fallback) to the clipboard. Shared by the
|
|
1558
|
+
// table buttons and the executive-summary button. richInner is the HTML
|
|
1559
|
+
// fragment; we wrap it with the Office namespaces so Word keeps formatting.
|
|
1560
|
+
async function writeClipboard(richInner, plain, btn){
|
|
1561
|
+
var html = wordWrapper(richInner);
|
|
1495
1562
|
// Modern path: ClipboardItem with text/html + text/plain
|
|
1496
1563
|
try {
|
|
1497
1564
|
if (window.ClipboardItem && navigator.clipboard && navigator.clipboard.write) {
|
|
1498
1565
|
await navigator.clipboard.write([
|
|
1499
1566
|
new ClipboardItem({
|
|
1500
|
-
'text/html': new Blob([html],
|
|
1501
|
-
'text/plain': new Blob([
|
|
1567
|
+
'text/html': new Blob([html], { type: 'text/html' }),
|
|
1568
|
+
'text/plain': new Blob([plain], { type: 'text/plain' })
|
|
1502
1569
|
})
|
|
1503
1570
|
]);
|
|
1504
1571
|
flash(btn, 'flashing-ok', '✓ Copied!');
|
|
1505
1572
|
return;
|
|
1506
1573
|
}
|
|
1507
1574
|
} catch(e) { /* fall through to legacy path */ }
|
|
1508
|
-
// Legacy path: select clone, execCommand('copy')
|
|
1575
|
+
// Legacy path: select a contenteditable clone, execCommand('copy')
|
|
1509
1576
|
try {
|
|
1510
1577
|
var temp = document.createElement('div');
|
|
1511
1578
|
temp.setAttribute('contenteditable', 'true');
|
|
1512
1579
|
temp.style.position = 'fixed';
|
|
1513
1580
|
temp.style.left = '-9999px';
|
|
1514
1581
|
temp.style.top = '0';
|
|
1515
|
-
temp.innerHTML =
|
|
1582
|
+
temp.innerHTML = richInner;
|
|
1516
1583
|
document.body.appendChild(temp);
|
|
1517
1584
|
var range = document.createRange();
|
|
1518
1585
|
range.selectNodeContents(temp);
|
|
@@ -1527,7 +1594,14 @@ const COPY_SCRIPT = `
|
|
|
1527
1594
|
flash(btn, 'flashing-err', '✗ Copy failed');
|
|
1528
1595
|
}
|
|
1529
1596
|
}
|
|
1597
|
+
function copyTable(table, btn){
|
|
1598
|
+
var clone = inlineStylesForWord(table);
|
|
1599
|
+
return writeClipboard(clone.outerHTML, tableToTsv(table), btn);
|
|
1600
|
+
}
|
|
1530
1601
|
document.querySelectorAll('.btn-copy').forEach(function(btn){
|
|
1602
|
+
// The executive-summary button is wired separately (it copies a prose
|
|
1603
|
+
// summary, not a table).
|
|
1604
|
+
if (btn.classList.contains('exec-copy-btn')) return;
|
|
1531
1605
|
btn.addEventListener('click', function(e){
|
|
1532
1606
|
e.stopPropagation();
|
|
1533
1607
|
var wrap = btn.closest('.table-wrap');
|
|
@@ -1535,6 +1609,15 @@ const COPY_SCRIPT = `
|
|
|
1535
1609
|
if (table) copyTable(table, btn);
|
|
1536
1610
|
});
|
|
1537
1611
|
});
|
|
1612
|
+
var execBtn = document.querySelector('.exec-copy-btn');
|
|
1613
|
+
if (execBtn) execBtn.addEventListener('click', function(e){
|
|
1614
|
+
e.stopPropagation();
|
|
1615
|
+
var tpl = document.querySelector('.exec-copy-rich');
|
|
1616
|
+
var ta = document.querySelector('.exec-copy-plain');
|
|
1617
|
+
var rich = tpl ? (tpl.innerHTML || '') : '';
|
|
1618
|
+
var plain = ta ? (ta.value || '') : '';
|
|
1619
|
+
writeClipboard(rich, plain, execBtn);
|
|
1620
|
+
});
|
|
1538
1621
|
})();
|
|
1539
1622
|
</script>`;
|
|
1540
1623
|
|
package/lib/npm/collect.js
CHANGED
|
@@ -199,6 +199,30 @@ function safeParse(fn, file, verbose) {
|
|
|
199
199
|
}
|
|
200
200
|
}
|
|
201
201
|
|
|
202
|
+
/**
|
|
203
|
+
* WebJars are client-side JS libraries shipped as Maven artifacts. Derive the
|
|
204
|
+
* upstream npm coordinate from a WebJar dep so it can flow through the npm
|
|
205
|
+
* pipeline (registry deprecation/outdated + endoflife EOL) instead of being
|
|
206
|
+
* special-cased.
|
|
207
|
+
*
|
|
208
|
+
* org.webjars.npm — deterministic npm mirror. artifactId == npm name;
|
|
209
|
+
* a scoped "@scope/name" is encoded as "scope__name".
|
|
210
|
+
* org.webjars — hand-curated catalogue; artifactId is the JS lib name
|
|
211
|
+
* (mostly aligned with npm, e.g. jquery/bootstrap).
|
|
212
|
+
* org.webjars.bower* — Bower mirrors; best-effort name match.
|
|
213
|
+
*
|
|
214
|
+
* Versions match the upstream package. Returns { name, version } or null for a
|
|
215
|
+
* non-WebJar coordinate.
|
|
216
|
+
*/
|
|
217
|
+
function webjarToNpm(dep) {
|
|
218
|
+
const g = dep.groupId || "";
|
|
219
|
+
if (g !== "org.webjars" && !g.startsWith("org.webjars.")) return null;
|
|
220
|
+
let name = dep.artifactId || "";
|
|
221
|
+
if (!name) return null;
|
|
222
|
+
if (name.includes("__")) name = "@" + name.replace(/__/g, "/");
|
|
223
|
+
return { name, version: dep.version || null };
|
|
224
|
+
}
|
|
225
|
+
|
|
202
226
|
function hasJsManifests(rootDir) {
|
|
203
227
|
try {
|
|
204
228
|
const stack = [rootDir];
|
|
@@ -221,4 +245,4 @@ function hasJsManifests(rootDir) {
|
|
|
221
245
|
return false;
|
|
222
246
|
}
|
|
223
247
|
|
|
224
|
-
module.exports = { collectNpmDeps, hasJsManifests };
|
|
248
|
+
module.exports = { collectNpmDeps, hasJsManifests, semverCompare, webjarToNpm };
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/npm/registry.js — npm registry queries for the npm half of the
|
|
3
|
+
* EOL/obsolete/outdated story.
|
|
4
|
+
*
|
|
5
|
+
* Two authoritative, online signals come from one packument fetch:
|
|
6
|
+
* - deprecated: the maintainer's `deprecated` string on the *resolved*
|
|
7
|
+
* version (the same data behind `npm WARN deprecated …`).
|
|
8
|
+
* - outdated: `dist-tags.latest` vs. the resolved version.
|
|
9
|
+
*
|
|
10
|
+
* The point of querying the registry rather than a curated list is to skip
|
|
11
|
+
* nothing: every npm dep is checked against the source of truth.
|
|
12
|
+
*
|
|
13
|
+
* `packumentToFindings` is the pure extractor (unit-tested without network);
|
|
14
|
+
* `checkNpmRegistryDeps` is the cached, concurrent driver.
|
|
15
|
+
*/
|
|
16
|
+
const fs = require("fs");
|
|
17
|
+
const path = require("path");
|
|
18
|
+
const os = require("os");
|
|
19
|
+
const pLimit = require("p-limit");
|
|
20
|
+
const { semverCompare, webjarToNpm } = require("./collect");
|
|
21
|
+
|
|
22
|
+
const CACHE_DIR = path.join(os.homedir(), ".fad-checker");
|
|
23
|
+
const CACHE_PATH = path.join(CACHE_DIR, "npm-registry-cache.json");
|
|
24
|
+
const CACHE_MAX_AGE_MS = 24 * 3600 * 1000; // 1 day, aligned with Maven Central
|
|
25
|
+
const REGISTRY = "https://registry.npmjs.org";
|
|
26
|
+
|
|
27
|
+
function loadCache() {
|
|
28
|
+
try { return JSON.parse(fs.readFileSync(CACHE_PATH, "utf8")); }
|
|
29
|
+
catch { return { meta: { fetchedAt: 0 }, entries: {} }; }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function saveCache(data) {
|
|
33
|
+
try {
|
|
34
|
+
fs.mkdirSync(CACHE_DIR, { recursive: true });
|
|
35
|
+
fs.writeFileSync(CACHE_PATH, JSON.stringify(data));
|
|
36
|
+
} catch { /* ignore */ }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Pull a recommended replacement out of a free-form deprecation message. */
|
|
40
|
+
function replacementFromMessage(msg) {
|
|
41
|
+
if (!msg) return null;
|
|
42
|
+
const url = msg.match(/https?:\/\/\S+/);
|
|
43
|
+
if (url) return url[0].replace(/[).,]+$/, "");
|
|
44
|
+
return "see deprecation notice";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Extract { deprecated, outdated } findings for one dep from its packument.
|
|
49
|
+
* Pure — no network, no cache. Either field is null when not applicable.
|
|
50
|
+
*/
|
|
51
|
+
function packumentToFindings(packument, dep) {
|
|
52
|
+
const out = { deprecated: null, outdated: null };
|
|
53
|
+
if (!packument || typeof packument !== "object") return out;
|
|
54
|
+
|
|
55
|
+
const versionEntry = packument.versions?.[dep.version];
|
|
56
|
+
const depMsg = versionEntry && typeof versionEntry.deprecated === "string"
|
|
57
|
+
? versionEntry.deprecated.trim()
|
|
58
|
+
: "";
|
|
59
|
+
if (depMsg) {
|
|
60
|
+
out.deprecated = {
|
|
61
|
+
dep,
|
|
62
|
+
severity: "MEDIUM",
|
|
63
|
+
replacement: replacementFromMessage(depMsg),
|
|
64
|
+
reason: depMsg,
|
|
65
|
+
source: "npm",
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const latest = packument["dist-tags"]?.latest;
|
|
70
|
+
if (latest && dep.version) {
|
|
71
|
+
let behind = false;
|
|
72
|
+
try { behind = semverCompare(dep.version, latest) < 0; }
|
|
73
|
+
catch { behind = false; }
|
|
74
|
+
if (behind) {
|
|
75
|
+
const t = packument.time?.[latest];
|
|
76
|
+
out.outdated = {
|
|
77
|
+
dep,
|
|
78
|
+
latest,
|
|
79
|
+
releaseDate: typeof t === "string" ? t.slice(0, 10) : null,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** registry.npmjs.org path-encodes a scoped name's slash. */
|
|
87
|
+
function packumentUrl(name) {
|
|
88
|
+
return name.startsWith("@")
|
|
89
|
+
? `${REGISTRY}/${name.replace("/", "%2F")}`
|
|
90
|
+
: `${REGISTRY}/${encodeURIComponent(name)}`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function fetchPackument(name, opts = {}) {
|
|
94
|
+
if (opts.offline) return null;
|
|
95
|
+
const timeoutMs = opts.timeoutMs || 15000;
|
|
96
|
+
try {
|
|
97
|
+
// Per-request timeout: a single stalled connection must never hang the
|
|
98
|
+
// whole run (one slow package would otherwise occupy a concurrency slot
|
|
99
|
+
// forever and starve the pool).
|
|
100
|
+
const res = await fetch(packumentUrl(name), {
|
|
101
|
+
headers: { "User-Agent": "fad-checker-npm-registry", Accept: "application/json" },
|
|
102
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
103
|
+
});
|
|
104
|
+
if (!res.ok) return { error: `HTTP ${res.status}` };
|
|
105
|
+
return await res.json();
|
|
106
|
+
} catch (err) {
|
|
107
|
+
return { error: err.name === "TimeoutError" ? `timeout after ${timeoutMs}ms` : err.message };
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Check every npm dep against the registry.
|
|
113
|
+
*
|
|
114
|
+
* Returns { deprecated: [obsolete-shaped], outdated: [outdated-shaped] }.
|
|
115
|
+
* Deprecation always runs (online); outdated is only collected when allLibs
|
|
116
|
+
* is set, mirroring the Maven Central outdated gate (--no-all-libs).
|
|
117
|
+
*
|
|
118
|
+
* opts: { verbose, offline, allLibs, concurrency = 8 }
|
|
119
|
+
*/
|
|
120
|
+
async function checkNpmRegistryDeps(resolvedDeps, opts = {}) {
|
|
121
|
+
const { verbose, offline, allLibs = true, concurrency = 8 } = opts;
|
|
122
|
+
// Targets = npm deps (queried by their own name) + WebJars (queried by their
|
|
123
|
+
// derived npm-equivalent name; version matches upstream). The original dep
|
|
124
|
+
// is kept for display/results so the report shows e.g. org.webjars:angularjs.
|
|
125
|
+
const targets = [];
|
|
126
|
+
for (const d of resolvedDeps.values()) {
|
|
127
|
+
if (!d.version) continue;
|
|
128
|
+
if (d.ecosystem === "npm") { targets.push({ dep: d, npmName: d.artifactId, version: d.version }); continue; }
|
|
129
|
+
const wj = webjarToNpm(d);
|
|
130
|
+
if (wj?.name) targets.push({ dep: d, npmName: wj.name, version: d.version });
|
|
131
|
+
}
|
|
132
|
+
const result = { deprecated: [], outdated: [] };
|
|
133
|
+
if (!targets.length) return result;
|
|
134
|
+
|
|
135
|
+
const cache = loadCache();
|
|
136
|
+
const fresh = cache.meta?.fetchedAt && (Date.now() - cache.meta.fetchedAt) < CACHE_MAX_AGE_MS;
|
|
137
|
+
if (!fresh && !offline) cache.entries = {};
|
|
138
|
+
|
|
139
|
+
const cacheKey = t => `${t.npmName}@${t.version}`;
|
|
140
|
+
const liveCount = offline ? 0 : targets.filter(t => !cache.entries[cacheKey(t)]).length;
|
|
141
|
+
if (liveCount && !offline) {
|
|
142
|
+
console.log(`📦 npm registry: checking ${targets.length} packages for deprecation${allLibs ? " + outdated" : ""} (${liveCount} live, ${targets.length - liveCount} cached)…`);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Incremental progress — fetching ~hundreds/thousands of packuments at
|
|
146
|
+
// concurrency N would otherwise be total silence for a minute or more.
|
|
147
|
+
let processed = 0;
|
|
148
|
+
const startedAt = Date.now();
|
|
149
|
+
const printProgress = (final = false) => {
|
|
150
|
+
if (!liveCount) return;
|
|
151
|
+
const elapsed = Math.round((Date.now() - startedAt) / 1000);
|
|
152
|
+
const pct = Math.round((processed / targets.length) * 100);
|
|
153
|
+
const line = ` npm registry: ${processed}/${targets.length} (${pct}%) — ${elapsed}s`;
|
|
154
|
+
if (process.stdout.isTTY) process.stdout.write(`\r${line}${final ? "\n" : " "}`);
|
|
155
|
+
else if (final) console.log(line);
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const limit = pLimit(concurrency);
|
|
159
|
+
await Promise.all(targets.map(t => limit(async () => {
|
|
160
|
+
const { dep, npmName, version } = t;
|
|
161
|
+
const key = cacheKey(t);
|
|
162
|
+
let extracted = cache.entries[key];
|
|
163
|
+
if (!extracted) {
|
|
164
|
+
const packument = await fetchPackument(npmName, { offline });
|
|
165
|
+
if (packument && !packument.error) {
|
|
166
|
+
const f = packumentToFindings(packument, { version });
|
|
167
|
+
extracted = {
|
|
168
|
+
deprecated: f.deprecated ? { reason: f.deprecated.reason, replacement: f.deprecated.replacement } : null,
|
|
169
|
+
latest: f.outdated ? f.outdated.latest : null,
|
|
170
|
+
latestDate: f.outdated ? f.outdated.releaseDate : null,
|
|
171
|
+
};
|
|
172
|
+
cache.entries[key] = extracted;
|
|
173
|
+
} else {
|
|
174
|
+
extracted = { deprecated: null, latest: null, latestDate: null, error: packument?.error || "no data" };
|
|
175
|
+
if (!offline) cache.entries[key] = extracted;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
processed++;
|
|
179
|
+
if (processed % 25 === 0 || processed === targets.length) printProgress();
|
|
180
|
+
if (extracted.deprecated) {
|
|
181
|
+
result.deprecated.push({
|
|
182
|
+
dep,
|
|
183
|
+
severity: "MEDIUM",
|
|
184
|
+
replacement: extracted.deprecated.replacement,
|
|
185
|
+
reason: extracted.deprecated.reason,
|
|
186
|
+
source: "npm",
|
|
187
|
+
});
|
|
188
|
+
if (verbose) process.stdout.write(` deprecated: ${npmName}@${version}\n`);
|
|
189
|
+
}
|
|
190
|
+
if (allLibs && extracted.latest) {
|
|
191
|
+
result.outdated.push({ dep, latest: extracted.latest, releaseDate: extracted.latestDate || null });
|
|
192
|
+
if (verbose) process.stdout.write(` outdated: ${npmName} ${version} → ${extracted.latest}\n`);
|
|
193
|
+
}
|
|
194
|
+
})));
|
|
195
|
+
if (liveCount) printProgress(true);
|
|
196
|
+
|
|
197
|
+
cache.meta = { fetchedAt: Date.now() };
|
|
198
|
+
saveCache(cache);
|
|
199
|
+
|
|
200
|
+
result.deprecated.sort((a, b) => a.dep.artifactId.localeCompare(b.dep.artifactId));
|
|
201
|
+
result.outdated.sort((a, b) => a.dep.artifactId.localeCompare(b.dep.artifactId));
|
|
202
|
+
if (liveCount && !offline) console.log(` npm registry: ${result.deprecated.length} deprecated, ${result.outdated.length} outdated`);
|
|
203
|
+
return result;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
module.exports = { packumentToFindings, checkNpmRegistryDeps, replacementFromMessage };
|
package/lib/osv.js
CHANGED
|
@@ -295,20 +295,26 @@ function runMatches(deps, indexMap, detailById) {
|
|
|
295
295
|
async function queryOsvForDeps(resolvedDeps, opts = {}) {
|
|
296
296
|
const deps = [];
|
|
297
297
|
for (const d of resolvedDeps.values()) {
|
|
298
|
-
if (!d.version || /\$\{|SNAPSHOT/i.test(d.version)) continue;
|
|
299
298
|
if (d.scope === "parent") continue; // parents don't have OSV entries typically
|
|
300
|
-
//
|
|
301
|
-
// a
|
|
302
|
-
//
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
299
|
+
// Query every distinct concrete version (e.g. two profiles pinning the
|
|
300
|
+
// same g:a), not just the representative highest — otherwise a vuln that
|
|
301
|
+
// only affects a lower-versioned variant would be missed.
|
|
302
|
+
const versions = (d.versions && d.versions.length) ? d.versions : [d.version];
|
|
303
|
+
for (const ver of versions) {
|
|
304
|
+
if (!ver || /\$\{|SNAPSHOT/i.test(ver)) continue;
|
|
305
|
+
// npm version specifiers like "^1.0.0" can't be queried against OSV —
|
|
306
|
+
// need a concrete version. Lockfiles always provide one; package.json-
|
|
307
|
+
// only deps are skipped here.
|
|
308
|
+
if (d.ecosystem === "npm" && /^[\^~>=<*]|^(latest|next|workspace:|git\+|file:|link:)/.test(ver)) continue;
|
|
309
|
+
deps.push({
|
|
310
|
+
groupId: d.groupId, artifactId: d.artifactId, version: ver,
|
|
311
|
+
scope: d.scope, via: d.via, depth: d.depth, pomPaths: d.pomPaths,
|
|
312
|
+
manifestPaths: d.manifestPaths,
|
|
313
|
+
ecosystem: d.ecosystem || "maven",
|
|
314
|
+
ecosystemType: d.ecosystemType,
|
|
315
|
+
isDev: !!d.isDev,
|
|
316
|
+
});
|
|
317
|
+
}
|
|
312
318
|
}
|
|
313
319
|
return queryBatch(deps, opts);
|
|
314
320
|
}
|
package/lib/outdated.js
CHANGED
|
@@ -9,6 +9,7 @@ const fs = require("fs");
|
|
|
9
9
|
const path = require("path");
|
|
10
10
|
const os = require("os");
|
|
11
11
|
const { compareMavenVersions } = require("./maven-version");
|
|
12
|
+
const { webjarToNpm } = require("./npm/collect");
|
|
12
13
|
|
|
13
14
|
const KNOWN_OBSOLETE = require("../data/known-obsolete.json");
|
|
14
15
|
const EOL_MAPPING = require("../data/eol-mapping.json");
|
|
@@ -39,6 +40,23 @@ function isEolCacheFresh(maxAge = EOL_CACHE_MAX_AGE_MS) {
|
|
|
39
40
|
// -------- EOL via endoflife.date --------
|
|
40
41
|
|
|
41
42
|
function findEolProduct(dep) {
|
|
43
|
+
// npm packages — and WebJars, which are client-side JS shipped as Maven
|
|
44
|
+
// artifacts — resolve by JS library name, not Maven coordinate. npm deps
|
|
45
|
+
// use their name directly; WebJars are reduced to their npm-equivalent name
|
|
46
|
+
// first (org.webjars:angularjs → "angularjs", org.webjars.npm:angular__core
|
|
47
|
+
// → "@angular/core"). The npm package literally named "angular" is AngularJS
|
|
48
|
+
// 1.x; modern Angular is the @angular/* scope — hence the name vs. scope maps.
|
|
49
|
+
const npmName = dep.ecosystem === "npm" ? (dep.artifactId || "") : webjarToNpm(dep)?.name;
|
|
50
|
+
if (npmName != null) {
|
|
51
|
+
const byName = EOL_MAPPING.by_npm_name?.[npmName];
|
|
52
|
+
if (byName) return byName;
|
|
53
|
+
const scopes = Object.keys(EOL_MAPPING.by_npm_scope || {})
|
|
54
|
+
.sort((a, b) => b.length - a.length);
|
|
55
|
+
for (const s of scopes) {
|
|
56
|
+
if (npmName.startsWith(s)) return EOL_MAPPING.by_npm_scope[s];
|
|
57
|
+
}
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
42
60
|
const key = `${dep.groupId}:${dep.artifactId}`;
|
|
43
61
|
const direct = EOL_MAPPING.by_group_artifact?.[key];
|
|
44
62
|
if (direct) return direct;
|
|
@@ -104,7 +122,6 @@ async function checkEolDeps(resolvedDeps, opts = {}) {
|
|
|
104
122
|
const seen = new Set();
|
|
105
123
|
|
|
106
124
|
for (const dep of resolvedDeps.values()) {
|
|
107
|
-
if (dep.ecosystem === "npm") continue; // EOL mapping is Maven-only for now
|
|
108
125
|
const product = findEolProduct(dep);
|
|
109
126
|
if (!product) continue;
|
|
110
127
|
const dedupeKey = `${dep.groupId}:${dep.artifactId}|${product.product}`;
|
package/package.json
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fad-checker",
|
|
3
|
-
"version": "1.0.
|
|
4
|
-
"description": "Fucking Autonomous Dependency Checker —
|
|
3
|
+
"version": "1.0.6",
|
|
4
|
+
"description": "Fucking Autonomous Dependency Checker — Scans ALL pom/bom, npm/Yarn stuff and vendored JavaScript in a source tree, deal with private deps, multi modules & produces 1 HTML/DOC report with CVE, EOL, obsolete, outdated deps & fix recos",
|
|
5
5
|
"license": "MIT",
|
|
6
|
+
"repository": "https://github.com/n8tz/fad-checker",
|
|
6
7
|
"author": "pp9Ping <pp9Ping@gmail.com>",
|
|
7
8
|
"maintainers": [
|
|
8
9
|
"pp9Ping <pp9Ping@gmail.com>"
|
package/test/cve-match.test.js
CHANGED
|
@@ -133,6 +133,46 @@ test("collectResolvedDeps keeps highest version on conflict", async () => {
|
|
|
133
133
|
if (lang) assert.equal(lang.version, "3.12.0");
|
|
134
134
|
});
|
|
135
135
|
|
|
136
|
+
test("collectResolvedDeps records every distinct version (e.g. two profiles)", () => {
|
|
137
|
+
const depXml = (g, a, v) => ({ groupId: [g], artifactId: [a], version: [v], scope: ["compile"] });
|
|
138
|
+
const store = { byPath: { a: {}, b: {} }, byId: {} };
|
|
139
|
+
// Same g:a pinned to different versions in two modules/profiles.
|
|
140
|
+
const props = {
|
|
141
|
+
a: { properties: {}, dependencies: [depXml("com.x", "y", "1.0.0")], dependencyManagement: [] },
|
|
142
|
+
b: { properties: {}, dependencies: [depXml("com.x", "y", "2.0.0")], dependencyManagement: [] },
|
|
143
|
+
};
|
|
144
|
+
const deps = collectResolvedDeps(store, props, {});
|
|
145
|
+
const y = deps.get("com.x:y");
|
|
146
|
+
assert.ok(y, "the dep is collected once, keyed by g:a");
|
|
147
|
+
assert.equal(y.version, "2.0.0", "representative version is still the highest");
|
|
148
|
+
assert.deepEqual([...y.versions].sort(), ["1.0.0", "2.0.0"], "both distinct versions are tracked");
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("matchDepsAgainstCves scans every distinct version, not just the highest", () => {
|
|
152
|
+
// A CVE that only affects the OLD version. Profiles pin 2.14.0 (vulnerable)
|
|
153
|
+
// and 2.20.0 (patched); the highest is patched, so deduping on max would
|
|
154
|
+
// hide the real exposure. Each distinct version must be scanned.
|
|
155
|
+
const idx = {
|
|
156
|
+
byPackageName: {
|
|
157
|
+
"org.apache.logging.log4j:log4j-core": [
|
|
158
|
+
{ id: "CVE-OLD-0001", severity: "CRITICAL", vendor: "apache", product: "log4j-core",
|
|
159
|
+
ranges: [{ version: "2.0", lessThan: "2.15.0", status: "affected" }] },
|
|
160
|
+
],
|
|
161
|
+
},
|
|
162
|
+
byProduct: {},
|
|
163
|
+
};
|
|
164
|
+
const deps = new Map([
|
|
165
|
+
["org.apache.logging.log4j:log4j-core", {
|
|
166
|
+
groupId: "org.apache.logging.log4j", artifactId: "log4j-core",
|
|
167
|
+
version: "2.20.0", versions: ["2.14.0", "2.20.0"], scope: "compile", pomPaths: [],
|
|
168
|
+
}],
|
|
169
|
+
]);
|
|
170
|
+
const matches = matchDepsAgainstCves(deps, idx);
|
|
171
|
+
assert.equal(matches.length, 1, "vulnerable 2.14.0 must be flagged despite 2.20.0 being highest");
|
|
172
|
+
assert.equal(matches[0].cve.id, "CVE-OLD-0001");
|
|
173
|
+
assert.equal(matches[0].dep.version, "2.14.0", "match carries the actually-vulnerable version");
|
|
174
|
+
});
|
|
175
|
+
|
|
136
176
|
test("collectResolvedDeps --ignore-test drops test-scope deps", async () => {
|
|
137
177
|
const { store, props } = await pipeline(COMPLEX);
|
|
138
178
|
const withTest = collectResolvedDeps(store, props, {});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
const { test } = require("node:test");
|
|
2
|
+
const assert = require("node:assert/strict");
|
|
3
|
+
const { packumentToFindings } = require("../lib/npm/registry");
|
|
4
|
+
|
|
5
|
+
const dep = (name, version) => ({ ecosystem: "npm", groupId: "", artifactId: name, version });
|
|
6
|
+
|
|
7
|
+
test("packumentToFindings flags a deprecated resolved version", () => {
|
|
8
|
+
const packument = {
|
|
9
|
+
name: "request",
|
|
10
|
+
"dist-tags": { latest: "2.88.2" },
|
|
11
|
+
versions: {
|
|
12
|
+
"2.88.2": { deprecated: "request has been deprecated, see https://github.com/request/request/issues/3142" },
|
|
13
|
+
},
|
|
14
|
+
time: { "2.88.2": "2020-02-11T00:00:00.000Z" },
|
|
15
|
+
};
|
|
16
|
+
const { deprecated, outdated } = packumentToFindings(packument, dep("request", "2.88.2"));
|
|
17
|
+
assert.ok(deprecated, "should return a deprecated finding");
|
|
18
|
+
assert.match(deprecated.reason, /has been deprecated/);
|
|
19
|
+
assert.equal(deprecated.source, "npm");
|
|
20
|
+
assert.equal(deprecated.dep.artifactId, "request");
|
|
21
|
+
// Latest === current, so not outdated.
|
|
22
|
+
assert.equal(outdated, null);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("packumentToFindings extracts a replacement URL from the deprecation message", () => {
|
|
26
|
+
const packument = {
|
|
27
|
+
"dist-tags": { latest: "1.0.0" },
|
|
28
|
+
versions: { "1.0.0": { deprecated: "use the foo package instead, see https://example.com/why" } },
|
|
29
|
+
};
|
|
30
|
+
const { deprecated } = packumentToFindings(packument, dep("bar", "1.0.0"));
|
|
31
|
+
assert.equal(deprecated.replacement, "https://example.com/why");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("packumentToFindings reports outdated when latest is newer", () => {
|
|
35
|
+
const packument = {
|
|
36
|
+
"dist-tags": { latest: "3.7.1" },
|
|
37
|
+
versions: { "3.6.0": {} },
|
|
38
|
+
time: { "3.7.1": "2023-08-28T00:00:00.000Z" },
|
|
39
|
+
};
|
|
40
|
+
const { deprecated, outdated } = packumentToFindings(packument, dep("jquery", "3.6.0"));
|
|
41
|
+
assert.equal(deprecated, null, "not deprecated");
|
|
42
|
+
assert.ok(outdated, "should be outdated");
|
|
43
|
+
assert.equal(outdated.latest, "3.7.1");
|
|
44
|
+
assert.equal(outdated.releaseDate, "2023-08-28");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("packumentToFindings returns nothing for an up-to-date, non-deprecated dep", () => {
|
|
48
|
+
const packument = {
|
|
49
|
+
"dist-tags": { latest: "4.18.2" },
|
|
50
|
+
versions: { "4.18.2": {} },
|
|
51
|
+
};
|
|
52
|
+
const { deprecated, outdated } = packumentToFindings(packument, dep("express", "4.18.2"));
|
|
53
|
+
assert.equal(deprecated, null);
|
|
54
|
+
assert.equal(outdated, null);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("packumentToFindings tolerates a missing version entry", () => {
|
|
58
|
+
// Resolved version not present in the registry (e.g. unpublished) — must not throw.
|
|
59
|
+
const packument = { "dist-tags": { latest: "2.0.0" }, versions: { "2.0.0": {} } };
|
|
60
|
+
const { deprecated, outdated } = packumentToFindings(packument, dep("ghost", "1.5.0"));
|
|
61
|
+
assert.equal(deprecated, null);
|
|
62
|
+
assert.ok(outdated, "1.5.0 < 2.0.0 so still outdated");
|
|
63
|
+
assert.equal(outdated.latest, "2.0.0");
|
|
64
|
+
});
|
package/test/outdated.test.js
CHANGED
|
@@ -54,3 +54,48 @@ test("findEolProduct picks longest prefix match", () => {
|
|
|
54
54
|
assert.equal(sec.product, "spring-framework");
|
|
55
55
|
assert.equal(sec.label, "Spring Security");
|
|
56
56
|
});
|
|
57
|
+
|
|
58
|
+
test("findEolProduct maps the npm 'angular' package to AngularJS 1.x", () => {
|
|
59
|
+
// The literal npm package named "angular" IS AngularJS (1.x), EOL since 2022.
|
|
60
|
+
const a = findEolProduct({ ecosystem: "npm", groupId: "", artifactId: "angular" });
|
|
61
|
+
assert.equal(a.product, "angularjs");
|
|
62
|
+
assert.equal(a.label, "AngularJS");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("findEolProduct maps @angular/* scoped packages to modern Angular", () => {
|
|
66
|
+
const core = findEolProduct({ ecosystem: "npm", groupId: "", artifactId: "@angular/core" });
|
|
67
|
+
assert.equal(core.product, "angular");
|
|
68
|
+
const router = findEolProduct({ ecosystem: "npm", groupId: "", artifactId: "@angular/router" });
|
|
69
|
+
assert.equal(router.product, "angular");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("findEolProduct maps react / react-dom / jquery / vue / bootstrap", () => {
|
|
73
|
+
assert.equal(findEolProduct({ ecosystem: "npm", artifactId: "react" }).product, "react");
|
|
74
|
+
assert.equal(findEolProduct({ ecosystem: "npm", artifactId: "react-dom" }).product, "react");
|
|
75
|
+
assert.equal(findEolProduct({ ecosystem: "npm", artifactId: "jquery" }).product, "jquery");
|
|
76
|
+
assert.equal(findEolProduct({ ecosystem: "npm", artifactId: "vue" }).product, "vue");
|
|
77
|
+
assert.equal(findEolProduct({ ecosystem: "npm", artifactId: "bootstrap" }).product, "bootstrap");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("findEolProduct returns null for an unmapped npm package", () => {
|
|
81
|
+
assert.equal(findEolProduct({ ecosystem: "npm", artifactId: "left-pad" }), null);
|
|
82
|
+
// A Maven groupId must never leak into the npm lookup.
|
|
83
|
+
assert.equal(findEolProduct({ ecosystem: "npm", artifactId: "org.springframework" }), null);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("findEolProduct maps WebJars (client-side JS shipped as Maven artifacts)", () => {
|
|
87
|
+
// org.webjars:angularjs:1.8.3 — AngularJS 1.x, EOL since 2021.
|
|
88
|
+
const ajs = findEolProduct({ groupId: "org.webjars", artifactId: "angularjs", version: "1.8.3" });
|
|
89
|
+
assert.ok(ajs, "org.webjars:angularjs must map");
|
|
90
|
+
assert.equal(ajs.product, "angularjs");
|
|
91
|
+
|
|
92
|
+
assert.equal(findEolProduct({ groupId: "org.webjars", artifactId: "jquery" }).product, "jquery");
|
|
93
|
+
assert.equal(findEolProduct({ groupId: "org.webjars", artifactId: "bootstrap" }).product, "bootstrap");
|
|
94
|
+
// org.webjars.npm mirrors npm names; scope slash is encoded as "__".
|
|
95
|
+
assert.equal(findEolProduct({ groupId: "org.webjars.npm", artifactId: "vue" }).product, "vue");
|
|
96
|
+
assert.equal(findEolProduct({ groupId: "org.webjars.npm", artifactId: "angular__core" }).product, "angular");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("findEolProduct returns null for an unmapped WebJar artifact", () => {
|
|
100
|
+
assert.equal(findEolProduct({ groupId: "org.webjars", artifactId: "datatables" }), null);
|
|
101
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
const { test } = require("node:test");
|
|
2
|
+
const assert = require("node:assert/strict");
|
|
3
|
+
const { webjarToNpm } = require("../lib/npm/collect");
|
|
4
|
+
|
|
5
|
+
test("webjarToNpm derives the npm name from org.webjars.npm (deterministic mirror)", () => {
|
|
6
|
+
assert.deepEqual(
|
|
7
|
+
webjarToNpm({ groupId: "org.webjars.npm", artifactId: "react", version: "18.2.0" }),
|
|
8
|
+
{ name: "react", version: "18.2.0" },
|
|
9
|
+
);
|
|
10
|
+
// Scoped packages encode "@scope/name" as "scope__name".
|
|
11
|
+
assert.deepEqual(
|
|
12
|
+
webjarToNpm({ groupId: "org.webjars.npm", artifactId: "angular__core", version: "17.2.3" }),
|
|
13
|
+
{ name: "@angular/core", version: "17.2.3" },
|
|
14
|
+
);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("webjarToNpm passes classic org.webjars artifactIds through as-is", () => {
|
|
18
|
+
// Classic WebJars are hand-curated; the artifactId is the JS lib name.
|
|
19
|
+
assert.deepEqual(
|
|
20
|
+
webjarToNpm({ groupId: "org.webjars", artifactId: "angularjs", version: "1.8.3" }),
|
|
21
|
+
{ name: "angularjs", version: "1.8.3" },
|
|
22
|
+
);
|
|
23
|
+
assert.equal(webjarToNpm({ groupId: "org.webjars", artifactId: "jquery", version: "3.7.1" }).name, "jquery");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("webjarToNpm handles bower webjars too", () => {
|
|
27
|
+
assert.equal(webjarToNpm({ groupId: "org.webjars.bowergithub.foo", artifactId: "bar", version: "1.0.0" }).name, "bar");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("webjarToNpm returns null for non-webjar coordinates", () => {
|
|
31
|
+
assert.equal(webjarToNpm({ groupId: "org.springframework", artifactId: "spring-core", version: "6.0.0" }), null);
|
|
32
|
+
assert.equal(webjarToNpm({ ecosystem: "npm", groupId: "", artifactId: "react", version: "18.0.0" }), null);
|
|
33
|
+
});
|