@nodesecure/scanner 2.0.1 → 3.1.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/src/depWalker.js CHANGED
@@ -8,18 +8,21 @@ import os from "os";
8
8
  import combineAsyncIterators from "combine-async-iterators";
9
9
  import iter from "itertools";
10
10
  import pacote from "pacote";
11
- import semver from "semver";
12
11
  import Arborist from "@npmcli/arborist";
13
12
  import Lock from "@slimio/lock";
14
- import { packument, getLocalRegistryURL } from "@nodesecure/npm-registry-sdk";
15
13
  import * as vuln from "@nodesecure/vuln";
16
- import { parseManifestAuthor } from "@nodesecure/utils";
14
+ import { getLocalRegistryURL } from "@nodesecure/npm-registry-sdk";
15
+ import { ScannerLoggerEvents } from "./constants.js";
17
16
 
18
17
  // Import Internal Dependencies
19
- import { mergeDependencies, constants, getCleanDependencyName, getDependenciesWarnings } from "./utils/index.js";
18
+ import {
19
+ mergeDependencies, getCleanDependencyName, getDependenciesWarnings, addMissingVersionFlags, isGitDependency,
20
+ NPM_TOKEN
21
+ } from "./utils/index.js";
20
22
  import { scanDirOrArchive } from "./tarball.js";
21
- import Dependency from "./dependency.class.js";
22
- import Logger from "./logger.class.js";
23
+ import { packageMetadata } from "./npmRegistry.js";
24
+ import Dependency from "./class/dependency.class.js";
25
+ import Logger from "./class/logger.class.js";
23
26
 
24
27
  const { version: packageVersion } = JSON.parse(
25
28
  readFileSync(
@@ -27,20 +30,18 @@ const { version: packageVersion } = JSON.parse(
27
30
  )
28
31
  );
29
32
 
30
-
31
- async function* searchDeepDependencies(packageName, gitURL, options) {
32
- const isGit = typeof gitURL === "string";
33
+ export async function* searchDeepDependencies(packageName, gitURL, options) {
33
34
  const { exclude, currDepth = 0, parent, maxDepth } = options;
34
35
 
35
- const { name, version, deprecated, ...pkg } = await pacote.manifest(isGit ? gitURL : packageName, {
36
- ...constants.NPM_TOKEN,
36
+ const { name, version, deprecated, ...pkg } = await pacote.manifest(gitURL ?? packageName, {
37
+ ...NPM_TOKEN,
37
38
  registry: getLocalRegistryURL(),
38
39
  cache: `${os.homedir()}/.npm`
39
40
  });
40
41
  const { dependencies, customResolvers } = mergeDependencies(pkg);
41
42
 
42
43
  const current = new Dependency(name, version, parent);
43
- isGit && current.isGit(gitURL);
44
+ gitURL !== null && current.isGit(gitURL);
44
45
  current.addFlag("isDeprecated", deprecated === true);
45
46
  current.addFlag("hasCustomResolver", customResolvers.size > 0);
46
47
  current.addFlag("hasDependencies", dependencies.size > 0);
@@ -50,9 +51,9 @@ async function* searchDeepDependencies(packageName, gitURL, options) {
50
51
  exclude, currDepth: currDepth + 1, parent: current, maxDepth
51
52
  };
52
53
 
53
- const gitDependencies = iter.filter(customResolvers.entries(), ([, valueStr]) => valueStr.startsWith("git+"));
54
+ const gitDependencies = iter.filter(customResolvers.entries(), ([, valueStr]) => isGitDependency(valueStr));
54
55
  for (const [depName, valueStr] of gitDependencies) {
55
- yield* searchDeepDependencies(depName, valueStr.slice(4), config);
56
+ yield* searchDeepDependencies(depName, valueStr, config);
56
57
  }
57
58
 
58
59
  const depsNames = await Promise.all(iter.map(dependencies.entries(), getCleanDependencyName));
@@ -66,7 +67,7 @@ async function* searchDeepDependencies(packageName, gitURL, options) {
66
67
  }
67
68
  else {
68
69
  exclude.set(cleanName, new Set([current.fullName]));
69
- yield* searchDeepDependencies(fullName, void 0, config);
70
+ yield* searchDeepDependencies(fullName, null, config);
70
71
  }
71
72
  }
72
73
  }
@@ -74,7 +75,7 @@ async function* searchDeepDependencies(packageName, gitURL, options) {
74
75
  yield current;
75
76
  }
76
77
 
77
- async function* deepReadEdges(currentPackageName, { to, parent, exclude, fullLockMode }) {
78
+ export async function* deepReadEdges(currentPackageName, { to, parent, exclude, fullLockMode }) {
78
79
  const { version, integrity = to.integrity } = to.package;
79
80
 
80
81
  const updatedVersion = version === "*" || typeof version === "undefined" ? "latest" : version;
@@ -82,7 +83,7 @@ async function* deepReadEdges(currentPackageName, { to, parent, exclude, fullLoc
82
83
 
83
84
  if (fullLockMode) {
84
85
  const { deprecated, _integrity, ...pkg } = await pacote.manifest(`${currentPackageName}@${updatedVersion}`, {
85
- ...constants.NPM_TOKEN,
86
+ ...NPM_TOKEN,
86
87
  registry: getLocalRegistryURL(),
87
88
  cache: `${os.homedir()}/.npm`
88
89
  });
@@ -111,64 +112,7 @@ async function* deepReadEdges(currentPackageName, { to, parent, exclude, fullLoc
111
112
  yield current;
112
113
  }
113
114
 
114
- async function fetchPackageMetadata(name, version, options) {
115
- const { ref, locker } = options;
116
- const free = await locker.acquireOne();
117
-
118
- try {
119
- const pkg = await packument(name);
120
-
121
- const publishers = new Set();
122
- const oneYearFromToday = new Date();
123
- oneYearFromToday.setFullYear(oneYearFromToday.getFullYear() - 1);
124
-
125
- ref.metadata.lastVersion = pkg["dist-tags"].latest;
126
- if (semver.neq(version, ref.metadata.lastVersion)) {
127
- ref[version].flags.push("isOutdated");
128
- }
129
- ref.metadata.publishedCount = Object.values(pkg.versions).length;
130
- ref.metadata.lastUpdateAt = new Date(pkg.time[ref.metadata.lastVersion]);
131
- ref.metadata.hasReceivedUpdateInOneYear = !(oneYearFromToday > ref.metadata.lastUpdateAt);
132
- ref.metadata.homepage = pkg.homepage || null;
133
- ref.metadata.maintainers = pkg.maintainers;
134
- if (typeof pkg.author === "string") {
135
- ref.metadata.author = parseManifestAuthor(pkg.author);
136
- }
137
- else {
138
- ref.metadata.author = pkg.author;
139
- }
140
- const authorName = ref.metadata.author?.name ?? null;
141
-
142
- for (const ver of Object.values(pkg.versions)) {
143
- const { _npmUser: npmUser, version } = ver;
144
-
145
- const isNullOrUndefined = typeof npmUser === "undefined" || npmUser === null;
146
- if (isNullOrUndefined || !("name" in npmUser) || typeof npmUser.name !== "string") {
147
- continue;
148
- }
149
-
150
- if (authorName === null) {
151
- ref.metadata.author.name = npmUser.name;
152
- }
153
- else if (npmUser.name !== ref.metadata.author.name) {
154
- ref.metadata.hasManyPublishers = true;
155
- }
156
-
157
- if (!publishers.has(npmUser.name)) {
158
- publishers.add(npmUser.name);
159
- ref.metadata.publishers.push({ name: npmUser.name, version, at: new Date(pkg.time[version]) });
160
- }
161
- }
162
- }
163
- catch (err) {
164
- // Ignore
165
- }
166
- finally {
167
- free();
168
- }
169
- }
170
-
171
- async function* getRootDependencies(manifest, options) {
115
+ export async function* getRootDependencies(manifest, options) {
172
116
  const { maxDepth = 4, exclude, usePackageLock, fullLockMode } = options;
173
117
 
174
118
  const { dependencies, customResolvers } = mergeDependencies(manifest, void 0);
@@ -179,7 +123,7 @@ async function* getRootDependencies(manifest, options) {
179
123
  let iterators;
180
124
  if (usePackageLock) {
181
125
  const arb = new Arborist({
182
- ...constants.NPM_TOKEN,
126
+ ...NPM_TOKEN,
183
127
  registry: getLocalRegistryURL()
184
128
  });
185
129
  let tree;
@@ -191,15 +135,15 @@ async function* getRootDependencies(manifest, options) {
191
135
  tree = await arb.loadVirtual();
192
136
  }
193
137
 
194
- iterators = iter.filter(tree.edgesOut.entries(), ([, { to }]) => !to.dev)
138
+ iterators = iter.filter(tree.edgesOut.entries(), ([, { to }]) => to !== null && !to.dev)
195
139
  .map(([packageName, { to }]) => deepReadEdges(packageName, { to, parent, fullLockMode, exclude }));
196
140
  }
197
141
  else {
198
142
  const configRef = { exclude, maxDepth, parent };
199
143
  iterators = [
200
- ...iter.filter(customResolvers.entries(), ([, valueStr]) => valueStr.startsWith("git+"))
201
- .map(([depName, valueStr]) => searchDeepDependencies(depName, valueStr.slice(4), configRef)),
202
- ...iter.map(dependencies.entries(), ([name, ver]) => searchDeepDependencies(`${name}@${ver}`, void 0, configRef))
144
+ ...iter.filter(customResolvers.entries(), ([, valueStr]) => isGitDependency(valueStr))
145
+ .map(([depName, valueStr]) => searchDeepDependencies(depName, valueStr, configRef)),
146
+ ...iter.map(dependencies.entries(), ([name, ver]) => searchDeepDependencies(`${name}@${ver}`, null, configRef))
203
147
  ];
204
148
  }
205
149
  for await (const dep of combineAsyncIterators({}, ...iterators)) {
@@ -241,58 +185,67 @@ export async function depWalker(manifest, options = {}, logger = new Logger()) {
241
185
  const payload = {
242
186
  id: tmpLocation.slice(-6),
243
187
  rootDepencyName: manifest.name,
244
- warnings: [],
245
- dependencies: new Map(),
246
- version: packageVersion
188
+ version: packageVersion,
189
+ vulnerabilityStrategy,
190
+ warnings: []
247
191
  };
248
192
 
249
193
  // We are dealing with an exclude Map to avoid checking a package more than one time in searchDeepDependencies
250
194
  const exclude = new Map();
195
+ const dependencies = new Map();
251
196
 
252
197
  {
253
- logger.start("walkTree").start("tarball").start("registry");
198
+ logger
199
+ .start(ScannerLoggerEvents.analysis.tree)
200
+ .start(ScannerLoggerEvents.analysis.tarball)
201
+ .start(ScannerLoggerEvents.analysis.registry);
202
+ const fetchedMetadataPackages = new Set();
254
203
  const promisesToWait = [];
255
- const rootDepsOptions = { maxDepth, exclude, usePackageLock, fullLockMode };
256
204
 
257
205
  const tarballLocker = new Lock({ maxConcurrent: 5 });
258
- const metadataLocker = new Lock({ maxConcurrent: 10 });
259
- metadataLocker.on("freeOne", () => logger.tick("registry"));
260
- tarballLocker.on("freeOne", () => logger.tick("tarball"));
206
+ tarballLocker.on("freeOne", () => logger.tick(ScannerLoggerEvents.analysis.tarball));
261
207
 
208
+ const rootDepsOptions = { maxDepth, exclude, usePackageLock, fullLockMode };
262
209
  for await (const currentDep of getRootDependencies(manifest, rootDepsOptions)) {
263
210
  const { name, version } = currentDep;
264
211
  const current = currentDep.exportAsPlainObject(name === manifest.name ? 0 : void 0);
265
212
  let proceedDependencyAnalysis = true;
266
213
 
267
- if (payload.dependencies.has(name)) {
214
+ if (dependencies.has(name)) {
268
215
  // TODO: how to handle different metadata ?
269
- const dep = payload.dependencies.get(name);
216
+ const dep = dependencies.get(name);
270
217
 
271
- const currVersion = current.versions[0];
272
- if (currVersion in dep) {
218
+ const currVersion = Object.keys(current.versions)[0];
219
+ if (currVersion in dep.versions) {
273
220
  // The dependency has already entered the analysis
274
221
  // This happens if the package is used by multiple packages in the tree
275
222
  proceedDependencyAnalysis = false;
276
223
  }
277
224
  else {
278
- dep[currVersion] = current[currVersion];
279
- dep.versions.push(currVersion);
225
+ dep.versions[currVersion] = current.versions[currVersion];
280
226
  }
281
227
  }
282
228
  else {
283
- payload.dependencies.set(name, current);
229
+ dependencies.set(name, current);
284
230
  }
285
231
 
286
232
  if (proceedDependencyAnalysis) {
287
- logger.tick("walkTree");
233
+ logger.tick(ScannerLoggerEvents.analysis.tree);
234
+
235
+ // There is no need to fetch 'N' times the npm metadata for the same package.
236
+ if (fetchedMetadataPackages.has(name)) {
237
+ logger.tick(ScannerLoggerEvents.analysis.registry);
238
+ }
239
+ else {
240
+ fetchedMetadataPackages.add(name);
241
+ promisesToWait.push(packageMetadata(name, version, {
242
+ ref: current,
243
+ logger
244
+ }));
245
+ }
288
246
 
289
- promisesToWait.push(fetchPackageMetadata(name, version, {
290
- ref: current,
291
- locker: metadataLocker,
292
- logger
293
- }));
294
247
  promisesToWait.push(scanDirOrArchive(name, version, {
295
- ref: current[version],
248
+ ref: current.versions[version],
296
249
  tmpLocation: forceRootAnalysis && name === manifest.name ? null : tmpLocation,
297
250
  locker: tarballLocker,
298
251
  logger
@@ -300,17 +253,17 @@ export async function depWalker(manifest, options = {}, logger = new Logger()) {
300
253
  }
301
254
  }
302
255
 
303
- logger.end("walkTree");
256
+ logger.end(ScannerLoggerEvents.analysis.tree);
304
257
 
305
258
  // Wait for all extraction to be done!
306
259
  await Promise.allSettled(promisesToWait);
307
260
  await timers.setImmediate();
308
261
 
309
- logger.end("tarball").end("registry");
262
+ logger.end(ScannerLoggerEvents.analysis.tarball).end(ScannerLoggerEvents.analysis.registry);
310
263
  }
311
264
 
312
265
  const { hydratePayloadDependencies, strategy } = await vuln.setStrategy(vulnerabilityStrategy);
313
- await hydratePayloadDependencies(payload.dependencies, {
266
+ await hydratePayloadDependencies(dependencies, {
314
267
  useStandardFormat: true
315
268
  });
316
269
 
@@ -318,8 +271,10 @@ export async function depWalker(manifest, options = {}, logger = new Logger()) {
318
271
 
319
272
  // We do this because it "seem" impossible to link all dependencies in the first walk.
320
273
  // Because we are dealing with package only one time it may happen sometimes.
321
- for (const [packageName, descriptor] of payload.dependencies) {
322
- for (const verStr of descriptor.versions) {
274
+ for (const [packageName, dependency] of dependencies) {
275
+ for (const [verStr, verDescriptor] of Object.entries(dependency.versions)) {
276
+ verDescriptor.flags.push(...addMissingVersionFlags(new Set(verDescriptor.flags), dependency));
277
+
323
278
  const fullName = `${packageName}@${verStr}`;
324
279
  const usedDeps = exclude.get(fullName) || new Set();
325
280
  if (usedDeps.size === 0) {
@@ -330,18 +285,20 @@ export async function depWalker(manifest, options = {}, logger = new Logger()) {
330
285
  for (const [name, version] of [...usedDeps].map((name) => name.split(" "))) {
331
286
  usedBy[name] = version;
332
287
  }
333
- Object.assign(descriptor[verStr].usedBy, usedBy);
288
+ Object.assign(verDescriptor.usedBy, usedBy);
334
289
  }
335
290
  }
336
291
 
337
292
  try {
338
- payload.warnings = getDependenciesWarnings(payload.dependencies);
339
- payload.dependencies = Object.fromEntries(payload.dependencies);
293
+ payload.warnings = getDependenciesWarnings(dependencies);
294
+ payload.dependencies = Object.fromEntries(dependencies);
340
295
 
341
296
  return payload;
342
297
  }
343
298
  finally {
344
299
  await timers.setImmediate();
345
300
  await fs.rm(tmpLocation, { recursive: true, force: true });
301
+
302
+ logger.emit(ScannerLoggerEvents.done);
346
303
  }
347
304
  }
@@ -0,0 +1,57 @@
1
+ // Import Node.js Dependencies
2
+ import fs from "fs/promises";
3
+ import path from "path";
4
+
5
+ // Import Third-party Dependencies
6
+ import { parseManifestAuthor } from "@nodesecure/utils";
7
+
8
+ // CONSTANTS
9
+ // PR welcome to contribute to this list!
10
+ const kNativeNpmPackages = new Set([
11
+ "node-gyp", "node-pre-gyp", "node-gyp-build", "node-addon-api"
12
+ ]);
13
+
14
+ /**
15
+ * @see https://www.nerdycode.com/prevent-npm-executing-scripts-security/
16
+ */
17
+ const kUnsafeNpmScripts = new Set([
18
+ "install",
19
+ "preinstall", "postinstall",
20
+ "preuninstall", "postuninstall"
21
+ ]);
22
+
23
+ /**
24
+ * @param {!string} location
25
+ * @returns {import("@npm/types").PackageJson}
26
+ */
27
+ export async function read(location) {
28
+ const packageStr = await fs.readFile(
29
+ path.join(location, "package.json"),
30
+ "utf-8"
31
+ );
32
+
33
+ return JSON.parse(packageStr);
34
+ }
35
+
36
+ // TODO: PR @npm/types to fix dependencies typo
37
+ export async function readAnalyze(location) {
38
+ const {
39
+ description = "", author = {}, scripts = {},
40
+ dependencies = {}, devDependencies = {}, gypfile = false
41
+ } = await read(location);
42
+
43
+ const packageDeps = Object.keys(dependencies);
44
+ const packageDevDeps = Object.keys(devDependencies);
45
+ const hasNativePackage = [...packageDevDeps, ...packageDeps]
46
+ .some((pkg) => kNativeNpmPackages.has(pkg));
47
+
48
+ return {
49
+ author: typeof author === "string" ? parseManifestAuthor(author) : author,
50
+ description,
51
+ hasScript: Object.keys(scripts)
52
+ .some((value) => kUnsafeNpmScripts.has(value.toLowerCase())),
53
+ packageDeps,
54
+ packageDevDeps,
55
+ hasNativeElements: hasNativePackage || gypfile
56
+ };
57
+ }
@@ -0,0 +1,68 @@
1
+ // Import Third-party Dependencies
2
+ import semver from "semver";
3
+ import { parseManifestAuthor } from "@nodesecure/utils";
4
+ import { packument } from "@nodesecure/npm-registry-sdk";
5
+
6
+ export function parseAuthor(author) {
7
+ return typeof author === "string" ? parseManifestAuthor(author) : author;
8
+ }
9
+
10
+ export async function packageMetadata(name, version, options) {
11
+ const { ref, logger } = options;
12
+
13
+ try {
14
+ const pkg = await packument(name);
15
+
16
+ const oneYearFromToday = new Date();
17
+ oneYearFromToday.setFullYear(oneYearFromToday.getFullYear() - 1);
18
+
19
+ const lastVersion = pkg["dist-tags"].latest;
20
+ const lastUpdateAt = new Date(pkg.time[lastVersion]);
21
+ const metadata = {
22
+ author: parseAuthor(pkg.author) ?? {},
23
+ homepage: pkg.homepage || null,
24
+ publishedCount: Object.values(pkg.versions).length,
25
+ lastVersion,
26
+ lastUpdateAt,
27
+ hasReceivedUpdateInOneYear: !(oneYearFromToday > lastUpdateAt),
28
+ maintainers: pkg.maintainers,
29
+ publishers: []
30
+ };
31
+
32
+ const isOutdated = semver.neq(version, lastVersion);
33
+ if (isOutdated) {
34
+ ref.versions[version].flags.push("isOutdated");
35
+ }
36
+
37
+ const publishers = new Set();
38
+ for (const ver of Object.values(pkg.versions).reverse()) {
39
+ const { _npmUser: npmUser, version } = ver;
40
+ const isNullOrUndefined = typeof npmUser === "undefined" || npmUser === null;
41
+ if (isNullOrUndefined || !("name" in npmUser) || typeof npmUser.name !== "string") {
42
+ continue;
43
+ }
44
+
45
+ const authorName = metadata.author?.name ?? null;
46
+ if (authorName === null) {
47
+ metadata.author = npmUser;
48
+ }
49
+ else if (npmUser.name !== metadata.author.name) {
50
+ metadata.hasManyPublishers = true;
51
+ }
52
+
53
+ // TODO: add npmUser.email
54
+ if (!publishers.has(npmUser.name)) {
55
+ publishers.add(npmUser.name);
56
+ metadata.publishers.push({ ...npmUser, version, at: new Date(pkg.time[version]) });
57
+ }
58
+ }
59
+
60
+ Object.assign(ref.metadata, metadata);
61
+ }
62
+ catch {
63
+ // ignore
64
+ }
65
+ finally {
66
+ logger.tick("registry");
67
+ }
68
+ }