@dependably/npm-check 1.7.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/LICENSE +201 -0
- package/README.md +71 -0
- package/bin/cli.js +1577 -0
- package/package.json +103 -0
- package/src/audit-config.js +331 -0
- package/src/audit.js +805 -0
- package/src/backup.js +235 -0
- package/src/checker.js +771 -0
- package/src/checksum-fixer.js +419 -0
- package/src/deprecation.js +325 -0
- package/src/fixer.js +207 -0
- package/src/format-library.js +132 -0
- package/src/index.js +138 -0
- package/src/integrity.js +519 -0
- package/src/migrator.js +263 -0
- package/src/npmrc-validator.js +205 -0
- package/src/overrides.js +71 -0
- package/src/package-json-validator.js +356 -0
- package/src/parallel-processor.js +374 -0
- package/src/parser.js +133 -0
- package/src/performance.js +283 -0
- package/src/pinner.js +248 -0
- package/src/pnpm-format.js +229 -0
- package/src/pnpm-workspace-validator.js +121 -0
- package/src/progress-reporter.js +245 -0
- package/src/pruner.js +177 -0
- package/src/remediate.js +389 -0
- package/src/report.js +663 -0
- package/src/schema.js +76 -0
- package/src/streaming-parser.js +251 -0
- package/src/updater.js +251 -0
- package/src/usage-scanner.js +215 -0
- package/src/validator.js +282 -0
- package/src/vuln.js +618 -0
- package/src/workers/dedupe-worker.js +20 -0
- package/src/workers/hash-upgrade-worker.js +20 -0
- package/src/workers/migration-worker.js +21 -0
- package/src/workers/validation-worker.js +20 -0
package/src/index.js
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// src/index.js
|
|
2
|
+
export { parseLockfile, serializeLockfile } from './parser.js';
|
|
3
|
+
export { validatePackageLock, ValidationError } from './validator.js';
|
|
4
|
+
export { validatePackageJson, PackageJsonValidationError } from './package-json-validator.js';
|
|
5
|
+
export { parseNpmrc, validateNpmrc, NpmrcValidationError, NPMRC_SECURITY_CODES } from './npmrc-validator.js';
|
|
6
|
+
export { parsePnpmWorkspace, validatePnpmWorkspace, PnpmWorkspaceValidationError } from './pnpm-workspace-validator.js';
|
|
7
|
+
export { forEachPnpmPackageEntry, resolvePnpmRegistryBase, parsePnpmDepPath } from './pnpm-format.js';
|
|
8
|
+
export { migrateToVersion, PackageLockMigrator, MigrationError } from './migrator.js';
|
|
9
|
+
export {
|
|
10
|
+
upgradeIntegrityHashes,
|
|
11
|
+
deduplicatePackages,
|
|
12
|
+
findPackagesMatching,
|
|
13
|
+
countUniquePackages,
|
|
14
|
+
findDuplicatePackages
|
|
15
|
+
} from './updater.js';
|
|
16
|
+
export { fixPackageLock, FixerError } from './fixer.js';
|
|
17
|
+
export {
|
|
18
|
+
LOCKFILE_VERSIONS,
|
|
19
|
+
detectLockfileVersion,
|
|
20
|
+
detectLockfileFlavor,
|
|
21
|
+
hasPackagesMap,
|
|
22
|
+
hasDependenciesTree,
|
|
23
|
+
forEachPackageEntry,
|
|
24
|
+
resolvePackageName
|
|
25
|
+
} from './format-library.js';
|
|
26
|
+
export {
|
|
27
|
+
createBackup,
|
|
28
|
+
listBackups,
|
|
29
|
+
restoreFromLatestBackup,
|
|
30
|
+
cleanOldBackups,
|
|
31
|
+
BackupError
|
|
32
|
+
} from './backup.js';
|
|
33
|
+
export {
|
|
34
|
+
generateIntegrityFromData,
|
|
35
|
+
generateIntegrityFromFile,
|
|
36
|
+
generateIntegrityFromRegistry,
|
|
37
|
+
generateOrPlaceholderIntegrity,
|
|
38
|
+
fetchPackumentIntegrity,
|
|
39
|
+
fetchPackumentManifest,
|
|
40
|
+
fetchPackument,
|
|
41
|
+
fetchLatestVersion,
|
|
42
|
+
isValidIntegrity,
|
|
43
|
+
isPlaceholder,
|
|
44
|
+
DEFAULT_REGISTRY
|
|
45
|
+
} from './integrity.js';
|
|
46
|
+
export {
|
|
47
|
+
fixChecksums,
|
|
48
|
+
deriveRegistryBase,
|
|
49
|
+
ChecksumFixError
|
|
50
|
+
} from './checksum-fixer.js';
|
|
51
|
+
export {
|
|
52
|
+
pinVersions,
|
|
53
|
+
classifyRange,
|
|
54
|
+
detectIndent,
|
|
55
|
+
PinnerError
|
|
56
|
+
} from './pinner.js';
|
|
57
|
+
export {
|
|
58
|
+
runAudit,
|
|
59
|
+
formatAuditReport,
|
|
60
|
+
classifyInstallScripts,
|
|
61
|
+
rules as auditRules,
|
|
62
|
+
AuditError
|
|
63
|
+
} from './audit.js';
|
|
64
|
+
export {
|
|
65
|
+
findOrphanedPackages,
|
|
66
|
+
prunePackages,
|
|
67
|
+
PrunerError
|
|
68
|
+
} from './pruner.js';
|
|
69
|
+
export {
|
|
70
|
+
scanUsedPackages,
|
|
71
|
+
findUnusedDependencies,
|
|
72
|
+
specifierToPackageName,
|
|
73
|
+
DEFAULT_BUILD_DIRS,
|
|
74
|
+
UsageScannerError
|
|
75
|
+
} from './usage-scanner.js';
|
|
76
|
+
export {
|
|
77
|
+
loadAuditConfig,
|
|
78
|
+
mergeConfig,
|
|
79
|
+
normalizeRuleEntry,
|
|
80
|
+
DEFAULT_CONFIG as DEFAULT_AUDIT_CONFIG,
|
|
81
|
+
CONFIG_FILENAMES as AUDIT_CONFIG_FILENAMES,
|
|
82
|
+
AuditConfigError
|
|
83
|
+
} from './audit-config.js';
|
|
84
|
+
export {
|
|
85
|
+
shallowCopyLockfile,
|
|
86
|
+
processBatchedPackages,
|
|
87
|
+
getMemoryStats,
|
|
88
|
+
filterPackagesLazy,
|
|
89
|
+
createDedupeMap,
|
|
90
|
+
reconstructFromDedupeMap,
|
|
91
|
+
chunkLockfile,
|
|
92
|
+
mergeLockfileChunks,
|
|
93
|
+
estimateLockfileSize,
|
|
94
|
+
isLargeLockfile
|
|
95
|
+
} from './performance.js';
|
|
96
|
+
export {
|
|
97
|
+
ProgressReporter,
|
|
98
|
+
createProgressReporter,
|
|
99
|
+
formatProgress,
|
|
100
|
+
createProgressBar
|
|
101
|
+
} from './progress-reporter.js';
|
|
102
|
+
export {
|
|
103
|
+
StreamingParser,
|
|
104
|
+
parseLockfileStream,
|
|
105
|
+
parseLockfileStreamSync
|
|
106
|
+
} from './streaming-parser.js';
|
|
107
|
+
export {
|
|
108
|
+
WorkerPool,
|
|
109
|
+
processInParallel,
|
|
110
|
+
parallelUpgradeIntegrityHashes,
|
|
111
|
+
parallelDeduplicatePackages,
|
|
112
|
+
parallelMigrate
|
|
113
|
+
} from './parallel-processor.js';
|
|
114
|
+
export {
|
|
115
|
+
checkIntegrity,
|
|
116
|
+
checkLicenses,
|
|
117
|
+
checkAll,
|
|
118
|
+
parseLicensesCsv,
|
|
119
|
+
hashPackageDirectory,
|
|
120
|
+
CheckError
|
|
121
|
+
} from './checker.js';
|
|
122
|
+
export {
|
|
123
|
+
checkVulnerabilities,
|
|
124
|
+
VulnError
|
|
125
|
+
} from './vuln.js';
|
|
126
|
+
export {
|
|
127
|
+
checkDeprecations,
|
|
128
|
+
DeprecationError
|
|
129
|
+
} from './deprecation.js';
|
|
130
|
+
export {
|
|
131
|
+
remediateDependencies,
|
|
132
|
+
RemediationError
|
|
133
|
+
} from './remediate.js';
|
|
134
|
+
export {
|
|
135
|
+
runReport,
|
|
136
|
+
formatReport,
|
|
137
|
+
ReportError
|
|
138
|
+
} from './report.js';
|
package/src/integrity.js
ADDED
|
@@ -0,0 +1,519 @@
|
|
|
1
|
+
// src/integrity.js
|
|
2
|
+
import crypto from 'crypto';
|
|
3
|
+
import fs from 'fs';
|
|
4
|
+
import http from 'http';
|
|
5
|
+
import https from 'https';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Generate SHA512 integrity hash for raw data
|
|
9
|
+
* @param {string} data - Content to hash
|
|
10
|
+
* @returns {string} Integrity hash in 'sha512-<base64>' format
|
|
11
|
+
*/
|
|
12
|
+
export function generateIntegrityFromData(data) {
|
|
13
|
+
const hash = crypto.createHash('sha512');
|
|
14
|
+
hash.update(data);
|
|
15
|
+
const digest = hash.digest('base64');
|
|
16
|
+
return `sha512-${digest}`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Generate SHA512 integrity hash from a file
|
|
21
|
+
* @param {string} filePath - Path to the file
|
|
22
|
+
* @returns {string} Integrity hash in 'sha512-<base64>' format
|
|
23
|
+
*/
|
|
24
|
+
export function generateIntegrityFromFile(filePath) {
|
|
25
|
+
try {
|
|
26
|
+
// Read raw bytes — hashing decoded utf8 corrupts binary content (e.g. tarballs)
|
|
27
|
+
const data = fs.readFileSync(filePath);
|
|
28
|
+
return generateIntegrityFromData(data);
|
|
29
|
+
} catch (e) {
|
|
30
|
+
console.error(`Failed to read file ${filePath}: ${e.message}`);
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const DEFAULT_REGISTRY = 'https://registry.npmjs.org';
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Return true when `url`'s host is acceptable to query. The lockfile's `resolved`
|
|
39
|
+
* URL is attacker-controlled, so a caller that wants to pin the trusted registry
|
|
40
|
+
* set (SSRF / self-attesting-host defense, issue #12) passes an `allowedHosts`
|
|
41
|
+
* allowlist; with no allowlist the historical "trust whatever the lockfile says"
|
|
42
|
+
* behavior is preserved so existing callers are unaffected.
|
|
43
|
+
* @param {URL} url - Parsed resolved URL
|
|
44
|
+
* @param {string[]|Set<string>} [allowedHosts] - Permitted host (or host:port) values
|
|
45
|
+
* @returns {boolean}
|
|
46
|
+
*/
|
|
47
|
+
function isAllowedRegistryHost(url, allowedHosts) {
|
|
48
|
+
if (!allowedHosts) return true;
|
|
49
|
+
const list = Array.isArray(allowedHosts) ? allowedHosts : Array.from(allowedHosts);
|
|
50
|
+
if (list.length === 0) return true;
|
|
51
|
+
const host = url.host.toLowerCase(); // hostname[:port]
|
|
52
|
+
const hostname = url.hostname.toLowerCase();
|
|
53
|
+
return list.some((h) => {
|
|
54
|
+
if (typeof h !== 'string') return false;
|
|
55
|
+
const allowed = h.toLowerCase();
|
|
56
|
+
if (allowed.includes(':')) {
|
|
57
|
+
// Entry pins an explicit port — require an exact host:port match.
|
|
58
|
+
return allowed === host;
|
|
59
|
+
}
|
|
60
|
+
// A port-less entry matches the hostname ONLY on the default port, so a
|
|
61
|
+
// hostile lockfile cannot redirect the request to a different service
|
|
62
|
+
// (e.g. :9200) on an otherwise-allowed host.
|
|
63
|
+
return allowed === hostname && url.port === '';
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Derive the registry base URL from a package's resolved tarball URL.
|
|
69
|
+
* npm tarball URLs follow <registryBase>/<name>/-/<file>.tgz, where scoped
|
|
70
|
+
* names may appear as '@scope/name' or '@scope%2fname' in the path.
|
|
71
|
+
* @param {string} resolvedUrl - The entry's resolved URL
|
|
72
|
+
* @param {string} packageName - The real package name
|
|
73
|
+
* @param {object} [options] - { allowedHosts } to pin the trusted registry set
|
|
74
|
+
* @returns {string|null} Registry base or null if not derivable / not allowed
|
|
75
|
+
*/
|
|
76
|
+
export function deriveRegistryBase(resolvedUrl, packageName, options = {}) {
|
|
77
|
+
if (!resolvedUrl || !packageName) return null;
|
|
78
|
+
let url;
|
|
79
|
+
try {
|
|
80
|
+
url = new URL(resolvedUrl);
|
|
81
|
+
} catch {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
if (url.protocol !== 'https:' && url.protocol !== 'http:') return null;
|
|
85
|
+
// Refuse to derive a base from a host outside the caller's allowlist. Without
|
|
86
|
+
// this, a hostile lockfile could steer a fetch to an arbitrary (internal) host
|
|
87
|
+
// of its choosing and self-attest a tampered `integrity` by also pointing
|
|
88
|
+
// `resolved` at a server it controls. NOTE: only checkIntegrity() currently
|
|
89
|
+
// passes `allowedHosts` (see checker.js); vuln/deprecation/checksum-fixer keep
|
|
90
|
+
// the historical "trust the lockfile" default until an allowlist is threaded
|
|
91
|
+
// through those entry points. With no allowlist this is a no-op.
|
|
92
|
+
if (!isAllowedRegistryHost(url, options.allowedHosts)) return null;
|
|
93
|
+
|
|
94
|
+
const markerIdx = url.pathname.indexOf('/-/');
|
|
95
|
+
if (markerIdx === -1) return null;
|
|
96
|
+
|
|
97
|
+
let beforeMarker = url.pathname.slice(0, markerIdx);
|
|
98
|
+
// Strip the package name (possibly %2f-encoded for scopes) off the tail
|
|
99
|
+
const encodedName = packageName.replace('/', '%2f');
|
|
100
|
+
for (const candidate of [`/${packageName}`, `/${encodedName}`]) {
|
|
101
|
+
if (beforeMarker.toLowerCase().endsWith(candidate.toLowerCase())) {
|
|
102
|
+
beforeMarker = beforeMarker.slice(0, beforeMarker.length - candidate.length);
|
|
103
|
+
return `${url.origin}${beforeMarker}`;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Hard ceiling on a single registry response body. The `resolved` host is
|
|
110
|
+
// attacker-controlled, so a hostile/broken registry could otherwise stream an
|
|
111
|
+
// unbounded body and exhaust memory (issue #12). Overridable per call for tests.
|
|
112
|
+
const MAX_RESPONSE_BYTES = 16 * 1024 * 1024;
|
|
113
|
+
|
|
114
|
+
// The wall-clock deadline defaults to a MULTIPLE of the idle timeout. Making it
|
|
115
|
+
// equal to timeoutMs (as before) rendered the idle timeout dead code and starved
|
|
116
|
+
// legitimately large responses (e.g. a multi-MB packument on a slow link) that
|
|
117
|
+
// stream steadily but take longer than a single idle window. The idle timeout
|
|
118
|
+
// still catches true inactivity; the size cap still bounds memory.
|
|
119
|
+
const DEADLINE_MULTIPLIER = 6;
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Pick the transport module for a URL by scheme. npm itself supports plaintext
|
|
123
|
+
* `http://` registries (Verdaccio/Nexus on a LAN); the old https-only transport
|
|
124
|
+
* threw ERR_INVALID_PROTOCOL on them (issue #16).
|
|
125
|
+
* @param {string} url
|
|
126
|
+
* @returns {typeof http | typeof https}
|
|
127
|
+
*/
|
|
128
|
+
function transportFor(url) {
|
|
129
|
+
return new URL(url).protocol === 'http:' ? http : https;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Wrap resolve/reject so the promise settles exactly once. Many independent
|
|
134
|
+
* events (size cap, response error, timeout, deadline, end) race to settle a
|
|
135
|
+
* single request; without a guard a later one throws "already settled".
|
|
136
|
+
*/
|
|
137
|
+
function onceSettlers(resolve, reject) {
|
|
138
|
+
let done = false;
|
|
139
|
+
return {
|
|
140
|
+
resolve: (v) => { if (!done) { done = true; resolve(v); } },
|
|
141
|
+
reject: (e) => { if (!done) { done = true; reject(e); } }
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Attach body handlers to a response: enforce the size cap, parse JSON on end,
|
|
147
|
+
* and — critically — handle a mid-body stream `error`. Once headers arrive a
|
|
148
|
+
* socket reset (ECONNRESET / premature close, routine under high concurrency) is
|
|
149
|
+
* emitted on the IncomingMessage, not the request; without this listener it is an
|
|
150
|
+
* uncaught exception that crashes the whole run (issue #16).
|
|
151
|
+
*/
|
|
152
|
+
function collectJsonBody(res, url, maxBytes, settle) {
|
|
153
|
+
let data = '';
|
|
154
|
+
let bytes = 0;
|
|
155
|
+
res.on('data', (chunk) => {
|
|
156
|
+
bytes += chunk.length;
|
|
157
|
+
if (bytes > maxBytes) {
|
|
158
|
+
settle.reject(new Error(`Registry response exceeded ${maxBytes} bytes for ${url}`));
|
|
159
|
+
res.destroy();
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
data += chunk;
|
|
163
|
+
});
|
|
164
|
+
res.on('end', () => {
|
|
165
|
+
try {
|
|
166
|
+
settle.resolve(JSON.parse(data));
|
|
167
|
+
} catch {
|
|
168
|
+
settle.reject(new Error(`Invalid JSON from registry for ${url}`));
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
res.on('error', (e) => settle.reject(e));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Shared GET/POST core: protocol-aware transport, single-host redirect, size cap,
|
|
176
|
+
* an idle (socket-inactivity) timeout AND a wall-clock deadline (a byte-trickle
|
|
177
|
+
* can keep resetting the idle timer forever — issue #12), plus a response-stream
|
|
178
|
+
* error handler.
|
|
179
|
+
*/
|
|
180
|
+
function buildRequestHeaders(payload, accept) {
|
|
181
|
+
if (payload !== null) {
|
|
182
|
+
return {
|
|
183
|
+
'Content-Type': 'application/json',
|
|
184
|
+
'Accept': accept || 'application/json',
|
|
185
|
+
'Content-Length': Buffer.byteLength(payload)
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
// GET with an explicit Accept (e.g. the abbreviated-packument media type).
|
|
189
|
+
return accept ? { 'Accept': accept } : undefined;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Handle a same-host redirect. Returns true if the response WAS a redirect (and
|
|
193
|
+
// has been dealt with — followed, or rejected for an invalid/cross-host target),
|
|
194
|
+
// false if it wasn't a redirect and normal status handling should proceed.
|
|
195
|
+
function handleRedirect(res, { url, redirectsLeft, settle, followRedirect }) {
|
|
196
|
+
const isRedirect = res.statusCode >= 300 && res.statusCode < 400 && res.headers.location && redirectsLeft > 0;
|
|
197
|
+
if (!isRedirect) return false;
|
|
198
|
+
res.resume();
|
|
199
|
+
let target;
|
|
200
|
+
try {
|
|
201
|
+
target = new URL(res.headers.location, url);
|
|
202
|
+
} catch {
|
|
203
|
+
settle.reject(new Error(`Invalid redirect location "${res.headers.location}" for ${url}`));
|
|
204
|
+
return true;
|
|
205
|
+
}
|
|
206
|
+
// Only follow a redirect to the SAME host — a security check must not be
|
|
207
|
+
// bounced to an arbitrary attacker-controlled origin for its answer.
|
|
208
|
+
if (target.host !== new URL(url).host) {
|
|
209
|
+
settle.reject(new Error(`refusing cross-host redirect to ${target.host} for ${url}`));
|
|
210
|
+
return true;
|
|
211
|
+
}
|
|
212
|
+
followRedirect(target.toString(), redirectsLeft - 1).then(settle.resolve, settle.reject);
|
|
213
|
+
return true;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Classify a registry response: follow redirects, resolve null on 404, reject on
|
|
217
|
+
// other non-200s, else collect + parse the JSON body.
|
|
218
|
+
function handleRegistryResponse(res, ctx) {
|
|
219
|
+
const { url, maxBytes, settle } = ctx;
|
|
220
|
+
if (handleRedirect(res, ctx)) return;
|
|
221
|
+
if (res.statusCode === 404) {
|
|
222
|
+
res.resume();
|
|
223
|
+
settle.resolve(null);
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
if (res.statusCode !== 200) {
|
|
227
|
+
res.resume();
|
|
228
|
+
settle.reject(new Error(`Registry responded with status ${res.statusCode} for ${url}`));
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
collectJsonBody(res, url, maxBytes, settle);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function requestJson({ url, method, payload, timeoutMs, redirectsLeft, maxBytes, deadlineMs, accept, followRedirect }) {
|
|
235
|
+
return new Promise((resolve, reject) => {
|
|
236
|
+
const settle = onceSettlers(resolve, reject);
|
|
237
|
+
const requestOptions = { method };
|
|
238
|
+
const headers = buildRequestHeaders(payload, accept);
|
|
239
|
+
if (headers) requestOptions.headers = headers;
|
|
240
|
+
const ctx = { url, redirectsLeft, maxBytes, settle, followRedirect };
|
|
241
|
+
|
|
242
|
+
let req;
|
|
243
|
+
try {
|
|
244
|
+
req = transportFor(url).request(url, requestOptions, (res) => handleRegistryResponse(res, ctx));
|
|
245
|
+
} catch (e) {
|
|
246
|
+
settle.reject(e);
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
req.on('error', settle.reject);
|
|
250
|
+
// Idle timeout: fires after `timeoutMs` of socket inactivity.
|
|
251
|
+
req.setTimeout(timeoutMs, () => {
|
|
252
|
+
req.destroy(new Error(`Registry request timed out after ${timeoutMs}ms: ${url}`));
|
|
253
|
+
});
|
|
254
|
+
// Wall-clock deadline: a hard ceiling on total request duration regardless of
|
|
255
|
+
// activity, so a slow trickle cannot hang the run indefinitely.
|
|
256
|
+
const deadline = setTimeout(() => {
|
|
257
|
+
req.destroy(new Error(`Registry request exceeded ${deadlineMs}ms deadline: ${url}`));
|
|
258
|
+
}, deadlineMs);
|
|
259
|
+
if (typeof deadline.unref === 'function') deadline.unref();
|
|
260
|
+
const clearDeadline = () => clearTimeout(deadline);
|
|
261
|
+
req.on('close', clearDeadline);
|
|
262
|
+
req.on('error', clearDeadline);
|
|
263
|
+
|
|
264
|
+
if (payload !== null) req.write(payload);
|
|
265
|
+
req.end();
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* GET and parse JSON from a registry URL.
|
|
271
|
+
* @param {string} url - Full URL
|
|
272
|
+
* @param {number} timeoutMs - Per-request idle timeout (also the default deadline)
|
|
273
|
+
* @param {number} [redirectsLeft] - Remaining same-host redirect hops
|
|
274
|
+
* @param {object} [options] - { maxBytes, deadlineMs } (deadlineMs defaults to timeoutMs)
|
|
275
|
+
* @returns {Promise<object|null>} Parsed JSON, or null on 404
|
|
276
|
+
*/
|
|
277
|
+
export function getJson(url, timeoutMs, redirectsLeft = 1, options = {}) {
|
|
278
|
+
const maxBytes = options.maxBytes ?? MAX_RESPONSE_BYTES;
|
|
279
|
+
const deadlineMs = options.deadlineMs ?? timeoutMs * DEADLINE_MULTIPLIER;
|
|
280
|
+
return requestJson({
|
|
281
|
+
url,
|
|
282
|
+
method: 'GET',
|
|
283
|
+
payload: null,
|
|
284
|
+
timeoutMs,
|
|
285
|
+
redirectsLeft,
|
|
286
|
+
maxBytes,
|
|
287
|
+
deadlineMs,
|
|
288
|
+
accept: options.accept,
|
|
289
|
+
followRedirect: (target, left) => getJson(target, timeoutMs, left, options)
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* POST a JSON body to a registry endpoint and resolve the parsed JSON response.
|
|
295
|
+
* Modeled on the GET helper above; used for the bulk advisory endpoint.
|
|
296
|
+
* Resolves null on 404 (endpoint not supported by this registry); rejects on
|
|
297
|
+
* network errors/timeouts/non-200 so callers can distinguish "offline" from
|
|
298
|
+
* "unsupported".
|
|
299
|
+
* @param {string} url - Full endpoint URL
|
|
300
|
+
* @param {object} bodyObject - JSON-serializable request body
|
|
301
|
+
* @param {number} timeoutMs - Per-request idle timeout (also the default deadline)
|
|
302
|
+
* @param {number} [redirectsLeft] - Remaining redirect hops
|
|
303
|
+
* @param {object} [options] - { maxBytes, deadlineMs }
|
|
304
|
+
* @returns {Promise<object|null>} Parsed JSON, or null on 404
|
|
305
|
+
*/
|
|
306
|
+
export function postJson(url, bodyObject, timeoutMs, redirectsLeft = 1, options = {}) {
|
|
307
|
+
const payload = JSON.stringify(bodyObject);
|
|
308
|
+
const maxBytes = options.maxBytes ?? MAX_RESPONSE_BYTES;
|
|
309
|
+
const deadlineMs = options.deadlineMs ?? timeoutMs * DEADLINE_MULTIPLIER;
|
|
310
|
+
return requestJson({
|
|
311
|
+
url,
|
|
312
|
+
method: 'POST',
|
|
313
|
+
payload,
|
|
314
|
+
timeoutMs,
|
|
315
|
+
redirectsLeft,
|
|
316
|
+
maxBytes,
|
|
317
|
+
deadlineMs,
|
|
318
|
+
accept: options.accept,
|
|
319
|
+
followRedirect: (target, left) => postJson(target, bodyObject, timeoutMs, left, options)
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// npm package-name grammar (case-insensitive to tolerate legacy mixed-case names
|
|
324
|
+
// such as `JSONStream`). A valid name is an optional single `@scope/` segment
|
|
325
|
+
// plus a name segment, with a restricted charset — so it can contain no extra
|
|
326
|
+
// `/`, no `?`/`#`, and can't start with `.`/`_`. This is the primary defense.
|
|
327
|
+
const NPM_NAME_RE = /^(@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/i;
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Validate a package name against npm's naming grammar, then percent-encode it
|
|
331
|
+
* for a registry URL path. A lockfile-controlled name (an entry's `name` field,
|
|
332
|
+
* an `npm:` alias, or a pnpm depPath) is untrusted; the old `replace('/', '%2f')`
|
|
333
|
+
* only touched the first slash and encoded nothing else, so a scoped name could
|
|
334
|
+
* smuggle extra path segments or a query string onto the target (issue #30).
|
|
335
|
+
* Scoped names keep the literal '@' and encode the '/' separator as '%2f'; every
|
|
336
|
+
* other segment is fully `encodeURIComponent`d as defense-in-depth.
|
|
337
|
+
* @param {string} packageName - Name of the package
|
|
338
|
+
* @returns {string} URL-safe name path
|
|
339
|
+
* @throws {Error} when the name is not a valid npm package name
|
|
340
|
+
*/
|
|
341
|
+
function encodePackageNamePath(packageName) {
|
|
342
|
+
if (typeof packageName !== 'string' || packageName.length > 214 || !NPM_NAME_RE.test(packageName)) {
|
|
343
|
+
throw new Error(`Invalid package name for registry URL: ${JSON.stringify(packageName)}`);
|
|
344
|
+
}
|
|
345
|
+
return packageName
|
|
346
|
+
.split('/')
|
|
347
|
+
.map((segment) => (segment.startsWith('@')
|
|
348
|
+
? `@${encodeURIComponent(segment.slice(1))}`
|
|
349
|
+
: encodeURIComponent(segment)))
|
|
350
|
+
.join('%2f');
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Build the registry URL for a single package version manifest.
|
|
355
|
+
* Scoped names keep the literal '/' encoded as %2f per registry convention.
|
|
356
|
+
* @param {string} registryBase - Registry base URL
|
|
357
|
+
* @param {string} packageName - Name of the package
|
|
358
|
+
* @param {string} version - Exact version
|
|
359
|
+
* @returns {string} Full manifest URL
|
|
360
|
+
*/
|
|
361
|
+
function packumentVersionUrl(registryBase, packageName, version) {
|
|
362
|
+
let base = registryBase;
|
|
363
|
+
while (base.endsWith('/')) base = base.slice(0, -1);
|
|
364
|
+
return `${base}/${encodePackageNamePath(packageName)}/${encodeURIComponent(version)}`;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Fetch a package's ABBREVIATED packument (dist-tags + per-version `dist`
|
|
369
|
+
* metadata, the "corgi" format) from a registry. NOTE: this intentionally does
|
|
370
|
+
* NOT return the full document (readme, `time`, full metadata) — the full
|
|
371
|
+
* packument can be enormous and blow the response size cap; callers here only
|
|
372
|
+
* need dist-tags + dist.integrity.
|
|
373
|
+
* Resolves the parsed packument, null on 404, rejects on network errors/timeouts.
|
|
374
|
+
* @param {string} packageName - Name of the package
|
|
375
|
+
* @param {object} options - { registryBase, timeoutMs, fetchJson (injectable transport for tests) }
|
|
376
|
+
* @returns {Promise<object|null>} Abbreviated packument or null
|
|
377
|
+
*/
|
|
378
|
+
export async function fetchPackument(packageName, options = {}) {
|
|
379
|
+
const { registryBase = DEFAULT_REGISTRY, timeoutMs = 10000, fetchJson = getJson, maxBytes, deadlineMs } = options;
|
|
380
|
+
let base = registryBase;
|
|
381
|
+
while (base.endsWith('/')) base = base.slice(0, -1);
|
|
382
|
+
// Request the ABBREVIATED packument. The full document can be enormous
|
|
383
|
+
// (renovate's is ~80MB — 5× the response size cap, so a full fetch always
|
|
384
|
+
// fails), while the abbreviated form carries dist-tags + per-version
|
|
385
|
+
// dist.integrity, which is everything fetchLatestVersion/remediate need.
|
|
386
|
+
// Mirror npm's own content negotiation: prefer the abbreviated ("corgi") type
|
|
387
|
+
// but fall back to the full packument JSON on a registry that 406s the corgi
|
|
388
|
+
// type, rather than surfacing a spurious "unreachable".
|
|
389
|
+
return fetchJson(`${base}/${encodePackageNamePath(packageName)}`, timeoutMs, 1, {
|
|
390
|
+
accept: 'application/vnd.npm.install-v1+json; q=1.0, application/json; q=0.8, */*',
|
|
391
|
+
maxBytes,
|
|
392
|
+
deadlineMs
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Fetch a package's "latest" dist-tag version from a registry.
|
|
398
|
+
* Resolves the version string, or null if the package/tag is unavailable.
|
|
399
|
+
* @param {string} packageName - Name of the package
|
|
400
|
+
* @param {object} options - { registryBase, timeoutMs, fetchJson }
|
|
401
|
+
* @returns {Promise<string|null>} Latest version or null
|
|
402
|
+
*/
|
|
403
|
+
export async function fetchLatestVersion(packageName, options = {}) {
|
|
404
|
+
const packument = await fetchPackument(packageName, options);
|
|
405
|
+
const latest = packument && packument['dist-tags'] && packument['dist-tags'].latest;
|
|
406
|
+
return typeof latest === 'string' ? latest : null;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Fetch a single package version's manifest from a registry.
|
|
411
|
+
* Resolves the parsed manifest object, null when the package/version is not
|
|
412
|
+
* found (404), and rejects on network errors/timeouts so callers can
|
|
413
|
+
* distinguish "offline" from "not on npm".
|
|
414
|
+
* @param {string} packageName - Name of the package
|
|
415
|
+
* @param {string} version - Exact version
|
|
416
|
+
* @param {object} options - { registryBase, timeoutMs, fetchJson (injectable transport for tests) }
|
|
417
|
+
* @returns {Promise<object|null>} Version manifest or null
|
|
418
|
+
*/
|
|
419
|
+
export async function fetchPackumentManifest(packageName, version, options = {}) {
|
|
420
|
+
const { registryBase = DEFAULT_REGISTRY, timeoutMs = 10000, fetchJson = getJson } = options;
|
|
421
|
+
const url = packumentVersionUrl(registryBase, packageName, version);
|
|
422
|
+
return fetchJson(url, timeoutMs);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Fetch a package version's integrity hash from a registry packument.
|
|
427
|
+
* Resolves null when the package/version is not found (404 or no dist.integrity);
|
|
428
|
+
* rejects on network errors/timeouts so callers can distinguish "offline" from "not on npm".
|
|
429
|
+
* @param {string} packageName - Name of the package
|
|
430
|
+
* @param {string} version - Exact version
|
|
431
|
+
* @param {object} options - { registryBase, timeoutMs, fetchJson (injectable transport for tests) }
|
|
432
|
+
* @returns {Promise<string|null>} Integrity hash or null
|
|
433
|
+
*/
|
|
434
|
+
export async function fetchPackumentIntegrity(packageName, version, options = {}) {
|
|
435
|
+
const { registryBase = DEFAULT_REGISTRY, timeoutMs = 10000, fetchJson = getJson } = options;
|
|
436
|
+
const url = packumentVersionUrl(registryBase, packageName, version);
|
|
437
|
+
|
|
438
|
+
const pkg = await fetchJson(url, timeoutMs);
|
|
439
|
+
if (pkg && pkg.dist && pkg.dist.integrity) {
|
|
440
|
+
return pkg.dist.integrity;
|
|
441
|
+
}
|
|
442
|
+
return null;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Fetch a package from npm registry and generate its integrity hash
|
|
447
|
+
* (back-compat wrapper around fetchPackumentIntegrity; swallows errors)
|
|
448
|
+
* @param {string} packageName - Name of the package
|
|
449
|
+
* @param {string} version - Version of the package
|
|
450
|
+
* @returns {Promise<string>} Integrity hash or null if fetch fails
|
|
451
|
+
*/
|
|
452
|
+
export async function generateIntegrityFromRegistry(packageName, version) {
|
|
453
|
+
try {
|
|
454
|
+
return await fetchPackumentIntegrity(packageName, version);
|
|
455
|
+
} catch {
|
|
456
|
+
return null;
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* Attempt to generate real integrity hash for a package
|
|
462
|
+
* Falls back to placeholder if generation fails
|
|
463
|
+
* @param {object} pkg - Package object with name and version
|
|
464
|
+
* @param {object} options - Options { tryRegistry: boolean }
|
|
465
|
+
* @returns {string} Integrity hash or placeholder
|
|
466
|
+
*/
|
|
467
|
+
export async function generateOrPlaceholderIntegrity(pkg, options = {}) {
|
|
468
|
+
const { tryRegistry = false } = options;
|
|
469
|
+
|
|
470
|
+
if (!pkg || typeof pkg !== 'object') {
|
|
471
|
+
return 'sha512-PLACEHOLDER';
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// If package already has integrity, return it
|
|
475
|
+
if (pkg.integrity) {
|
|
476
|
+
return pkg.integrity;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// Try registry if enabled and package has name/version
|
|
480
|
+
if (tryRegistry && pkg.name && pkg.version) {
|
|
481
|
+
try {
|
|
482
|
+
const hash = await generateIntegrityFromRegistry(pkg.name, pkg.version);
|
|
483
|
+
if (hash) {
|
|
484
|
+
return hash;
|
|
485
|
+
}
|
|
486
|
+
} catch {
|
|
487
|
+
// Silently fall through to placeholder
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// Return placeholder
|
|
492
|
+
return 'sha512-PLACEHOLDER';
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* Check if an integrity string looks valid
|
|
497
|
+
* @param {string} integrity - Integrity string
|
|
498
|
+
* @returns {boolean} True if valid format
|
|
499
|
+
*/
|
|
500
|
+
export function isValidIntegrity(integrity) {
|
|
501
|
+
if (!integrity || typeof integrity !== 'string') {
|
|
502
|
+
return false;
|
|
503
|
+
}
|
|
504
|
+
// Match 'sha512-<base64>' or 'sha256-<base64>' format
|
|
505
|
+
return /^sha(256|512)-[A-Za-z0-9+/]+={0,2}$/.test(integrity);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* Check if integrity is a placeholder
|
|
510
|
+
* @param {string} integrity - Integrity string
|
|
511
|
+
* @returns {boolean} True if placeholder
|
|
512
|
+
*/
|
|
513
|
+
export function isPlaceholder(integrity) {
|
|
514
|
+
return integrity && (
|
|
515
|
+
integrity.includes('PLACEHOLDER') ||
|
|
516
|
+
integrity === 'sha512-PLACEHOLDER' ||
|
|
517
|
+
integrity === 'sha256-PLACEHOLDER'
|
|
518
|
+
);
|
|
519
|
+
}
|