@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/package.json ADDED
@@ -0,0 +1,103 @@
1
+ {
2
+ "name": "@dependably/npm-check",
3
+ "version": "1.7.0",
4
+ "description": "A comprehensive tool for validating, migrating, and updating npm package-lock.json files across versions 1, 2, and 3.",
5
+ "main": "src/index.js",
6
+ "type": "module",
7
+ "exports": {
8
+ ".": "./src/index.js",
9
+ "./package.json": "./package.json"
10
+ },
11
+ "bin": {
12
+ "npm-check": "bin/cli.js"
13
+ },
14
+ "publishConfig": {
15
+ "access": "public"
16
+ },
17
+ "files": [
18
+ "src/",
19
+ "bin/",
20
+ "README.md",
21
+ "LICENSE"
22
+ ],
23
+ "scripts": {
24
+ "setup:hooks": "git config core.hooksPath .githooks",
25
+ "test": "node --experimental-vm-modules ./node_modules/jest/bin/jest.js --config=jest.config.mjs",
26
+ "test:unit": "npm test -- --testPathPattern=tests/unit",
27
+ "test:integration": "INTEGRATION_TESTS=1 npm test -- --testPathPattern=tests/integration --runInBand",
28
+ "test:all": "npm run test:unit && npm run test:integration",
29
+ "test:coverage": "npm test -- --coverage",
30
+ "docker:build": "docker-compose -f tests/integration/docker/docker-compose.yml build",
31
+ "docker:test:node22": "docker-compose -f tests/integration/docker/docker-compose.yml run --rm test-node22-npm10",
32
+ "docker:test:node24": "docker-compose -f tests/integration/docker/docker-compose.yml run --rm test-node24-npm11",
33
+ "docker:test:all": "docker-compose -f tests/integration/docker/docker-compose.yml up --abort-on-container-exit",
34
+ "lint": "eslint .",
35
+ "sbom": "cyclonedx-npm --output-format json --output-file sbom.json",
36
+ "sbom:prod": "cyclonedx-npm --omit dev --output-format json --output-file sbom-prod.json",
37
+ "prepublishOnly": "npm run test && npm run lint"
38
+ },
39
+ "repository": {
40
+ "type": "git",
41
+ "url": "git+https://github.com/dependably/npm-check.git"
42
+ },
43
+ "homepage": "https://github.com/dependably/npm-check#readme",
44
+ "bugs": {
45
+ "url": "https://github.com/dependably/npm-check/issues"
46
+ },
47
+ "keywords": [
48
+ "npm",
49
+ "package-lock",
50
+ "npm-package-lock",
51
+ "validator",
52
+ "migrator",
53
+ "updater",
54
+ "lockfile",
55
+ "package-lock.json",
56
+ "integrity",
57
+ "hash",
58
+ "sha512",
59
+ "node-modules"
60
+ ],
61
+ "author": {
62
+ "name": "MHiland",
63
+ "email": "michael@dependably.ca",
64
+ "url": "https://github.com/dependably"
65
+ },
66
+ "license": "Apache-2.0",
67
+ "engines": {
68
+ "node": ">=22.0.0",
69
+ "npm": ">=10.0.0"
70
+ },
71
+ "dependencies": {
72
+ "yaml": "2.9.0"
73
+ },
74
+ "devDependencies": {
75
+ "@babel/core": "7.29.7",
76
+ "@babel/preset-env": "7.28.6",
77
+ "@cyclonedx/cyclonedx-npm": "5.0.0",
78
+ "@eslint/js": "10.0.1",
79
+ "babel-jest": "30.2.0",
80
+ "eslint": "10.5.0",
81
+ "globals": "17.6.0",
82
+ "jest": "29.7.0",
83
+ "sonarqube-scanner": "3.5.0"
84
+ },
85
+ "allowScripts": {
86
+ "fsevents": true,
87
+ "libxmljs2": true
88
+ },
89
+ "overrides": {
90
+ "@babel/plugin-transform-modules-systemjs": "7.29.7",
91
+ "@ungap/structured-clone": "1.3.1",
92
+ "js-yaml": "4.2.0",
93
+ "picomatch": "4.0.4",
94
+ "glob": {
95
+ "minimatch": "3.1.5",
96
+ "brace-expansion": "1.1.15"
97
+ },
98
+ "test-exclude": {
99
+ "minimatch": "3.1.5",
100
+ "brace-expansion": "1.1.15"
101
+ }
102
+ }
103
+ }
@@ -0,0 +1,331 @@
1
+ // src/audit-config.js
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+
5
+ export class AuditConfigError extends Error {
6
+ constructor(message, code, context = {}) {
7
+ super(message);
8
+ this.name = 'AuditConfigError';
9
+ this.code = code;
10
+ this.context = context;
11
+ }
12
+ }
13
+
14
+ export const CONFIG_FILENAMES = ['.npm-checkrc.json', 'npm-check.config.json'];
15
+
16
+ // Shared, cross-tool config file (JSON, no extension) discovered by walking up
17
+ // from the working directory. Its `common`/`npm` `allowedRegistryHosts` extend
18
+ // the secure-resolved trusted-host allowlist additively (public npm stays trusted).
19
+ export const SHARED_CONFIG_FILENAME = '.dependably-check';
20
+
21
+ export const SEVERITIES = ['error', 'warn', 'off'];
22
+
23
+ export const DEFAULT_CONFIG = {
24
+ maxWarnings: -1,
25
+ rules: {
26
+ 'lockfile-version': ['error', { minVersion: 3 }],
27
+ 'valid-structure': 'error',
28
+ 'valid-package-json': 'error',
29
+ 'integrity-hygiene': ['error', { allowSha1: false }],
30
+ 'secure-resolved': ['error', {
31
+ allowedHosts: ['registry.npmjs.org'],
32
+ allowHttp: false,
33
+ allowGit: true,
34
+ allowFile: true
35
+ }],
36
+ 'install-scripts': ['warn', { allow: [] }],
37
+ 'no-git-deps': 'warn',
38
+ 'no-remote-deps': ['warn', { allowedHosts: ['registry.npmjs.org', 'npm.pkg.github.com'] }],
39
+ 'pinned-versions': ['warn', {
40
+ sections: ['dependencies', 'devDependencies', 'optionalDependencies'],
41
+ ignore: []
42
+ }],
43
+ 'lockfile-sync': 'error',
44
+ 'no-orphan-packages': 'warn',
45
+ 'unused-dependencies': ['warn', { includeDev: false, ignore: [] }],
46
+ 'no-fund': 'warn',
47
+ 'valid-npmrc': ['warn', {}],
48
+ // pnpm-only rules (no-op on npm lockfiles via flavor gating in runAudit).
49
+ 'valid-pnpm-workspace': 'error',
50
+ 'valid-pnpm-field': 'error'
51
+ }
52
+ };
53
+
54
+ export const KNOWN_RULES = Object.keys(DEFAULT_CONFIG.rules);
55
+
56
+ /**
57
+ * Normalize a config rule entry to { severity, options }.
58
+ * Accepts 'error' | 'warn' | 'off' | [severity, options].
59
+ */
60
+ export function normalizeRuleEntry(entry) {
61
+ let severity;
62
+ let options = {};
63
+
64
+ if (typeof entry === 'string') {
65
+ severity = entry;
66
+ } else if (Array.isArray(entry) && entry.length >= 1) {
67
+ severity = entry[0];
68
+ if (entry.length > 1) {
69
+ if (typeof entry[1] !== 'object' || entry[1] === null) {
70
+ throw new AuditConfigError(
71
+ `Rule options must be an object, got: ${JSON.stringify(entry[1])}`,
72
+ 'INVALID_RULE_OPTIONS'
73
+ );
74
+ }
75
+ options = structuredClone(entry[1]);
76
+ }
77
+ } else {
78
+ throw new AuditConfigError(
79
+ `Invalid rule entry: ${JSON.stringify(entry)}`,
80
+ 'INVALID_RULE_ENTRY'
81
+ );
82
+ }
83
+
84
+ if (!SEVERITIES.includes(severity)) {
85
+ throw new AuditConfigError(
86
+ `Invalid severity "${severity}" (expected: ${SEVERITIES.join(', ')})`,
87
+ 'INVALID_SEVERITY'
88
+ );
89
+ }
90
+
91
+ return { severity, options };
92
+ }
93
+
94
+ /**
95
+ * Resolve the effective audit config: built-in defaults overlaid by a
96
+ * discovered or explicitly-given JSON config file.
97
+ * Rule options merge over the defaults for that rule; severity replaces.
98
+ *
99
+ * @param {string} cwd - Directory to search for config files
100
+ * @param {string|null} explicitPath - Path passed via --config (wins over discovery)
101
+ * @returns {{maxWarnings, rules: {[id]: {severity, options}}, configPath}}
102
+ */
103
+ /**
104
+ * Walk up from `cwd` to the filesystem root looking for the shared
105
+ * `.dependably-check` config file. Stops at the first hit, at a directory
106
+ * containing a `.git` entry (the repo root), or at the filesystem root.
107
+ *
108
+ * @param {string} cwd - Directory to start the search from
109
+ * @returns {string|null} Absolute path to the shared config, or null when absent
110
+ */
111
+ export function findSharedConfig(cwd = process.cwd()) {
112
+ let dir = path.resolve(cwd);
113
+ for (;;) {
114
+ const candidate = path.join(dir, SHARED_CONFIG_FILENAME);
115
+ if (fs.existsSync(candidate)) return candidate;
116
+
117
+ // Stop at the repo root: a directory containing `.git`.
118
+ if (fs.existsSync(path.join(dir, '.git'))) return null;
119
+
120
+ const parent = path.dirname(dir);
121
+ if (parent === dir) return null; // reached the filesystem root
122
+ dir = parent;
123
+ }
124
+ }
125
+
126
+ // Union of `common.allowedRegistryHosts` and `npm.allowedRegistryHosts` from a
127
+ // parsed shared config (deduped, common first). Other sections are ignored.
128
+ function collectSharedHosts(parsed) {
129
+ const collect = (section) => {
130
+ const hosts = section && section.allowedRegistryHosts;
131
+ return Array.isArray(hosts) ? hosts.filter((h) => typeof h === 'string') : [];
132
+ };
133
+ return [...new Set([...collect(parsed.common), ...collect(parsed.npm)])];
134
+ }
135
+
136
+ // Pull npm-check's audit settings (`rules`, `maxWarnings`) out of a shared
137
+ // `.dependably-check` object: the `common` section is the base, the `npm`
138
+ // section overrides it. Mirrors how the other suite tools read their section.
139
+ function extractSharedAuditSettings(parsed) {
140
+ const pick = (section) => {
141
+ const out = {};
142
+ if (section && typeof section === 'object') {
143
+ if (section.rules !== undefined) out.rules = section.rules;
144
+ if (section.maxWarnings !== undefined) out.maxWarnings = section.maxWarnings;
145
+ }
146
+ return out;
147
+ };
148
+ return { ...pick(parsed && parsed.common), ...pick(parsed && parsed.npm) };
149
+ }
150
+
151
+ // True when a parsed config is the shared `.dependably-check` shape (sectioned
152
+ // by tool) rather than the legacy flat tool-config shape (top-level rules/maxWarnings).
153
+ function isSharedShape(configPath, parsed) {
154
+ if (path.basename(configPath) === SHARED_CONFIG_FILENAME) return true;
155
+ if (!parsed || typeof parsed !== 'object') return false;
156
+ const hasToolKeys = 'rules' in parsed || 'maxWarnings' in parsed;
157
+ const hasSharedSections = 'common' in parsed || 'npm' in parsed;
158
+ return !hasToolKeys && hasSharedSections;
159
+ }
160
+
161
+ // Read + JSON-parse a tool-config file, raising CONFIG_READ / CONFIG_PARSE.
162
+ function readJsonConfig(configPath) {
163
+ let raw;
164
+ try {
165
+ raw = fs.readFileSync(configPath, 'utf8');
166
+ } catch (e) {
167
+ throw new AuditConfigError(`Cannot read config file: ${e.message}`, 'CONFIG_READ', { configPath });
168
+ }
169
+ try {
170
+ return JSON.parse(raw);
171
+ } catch (e) {
172
+ throw new AuditConfigError(`Invalid JSON in ${configPath}: ${e.message}`, 'CONFIG_PARSE', { configPath });
173
+ }
174
+ }
175
+
176
+ /**
177
+ * Read and parse the shared `.dependably-check` file, returning its registry-host
178
+ * allowlist and npm-check audit settings (`rules`/`maxWarnings`). Other tool
179
+ * sections and unknown keys are ignored.
180
+ *
181
+ * @param {string} cwd - Directory to start discovery from
182
+ * @returns {{ allowedRegistryHosts: string[], sharedPath: string|null, auditSettings: object }}
183
+ */
184
+ export function loadSharedConfig(cwd = process.cwd()) {
185
+ const sharedPath = findSharedConfig(cwd);
186
+ if (!sharedPath) return { allowedRegistryHosts: [], sharedPath: null, auditSettings: {} };
187
+
188
+ let raw;
189
+ try {
190
+ raw = fs.readFileSync(sharedPath, 'utf8');
191
+ } catch (e) {
192
+ throw new AuditConfigError(`Cannot read shared config file: ${e.message}`, 'SHARED_CONFIG_READ', { sharedPath });
193
+ }
194
+
195
+ let parsed;
196
+ try {
197
+ parsed = JSON.parse(raw);
198
+ } catch (e) {
199
+ throw new AuditConfigError(`Invalid JSON in ${sharedPath}: ${e.message}`, 'SHARED_CONFIG_PARSE', { sharedPath });
200
+ }
201
+
202
+ return {
203
+ allowedRegistryHosts: collectSharedHosts(parsed),
204
+ sharedPath,
205
+ auditSettings: extractSharedAuditSettings(parsed)
206
+ };
207
+ }
208
+
209
+ export function loadAuditConfig(cwd = process.cwd(), explicitPath = null) {
210
+ // The shared `.dependably-check` (discovered by walking up to the repo root)
211
+ // is the PRIMARY config source — its `common`/`npm` sections supply the base
212
+ // audit settings and the registry-host allowlist. A tool-specific
213
+ // `.npm-checkrc.json` (or an explicit `--config`) overrides it.
214
+ const shared = loadSharedConfig(cwd);
215
+
216
+ let toolConfig = {};
217
+ let configPath = null;
218
+ let explicitSharedHosts = [];
219
+
220
+ if (explicitPath) {
221
+ configPath = path.resolve(explicitPath);
222
+ if (!fs.existsSync(configPath)) {
223
+ throw new AuditConfigError(`Config file not found: ${configPath}`, 'CONFIG_NOT_FOUND');
224
+ }
225
+ const parsed = readJsonConfig(configPath);
226
+ if (isSharedShape(configPath, parsed)) {
227
+ // `--config` points at a `.dependably-check`: read npm-check's settings
228
+ // from its common/npm sections and take its registry-host allowlist too.
229
+ toolConfig = extractSharedAuditSettings(parsed);
230
+ explicitSharedHosts = collectSharedHosts(parsed);
231
+ } else {
232
+ // Legacy flat tool-config (.npm-checkrc.json shape) given explicitly.
233
+ toolConfig = parsed;
234
+ }
235
+ } else {
236
+ // Discover a tool-specific config in the working directory (fallback for
237
+ // back-compat; the shared `.dependably-check` above is the primary source).
238
+ for (const name of CONFIG_FILENAMES) {
239
+ const candidate = path.join(cwd, name);
240
+ if (fs.existsSync(candidate)) {
241
+ configPath = candidate;
242
+ toolConfig = readJsonConfig(candidate);
243
+ break;
244
+ }
245
+ }
246
+ }
247
+
248
+ // Shared audit settings are the base; the tool-specific config overrides them.
249
+ const userConfig = { ...shared.auditSettings, ...toolConfig };
250
+ const config = mergeConfig(userConfig, configPath || shared.sharedPath);
251
+
252
+ // Layer the shared `.dependably-check` hosts ADDITIVELY onto whatever
253
+ // secure-resolved.allowedHosts resolved to (built-in default or a
254
+ // tool-config replacement) — public npm always stays trusted.
255
+ const hosts = [...new Set([...shared.allowedRegistryHosts, ...explicitSharedHosts])];
256
+ if (hosts.length > 0) {
257
+ extendAllowedHosts(config, hosts);
258
+ }
259
+ config.sharedConfigPath = shared.sharedPath;
260
+
261
+ return config;
262
+ }
263
+
264
+ /**
265
+ * Add the given hosts to every host-based rule's `allowedHosts`, deduplicated,
266
+ * without replacing the existing entries. No-op for rules that are absent.
267
+ *
268
+ * BOTH `secure-resolved` (which registries are trusted for HTTPS resolution) and
269
+ * `no-remote-deps` (which registry hosts count as a registry rather than a
270
+ * remote/git dep) consult `allowedHosts`. The shared `.dependably-check`
271
+ * allowlist must reach both — otherwise a private-registry project silences one
272
+ * rule but still trips the other, breaking the documented `--fail-on` CI gate.
273
+ *
274
+ * @param {object} config - A merged audit config (from mergeConfig)
275
+ * @param {string[]} hosts - Bare hostnames to add to the allowlist
276
+ */
277
+ export function extendAllowedHosts(config, hosts) {
278
+ for (const ruleId of ['secure-resolved', 'no-remote-deps']) {
279
+ const rule = config.rules && config.rules[ruleId];
280
+ if (!rule) continue;
281
+ const opts = rule.options || {};
282
+ const existing = Array.isArray(opts.allowedHosts) ? opts.allowedHosts : [];
283
+ rule.options = { ...opts, allowedHosts: [...new Set([...existing, ...hosts])] };
284
+ }
285
+ }
286
+
287
+ /**
288
+ * Merge a user config object over the defaults, validating rule ids and severities.
289
+ * @param {object} userConfig - Parsed user config ({ maxWarnings?, rules? })
290
+ * @param {string|null} configPath - Where it came from (for reporting)
291
+ */
292
+ export function mergeConfig(userConfig = {}, configPath = null) {
293
+ if (userConfig.rules) {
294
+ for (const ruleId of Object.keys(userConfig.rules)) {
295
+ if (!KNOWN_RULES.includes(ruleId)) {
296
+ throw new AuditConfigError(
297
+ `Unknown rule "${ruleId}" (known rules: ${KNOWN_RULES.join(', ')})`,
298
+ 'UNKNOWN_RULE',
299
+ { ruleId, configPath }
300
+ );
301
+ }
302
+ }
303
+ }
304
+
305
+ const rules = {};
306
+ for (const ruleId of KNOWN_RULES) {
307
+ const defaults = normalizeRuleEntry(DEFAULT_CONFIG.rules[ruleId]);
308
+ if (userConfig.rules && userConfig.rules[ruleId] !== undefined) {
309
+ const user = normalizeRuleEntry(userConfig.rules[ruleId]);
310
+ rules[ruleId] = {
311
+ severity: user.severity,
312
+ options: { ...defaults.options, ...user.options }
313
+ };
314
+ } else {
315
+ rules[ruleId] = defaults;
316
+ }
317
+ }
318
+
319
+ let maxWarnings = DEFAULT_CONFIG.maxWarnings;
320
+ if (userConfig.maxWarnings !== undefined) {
321
+ if (typeof userConfig.maxWarnings !== 'number' || !Number.isInteger(userConfig.maxWarnings)) {
322
+ throw new AuditConfigError(
323
+ `maxWarnings must be an integer, got: ${JSON.stringify(userConfig.maxWarnings)}`,
324
+ 'INVALID_MAX_WARNINGS'
325
+ );
326
+ }
327
+ maxWarnings = userConfig.maxWarnings;
328
+ }
329
+
330
+ return { maxWarnings, rules, configPath };
331
+ }