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 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 |
@@ -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
  }
@@ -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
- - **EOL**: matches dep coord against `data/eol-mapping.json`, fetches the cycle list from endoflife.date (cached 7d), flags cycles past their EOL date.
82
- - **Obsolete**: lookup in `data/known-obsolete.json` (curated: log4j 1.x, jackson-mapper-asl, joda-time, commons-httpclient 3.x, ).
83
- - **Outdated**: Maven Central Solr query for the latest version. Cache 24h. Concurrency 8.
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.groupId}:${e.dep.artifactId}:${e.dep.version} ${e.eol}`);
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.groupId}:${o.dep.artifactId}:${o.dep.version} → ${o.replacement || "n/a"}`);
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.groupId}:${o.dep.artifactId} ${o.dep.version} → ${o.latest}`);
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 keeping the highest version seen,
24
- * also include external parent POMs as scope='parent'.
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 affected = (e.ranges || []).some(r => {
156
- if (!dep.version) return r.status === "affected"; // unknown version → assume affected
157
- return isVersionAffected(dep.version, r);
158
- });
159
- if (affected) matches.push({ dep, cve: { ...e }, confidence });
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
- function renderExecutiveSummary({ projectInfo, prodStats, devStats, retireStats, eolResults, obsoleteResults, outdatedResults, warnings, totalDeps, topCriticalMatches }) {
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
- async function copyTable(table, btn){
1492
- var clone = inlineStylesForWord(table);
1493
- var html = wordWrapper(clone.outerHTML);
1494
- var tsv = tableToTsv(table);
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], { type: 'text/html' }),
1501
- 'text/plain': new Blob([tsv], { type: 'text/plain' })
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') — gets the HTML
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 = clone.outerHTML;
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
 
@@ -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
- // npm version specifiers like "^1.0.0" can't be queried against OSV — need
301
- // a concrete version. Lockfiles always provide one; package.json-only deps
302
- // are skipped here.
303
- if (d.ecosystem === "npm" && /^[\^~>=<*]|^(latest|next|workspace:|git\+|file:|link:)/.test(d.version)) continue;
304
- deps.push({
305
- groupId: d.groupId, artifactId: d.artifactId, version: d.version,
306
- scope: d.scope, via: d.via, depth: d.depth, pomPaths: d.pomPaths,
307
- manifestPaths: d.manifestPaths,
308
- ecosystem: d.ecosystem || "maven",
309
- ecosystemType: d.ecosystemType,
310
- isDev: !!d.isDev,
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",
4
- "description": "Fucking Autonomous Dependency Checker — multi-ecosystem CVE / EOL / outdated / vendored-JS scanner for Maven, npm and Yarn monorepos. Self-contained HTML + Word report.",
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>"
@@ -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
+ });
@@ -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
+ });