diff --git a/.circleci/config.yml b/.circleci/config.yml index ad1420d..3e0bc95 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1,7 +1,8 @@ version: 2.1 orbs: - prodsec: snyk/prodsec-orb@1 + win: circleci/windows@2.4.0 + prodsec: snyk/prodsec-orb@1.0 filters_branches_ignore_main: &filters_branches_ignore_main filters: @@ -9,14 +10,126 @@ filters_branches_ignore_main: &filters_branches_ignore_main ignore: - main +defaults: &defaults + parameters: + node_version: + type: string + default: "18.19.1" + working_directory: ~/snyk-nodejs-plugin + +windows_defaults: &windows_defaults + environment: + npm_config_loglevel: silent + executor: + name: win/default + +test_matrix: &test_matrix + node_version: ['14.17.6', '16.13.2', '18.19.1'] + +commands: + install_deps: + description: Install dependencies + steps: + - checkout + - run: + name: Use snyk-main npmjs user + command: echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" >> .npmrc + - run: + name: Install dependencies + command: npm install + install_node_npm: + description: Install correct Node version + parameters: + node_version: + type: string + default: "" + steps: + - run: + name: Install correct version of Node + command: nvm install << parameters.node_version >> + - run: + name: Use correct version of Node + command: nvm use << parameters.node_version >> + show_node_version: + description: Log Node and npm version + steps: + - run: + name: Node version + command: node --version + - run: + name: NPM version + command: npm --version + +jobs: + lint: + <<: *defaults + docker: + - image: cimg/node:<< parameters.node_version >> + steps: + - checkout + - install_deps + - show_node_version + - run: + name: Run lint + command: npm run lint + + test-windows: + <<: *defaults + <<: *windows_defaults + steps: + - run: git config --global core.autocrlf false + - checkout + - install_node_npm: + node_version: << parameters.node_version >> + - install_deps + - show_node_version + - run: + name: Run tests + command: npm test + + test-unix: + <<: *defaults + docker: + - image: cimg/node:<< parameters.node_version >> + steps: + - checkout + - install_deps + - show_node_version + - run: + name: Run tests + command: npm test + workflows: version: 2 test_and_release: jobs: - prodsec/secrets-scan: name: Scan repository for secrets - trusted-branch: main context: - snyk-bot-slack channel: os-team-managed-alerts + - lint: + name: Lint + context: nodejs-install + node_version: "lts" + - test-windows: + matrix: + alias: test-windows + parameters: + <<: *test_matrix + name: Windows Tests for Node=<< matrix.node_version >> + context: nodejs-install + requires: + - Lint <<: *filters_branches_ignore_main + - test-unix: + matrix: + alias: test-unix + parameters: + <<: *test_matrix + name: Unix Tests for Node=<< matrix.node_version >> + context: nodejs-install + requires: + - Lint + <<: *filters_branches_ignore_main + diff --git a/.eslintrc b/.eslintrc new file mode 100644 index 0000000..4a83ab4 --- /dev/null +++ b/.eslintrc @@ -0,0 +1,29 @@ +{ + "root": true, + "parser": "@typescript-eslint/parser", + "plugins": [ + "@typescript-eslint" + ], + "extends": [ + "eslint:recommended", + "plugin:@typescript-eslint/recommended" + ], + "rules": { + "@typescript-eslint/no-explicit-any": "off", + "require-await": "warn", + "camelcase": "error", + "default-case": "error", + "default-case-last": "error", + "no-constant-binary-expression": "error", + "no-duplicate-imports": "error", + "no-else-return": "error", + "no-invalid-this": "error", + "no-template-curly-in-string": "error", + "no-use-before-define": "error", + "no-var": "error", + "prefer-const": "error", + "require-atomic-updates": "error", + "spaced-comment": "error", + "yoda": "error" + } + } \ No newline at end of file diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md new file mode 100644 index 0000000..c59bdab --- /dev/null +++ b/.github/CONTRIBUTING.md @@ -0,0 +1,48 @@ +# Contributing + +## Commit messages + +Commit messages must follow the [Angular-style](https://github.com/angular/angular.js/blob/master/CONTRIBUTING.md#commit-message-format) commit format (but excluding the scope). + +i.e: + +```text +fix: minified scripts being removed + +Also includes tests +``` + +This will allow for the automatic changelog to generate correctly. + +### Commit types + +Must be one of the following: + +- **feat**: A new feature +- **fix**: A bug fix +- **docs**: Documentation only changes +- **test**: Adding missing tests +- **chore**: Changes to the build process or auxiliary tools and libraries such as documentation generation +- **refactor**: A code change that neither fixes a bug nor adds a feature +- **style**: Changes that do not affect the meaning of the code (white-space, formatting, missing semi-colons, etc) +- **perf**: A code change that improves performance + +To release a major you need to add `BREAKING CHANGE: ` to the start of the body and the detail of the breaking change. + +## Code standards + +Ensure that your code adheres to the included `.eslintrc` config by running `npm run lint`. + +## Sending pull requests + +- add tests for newly added code (and try to mirror directory and file structure if possible) +- spell check +- PRs will not be code reviewed unless all tests are passing (run `npm test`) + +_Important:_ when fixing a bug, please commit a **failing test** first demonstrate the current code is failing. Once that commit is in place, then commit the bug fix, so that we can test _before_ and _after_. + +Remember that you're developing for multiple platforms and versions of node, so if the tests pass on your Mac or Linux or Windows machine, it _may_ not pass elsewhere. + +## Contributor Agreement + +A pull-request will only be considered for merging into the upstream codebase after you have signed our [contributor agreement](https://github.com/snyk/snyk-nuget-plugin/blob/main/Contributor-Agreement.md), assigning us the rights to the contributed code and granting you a license to use it in return. If you submit a pull request, you will be prompted to review and sign the agreement with one click (we use [CLA assistant](https://cla-assistant.io/)). diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..d28780d --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,22 @@ +- [ ] Tests written and linted +- [ ] Documentation written / README.md updated [https://snyk.io/docs/snyk-for-node/](i) +- [ ] Follows [CONTRIBUTING agreement](CONTRIBUTING.md) +- [ ] Commit history is tidy [https://git-scm.com/book/en/v2/Git-Branching-Rebasing](i) +- [ ] Reviewed by Snyk team + +### What this does + +_Explain why this PR exists_ + +### Notes for the reviewer + +_Instructions on how to run this locally, background context, what to review, questions…_ + +### More information + +- [SC-XXXX]() +- [Link to documentation]() + +### Screenshots + +_Visuals that may help the reviewer_ diff --git a/.github/workflows/pr-housekeeping.yml b/.github/workflows/pr-housekeeping.yml new file mode 100644 index 0000000..d243d1c --- /dev/null +++ b/.github/workflows/pr-housekeeping.yml @@ -0,0 +1,13 @@ +on: + schedule: + - cron: "0 0 * * *" # Every day at midnight + workflow_dispatch: + +jobs: + stale: + runs-on: ubuntu-latest + steps: + - uses: actions/stale@v7 + with: + stale-pr-message: "Your PR has not had any activity for 60 days. In 7 days I'll close it. Make some activity to remove this." + close-pr-message: "Your PR has now been stale for 7 days. I'm closing it." diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8f79cf1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +.DS_Store + +dist +node_modules +./package-lock.json + +.eslintcache + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json +coverage/ + +# IDEs +.idea +.vscode +.ionide/ \ No newline at end of file diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..706a4ec --- /dev/null +++ b/.npmignore @@ -0,0 +1,7 @@ +.github +.jscsrc +.travis.yml +.vscode +.idea +/dev-test.js +/test \ No newline at end of file diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..9cf9495 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +package-lock=false \ No newline at end of file diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..25bf17f --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +18 \ No newline at end of file diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..a1624f6 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,21 @@ +{ + "singleQuote": true, + "trailingComma": "all", + "overrides": [ + { + "files": "*.csproj", + "options": { + "parser": "html" + } + }, + { + "files": "*.json", + "options": { + "printWidth": 40, + "parser": "json", + "bracketSpacing": true, + "trailingComma": "none" + } + } + ] + } \ No newline at end of file diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..b646748 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,15 @@ +{ + "singleQuote": true, + "trailingComma": "all", + "overrides": [ + { + "files": "*.json", + "options": { + "printWidth": 40, + "parser": "json", + "bracketSpacing": true, + "trailingComma": "none" + } + } + ] +} \ No newline at end of file diff --git a/.releaserc.json b/.releaserc.json new file mode 100644 index 0000000..e7e9662 --- /dev/null +++ b/.releaserc.json @@ -0,0 +1,5 @@ +{ + "branches": [ + "main" + ] + } \ No newline at end of file diff --git a/Contributor-Agreement.md b/Contributor-Agreement.md new file mode 100644 index 0000000..f5d9759 --- /dev/null +++ b/Contributor-Agreement.md @@ -0,0 +1,42 @@ +# Snyk CLI tool contributor agreement + +This Snyk CLI tool Agreement (this **"Agreement"**) applies to any Contribution you make to any Work. + +This is a binding legal agreement on you and any organization you represent. If you are signing this Agreement on behalf of your employer or other organization, you represent and warrant that you have the authority to agree to this Agreement on behalf of the organization. + +## 1. Definitions + +**"Contribution"** means any original work, including any modification of or addition to an existing work, that you submit to Snyk CLI tool repo in any manner for inclusion in any Work. + +**"Snyk", "we"** and **"us"** means Snyk Ltd. + +**"Work"** means any project, work or materials owned or managed by Snyk Ltd. + +**"You"** and **"your"** means you and any organization on whose behalf you are entering this Agreement. + +## 2. Copyright Assignment, License and Waiver + +**(a) Assignment.** By submitting a Contribution, you assign to Snyk all right, title and interest in any copright you have in the Contribution, and you waive any rights, including any moral rights, database rights, etc., that may affect your ownership of the copyright in the Contribution. + +**(b) License to Snyk.** If your assignment in Section 2(a) is ineffective for any reason, you grant to us and to any recipient of any Work distributed by use, a perpetual, worldwide, transferable, non-exclusive, no-charge, royalty-free, irrevocable, and sublicensable licence to use, reproduce, prepare derivative works of, publicly display, publicly perform, sublicense, and distribute Contributions and any derivative work created based on a Contribution. If your license grant is ineffective for any reason, you irrevocably waive and covenant to not assert any claim you may have against us, our successors in interest, and any of our direct or indirect licensees and customers, arising out of our, our successors in interest's, or any of our direct or indirect licensees' or customers' use, reproduction, preparation of derivative works, public display, public performance, sublicense, and distribution of a Contribution. You also agree that we may publicly use your name and the name of any organization on whose behalf you're entering into this Agreement in connection with publicizing the Work. + +**(c) License to you.** We grant to you a perpetual, worldwide, transferable, non-exclusive, no-charge, royalty-free, irrevocable, and sublicensable license to use, reproduce, prepare derivative works of, publicly display, publicly perform, sublicense, and distribute a Contribution and any derivative works you create based on a Contribution. + +## 3. Patent License + +You grant to us and to any recipient of any Work distributed by us, a perpetual, worldwide, transferable, non-exclusive, no-charge, royalty-free, irrevocable, and sublicensable patent license to make, have made, use, sell, offer to sell, import, and otherwise transfer the Contribution in whole or in part, along or included in any Work under any patent you own, or license from a third party, that is necessarily infringed by the Contribution or by combination of the Contribution with any Work. + +## 4. Your Representation and Warranties. + +By submitting a Contribution, you represent and warrant that: (a) each Contribution you submit is an original work and you can legally grant the rights set out in this Agreement; (b) the Contribution does not, and any exercise of the rights granted by you will not, infringe any third party's intellectual property or other right; and (c) you are not aware of any claims, suits, or actions pertaining to the Contribution. You will notify us immediately if you become aware or have reason to believe that any of your representations and warranties is or becomes inaccurate. + +##5. Intellectual Property +Except for the assignment and licenses set forth in this Agreement, this Agreement does not transfer any right, title or interest in any intellectual property right of either party to the other. If you choose to provide us with suggestions, ideas for improvement, recommendations or other feedback, on any Work we may use your feedback without any restriction or payment. + +## Miscellaneous + +English law governs this Agreement, excluding any applicable conflict of laws rules or principles, and the parties agree to the exclusive jurisdiction of the courts in England, UK. This Agreement does not create a partnership, agency relationship, or joint venture between the parties. We may assign this Agreement without notice or restriction. If any provision of this Agreement is unenforcable, that provision will be modified to render it enforceable to the extent possible to effect the parties' intention and the remaining provisions will not be affected. The parties may amend this Agreement only in a written amendment signed by both parties. This Agreement comprises the parties' entire agreement relating to the subject matter of this Agreement. + +**Agreed and accepted on my behalf and on behalf of my organization** + +Our contributor agreement is based on the [mongoDB contributor agreement] (https://www.mongodb.com/legal/contributor-agreement). diff --git a/LICENSE b/LICENSE index 261eeb9..698e2bf 100644 --- a/LICENSE +++ b/LICENSE @@ -1,201 +1,13 @@ - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ +Copyright 2018 Snyk Ltd. - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at - 1. Definitions. + http://www.apache.org/licenses/LICENSE-2.0 - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright [yyyy] [name of copyright owner] - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/README.md b/README.md index a044e75..98c5371 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,43 @@ -# snyk-nodejs-plugin -Basic Snyk CLI plugin for NodeJS support +![Snyk logo](https://snyk.io/style/asset/logo/snyk-print.svg) + +--- + +[![Known Vulnerabilities](https://snyk.io/test/github/snyk/snyk-nodejs-plugin/badge.svg)](https://snyk.io/test/github/snyk/snyk-nodejs-plugin) + +Snyk helps you find, fix and monitor for known vulnerabilities in your dependencies, both on an ad hoc basis and as part of your CI (Build) system. + +| :information_source: This repository is only a plugin to be used with the Snyk CLI tool. To use this plugin to test and fix vulnerabilities in your project, install the Snyk CLI tool first. Head over to [snyk.io](https://github.com/snyk/snyk) to get started. | +| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | + +## Snyk Node.js Plugin + +This plugin provides dependency metadata for npm and Yarn projects and workspaces. It is an internal component intended for use by our [CLI tool](https://github.com/snyk/snyk). + +# Support + +❌ Not supported +❓ No issues expected but not regularly tested +✅ Supported and verified with tests + +## Supported OS + +| OS | Supported | +| ------- | --------- | +| Windows | ✅ | +| Linux | ✅ | +| OSX | ️✅ | + +## Supported Package Managers + +| Pkg Manager | Supported | +| ----------- | --------- | +| npm | ✅ | +| Yarn | ✅ | + +## Supported Node versions + +| Node | Supported | +| ---- | --------- | +| 14 | ✅ | +| 16 | ✅ | +| 18 | ✅ | diff --git a/jest.config.json b/jest.config.json new file mode 100644 index 0000000..ad1a8fa --- /dev/null +++ b/jest.config.json @@ -0,0 +1,11 @@ +{ + "testEnvironment": "node", + "transform": { + "^.+\\.(ts)?$": "ts-jest" + }, + "testMatch": ["**/*.spec.ts"], + "collectCoverage": false, + "moduleFileExtensions": ["ts", "js", "json"], + "forceExit": true, + "testTimeout": 80000 +} diff --git a/lib/errors.ts b/lib/errors.ts new file mode 100644 index 0000000..56da241 --- /dev/null +++ b/lib/errors.ts @@ -0,0 +1,44 @@ +export class CustomError extends Error { + public innerError; + public code: number | undefined; + public userMessage: string | undefined; + public strCode: string | undefined; + + constructor(message: string) { + super(message); + Error.captureStackTrace(this, this.constructor); + this.name = this.constructor.name; + this.code = undefined; + this.strCode = undefined; + this.innerError = undefined; + this.userMessage = undefined; + } +} + +export function MissingTargetFileError(path: string) { + const errorMsg = + `Not a recognised option did you mean --file=${path}? ` + + 'Check other options by running snyk --help'; + + const error = new CustomError(errorMsg); + error.code = 422; + error.userMessage = errorMsg; + return error; +} + +export function NoSupportedManifestsFoundError( + atLocations: string[], +): CustomError { + const locationsStr = atLocations.join(', '); + const errorMsg = + 'Could not detect supported target files in ' + + locationsStr + + '.\nPlease see our documentation for supported languages and ' + + 'target files: https://snyk.co/udVgQ' + + ' and make sure you are in the right directory.'; + + const error = new CustomError(errorMsg); + error.code = 422; + error.userMessage = errorMsg; + return error; +} diff --git a/lib/index.ts b/lib/index.ts new file mode 100644 index 0000000..277d584 --- /dev/null +++ b/lib/index.ts @@ -0,0 +1,12 @@ +import { inspect } from './inspect'; +import { + processNpmWorkspaces, + processPnpmWorkspaces, + processYarnWorkspaces, +} from './workspaces'; +export { + inspect, + processNpmWorkspaces, + processPnpmWorkspaces, + processYarnWorkspaces, +}; diff --git a/lib/inspect.ts b/lib/inspect.ts new file mode 100644 index 0000000..a7c32b5 --- /dev/null +++ b/lib/inspect.ts @@ -0,0 +1,45 @@ +import * as modulesParser from './npm-modules-parser'; +import * as lockParser from './lock-parser'; +import * as types from './types'; +import { MissingTargetFileError } from './errors'; +import { MultiProjectResult } from '@snyk/cli-interface/legacy/plugin'; +import { DepGraph } from '@snyk/dep-graph'; +import { PkgTree } from 'snyk-nodejs-lockfile-parser'; + +export async function inspect( + root: string, + targetFile: string, + options: types.Options = {}, +): Promise { + if (!targetFile) { + throw MissingTargetFileError(root); + } + const isLockFileBased = + targetFile.endsWith('package-lock.json') || + targetFile.endsWith('yarn.lock') || + targetFile.endsWith('pnpm-lock.yaml'); + + const getLockFileDeps = isLockFileBased && !options.traverseNodeModules; + const depRes: PkgTree | DepGraph = getLockFileDeps + ? await lockParser.parse(root, targetFile, options) + : await modulesParser.parse(root, targetFile, options); + + let scannedProjects: any[] = []; + if (isResDepGraph(depRes)) { + scannedProjects = [{ depGraph: depRes }]; + } else { + scannedProjects = [{ depTree: depRes }]; + } + + return { + plugin: { + name: 'snyk-nodejs-lockfile-parser', + runtime: process.version, + }, + scannedProjects, + }; +} + +function isResDepGraph(depRes: PkgTree | DepGraph): depRes is DepGraph { + return 'rootPkg' in depRes; +} diff --git a/lib/lock-parser/build-dep-graph.ts b/lib/lock-parser/build-dep-graph.ts new file mode 100644 index 0000000..6024f2e --- /dev/null +++ b/lib/lock-parser/build-dep-graph.ts @@ -0,0 +1,82 @@ +import * as path from 'path'; +import * as fs from 'fs'; +import * as lockFileParser from 'snyk-nodejs-lockfile-parser'; +import { + NodeLockfileVersion, + InvalidUserInputError, + ProjectParseOptions, +} from 'snyk-nodejs-lockfile-parser'; +import { DepGraph } from '@snyk/dep-graph'; + +export async function buildDepGraph( + root: string, + manifestFilePath: string, + lockfilePath: string, + lockfileVersion: NodeLockfileVersion, + options: ProjectParseOptions, +): Promise { + const manifestFileFullPath = path.resolve(root, manifestFilePath); + const lockFileFullPath = path.resolve(root, lockfilePath); + + if (!fs.existsSync(manifestFileFullPath)) { + throw new InvalidUserInputError( + 'Target file package.json not found at ' + + `location: ${manifestFileFullPath}`, + ); + } + if (!fs.existsSync(lockFileFullPath)) { + throw new InvalidUserInputError( + 'Lockfile not found at location: ' + lockFileFullPath, + ); + } + + const manifestFileContents = fs.readFileSync(manifestFileFullPath, 'utf-8'); + const lockFileContents = fs.readFileSync(lockFileFullPath, 'utf-8'); + + switch (lockfileVersion) { + case NodeLockfileVersion.PnpmLockV5: + case NodeLockfileVersion.PnpmLockV6: + return await lockFileParser.parsePnpmProject( + manifestFileContents, + lockFileContents, + { + includeDevDeps: options.includeDevDeps, + includeOptionalDeps: options.includeOptionalDeps, + pruneWithinTopLevelDeps: true, + strictOutOfSync: options.strictOutOfSync, + }, + lockfileVersion, + ); + case NodeLockfileVersion.YarnLockV1: + return await lockFileParser.parseYarnLockV1Project( + manifestFileContents, + lockFileContents, + { + includeDevDeps: options.includeDevDeps, + includeOptionalDeps: options.includeOptionalDeps, + includePeerDeps: options.includePeerDeps || false, + pruneLevel: 'withinTopLevelDeps', + strictOutOfSync: options.strictOutOfSync, + }, + ); + case NodeLockfileVersion.YarnLockV2: + return await lockFileParser.parseYarnLockV2Project( + manifestFileContents, + lockFileContents, + { + includeDevDeps: options.includeDevDeps, + includeOptionalDeps: options.includeOptionalDeps, + pruneWithinTopLevelDeps: true, + strictOutOfSync: options.strictOutOfSync, + }, + ); + case NodeLockfileVersion.NpmLockV2: + case NodeLockfileVersion.NpmLockV3: + return await lockFileParser.parseNpmLockV2Project( + manifestFileContents, + lockFileContents, + options, + ); + } + throw new Error('Failed to build dep graph from current project'); +} diff --git a/lib/lock-parser/index.ts b/lib/lock-parser/index.ts new file mode 100644 index 0000000..eab8b78 --- /dev/null +++ b/lib/lock-parser/index.ts @@ -0,0 +1,73 @@ +import * as path from 'path'; +import * as fs from 'fs'; +import { buildDepGraph } from './build-dep-graph'; +import * as lockFileParser from 'snyk-nodejs-lockfile-parser'; +import { NodeLockfileVersion, PkgTree } from 'snyk-nodejs-lockfile-parser'; +import { Options } from '../types'; +import { DepGraph } from '@snyk/dep-graph'; + +export async function parse( + root: string, + targetFile: string, + options: Options, +): Promise { + const lockFileFullPath = path.resolve(root, targetFile); + if (!fs.existsSync(lockFileFullPath)) { + throw new Error( + 'Lockfile ' + targetFile + ' not found at location: ' + lockFileFullPath, + ); + } + + const fullPath = path.parse(lockFileFullPath); + const manifestFileFullPath = path.resolve(fullPath.dir, 'package.json'); + const shrinkwrapFullPath = path.resolve(fullPath.dir, 'npm-shrinkwrap.json'); + + if (!fs.existsSync(manifestFileFullPath)) { + throw new Error( + `Could not find package.json at ${manifestFileFullPath} ` + + `(lockfile found at ${targetFile})`, + ); + } + + if (fs.existsSync(shrinkwrapFullPath)) { + throw new Error( + 'Both `npm-shrinkwrap.json` and `package-lock.json` were found in ' + + fullPath.dir + + '.\n' + + 'Please run your command again specifying `--file=package.json` flag.', + ); + } + + const strictOutOfSync = options.strictOutOfSync !== false; + const lockfileVersion = + lockFileParser.getLockfileVersionFromFile(lockFileFullPath); + if ( + lockfileVersion === NodeLockfileVersion.YarnLockV1 || + lockfileVersion === NodeLockfileVersion.YarnLockV2 || + lockfileVersion === NodeLockfileVersion.NpmLockV2 || + lockfileVersion === NodeLockfileVersion.NpmLockV3 || + lockfileVersion === NodeLockfileVersion.PnpmLockV5 || + lockfileVersion === NodeLockfileVersion.PnpmLockV6 + ) { + return await buildDepGraph( + root, + manifestFileFullPath, + lockFileFullPath, + lockfileVersion, + { + includeDevDeps: options.dev || false, + includeOptionalDeps: true, + strictOutOfSync, + pruneCycles: true, + }, + ); + } + + return lockFileParser.buildDepTreeFromFiles( + root, + manifestFileFullPath, + lockFileFullPath, + options.dev, + strictOutOfSync, + ); +} diff --git a/lib/npm-lock-parser.ts b/lib/npm-lock-parser.ts new file mode 100644 index 0000000..cb80610 --- /dev/null +++ b/lib/npm-lock-parser.ts @@ -0,0 +1,141 @@ +import * as baseDebug from 'debug'; +const debug = baseDebug('snyk-test'); +import * as path from 'path'; +import * as fs from 'fs'; +import * as lockFileParser from 'snyk-nodejs-lockfile-parser'; +import { + NodeLockfileVersion, + PkgTree, + InvalidUserInputError, + ProjectParseOptions, +} from 'snyk-nodejs-lockfile-parser'; +import { Options } from './types'; +import { DepGraph } from '@snyk/dep-graph'; + +async function buildDepGraph( + root: string, + manifestFilePath: string, + lockfilePath: string, + lockfileVersion: NodeLockfileVersion, + options: ProjectParseOptions, +): Promise { + const manifestFileFullPath = path.resolve(root, manifestFilePath); + const lockFileFullPath = path.resolve(root, lockfilePath); + + if (!fs.existsSync(manifestFileFullPath)) { + throw new InvalidUserInputError( + 'Target file package.json not found at ' + + `location: ${manifestFileFullPath}`, + ); + } + if (!fs.existsSync(lockFileFullPath)) { + throw new InvalidUserInputError( + 'Lockfile not found at location: ' + lockFileFullPath, + ); + } + + const manifestFileContents = fs.readFileSync(manifestFileFullPath, 'utf-8'); + const lockFileContents = fs.readFileSync(lockFileFullPath, 'utf-8'); + + switch (lockfileVersion) { + case NodeLockfileVersion.YarnLockV1: + return await lockFileParser.parseYarnLockV1Project( + manifestFileContents, + lockFileContents, + { + includeDevDeps: options.includeDevDeps, + includeOptionalDeps: options.includeOptionalDeps, + includePeerDeps: options.includePeerDeps || false, + pruneLevel: 'withinTopLevelDeps', + strictOutOfSync: options.strictOutOfSync, + }, + ); + case NodeLockfileVersion.YarnLockV2: + return await lockFileParser.parseYarnLockV2Project( + manifestFileContents, + lockFileContents, + { + includeDevDeps: options.includeDevDeps, + includeOptionalDeps: options.includeOptionalDeps, + pruneWithinTopLevelDeps: true, + strictOutOfSync: options.strictOutOfSync, + }, + ); + case NodeLockfileVersion.NpmLockV2: + case NodeLockfileVersion.NpmLockV3: + return await lockFileParser.parseNpmLockV2Project( + manifestFileContents, + lockFileContents, + options, + ); + default: + throw new Error('Failed to build dep graph from current project'); + } +} + +export async function parse( + root: string, + targetFile: string, + options: Options, +): Promise { + const lockFileFullPath = path.resolve(root, targetFile); + if (!fs.existsSync(lockFileFullPath)) { + throw new Error( + 'Lockfile ' + targetFile + ' not found at location: ' + lockFileFullPath, + ); + } + + const fullPath = path.parse(lockFileFullPath); + const manifestFileFullPath = path.resolve(fullPath.dir, 'package.json'); + const shrinkwrapFullPath = path.resolve(fullPath.dir, 'npm-shrinkwrap.json'); + + if (!fs.existsSync(manifestFileFullPath)) { + throw new Error( + `Could not find package.json at ${manifestFileFullPath} ` + + `(lockfile found at ${targetFile})`, + ); + } + + if (fs.existsSync(shrinkwrapFullPath)) { + throw new Error( + 'Both `npm-shrinkwrap.json` and `package-lock.json` were found in ' + + fullPath.dir + + '.\n' + + 'Please run your command again specifying `--file=package.json` flag.', + ); + } + + const resolveModuleSpinnerLabel = `Analyzing npm dependencies for ${lockFileFullPath}`; + debug(resolveModuleSpinnerLabel); + + const strictOutOfSync = options.strictOutOfSync !== false; + const lockfileVersion = + lockFileParser.getLockfileVersionFromFile(lockFileFullPath); + if ( + lockfileVersion === NodeLockfileVersion.YarnLockV1 || + lockfileVersion === NodeLockfileVersion.YarnLockV2 || + lockfileVersion === NodeLockfileVersion.NpmLockV2 || + lockfileVersion === NodeLockfileVersion.NpmLockV3 + ) { + return await buildDepGraph( + root, + manifestFileFullPath, + lockFileFullPath, + lockfileVersion, + { + includeDevDeps: options.dev || false, + includeOptionalDeps: true, + strictOutOfSync, + pruneCycles: true, + }, + ); + } + + return lockFileParser.buildDepTreeFromFiles( + root, + manifestFileFullPath, + lockFileFullPath, + options.dev, + strictOutOfSync, + ); +} diff --git a/lib/npm-modules-parser.ts b/lib/npm-modules-parser.ts new file mode 100644 index 0000000..8e47cd1 --- /dev/null +++ b/lib/npm-modules-parser.ts @@ -0,0 +1,75 @@ +import * as path from 'path'; +import * as fs from 'fs'; +import * as resolveNodeDeps from 'snyk-resolve-deps'; +import * as baseDebug from 'debug'; +const isEmpty = require('lodash.isempty'); +import { Options } from './types'; +import { getFileContents } from './utils'; + +const debug = baseDebug('snyk-nodejs-plugin'); + +export async function parse( + root: string, + targetFile: string, + options: Options, +): Promise { + if (targetFile.endsWith('yarn.lock')) { + options.file = + options.file && options.file.replace('yarn.lock', 'package.json'); + } + + if (targetFile.endsWith('pnpm-lock.yaml')) { + options.file = + options.file && options.file.replace('pnpm-lock.yaml', 'package.json'); + } + + // package-lock.json falls back to package.json (used in wizard code) + if (targetFile.endsWith('package-lock.json')) { + options.file = + options.file && options.file.replace('package-lock.json', 'package.json'); + } + // check if there any dependencies + const packageJsonFileName = path.resolve(root, options.file!); + const packageManager = options.packageManager || 'npm'; + + try { + const packageJson = JSON.parse( + getFileContents(root, packageJsonFileName).content, + ); + + let dependencies = packageJson.dependencies; + if (options.dev) { + dependencies = { ...dependencies, ...packageJson.devDependencies }; + } + if (isEmpty(dependencies)) { + return new Promise((resolve) => + resolve({ + name: packageJson.name || 'package.json', + dependencies: {}, + version: packageJson.version, + }), + ); + } + } catch (e: any) { + debug(`Failed to read ${packageJsonFileName}: Error: ${e}`); + throw new Error( + `Failed to read ${packageJsonFileName}. Error: ${e.message}`, + ); + } + const nodeModulesPath = path.join( + path.dirname(path.resolve(root, targetFile)), + 'node_modules', + ); + + if (!fs.existsSync(nodeModulesPath)) { + // throw a custom error + throw new Error( + "Missing node_modules folder: we can't test " + + `without dependencies.\nPlease run '${packageManager} install' first.`, + ); + } + return resolveNodeDeps( + root, + Object.assign({}, options, { noFromArrays: true }), + ); +} diff --git a/lib/npm-workspaces-parser.ts b/lib/npm-workspaces-parser.ts new file mode 100644 index 0000000..e206c2f --- /dev/null +++ b/lib/npm-workspaces-parser.ts @@ -0,0 +1,177 @@ +import * as baseDebug from 'debug'; +import * as pathUtil from 'path'; +import { sortBy } from 'lodash.sortby'; +import { groupBy } from 'lodash.groupby'; +import * as micromatch from 'micromatch'; + +const debug = baseDebug('snyk-npm-workspaces'); +import * as lockFileParser from 'snyk-nodejs-lockfile-parser'; +import { MultiProjectResultCustom, ScannedProjectCustom } from './types'; +import { getFileContents } from './utils'; +import { NoSupportedManifestsFoundError } from './errors'; + +interface NpmWorkspacesMap { + [packageJsonName: string]: { + workspaces: string[]; + }; +} + +export function getWorkspacesMap(file: { + content: string; + fileName: string; +}): NpmWorkspacesMap { + const workspacesMap = {}; + if (!file) { + return workspacesMap; + } + + try { + const rootFileWorkspacesDefinitions = + JSON.parse(file.content).workspaces || false; + if (rootFileWorkspacesDefinitions && rootFileWorkspacesDefinitions.length) { + workspacesMap[file.fileName] = { + workspaces: rootFileWorkspacesDefinitions, + }; + } + } catch (e: any) { + debug('Failed to process a workspace', e.message); + } + return workspacesMap; +} + +export function packageJsonBelongsToWorkspace( + packageJsonFileName: string, + workspacesMap: NpmWorkspacesMap, + workspaceRoot: string, +): boolean { + const workspaceRootFolder = pathUtil.dirname( + workspaceRoot.replace(/\\/g, '/'), + ); + const workspacesGlobs = (workspacesMap[workspaceRoot].workspaces || []).map( + (workspace) => pathUtil.join(workspaceRootFolder, workspace), + ); + + const match = micromatch.isMatch( + packageJsonFileName.replace(/\\/g, '/'), + workspacesGlobs.map((p) => + pathUtil.normalize(pathUtil.join(p, 'package.json')).replace(/\\/g, '/'), + ), + ); + return match; +} + +export async function processNpmWorkspaces( + root: string, + settings: { + strictOutOfSync?: boolean; + dev?: boolean; + yarnWorkspaces?: boolean; + }, + targetFiles: string[], +): Promise { + // the order of npmTargetFiles folders is important + // must have the root level most folders at the top + const mappedAndFiltered = targetFiles + .map((p) => ({ path: p, ...pathUtil.parse(p) })) + .filter((res) => ['package.json', 'package-lock.json'].includes(res.base)); + const sorted = sortBy(mappedAndFiltered, 'dir'); + const grouped = groupBy(sorted, 'dir'); + + const npmTargetFiles: { + [dir: string]: Array<{ + path: string; + base: string; + dir: string; + }>; + } = grouped; + + debug(`Processing potential Npm workspaces (${targetFiles.length})`); + if (settings.yarnWorkspaces && Object.keys(npmTargetFiles).length === 0) { + throw NoSupportedManifestsFoundError([root]); + } + let npmWorkspacesMap = {}; + const workspacesFilesMap = {}; + const result: MultiProjectResultCustom = { + plugin: { + name: 'snyk-nodejs-npm-workspaces', + runtime: process.version, + }, + scannedProjects: [], + }; + // the folders must be ordered highest first + for (const directory of Object.keys(npmTargetFiles)) { + debug(`Processing ${directory} as a potential npm workspace`); + let isWorkspacePackage = false; + let isRootPackageJson = false; + const packageJsonFileName = pathUtil.join(directory, 'package.json'); + const packageJson = getFileContents(root, packageJsonFileName); + npmWorkspacesMap = { + ...npmWorkspacesMap, + ...getWorkspacesMap(packageJson), + }; + for (const workspaceRoot of Object.keys(npmWorkspacesMap)) { + const match = packageJsonBelongsToWorkspace( + packageJsonFileName, + npmWorkspacesMap, + workspaceRoot, + ); + if (match) { + debug(`${packageJsonFileName} matches an existing workspace pattern`); + workspacesFilesMap[packageJsonFileName] = { + root: workspaceRoot, + }; + isWorkspacePackage = true; + } + if (packageJsonFileName === workspaceRoot) { + isRootPackageJson = true; + } + } + + if (!(isWorkspacePackage || isRootPackageJson)) { + debug( + `${packageJsonFileName} is not part of any detected workspace, skipping`, + ); + continue; + } + try { + const rootDir = isWorkspacePackage + ? pathUtil.dirname(workspacesFilesMap[packageJsonFileName].root) + : pathUtil.dirname(packageJsonFileName); + const rootLockfileName = pathUtil.join(rootDir, 'package-lock.json'); + const lockContent = getFileContents(root, rootLockfileName); + + const res = await lockFileParser.parseNpmLockV2Project( + packageJson.content, + lockContent.content, + { + includeDevDeps: settings.dev || false, + strictOutOfSync: settings.strictOutOfSync || false, + includeOptionalDeps: false, + pruneCycles: true, + }, + ); + + const project: ScannedProjectCustom = { + packageManager: 'npm', + targetFile: pathUtil.relative(root, packageJson.fileName), + depGraph: res as any, + plugin: { + name: 'snyk-nodejs-lockfile-parser', + runtime: process.version, + }, + }; + result.scannedProjects.push(project); + } catch (e) { + if (settings.yarnWorkspaces) { + throw e; + } + debug(`Error process workspace: ${packageJsonFileName}. ERROR: ${e}`); + } + } + if (!result.scannedProjects.length) { + debug( + `No npm workspaces detected in any of the ${targetFiles.length} target files.`, + ); + } + return result; +} diff --git a/lib/types.ts b/lib/types.ts new file mode 100644 index 0000000..567a45c --- /dev/null +++ b/lib/types.ts @@ -0,0 +1,39 @@ +import * as cliInterface from '@snyk/cli-interface'; +import { PluginMetadata } from '@snyk/cli-interface/legacy/plugin'; +import { CallGraph } from '@snyk/cli-interface/legacy/common'; + +export type SupportedPackageManagers = 'npm' | 'yarn' | 'pnpm'; + +export interface Options { + file?: string; + docker?: boolean; + traverseNodeModules?: boolean; + dev?: boolean; + strictOutOfSync?: boolean; + allSubProjects?: boolean; + debug?: boolean; + packageManager?: string; + composerIsFine?: boolean; + composerPharIsFine?: boolean; + systemVersions?: object; + scanAllUnmanaged?: boolean; +} + +export interface ScannedProjectCustom + extends cliInterface.legacyCommon.ScannedProject { + packageManager: SupportedPackageManagers; + plugin: PluginMetadata; + callGraph?: CallGraph; +} + +interface FailedProjectScanError { + targetFile?: string; + error?: Error; + errMessage: string; +} + +export interface MultiProjectResultCustom + extends cliInterface.legacyPlugin.MultiProjectResult { + scannedProjects: ScannedProjectCustom[]; + failedResults?: FailedProjectScanError[]; +} diff --git a/lib/utils.ts b/lib/utils.ts new file mode 100644 index 0000000..1c9c164 --- /dev/null +++ b/lib/utils.ts @@ -0,0 +1,27 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +export function getFileContents( + root: string, + fileName: string, +): { + content: string; + fileName: string; +} { + const fullPath = path.resolve(root, fileName); + if (!fs.existsSync(fullPath)) { + throw new Error( + 'Manifest ' + fileName + ' not found at location: ' + fileName, + ); + } + const content = fs.readFileSync(fullPath, 'utf-8'); + return { + content, + fileName, + }; +} + +export function fileExists(root: string, fileName: string): boolean { + const fullPath = path.resolve(root, fileName); + return fs.existsSync(fullPath); +} diff --git a/lib/workspaces/index.ts b/lib/workspaces/index.ts new file mode 100644 index 0000000..d879bd1 --- /dev/null +++ b/lib/workspaces/index.ts @@ -0,0 +1,4 @@ +import { processNpmWorkspaces } from './npm-workspaces-parser'; +import { processPnpmWorkspaces } from './pnpm-workspaces-parser'; +import { processYarnWorkspaces } from './yarn-workspaces-parser'; +export { processNpmWorkspaces, processPnpmWorkspaces, processYarnWorkspaces }; diff --git a/lib/workspaces/npm-workspaces-parser.ts b/lib/workspaces/npm-workspaces-parser.ts new file mode 100644 index 0000000..ccd8e2b --- /dev/null +++ b/lib/workspaces/npm-workspaces-parser.ts @@ -0,0 +1,176 @@ +import * as baseDebug from 'debug'; +import * as pathUtil from 'path'; +const sortBy = require('lodash.sortby'); +const groupBy = require('lodash.groupby'); +import * as micromatch from 'micromatch'; + +const debug = baseDebug('snyk-npm-workspaces'); +import * as lockFileParser from 'snyk-nodejs-lockfile-parser'; +import { MultiProjectResultCustom, ScannedProjectCustom } from '../types'; +import { getFileContents } from '../utils'; +import { NoSupportedManifestsFoundError } from '../errors'; + +export async function processNpmWorkspaces( + root: string, + settings: { + strictOutOfSync?: boolean; + dev?: boolean; + yarnWorkspaces?: boolean; + }, + targetFiles: string[], +): Promise { + // the order of npmTargetFiles folders is important + // must have the root level most folders at the top + const mappedAndFiltered = targetFiles + .map((p) => ({ path: p, ...pathUtil.parse(p) })) + .filter((res) => ['package.json', 'package-lock.json'].includes(res.base)); + const sorted = sortBy(mappedAndFiltered, 'dir'); + + const npmTargetFiles: { + [dir: string]: Array<{ + path: string; + base: string; + dir: string; + }>; + } = groupBy(sorted, 'dir'); + + debug(`Processing potential Npm workspaces (${targetFiles.length})`); + if (settings.yarnWorkspaces && Object.keys(npmTargetFiles).length === 0) { + throw NoSupportedManifestsFoundError([root]); + } + let npmWorkspacesMap = {}; + const workspacesFilesMap = {}; + const result: MultiProjectResultCustom = { + plugin: { + name: 'snyk-nodejs-npm-workspaces', + runtime: process.version, + }, + scannedProjects: [], + }; + // the folders must be ordered highest first + for (const directory of Object.keys(npmTargetFiles)) { + debug(`Processing ${directory} as a potential npm workspace`); + let isWorkspacePackage = false; + let isRootPackageJson = false; + const packageJsonFileName = pathUtil.join(directory, 'package.json'); + const packageJson = getFileContents(root, packageJsonFileName); + npmWorkspacesMap = { + ...npmWorkspacesMap, + ...getWorkspacesMap(packageJson), + }; + for (const workspaceRoot of Object.keys(npmWorkspacesMap)) { + const match = packageJsonBelongsToWorkspace( + packageJsonFileName, + npmWorkspacesMap, + workspaceRoot, + ); + if (match) { + debug(`${packageJsonFileName} matches an existing workspace pattern`); + workspacesFilesMap[packageJsonFileName] = { + root: workspaceRoot, + }; + isWorkspacePackage = true; + } + if (packageJsonFileName === workspaceRoot) { + isRootPackageJson = true; + } + } + + if (!(isWorkspacePackage || isRootPackageJson)) { + debug( + `${packageJsonFileName} is not part of any detected workspace, skipping`, + ); + continue; + } + try { + const rootDir = isWorkspacePackage + ? pathUtil.dirname(workspacesFilesMap[packageJsonFileName].root) + : pathUtil.dirname(packageJsonFileName); + const rootLockfileName = pathUtil.join(rootDir, 'package-lock.json'); + const lockContent = getFileContents(root, rootLockfileName); + + const res = await lockFileParser.parseNpmLockV2Project( + packageJson.content, + lockContent.content, + { + includeDevDeps: settings.dev || false, + strictOutOfSync: settings.strictOutOfSync || false, + includeOptionalDeps: false, + pruneCycles: true, + }, + ); + + const project: ScannedProjectCustom = { + packageManager: 'npm', + targetFile: pathUtil.relative(root, packageJson.fileName), + depGraph: res as any, + plugin: { + name: 'snyk-nodejs-lockfile-parser', + runtime: process.version, + }, + }; + result.scannedProjects.push(project); + } catch (e) { + if (settings.yarnWorkspaces) { + throw e; + } + debug(`Error process workspace: ${packageJsonFileName}. ERROR: ${e}`); + } + } + if (!result.scannedProjects.length) { + debug( + `No npm workspaces detected in any of the ${targetFiles.length} target files.`, + ); + } + return result; +} + +interface NpmWorkspacesMap { + [packageJsonName: string]: { + workspaces: string[]; + }; +} + +export function getWorkspacesMap(file: { + content: string; + fileName: string; +}): NpmWorkspacesMap { + const workspacesMap = {}; + if (!file) { + return workspacesMap; + } + + try { + const rootFileWorkspacesDefinitions = + JSON.parse(file.content).workspaces || false; + if (rootFileWorkspacesDefinitions && rootFileWorkspacesDefinitions.length) { + workspacesMap[file.fileName] = { + workspaces: rootFileWorkspacesDefinitions, + }; + } + } catch (e: any) { + debug('Failed to process a workspace', e.message); + } + return workspacesMap; +} + +export function packageJsonBelongsToWorkspace( + packageJsonFileName: string, + workspacesMap: NpmWorkspacesMap, + workspaceRoot: string, +): boolean { + const workspaceRootFolder = pathUtil.dirname( + workspaceRoot.replace(/\\/g, '/'), + ); + const workspacesGlobs = (workspacesMap[workspaceRoot].workspaces || []).map( + (workspace) => pathUtil.join(workspaceRootFolder, workspace), + ); + + const match = micromatch.isMatch( + packageJsonFileName.replace(/\\/g, '/'), + workspacesGlobs.map((p) => + pathUtil.normalize(pathUtil.join(p, 'package.json')).replace(/\\/g, '/'), + ), + ); + return match; +} diff --git a/lib/workspaces/pnpm-workspaces-parser.ts b/lib/workspaces/pnpm-workspaces-parser.ts new file mode 100644 index 0000000..ecf59c5 --- /dev/null +++ b/lib/workspaces/pnpm-workspaces-parser.ts @@ -0,0 +1,241 @@ +import * as baseDebug from 'debug'; +import * as pathUtil from 'path'; +const sortBy = require('lodash.sortby'); +const groupBy = require('lodash.groupby'); +import * as micromatch from 'micromatch'; + +const debug = baseDebug('snyk-pnpm-workspaces'); +import * as lockFileParser from 'snyk-nodejs-lockfile-parser'; +import { MultiProjectResultCustom, ScannedProjectCustom } from '../types'; +import { fileExists, getFileContents } from '../utils'; +import { NoSupportedManifestsFoundError } from '../errors'; +import { DepGraph } from '@snyk/dep-graph'; + +const UNKNOWN_VERSION = '?'; + +export async function processPnpmWorkspaces( + root: string, + settings: { + strictOutOfSync?: boolean; + dev?: boolean; + pnpmWorkspaces?: boolean; + }, + targetFiles: string[], +): Promise { + // the order of pnpmTargetFiles folders is important + // must have the root level most folders at the top + const mappedAndFiltered = targetFiles + .map((p) => ({ path: p, ...pathUtil.parse(p) })) + .filter((res) => + ['pnpm-workspace.yaml', 'package.json', 'pnpm-lock.yaml'].includes( + res.base, + ), + ); + const sorted = sortBy(mappedAndFiltered, 'dir'); + + const pnpmTargetFiles: { + [dir: string]: Array<{ + path: string; + base: string; + dir: string; + }>; + } = groupBy(sorted, 'dir'); + + debug(`Processing potential Pnpm workspaces (${targetFiles.length})`); + if (settings.pnpmWorkspaces && Object.keys(pnpmTargetFiles).length === 0) { + // replace with error-catalog + throw NoSupportedManifestsFoundError([root]); + } + let pnpmWorkspacesMap = {}; + const pnpmWorkspacesFilesMap = {}; + const result: MultiProjectResultCustom = { + plugin: { + name: 'snyk-nodejs-pnpm-workspaces', + runtime: process.version, + }, + scannedProjects: [], + }; + + let rootWorkspaceManifestContent = {}; + let projectsVersionMap = {}; + + // Compute project versions map first + // This is needed because the lockfile doesn't present the version of + // a project that's part of a workspace, we need to retrieve it from + // its package.json + for (const directory of Object.keys(pnpmTargetFiles)) { + debug( + `Processing ${directory} as a potential Pnpm workspace. Building project version map.`, + ); + const packageJsonFileName = pathUtil.join(directory, 'package.json'); + const packageJson = getFileContents(root, packageJsonFileName); + + let projectVersion = UNKNOWN_VERSION; + try { + const parsedPkgJson = lockFileParser.parsePkgJson(packageJson.content); + projectVersion = parsedPkgJson.version + ? parsedPkgJson.version + : UNKNOWN_VERSION; + } catch (err: any) { + debug( + `Error getting version for project: ${packageJsonFileName}. ERROR: ${err}`, + ); + } + projectsVersionMap[pathUtil.normalize(directory)] = projectVersion; + } + + // the folders must be ordered highest first + for (const directory of Object.keys(pnpmTargetFiles)) { + debug(`Processing ${directory} as a potential Pnpm workspace`); + let isPnpmWorkspacePackage = false; + let isRootPackageJson = false; + const packageJsonFileName = pathUtil.join(directory, 'package.json'); + const packageJson = getFileContents(root, packageJsonFileName); + pnpmWorkspacesMap = { + ...pnpmWorkspacesMap, + ...getWorkspacesMap(root, directory, packageJson), + }; + + for (const workspaceRoot of Object.keys(pnpmWorkspacesMap)) { + const match = packageJsonBelongsToWorkspace( + packageJsonFileName, + pnpmWorkspacesMap, + workspaceRoot, + ); + if (match) { + debug(`${packageJsonFileName} matches an existing workspace pattern`); + pnpmWorkspacesFilesMap[packageJsonFileName] = { + root: workspaceRoot, + }; + isPnpmWorkspacePackage = true; + } + if (packageJsonFileName === workspaceRoot) { + isRootPackageJson = true; + rootWorkspaceManifestContent = JSON.parse(packageJson.content); + } + } + + if (!(isPnpmWorkspacePackage || isRootPackageJson)) { + debug( + `${packageJsonFileName} is not part of any detected workspace, skipping`, + ); + continue; + } + + try { + const rootDir = isPnpmWorkspacePackage + ? pathUtil.dirname(pnpmWorkspacesFilesMap[packageJsonFileName].root) + : pathUtil.dirname(packageJsonFileName); + const rootPnpmLockfileName = pathUtil.join(rootDir, 'pnpm-lock.yaml'); + const pnpmLock = getFileContents(root, rootPnpmLockfileName); + const lockfileVersion = lockFileParser.getPnpmLockfileVersion( + pnpmLock.content, + ); + + let res: DepGraph; + res = await lockFileParser.parsePnpmProject( + packageJson.content, + pnpmLock.content, + { + includeDevDeps: settings.dev || false, + includeOptionalDeps: false, + pruneWithinTopLevelDeps: true, + strictOutOfSync: + settings.strictOutOfSync === undefined + ? true + : settings.strictOutOfSync, + }, + lockfileVersion, + { + isWorkspacePkg: true, + workspacePath: pathUtil.normalize(directory), + isRoot: isRootPackageJson, + projectsVersionMap, + rootOverrides: rootWorkspaceManifestContent?.['pnpm.overrides'] || {}, + }, + ); + const project: ScannedProjectCustom = { + packageManager: 'pnpm', + targetFile: pathUtil.relative(root, packageJson.fileName), + depGraph: res as any, + plugin: { + name: 'snyk-nodejs-lockfile-parser', + runtime: process.version, + }, + }; + result.scannedProjects.push(project); + } catch (e) { + if (settings.pnpmWorkspaces) { + throw e; + } + debug(`Error process workspace: ${packageJsonFileName}. ERROR: ${e}`); + } + } + if (!result.scannedProjects.length) { + debug( + `No pnpm workspaces detected in any of the ${targetFiles.length} target files.`, + ); + } + return result; +} + +interface PnpmWorkspacesMap { + [packageJsonName: string]: { + workspaces: string[]; + }; +} + +export function getWorkspacesMap( + root: string, + directory: string, + packageJson: { + fileName: string; + content: string; + }, +): PnpmWorkspacesMap { + const pnpmWorkspacesMap = {}; + const pnpmWorkspacesPath = pathUtil.join(directory, 'pnpm-workspace.yaml'); + if (!fileExists(root, pnpmWorkspacesPath)) { + return pnpmWorkspacesMap; + } + const file = getFileContents(root, pnpmWorkspacesPath); + if (!file) { + return pnpmWorkspacesMap; + } + + try { + const rootFileWorkspacesDefinitions = lockFileParser.getPnpmWorkspaces( + file.content, + ); + + if (rootFileWorkspacesDefinitions && rootFileWorkspacesDefinitions.length) { + pnpmWorkspacesMap[packageJson.fileName] = { + workspaces: rootFileWorkspacesDefinitions, + }; + } + } catch (e: any) { + debug('Failed to process a workspace', e.message); + } + return pnpmWorkspacesMap; +} + +export function packageJsonBelongsToWorkspace( + packageJsonFileName: string, + pnpmWorkspacesMap: PnpmWorkspacesMap, + workspaceRoot: string, +): boolean { + const workspaceRootFolder = pathUtil.dirname( + workspaceRoot.replace(/\\/g, '/'), + ); + const workspacesGlobs = ( + pnpmWorkspacesMap[workspaceRoot].workspaces || [] + ).map((workspace) => pathUtil.join(workspaceRootFolder, workspace)); + + const match = micromatch.isMatch( + packageJsonFileName.replace(/\\/g, '/'), + workspacesGlobs.map((p) => + pathUtil.normalize(pathUtil.join(p, '**')).replace(/\\/g, '/'), + ), + ); + return match; +} diff --git a/lib/workspaces/yarn-workspaces-parser.ts b/lib/workspaces/yarn-workspaces-parser.ts new file mode 100644 index 0000000..13e13ae --- /dev/null +++ b/lib/workspaces/yarn-workspaces-parser.ts @@ -0,0 +1,218 @@ +import * as baseDebug from 'debug'; +import * as pathUtil from 'path'; +const sortBy = require('lodash.sortby'); +const groupBy = require('lodash.groupby'); +import * as micromatch from 'micromatch'; + +const debug = baseDebug('snyk-yarn-workspaces'); +import * as lockFileParser from 'snyk-nodejs-lockfile-parser'; +import { MultiProjectResultCustom, ScannedProjectCustom } from '../types'; +import { getFileContents } from '../utils'; +import { NoSupportedManifestsFoundError } from '../errors'; +import { DepGraph } from '@snyk/dep-graph'; + +export async function processYarnWorkspaces( + root: string, + settings: { + strictOutOfSync?: boolean; + dev?: boolean; + yarnWorkspaces?: boolean; + }, + targetFiles: string[], +): Promise { + // the order of yarnTargetFiles folders is important + // must have the root level most folders at the top + const mappedAndFiltered = targetFiles + .map((p) => ({ path: p, ...pathUtil.parse(p) })) + .filter((res) => ['package.json', 'yarn.lock'].includes(res.base)); + const sorted = sortBy(mappedAndFiltered, 'dir'); + + const yarnTargetFiles: { + [dir: string]: Array<{ + path: string; + base: string; + dir: string; + }>; + } = groupBy(sorted, 'dir'); + + debug(`Processing potential Yarn workspaces (${targetFiles.length})`); + if (settings.yarnWorkspaces && Object.keys(yarnTargetFiles).length === 0) { + throw NoSupportedManifestsFoundError([root]); + } + let yarnWorkspacesMap = {}; + const yarnWorkspacesFilesMap = {}; + const result: MultiProjectResultCustom = { + plugin: { + name: 'snyk-nodejs-yarn-workspaces', + runtime: process.version, + }, + scannedProjects: [], + }; + + let rootWorkspaceManifestContent = {}; + // the folders must be ordered highest first + for (const directory of Object.keys(yarnTargetFiles)) { + debug(`Processing ${directory} as a potential Yarn workspace`); + let isYarnWorkspacePackage = false; + let isRootPackageJson = false; + const packageJsonFileName = pathUtil.join(directory, 'package.json'); + const packageJson = getFileContents(root, packageJsonFileName); + yarnWorkspacesMap = { + ...yarnWorkspacesMap, + ...getWorkspacesMap(packageJson), + }; + + for (const workspaceRoot of Object.keys(yarnWorkspacesMap)) { + const match = packageJsonBelongsToWorkspace( + packageJsonFileName, + yarnWorkspacesMap, + workspaceRoot, + ); + if (match) { + debug(`${packageJsonFileName} matches an existing workspace pattern`); + yarnWorkspacesFilesMap[packageJsonFileName] = { + root: workspaceRoot, + }; + isYarnWorkspacePackage = true; + } + if (packageJsonFileName === workspaceRoot) { + isRootPackageJson = true; + rootWorkspaceManifestContent = JSON.parse(packageJson.content); + } + } + + if (!(isYarnWorkspacePackage || isRootPackageJson)) { + debug( + `${packageJsonFileName} is not part of any detected workspace, skipping`, + ); + continue; + } + + try { + const rootDir = isYarnWorkspacePackage + ? pathUtil.dirname(yarnWorkspacesFilesMap[packageJsonFileName].root) + : pathUtil.dirname(packageJsonFileName); + const rootYarnLockfileName = pathUtil.join(rootDir, 'yarn.lock'); + const yarnLock = getFileContents(root, rootYarnLockfileName); + const lockfileVersion = lockFileParser.getYarnLockfileVersion( + yarnLock.content, + ); + + let res: DepGraph; + switch (lockfileVersion) { + case lockFileParser.NodeLockfileVersion.YarnLockV1: + res = await lockFileParser.parseYarnLockV1Project( + packageJson.content, + yarnLock.content, + { + includeDevDeps: settings.dev || false, + includeOptionalDeps: false, + includePeerDeps: false, + pruneLevel: 'withinTopLevelDeps', + strictOutOfSync: + settings.strictOutOfSync === undefined + ? true + : settings.strictOutOfSync, + }, + ); + break; + case lockFileParser.NodeLockfileVersion.YarnLockV2: + res = await lockFileParser.parseYarnLockV2Project( + packageJson.content, + yarnLock.content, + { + includeDevDeps: settings.dev || false, + includeOptionalDeps: false, + pruneWithinTopLevelDeps: true, + strictOutOfSync: + settings.strictOutOfSync === undefined + ? true + : settings.strictOutOfSync, + }, + { + isWorkspacePkg: true, + isRoot: isRootPackageJson, + rootResolutions: + rootWorkspaceManifestContent?.['resolutions'] || {}, + }, + ); + break; + default: + throw new Error('Failed to build dep graph from current project'); + } + const project: ScannedProjectCustom = { + packageManager: 'yarn', + targetFile: pathUtil.relative(root, packageJson.fileName), + depGraph: res as any, + plugin: { + name: 'snyk-nodejs-lockfile-parser', + runtime: process.version, + }, + }; + result.scannedProjects.push(project); + } catch (e) { + if (settings.yarnWorkspaces) { + throw e; + } + debug(`Error process workspace: ${packageJsonFileName}. ERROR: ${e}`); + } + } + if (!result.scannedProjects.length) { + debug( + `No yarn workspaces detected in any of the ${targetFiles.length} target files.`, + ); + } + return result; +} + +interface YarnWorkspacesMap { + [packageJsonName: string]: { + workspaces: string[]; + }; +} + +export function getWorkspacesMap(file: { + content: string; + fileName: string; +}): YarnWorkspacesMap { + const yarnWorkspacesMap = {}; + if (!file) { + return yarnWorkspacesMap; + } + + try { + const rootFileWorkspacesDefinitions = lockFileParser.getYarnWorkspaces( + file.content, + ); + + if (rootFileWorkspacesDefinitions && rootFileWorkspacesDefinitions.length) { + yarnWorkspacesMap[file.fileName] = { + workspaces: rootFileWorkspacesDefinitions, + }; + } + } catch (e: any) { + debug('Failed to process a workspace', e.message); + } + return yarnWorkspacesMap; +} + +export function packageJsonBelongsToWorkspace( + packageJsonFileName: string, + yarnWorkspacesMap: YarnWorkspacesMap, + workspaceRoot: string, +): boolean { + const workspaceRootFolder = pathUtil.dirname( + workspaceRoot.replace(/\\/g, '/'), + ); + const workspacesGlobs = ( + yarnWorkspacesMap[workspaceRoot].workspaces || [] + ).map((workspace) => pathUtil.join(workspaceRootFolder, workspace)); + + const match = micromatch.isMatch( + packageJsonFileName.replace(/\\/g, '/'), + workspacesGlobs.map((p) => + pathUtil.normalize(pathUtil.join(p, '**')).replace(/\\/g, '/'), + ), + ); + return match; +} diff --git a/lib/yarn-workspaces-parser.ts b/lib/yarn-workspaces-parser.ts new file mode 100644 index 0000000..f20d8d1 --- /dev/null +++ b/lib/yarn-workspaces-parser.ts @@ -0,0 +1,219 @@ +import * as baseDebug from 'debug'; +import * as pathUtil from 'path'; +import { sortBy } from 'lodash.sortby'; +import { groupBy } from 'lodash.groupby'; +import * as micromatch from 'micromatch'; + +const debug = baseDebug('snyk-yarn-workspaces'); +import * as lockFileParser from 'snyk-nodejs-lockfile-parser'; +import { MultiProjectResultCustom, ScannedProjectCustom } from './types'; +import { getFileContents } from './utils'; +import { NoSupportedManifestsFoundError } from './errors'; +import { DepGraph } from '@snyk/dep-graph'; + +interface YarnWorkspacesMap { + [packageJsonName: string]: { + workspaces: string[]; + }; +} + +export function getWorkspacesMap(file: { + content: string; + fileName: string; +}): YarnWorkspacesMap { + const yarnWorkspacesMap = {}; + if (!file) { + return yarnWorkspacesMap; + } + + try { + const rootFileWorkspacesDefinitions = lockFileParser.getYarnWorkspaces( + file.content, + ); + + if (rootFileWorkspacesDefinitions && rootFileWorkspacesDefinitions.length) { + yarnWorkspacesMap[file.fileName] = { + workspaces: rootFileWorkspacesDefinitions, + }; + } + } catch (e: any) { + debug('Failed to process a workspace', e.message); + } + return yarnWorkspacesMap; +} + +export function packageJsonBelongsToWorkspace( + packageJsonFileName: string, + yarnWorkspacesMap: YarnWorkspacesMap, + workspaceRoot: string, +): boolean { + const workspaceRootFolder = pathUtil.dirname( + workspaceRoot.replace(/\\/g, '/'), + ); + const workspacesGlobs = ( + yarnWorkspacesMap[workspaceRoot].workspaces || [] + ).map((workspace) => pathUtil.join(workspaceRootFolder, workspace)); + + const match = micromatch.isMatch( + packageJsonFileName.replace(/\\/g, '/'), + workspacesGlobs.map((p) => + pathUtil.normalize(pathUtil.join(p, '**')).replace(/\\/g, '/'), + ), + ); + return match; +} + +export async function processYarnWorkspaces( + root: string, + settings: { + strictOutOfSync?: boolean; + dev?: boolean; + yarnWorkspaces?: boolean; + }, + targetFiles: string[], +): Promise { + // the order of yarnTargetFiles folders is important + // must have the root level most folders at the top + const mappedAndFiltered = targetFiles + .map((p) => ({ path: p, ...pathUtil.parse(p) })) + .filter((res) => ['package.json', 'yarn.lock'].includes(res.base)); + const sorted = sortBy(mappedAndFiltered, 'dir'); + const grouped = groupBy(sorted, 'dir'); + + const yarnTargetFiles: { + [dir: string]: Array<{ + path: string; + base: string; + dir: string; + }>; + } = grouped; + + debug(`Processing potential Yarn workspaces (${targetFiles.length})`); + if (settings.yarnWorkspaces && Object.keys(yarnTargetFiles).length === 0) { + throw NoSupportedManifestsFoundError([root]); + } + let yarnWorkspacesMap = {}; + const yarnWorkspacesFilesMap = {}; + const result: MultiProjectResultCustom = { + plugin: { + name: 'snyk-nodejs-yarn-workspaces', + runtime: process.version, + }, + scannedProjects: [], + }; + + let rootWorkspaceManifestContent = {}; + // the folders must be ordered highest first + for (const directory of Object.keys(yarnTargetFiles)) { + debug(`Processing ${directory} as a potential Yarn workspace`); + let isYarnWorkspacePackage = false; + let isRootPackageJson = false; + const packageJsonFileName = pathUtil.join(directory, 'package.json'); + const packageJson = getFileContents(root, packageJsonFileName); + yarnWorkspacesMap = { + ...yarnWorkspacesMap, + ...getWorkspacesMap(packageJson), + }; + + for (const workspaceRoot of Object.keys(yarnWorkspacesMap)) { + const match = packageJsonBelongsToWorkspace( + packageJsonFileName, + yarnWorkspacesMap, + workspaceRoot, + ); + if (match) { + debug(`${packageJsonFileName} matches an existing workspace pattern`); + yarnWorkspacesFilesMap[packageJsonFileName] = { + root: workspaceRoot, + }; + isYarnWorkspacePackage = true; + } + if (packageJsonFileName === workspaceRoot) { + isRootPackageJson = true; + rootWorkspaceManifestContent = JSON.parse(packageJson.content); + } + } + + if (!(isYarnWorkspacePackage || isRootPackageJson)) { + debug( + `${packageJsonFileName} is not part of any detected workspace, skipping`, + ); + continue; + } + + try { + const rootDir = isYarnWorkspacePackage + ? pathUtil.dirname(yarnWorkspacesFilesMap[packageJsonFileName].root) + : pathUtil.dirname(packageJsonFileName); + const rootYarnLockfileName = pathUtil.join(rootDir, 'yarn.lock'); + const yarnLock = getFileContents(root, rootYarnLockfileName); + const lockfileVersion = lockFileParser.getYarnLockfileVersion( + yarnLock.content, + ); + + let res: DepGraph; + switch (lockfileVersion) { + case lockFileParser.NodeLockfileVersion.YarnLockV1: + res = await lockFileParser.parseYarnLockV1Project( + packageJson.content, + yarnLock.content, + { + includeDevDeps: settings.dev || false, + includeOptionalDeps: false, + includePeerDeps: false, + pruneLevel: 'withinTopLevelDeps', + strictOutOfSync: + settings.strictOutOfSync === undefined + ? true + : settings.strictOutOfSync, + }, + ); + break; + case lockFileParser.NodeLockfileVersion.YarnLockV2: + res = await lockFileParser.parseYarnLockV2Project( + packageJson.content, + yarnLock.content, + { + includeDevDeps: settings.dev || false, + includeOptionalDeps: false, + pruneWithinTopLevelDeps: true, + strictOutOfSync: + settings.strictOutOfSync === undefined + ? true + : settings.strictOutOfSync, + }, + { + isWorkspacePkg: true, + isRoot: isRootPackageJson, + rootResolutions: + rootWorkspaceManifestContent?.['resolutions'] || {}, + }, + ); + break; + default: + throw new Error('Failed to build dep graph from current project'); + } + const project: ScannedProjectCustom = { + packageManager: 'yarn', + targetFile: pathUtil.relative(root, packageJson.fileName), + depGraph: res as any, + plugin: { + name: 'snyk-nodejs-lockfile-parser', + runtime: process.version, + }, + }; + result.scannedProjects.push(project); + } catch (e) { + if (settings.yarnWorkspaces) { + throw e; + } + debug(`Error process workspace: ${packageJsonFileName}. ERROR: ${e}`); + } + } + if (!result.scannedProjects.length) { + debug( + `No yarn workspaces detected in any of the ${targetFiles.length} target files.`, + ); + } + return result; +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..42154c7 --- /dev/null +++ b/package.json @@ -0,0 +1,59 @@ +{ + "name": "snyk-nodejs-plugin", + "description": "Snyk CLI NodeJS plugin", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "scripts": { + "test": "npm run test:unit", + "test:unit": "jest --coverage --runInBand", + "lint": "eslint --color --cache '{lib,test}/**/*.{js,ts}' && prettier --check '{lib,test}/**/*.{js,ts}'", + "format": "prettier --write '{lib,test}/**/*.{js,ts,json}'", + "build": "tsc", + "build:watch": "tsc -w", + "prepare": "npm run build" + }, + "repository": { + "type": "git", + "url": "https://github.com/snyk/snyk-nodejs-plugin.git" + }, + "keywords": [ + "snyk", + "nodejs" + ], + "author": "snyk.io", + "license": "Apache-2.0", + "engines": { + "node": "^18" + }, + "files": [ + "bin", + "dist" + ], + "bugs": { + "url": "https://github.com/snyk/snyk-nodejs-plugin/issues" + }, + "homepage": "https://github.com/snyk/snyk-nodejs-plugin#readme", + "dependencies": { + "@snyk/cli-interface": "^2.13.0", + "@snyk/dep-graph": "^2.7.4", + "debug": "^4.3.4", + "lodash": "^4.17.21", + "lodash.groupby": "^4.6.0", + "lodash.isempty": "^4.4.0", + "lodash.sortby": "^4.7.0", + "micromatch": "4.0.2", + "snyk-nodejs-lockfile-parser": "1.52.11", + "snyk-resolve-deps": "4.7.3" + }, + "devDependencies": { + "@types/jest": "^29.5.3", + "@types/node": "^20.4.5", + "@typescript-eslint/eslint-plugin": "^6.5.0", + "@typescript-eslint/parser": "^6.5.0", + "eslint": "^8.48.0", + "jest": "^29.6.2", + "prettier": "^3.0.3", + "ts-jest": "^29.1.2", + "typescript": "^5.1.6" + } +} diff --git a/pre-commit-config.yaml b/pre-commit-config.yaml new file mode 100644 index 0000000..99bddc4 --- /dev/null +++ b/pre-commit-config.yaml @@ -0,0 +1,5 @@ +repos: + - repo: https://github.com/gitleaks/gitleaks + rev: v8.16.1 + hooks: + - id: gitleaks \ No newline at end of file diff --git a/test/fixtures/npm/lock-v2/simple-app/package-lock.json b/test/fixtures/npm/lock-v2/simple-app/package-lock.json new file mode 100644 index 0000000..4b75893 --- /dev/null +++ b/test/fixtures/npm/lock-v2/simple-app/package-lock.json @@ -0,0 +1,84 @@ +{ + "name": "one-dep", + "version": "1.0.0", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "name": "one-dep", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "accepts": "1.3.7" + } + }, + "node_modules/accepts": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.7.tgz", + "integrity": "sha512-Il80Qs2WjYlJIBNzNkK6KYqlVMTbZLXgHx2oT0pU/fjRHyEp+PEfEPY0R3WCwAGVOtauxh1hOxNgIf5bv7dQpA==", + "dependencies": { + "mime-types": "~2.1.24", + "negotiator": "0.6.2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/negotiator": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.2.tgz", + "integrity": "sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw==", + "engines": { + "node": ">= 0.6" + } + } + }, + "dependencies": { + "accepts": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.7.tgz", + "integrity": "sha512-Il80Qs2WjYlJIBNzNkK6KYqlVMTbZLXgHx2oT0pU/fjRHyEp+PEfEPY0R3WCwAGVOtauxh1hOxNgIf5bv7dQpA==", + "requires": { + "mime-types": "~2.1.24", + "negotiator": "0.6.2" + } + }, + "mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==" + }, + "mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "requires": { + "mime-db": "1.52.0" + } + }, + "negotiator": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.2.tgz", + "integrity": "sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw==" + } + } +} diff --git a/test/fixtures/npm/lock-v2/simple-app/package.json b/test/fixtures/npm/lock-v2/simple-app/package.json new file mode 100644 index 0000000..e9e1901 --- /dev/null +++ b/test/fixtures/npm/lock-v2/simple-app/package.json @@ -0,0 +1,10 @@ +{ + "name": "one-dep", + "version": "1.0.0", + "main": "index.js", + "license": "MIT", + "dependencies": { + "accepts": "1.3.7" + } + } + diff --git a/test/fixtures/pnpm/lock-v5/simple-app/package.json b/test/fixtures/pnpm/lock-v5/simple-app/package.json new file mode 100644 index 0000000..8bf2fa5 --- /dev/null +++ b/test/fixtures/pnpm/lock-v5/simple-app/package.json @@ -0,0 +1,10 @@ +{ + "name": "one-dep", + "version": "1.0.0", + "main": "index.js", + "license": "MIT", + "dependencies": { + "accepts": "1.3.7" + } + } + \ No newline at end of file diff --git a/test/fixtures/pnpm/lock-v5/simple-app/pnpm-lock.yaml b/test/fixtures/pnpm/lock-v5/simple-app/pnpm-lock.yaml new file mode 100644 index 0000000..bc63e97 --- /dev/null +++ b/test/fixtures/pnpm/lock-v5/simple-app/pnpm-lock.yaml @@ -0,0 +1,34 @@ +lockfileVersion: 5.4 + +specifiers: + accepts: 1.3.7 + +dependencies: + accepts: 1.3.7 + +packages: + + /accepts/1.3.7: + resolution: {integrity: sha512-Il80Qs2WjYlJIBNzNkK6KYqlVMTbZLXgHx2oT0pU/fjRHyEp+PEfEPY0R3WCwAGVOtauxh1hOxNgIf5bv7dQpA==} + engines: {node: '>= 0.6'} + dependencies: + mime-types: 2.1.35 + negotiator: 0.6.2 + dev: false + + /mime-db/1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + dev: false + + /mime-types/2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + dependencies: + mime-db: 1.52.0 + dev: false + + /negotiator/0.6.2: + resolution: {integrity: sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw==} + engines: {node: '>= 0.6'} + dev: false diff --git a/test/fixtures/pnpm/lock-v5/workspace-with-cross-ref/package.json b/test/fixtures/pnpm/lock-v5/workspace-with-cross-ref/package.json new file mode 100644 index 0000000..e652858 --- /dev/null +++ b/test/fixtures/pnpm/lock-v5/workspace-with-cross-ref/package.json @@ -0,0 +1,10 @@ +{ + "name": "pnpm-1-workspace-with-cross-ref", + "version": "1.0.0", + "main": "index.js", + "license": "MIT", + "private": true, + "workspaces": [ + "packages/*" + ] +} diff --git a/test/fixtures/pnpm/lock-v5/workspace-with-cross-ref/packages/pkg-a/expected.json b/test/fixtures/pnpm/lock-v5/workspace-with-cross-ref/packages/pkg-a/expected.json new file mode 100644 index 0000000..84f7b11 --- /dev/null +++ b/test/fixtures/pnpm/lock-v5/workspace-with-cross-ref/packages/pkg-a/expected.json @@ -0,0 +1,149 @@ +{ + "schemaVersion": "1.3.0", + "pkgManager": { + "name": "pnpm" + }, + "pkgs": [ + { + "id": "pkg-a@1.0.0", + "info": { + "name": "pkg-a", + "version": "1.0.0" + } + }, + { + "id": "ms@2.1.2", + "info": { + "name": "ms", + "version": "2.1.2" + } + }, + { + "id": "package-b@1.0.0", + "info": { + "name": "package-b", + "version": "1.0.0" + } + }, + { + "id": "accepts@1.3.7", + "info": { + "name": "accepts", + "version": "1.3.7" + } + }, + { + "id": "mime-types@2.1.35", + "info": { + "name": "mime-types", + "version": "2.1.35" + } + }, + { + "id": "mime-db@1.52.0", + "info": { + "name": "mime-db", + "version": "1.52.0" + } + }, + { + "id": "negotiator@0.6.2", + "info": { + "name": "negotiator", + "version": "0.6.2" + } + } + ], + "graph": { + "rootNodeId": "root-node", + "nodes": [ + { + "nodeId": "root-node", + "pkgId": "pkg-a@1.0.0", + "deps": [ + { + "nodeId": "ms@2.1.2" + }, + { + "nodeId": "package-b@1.0.0" + } + ] + }, + { + "nodeId": "ms@2.1.2", + "pkgId": "ms@2.1.2", + "deps": [], + "info": { + "labels": { + "scope": "prod" + } + } + }, + { + "nodeId": "package-b@1.0.0", + "pkgId": "package-b@1.0.0", + "deps": [ + { + "nodeId": "accepts@1.3.7" + } + ], + "info": { + "labels": { + "scope": "prod" + } + } + }, + { + "nodeId": "accepts@1.3.7", + "pkgId": "accepts@1.3.7", + "deps": [ + { + "nodeId": "mime-types@2.1.35" + }, + { + "nodeId": "negotiator@0.6.2" + } + ], + "info": { + "labels": { + "scope": "prod" + } + } + }, + { + "nodeId": "mime-types@2.1.35", + "pkgId": "mime-types@2.1.35", + "deps": [ + { + "nodeId": "mime-db@1.52.0" + } + ], + "info": { + "labels": { + "scope": "prod" + } + } + }, + { + "nodeId": "mime-db@1.52.0", + "pkgId": "mime-db@1.52.0", + "deps": [], + "info": { + "labels": { + "scope": "prod" + } + } + }, + { + "nodeId": "negotiator@0.6.2", + "pkgId": "negotiator@0.6.2", + "deps": [], + "info": { + "labels": { + "scope": "prod" + } + } + } + ] + } +} \ No newline at end of file diff --git a/test/fixtures/pnpm/lock-v5/workspace-with-cross-ref/packages/pkg-a/package.json b/test/fixtures/pnpm/lock-v5/workspace-with-cross-ref/packages/pkg-a/package.json new file mode 100644 index 0000000..80ad97e --- /dev/null +++ b/test/fixtures/pnpm/lock-v5/workspace-with-cross-ref/packages/pkg-a/package.json @@ -0,0 +1,8 @@ +{ + "name": "pkg-a", + "version": "1.0.0", + "dependencies": { + "ms": "2.1.2", + "package-b": "1.0.0" + } +} diff --git a/test/fixtures/pnpm/lock-v5/workspace-with-cross-ref/packages/pkg-b/package.json b/test/fixtures/pnpm/lock-v5/workspace-with-cross-ref/packages/pkg-b/package.json new file mode 100644 index 0000000..5303a6c --- /dev/null +++ b/test/fixtures/pnpm/lock-v5/workspace-with-cross-ref/packages/pkg-b/package.json @@ -0,0 +1,7 @@ +{ + "name": "package-b", + "version": "1.0.0", + "dependencies": { + "accepts": "1.3.7" + } +} diff --git a/test/fixtures/pnpm/lock-v5/workspace-with-cross-ref/pnpm-lock.yaml b/test/fixtures/pnpm/lock-v5/workspace-with-cross-ref/pnpm-lock.yaml new file mode 100644 index 0000000..163c773 --- /dev/null +++ b/test/fixtures/pnpm/lock-v5/workspace-with-cross-ref/pnpm-lock.yaml @@ -0,0 +1,51 @@ +lockfileVersion: 5.4 + +importers: + + .: + specifiers: {} + + packages/pkg-a: + specifiers: + ms: 2.1.2 + package-b: ^1.0.0 + dependencies: + ms: 2.1.2 + package-b: link:../pkg-b + + packages/pkg-b: + specifiers: + accepts: 1.3.7 + dependencies: + accepts: 1.3.7 + +packages: + + /accepts/1.3.7: + resolution: {integrity: sha512-Il80Qs2WjYlJIBNzNkK6KYqlVMTbZLXgHx2oT0pU/fjRHyEp+PEfEPY0R3WCwAGVOtauxh1hOxNgIf5bv7dQpA==} + engines: {node: '>= 0.6'} + dependencies: + mime-types: 2.1.35 + negotiator: 0.6.2 + dev: false + + /mime-db/1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + dev: false + + /mime-types/2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + dependencies: + mime-db: 1.52.0 + dev: false + + /ms/2.1.2: + resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==} + dev: false + + /negotiator/0.6.2: + resolution: {integrity: sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw==} + engines: {node: '>= 0.6'} + dev: false diff --git a/test/fixtures/pnpm/lock-v5/workspace-with-cross-ref/pnpm-workspace.yaml b/test/fixtures/pnpm/lock-v5/workspace-with-cross-ref/pnpm-workspace.yaml new file mode 100644 index 0000000..d6d0607 --- /dev/null +++ b/test/fixtures/pnpm/lock-v5/workspace-with-cross-ref/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +packages: + # all packages in direct subdirs of packages/ + - 'packages/*' \ No newline at end of file diff --git a/test/fixtures/pnpm/lock-v5/workspace-with-isolated-pkgs/expected.json b/test/fixtures/pnpm/lock-v5/workspace-with-isolated-pkgs/expected.json new file mode 100644 index 0000000..6d899d6 --- /dev/null +++ b/test/fixtures/pnpm/lock-v5/workspace-with-isolated-pkgs/expected.json @@ -0,0 +1,88 @@ +{ + "schemaVersion": "1.3.0", + "pkgManager": { + "name": "pnpm" + }, + "pkgs": [ + { + "id": "yarn-1-workspace-with-isolated-pkgs@1.0.0", + "info": { + "name": "yarn-1-workspace-with-isolated-pkgs", + "version": "1.0.0" + } + }, + { + "id": "react@18.0.0", + "info": { + "name": "react", + "version": "18.0.0" + } + }, + { + "id": "loose-envify@1.4.0", + "info": { + "name": "loose-envify", + "version": "1.4.0" + } + }, + { + "id": "js-tokens@4.0.0", + "info": { + "name": "js-tokens", + "version": "4.0.0" + } + } + ], + "graph": { + "rootNodeId": "root-node", + "nodes": [ + { + "nodeId": "root-node", + "pkgId": "yarn-1-workspace-with-isolated-pkgs@1.0.0", + "deps": [ + { + "nodeId": "react@18.0.0" + } + ] + }, + { + "nodeId": "react@18.0.0", + "pkgId": "react@18.0.0", + "deps": [ + { + "nodeId": "loose-envify@1.4.0" + } + ], + "info": { + "labels": { + "scope": "prod" + } + } + }, + { + "nodeId": "loose-envify@1.4.0", + "pkgId": "loose-envify@1.4.0", + "deps": [ + { + "nodeId": "js-tokens@4.0.0" + } + ], + "info": { + "labels": { + "scope": "prod" + } + } + }, + { + "nodeId": "js-tokens@4.0.0", + "pkgId": "js-tokens@4.0.0", + "deps": [], + "info": { + "labels": { + "scope": "prod" + } + } + } + ] + } + } \ No newline at end of file diff --git a/test/fixtures/pnpm/lock-v5/workspace-with-isolated-pkgs/package.json b/test/fixtures/pnpm/lock-v5/workspace-with-isolated-pkgs/package.json new file mode 100644 index 0000000..2e63fa0 --- /dev/null +++ b/test/fixtures/pnpm/lock-v5/workspace-with-isolated-pkgs/package.json @@ -0,0 +1,10 @@ +{ + "name": "yarn-1-workspace-with-isolated-pkgs", + "version": "1.0.0", + "main": "index.js", + "license": "MIT", + "dependencies": { + "react": "18.0.0" + }, + "private": true +} diff --git a/test/fixtures/pnpm/lock-v5/workspace-with-isolated-pkgs/packages/pkg-a/package.json b/test/fixtures/pnpm/lock-v5/workspace-with-isolated-pkgs/packages/pkg-a/package.json new file mode 100644 index 0000000..95034c0 --- /dev/null +++ b/test/fixtures/pnpm/lock-v5/workspace-with-isolated-pkgs/packages/pkg-a/package.json @@ -0,0 +1,7 @@ +{ + "name": "package-a", + "version": "1.0.0", + "dependencies": { + "ms": "2.1.2" + } +} diff --git a/test/fixtures/pnpm/lock-v5/workspace-with-isolated-pkgs/packages/pkg-b/expected.json b/test/fixtures/pnpm/lock-v5/workspace-with-isolated-pkgs/packages/pkg-b/expected.json new file mode 100644 index 0000000..16d3563 --- /dev/null +++ b/test/fixtures/pnpm/lock-v5/workspace-with-isolated-pkgs/packages/pkg-b/expected.json @@ -0,0 +1,108 @@ +{ + "schemaVersion": "1.3.0", + "pkgManager": { + "name": "pnpm" + }, + "pkgs": [ + { + "id": "pkg-b@1.0.0", + "info": { + "name": "pkg-b", + "version": "1.0.0" + } + }, + { + "id": "accepts@1.3.7", + "info": { + "name": "accepts", + "version": "1.3.7" + } + }, + { + "id": "mime-types@2.1.35", + "info": { + "name": "mime-types", + "version": "2.1.35" + } + }, + { + "id": "mime-db@1.52.0", + "info": { + "name": "mime-db", + "version": "1.52.0" + } + }, + { + "id": "negotiator@0.6.2", + "info": { + "name": "negotiator", + "version": "0.6.2" + } + } + ], + "graph": { + "rootNodeId": "root-node", + "nodes": [ + { + "nodeId": "root-node", + "pkgId": "pkg-b@1.0.0", + "deps": [ + { + "nodeId": "accepts@1.3.7" + } + ] + }, + { + "nodeId": "accepts@1.3.7", + "pkgId": "accepts@1.3.7", + "deps": [ + { + "nodeId": "mime-types@2.1.35" + }, + { + "nodeId": "negotiator@0.6.2" + } + ], + "info": { + "labels": { + "scope": "prod" + } + } + }, + { + "nodeId": "mime-types@2.1.35", + "pkgId": "mime-types@2.1.35", + "deps": [ + { + "nodeId": "mime-db@1.52.0" + } + ], + "info": { + "labels": { + "scope": "prod" + } + } + }, + { + "nodeId": "mime-db@1.52.0", + "pkgId": "mime-db@1.52.0", + "deps": [], + "info": { + "labels": { + "scope": "prod" + } + } + }, + { + "nodeId": "negotiator@0.6.2", + "pkgId": "negotiator@0.6.2", + "deps": [], + "info": { + "labels": { + "scope": "prod" + } + } + } + ] + } + } \ No newline at end of file diff --git a/test/fixtures/pnpm/lock-v5/workspace-with-isolated-pkgs/packages/pkg-b/package.json b/test/fixtures/pnpm/lock-v5/workspace-with-isolated-pkgs/packages/pkg-b/package.json new file mode 100644 index 0000000..aab47a2 --- /dev/null +++ b/test/fixtures/pnpm/lock-v5/workspace-with-isolated-pkgs/packages/pkg-b/package.json @@ -0,0 +1,7 @@ +{ + "name": "pkg-b", + "version": "1.0.0", + "dependencies": { + "accepts": "1.3.7" + } +} diff --git a/test/fixtures/pnpm/lock-v5/workspace-with-isolated-pkgs/pnpm-lock.yaml b/test/fixtures/pnpm/lock-v5/workspace-with-isolated-pkgs/pnpm-lock.yaml new file mode 100644 index 0000000..8557e21 --- /dev/null +++ b/test/fixtures/pnpm/lock-v5/workspace-with-isolated-pkgs/pnpm-lock.yaml @@ -0,0 +1,70 @@ +lockfileVersion: 5.4 + +importers: + + .: + specifiers: + react: 18.0.0 + dependencies: + react: 18.0.0 + + packages/pkg-a: + specifiers: + ms: 2.1.2 + dependencies: + ms: 2.1.2 + + packages/pkg-b: + specifiers: + accepts: 1.3.7 + dependencies: + accepts: 1.3.7 + +packages: + + /accepts/1.3.7: + resolution: {integrity: sha512-Il80Qs2WjYlJIBNzNkK6KYqlVMTbZLXgHx2oT0pU/fjRHyEp+PEfEPY0R3WCwAGVOtauxh1hOxNgIf5bv7dQpA==} + engines: {node: '>= 0.6'} + dependencies: + mime-types: 2.1.35 + negotiator: 0.6.2 + dev: false + + /js-tokens/4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + dev: false + + /loose-envify/1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + dependencies: + js-tokens: 4.0.0 + dev: false + + /mime-db/1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + dev: false + + /mime-types/2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + dependencies: + mime-db: 1.52.0 + dev: false + + /ms/2.1.2: + resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==} + dev: false + + /negotiator/0.6.2: + resolution: {integrity: sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw==} + engines: {node: '>= 0.6'} + dev: false + + /react/18.0.0: + resolution: {integrity: sha512-x+VL6wbT4JRVPm7EGxXhZ8w8LTROaxPXOqhlGyVSrv0sB1jkyFGgXxJ8LVoPRLvPR6/CIZGFmfzqUa2NYeMr2A==} + engines: {node: '>=0.10.0'} + dependencies: + loose-envify: 1.4.0 + dev: false diff --git a/test/fixtures/pnpm/lock-v5/workspace-with-isolated-pkgs/pnpm-workspace.yaml b/test/fixtures/pnpm/lock-v5/workspace-with-isolated-pkgs/pnpm-workspace.yaml new file mode 100644 index 0000000..d6d0607 --- /dev/null +++ b/test/fixtures/pnpm/lock-v5/workspace-with-isolated-pkgs/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +packages: + # all packages in direct subdirs of packages/ + - 'packages/*' \ No newline at end of file diff --git a/test/fixtures/pnpm/lock-v6/simple-app/package.json b/test/fixtures/pnpm/lock-v6/simple-app/package.json new file mode 100644 index 0000000..8bf2fa5 --- /dev/null +++ b/test/fixtures/pnpm/lock-v6/simple-app/package.json @@ -0,0 +1,10 @@ +{ + "name": "one-dep", + "version": "1.0.0", + "main": "index.js", + "license": "MIT", + "dependencies": { + "accepts": "1.3.7" + } + } + \ No newline at end of file diff --git a/test/fixtures/pnpm/lock-v6/simple-app/pnpm-lock.yaml b/test/fixtures/pnpm/lock-v6/simple-app/pnpm-lock.yaml new file mode 100644 index 0000000..156f9cb --- /dev/null +++ b/test/fixtures/pnpm/lock-v6/simple-app/pnpm-lock.yaml @@ -0,0 +1,37 @@ +lockfileVersion: '6.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +dependencies: + accepts: + specifier: 1.3.7 + version: 1.3.7 + +packages: + + /accepts@1.3.7: + resolution: {integrity: sha512-Il80Qs2WjYlJIBNzNkK6KYqlVMTbZLXgHx2oT0pU/fjRHyEp+PEfEPY0R3WCwAGVOtauxh1hOxNgIf5bv7dQpA==} + engines: {node: '>= 0.6'} + dependencies: + mime-types: 2.1.35 + negotiator: 0.6.2 + dev: false + + /mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + dev: false + + /mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + dependencies: + mime-db: 1.52.0 + dev: false + + /negotiator@0.6.2: + resolution: {integrity: sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw==} + engines: {node: '>= 0.6'} + dev: false diff --git a/test/fixtures/pnpm/lock-v6/workspace-with-cross-ref/package.json b/test/fixtures/pnpm/lock-v6/workspace-with-cross-ref/package.json new file mode 100644 index 0000000..d0b93da --- /dev/null +++ b/test/fixtures/pnpm/lock-v6/workspace-with-cross-ref/package.json @@ -0,0 +1,10 @@ +{ + "name": "yarn-1-workspace-with-cross-ref", + "version": "1.0.0", + "main": "index.js", + "license": "MIT", + "private": true, + "workspaces": [ + "packages/*" + ] +} diff --git a/test/fixtures/pnpm/lock-v6/workspace-with-cross-ref/packages/pkg-a/expected.json b/test/fixtures/pnpm/lock-v6/workspace-with-cross-ref/packages/pkg-a/expected.json new file mode 100644 index 0000000..6d899d6 --- /dev/null +++ b/test/fixtures/pnpm/lock-v6/workspace-with-cross-ref/packages/pkg-a/expected.json @@ -0,0 +1,88 @@ +{ + "schemaVersion": "1.3.0", + "pkgManager": { + "name": "pnpm" + }, + "pkgs": [ + { + "id": "yarn-1-workspace-with-isolated-pkgs@1.0.0", + "info": { + "name": "yarn-1-workspace-with-isolated-pkgs", + "version": "1.0.0" + } + }, + { + "id": "react@18.0.0", + "info": { + "name": "react", + "version": "18.0.0" + } + }, + { + "id": "loose-envify@1.4.0", + "info": { + "name": "loose-envify", + "version": "1.4.0" + } + }, + { + "id": "js-tokens@4.0.0", + "info": { + "name": "js-tokens", + "version": "4.0.0" + } + } + ], + "graph": { + "rootNodeId": "root-node", + "nodes": [ + { + "nodeId": "root-node", + "pkgId": "yarn-1-workspace-with-isolated-pkgs@1.0.0", + "deps": [ + { + "nodeId": "react@18.0.0" + } + ] + }, + { + "nodeId": "react@18.0.0", + "pkgId": "react@18.0.0", + "deps": [ + { + "nodeId": "loose-envify@1.4.0" + } + ], + "info": { + "labels": { + "scope": "prod" + } + } + }, + { + "nodeId": "loose-envify@1.4.0", + "pkgId": "loose-envify@1.4.0", + "deps": [ + { + "nodeId": "js-tokens@4.0.0" + } + ], + "info": { + "labels": { + "scope": "prod" + } + } + }, + { + "nodeId": "js-tokens@4.0.0", + "pkgId": "js-tokens@4.0.0", + "deps": [], + "info": { + "labels": { + "scope": "prod" + } + } + } + ] + } + } \ No newline at end of file diff --git a/test/fixtures/pnpm/lock-v6/workspace-with-cross-ref/packages/pkg-a/package.json b/test/fixtures/pnpm/lock-v6/workspace-with-cross-ref/packages/pkg-a/package.json new file mode 100644 index 0000000..80ad97e --- /dev/null +++ b/test/fixtures/pnpm/lock-v6/workspace-with-cross-ref/packages/pkg-a/package.json @@ -0,0 +1,8 @@ +{ + "name": "pkg-a", + "version": "1.0.0", + "dependencies": { + "ms": "2.1.2", + "package-b": "1.0.0" + } +} diff --git a/test/fixtures/pnpm/lock-v6/workspace-with-cross-ref/packages/pkg-b/package.json b/test/fixtures/pnpm/lock-v6/workspace-with-cross-ref/packages/pkg-b/package.json new file mode 100644 index 0000000..5303a6c --- /dev/null +++ b/test/fixtures/pnpm/lock-v6/workspace-with-cross-ref/packages/pkg-b/package.json @@ -0,0 +1,7 @@ +{ + "name": "package-b", + "version": "1.0.0", + "dependencies": { + "accepts": "1.3.7" + } +} diff --git a/test/fixtures/pnpm/lock-v6/workspace-with-cross-ref/pnpm-lock.yaml b/test/fixtures/pnpm/lock-v6/workspace-with-cross-ref/pnpm-lock.yaml new file mode 100644 index 0000000..79b57b3 --- /dev/null +++ b/test/fixtures/pnpm/lock-v6/workspace-with-cross-ref/pnpm-lock.yaml @@ -0,0 +1,55 @@ +lockfileVersion: '6.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: {} + + packages/pkg-a: + dependencies: + ms: + specifier: 2.1.2 + version: 2.1.2 + package-b: + specifier: 1.0.0 + version: link:../pkg-b + + packages/pkg-b: + dependencies: + accepts: + specifier: 1.3.7 + version: 1.3.7 + +packages: + + /accepts@1.3.7: + resolution: {integrity: sha512-Il80Qs2WjYlJIBNzNkK6KYqlVMTbZLXgHx2oT0pU/fjRHyEp+PEfEPY0R3WCwAGVOtauxh1hOxNgIf5bv7dQpA==} + engines: {node: '>= 0.6'} + dependencies: + mime-types: 2.1.35 + negotiator: 0.6.2 + dev: false + + /mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + dev: false + + /mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + dependencies: + mime-db: 1.52.0 + dev: false + + /ms@2.1.2: + resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==} + dev: false + + /negotiator@0.6.2: + resolution: {integrity: sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw==} + engines: {node: '>= 0.6'} + dev: false diff --git a/test/fixtures/pnpm/lock-v6/workspace-with-cross-ref/pnpm-workspace.yaml b/test/fixtures/pnpm/lock-v6/workspace-with-cross-ref/pnpm-workspace.yaml new file mode 100644 index 0000000..d6d0607 --- /dev/null +++ b/test/fixtures/pnpm/lock-v6/workspace-with-cross-ref/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +packages: + # all packages in direct subdirs of packages/ + - 'packages/*' \ No newline at end of file diff --git a/test/fixtures/pnpm/lock-v6/workspace-with-isolated-pkgs/expected.json b/test/fixtures/pnpm/lock-v6/workspace-with-isolated-pkgs/expected.json new file mode 100644 index 0000000..6d899d6 --- /dev/null +++ b/test/fixtures/pnpm/lock-v6/workspace-with-isolated-pkgs/expected.json @@ -0,0 +1,88 @@ +{ + "schemaVersion": "1.3.0", + "pkgManager": { + "name": "pnpm" + }, + "pkgs": [ + { + "id": "yarn-1-workspace-with-isolated-pkgs@1.0.0", + "info": { + "name": "yarn-1-workspace-with-isolated-pkgs", + "version": "1.0.0" + } + }, + { + "id": "react@18.0.0", + "info": { + "name": "react", + "version": "18.0.0" + } + }, + { + "id": "loose-envify@1.4.0", + "info": { + "name": "loose-envify", + "version": "1.4.0" + } + }, + { + "id": "js-tokens@4.0.0", + "info": { + "name": "js-tokens", + "version": "4.0.0" + } + } + ], + "graph": { + "rootNodeId": "root-node", + "nodes": [ + { + "nodeId": "root-node", + "pkgId": "yarn-1-workspace-with-isolated-pkgs@1.0.0", + "deps": [ + { + "nodeId": "react@18.0.0" + } + ] + }, + { + "nodeId": "react@18.0.0", + "pkgId": "react@18.0.0", + "deps": [ + { + "nodeId": "loose-envify@1.4.0" + } + ], + "info": { + "labels": { + "scope": "prod" + } + } + }, + { + "nodeId": "loose-envify@1.4.0", + "pkgId": "loose-envify@1.4.0", + "deps": [ + { + "nodeId": "js-tokens@4.0.0" + } + ], + "info": { + "labels": { + "scope": "prod" + } + } + }, + { + "nodeId": "js-tokens@4.0.0", + "pkgId": "js-tokens@4.0.0", + "deps": [], + "info": { + "labels": { + "scope": "prod" + } + } + } + ] + } + } \ No newline at end of file diff --git a/test/fixtures/pnpm/lock-v6/workspace-with-isolated-pkgs/package.json b/test/fixtures/pnpm/lock-v6/workspace-with-isolated-pkgs/package.json new file mode 100644 index 0000000..2215c15 --- /dev/null +++ b/test/fixtures/pnpm/lock-v6/workspace-with-isolated-pkgs/package.json @@ -0,0 +1,10 @@ +{ + "name": "pnpm-1-workspace-with-isolated-pkgs", + "version": "1.0.0", + "main": "index.js", + "license": "MIT", + "dependencies": { + "react": "18.0.0" + }, + "private": true +} diff --git a/test/fixtures/pnpm/lock-v6/workspace-with-isolated-pkgs/packages/pkg-a/package.json b/test/fixtures/pnpm/lock-v6/workspace-with-isolated-pkgs/packages/pkg-a/package.json new file mode 100644 index 0000000..95034c0 --- /dev/null +++ b/test/fixtures/pnpm/lock-v6/workspace-with-isolated-pkgs/packages/pkg-a/package.json @@ -0,0 +1,7 @@ +{ + "name": "package-a", + "version": "1.0.0", + "dependencies": { + "ms": "2.1.2" + } +} diff --git a/test/fixtures/pnpm/lock-v6/workspace-with-isolated-pkgs/packages/pkg-b/expected.json b/test/fixtures/pnpm/lock-v6/workspace-with-isolated-pkgs/packages/pkg-b/expected.json new file mode 100644 index 0000000..16d3563 --- /dev/null +++ b/test/fixtures/pnpm/lock-v6/workspace-with-isolated-pkgs/packages/pkg-b/expected.json @@ -0,0 +1,108 @@ +{ + "schemaVersion": "1.3.0", + "pkgManager": { + "name": "pnpm" + }, + "pkgs": [ + { + "id": "pkg-b@1.0.0", + "info": { + "name": "pkg-b", + "version": "1.0.0" + } + }, + { + "id": "accepts@1.3.7", + "info": { + "name": "accepts", + "version": "1.3.7" + } + }, + { + "id": "mime-types@2.1.35", + "info": { + "name": "mime-types", + "version": "2.1.35" + } + }, + { + "id": "mime-db@1.52.0", + "info": { + "name": "mime-db", + "version": "1.52.0" + } + }, + { + "id": "negotiator@0.6.2", + "info": { + "name": "negotiator", + "version": "0.6.2" + } + } + ], + "graph": { + "rootNodeId": "root-node", + "nodes": [ + { + "nodeId": "root-node", + "pkgId": "pkg-b@1.0.0", + "deps": [ + { + "nodeId": "accepts@1.3.7" + } + ] + }, + { + "nodeId": "accepts@1.3.7", + "pkgId": "accepts@1.3.7", + "deps": [ + { + "nodeId": "mime-types@2.1.35" + }, + { + "nodeId": "negotiator@0.6.2" + } + ], + "info": { + "labels": { + "scope": "prod" + } + } + }, + { + "nodeId": "mime-types@2.1.35", + "pkgId": "mime-types@2.1.35", + "deps": [ + { + "nodeId": "mime-db@1.52.0" + } + ], + "info": { + "labels": { + "scope": "prod" + } + } + }, + { + "nodeId": "mime-db@1.52.0", + "pkgId": "mime-db@1.52.0", + "deps": [], + "info": { + "labels": { + "scope": "prod" + } + } + }, + { + "nodeId": "negotiator@0.6.2", + "pkgId": "negotiator@0.6.2", + "deps": [], + "info": { + "labels": { + "scope": "prod" + } + } + } + ] + } + } \ No newline at end of file diff --git a/test/fixtures/pnpm/lock-v6/workspace-with-isolated-pkgs/packages/pkg-b/package.json b/test/fixtures/pnpm/lock-v6/workspace-with-isolated-pkgs/packages/pkg-b/package.json new file mode 100644 index 0000000..aab47a2 --- /dev/null +++ b/test/fixtures/pnpm/lock-v6/workspace-with-isolated-pkgs/packages/pkg-b/package.json @@ -0,0 +1,7 @@ +{ + "name": "pkg-b", + "version": "1.0.0", + "dependencies": { + "accepts": "1.3.7" + } +} diff --git a/test/fixtures/pnpm/lock-v6/workspace-with-isolated-pkgs/pnpm-lock.yaml b/test/fixtures/pnpm/lock-v6/workspace-with-isolated-pkgs/pnpm-lock.yaml new file mode 100644 index 0000000..b7eeb52 --- /dev/null +++ b/test/fixtures/pnpm/lock-v6/workspace-with-isolated-pkgs/pnpm-lock.yaml @@ -0,0 +1,74 @@ +lockfileVersion: '6.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + react: + specifier: 18.0.0 + version: 18.0.0 + + packages/pkg-a: + dependencies: + ms: + specifier: 2.1.2 + version: 2.1.2 + + packages/pkg-b: + dependencies: + accepts: + specifier: 1.3.7 + version: 1.3.7 + +packages: + + /accepts@1.3.7: + resolution: {integrity: sha512-Il80Qs2WjYlJIBNzNkK6KYqlVMTbZLXgHx2oT0pU/fjRHyEp+PEfEPY0R3WCwAGVOtauxh1hOxNgIf5bv7dQpA==} + engines: {node: '>= 0.6'} + dependencies: + mime-types: 2.1.35 + negotiator: 0.6.2 + dev: false + + /js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + dev: false + + /loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + dependencies: + js-tokens: 4.0.0 + dev: false + + /mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + dev: false + + /mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + dependencies: + mime-db: 1.52.0 + dev: false + + /ms@2.1.2: + resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==} + dev: false + + /negotiator@0.6.2: + resolution: {integrity: sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw==} + engines: {node: '>= 0.6'} + dev: false + + /react@18.0.0: + resolution: {integrity: sha512-x+VL6wbT4JRVPm7EGxXhZ8w8LTROaxPXOqhlGyVSrv0sB1jkyFGgXxJ8LVoPRLvPR6/CIZGFmfzqUa2NYeMr2A==} + engines: {node: '>=0.10.0'} + dependencies: + loose-envify: 1.4.0 + dev: false diff --git a/test/fixtures/pnpm/lock-v6/workspace-with-isolated-pkgs/pnpm-workspace.yaml b/test/fixtures/pnpm/lock-v6/workspace-with-isolated-pkgs/pnpm-workspace.yaml new file mode 100644 index 0000000..d6d0607 --- /dev/null +++ b/test/fixtures/pnpm/lock-v6/workspace-with-isolated-pkgs/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +packages: + # all packages in direct subdirs of packages/ + - 'packages/*' \ No newline at end of file diff --git a/test/fixtures/yarn/lock-v1/simple-app/package.json b/test/fixtures/yarn/lock-v1/simple-app/package.json new file mode 100644 index 0000000..e9e1901 --- /dev/null +++ b/test/fixtures/yarn/lock-v1/simple-app/package.json @@ -0,0 +1,10 @@ +{ + "name": "one-dep", + "version": "1.0.0", + "main": "index.js", + "license": "MIT", + "dependencies": { + "accepts": "1.3.7" + } + } + diff --git a/test/fixtures/yarn/lock-v1/simple-app/yarn.lock b/test/fixtures/yarn/lock-v1/simple-app/yarn.lock new file mode 100644 index 0000000..fe512ba --- /dev/null +++ b/test/fixtures/yarn/lock-v1/simple-app/yarn.lock @@ -0,0 +1,28 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +accepts@1.3.7: + version "1.3.7" + resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.7.tgz#531bc726517a3b2b41f850021c6cc15eaab507cd" + integrity sha512-Il80Qs2WjYlJIBNzNkK6KYqlVMTbZLXgHx2oT0pU/fjRHyEp+PEfEPY0R3WCwAGVOtauxh1hOxNgIf5bv7dQpA== + dependencies: + mime-types "~2.1.24" + negotiator "0.6.2" + +mime-db@1.48.0: + version "1.48.0" + resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.48.0.tgz#e35b31045dd7eada3aaad537ed88a33afbef2d1d" + integrity sha512-FM3QwxV+TnZYQ2aRqhlKBMHxk10lTbMt3bBkMAp54ddrNeVSfcQYOOKuGuy3Ddrm38I04If834fOUSq1yzslJQ== + +mime-types@~2.1.24: + version "2.1.31" + resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.31.tgz#a00d76b74317c61f9c2db2218b8e9f8e9c5c9e6b" + integrity sha512-XGZnNzm3QvgKxa8dpzyhFTHmpP3l5YNusmne07VUOXxou9CqUqYa/HBy124RqtVh/O2pECas/MOcsDgpilPOPg== + dependencies: + mime-db "1.48.0" + +negotiator@0.6.2: + version "0.6.2" + resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.2.tgz#feacf7ccf525a77ae9634436a64883ffeca346fb" + integrity sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw== diff --git a/test/fixtures/yarn/lock-v2/simple-app/package.json b/test/fixtures/yarn/lock-v2/simple-app/package.json new file mode 100644 index 0000000..e9e1901 --- /dev/null +++ b/test/fixtures/yarn/lock-v2/simple-app/package.json @@ -0,0 +1,10 @@ +{ + "name": "one-dep", + "version": "1.0.0", + "main": "index.js", + "license": "MIT", + "dependencies": { + "accepts": "1.3.7" + } + } + diff --git a/test/fixtures/yarn/lock-v2/simple-app/yarn.lock b/test/fixtures/yarn/lock-v2/simple-app/yarn.lock new file mode 100644 index 0000000..54bf28b --- /dev/null +++ b/test/fixtures/yarn/lock-v2/simple-app/yarn.lock @@ -0,0 +1,33 @@ +# This file is generated by running "yarn install" inside your project. +# Manual changes might be lost - proceed with caution! + +__metadata: + version: 6 + cacheKey: 8 + +"debug@npm:4.3.1": + version: 4.3.1 + resolution: "debug@npm:4.3.1" + dependencies: + ms: 2.1.2 + peerDependenciesMeta: + supports-color: + optional: true + checksum: 2c3352e37d5c46b0d203317cd45ea0e26b2c99f2d9dfec8b128e6ceba90dfb65425f5331bf3020fe9929d7da8c16758e737f4f3bfc0fce6b8b3d503bae03298b + languageName: node + linkType: hard + +"ms@npm:1.0.0": + version: 1.0.0 + resolution: "ms@npm:1.0.0" + checksum: d8df74551552ffce335dcb07b77fae25573b6e83789cd58e04c6bd5652095390d55e5be54ec8e143b6dbb785b61615f6a314d5d8e1a141fdf53cf3596d8972b5 + languageName: node + linkType: hard + +"resolutions-simple@workspace:.": + version: 0.0.0-use.local + resolution: "resolutions-simple@workspace:." + dependencies: + debug: 4.3.1 + languageName: unknown + linkType: soft \ No newline at end of file diff --git a/test/inspect.spec.ts b/test/inspect.spec.ts new file mode 100644 index 0000000..2e1ae4a --- /dev/null +++ b/test/inspect.spec.ts @@ -0,0 +1,56 @@ +import { inspect } from '../lib/index'; +import * as path from 'path'; + +describe('inspect', () => { + const originalCurrentWorkingDirectory = process.cwd(); + + afterEach(() => { + process.chdir(originalCurrentWorkingDirectory); + }); + + describe('lockfile-based projects', () => { + it.each([ + { + packageManager: 'pnpm', + lockFileVersion: '5', + fixture: 'simple-app', + targetFile: 'pnpm-lock.yaml', + }, + { + packageManager: 'pnpm', + lockFileVersion: '6', + fixture: 'simple-app', + targetFile: 'pnpm-lock.yaml', + }, + { + packageManager: 'npm', + lockFileVersion: '2', + fixture: 'simple-app', + targetFile: 'package-lock.json', + }, + { + packageManager: 'yarn', + lockFileVersion: '1', + fixture: 'simple-app', + targetFile: 'yarn.lock', + }, + ])( + 'should build valid dep graph for $packageManager, lockfile version = $lockFileVersion, fixture = $fixture', + async ({ packageManager, lockFileVersion, fixture, targetFile }) => { + const fixturePath = path.resolve( + __dirname, + 'fixtures', + packageManager, + `lock-v${lockFileVersion}`, + fixture, + ); + process.chdir(fixturePath); + + const result = await inspect('.', targetFile, {}); + expect(result.plugin.name).toEqual('snyk-nodejs-lockfile-parser'); + expect(result.scannedProjects.length).toEqual(1); + expect(result.scannedProjects[0].depGraph?.toJSON()).not.toEqual({}); + }, + ); + }); +}); diff --git a/test/npm-modules-parser.spec.ts b/test/npm-modules-parser.spec.ts new file mode 100644 index 0000000..efd1d84 --- /dev/null +++ b/test/npm-modules-parser.spec.ts @@ -0,0 +1,64 @@ +import { mocked } from 'jest-mock'; + +import { parse } from '../lib//npm-modules-parser'; +import { getFileContents } from '../lib/utils'; + +jest.mock('../lib/utils'); +const mockedGetFileContents = mocked(getFileContents); + +afterEach(() => { + jest.clearAllMocks(); +}); + +const packageJsonNoNameAndNoDeps = { + description: '', + main: 'index.js', + scripts: { + test: 'echo "Error: no test specified" && exit 1', + }, + author: '', + license: 'ISC', +}; + +const packageJsonNoDeps = { + name: 'test-package', + description: '', + main: 'index.js', + scripts: { + test: 'echo "Error: no test specified" && exit 1', + }, + author: '', + license: 'ISC', +}; + +describe('npm-modules-parser', () => { + describe('parse', () => { + it('package name should fall back to "package.json" when no name and no dependencies', async () => { + mockedGetFileContents.mockImplementation(() => ({ + content: JSON.stringify(packageJsonNoNameAndNoDeps), + fileName: 'package.json', + })); + + const result = await parse('some/fake/path', 'package.json', { + packageManager: 'npm', + file: 'package.json', + }); + + expect(result.name).toBe('package.json'); + }); + + it('package name should match package.json when no dependencies', async () => { + mockedGetFileContents.mockImplementation(() => ({ + content: JSON.stringify(packageJsonNoDeps), + fileName: 'package.json', + })); + + const result = await parse('some/fake/path', 'package.json', { + packageManager: 'npm', + file: 'package.json', + }); + + expect(result.name).toBe('test-package'); + }); + }); +}); diff --git a/test/utils.ts b/test/utils.ts new file mode 100644 index 0000000..e28047a --- /dev/null +++ b/test/utils.ts @@ -0,0 +1,6 @@ +/* eslint-disable no-console, @typescript-eslint/no-non-null-assertion */ +import * as path from 'path'; + +export function chdirFixtures(dir: string) { + process.chdir(path.resolve(__dirname, 'fixtures', dir)); +} diff --git a/test/workspaces/pnpm-workspaces-parser.spec.ts b/test/workspaces/pnpm-workspaces-parser.spec.ts new file mode 100644 index 0000000..0d5b2e8 --- /dev/null +++ b/test/workspaces/pnpm-workspaces-parser.spec.ts @@ -0,0 +1,77 @@ +import { processPnpmWorkspaces } from '../../lib'; +import * as path from 'path'; + +describe('process pnpm workspaces', () => { + const originalCurrentWorkingDirectory = process.cwd(); + + afterEach(() => { + process.chdir(originalCurrentWorkingDirectory); + }); + describe('Processing throws error', () => { + it('Throws no supported manifests found error', async () => { + await expect( + processPnpmWorkspaces( + '.', + { strictOutOfSync: true, dev: true, pnpmWorkspaces: true }, + [], + ), + ).rejects.toThrow(); + }); + }); + + describe('Processing returns dep graph for one workspace with multiple projects', () => { + it.each([ + { + packageManager: 'pnpm', + lockFileVersion: '5', + fixture: 'workspace-with-isolated-pkgs', + targetFile: 'pnpm-lock.yaml', + }, + { + packageManager: 'pnpm', + lockFileVersion: '6', + fixture: 'workspace-with-isolated-pkgs', + targetFile: 'pnpm-lock.yaml', + }, + { + packageManager: 'pnpm', + lockFileVersion: '5', + fixture: 'workspace-with-cross-ref', + targetFile: 'pnpm-lock.yaml', + }, + { + packageManager: 'pnpm', + lockFileVersion: '6', + fixture: 'workspace-with-cross-ref', + targetFile: 'pnpm-lock.yaml', + }, + ])( + 'should build valid dep graph for $packageManager, lockfile version = $lockFileVersion, fixture = $fixture', + async ({ packageManager, lockFileVersion, fixture, targetFile }) => { + const fixturePath = path.resolve( + __dirname, + '..', + 'fixtures', + packageManager, + `lock-v${lockFileVersion}`, + fixture, + ); + process.chdir(fixturePath); + + const result = await processPnpmWorkspaces( + '.', + { strictOutOfSync: true, dev: true, pnpmWorkspaces: true }, + [ + 'package.json', + 'pnpm-lock.yaml', + './packages/pkg-a/package.json', + './packages/pkg-b/package.json', + ], + ); + expect(result.plugin.name).toEqual('snyk-nodejs-pnpm-workspaces'); + expect(result.scannedProjects.length).toEqual(3); + expect(result.scannedProjects[0].depGraph?.toJSON()).not.toEqual({}); + }, + ); + }); +}); diff --git a/test/yarn-workspaces-parser.spec.ts b/test/yarn-workspaces-parser.spec.ts new file mode 100644 index 0000000..216d432 --- /dev/null +++ b/test/yarn-workspaces-parser.spec.ts @@ -0,0 +1,153 @@ +import { packageJsonBelongsToWorkspace } from '../lib/yarn-workspaces-parser'; + +const yarnWorkspacesMap = { + 'snyk/test/acceptance/workspaces/yarn-workspace-out-of-sync/package.json': { + workspaces: ['packages/*'], + }, + 'snyk/test/acceptance/workspaces/yarn-workspace/package.json': { + workspaces: ['libs/*/**', 'tools/*'], + }, +}; + +const yarnWorkspacesMapWindows = { + 'C:\\snyk\\test\\acceptance\\workspaces\\yarn-workspace-out-of-sync\\package.json': + { + workspaces: ['packages'], + }, + 'C:\\snyk\\test\\acceptance\\workspaces\\yarn-workspace\\package.json': { + workspaces: ['libs/*/**', 'tools/*'], + }, + 'C:\\snyk\\yarn-workspace\\package.json': { + workspaces: ['libs\\*\\**', 'tools\\*'], + }, +}; + +describe('packageJsonBelongsToWorkspace', () => { + test('does not match workspace root', () => { + const packageJsonFileName = + 'snyk/test/acceptance/workspaces/yarn-workspace-out-of-sync/package.json'; + const workspaceRoot = + 'snyk/test/acceptance/workspaces/yarn-workspace-out-of-sync/package.json'; + expect( + packageJsonBelongsToWorkspace( + packageJsonFileName, + yarnWorkspacesMap, + workspaceRoot, + ), + ).toBeFalsy(); + }); + test('correctly matches a workspace with /* globs (meaning all folders)', () => { + // docs: https://yarnpkg.com/features/workspaces#how-to-declare-a-worktree + const packageJsonFileName = + 'snyk/test/acceptance/workspaces/yarn-workspace-out-of-sync/packages/apple/package.json'; + const workspaceRoot = + 'snyk/test/acceptance/workspaces/yarn-workspace-out-of-sync/package.json'; + expect( + packageJsonBelongsToWorkspace( + packageJsonFileName, + yarnWorkspacesMap, + workspaceRoot, + ), + ).toBeTruthy(); + }); + + test('correctly matches a workspace with /*/** globs', () => { + const packageJsonFileName = + 'snyk/test/acceptance/workspaces/yarn-workspace/libs/a/package.json'; + const workspaceRoot = + 'snyk/test/acceptance/workspaces/yarn-workspace/package.json'; + expect( + packageJsonBelongsToWorkspace( + packageJsonFileName, + yarnWorkspacesMap, + workspaceRoot, + ), + ).toBeTruthy(); + }); + + test('does not match a workspace outside declared globs', () => { + const packageJsonFileName = + 'snyk/test/acceptance/workspaces/yarn-workspace/packages/a/package.json'; + const workspaceRoot = + 'snyk/test/acceptance/workspaces/yarn-workspace/package.json'; + expect( + packageJsonBelongsToWorkspace( + packageJsonFileName, + yarnWorkspacesMap, + workspaceRoot, + ), + ).toBeFalsy(); + }); +}); + +describe('packageJsonBelongsToWorkspace Windows', () => { + test('does not match workspace root', () => { + const packageJsonFileName = + 'C:\\snyk\\test\\acceptance\\workspaces\\yarn-workspace-out-of-sync\\package.json'; + const workspaceRoot = + 'C:\\snyk\\test\\acceptance\\workspaces\\yarn-workspace-out-of-sync\\package.json'; + expect( + packageJsonBelongsToWorkspace( + packageJsonFileName, + yarnWorkspacesMapWindows, + workspaceRoot, + ), + ).toBeFalsy(); + }); + test('correctly matches a workspace with /* globs (meaning all folders)', () => { + // docs: https://yarnpkg.com/features/workspaces#how-to-declare-a-worktree + const packageJsonFileName = + 'C:\\snyk\\test\\acceptance\\workspaces\\yarn-workspace-out-of-sync\\packages\\apple\\package.json'; + const workspaceRoot = + 'C:\\snyk\\test\\acceptance\\workspaces\\yarn-workspace-out-of-sync\\package.json'; + expect( + packageJsonBelongsToWorkspace( + packageJsonFileName, + yarnWorkspacesMapWindows, + workspaceRoot, + ), + ).toBeTruthy(); + }); + + test('correctly matches a workspace with \\* globs (meaning all folders)', () => { + // docs: https://yarnpkg.com/features/workspaces#how-to-declare-a-worktree + const packageJsonFileName = + 'C:\\snyk\\yarn-workspace\\tools\\apple\\package.json'; + const workspaceRoot = 'C:\\snyk\\yarn-workspace\\package.json'; + expect( + packageJsonBelongsToWorkspace( + packageJsonFileName, + yarnWorkspacesMapWindows, + workspaceRoot, + ), + ).toBeTruthy(); + }); + + test('correctly matches a workspace with /*/** globs', () => { + const packageJsonFileName = + 'C:\\snyk\\test\\acceptance\\workspaces\\yarn-workspace\\libs\\a\\package.json'; + const workspaceRoot = + 'C:\\snyk\\test\\acceptance\\workspaces\\yarn-workspace\\package.json'; + expect( + packageJsonBelongsToWorkspace( + packageJsonFileName, + yarnWorkspacesMapWindows, + workspaceRoot, + ), + ).toBeTruthy(); + }); + + test('does not match a workspace outside declared globs', () => { + const packageJsonFileName = + 'C:\\snyk\\test\\acceptance\\workspaces\\yarn-workspace\\packages\\a\\package.json'; + const workspaceRoot = + 'C:\\snyk\\test\\acceptance\\workspaces\\yarn-workspace\\package.json'; + expect( + packageJsonBelongsToWorkspace( + packageJsonFileName, + yarnWorkspacesMapWindows, + workspaceRoot, + ), + ).toBeFalsy(); + }); +}); diff --git a/tsconfig-test.json b/tsconfig-test.json new file mode 100644 index 0000000..2aec30e --- /dev/null +++ b/tsconfig-test.json @@ -0,0 +1,13 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./build-with-tests", + "declaration": false, + "noImplicitAny": false, + }, + "include": [ + "./lib/**/*", + "./test/**/*", + "./test/matchers/*.ts", + ] + } \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..1c16fdd --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "outDir": "./dist", + "pretty": true, + "target": "es2019", + "module": "commonjs", + "sourceMap": true, + "declaration": true, + "strict": true, + "noImplicitAny": false, + "noUnusedLocals": true, + "noImplicitReturns": true + }, + "include": [ + "./lib/*" +, "lib/workspaces/yarn-workspaces-parser.ts", "lib/workspaces/npm-workspaces-parser.ts" ] + } \ No newline at end of file