dependency-radar 0.8.1 → 0.9.0
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/README.md +176 -15
- package/dist/aggregator.js +458 -15
- package/dist/cli.js +60 -6
- package/dist/explain.js +83 -1
- package/dist/failOn.js +370 -1
- package/dist/findings.js +81 -3
- package/dist/report-assets.js +3 -4
- package/dist/reportDetailRules.js +162 -0
- package/dist/runners/npmRegistryMetadata.js +390 -0
- package/package.json +4 -4
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.reportVulnerabilityTotal = reportVulnerabilityTotal;
|
|
4
|
+
exports.reportAllExecutionSignals = reportAllExecutionSignals;
|
|
5
|
+
exports.buildReportOverallRisk = buildReportOverallRisk;
|
|
6
|
+
exports.buildReportKeyPoints = buildReportKeyPoints;
|
|
7
|
+
const EXECUTION_SIGNAL_LABELS = {
|
|
8
|
+
'network-access': 'Accesses network during install',
|
|
9
|
+
'dynamic-exec': 'Uses dynamic execution',
|
|
10
|
+
'child-process': 'Spawns child processes',
|
|
11
|
+
encoding: 'Uses encoding/decoding logic',
|
|
12
|
+
obfuscated: 'Contains obfuscated/minified install logic',
|
|
13
|
+
'reads-env': 'Reads environment variables',
|
|
14
|
+
'reads-home': 'Reads user home directory',
|
|
15
|
+
'uses-ssh': 'Uses SSH configuration/keys'
|
|
16
|
+
};
|
|
17
|
+
function reportVulnerabilityTotal(summary) {
|
|
18
|
+
return summary.critical + summary.high + summary.moderate + summary.low;
|
|
19
|
+
}
|
|
20
|
+
function reportAllExecutionSignals(dep) {
|
|
21
|
+
var _a, _b, _c;
|
|
22
|
+
return Array.from(new Set([
|
|
23
|
+
...(((_b = (_a = dep.execution) === null || _a === void 0 ? void 0 : _a.scripts) === null || _b === void 0 ? void 0 : _b.signals) || []),
|
|
24
|
+
...(((_c = dep.execution) === null || _c === void 0 ? void 0 : _c.signals) || [])
|
|
25
|
+
]));
|
|
26
|
+
}
|
|
27
|
+
function maxRisk(risks) {
|
|
28
|
+
if (risks.includes('red'))
|
|
29
|
+
return 'red';
|
|
30
|
+
if (risks.includes('amber'))
|
|
31
|
+
return 'amber';
|
|
32
|
+
return 'green';
|
|
33
|
+
}
|
|
34
|
+
function buildReportOverallRisk(dep, summary, supplyChainSignalCount = 0) {
|
|
35
|
+
var _a, _b, _c, _d, _e, _f;
|
|
36
|
+
const installRisk = ((_a = dep.execution) === null || _a === void 0 ? void 0 : _a.risk) || 'green';
|
|
37
|
+
const supplyChainRisk = supplyChainSignalCount > 0 || (((_c = (_b = dep.packaging) === null || _b === void 0 ? void 0 : _b.signals) === null || _c === void 0 ? void 0 : _c.length) || 0) > 0
|
|
38
|
+
? 'amber'
|
|
39
|
+
: 'green';
|
|
40
|
+
const maintenanceRisk = (((_f = (_e = (_d = dep.supplyChain) === null || _d === void 0 ? void 0 : _d.registry) === null || _e === void 0 ? void 0 : _e.signals) === null || _f === void 0 ? void 0 : _f.length) || 0) > 0 ? 'amber' : 'green';
|
|
41
|
+
return maxRisk([
|
|
42
|
+
summary.risk,
|
|
43
|
+
dep.compliance.licenseRisk,
|
|
44
|
+
installRisk,
|
|
45
|
+
supplyChainRisk,
|
|
46
|
+
maintenanceRisk
|
|
47
|
+
]);
|
|
48
|
+
}
|
|
49
|
+
function titleCaseValue(value) {
|
|
50
|
+
return value
|
|
51
|
+
.split(/[-_\s]+/)
|
|
52
|
+
.filter(Boolean)
|
|
53
|
+
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
54
|
+
.join(' ');
|
|
55
|
+
}
|
|
56
|
+
function scopeLabel(scope) {
|
|
57
|
+
if (scope === 'runtime')
|
|
58
|
+
return 'Runtime';
|
|
59
|
+
if (scope === 'dev')
|
|
60
|
+
return 'Dev';
|
|
61
|
+
if (scope === 'optional')
|
|
62
|
+
return 'Optional';
|
|
63
|
+
if (scope === 'peer')
|
|
64
|
+
return 'Peer';
|
|
65
|
+
return titleCaseValue(scope);
|
|
66
|
+
}
|
|
67
|
+
function toneLabel(tone) {
|
|
68
|
+
if (tone === 'red')
|
|
69
|
+
return 'High';
|
|
70
|
+
if (tone === 'amber')
|
|
71
|
+
return 'Medium';
|
|
72
|
+
return 'Low';
|
|
73
|
+
}
|
|
74
|
+
function formatLicenseStatus(status) {
|
|
75
|
+
switch (status) {
|
|
76
|
+
case 'declared-only':
|
|
77
|
+
return 'Declared';
|
|
78
|
+
case 'inferred-only':
|
|
79
|
+
return 'Inferred';
|
|
80
|
+
case 'match':
|
|
81
|
+
return 'Declared + Inferred (match)';
|
|
82
|
+
case 'mismatch':
|
|
83
|
+
return 'Declared + Inferred (mismatch)';
|
|
84
|
+
case 'invalid-spdx':
|
|
85
|
+
return 'Invalid SPDX';
|
|
86
|
+
default:
|
|
87
|
+
return 'Unknown';
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
function formatModerateLow(summary) {
|
|
91
|
+
const parts = [];
|
|
92
|
+
if (summary.moderate)
|
|
93
|
+
parts.push(`${summary.moderate} moderate`);
|
|
94
|
+
if (summary.low)
|
|
95
|
+
parts.push(`${summary.low} low`);
|
|
96
|
+
return parts.join(', ');
|
|
97
|
+
}
|
|
98
|
+
function buildReportKeyPoints(dep, summary) {
|
|
99
|
+
var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m;
|
|
100
|
+
const points = [];
|
|
101
|
+
const vulnTotal = reportVulnerabilityTotal(summary);
|
|
102
|
+
const hasFix = (_a = dep.security.advisories) === null || _a === void 0 ? void 0 : _a.some((adv) => adv.fixAvailable);
|
|
103
|
+
if (summary.critical || summary.high) {
|
|
104
|
+
const highTotal = summary.critical + summary.high;
|
|
105
|
+
points.push(`${highTotal} critical/high ${highTotal === 1 ? 'vulnerability' : 'vulnerabilities'}${hasFix ? ', fix available' : ''}`);
|
|
106
|
+
}
|
|
107
|
+
if (summary.moderate || summary.low) {
|
|
108
|
+
const lowerTotal = summary.moderate + summary.low;
|
|
109
|
+
points.push(`${formatModerateLow(summary)} ${lowerTotal === 1 ? 'vulnerability' : 'vulnerabilities'}${hasFix ? ', fix available' : ''}`);
|
|
110
|
+
}
|
|
111
|
+
if (dep.compliance.licenseRisk !== 'green') {
|
|
112
|
+
points.push('Licence status: ' + formatLicenseStatus(dep.compliance.license.status));
|
|
113
|
+
}
|
|
114
|
+
if (dep.upgrade.blocksNodeMajor)
|
|
115
|
+
points.push('Blocks Node major upgrade');
|
|
116
|
+
if ((_b = dep.upgrade.blockers) === null || _b === void 0 ? void 0 : _b.length) {
|
|
117
|
+
points.push(`${dep.upgrade.blockers.length} upgrade ${dep.upgrade.blockers.length === 1 ? 'blocker' : 'blockers'} detected`);
|
|
118
|
+
}
|
|
119
|
+
const executionRisk = ((_c = dep.execution) === null || _c === void 0 ? void 0 : _c.risk) || 'green';
|
|
120
|
+
if (executionRisk !== 'green')
|
|
121
|
+
points.push(`${toneLabel(executionRisk)} install-time execution risk`);
|
|
122
|
+
if ((_f = (_e = (_d = dep.execution) === null || _d === void 0 ? void 0 : _d.scripts) === null || _e === void 0 ? void 0 : _e.hooks) === null || _f === void 0 ? void 0 : _f.length) {
|
|
123
|
+
points.push('Runs ' +
|
|
124
|
+
dep.execution.scripts.hooks.slice(0, 2).join(', ') +
|
|
125
|
+
' lifecycle script' +
|
|
126
|
+
(dep.execution.scripts.hooks.length === 1 ? '' : 's'));
|
|
127
|
+
}
|
|
128
|
+
reportAllExecutionSignals(dep)
|
|
129
|
+
.slice(0, 3)
|
|
130
|
+
.forEach((signal) => points.push(EXECUTION_SIGNAL_LABELS[signal]));
|
|
131
|
+
if ((_h = (_g = dep.packaging) === null || _g === void 0 ? void 0 : _g.signals) === null || _h === void 0 ? void 0 : _h.length) {
|
|
132
|
+
points.push(`${dep.packaging.signals.length} package content ${dep.packaging.signals.length === 1 ? 'signal' : 'signals'}`);
|
|
133
|
+
}
|
|
134
|
+
if ((_l = (_k = (_j = dep.supplyChain) === null || _j === void 0 ? void 0 : _j.registry) === null || _k === void 0 ? void 0 : _k.signals) === null || _l === void 0 ? void 0 : _l.length) {
|
|
135
|
+
points.push(`${dep.supplyChain.registry.signals.length} registry metadata ${dep.supplyChain.registry.signals.length === 1 ? 'signal' : 'signals'}`);
|
|
136
|
+
}
|
|
137
|
+
if (dep.usage.direct) {
|
|
138
|
+
points.push(`Direct ${scopeLabel(dep.usage.scope).toLowerCase()} dependency`);
|
|
139
|
+
}
|
|
140
|
+
else {
|
|
141
|
+
const intro = ((_m = dep.usage.origins.topParentPackages) === null || _m === void 0 ? void 0 : _m[0])
|
|
142
|
+
? ` introduced by ${dep.usage.origins.topParentPackages[0]}`
|
|
143
|
+
: '';
|
|
144
|
+
points.push(`Transitive ${scopeLabel(dep.usage.scope).toLowerCase()} dependency${intro}`);
|
|
145
|
+
}
|
|
146
|
+
if (dep.usage.depth > 1)
|
|
147
|
+
points.push(`Dependency depth ${dep.usage.depth}`);
|
|
148
|
+
if (points.length === 0 || (vulnTotal === 0 && executionRisk === 'green' && dep.compliance.licenseRisk === 'green' && points.length < 3)) {
|
|
149
|
+
[
|
|
150
|
+
'No known vulnerabilities',
|
|
151
|
+
'No install-time execution signals detected',
|
|
152
|
+
'Licence status appears consistent',
|
|
153
|
+
dep.usage.direct
|
|
154
|
+
? `Direct ${scopeLabel(dep.usage.scope).toLowerCase()} dependency`
|
|
155
|
+
: `Transitive ${scopeLabel(dep.usage.scope).toLowerCase()} dependency`
|
|
156
|
+
].forEach((point) => {
|
|
157
|
+
if (!points.includes(point))
|
|
158
|
+
points.push(point);
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
return Array.from(new Set(points)).slice(0, 8);
|
|
162
|
+
}
|
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.REGISTRY_ENRICHMENT_DEFAULT_LIMIT = void 0;
|
|
4
|
+
exports.parseNpmRegistryMetadata = parseNpmRegistryMetadata;
|
|
5
|
+
exports.deriveRegistryRiskSignals = deriveRegistryRiskSignals;
|
|
6
|
+
exports.selectRegistryEnrichmentCandidates = selectRegistryEnrichmentCandidates;
|
|
7
|
+
exports.fetchNpmRegistryMetadata = fetchNpmRegistryMetadata;
|
|
8
|
+
exports.enrichAggregatedWithRegistryMetadata = enrichAggregatedWithRegistryMetadata;
|
|
9
|
+
const utils_1 = require("../utils");
|
|
10
|
+
exports.REGISTRY_ENRICHMENT_DEFAULT_LIMIT = 10;
|
|
11
|
+
// Thresholds are intentionally conservative and easy to explain in README/report output.
|
|
12
|
+
const RECENT_PACKAGE_DAYS = 14;
|
|
13
|
+
const RECENT_VERSION_DAYS = 14;
|
|
14
|
+
const LOW_RELEASE_HISTORY_VERSION_COUNT = 3;
|
|
15
|
+
const REACTIVATED_RECENT_DAYS = 30;
|
|
16
|
+
const REACTIVATED_DORMANT_DAYS = 365;
|
|
17
|
+
const OLD_MAJOR_PATCH_RECENT_DAYS = 30;
|
|
18
|
+
const REGISTRY_FETCH_CONCURRENCY = 3;
|
|
19
|
+
const CANDIDATE_REASON_WEIGHTS = {
|
|
20
|
+
'supply-chain-source': 5,
|
|
21
|
+
'execution-signals': 5,
|
|
22
|
+
'install-hooks': 4,
|
|
23
|
+
'native-binding': 4,
|
|
24
|
+
'packaging-signals': 3,
|
|
25
|
+
bin: 2
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* Compute the difference in days between two Date objects.
|
|
29
|
+
*
|
|
30
|
+
* @param later - The later date to compare
|
|
31
|
+
* @param earlier - The earlier date to compare
|
|
32
|
+
* @returns The difference in days as a number (may be fractional); positive when `later` is after `earlier`, negative when `later` is before `earlier`, and zero when equal
|
|
33
|
+
*/
|
|
34
|
+
function daysBetween(later, earlier) {
|
|
35
|
+
return (later.getTime() - earlier.getTime()) / (24 * 60 * 60 * 1000);
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Parse a date string into a JavaScript Date, returning undefined for missing or invalid input.
|
|
39
|
+
*
|
|
40
|
+
* @param value - The date string to parse; may be undefined.
|
|
41
|
+
* @returns A `Date` representing `value` when valid, or `undefined` if `value` is missing or not a valid date.
|
|
42
|
+
*/
|
|
43
|
+
function parseDate(value) {
|
|
44
|
+
if (!value)
|
|
45
|
+
return undefined;
|
|
46
|
+
const date = new Date(value);
|
|
47
|
+
return Number.isNaN(date.getTime()) ? undefined : date;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Determines whether a date string represents a time within the past given number of days relative to `now`.
|
|
51
|
+
*
|
|
52
|
+
* @param value - The date string to evaluate; if `undefined` or unparsable it is treated as not recent.
|
|
53
|
+
* @param now - Reference date to compare against.
|
|
54
|
+
* @param days - Maximum allowed age in days (inclusive).
|
|
55
|
+
* @returns `true` if `value` parses to a date whose age is between 0 and `days` inclusive, `false` otherwise.
|
|
56
|
+
*/
|
|
57
|
+
function isRecent(value, now, days) {
|
|
58
|
+
const date = parseDate(value);
|
|
59
|
+
if (!date)
|
|
60
|
+
return false;
|
|
61
|
+
const age = daysBetween(now, date);
|
|
62
|
+
return age >= 0 && age <= days;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Extracts the leading major version number from a semver-like version string.
|
|
66
|
+
*
|
|
67
|
+
* @param version - The version string to parse (e.g., "1.2.3"); may be `undefined`.
|
|
68
|
+
* @returns The major version as an integer if present and numeric, `undefined` otherwise.
|
|
69
|
+
*/
|
|
70
|
+
function parseMajor(version) {
|
|
71
|
+
if (!version)
|
|
72
|
+
return undefined;
|
|
73
|
+
const match = version.match(/^(\d+)\./);
|
|
74
|
+
if (!match)
|
|
75
|
+
return undefined;
|
|
76
|
+
const major = Number.parseInt(match[1], 10);
|
|
77
|
+
return Number.isFinite(major) ? major : undefined;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Produce chronological version entries with parsed publication dates.
|
|
81
|
+
*
|
|
82
|
+
* @param metadata - Parsed npm registry metadata to extract version timestamps from
|
|
83
|
+
* @returns An array of objects for each version that has a parsable timestamp, sorted by ascending publication date. Each object contains `version` (the version string), `date` (the parsed `Date`), and `raw` (the original timestamp string)
|
|
84
|
+
*/
|
|
85
|
+
function versionTimeEntries(metadata) {
|
|
86
|
+
return metadata.versions
|
|
87
|
+
.map((version) => {
|
|
88
|
+
const raw = metadata.time[version];
|
|
89
|
+
const date = parseDate(raw);
|
|
90
|
+
return date ? { version, date, raw } : undefined;
|
|
91
|
+
})
|
|
92
|
+
.filter((entry) => Boolean(entry))
|
|
93
|
+
.sort((a, b) => a.date.getTime() - b.date.getTime());
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Parse the JSON-like output from `npm view <name> --json` into a normalized registry metadata object.
|
|
97
|
+
*
|
|
98
|
+
* Accepts a raw parsed JSON value and extracts string-valued `time` entries, string-valued `dist-tags`,
|
|
99
|
+
* and a deduplicated, sorted list of `versions`. Returns `undefined` when `raw` is not an object or when
|
|
100
|
+
* none of `time`, `dist-tags`, or `versions` contain usable data.
|
|
101
|
+
*
|
|
102
|
+
* @param name - Package name to set on the returned metadata
|
|
103
|
+
* @param raw - Raw parsed JSON output from `npm view ... --json` (any)
|
|
104
|
+
* @returns A `ParsedNpmMetadata` object containing `name`, `time`, `distTags`, and a deduplicated sorted `versions` array, or `undefined` if parsing yields no usable fields
|
|
105
|
+
*/
|
|
106
|
+
function parseNpmRegistryMetadata(name, raw) {
|
|
107
|
+
if (!raw || typeof raw !== 'object')
|
|
108
|
+
return undefined;
|
|
109
|
+
const data = raw;
|
|
110
|
+
const time = data.time && typeof data.time === 'object' && !Array.isArray(data.time)
|
|
111
|
+
? Object.fromEntries(Object.entries(data.time)
|
|
112
|
+
.filter((entry) => typeof entry[1] === 'string'))
|
|
113
|
+
: {};
|
|
114
|
+
const distTags = data['dist-tags'] && typeof data['dist-tags'] === 'object' && !Array.isArray(data['dist-tags'])
|
|
115
|
+
? Object.fromEntries(Object.entries(data['dist-tags'])
|
|
116
|
+
.filter((entry) => typeof entry[1] === 'string'))
|
|
117
|
+
: {};
|
|
118
|
+
const versions = Array.isArray(data.versions)
|
|
119
|
+
? data.versions.filter((version) => typeof version === 'string' && version.trim().length > 0)
|
|
120
|
+
: Object.keys(data.versions || {}).filter(Boolean);
|
|
121
|
+
if (Object.keys(time).length === 0 && versions.length === 0 && Object.keys(distTags).length === 0) {
|
|
122
|
+
return undefined;
|
|
123
|
+
}
|
|
124
|
+
return {
|
|
125
|
+
name,
|
|
126
|
+
time,
|
|
127
|
+
distTags,
|
|
128
|
+
versions: Array.from(new Set(versions)).sort()
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Derives conservative registry-based risk signals for a dependency using its installed version and npm registry metadata.
|
|
133
|
+
*
|
|
134
|
+
* @param installedVersion - The package version currently installed to evaluate against registry history
|
|
135
|
+
* @param metadata - Parsed npm registry metadata for the package
|
|
136
|
+
* @param now - Reference time used to evaluate recency thresholds; defaults to the current time
|
|
137
|
+
* @returns An array of detected registry risk signal names in priority order. Possible values:
|
|
138
|
+
* `recent-package`, `recent-version`, `low-release-history`, `reactivated-package`, `old-major-new-patch`
|
|
139
|
+
*/
|
|
140
|
+
function deriveRegistryRiskSignals(installedVersion, metadata, now = new Date()) {
|
|
141
|
+
const signals = new Set();
|
|
142
|
+
const installedPublishedAt = metadata.time[installedVersion];
|
|
143
|
+
if (isRecent(metadata.time.created, now, RECENT_PACKAGE_DAYS)) {
|
|
144
|
+
signals.add('recent-package');
|
|
145
|
+
}
|
|
146
|
+
if (isRecent(installedPublishedAt, now, RECENT_VERSION_DAYS)) {
|
|
147
|
+
signals.add('recent-version');
|
|
148
|
+
}
|
|
149
|
+
if (metadata.versions.length > 0 && metadata.versions.length <= LOW_RELEASE_HISTORY_VERSION_COUNT) {
|
|
150
|
+
signals.add('low-release-history');
|
|
151
|
+
}
|
|
152
|
+
const entries = versionTimeEntries(metadata);
|
|
153
|
+
const latest = entries[entries.length - 1];
|
|
154
|
+
const previous = entries[entries.length - 2];
|
|
155
|
+
if (latest &&
|
|
156
|
+
previous &&
|
|
157
|
+
isRecent(latest.raw, now, REACTIVATED_RECENT_DAYS) &&
|
|
158
|
+
daysBetween(latest.date, previous.date) >= REACTIVATED_DORMANT_DAYS) {
|
|
159
|
+
signals.add('reactivated-package');
|
|
160
|
+
}
|
|
161
|
+
const installedMajor = parseMajor(installedVersion);
|
|
162
|
+
const latestVersion = metadata.distTags.latest;
|
|
163
|
+
const latestMajor = parseMajor(latestVersion);
|
|
164
|
+
if (installedMajor !== undefined &&
|
|
165
|
+
latestMajor !== undefined &&
|
|
166
|
+
installedMajor < latestMajor &&
|
|
167
|
+
isRecent(installedPublishedAt, now, OLD_MAJOR_PATCH_RECENT_DAYS)) {
|
|
168
|
+
signals.add('old-major-new-patch');
|
|
169
|
+
}
|
|
170
|
+
return [
|
|
171
|
+
'recent-package',
|
|
172
|
+
'recent-version',
|
|
173
|
+
'low-release-history',
|
|
174
|
+
'reactivated-package',
|
|
175
|
+
'old-major-new-patch'
|
|
176
|
+
].filter((signal) => signals.has(signal));
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Extracts package names referenced by supply-chain signals in the aggregated data.
|
|
180
|
+
*
|
|
181
|
+
* @param aggregated - Aggregated data containing `supplyChain.signals`.
|
|
182
|
+
* @returns A set of unique package names. For each signal, `signal.packageName` is used when present; otherwise the name is derived from `signal.packageId` by removing a trailing `@version` segment when present.
|
|
183
|
+
*/
|
|
184
|
+
function supplyChainSignalPackageNames(aggregated) {
|
|
185
|
+
var _a;
|
|
186
|
+
const names = new Set();
|
|
187
|
+
for (const signal of ((_a = aggregated.supplyChain) === null || _a === void 0 ? void 0 : _a.signals) || []) {
|
|
188
|
+
if (signal.packageName) {
|
|
189
|
+
names.add(signal.packageName);
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
if (signal.packageId) {
|
|
193
|
+
const at = signal.packageId.lastIndexOf('@');
|
|
194
|
+
if (at > 0)
|
|
195
|
+
names.add(signal.packageId.slice(0, at));
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return names;
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Produce a sorted array of unique suspicious reason identifiers for a dependency.
|
|
202
|
+
*
|
|
203
|
+
* @param dep - Dependency record to evaluate for suspicious attributes
|
|
204
|
+
* @param sourceSignalNames - Set of package names considered supply-chain signal sources
|
|
205
|
+
* @returns An array of unique reason identifiers sorted in ascending order. Possible values include `supply-chain-source`, `install-hooks`, `native-binding`, `bin`, `execution-signals`, and `packaging-signals`
|
|
206
|
+
*/
|
|
207
|
+
function suspiciousReasons(dep, sourceSignalNames) {
|
|
208
|
+
var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l;
|
|
209
|
+
const reasons = [];
|
|
210
|
+
if (sourceSignalNames.has(dep.package.name))
|
|
211
|
+
reasons.push('supply-chain-source');
|
|
212
|
+
if ((_c = (_b = (_a = dep.execution) === null || _a === void 0 ? void 0 : _a.scripts) === null || _b === void 0 ? void 0 : _b.hooks) === null || _c === void 0 ? void 0 : _c.length)
|
|
213
|
+
reasons.push('install-hooks');
|
|
214
|
+
if ((_d = dep.execution) === null || _d === void 0 ? void 0 : _d.native)
|
|
215
|
+
reasons.push('native-binding');
|
|
216
|
+
if (dep.package.hasBin)
|
|
217
|
+
reasons.push('bin');
|
|
218
|
+
if (((_f = (_e = dep.execution) === null || _e === void 0 ? void 0 : _e.signals) === null || _f === void 0 ? void 0 : _f.length) || ((_j = (_h = (_g = dep.execution) === null || _g === void 0 ? void 0 : _g.scripts) === null || _h === void 0 ? void 0 : _h.signals) === null || _j === void 0 ? void 0 : _j.length))
|
|
219
|
+
reasons.push('execution-signals');
|
|
220
|
+
if ((_l = (_k = dep.packaging) === null || _k === void 0 ? void 0 : _k.signals) === null || _l === void 0 ? void 0 : _l.length)
|
|
221
|
+
reasons.push('packaging-signals');
|
|
222
|
+
return Array.from(new Set(reasons)).sort();
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Selects and ranks package names from an aggregated dependency graph as candidates for npm registry enrichment.
|
|
226
|
+
*
|
|
227
|
+
* The function collects per-dependency suspicious reasons, aggregates them by package name, and returns up to `limit`
|
|
228
|
+
* candidates ordered by total weighted reason score (descending), number of reasons (descending), then package name.
|
|
229
|
+
*
|
|
230
|
+
* @param aggregated - Aggregated dependency graph and supply-chain signals used to derive candidate reasons
|
|
231
|
+
* @param limit - Maximum number of candidates to return; if `limit <= 0` an empty array is returned
|
|
232
|
+
* @returns An array of `RegistryEnrichmentCandidate` objects (each `{ name, reasons }`) where `reasons` is a sorted list of unique reason strings
|
|
233
|
+
*/
|
|
234
|
+
function selectRegistryEnrichmentCandidates(aggregated, limit = exports.REGISTRY_ENRICHMENT_DEFAULT_LIMIT) {
|
|
235
|
+
if (limit <= 0)
|
|
236
|
+
return [];
|
|
237
|
+
const sourceSignalNames = supplyChainSignalPackageNames(aggregated);
|
|
238
|
+
const byName = new Map();
|
|
239
|
+
for (const dep of Object.values(aggregated.dependencies || {})) {
|
|
240
|
+
const reasons = suspiciousReasons(dep, sourceSignalNames);
|
|
241
|
+
if (reasons.length === 0)
|
|
242
|
+
continue;
|
|
243
|
+
const existing = byName.get(dep.package.name) || new Set();
|
|
244
|
+
reasons.forEach((reason) => existing.add(reason));
|
|
245
|
+
byName.set(dep.package.name, existing);
|
|
246
|
+
}
|
|
247
|
+
return Array.from(byName.entries())
|
|
248
|
+
.sort((a, b) => {
|
|
249
|
+
const score = (reasons) => Array.from(reasons)
|
|
250
|
+
.reduce((total, reason) => total + (CANDIDATE_REASON_WEIGHTS[reason] || 1), 0);
|
|
251
|
+
const scoreDiff = score(b[1]) - score(a[1]);
|
|
252
|
+
if (scoreDiff !== 0)
|
|
253
|
+
return scoreDiff;
|
|
254
|
+
const reasonCountDiff = b[1].size - a[1].size;
|
|
255
|
+
if (reasonCountDiff !== 0)
|
|
256
|
+
return reasonCountDiff;
|
|
257
|
+
return a[0].localeCompare(b[0]);
|
|
258
|
+
})
|
|
259
|
+
.slice(0, limit)
|
|
260
|
+
.map(([name, reasons]) => ({ name, reasons: Array.from(reasons).sort() }));
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* Fetches and parses npm registry metadata for the given package name using `npm view`.
|
|
264
|
+
*
|
|
265
|
+
* @param name - The package name to query (as passed to `npm view`)
|
|
266
|
+
* @returns A `ToolResult` that is `ok: true` with `data` containing parsed registry metadata on success, or `ok: false` with an `error` message on failure
|
|
267
|
+
*/
|
|
268
|
+
async function fetchNpmRegistryMetadata(name) {
|
|
269
|
+
try {
|
|
270
|
+
const result = await (0, utils_1.runCommand)('npm', ['view', name, '--json', 'time', 'dist-tags', 'versions'], {
|
|
271
|
+
timeoutMs: 30000,
|
|
272
|
+
maxOutputBytes: 2 * 1024 * 1024
|
|
273
|
+
});
|
|
274
|
+
if (result.code !== 0) {
|
|
275
|
+
return { ok: false, error: result.stderr || result.stdout || `npm view exited with code ${result.code}` };
|
|
276
|
+
}
|
|
277
|
+
const parsed = (0, utils_1.parseJsonOutput)(result.stdout);
|
|
278
|
+
const metadata = parseNpmRegistryMetadata(name, parsed);
|
|
279
|
+
if (!metadata) {
|
|
280
|
+
return { ok: false, error: 'npm registry metadata was unavailable or incomplete' };
|
|
281
|
+
}
|
|
282
|
+
return { ok: true, data: metadata };
|
|
283
|
+
}
|
|
284
|
+
catch (err) {
|
|
285
|
+
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* Build a DependencyRegistryEnrichment object for a dependency based on an npm registry fetch result.
|
|
290
|
+
*
|
|
291
|
+
* @param dep - The dependency record being enriched
|
|
292
|
+
* @param candidateReasons - The list of suspicious reasons that caused this package to be a candidate
|
|
293
|
+
* @param result - The tool result from fetching parsed npm registry metadata for the package
|
|
294
|
+
* @param now - Reference time used to derive time-based risk signals
|
|
295
|
+
* @returns A DependencyRegistryEnrichment describing the enrichment attempt:
|
|
296
|
+
* - When the fetch failed (`result.ok` is `false` or `result.data` is missing`): `attempted: true`, `ok: false`, `source: 'npm-registry'`, `candidateReasons`, and an `error` message.
|
|
297
|
+
* - When the fetch succeeded: `attempted: true`, `ok: true`, `source: 'npm-registry'`, `candidateReasons`, `versionCount`, and optionally `packageCreatedAt`, `packageModifiedAt`, `installedVersionPublishedAt`, `latestVersion`, `latestPublishedAt`, `distTags`, and `signals` when those values are available.
|
|
298
|
+
*/
|
|
299
|
+
function buildRegistryEnrichment(dep, candidateReasons, result, now) {
|
|
300
|
+
if (!result.ok || !result.data) {
|
|
301
|
+
return {
|
|
302
|
+
attempted: true,
|
|
303
|
+
ok: false,
|
|
304
|
+
source: 'npm-registry',
|
|
305
|
+
candidateReasons,
|
|
306
|
+
error: result.error || 'npm registry metadata lookup failed'
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
const metadata = result.data;
|
|
310
|
+
const signals = deriveRegistryRiskSignals(dep.package.version, metadata, now);
|
|
311
|
+
const latestVersion = metadata.distTags.latest;
|
|
312
|
+
const latestPublishedAt = latestVersion ? metadata.time[latestVersion] : undefined;
|
|
313
|
+
return {
|
|
314
|
+
attempted: true,
|
|
315
|
+
ok: true,
|
|
316
|
+
source: 'npm-registry',
|
|
317
|
+
candidateReasons,
|
|
318
|
+
...(metadata.time.created ? { packageCreatedAt: metadata.time.created } : {}),
|
|
319
|
+
...(metadata.time.modified ? { packageModifiedAt: metadata.time.modified } : {}),
|
|
320
|
+
...(metadata.time[dep.package.version] ? { installedVersionPublishedAt: metadata.time[dep.package.version] } : {}),
|
|
321
|
+
...(latestVersion ? { latestVersion } : {}),
|
|
322
|
+
...(latestPublishedAt ? { latestPublishedAt } : {}),
|
|
323
|
+
versionCount: metadata.versions.length,
|
|
324
|
+
...(Object.keys(metadata.distTags).length > 0 ? { distTags: metadata.distTags } : {}),
|
|
325
|
+
...(signals.length > 0 ? { signals } : {})
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* Fetches npm registry metadata for the provided candidates in bounded concurrent batches.
|
|
330
|
+
*
|
|
331
|
+
* Processes candidates in batches of size `REGISTRY_FETCH_CONCURRENCY`, invoking `fetcher` for each
|
|
332
|
+
* candidate and collecting results without aborting on individual failures.
|
|
333
|
+
*
|
|
334
|
+
* @param candidates - Candidate packages to fetch registry metadata for.
|
|
335
|
+
* @param fetcher - Function that takes a package name and returns a `ToolResult<ParsedNpmMetadata>`.
|
|
336
|
+
* @returns A map from candidate package name to its `ToolResult<ParsedNpmMetadata>`. If a fetch fails,
|
|
337
|
+
* the corresponding entry will be `{ ok: false, error: <message> }`.
|
|
338
|
+
*/
|
|
339
|
+
async function fetchRegistryMetadataForCandidates(candidates, fetcher) {
|
|
340
|
+
const results = new Map();
|
|
341
|
+
for (let index = 0; index < candidates.length; index += REGISTRY_FETCH_CONCURRENCY) {
|
|
342
|
+
const batch = candidates.slice(index, index + REGISTRY_FETCH_CONCURRENCY);
|
|
343
|
+
const settled = await Promise.allSettled(batch.map((candidate) => fetcher(candidate.name)));
|
|
344
|
+
settled.forEach((result, offset) => {
|
|
345
|
+
const name = batch[offset].name;
|
|
346
|
+
results.set(name, result.status === 'fulfilled'
|
|
347
|
+
? result.value
|
|
348
|
+
: { ok: false, error: result.reason instanceof Error ? result.reason.message : String(result.reason) });
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
return results;
|
|
352
|
+
}
|
|
353
|
+
/**
|
|
354
|
+
* Enriches an aggregated dependency graph with npm registry metadata for selected candidates.
|
|
355
|
+
*
|
|
356
|
+
* @param aggregated - The aggregated dependency data whose dependencies may be enriched.
|
|
357
|
+
* @param options - Optional settings that control enrichment behavior.
|
|
358
|
+
* @param options.offline - When true, skips network activity and returns no candidates.
|
|
359
|
+
* @param options.limit - Maximum number of packages to select for enrichment (defaults to module default).
|
|
360
|
+
* @param options.now - Reference `Date` used for time-based signal derivation (defaults to current time).
|
|
361
|
+
* @param options.fetcher - Optional custom registry fetcher used to retrieve package metadata.
|
|
362
|
+
* @returns An object containing:
|
|
363
|
+
* - `candidates`: the chosen registry enrichment candidates and their reasons,
|
|
364
|
+
* - `attempted`: the number of registry lookups attempted,
|
|
365
|
+
* - `succeeded`: the number of lookups that returned successful metadata results.
|
|
366
|
+
*/
|
|
367
|
+
async function enrichAggregatedWithRegistryMetadata(aggregated, options = {}) {
|
|
368
|
+
var _a;
|
|
369
|
+
if (options.offline)
|
|
370
|
+
return { candidates: [], attempted: 0, succeeded: 0 };
|
|
371
|
+
const candidates = selectRegistryEnrichmentCandidates(aggregated, (_a = options.limit) !== null && _a !== void 0 ? _a : exports.REGISTRY_ENRICHMENT_DEFAULT_LIMIT);
|
|
372
|
+
if (candidates.length === 0)
|
|
373
|
+
return { candidates, attempted: 0, succeeded: 0 };
|
|
374
|
+
const fetcher = options.fetcher || fetchNpmRegistryMetadata;
|
|
375
|
+
const now = options.now || new Date();
|
|
376
|
+
const results = await fetchRegistryMetadataForCandidates(candidates, fetcher);
|
|
377
|
+
const reasonsByName = new Map(candidates.map((candidate) => [candidate.name, candidate.reasons]));
|
|
378
|
+
for (const dep of Object.values(aggregated.dependencies || {})) {
|
|
379
|
+
const result = results.get(dep.package.name);
|
|
380
|
+
if (!result)
|
|
381
|
+
continue;
|
|
382
|
+
const candidateReasons = reasonsByName.get(dep.package.name) || [];
|
|
383
|
+
dep.supplyChain = {
|
|
384
|
+
...(dep.supplyChain || {}),
|
|
385
|
+
registry: buildRegistryEnrichment(dep, candidateReasons, result, now)
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
const succeeded = Array.from(results.values()).filter((result) => result.ok).length;
|
|
389
|
+
return { candidates, attempted: results.size, succeeded };
|
|
390
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dependency-radar",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "Local-first dependency analysis tool that generates a single HTML report showing risk, size, usage, and structure of your project's dependencies.",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -59,11 +59,11 @@
|
|
|
59
59
|
"html"
|
|
60
60
|
],
|
|
61
61
|
"devDependencies": {
|
|
62
|
-
"@types/node": "^
|
|
62
|
+
"@types/node": "^25.8.0",
|
|
63
63
|
"terser": "^5.27.0",
|
|
64
64
|
"ts-node": "^10.9.2",
|
|
65
|
-
"typescript": "^
|
|
66
|
-
"vite": "^
|
|
65
|
+
"typescript": "^6.0.3",
|
|
66
|
+
"vite": "^8.0.13",
|
|
67
67
|
"vitest": "^4.0.18"
|
|
68
68
|
},
|
|
69
69
|
"packageManager": "npm@10.9.2"
|