@dependably/npm-check 1.9.0 → 1.10.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 +47 -0
- package/bin/cli.js +93 -1
- package/package.json +20 -3
- package/src/audit-config.js +98 -68
- package/src/audit.js +198 -58
- package/src/exceptions.js +82 -66
- package/src/facts/collect.js +201 -0
- package/src/facts/document.js +140 -0
- package/src/facts/errors.js +19 -0
- package/src/facts/index.js +36 -0
- package/src/facts/lockfile-graph.js +467 -0
- package/src/facts/modulegraph.js +289 -0
- package/src/facts/resolve.js +486 -0
- package/src/facts/scan.js +755 -0
- package/src/facts/specifier.js +48 -0
- package/src/facts/ts.js +47 -0
- package/src/facts/types.d.ts +493 -0
- package/src/facts/version.js +11 -0
- package/src/facts/workspace.js +183 -0
- package/src/npmrc-validator.js +2 -1
- package/src/pnpm-workspace-validator.js +8 -2
- package/src/report.js +12 -6
- package/src/schema.js +54 -0
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
// src/facts/workspace.js
|
|
2
|
+
// What the tree under `srcDir` declares about itself: the first-party package
|
|
3
|
+
// names (every package.json's `name`), the dev/runtime scope each manifest
|
|
4
|
+
// gives its dependencies ("runtime anywhere wins" across manifests), the
|
|
5
|
+
// tsconfig/jsconfig `paths` alias bases (specifiers under one are never a
|
|
6
|
+
// package import), and the first-party source files themselves — everything
|
|
7
|
+
// the scan and the module-graph walk take as their starting point.
|
|
8
|
+
//
|
|
9
|
+
// Ported from sbom-reach's `packages/analyzer-npm/src/workspace.ts`.
|
|
10
|
+
import { readFileSync } from 'node:fs';
|
|
11
|
+
import { dirname, join, relative, sep } from 'node:path';
|
|
12
|
+
import fg from 'fast-glob';
|
|
13
|
+
import ignoreFactory from 'ignore';
|
|
14
|
+
import { aliasBaseFromPathsKey } from './specifier.js';
|
|
15
|
+
import { loadTypeScript } from './ts.js';
|
|
16
|
+
|
|
17
|
+
/** @typedef {import('./types.d.ts').DepScope} DepScope */
|
|
18
|
+
/** @typedef {import('./types.d.ts').Workspace} Workspace */
|
|
19
|
+
|
|
20
|
+
const IGNORE_DIRS = [
|
|
21
|
+
'**/node_modules/**',
|
|
22
|
+
'**/.git/**',
|
|
23
|
+
'**/dist/**',
|
|
24
|
+
'**/build/**',
|
|
25
|
+
'**/out/**',
|
|
26
|
+
'**/coverage/**',
|
|
27
|
+
'**/.next/**',
|
|
28
|
+
'**/.turbo/**',
|
|
29
|
+
'**/vendor/**'
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
const SOURCE_GLOB = '**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs,svelte}';
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* @param {string} srcDir absolute path of the tree to describe
|
|
36
|
+
* @returns {Workspace}
|
|
37
|
+
*/
|
|
38
|
+
export function discoverWorkspace(srcDir) {
|
|
39
|
+
const ts = loadTypeScript();
|
|
40
|
+
/** @type {string[]} */
|
|
41
|
+
const diagnostics = [];
|
|
42
|
+
|
|
43
|
+
const gitignore = loadGitignore(srcDir);
|
|
44
|
+
/** @param {string[]} paths @returns {string[]} */
|
|
45
|
+
const filterIgnored = (paths) => {
|
|
46
|
+
if (!gitignore) return paths;
|
|
47
|
+
return paths.filter((p) => {
|
|
48
|
+
const rel = relative(srcDir, p).split(sep).join('/');
|
|
49
|
+
return rel === '' || !gitignore.ignores(rel);
|
|
50
|
+
});
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const manifestPaths = filterIgnored(
|
|
54
|
+
fg.sync('**/package.json', {
|
|
55
|
+
cwd: srcDir,
|
|
56
|
+
absolute: true,
|
|
57
|
+
ignore: IGNORE_DIRS,
|
|
58
|
+
dot: false,
|
|
59
|
+
followSymbolicLinks: false
|
|
60
|
+
})
|
|
61
|
+
).sort();
|
|
62
|
+
|
|
63
|
+
/** @type {Set<string>} */
|
|
64
|
+
const firstPartyNames = new Set();
|
|
65
|
+
/** @type {Map<string, DepScope>} */
|
|
66
|
+
const depScopes = new Map();
|
|
67
|
+
|
|
68
|
+
for (const path of manifestPaths) {
|
|
69
|
+
/** @type {Record<string, unknown>} */
|
|
70
|
+
let json;
|
|
71
|
+
try {
|
|
72
|
+
json = /** @type {Record<string, unknown>} */ (JSON.parse(readFileSync(path, 'utf8')));
|
|
73
|
+
} catch {
|
|
74
|
+
diagnostics.push(`unparseable package.json at ${relative(srcDir, path)}; skipped`);
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (typeof json.name === 'string') firstPartyNames.add(json.name.toLowerCase());
|
|
78
|
+
|
|
79
|
+
const runtimeSections = ['dependencies', 'optionalDependencies', 'peerDependencies'];
|
|
80
|
+
for (const section of runtimeSections) {
|
|
81
|
+
for (const name of depNames(json[section])) depScopes.set(name, 'runtime');
|
|
82
|
+
}
|
|
83
|
+
for (const name of depNames(json.devDependencies)) {
|
|
84
|
+
if (depScopes.get(name) !== 'runtime') depScopes.set(name, 'dev');
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** @type {Set<string>} */
|
|
89
|
+
const aliasPrefixes = new Set();
|
|
90
|
+
const aliasConfigPaths = filterIgnored(
|
|
91
|
+
fg.sync(['**/tsconfig*.json', '**/jsconfig*.json'], {
|
|
92
|
+
cwd: srcDir,
|
|
93
|
+
absolute: true,
|
|
94
|
+
ignore: IGNORE_DIRS,
|
|
95
|
+
dot: false,
|
|
96
|
+
followSymbolicLinks: false
|
|
97
|
+
})
|
|
98
|
+
).sort();
|
|
99
|
+
for (const path of aliasConfigPaths) {
|
|
100
|
+
const read = ts.readConfigFile(path, (p) => readFileSync(p, 'utf8'));
|
|
101
|
+
if (read.error) {
|
|
102
|
+
diagnostics.push(`unparseable tsconfig/jsconfig at ${relative(srcDir, path)}; aliases from it ignored`);
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
// Resolve `extends` so inherited paths are honored too.
|
|
106
|
+
/** @type {Record<string, unknown>} */
|
|
107
|
+
let config = /** @type {Record<string, unknown>} */ (read.config);
|
|
108
|
+
const visited = new Set([path]);
|
|
109
|
+
let current = path;
|
|
110
|
+
while (typeof config.extends === 'string') {
|
|
111
|
+
const parentPath = resolveExtends(config.extends, dirname(current));
|
|
112
|
+
if (!parentPath || visited.has(parentPath)) break;
|
|
113
|
+
visited.add(parentPath);
|
|
114
|
+
const parent = ts.readConfigFile(parentPath, (p) => readFileSync(p, 'utf8'));
|
|
115
|
+
if (parent.error) break;
|
|
116
|
+
const parentConfig = /** @type {Record<string, unknown>} */ (parent.config);
|
|
117
|
+
config = {
|
|
118
|
+
...parentConfig,
|
|
119
|
+
...config,
|
|
120
|
+
compilerOptions: {
|
|
121
|
+
.../** @type {object | undefined} */ (parentConfig.compilerOptions),
|
|
122
|
+
.../** @type {object | undefined} */ (config.compilerOptions)
|
|
123
|
+
},
|
|
124
|
+
extends: parentConfig.extends
|
|
125
|
+
};
|
|
126
|
+
current = parentPath;
|
|
127
|
+
}
|
|
128
|
+
const compilerOptions = /** @type {{ paths?: Record<string, unknown> } | undefined} */ (config.compilerOptions);
|
|
129
|
+
const paths = compilerOptions?.paths;
|
|
130
|
+
if (paths) {
|
|
131
|
+
for (const key of Object.keys(paths)) aliasPrefixes.add(aliasBaseFromPathsKey(key));
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const sourceFiles = filterIgnored(
|
|
136
|
+
fg.sync(SOURCE_GLOB, {
|
|
137
|
+
cwd: srcDir,
|
|
138
|
+
absolute: true,
|
|
139
|
+
ignore: IGNORE_DIRS,
|
|
140
|
+
dot: false,
|
|
141
|
+
followSymbolicLinks: false
|
|
142
|
+
})
|
|
143
|
+
).sort();
|
|
144
|
+
|
|
145
|
+
return { firstPartyNames, depScopes, aliasPrefixes, sourceFiles, diagnostics };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* @param {unknown} section
|
|
150
|
+
* @returns {string[]}
|
|
151
|
+
*/
|
|
152
|
+
function depNames(section) {
|
|
153
|
+
if (section === null || typeof section !== 'object') return [];
|
|
154
|
+
return Object.keys(/** @type {Record<string, unknown>} */ (section)).map((n) => n.toLowerCase());
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* @param {string} srcDir
|
|
159
|
+
* @returns {ReturnType<typeof ignoreFactory> | undefined}
|
|
160
|
+
*/
|
|
161
|
+
function loadGitignore(srcDir) {
|
|
162
|
+
try {
|
|
163
|
+
const content = readFileSync(join(srcDir, '.gitignore'), 'utf8');
|
|
164
|
+
return ignoreFactory().add(content);
|
|
165
|
+
} catch {
|
|
166
|
+
return undefined;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* @param {string} spec
|
|
172
|
+
* @param {string} fromDir
|
|
173
|
+
* @returns {string | undefined}
|
|
174
|
+
*/
|
|
175
|
+
function resolveExtends(spec, fromDir) {
|
|
176
|
+
if (spec.startsWith('.') || spec.startsWith('/')) {
|
|
177
|
+
const p = join(fromDir, spec);
|
|
178
|
+
return p.endsWith('.json') ? p : `${p}.json`;
|
|
179
|
+
}
|
|
180
|
+
// Package-based extends (e.g. @tsconfig/node20) would need module resolution;
|
|
181
|
+
// out of scope for alias collection.
|
|
182
|
+
return undefined;
|
|
183
|
+
}
|
package/src/npmrc-validator.js
CHANGED
|
@@ -59,7 +59,8 @@ const KNOWN_KEYS = new Set([
|
|
|
59
59
|
'fetch-retry-maxtimeout', 'fetch-timeout', 'access', 'tag', 'lockfile-version',
|
|
60
60
|
'omit', 'include', 'ignore-scripts', 'foreground-scripts', 'node-options',
|
|
61
61
|
'progress', 'prefer-offline', 'prefer-online', 'offline', 'global', 'unsafe-perm',
|
|
62
|
-
'update-notifier', 'user-agent', 'maxsockets', 'before', 'workspaces', 'workspace'
|
|
62
|
+
'update-notifier', 'user-agent', 'maxsockets', 'before', 'workspaces', 'workspace',
|
|
63
|
+
'min-release-age'
|
|
63
64
|
]);
|
|
64
65
|
|
|
65
66
|
// Plaintext-credential keys: bare `_auth`/`_authtoken`/`_password`, or the
|
|
@@ -22,6 +22,7 @@ export class PnpmWorkspaceValidationError extends Error {
|
|
|
22
22
|
|
|
23
23
|
const isPlainObject = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
24
24
|
const isStringArray = (v) => Array.isArray(v) && v.every((x) => typeof x === 'string');
|
|
25
|
+
const isNumber = (v) => typeof v === 'number' && Number.isFinite(v);
|
|
25
26
|
|
|
26
27
|
// Recognized top-level keys and the type each must have. Generous (warn-only on
|
|
27
28
|
// unknowns) rather than exhaustive — pnpm adds settings often.
|
|
@@ -52,12 +53,17 @@ const KNOWN_KEYS = {
|
|
|
52
53
|
virtualStoreDir: (v) => typeof v === 'string',
|
|
53
54
|
preferWorkspacePackages: (v) => typeof v === 'boolean',
|
|
54
55
|
linkWorkspacePackages: (v) => typeof v === 'boolean' || typeof v === 'string',
|
|
55
|
-
saveWorkspaceProtocol: (v) => typeof v === 'boolean' || typeof v === 'string'
|
|
56
|
+
saveWorkspaceProtocol: (v) => typeof v === 'boolean' || typeof v === 'string',
|
|
57
|
+
// Supply-chain cooldown (pnpm >= 10.16). NOTE the unit is MINUTES here, while
|
|
58
|
+
// npm's `.npmrc` `min-release-age` is DAYS — see the min-release-age audit rule.
|
|
59
|
+
minimumReleaseAge: isNumber,
|
|
60
|
+
minimumReleaseAgeExclude: isStringArray
|
|
56
61
|
};
|
|
57
62
|
|
|
58
63
|
const TYPE_LABEL = new Map([
|
|
59
64
|
[isStringArray, 'an array of strings'],
|
|
60
|
-
[isPlainObject, 'an object']
|
|
65
|
+
[isPlainObject, 'an object'],
|
|
66
|
+
[isNumber, 'a number']
|
|
61
67
|
]);
|
|
62
68
|
function expectedLabel(validator) {
|
|
63
69
|
return TYPE_LABEL.get(validator) || 'the correct type';
|
package/src/report.js
CHANGED
|
@@ -18,7 +18,7 @@ import { buildEnvelope } from './schema.js';
|
|
|
18
18
|
// pin just like npm's, and the pinned-versions rule is npm+pnpm flavored). The
|
|
19
19
|
// npm-lockfile-shape sections (and license, pending a `.pnpm` store walk) are
|
|
20
20
|
// marked N/A rather than rendered as a misleading pass.
|
|
21
|
-
const PNPM_LIVE_SECTIONS = new Set(['integrity', 'vuln', 'deprecated', 'package-json', 'npmrc', 'pnpm-config', 'pinned', 'unresolved']);
|
|
21
|
+
const PNPM_LIVE_SECTIONS = new Set(['integrity', 'vuln', 'deprecated', 'package-json', 'npmrc', 'pnpm-config', 'pinned', 'unresolved', 'release-age']);
|
|
22
22
|
// The pnpm-config section has no meaning for an npm lockfile.
|
|
23
23
|
const NPM_NA_SECTIONS = new Set(['pnpm-config']);
|
|
24
24
|
|
|
@@ -48,6 +48,7 @@ const RULE_SECTION = {
|
|
|
48
48
|
'no-orphan-packages': 'orphans',
|
|
49
49
|
'unused-dependencies': 'unused',
|
|
50
50
|
'no-fund': 'fund',
|
|
51
|
+
'min-release-age': 'release-age',
|
|
51
52
|
'valid-pnpm-workspace': 'pnpm-config',
|
|
52
53
|
'valid-pnpm-field': 'pnpm-config'
|
|
53
54
|
};
|
|
@@ -77,7 +78,8 @@ const SECTIONS = [
|
|
|
77
78
|
{ id: 'pinned', title: 'Pinned versions' },
|
|
78
79
|
{ id: 'orphans', title: 'Orphaned packages' },
|
|
79
80
|
{ id: 'unused', title: 'Unused dependencies' },
|
|
80
|
-
{ id: 'fund', title: 'Funding solicitations' }
|
|
81
|
+
{ id: 'fund', title: 'Funding solicitations' },
|
|
82
|
+
{ id: 'release-age', title: 'Release-age cooldown' }
|
|
81
83
|
];
|
|
82
84
|
|
|
83
85
|
const MAX_DETAIL = 50; // cap per-section detail lines so the report stays readable
|
|
@@ -247,7 +249,8 @@ function scanSummary(r, flaggedKey, flaggedAdjective, detail = null) {
|
|
|
247
249
|
const n = r[flaggedKey];
|
|
248
250
|
if (n) {
|
|
249
251
|
const unit = `${flaggedAdjective} package${n === 1 ? '' : 's'}`;
|
|
250
|
-
|
|
252
|
+
const suffix = detail ? ` (${detail})` : '';
|
|
253
|
+
bits.push(`${n} ${unit}${suffix}`);
|
|
251
254
|
}
|
|
252
255
|
if (r.skipped) bits.push(`${r.skipped} skipped`);
|
|
253
256
|
return bits.join(' · ');
|
|
@@ -320,7 +323,8 @@ const SECTION_DESCRIBERS = {
|
|
|
320
323
|
// "Unresolved" section — see collectVulnFindings), so its length IS the
|
|
321
324
|
// advisory count, matching the section header 1:1 (SECTION_HEADER_LABEL.vuln).
|
|
322
325
|
const n = findings.length;
|
|
323
|
-
const
|
|
326
|
+
const advisoryWord = n === 1 ? 'advisory' : 'advisories';
|
|
327
|
+
const detail = n ? `${n} ${advisoryWord}` : null;
|
|
324
328
|
return liveSection(findings, scanSummary(state.vulnResult, 'vulnerable', 'vulnerable', detail));
|
|
325
329
|
},
|
|
326
330
|
deprecated(findings, state) {
|
|
@@ -653,7 +657,8 @@ const DEFAULT_PASS_SUMMARY = {
|
|
|
653
657
|
pinned: 'all pinned',
|
|
654
658
|
orphans: 'none',
|
|
655
659
|
unused: 'none',
|
|
656
|
-
fund: 'suppressed'
|
|
660
|
+
fund: 'suppressed',
|
|
661
|
+
'release-age': 'configured'
|
|
657
662
|
};
|
|
658
663
|
|
|
659
664
|
// moonlitlabs/npm-check#35: one glyph per fixed status (see statusLabel()
|
|
@@ -684,7 +689,8 @@ const SECTION_CATEGORY = {
|
|
|
684
689
|
pinned: 'policy',
|
|
685
690
|
orphans: 'lint',
|
|
686
691
|
unused: 'unused',
|
|
687
|
-
fund: 'lint'
|
|
692
|
+
fund: 'lint',
|
|
693
|
+
'release-age': 'policy'
|
|
688
694
|
};
|
|
689
695
|
|
|
690
696
|
// The report tier is error|warn; the shared ladder needs one of five strings.
|
package/src/schema.js
CHANGED
|
@@ -6,10 +6,30 @@
|
|
|
6
6
|
import fs from 'fs';
|
|
7
7
|
import path from 'path';
|
|
8
8
|
import { fileURLToPath } from 'url';
|
|
9
|
+
import { FACTS_SCHEMA_VERSION } from './facts/version.js';
|
|
10
|
+
|
|
11
|
+
export { FACTS_SCHEMA_VERSION };
|
|
9
12
|
|
|
10
13
|
export const TOOL_NAME = 'npm-check';
|
|
11
14
|
export const SCHEMA_VERSION = '1.0';
|
|
12
15
|
|
|
16
|
+
// The `documentType` discriminator for the import-facts document (`npm-check
|
|
17
|
+
// imports`). A findings document has NO `documentType` — schema 1.0 predates
|
|
18
|
+
// the split and the findings envelope is unchanged — so a consumer reads
|
|
19
|
+
// "absent" as findings and "imports" as facts, and never has to guess the
|
|
20
|
+
// payload from whichever key happens to be present. Precedent: pycheck's
|
|
21
|
+
// `--imports` document.
|
|
22
|
+
export const DOCUMENT_TYPE_IMPORTS = 'imports';
|
|
23
|
+
|
|
24
|
+
// `FACTS_SCHEMA_VERSION` (re-exported above) is the facts document's OWN
|
|
25
|
+
// version line — see src/facts/version.js, a dependency-free leaf, so that
|
|
26
|
+
// this module (loaded by every lockfile command) never pulls the facts
|
|
27
|
+
// barrel in and the facts barrel never pulls this one into its type-check.
|
|
28
|
+
|
|
29
|
+
// The envelope-owned keys of a facts document; a body section may never
|
|
30
|
+
// spell one of these (see buildFactsEnvelope).
|
|
31
|
+
const FACTS_IDENTITY_KEYS = new Set(['tool', 'toolVersion', 'schemaVersion', 'documentType', 'target', 'summary']);
|
|
32
|
+
|
|
13
33
|
// The ONE severity ladder, most-severe first.
|
|
14
34
|
export const SEVERITY_LADDER = ['critical', 'high', 'moderate', 'low', 'info'];
|
|
15
35
|
|
|
@@ -74,3 +94,37 @@ export function buildEnvelope({ target, scanned, findings, exitCode, extra }) {
|
|
|
74
94
|
if (extra !== undefined) envelope.extra = extra;
|
|
75
95
|
return envelope;
|
|
76
96
|
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Assemble the import-facts envelope: the SAME identity fields as the
|
|
100
|
+
* findings envelope (`tool`, `toolVersion`, `schemaVersion` — the facts
|
|
101
|
+
* document's OWN version line, `FACTS_SCHEMA_VERSION` — `target`,
|
|
102
|
+
* `summary`) plus the `documentType` discriminator, and NO `findings` — an
|
|
103
|
+
* import site has no severity, so it must never ride in the findings array
|
|
104
|
+
* where `--fail-on` could gate on it. `body` is spread after the identity
|
|
105
|
+
* fields: the facts sections (`workspace`, `imports`, `moduleGraph`,
|
|
106
|
+
* `lockfile`, `unanalyzable`); a `summary` inside `body` is ignored in favour
|
|
107
|
+
* of the one passed explicitly, and the identity fields always win.
|
|
108
|
+
*
|
|
109
|
+
* @param {object} args
|
|
110
|
+
* @param {string} args.target - path scanned, as given
|
|
111
|
+
* @param {object} args.summary - the facts summary; `summary.exitCode` MUST equal the real process exit code
|
|
112
|
+
* @param {object} args.body - the document sections
|
|
113
|
+
* @returns {object} the envelope
|
|
114
|
+
*/
|
|
115
|
+
export function buildFactsEnvelope({ target, summary, body }) {
|
|
116
|
+
// Strip anything in `body` that spells an identity field, so the sections
|
|
117
|
+
// can never overwrite the envelope's own claims about what it is.
|
|
118
|
+
const sections = Object.fromEntries(
|
|
119
|
+
Object.entries(body && typeof body === 'object' ? body : {}).filter(([key]) => !FACTS_IDENTITY_KEYS.has(key))
|
|
120
|
+
);
|
|
121
|
+
return {
|
|
122
|
+
tool: TOOL_NAME,
|
|
123
|
+
toolVersion: toolVersion(),
|
|
124
|
+
schemaVersion: FACTS_SCHEMA_VERSION,
|
|
125
|
+
documentType: DOCUMENT_TYPE_IMPORTS,
|
|
126
|
+
target,
|
|
127
|
+
summary,
|
|
128
|
+
...sections
|
|
129
|
+
};
|
|
130
|
+
}
|