Repository: lirantal/lockfile-lint (js)

Issue:
Does not support NPM lockfile version 3
Lockfiles created using the NPM (9.x) that ships with the latest LTS of Node (18.x) use lockfile version 3. Unfortunately, this tool does not support these lockfiles. I suspect all the features do not work, but the options I have tested and confirmed do not work are `--validate-https` and `--validate-integrity`.

Pull request:
Feat/support lockfile v3
<!--- Provide a general summary of your changes in the Title above -->

## Description

Fixes #168 to add support for npm's lockfile v3

<!--- Describe your changes in detail -->

## Types of changes

Adding support for parsing the `packages` field (used to be `dependencies` for v2 and v1 of the lockfile format)

<!--- What types of changes does your code introduce? Put an `x` in all the boxes that apply: -->

- [ ] Bug fix (non-breaking change which fixes an issue)
- [x] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to change)

## Related Issue

#168 


Patch:
diff --git a/.changeset/rich-monkeys-fry.md b/.changeset/rich-monkeys-fry.md
new file mode 100644
index 0000000..5853b59
--- /dev/null
+++ b/.changeset/rich-monkeys-fry.md
@@ -0,0 +1,5 @@
+---
+'lockfile-lint-api': minor
+---
+
+Add support for npm lockfile v3 format
diff --git a/package-lock.json b/package-lock.json
index d823a4b..cc9f3b5 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -27,7 +27,7 @@
     },
     "node_modules/@ampproject/remapping": {
       "version": "2.2.1",
-      "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.2.1.tgz",
+      "resolved": "http://registry.npmjs.org/@ampproject/remapping/-/remapping-2.2.1.tgz",
       "integrity": "sha512-lFMjJTrFL3j7L9yBxwYfCq2k6qqwHyzuUl/XBnif78PWTJYyL/dfowQHWE3sp6U6ZzqWiiIZnpTMO96zhkjwtg==",
       "dev": true,
       "dependencies": {
@@ -40,8 +40,8 @@
     },
     "node_modules/@babel/code-frame": {
       "version": "7.22.5",
-      "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.22.5.tgz",
-      "integrity": "sha512-Xmwn266vad+6DAqEB2A6V/CcZVp62BbwVmcOJc2RPuwih1kw02TjQvWVWlcKGbBPd+8/0V5DEkOcizRGYsspYQ==",
+      "resolved": "http://registry.npmjs.org/@babel/code-frame/-/code-frame-7.22.5.tgz",
+      "integrity": "",
       "dependencies": {
         "@babel/highlight": "^7.22.5"
       },
diff --git a/packages/lockfile-lint-api/src/ParseLockfile.js b/packages/lockfile-lint-api/src/ParseLockfile.js
index 7b82d7f..9c9d8fe 100644
--- a/packages/lockfile-lint-api/src/ParseLockfile.js
+++ b/packages/lockfile-lint-api/src/ParseLockfile.js
@@ -27,10 +27,10 @@ function checkSampleContent (lockfile, isYarnBerry) {
   const [sampleKey, sampleValue] = Object.entries(lockfile)[isYarnBerry ? 1 : 0]
   return (
     sampleKey.match(/.*@.*/) &&
-    (sampleValue &&
-      typeof sampleValue === 'object' &&
-      sampleValue.hasOwnProperty('version') &&
-      (sampleValue.hasOwnProperty('resolved') || sampleValue.hasOwnProperty('resolution')))
+    sampleValue &&
+    typeof sampleValue === 'object' &&
+    sampleValue.hasOwnProperty('version') &&
+    (sampleValue.hasOwnProperty('resolved') || sampleValue.hasOwnProperty('resolution'))
   )
 }
 /**
@@ -174,7 +174,20 @@ class ParseLockfile {
 
       // transform original format of npm's package-json to match yarns
       // so we have a unified format to validate against
-      const npmDepsTree = packageJsonParsed.dependencies
+      // const npmDepsTree = packageJsonParsed.dependencies
+      let npmDepsTree = null
+
+      if (
+        packageJsonParsed.dependencies &&
+        Object.keys(packageJsonParsed.dependencies).length > 0
+      ) {
+        npmDepsTree = packageJsonParsed.dependencies
+      }
+
+      if (packageJsonParsed.packages && Object.keys(packageJsonParsed.packages).length > 0) {
+        npmDepsTree = packageJsonParsed.packages
+      }
+
       flattenedDepTree = npmDepsTree ? this._flattenNpmDepsTree(npmDepsTree) : {}
     } catch (error) {
       throw new ParsingError(PARSE_NPMLOCKFILE_FAILED, this.options.lockfilePath, error)
@@ -188,25 +201,61 @@ class ParseLockfile {
 
   _flattenNpmDepsTree (npmDepsTree, npmDepMap = {}) {
     for (const [depName, depMetadata] of Object.entries(npmDepsTree)) {
-      const depMetadataShortend = {
-        version: depMetadata.version,
-        resolved: depMetadata.resolved ? depMetadata.resolved : depMetadata.version,
-        integrity: depMetadata.integrity,
-        requires: depMetadata.requires
-      }
-      const hashedDepValues = hash(depMetadataShortend)
+      // only evaluate dependency metadata if it's an object with actual metadata
+      // @TODO potentially, this entry can be just a dependency name and version
+      // which would inject a new dependency on npm install - warn based on diff?
+      if (typeof depMetadata === 'object' && depName.length > 0) {
+        const depMetadataShortend = {
+          version: depMetadata.version,
+          resolved: depMetadata.resolved ? depMetadata.resolved : depMetadata.version,
+          integrity: depMetadata.integrity,
+          requires: depMetadata.requires
+        }
+        const hashedDepValues = hash(depMetadataShortend)
 
-      npmDepMap[`${depName}@${depMetadata.version}-${hashedDepValues}`] = depMetadataShortend
+        // @TODO should we implement a clean package name
+        // or stay aligned with npm's lockfile reporting of full package path on disk?
+        // it has advantages in monorepos, such as reporting something like:
+        //   packages/lockfile-lint/node_modules/yargs-parser
+        // instead of just
+        //   yargs-parser
+        //
+        // npm package-lock.json v3 has depName set to path on disk, i.e:
+        // "node_modules/@babel/compat-data": {
+        //  "version": "7.22.5",
+        //  ..}
+        // we strip off the 'node_modules/' suffix to print pretty package name
+        // let depNameClean = depName
+        // if (depName.indexOf('node_modules/') === 0) {
+        //   depNameClean = depName.substring('node_modules/'.length)
+        // }
+        const depNameClean = this.extractedPackageName(depName)
 
-      const nestedDepsTree = depMetadata.dependencies
+        npmDepMap[`${depNameClean}@${depMetadata.version}-${hashedDepValues}`] = depMetadataShortend
 
-      if (nestedDepsTree && Object.keys(nestedDepsTree).length !== 0) {
-        this._flattenNpmDepsTree(nestedDepsTree, npmDepMap)
+        const nestedDepsTree = depMetadata.dependencies
+
+        if (nestedDepsTree && Object.keys(nestedDepsTree).length !== 0) {
+          this._flattenNpmDepsTree(nestedDepsTree, npmDepMap)
+        }
       }
     }
 
     return npmDepMap
   }
+
+  extractedPackageName (packageName) {
+    const parts = packageName.split('/')
+    const lastIndex = parts.lastIndexOf('node_modules')
+
+    if (lastIndex === -1) {
+      // If "node_modules" is not found, return the last part of the input
+      return parts[parts.length - 1]
+    } else {
+      // If "node_modules" is found, return the part after it
+      return parts.slice(lastIndex + 1).join('/')
+    }
+  }
 }
 
 module.exports = ParseLockfile


Test patch:
diff --git a/packages/lockfile-lint-api/__tests__/__fixtures__/bad-package-lock-v3.json b/packages/lockfile-lint-api/__tests__/__fixtures__/bad-package-lock-v3.json
new file mode 100644
index 0000000..1097ccc
--- /dev/null
+++ b/packages/lockfile-lint-api/__tests__/__fixtures__/bad-package-lock-v3.json
@@ -0,0 +1,23 @@
+{
+    "name": "a",
+    "version": "1.0.0",
+    "lockfileVersion": 3,
+    "requires": true,
+    "packages2": {
+      "": {
+        "name": "a",
+        "version": "1.0.0",
+        "license": "ISC",
+        "devDependencies": {
+          "debug": "^4.3.4"
+        }
+      },
+      "node_modules/debug": {
+        "version": "4.3.4",
+        "resolved2": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
+        "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==",
+        "dev": true
+    }
+  
+}
+  
\ No newline at end of file
diff --git a/packages/lockfile-lint-api/__tests__/__fixtures__/package-lock-v3-empty.json b/packages/lockfile-lint-api/__tests__/__fixtures__/package-lock-v3-empty.json
new file mode 100644
index 0000000..7728695
--- /dev/null
+++ b/packages/lockfile-lint-api/__tests__/__fixtures__/package-lock-v3-empty.json
@@ -0,0 +1,17 @@
+{
+    "name": "a",
+    "version": "1.0.0",
+    "lockfileVersion": 3,
+    "requires": true,
+    "packages": {
+      "": {
+        "name": "a",
+        "version": "1.0.0",
+        "license": "ISC",
+        "devDependencies": {
+          "debug": "^4.3.4"
+        }
+      }
+  }
+}
+  
\ No newline at end of file
diff --git a/packages/lockfile-lint-api/__tests__/__fixtures__/package-lock-v3.json b/packages/lockfile-lint-api/__tests__/__fixtures__/package-lock-v3.json
new file mode 100644
index 0000000..7d2f3d1
--- /dev/null
+++ b/packages/lockfile-lint-api/__tests__/__fixtures__/package-lock-v3.json
@@ -0,0 +1,84 @@
+{
+    "name": "a",
+    "version": "1.0.0",
+    "lockfileVersion": 3,
+    "requires": true,
+    "packages": {
+      "": {
+        "name": "a",
+        "version": "1.0.0",
+        "license": "ISC",
+        "devDependencies": {
+          "debug": "^4.3.4"
+        }
+      },
+      "node_modules/debug": {
+        "version": "4.3.4",
+        "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
+        "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==",
+        "dev": true,
+        "dependencies": {
+          "ms": "2.1.2"
+        },
+        "engines": {
+          "node": ">=6.0"
+        },
+        "peerDependenciesMeta": {
+          "supports-color": {
+            "optional": true
+          }
+        }
+      },
+      "node_modules/ms": {
+        "version": "2.1.2",
+        "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
+        "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
+        "dev": true
+      },
+      "node_modules/@ampproject/remapping": {
+        "version": "2.2.1",
+        "resolved": "http://registry.npmjs.org/@ampproject/remapping/-/remapping-2.2.1.tgz",
+        "integrity": "sha512-lFMjJTrFL3j7L9yBxwYfCq2k6qqwHyzuUl/XBnif78PWTJYyL/dfowQHWE3sp6U6ZzqWiiIZnpTMO96zhkjwtg==",
+        "dev": true,
+        "dependencies": {
+          "@jridgewell/gen-mapping": "^0.3.0",
+          "@jridgewell/trace-mapping": "^0.3.9"
+        },
+        "engines": {
+          "node": ">=6.0.0"
+        }
+      },
+      "node_modules/@babel/core/node_modules/debug": {
+        "version": "4.3.4",
+        "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
+        "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==",
+        "dev": true,
+        "dependencies": {
+          "ms": "2.1.2"
+        },
+        "engines": {
+          "node": ">=6.0"
+        },
+        "peerDependenciesMeta": {
+          "supports-color": {
+            "optional": true
+          }
+        }
+      },
+      "packages/lockfile-lint/node_modules/argparse": {
+        "version": "2.0.1",
+        "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
+        "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="
+      },
+      "packages/lockfile-lint/node_modules/cliui": {
+        "version": "7.0.4",
+        "license": "ISC",
+        "dependencies": {
+          "string-width": "^4.2.0",
+          "strip-ansi": "^6.0.0",
+          "wrap-ansi": "^7.0.0"
+        }
+      }
+    }
+  }
+  
\ No newline at end of file
diff --git a/packages/lockfile-lint-api/__tests__/parseNpmLockfile3.test.js b/packages/lockfile-lint-api/__tests__/parseNpmLockfile3.test.js
new file mode 100644
index 0000000..28f6f48
--- /dev/null
+++ b/packages/lockfile-lint-api/__tests__/parseNpmLockfile3.test.js
@@ -0,0 +1,53 @@
+/* eslint-disable no-new */
+'use strict'
+
+const ParseLockfile = require('../src/ParseLockfile')
+const path = require('path')
+
+describe('ParseLockfile v3 Npm', () => {
+  it('parsing an npm lockfile returns an object with packages', () => {
+    const mockNpmLockfilePath = path.join(__dirname, './__fixtures__/package-lock-v3.json')
+    const options = {
+      lockfilePath: mockNpmLockfilePath,
+      lockfileType: 'npm'
+    }
+    const parser = new ParseLockfile(options)
+    const lockfile = parser.parseSync()
+
+    expect(lockfile.type).toEqual('success')
+    expect(lockfile.object).toEqual(
+      expect.objectContaining({
+        '@ampproject/remapping@2.2.1-6896846cb1525fe356a0a09c28e387a034d364ab': expect.any(Object),
+        'argparse@2.0.1-d71b6b43bc0d034f7b86d6d1149d6a43be13f2ed': expect.any(Object),
+        'cliui@7.0.4-a26e875b912c887d1429fe2e16ed66bcbe362d61': expect.any(Object),
+        'debug@4.3.4-c15d73814bfc58727435c213e23203e48f036cd9': expect.any(Object),
+        'ms@2.1.2-d6934ce87f6e568c4f5d6d9f6e5c5697992e7b91': expect.any(Object)
+      })
+    )
+  })
+
+  it('parsing an npm lockfile with invalid content throws an error', () => {
+    const mockNpmLockfilePath = path.join(__dirname, './__fixtures__/bad-package-lock-v3.json')
+    const options = {
+      lockfilePath: mockNpmLockfilePath,
+      lockfileType: 'npm'
+    }
+    const parser = new ParseLockfile(options)
+    expect(() => parser.parseSync()).toThrowError(
+      `Unable to parse npm lockfile "${mockNpmLockfilePath}"`
+    )
+  })
+
+  it('parsing an npm lockfile with no packages doesnt trigger an error', () => {
+    const mockNpmLockfilePath = path.join(__dirname, './__fixtures__/package-lock-v3-empty.json')
+    const options = {
+      lockfilePath: mockNpmLockfilePath,
+      lockfileType: 'npm'
+    }
+    const parser = new ParseLockfile(options)
+    const lockfile = parser.parseSync()
+
+    expect(lockfile.type).toEqual('success')
+    expect(lockfile.object).toEqual({})
+  })
+})